@tangle-network/sandbox-ui 0.113.7 → 0.114.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/WorkflowGraph-B-NsdYz_.js +1961 -0
  2. package/dist/assets.d.ts +214 -206
  3. package/dist/assets.js +1057 -908
  4. package/dist/auth-CWe17Hly.js +37 -0
  5. package/dist/auth.d.ts +2 -30
  6. package/dist/auth.js +2 -15
  7. package/dist/chat-BOvFAe6p.js +903 -0
  8. package/dist/chat.d.ts +2 -230
  9. package/dist/chat.js +2 -34
  10. package/dist/connectors.d.ts +52 -49
  11. package/dist/connectors.js +595 -643
  12. package/dist/dashboard-CZ3VgNPR.js +4969 -0
  13. package/dist/dashboard.d.ts +4 -1297
  14. package/dist/dashboard.js +4 -152
  15. package/dist/editor.d.ts +2 -1
  16. package/dist/editor.js +2 -31
  17. package/dist/files.d.ts +2 -6
  18. package/dist/files.js +2 -33
  19. package/dist/globals.css +63 -3
  20. package/dist/hooks.d.ts +146 -148
  21. package/dist/hooks.js +222 -55
  22. package/dist/index-CKRaj1pM.d.ts +776 -0
  23. package/dist/index-CXX5IpH2.d.ts +2 -0
  24. package/dist/index-DPePq7c8.d.ts +1340 -0
  25. package/dist/index-DiyLLZuJ.d.ts +230 -0
  26. package/dist/index-DvmZLMT1.d.ts +30 -0
  27. package/dist/index-I8ZLVtHd.d.ts +330 -0
  28. package/dist/index-PRM636Ex.d.ts +86 -0
  29. package/dist/index.d.ts +21 -33
  30. package/dist/index.js +21 -701
  31. package/dist/info-panel-C9cqDzOL.js +594 -0
  32. package/dist/integrations.d.ts +133 -141
  33. package/dist/integrations.js +491 -673
  34. package/dist/markdown.d.ts +2 -1
  35. package/dist/markdown.js +2 -13
  36. package/dist/model-I-NIxeM-.js +1250 -0
  37. package/dist/model-brand-B9A4H9pJ.d.ts +98 -0
  38. package/dist/model-brand-hMyDTUqB.js +624 -0
  39. package/dist/motion-DkyBRJb9.js +37 -0
  40. package/dist/openui.d.ts +2 -1
  41. package/dist/openui.js +2 -7
  42. package/dist/pages.d.ts +307 -300
  43. package/dist/pages.js +4404 -4181
  44. package/dist/primitives-Dh1vG6J3.js +113 -0
  45. package/dist/primitives.d.ts +2 -74
  46. package/dist/primitives.js +2 -204
  47. package/dist/provider-logo-DjqmConF.js +311 -0
  48. package/dist/run.d.ts +2 -1
  49. package/dist/run.js +2 -23
  50. package/dist/sdk-hooks.d.ts +3 -4
  51. package/dist/sdk-hooks.js +3 -31
  52. package/dist/sidecar-DgH_vQ6m.d.ts +9 -0
  53. package/dist/stores.d.ts +14 -15
  54. package/dist/stores.js +13 -81
  55. package/dist/styles.css +63 -3
  56. package/dist/template-card-L-4gmjx2.d.ts +90 -0
  57. package/dist/terminal-CN9ZgHRt.js +197 -0
  58. package/dist/terminal.d.ts +60 -60
  59. package/dist/terminal.js +2 -10
  60. package/dist/tokens.css +51 -0
  61. package/dist/types.d.ts +4 -11
  62. package/dist/types.js +0 -1
  63. package/dist/use-pty-session-C8cE0adT.js +669 -0
  64. package/dist/use-sidecar-auth-CCTJcrq7.d.ts +145 -0
  65. package/dist/use-sidecar-auth-DkdcufOz.js +742 -0
  66. package/dist/utils-DzWbktWG.js +2 -0
  67. package/dist/utils.d.ts +2 -1
  68. package/dist/utils.js +2 -37
  69. package/dist/workbench-BCaCKl-J.js +8347 -0
  70. package/dist/workbench.d.ts +2 -310
  71. package/dist/workbench.js +2 -44
  72. package/dist/workflows.d.ts +341 -339
  73. package/dist/workflows.js +74 -109
  74. package/dist/workspace-B6AtI6ld.js +2510 -0
  75. package/dist/workspace.d.ts +2 -761
  76. package/dist/workspace.js +2 -51
  77. package/package.json +46 -40
  78. package/dist/WorkflowGraph-2QOORYIR.js +0 -1722
  79. package/dist/chunk-2BUPSB7O.js +0 -0
  80. package/dist/chunk-3KBNPQQF.js +0 -2697
  81. package/dist/chunk-3LIXVEEE.js +0 -279
  82. package/dist/chunk-72UF2H5H.js +0 -707
  83. package/dist/chunk-AI25G5MX.js +0 -944
  84. package/dist/chunk-ANSC5AFC.js +0 -314
  85. package/dist/chunk-AZ3AWMTM.js +0 -8
  86. package/dist/chunk-BGVEHENC.js +0 -10
  87. package/dist/chunk-CZ7CSFO4.js +0 -39
  88. package/dist/chunk-DYXXYGS4.js +0 -4700
  89. package/dist/chunk-EI44GEQ5.js +0 -6
  90. package/dist/chunk-IACRDXER.js +0 -219
  91. package/dist/chunk-IOB2PW5Z.js +0 -55
  92. package/dist/chunk-M4K7ZGR2.js +0 -24
  93. package/dist/chunk-MYZAW4RX.js +0 -665
  94. package/dist/chunk-OMUIPJQZ.js +0 -555
  95. package/dist/chunk-RQZRXKMR.js +0 -398
  96. package/dist/chunk-SOKKTB7W.js +0 -230
  97. package/dist/chunk-TADGS4Q7.js +0 -1010
  98. package/dist/chunk-UCQNOUN3.js +0 -7964
  99. package/dist/chunk-ULPLRBMF.js +0 -34
  100. package/dist/chunk-WID73FPH.js +0 -89
  101. package/dist/chunk-X3YXP7FG.js +0 -38
  102. package/dist/logo-DiAjq759.d.ts +0 -15
  103. package/dist/model-brand-BNnaUKKZ.d.ts +0 -99
  104. package/dist/ports-list-CDIvTNnX.d.ts +0 -17
  105. package/dist/sdk-hooks-Djkjq4cp.d.ts +0 -145
  106. package/dist/template-card-Bn-Bw5xC.d.ts +0 -87
@@ -0,0 +1,1250 @@
1
+ import { parse } from "yaml";
2
+ //#region src/workflows/cron.ts
3
+ /**
4
+ * Cron → English for a schedule trigger's node label. A raw `0 9 * * 1-5` tells
5
+ * a reader nothing at a glance; "Weekdays at 09:00" tells them everything.
6
+ *
7
+ * Deliberately partial: it recognizes the timetables people actually author and
8
+ * returns the EXPRESSION VERBATIM for anything else (step ranges, multi-field
9
+ * lists, `@yearly` macros, 6-field second-precision cron). A wrong-but-confident
10
+ * translation of an exotic expression is worse than the expression itself, so
11
+ * there is no best-effort guessing — the node just shows what was written.
12
+ */
13
+ const WEEKDAY_NAMES = [
14
+ "Sunday",
15
+ "Monday",
16
+ "Tuesday",
17
+ "Wednesday",
18
+ "Thursday",
19
+ "Friday",
20
+ "Saturday"
21
+ ];
22
+ /**
23
+ * A step field (star-slash-N), but ONLY when N actually produces an even
24
+ * cadence. Cron steps WITHIN a field's own range and then restarts at the top of
25
+ * the next unit — so a minute step means "every N minutes" only when N divides
26
+ * 60, and an hour step means "every N hours" only when N divides 24. A 90-minute
27
+ * step (a classic authoring mistake) does NOT fire every 90 minutes: it matches
28
+ * minute 0 alone, i.e. hourly. Translating it as "Every 90 minutes" would tell an
29
+ * author their broken cron is exactly what they meant — the one thing this module
30
+ * must never do.
31
+ */
32
+ function evenStep(field, period) {
33
+ const m = /^\*\/(\d+)$/.exec(field);
34
+ if (!m) return null;
35
+ const n = Number.parseInt(m[1], 10);
36
+ if (n < 1 || n >= period || period % n !== 0) return null;
37
+ return n;
38
+ }
39
+ /** A single, plain integer field value (no list/range/step). */
40
+ function plainInt(field) {
41
+ if (!/^\d+$/.test(field)) return null;
42
+ return Number.parseInt(field, 10);
43
+ }
44
+ /** `9` → "09:00". Returns null when either half isn't a plain in-range value. */
45
+ function timeOfDay(minute, hour) {
46
+ const m = plainInt(minute);
47
+ const h = plainInt(hour);
48
+ if (m === null || h === null || m > 59 || h > 23) return null;
49
+ return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
50
+ }
51
+ /** English for a day-of-week field: `1-5` → "Weekdays", `1` → "Mondays",
52
+ * `1,3,5` → "Mon, Wed, Fri". Null for anything else (a step, an out-of-range
53
+ * day), so the caller falls back to the raw expression. Cron allows both `0`
54
+ * and `7` for Sunday. */
55
+ function weekdays(field) {
56
+ if (field === "1-5") return "Weekdays";
57
+ if (field === "0,6" || field === "6,0" || field === "6-7") return "Weekends";
58
+ const dayName = (value) => {
59
+ if (value < 0 || value > 7) return null;
60
+ return WEEKDAY_NAMES[value === 7 ? 0 : value];
61
+ };
62
+ const single = plainInt(field);
63
+ if (single !== null) {
64
+ const name = dayName(single);
65
+ return name ? `${name}s` : null;
66
+ }
67
+ if (/^\d+(,\d+)+$/.test(field)) {
68
+ const names = field.split(",").map((d) => dayName(Number.parseInt(d, 10)));
69
+ if (names.some((n) => n === null)) return null;
70
+ return [...new Set(names)].map((n) => n.slice(0, 3)).join(", ");
71
+ }
72
+ return null;
73
+ }
74
+ /** Ordinal for a day of the month: 1 → "1st", 22 → "22nd". */
75
+ function ordinal(day) {
76
+ const tens = day % 100;
77
+ if (tens >= 11 && tens <= 13) return `${day}th`;
78
+ switch (day % 10) {
79
+ case 1: return `${day}st`;
80
+ case 2: return `${day}nd`;
81
+ case 3: return `${day}rd`;
82
+ default: return `${day}th`;
83
+ }
84
+ }
85
+ /**
86
+ * English for a standard 5-field cron expression (`minute hour day-of-month
87
+ * month day-of-week`), or the expression VERBATIM (trimmed) when it isn't one of
88
+ * the recognized shapes — including when it is empty. Never throws, so it is
89
+ * always safe to render whatever the definition happens to carry.
90
+ */
91
+ function describeCron(expression) {
92
+ const raw = expression.trim();
93
+ const fields = raw.split(/\s+/);
94
+ if (fields.length !== 5) return raw;
95
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = fields;
96
+ const anyDayOfMonth = dayOfMonth === "*";
97
+ const anyDayOfWeek = dayOfWeek === "*";
98
+ if (!(month === "*")) return raw;
99
+ if (!anyDayOfMonth && !anyDayOfWeek) return raw;
100
+ if (anyDayOfMonth && anyDayOfWeek) {
101
+ if (minute === "*" && hour === "*") return "Every minute";
102
+ const everyNMinutes = evenStep(minute, 60);
103
+ if (everyNMinutes !== null && hour === "*") return everyNMinutes === 1 ? "Every minute" : `Every ${everyNMinutes} minutes`;
104
+ const everyNHours = evenStep(hour, 24);
105
+ const atMinute = plainInt(minute);
106
+ const pastTheHour = atMinute !== null && atMinute <= 59 ? `:${String(atMinute).padStart(2, "0")}` : null;
107
+ if (everyNHours !== null && pastTheHour) return everyNHours === 1 ? `Hourly at ${pastTheHour}` : `Every ${everyNHours} hours at ${pastTheHour}`;
108
+ if (hour === "*" && pastTheHour) return `Hourly at ${pastTheHour}`;
109
+ const time = timeOfDay(minute, hour);
110
+ return time ? `Daily at ${time}` : raw;
111
+ }
112
+ const time = timeOfDay(minute, hour);
113
+ if (!time) return raw;
114
+ if (!anyDayOfWeek) {
115
+ const days = weekdays(dayOfWeek);
116
+ return days ? `${days} at ${time}` : raw;
117
+ }
118
+ const day = plainInt(dayOfMonth);
119
+ if (day !== null && day >= 1 && day <= 31) return `Monthly on the ${ordinal(day)} at ${time}`;
120
+ return raw;
121
+ }
122
+ //#endregion
123
+ //#region src/workflows/format.ts
124
+ /**
125
+ * Display formatters for the workflow graph's live run-state chips. Pure and
126
+ * dependency-free so they can be unit-tested without rendering the graph.
127
+ */
128
+ /** Human-readable duration: `850ms`, `4.2s`, `1m30s`. Undefined for an absent,
129
+ * non-finite, or negative input (e.g. clock skew) so the caller renders no
130
+ * chip rather than a confusing `-5ms`. */
131
+ function fmtDuration(ms) {
132
+ if (ms === void 0 || !Number.isFinite(ms) || ms < 0) return void 0;
133
+ if (ms < 1e3) return `${Math.round(ms)}ms`;
134
+ const s = ms / 1e3;
135
+ if (s < 60) return `${s.toFixed(1)}s`;
136
+ const totalSeconds = Math.round(s);
137
+ return `${Math.floor(totalSeconds / 60)}m${totalSeconds % 60}s`;
138
+ }
139
+ /** Human-readable USD cost: `$0`, `$0.0032` (sub-cent, 4dp), `$1.20`. Undefined
140
+ * for an absent, non-finite, or negative input — a cost is a billing amount, so
141
+ * a negative value is bad data, not a refund to render. */
142
+ function fmtCost(usd) {
143
+ if (usd === void 0 || !Number.isFinite(usd) || usd < 0) return void 0;
144
+ if (usd === 0) return "$0";
145
+ return usd < .01 ? `$${usd.toFixed(4)}` : `$${usd.toFixed(2)}`;
146
+ }
147
+ /** Bound host-supplied preview/error text to a fixed length (with an ellipsis)
148
+ * before it reaches the DOM, so an oversized payload can't bloat the node tree —
149
+ * the card only ever shows a short preview, and CSS clamping is visual only.
150
+ * Truncation is code-point aware: it never slices through a surrogate pair (which
151
+ * would leave a lone surrogate that renders as the replacement character). */
152
+ function clampPreview(text, max = 200) {
153
+ if (text.length <= max) return text;
154
+ const lastCode = text.charCodeAt(max - 1);
155
+ const end = lastCode >= 55296 && lastCode <= 56319 ? max - 1 : max;
156
+ return `${text.slice(0, end)}…`;
157
+ }
158
+ /** Human-readable token usage: `1200/340 tok`. Undefined when neither side is a
159
+ * finite, non-negative count, so the caller renders no chip; an invalid side
160
+ * (negative / NaN / absent) shows as 0 rather than leaking `-5/NaN tok`. */
161
+ function fmtTokens(input, output) {
162
+ const valid = (n) => n !== void 0 && Number.isFinite(n) && n >= 0 ? n : void 0;
163
+ const i = valid(input);
164
+ const o = valid(output);
165
+ if (i === void 0 && o === void 0) return void 0;
166
+ return `${i ?? 0}/${o ?? 0} tok`;
167
+ }
168
+ //#endregion
169
+ //#region src/workflows/naming.ts
170
+ /**
171
+ * Human-facing naming for workflow nodes. A definition addresses things by
172
+ * machine identifier — an action kind (`agent.run`), a hub action path
173
+ * (`github.pulls.reviews.create`), a profile slug (`pr-reviewer`) — and showing
174
+ * those verbatim on a node makes the graph read like a config file. These turn
175
+ * an identifier into the label a person would say out loud.
176
+ *
177
+ * Pure and dependency-free so the naming rules are unit-testable without the
178
+ * YAML parser or a render.
179
+ */
180
+ /** Identifier fragments that read as an initialism, so they are upper-cased
181
+ * whole rather than sentence-cased into "Pr" / "Api". */
182
+ const ACRONYMS = /* @__PURE__ */ new Set([
183
+ "ai",
184
+ "api",
185
+ "cd",
186
+ "ci",
187
+ "cli",
188
+ "cpu",
189
+ "css",
190
+ "csv",
191
+ "db",
192
+ "dns",
193
+ "gpu",
194
+ "html",
195
+ "http",
196
+ "https",
197
+ "id",
198
+ "io",
199
+ "ip",
200
+ "json",
201
+ "llm",
202
+ "mcp",
203
+ "ml",
204
+ "pr",
205
+ "qa",
206
+ "sdk",
207
+ "sla",
208
+ "sql",
209
+ "ssh",
210
+ "ui",
211
+ "url",
212
+ "uuid",
213
+ "vm",
214
+ "yaml"
215
+ ]);
216
+ /** Split a machine identifier into its words, on separators and camelCase humps. */
217
+ function words(id) {
218
+ return id.replace(/[._\-/]+/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").trim().split(/\s+/).filter(Boolean);
219
+ }
220
+ /**
221
+ * Humanize a machine identifier: `pr-reviewer` → "PR reviewer", `sandbox.spawn`
222
+ * → "Sandbox spawn", `postMessage` → "Post message". Known initialisms are
223
+ * upper-cased wherever they appear; `lead` picks the case of the rest.
224
+ *
225
+ * - `sentence` (default): "Create issue comment" — a label or a title.
226
+ * - `lower`: "create issue comment" — a fragment that sits INSIDE a phrase
227
+ * ("On pull request"), where a capital would read as a new sentence. An
228
+ * initialism still wins: `ci_run_completed` → "CI run completed", never
229
+ * "cI run completed".
230
+ * - `title`: "Google Sheets" — a brand/proper name.
231
+ *
232
+ * An identifier that carries no word characters is returned verbatim rather than
233
+ * blanked.
234
+ */
235
+ function humanizeIdentifier(id, lead = "sentence") {
236
+ const parts = words(id);
237
+ if (parts.length === 0) return id;
238
+ return parts.map((word, i) => {
239
+ const lower = word.toLowerCase();
240
+ if (ACRONYMS.has(lower)) return lower.toUpperCase();
241
+ const capitalized = lower.charAt(0).toUpperCase() + lower.slice(1);
242
+ if (lead === "title") return capitalized;
243
+ if (i > 0) return lower;
244
+ return lead === "lower" ? lower : capitalized;
245
+ }).join(" ");
246
+ }
247
+ /** A model slug without its provider prefix (`anthropic/claude-sonnet-5` →
248
+ * `claude-sonnet-5`) — the vendor is noise on a card whose width is the scarce
249
+ * resource, and the full slug stays in the node's config for the detail view. */
250
+ function shortModel(model) {
251
+ const slash = model.lastIndexOf("/");
252
+ return slash === -1 ? model : model.slice(slash + 1);
253
+ }
254
+ /** The charset a hub action path is made of (mirrors the server's path schema:
255
+ * `<provider>.<action…>`, letters/digits/`_`/`-` per segment). A value that
256
+ * isn't one — most importantly a `${…}` mapping expression, which also contains
257
+ * dots — must not be split into a bogus provider (whose brand logo we'd then go
258
+ * and fetch). */
259
+ const ACTION_PATH = /^[a-z0-9_-]+(\.[a-z0-9_-]+)+$/i;
260
+ /** Split a hub action path into provider / resource / operation. Null when the
261
+ * value isn't a dotted path (a `${…}` expression, or a bare word), so callers
262
+ * fall back to showing it verbatim instead of inventing structure. */
263
+ function parseActionPath(path) {
264
+ if (!ACTION_PATH.test(path.trim())) return null;
265
+ const [provider, ...rest] = path.trim().split(".");
266
+ return {
267
+ provider,
268
+ operation: rest[rest.length - 1],
269
+ resource: rest.length > 1 ? rest.slice(0, -1).join(".") : void 0
270
+ };
271
+ }
272
+ /**
273
+ * The operation label for an action path, in n8n's `operation: resource` shape:
274
+ * `github.pulls.reviews.create` → "create: pulls.reviews", `slack.postMessage`
275
+ * → "post message". The provider names the node itself (its title + logo), so
276
+ * it is deliberately absent here. A path we can't split is shown verbatim.
277
+ */
278
+ function actionPathLabel(path) {
279
+ const parts = parseActionPath(path);
280
+ if (!parts) return path;
281
+ const operation = humanizeIdentifier(parts.operation, "lower");
282
+ return parts.resource ? `${operation}: ${parts.resource}` : operation;
283
+ }
284
+ //#endregion
285
+ //#region src/workflows/provider-label.ts
286
+ /**
287
+ * Display label for a connector slug ("github" → "GitHub"). Separate from
288
+ * `model.ts` so consumers that only need the label avoid pulling the `yaml`
289
+ * parser (a `model.ts` dependency) into their bundle.
290
+ */
291
+ const PROVIDER_LABELS = {
292
+ github: "GitHub",
293
+ gitlab: "GitLab",
294
+ slack: "Slack",
295
+ stripe: "Stripe",
296
+ notion: "Notion",
297
+ linear: "Linear",
298
+ discord: "Discord"
299
+ };
300
+ /**
301
+ * The label above is a curated exception list (brands whose casing no rule
302
+ * predicts: "GitHub", "GitLab"). Everything else is title-cased from its slug —
303
+ * a connector id is multi-word far more often than not (`google-sheets`,
304
+ * `microsoft-teams`), and merely capitalizing the first letter left the node
305
+ * NAMED after the machine identifier: "Google-sheets".
306
+ */
307
+ function providerLabel(provider) {
308
+ const key = provider.toLowerCase();
309
+ return PROVIDER_LABELS[key] ?? humanizeIdentifier(provider, "title");
310
+ }
311
+ //#endregion
312
+ //#region src/workflows/model.ts
313
+ /**
314
+ * Turns a workflow YAML definition into a positioned node/edge graph for the
315
+ * visualizer. Pure and React-Flow-agnostic so the parsing + layout is unit
316
+ * testable on its own; the component maps these shapes onto React Flow nodes.
317
+ *
318
+ * The workflow model is a linear spine (trigger → action → action …) with
319
+ * one-level fan-out: a `parallel` action has N leaf branches and a `foreach`
320
+ * action has one leaf template. Branches dangle to the right of their structural
321
+ * node on the spine; they never continue the chain (the schema forbids nesting),
322
+ * so the spine stays a single readable column.
323
+ */
324
+ /**
325
+ * Node ids are PUBLIC contract, not an internal detail. A host keys its live
326
+ * `nodeState` by them, points the graph's `selectedNodeId` at one, and — once it
327
+ * supplies a declared topology — names its own edge endpoints with them. They
328
+ * are bare strings, so a host that re-derives the format at the call site gets
329
+ * no type error when the format changes here; the graph simply renders without
330
+ * edges. The format is therefore written in exactly one place (these helpers)
331
+ * and read everywhere else through them.
332
+ */
333
+ /** The workflow's first (or only) trigger. */
334
+ const TRIGGER_NODE_ID = "trigger";
335
+ const TRIGGER_NODE_ID_PATTERN = new RegExp(`^${TRIGGER_NODE_ID}:(\\d+)$`);
336
+ /** The node for the `index`th entry of a list-form `on:`. Entry 0 IS
337
+ * {@link TRIGGER_NODE_ID}, so a single-trigger graph — the overwhelmingly
338
+ * common one — keeps the plain id a host may already have persisted.
339
+ *
340
+ * `index` is a position in the definition's `on:` list: a non-negative
341
+ * integer. Anything else formats an id no node bears, which a declared
342
+ * topology then rejects by name ("…names "trigger:NaN", which this definition
343
+ * has no step for") — reported there rather than thrown from here, because
344
+ * {@link buildWorkflowGraph} calls this and must never throw. */
345
+ function triggerNodeId(index) {
346
+ return index === 0 ? TRIGGER_NODE_ID : `${TRIGGER_NODE_ID}:${index}`;
347
+ }
348
+ /** The `on:` entry a trigger node stands for, or null when the id names
349
+ * anything else — so a host can tell a trigger from an action without
350
+ * matching the id format itself. */
351
+ function triggerNodeIndex(nodeId) {
352
+ if (nodeId === "trigger") return 0;
353
+ const match = TRIGGER_NODE_ID_PATTERN.exec(nodeId);
354
+ return match ? Number(match[1]) : null;
355
+ }
356
+ /** The node for the `do` entry at `index`. */
357
+ function actionNodeId(index) {
358
+ return `a${index}`;
359
+ }
360
+ /** The node for the `branchIndex`th fan-out leaf of the `do` entry at
361
+ * `actionIndex` — a `parallel` branch or a `foreach` template. */
362
+ function branchNodeId(actionIndex, branchIndex) {
363
+ return `${actionNodeId(actionIndex)}-b${branchIndex}`;
364
+ }
365
+ /** An edge's id, from its endpoints. Written in ONE place so the graph, the
366
+ * styling pass and a problem anchored to a pair all name the same edge. */
367
+ function wfEdgeId(source, target) {
368
+ return `${source}->${target}`;
369
+ }
370
+ /** The loudest severity in a set — one error among warnings makes the whole
371
+ * anchor read as an error. Null for an empty set, so a caller can skip. */
372
+ function worstSeverity(problems) {
373
+ if (!problems || problems.length === 0) return null;
374
+ return problems.every((p) => p.severity === "warning") ? "warning" : "error";
375
+ }
376
+ /** Fixed card width — uniform so every layer is evenly pitched and the cards read
377
+ * as one system. */
378
+ const NODE_W = 292;
379
+ /** Gap between successive layers along the main axis. */
380
+ const RANK_SEP = 72;
381
+ /** Gap between two nodes stacked in the same layer (branch leaves) along the
382
+ * cross axis. */
383
+ const CROSS_SEP = 24;
384
+ /**
385
+ * The layer gap a graph uses once its edges carry labels (a guard summary, a
386
+ * cycle badge). An edge label sits in the corridor BETWEEN two layers, so the
387
+ * corridor has to be wide enough to hold one — at the ordinary separations (72
388
+ * expanded, 20 compact) a chip is several times the gap it sits in and spills
389
+ * across the nodes either side.
390
+ *
391
+ * Reserved for the same reason node heights are: the layout renders the label,
392
+ * so the layout owes it room. Sized to the chip's own max width (`max-w-40`,
393
+ * 160px — the chips stack rather than sit side by side, so the widest possible
394
+ * group is one chip) plus breathing space. Applied only to graphs that actually
395
+ * have labelled edges, so nothing else spreads out.
396
+ */
397
+ const EDGE_LABEL_LANE = 180;
398
+ /** The corridor an edge needs when the canvas draws an INSERT control at its
399
+ * midpoint: the button plus clearance from the cards either side, so the "+"
400
+ * never sits on a node. Only widens a gap — a labelled graph's lane is already
401
+ * bigger. */
402
+ const EDGE_INSERT_LANE = 44;
403
+ /** The two-line, clamped description (agent prompt, notify URL, trigger events). */
404
+ const DESCRIPTION_ROW = 40;
405
+ const WELL_PROSE_LINE = 15.125;
406
+ const WELL_JSON_LINE = 14.4375;
407
+ const WELL_JSON_GAP = 2;
408
+ /** Caption + `mb-1`, `py-1.5`, the border, and the `mt-2` above the well. */
409
+ const WELL_CHROME = 39.5;
410
+ /**
411
+ * Reservation for a well holding `rows` lines, with ~3px of headroom — the same
412
+ * the neighbouring text bands carry against a fractional rendered line.
413
+ *
414
+ * Sized to the TALLER of the two bodies rather than to prose alone. JSON wins
415
+ * from three rows up despite its smaller type, because its gaps accumulate where
416
+ * prose has none (3 rows: 47.3px json vs 45.4px prose) — so sizing off the prose
417
+ * case, which is the one that comes to mind, under-reserves exactly where the
418
+ * card is most crowded.
419
+ */
420
+ function wellRow(rows) {
421
+ const body = Math.max(rows * WELL_PROSE_LINE, rows * WELL_JSON_LINE + (rows - 1) * WELL_JSON_GAP);
422
+ return Math.ceil(WELL_CHROME + body) + 3;
423
+ }
424
+ /**
425
+ * Kinds that route the run rather than do work. `decision` is deliberately NOT
426
+ * here: it carries the same `structural` tone, but it is the one node that stops
427
+ * the run to ask the reader something, and its card has to hold the question.
428
+ */
429
+ const STRUCTURAL_KINDS = /* @__PURE__ */ new Set(["parallel", "foreach"]);
430
+ function isStructuralKind(kind) {
431
+ return kind !== void 0 && STRUCTURAL_KINDS.has(kind);
432
+ }
433
+ /** The run status FOOTER pinned to the card's bottom: a top border (1px), the
434
+ * progress bar (`h-1`, 4px), and a `py-1` caption row whose line box is pinned
435
+ * (so a node with no rounds/elapsed to report keeps the same footer as one that
436
+ * has both — otherwise the band's height would drift with its content). */
437
+ const FOOTER_ROW = 28;
438
+ /** LR: the tile, the gap, and a two-line name block (title + subtitle) beneath it
439
+ * — plus one more line of run metrics once a run is in play, reserved up front so
440
+ * the merge never reflows. */
441
+ const COMPACT_NODE_SIZE = {
442
+ width: 168,
443
+ height: 118
444
+ };
445
+ const COMPACT_NODE_SIZE_RUN = {
446
+ width: 168,
447
+ height: 133
448
+ };
449
+ /** TB: the name sits beside the tile, so the box is the tile's height — the name
450
+ * block (2-3 short lines) is shorter than the tile it sits next to, run state or
451
+ * not, which is why TB needs no separate run-reserved size. */
452
+ const COMPACT_NODE_SIZE_TB = {
453
+ width: 236,
454
+ height: 76
455
+ };
456
+ /**
457
+ * The card's box height. The React Flow node is PINNED to this height (the card
458
+ * fills it via `h-full` inside a node sized to it) and clips overflow, so cards
459
+ * can never overlap regardless of the consumer's fonts — this height IS the
460
+ * layout, not an estimate of the DOM. The row constants are tuned for the
461
+ * library's default token/font config; a consumer whose fonts render a row
462
+ * taller just sees that content clamped within the fixed box, never a reflow.
463
+ * `withRunState` reserves the rows live run state adds (the metrics line, the
464
+ * output block, and the bottom status footer) so the layout — computed ONCE,
465
+ * before any run state is merged in — already leaves room for a node that later
466
+ * runs, and the merge never reflows. A trigger only ever shows a status (no
467
+ * metrics/output/progress), so it's spaced by its static height (`withRunState`
468
+ * false) and the node component skips its footer to match.
469
+ */
470
+ function nodeHeight(data, withRunState) {
471
+ if (!withRunState || data.tone === "trigger") return 54 + (data.description ? DESCRIPTION_ROW : 0);
472
+ if (data.kind === "decision") return 133;
473
+ if (isStructuralKind(data.kind)) return 104;
474
+ if (data.kind === "agent.run") return 154;
475
+ return 54 + wellRow(3) + FOOTER_ROW;
476
+ }
477
+ /**
478
+ * The node box + the layer/lane separators for one density, resolved ONCE per
479
+ * graph. The two travel together: a compact box is much wider than the tile it
480
+ * draws (the name underneath it is), so the separator is only part of the gap a
481
+ * reader actually sees — the box's own margin supplies the rest. Its layers
482
+ * therefore pitch much tighter than the expanded cards', to land at a comparable
483
+ * VISUAL gap rather than a comparable numeric one.
484
+ *
485
+ * `runMode` is the GRAPH's — not the individual node's. Every compact tile is
486
+ * the same box even though a trigger shows no metrics line, because the tile sits
487
+ * at the top of its box: a shorter trigger box would ride its tile up out of line
488
+ * with the row it belongs to. (An expanded card, whose content starts at the top
489
+ * edge either way, is free to be exactly as tall as it needs — see nodeHeight.)
490
+ */
491
+ function geometry(compact, runMode, direction) {
492
+ if (compact) {
493
+ if (direction === "TB") return {
494
+ size: COMPACT_NODE_SIZE_TB,
495
+ rankSep: 34,
496
+ crossSep: 24
497
+ };
498
+ return {
499
+ size: runMode ? COMPACT_NODE_SIZE_RUN : COMPACT_NODE_SIZE,
500
+ rankSep: 20,
501
+ crossSep: 20
502
+ };
503
+ }
504
+ return {
505
+ size: void 0,
506
+ rankSep: RANK_SEP,
507
+ crossSep: CROSS_SEP
508
+ };
509
+ }
510
+ function asRecord(v) {
511
+ return v && typeof v === "object" && !Array.isArray(v) ? v : {};
512
+ }
513
+ function str(v) {
514
+ return typeof v === "string" && v.trim() !== "" ? v : void 0;
515
+ }
516
+ /** A free-text field as a node DESCRIPTION: bounded, because the card clamps it
517
+ * to two lines and hangs it in a tooltip — an 8k prompt has no business in
518
+ * either. The untruncated value stays on `config` for the detail view. */
519
+ function describeText(v) {
520
+ const text = str(v);
521
+ return text ? clampPreview(text.trim(), 220) : void 0;
522
+ }
523
+ /** Host of an https URL for a compact `notify` subtitle; the raw value if it
524
+ * isn't a parseable URL (e.g. a `${...}` expression). */
525
+ function urlHost(url) {
526
+ try {
527
+ return new URL(url).host;
528
+ } catch {
529
+ return url;
530
+ }
531
+ }
532
+ /**
533
+ * The keys a `do` entry may carry ALONGSIDE its one action key: the guard, the
534
+ * retry policy, and the failure policy. They are not actions and never name a
535
+ * node.
536
+ */
537
+ const CONTROL_FLOW_KEYS = [
538
+ "if",
539
+ "retry",
540
+ "onError"
541
+ ];
542
+ const CONTROL_FLOW = new Set(CONTROL_FLOW_KEYS);
543
+ /**
544
+ * The action kind a `do` entry declares. A `do` entry is a one-key action map
545
+ * that may also carry the control-flow siblings above, and YAML preserves the
546
+ * author's key order — so `- if: … / agent.run: …` puts `if` first. The kind can
547
+ * therefore never be "the first key": that names a guarded step after its guard.
548
+ *
549
+ * It is whatever is NOT control flow — chosen by EXCLUSION, not by matching a list
550
+ * of kinds we happen to know. An allowlist would silently start naming steps after
551
+ * their guard again the moment the API adds a kind (`agent.review`), since an
552
+ * unknown kind would fall out of the list and the guard would win by position.
553
+ */
554
+ function actionKind(rec) {
555
+ return Object.keys(rec).find((k) => !CONTROL_FLOW.has(k));
556
+ }
557
+ /** The SHAPE of an id the platform minted for a stored profile: `ap_` followed by 16
558
+ * base64url characters (what 12 random bytes encode to). Shape only — it says
559
+ * nothing about the bytes behind it, and nothing about whether such a profile
560
+ * exists; the host holds the catalog that could answer either.
561
+ *
562
+ * What it buys is a title. A minted id names the profile to a DATABASE, not to a
563
+ * reader — humanising it yields noise ("ap_NROQux-n7dC7Ll30" → "Ap nro qux n7d c7
564
+ * ll30") — so a node named by one is titled generically instead.
565
+ *
566
+ * The length is pinned rather than open-ended: `{8,}` would also swallow a slug a
567
+ * person wrote that happens to start with `ap_` ("ap_code_review"), replacing their
568
+ * name with a generic one. */
569
+ const MINTED_PROFILE_ID = /^ap_[A-Za-z0-9_-]{16}$/;
570
+ /** The agent's name: a profile named by a readable slug reads as the role it plays
571
+ * ("pr-reviewer" → "PR reviewer"). An inline profile object, or one named only by
572
+ * a minted catalog id, has no readable name here — the node is the generic agent
573
+ * rather than a mangled identifier. */
574
+ function agentTitle(profile) {
575
+ const named = str(profile);
576
+ if (!named || MINTED_PROFILE_ID.test(named)) return "AI Agent";
577
+ return humanizeIdentifier(named);
578
+ }
579
+ /** Build the card-facing node data for a single `do` leaf or top-level action.
580
+ * The action object is a single-key map (`{ "integration.invoke": {...} }`),
581
+ * mirroring the YAML schema, optionally alongside control-flow siblings.
582
+ * Returns the base data WITHOUT the raw `config`. */
583
+ function describeActionBase(action) {
584
+ const rec = asRecord(action);
585
+ const kind = actionKind(rec);
586
+ const cfg = asRecord(kind ? rec[kind] : void 0);
587
+ switch (kind) {
588
+ case "sandbox.spawn": return {
589
+ title: "Sandbox",
590
+ kind,
591
+ subtitle: str(cfg.template) ?? "Provision",
592
+ description: describeText(cfg.prompt),
593
+ isRoot: false,
594
+ tone: "action"
595
+ };
596
+ case "integration.invoke": {
597
+ const path = str(cfg.path);
598
+ const parts = path ? parseActionPath(path) : null;
599
+ return {
600
+ title: parts ? providerLabel(parts.provider) : "Integration",
601
+ kind,
602
+ subtitle: path ? actionPathLabel(path) : void 0,
603
+ path,
604
+ provider: parts?.provider,
605
+ isRoot: false,
606
+ tone: "action"
607
+ };
608
+ }
609
+ case "notify": {
610
+ const url = str(cfg.url);
611
+ return {
612
+ title: "Notify",
613
+ kind,
614
+ subtitle: url ? urlHost(url) : void 0,
615
+ description: describeText(url),
616
+ isRoot: false,
617
+ tone: "action"
618
+ };
619
+ }
620
+ case "agent.run": {
621
+ const model = str(cfg.model);
622
+ return {
623
+ title: agentTitle(cfg.profile),
624
+ kind,
625
+ subtitle: model ? shortModel(model) : "Agent",
626
+ description: describeText(cfg.prompt),
627
+ model,
628
+ isRoot: false,
629
+ tone: "action"
630
+ };
631
+ }
632
+ case "parallel": {
633
+ const branches = Array.isArray(cfg.branches) ? cfg.branches : [];
634
+ return {
635
+ title: "Parallel",
636
+ kind,
637
+ subtitle: `${branches.length} branch${branches.length === 1 ? "" : "es"}`,
638
+ badge: branches.length > 0 ? `×${branches.length}` : void 0,
639
+ isRoot: false,
640
+ tone: "structural"
641
+ };
642
+ }
643
+ case "foreach": return {
644
+ title: "For each",
645
+ kind,
646
+ subtitle: "Repeat per item",
647
+ description: typeof cfg.items === "string" ? cfg.items : "Literal list",
648
+ isRoot: false,
649
+ tone: "structural"
650
+ };
651
+ case "decision": {
652
+ const options = Array.isArray(cfg.options) ? cfg.options.filter((o) => typeof o === "string") : [];
653
+ return {
654
+ title: str(cfg.title) ?? "Decision",
655
+ kind,
656
+ subtitle: options.length > 0 ? describeText(options.join(" / ")) : "Waits for a human",
657
+ description: describeText(cfg.prompt),
658
+ isRoot: false,
659
+ tone: "structural"
660
+ };
661
+ }
662
+ case "script.run": {
663
+ const connections = Array.isArray(cfg.connections) ? cfg.connections.filter((c) => typeof c === "string") : [];
664
+ return {
665
+ title: "Script",
666
+ kind,
667
+ subtitle: connections.length > 0 ? `TypeScript · ${connections.length} connection${connections.length === 1 ? "" : "s"}` : "TypeScript",
668
+ description: describeText(cfg.source),
669
+ isRoot: false,
670
+ tone: "action"
671
+ };
672
+ }
673
+ case "sandbox.snapshot": return {
674
+ title: "Snapshot",
675
+ kind,
676
+ subtitle: str(cfg.sandbox) ?? "Capture a sandbox",
677
+ isRoot: false,
678
+ tone: "action"
679
+ };
680
+ case "trace.analyze": {
681
+ const kinds = Array.isArray(cfg.kinds) ? cfg.kinds.filter((k) => typeof k === "string") : [];
682
+ return {
683
+ title: "Trace analysis",
684
+ kind,
685
+ subtitle: kinds.length > 0 ? describeText(kinds.map((k) => humanizeIdentifier(k, "lower")).join(", ")) : "Default analysts",
686
+ description: describeText(cfg.trace),
687
+ isRoot: false,
688
+ tone: "action"
689
+ };
690
+ }
691
+ default: return {
692
+ title: kind ? humanizeIdentifier(kind) : "Action",
693
+ kind: kind ?? "action",
694
+ isRoot: false,
695
+ tone: "action"
696
+ };
697
+ }
698
+ }
699
+ /** Max nesting depth {@link toJsonSafe} descends before returning a marker. A
700
+ * real workflow config is a handful of levels deep; this bounds the recursion
701
+ * far below the JS stack limit so a pathologically deep config yields a marker
702
+ * instead of a `RangeError`, keeping {@link buildWorkflowGraph}'s no-throw
703
+ * contract intact (node-building runs outside the parse try/catch). */
704
+ const MAX_CONFIG_DEPTH = 100;
705
+ /** Deep-copy a parsed-config value into a JSON-safe tree for the public `config`
706
+ * surface. The result is owned by the caller (no references back into the parsed
707
+ * definition) and is always serializable: cycles — reachable via recursive YAML
708
+ * anchors — collapse to `"[Circular]"`, nesting beyond {@link MAX_CONFIG_DEPTH}
709
+ * collapses to `"[Max depth exceeded]"`, and non-JSON values (undefined,
710
+ * functions, symbols, bigints) are dropped, so a consumer can `JSON.stringify`,
711
+ * diff, or recursively render the config without throwing. Total by
712
+ * construction — it never throws, preserving {@link buildWorkflowGraph}'s
713
+ * no-throw contract. */
714
+ function toJsonSafe(value, seen = /* @__PURE__ */ new WeakSet(), depth = 0) {
715
+ if (value === null) return null;
716
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
717
+ if (typeof value === "string" || typeof value === "boolean") return value;
718
+ if (typeof value !== "object") return void 0;
719
+ if (value instanceof Date) return value.toISOString();
720
+ if (seen.has(value)) return "[Circular]";
721
+ if (depth >= MAX_CONFIG_DEPTH) return "[Max depth exceeded]";
722
+ seen.add(value);
723
+ let out;
724
+ if (Array.isArray(value)) out = value.map((v) => {
725
+ const s = toJsonSafe(v, seen, depth + 1);
726
+ return s === void 0 ? null : s;
727
+ });
728
+ else {
729
+ const obj = {};
730
+ for (const [k, v] of Object.entries(value)) {
731
+ const s = toJsonSafe(v, seen, depth + 1);
732
+ if (s !== void 0) obj[k] = s;
733
+ }
734
+ out = obj;
735
+ }
736
+ seen.delete(value);
737
+ return out;
738
+ }
739
+ /** Attach the raw, untruncated `config` to node data for the full-detail view,
740
+ * but only when non-empty — an empty config is omitted (never `config: {}`), so
741
+ * action and trigger nodes honor the same "omitted when no config" contract.
742
+ * The config is normalized to a JSON-safe deep copy ({@link toJsonSafe}) so the
743
+ * node owns it outright and a consumer can always serialize/render it. */
744
+ function withConfig(base, cfg) {
745
+ return Object.keys(cfg).length > 0 ? {
746
+ ...base,
747
+ config: toJsonSafe(cfg)
748
+ } : base;
749
+ }
750
+ /**
751
+ * Describe one action as node data, attaching the raw, untruncated `config` for a
752
+ * full-detail view on top of the card-facing summary from
753
+ * {@link describeActionBase}.
754
+ *
755
+ * The config carries the action's own fields PLUS any control-flow the entry
756
+ * declares (`if`/`retry`/`onError`), under those names. A step that may be skipped
757
+ * by a guard, or that retries three times, is not the same step as one that does
758
+ * neither — dropping the envelope would leave the two indistinguishable in the
759
+ * detail view, which is the one place that promises every field.
760
+ */
761
+ function describeAction(action) {
762
+ const rec = asRecord(action);
763
+ const kind = actionKind(rec);
764
+ const base = describeActionBase(action);
765
+ const config = toJsonSafe(asRecord(kind ? rec[kind] : {}));
766
+ for (const key of CONTROL_FLOW_KEYS) if (rec[key] !== void 0) config[key] = toJsonSafe(rec[key]);
767
+ return Object.keys(config).length > 0 ? {
768
+ ...base,
769
+ config
770
+ } : base;
771
+ }
772
+ /**
773
+ * A trigger's `scope` in a phrase: which of the provider's things it fires on.
774
+ *
775
+ * The dimension key is the only name available here (the human label lives in
776
+ * the provider catalog, server-side), so it is used as written — `repository`,
777
+ * `channel`. Both directions read as what they mean: a denylist is "all but",
778
+ * an allowlist names its members when there are few enough to be worth naming.
779
+ *
780
+ * Dimensions are read in key order, not in the order the author happened to
781
+ * write them: `Object.entries` follows insertion order, so the same logical
782
+ * scope would otherwise label the node differently depending on how its YAML
783
+ * was typed. Sorted here for the same reason the platform sorts keys when it
784
+ * canonicalises a scope — one scope, one rendering.
785
+ *
786
+ * Compared by code unit rather than `localeCompare`: a dimension id is a
787
+ * machine identifier (`repository`, `channel`), not prose to collate for a
788
+ * reader, and locale-aware ordering would make the rendering depend on the
789
+ * runtime's locale — reintroducing, per viewer, exactly the instability the
790
+ * sort exists to remove.
791
+ */
792
+ function describeScope(scope) {
793
+ const rec = asRecord(scope);
794
+ const parts = [];
795
+ for (const [dimension, raw] of Object.entries(rec).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
796
+ const selection = asRecord(raw);
797
+ const except = Array.isArray(selection.except) ? [...new Set(selection.except.filter((entry) => typeof entry === "string").map((entry) => entry.trim()).filter((entry) => entry !== ""))] : [];
798
+ if (except.length === 0) {
799
+ if (selection.default === "exclude") parts.push(`${dimension}: none`);
800
+ continue;
801
+ }
802
+ const named = except.length <= 2 ? except.join(", ") : `${except.length} selected`;
803
+ parts.push(selection.default === "exclude" ? `${dimension}: ${named}` : `${dimension}: all but ${named}`);
804
+ }
805
+ return parts.length > 0 ? parts.join(" · ") : void 0;
806
+ }
807
+ /** Describe the `on:` trigger as the spine's root node. */
808
+ function describeTrigger(on) {
809
+ const rec = asRecord(on);
810
+ if (rec.provider_event) {
811
+ const ev = asRecord(rec.provider_event);
812
+ const connection = str(ev.connection);
813
+ const event = str(ev.event);
814
+ const actions = Array.isArray(ev.actions) ? ev.actions.filter((a) => typeof a === "string") : [];
815
+ const repo = str(ev.repo);
816
+ const description = describeText([
817
+ actions.length > 0 ? actions.join(", ") : void 0,
818
+ repo,
819
+ describeScope(ev.scope)
820
+ ].filter(Boolean).join(" · "));
821
+ return withConfig({
822
+ title: connection ? providerLabel(connection) : "Trigger",
823
+ kind: "provider_event",
824
+ subtitle: event ? `On ${humanizeIdentifier(event, "lower")}` : "On an event",
825
+ description,
826
+ provider: connection,
827
+ isRoot: true,
828
+ tone: "trigger"
829
+ }, ev);
830
+ }
831
+ if (rec.schedule) {
832
+ const sch = asRecord(rec.schedule);
833
+ const cron = str(sch.cron);
834
+ const tz = str(sch.timezone);
835
+ return withConfig({
836
+ title: "Schedule",
837
+ kind: "schedule",
838
+ subtitle: cron ? describeCron(cron) : "On a timetable",
839
+ description: cron ? tz ? `${cron} · ${tz}` : cron : void 0,
840
+ isRoot: true,
841
+ tone: "trigger"
842
+ }, sch);
843
+ }
844
+ if (rec.webhook !== void 0) return withConfig({
845
+ title: "Webhook",
846
+ kind: "webhook",
847
+ subtitle: "On an inbound POST",
848
+ isRoot: true,
849
+ tone: "trigger"
850
+ }, asRecord(rec.webhook));
851
+ const [kind] = Object.keys(rec);
852
+ return withConfig({
853
+ title: "Trigger",
854
+ kind: "trigger",
855
+ subtitle: kind ? humanizeIdentifier(kind) : void 0,
856
+ isRoot: true,
857
+ tone: "trigger"
858
+ }, asRecord(kind ? rec[kind] : void 0));
859
+ }
860
+ /**
861
+ * The shape a folded layout aims for: roughly the proportions of the panels
862
+ * these graphs are shown in (a wide, half-height card). Folding to a target
863
+ * SHAPE rather than a target width is what keeps the layouter free of the
864
+ * canvas — it never measures anything, so a resize can never trigger a
865
+ * relayout, and the framing math still does the fitting.
866
+ */
867
+ const WRAP_TARGET_ASPECT = 742 / 480;
868
+ /**
869
+ * How many times wider than {@link WRAP_TARGET_ASPECT} a single row must get
870
+ * before it is worth folding. A short pipeline reads best as what it is — one
871
+ * line, left to right — and folding three steps into an L buys legibility
872
+ * nobody needed while making the order something you have to work out. Past
873
+ * this the line is several panels wide and the trade reverses: at four nodes a
874
+ * row still frames at a readable size, at five it does not.
875
+ *
876
+ * The value has to clear a WINDOW, not just land on the right side of one case:
877
+ * a compact node is 118px tall as a definition and 133px once run rows are
878
+ * reserved, which moves a four-node row's aspect between 6.20 and 5.50 and a
879
+ * five-node row's between 7.80 and 6.92. Anything in (6.20, 6.92) — i.e. a
880
+ * multiplier in (4.01, 4.48) — folds the same graph the same way whether or not
881
+ * a run is in play. Sitting at 4.0 does not, and a graph that re-folds the
882
+ * moment a run starts is worse than one that never folds.
883
+ */
884
+ const WRAP_ASPECT_TRIGGER = 4.2;
885
+ /** The corridor a row change needs. A layer separator is sized for two nodes
886
+ * standing side by side; stacked rows need enough air that the return bracket
887
+ * reads as a turn rather than a collision. */
888
+ const WRAP_ROW_GAP = 28;
889
+ /** How many columns fold `count` equal cells closest to {@link
890
+ * WRAP_TARGET_ASPECT}. Compared in LOG space so being twice too wide and half
891
+ * too wide are penalised the same — a linear error would always prefer the
892
+ * wide side. */
893
+ function foldColumns(count, cellW, cellH, colSep, rowSep) {
894
+ let best = count;
895
+ let bestError = Number.POSITIVE_INFINITY;
896
+ for (let cols = 1; cols <= count; cols++) {
897
+ const rows = Math.ceil(count / cols);
898
+ const width = cols * cellW + (cols - 1) * colSep;
899
+ const height = rows * cellH + (rows - 1) * rowSep;
900
+ const error = Math.abs(Math.log(width / height / WRAP_TARGET_ASPECT));
901
+ if (error < bestError) {
902
+ bestError = error;
903
+ best = cols;
904
+ }
905
+ }
906
+ return best;
907
+ }
908
+ /**
909
+ * Fold a single-file pipeline into ROWS that read boustrophedon — left to
910
+ * right, then right to left on the row below — so consecutive steps stay
911
+ * adjacent and no edge ever travels back across the canvas.
912
+ *
913
+ * One unbounded row is why a long workflow cannot fit its panel: it grows along
914
+ * a single axis while the other sits empty, so framing it whole means zooming
915
+ * until the nodes are specks, and refusing to zoom that far means clipping it.
916
+ * Folding spends the idle axis instead — the eleven steps that need a 2048px
917
+ * row occupy a 732x439 block, which a 742x480 panel frames nearly full size.
918
+ *
919
+ * Returns null unless the graph is a plain LINE, on two counts. Any layer
920
+ * holding more than one node is a fan-out, whose branches already occupy the
921
+ * cross axis the fold needs. And every edge must join one layer to the next: a
922
+ * declared topology may name a shortcut past a step, and mirroring only keeps
923
+ * CONSECUTIVE steps adjacent — a skip that reads as one hop in a straight line
924
+ * becomes a stroke across the middle of a grid. Re-ranking either shape is a
925
+ * different problem from folding a line.
926
+ */
927
+ function layoutSerpentine(nodes, edges, colSep, laneSep) {
928
+ const rowSep = Math.max(laneSep, WRAP_ROW_GAP);
929
+ const byRank = /* @__PURE__ */ new Map();
930
+ for (const n of nodes) {
931
+ const arr = byRank.get(n.rank);
932
+ if (arr) arr.push(n);
933
+ else byRank.set(n.rank, [n]);
934
+ }
935
+ const ranks = [...byRank.keys()].sort((a, b) => a - b);
936
+ if (ranks.some((r) => byRank.get(r).length !== 1)) return null;
937
+ const rankOf = new Map(nodes.map((n) => [n.id, n.rank]));
938
+ const joinsNextLayer = (e) => {
939
+ const from = rankOf.get(e.source);
940
+ const to = rankOf.get(e.target);
941
+ return from !== void 0 && to !== void 0 && to === from + 1;
942
+ };
943
+ if (!edges.every(joinsNextLayer)) return null;
944
+ const ordered = ranks.map((r) => byRank.get(r)[0]);
945
+ const count = ordered.length;
946
+ const cellW = Math.max(...ordered.map((n) => n.width));
947
+ const cellH = Math.max(...ordered.map((n) => n.height));
948
+ if ((count * cellW + (count - 1) * colSep) / cellH <= WRAP_TARGET_ASPECT * WRAP_ASPECT_TRIGGER) return null;
949
+ const cols = foldColumns(count, cellW, cellH, colSep, rowSep);
950
+ if (cols >= count) return null;
951
+ return ordered.map((node, i) => {
952
+ const row = Math.floor(i / cols);
953
+ const indexInRow = i % cols;
954
+ const leftToRight = row % 2 === 0;
955
+ const col = leftToRight ? indexInRow : cols - 1 - indexInRow;
956
+ return {
957
+ id: node.id,
958
+ position: {
959
+ x: col * (cellW + colSep) + (cellW - node.width) / 2,
960
+ y: row * (cellH + rowSep) + (cellH - node.height) / 2
961
+ },
962
+ width: node.width,
963
+ height: node.height,
964
+ data: node.data,
965
+ targetSide: leftToRight ? "left" : "right",
966
+ sourceSide: leftToRight ? "right" : "left"
967
+ };
968
+ });
969
+ }
970
+ /**
971
+ * Position the logical nodes as a layered flow: advance layers along the MAIN
972
+ * axis (x for "LR", y for "TB") and stack each layer's nodes along the CROSS
973
+ * axis, centered on the spine (cross = 0) so a fan-out fans symmetrically and
974
+ * reconverges cleanly. Because dims are authoritative and each layer is pitched
975
+ * by its widest/tallest node plus a separator, layers can never overlap.
976
+ */
977
+ function layoutLayers(nodes, direction, rankSep, crossSep) {
978
+ const isLR = direction === "LR";
979
+ const mainSize = (n) => isLR ? n.width : n.height;
980
+ const crossSize = (n) => isLR ? n.height : n.width;
981
+ const byRank = /* @__PURE__ */ new Map();
982
+ for (const n of nodes) {
983
+ const arr = byRank.get(n.rank);
984
+ if (arr) arr.push(n);
985
+ else byRank.set(n.rank, [n]);
986
+ }
987
+ const ranks = [...byRank.keys()].sort((a, b) => a - b);
988
+ const mainStart = /* @__PURE__ */ new Map();
989
+ let cursor = 0;
990
+ for (const r of ranks) {
991
+ mainStart.set(r, cursor);
992
+ cursor += Math.max(...byRank.get(r).map(mainSize)) + rankSep;
993
+ }
994
+ const out = [];
995
+ for (const r of ranks) {
996
+ const layer = byRank.get(r);
997
+ const extent = Math.max(...layer.map(mainSize));
998
+ let cross = -(layer.reduce((s, n) => s + crossSize(n), 0) + crossSep * (layer.length - 1)) / 2;
999
+ for (const n of layer) {
1000
+ const main = mainStart.get(r) + (extent - mainSize(n)) / 2;
1001
+ out.push({
1002
+ id: n.id,
1003
+ position: isLR ? {
1004
+ x: main,
1005
+ y: cross
1006
+ } : {
1007
+ x: cross,
1008
+ y: main
1009
+ },
1010
+ width: n.width,
1011
+ height: n.height,
1012
+ data: n.data
1013
+ });
1014
+ cross += crossSize(n) + crossSep;
1015
+ }
1016
+ }
1017
+ const minX = Math.min(...out.map((n) => n.position.x));
1018
+ const minY = Math.min(...out.map((n) => n.position.y));
1019
+ for (const n of out) n.position = {
1020
+ x: n.position.x - minX,
1021
+ y: n.position.y - minY
1022
+ };
1023
+ return out;
1024
+ }
1025
+ /**
1026
+ * The edges that close a cycle, by edge id — an edge whose target is already on
1027
+ * the path that reached it, which is the one edge in a loop that can make a node
1028
+ * run twice.
1029
+ *
1030
+ * The walk starts at the graph's ENTRY POINTS and only then at whatever they
1031
+ * miss. Both orders find a correct back edge for every cycle (any DFS does), but
1032
+ * only this one picks the edge a reader would point at: entering `a→b→c→a` from
1033
+ * its entry marks `c→a`, while starting mid-cycle would just as validly mark
1034
+ * `a→b`. Nodes no entry point reaches — a loop closed entirely on itself — are
1035
+ * walked afterwards, so every edge is classified either way.
1036
+ */
1037
+ function classifyBackEdges(orderedIds, edges) {
1038
+ const outgoing = /* @__PURE__ */ new Map();
1039
+ const hasIncoming = /* @__PURE__ */ new Set();
1040
+ for (const e of edges) {
1041
+ const list = outgoing.get(e.source);
1042
+ if (list) list.push(e);
1043
+ else outgoing.set(e.source, [e]);
1044
+ hasIncoming.add(e.target);
1045
+ }
1046
+ const walkOrder = [...orderedIds.filter((id) => !hasIncoming.has(id)), ...orderedIds.filter((id) => hasIncoming.has(id))];
1047
+ const back = /* @__PURE__ */ new Set();
1048
+ const color = new Map(orderedIds.map((id) => [id, 0]));
1049
+ for (const start of walkOrder) {
1050
+ if (color.get(start) !== 0) continue;
1051
+ color.set(start, 1);
1052
+ const stack = [{
1053
+ id: start,
1054
+ next: 0
1055
+ }];
1056
+ while (stack.length > 0) {
1057
+ const frame = stack[stack.length - 1];
1058
+ const out = outgoing.get(frame.id);
1059
+ if (!out || frame.next >= out.length) {
1060
+ color.set(frame.id, 2);
1061
+ stack.pop();
1062
+ continue;
1063
+ }
1064
+ const edge = out[frame.next];
1065
+ frame.next += 1;
1066
+ const target = color.get(edge.target);
1067
+ if (target === 1) back.add(edge.id);
1068
+ else if (target === 0) {
1069
+ color.set(edge.target, 1);
1070
+ stack.push({
1071
+ id: edge.target,
1072
+ next: 0
1073
+ });
1074
+ }
1075
+ }
1076
+ }
1077
+ return back;
1078
+ }
1079
+ /**
1080
+ * Layer each node by its LONGEST path from an entry point. Longest, not
1081
+ * shortest, is what makes a diamond's two arms meet again in one column: the
1082
+ * node both arms lead to sits past the later of them, instead of a layer short
1083
+ * with an edge reaching forward over its neighbour.
1084
+ *
1085
+ * Back edges are excluded — they are what makes the graph cyclic, and layering
1086
+ * is defined only on an acyclic one. Removing every DFS back edge always leaves
1087
+ * a DAG, so the sweep below drains completely and no node is left unranked.
1088
+ */
1089
+ function rankByTopology(orderedIds, edges, backEdgeIds) {
1090
+ const outgoing = /* @__PURE__ */ new Map();
1091
+ const indegree = new Map(orderedIds.map((id) => [id, 0]));
1092
+ for (const e of edges) {
1093
+ if (backEdgeIds.has(e.id)) continue;
1094
+ const list = outgoing.get(e.source);
1095
+ if (list) list.push(e);
1096
+ else outgoing.set(e.source, [e]);
1097
+ indegree.set(e.target, (indegree.get(e.target) ?? 0) + 1);
1098
+ }
1099
+ const rank = new Map(orderedIds.map((id) => [id, 0]));
1100
+ const queue = orderedIds.filter((id) => indegree.get(id) === 0);
1101
+ for (let head = 0; head < queue.length; head += 1) {
1102
+ const id = queue[head];
1103
+ for (const e of outgoing.get(id) ?? []) {
1104
+ rank.set(e.target, Math.max(rank.get(e.target) ?? 0, (rank.get(id) ?? 0) + 1));
1105
+ const remaining = (indegree.get(e.target) ?? 0) - 1;
1106
+ indegree.set(e.target, remaining);
1107
+ if (remaining === 0) queue.push(e.target);
1108
+ }
1109
+ }
1110
+ return rank;
1111
+ }
1112
+ /** Build a positioned graph from a workflow YAML string. Never throws —
1113
+ * malformed YAML or an empty definition returns an `error` the UI can fall
1114
+ * back on (e.g. show the raw YAML while authoring). `reserveRunState` leaves
1115
+ * room for the rows live run state adds (see {@link nodeHeight}); `direction`
1116
+ * picks the flow axis (default "LR"); `compact` collapses nodes to icon tiles;
1117
+ * `edges` replaces the inferred positional spine with a declared topology. */
1118
+ function buildWorkflowGraph(yaml, options) {
1119
+ const reserveRunState = options?.reserveRunState ?? false;
1120
+ const direction = options?.direction ?? "LR";
1121
+ const compact = options?.compact ?? false;
1122
+ const declared = options?.edges;
1123
+ if (!yaml || yaml.trim() === "") return {
1124
+ nodes: [],
1125
+ edges: [],
1126
+ error: "No definition"
1127
+ };
1128
+ let parsed;
1129
+ try {
1130
+ parsed = parse(yaml);
1131
+ } catch {
1132
+ return {
1133
+ nodes: [],
1134
+ edges: [],
1135
+ error: "Invalid YAML"
1136
+ };
1137
+ }
1138
+ const def = asRecord(parsed);
1139
+ const actions = Array.isArray(def.do) ? def.do : [];
1140
+ const triggers = !def.on ? [] : Array.isArray(def.on) ? def.on : [def.on];
1141
+ if (triggers.length === 0 && actions.length === 0) return {
1142
+ nodes: [],
1143
+ edges: [],
1144
+ error: "Empty workflow"
1145
+ };
1146
+ const logical = [];
1147
+ const edges = [];
1148
+ const geo = geometry(compact, reserveRunState, direction);
1149
+ const addNode = (id, data, rank, withRunState) => {
1150
+ const dims = geo.size ?? {
1151
+ width: NODE_W,
1152
+ height: nodeHeight(data, withRunState)
1153
+ };
1154
+ logical.push({
1155
+ id,
1156
+ data,
1157
+ width: dims.width,
1158
+ height: dims.height,
1159
+ rank
1160
+ });
1161
+ };
1162
+ const edgeIds = /* @__PURE__ */ new Set();
1163
+ const addEdge = (source, target, kind, whenLabel) => {
1164
+ const id = wfEdgeId(source, target);
1165
+ if (edgeIds.has(id)) return;
1166
+ edgeIds.add(id);
1167
+ edges.push({
1168
+ id,
1169
+ source,
1170
+ target,
1171
+ kind,
1172
+ ...whenLabel ? { whenLabel } : {}
1173
+ });
1174
+ };
1175
+ let rank = 0;
1176
+ let prevExits = [];
1177
+ const branchLeafIds = /* @__PURE__ */ new Set();
1178
+ const triggerIds = triggers.map((_, i) => triggerNodeId(i));
1179
+ triggers.forEach((trigger, i) => {
1180
+ addNode(triggerNodeId(i), describeTrigger(trigger), rank, false);
1181
+ });
1182
+ if (triggers.length > 0) {
1183
+ prevExits = [...triggerIds];
1184
+ rank += 1;
1185
+ }
1186
+ actions.forEach((action, i) => {
1187
+ const id = actionNodeId(i);
1188
+ const data = describeAction(action);
1189
+ if (i === 0 && triggers.length === 0) data.isRoot = true;
1190
+ addNode(id, data, rank, reserveRunState);
1191
+ if (!declared) for (const from of prevExits) addEdge(from, id, branchLeafIds.has(from) ? "join" : "spine");
1192
+ const rec = asRecord(action);
1193
+ const children = "parallel" in rec ? Array.isArray(asRecord(rec.parallel).branches) ? asRecord(rec.parallel).branches : [] : "foreach" in rec ? [asRecord(rec.foreach).do].filter(Boolean) : [];
1194
+ if (children.length > 0) {
1195
+ const branchRank = rank + 1;
1196
+ const branchIds = [];
1197
+ children.forEach((child, j) => {
1198
+ const cid = branchNodeId(i, j);
1199
+ addNode(cid, describeAction(child), branchRank, reserveRunState);
1200
+ addEdge(id, cid, "fork");
1201
+ branchIds.push(cid);
1202
+ branchLeafIds.add(cid);
1203
+ });
1204
+ prevExits = branchIds;
1205
+ rank = branchRank + 1;
1206
+ } else {
1207
+ prevExits = [id];
1208
+ rank += 1;
1209
+ }
1210
+ });
1211
+ if (declared) {
1212
+ const known = new Set(logical.map((n) => n.id));
1213
+ for (const spec of declared) {
1214
+ const missing = !known.has(spec.from) ? spec.from : !known.has(spec.to) ? spec.to : null;
1215
+ if (missing !== null) return {
1216
+ nodes: [],
1217
+ edges: [],
1218
+ error: `Declared topology names "${missing}", which this definition has no step for`
1219
+ };
1220
+ addEdge(spec.from, spec.to, "spine", spec.whenLabel);
1221
+ }
1222
+ const rootward = new Set(edges.map((e) => e.target));
1223
+ for (const n of logical) {
1224
+ if (triggerNodeIndex(n.id) !== null) continue;
1225
+ if (rootward.has(n.id)) continue;
1226
+ for (const triggerId of triggerIds) addEdge(triggerId, n.id, "spine");
1227
+ }
1228
+ const orderedIds = logical.map((n) => n.id);
1229
+ const backEdgeIds = classifyBackEdges(orderedIds, edges);
1230
+ for (const e of edges) if (backEdgeIds.has(e.id)) e.backEdge = true;
1231
+ const rankById = rankByTopology(orderedIds, edges, backEdgeIds);
1232
+ const incoming = new Set(edges.map((e) => e.target));
1233
+ for (const n of logical) {
1234
+ n.rank = rankById.get(n.id) ?? 0;
1235
+ n.data = {
1236
+ ...n.data,
1237
+ isRoot: !incoming.has(n.id)
1238
+ };
1239
+ }
1240
+ }
1241
+ const labelled = edges.some((e) => e.whenLabel !== void 0 || e.backEdge === true);
1242
+ const rankSep = labelled ? Math.max(geo.rankSep, EDGE_LABEL_LANE) : options?.reserveEdgeInsert ? Math.max(geo.rankSep, EDGE_INSERT_LANE) : geo.rankSep;
1243
+ return {
1244
+ nodes: (options?.wrap && direction === "LR" && !labelled ? layoutSerpentine(logical, edges, rankSep, geo.crossSep) : null) ?? layoutLayers(logical, direction, rankSep, geo.crossSep),
1245
+ edges,
1246
+ error: null
1247
+ };
1248
+ }
1249
+ //#endregion
1250
+ export { buildWorkflowGraph as a, triggerNodeIndex as c, providerLabel as d, shortModel as f, fmtTokens as g, fmtDuration as h, branchNodeId as i, wfEdgeId as l, fmtCost as m, TRIGGER_NODE_ID as n, isStructuralKind as o, clampPreview as p, actionNodeId as r, triggerNodeId as s, COMPACT_NODE_SIZE as t, worstSeverity as u };