@particle-academy/fancy-flow 0.66.0 → 0.66.1

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 (52) hide show
  1. package/dist/chunk-27RZJAE2.js +2586 -0
  2. package/dist/chunk-27RZJAE2.js.map +1 -0
  3. package/dist/{chunk-FNYLKSFJ.js → chunk-2QTEELJE.js} +4 -3
  4. package/dist/chunk-2QTEELJE.js.map +1 -0
  5. package/dist/{chunk-MTONBM53.js → chunk-6AYC3BWI.js} +4 -4
  6. package/dist/{chunk-MTONBM53.js.map → chunk-6AYC3BWI.js.map} +1 -1
  7. package/dist/{chunk-IEYCNFXZ.js → chunk-6TH646CS.js} +127 -5
  8. package/dist/chunk-6TH646CS.js.map +1 -0
  9. package/dist/{chunk-UCCTXJXL.js → chunk-BUNFPBTF.js} +5 -4
  10. package/dist/chunk-BUNFPBTF.js.map +1 -0
  11. package/dist/{chunk-B7HKJJVV.js → chunk-FV3D366L.js} +3 -3
  12. package/dist/{chunk-B7HKJJVV.js.map → chunk-FV3D366L.js.map} +1 -1
  13. package/dist/{chunk-HQGOFGAL.js → chunk-OWENS2H5.js} +64 -2754
  14. package/dist/chunk-OWENS2H5.js.map +1 -0
  15. package/dist/{chunk-PNHZHEWE.js → chunk-RE3XNVSB.js} +3 -3
  16. package/dist/{chunk-PNHZHEWE.js.map → chunk-RE3XNVSB.js.map} +1 -1
  17. package/dist/durable.cjs +153 -9213
  18. package/dist/durable.cjs.map +1 -1
  19. package/dist/durable.js +1 -2
  20. package/dist/durable.js.map +1 -1
  21. package/dist/engine.cjs +103 -9165
  22. package/dist/engine.cjs.map +1 -1
  23. package/dist/engine.js +4 -5
  24. package/dist/engine.js.map +1 -1
  25. package/dist/index.cjs +11135 -11121
  26. package/dist/index.cjs.map +1 -1
  27. package/dist/index.js +15 -13
  28. package/dist/index.js.map +1 -1
  29. package/dist/registry/index.d.cts +15 -3
  30. package/dist/registry/index.d.ts +15 -3
  31. package/dist/registry.cjs +11166 -11153
  32. package/dist/registry.cjs.map +1 -1
  33. package/dist/registry.js +4 -3
  34. package/dist/runtime.cjs +10772 -10924
  35. package/dist/runtime.cjs.map +1 -1
  36. package/dist/runtime.js +4 -4
  37. package/dist/schema.cjs +85 -9147
  38. package/dist/schema.cjs.map +1 -1
  39. package/dist/schema.js +3 -4
  40. package/dist/screens.cjs +7 -52
  41. package/dist/screens.cjs.map +1 -1
  42. package/dist/screens.js +6 -5
  43. package/dist/screens.js.map +1 -1
  44. package/dist/ux.cjs +89 -9147
  45. package/dist/ux.cjs.map +1 -1
  46. package/dist/ux.js +1 -2
  47. package/dist/ux.js.map +1 -1
  48. package/package.json +1 -1
  49. package/dist/chunk-FNYLKSFJ.js.map +0 -1
  50. package/dist/chunk-HQGOFGAL.js.map +0 -1
  51. package/dist/chunk-IEYCNFXZ.js.map +0 -1
  52. package/dist/chunk-UCCTXJXL.js.map +0 -1
@@ -0,0 +1,2586 @@
1
+ import { getLlmClient, getWorkflowResolver, isResolutionFailure, getTerminalHost } from './chunk-2QXKDLGT.js';
2
+
3
+ // src/registry/llm-router.ts
4
+ function declaredRoutes(config) {
5
+ const raw = config.routes;
6
+ if (!Array.isArray(raw)) return [];
7
+ return raw.map((r) => ({ port: String(r?.port ?? "").trim(), description: r?.description })).filter((r) => r.port !== "");
8
+ }
9
+ function resolveFallbackPort(routes, fallbackEnabled) {
10
+ if (fallbackEnabled) return "fallback";
11
+ return routes[0]?.port ?? "out";
12
+ }
13
+ var llmRouterExecutor = async (ctx) => {
14
+ const config = ctx.node.data?.config ?? {};
15
+ const routes = declaredRoutes(config);
16
+ if (routes.length === 0) {
17
+ ctx.abort("llm_router has no routes configured");
18
+ }
19
+ const client = getLlmClient();
20
+ if (!client) {
21
+ ctx.abort(
22
+ "No LLM client registered. Call registerLlmClient() with your provider adapter \u2014 fancy-flow ships the routing, not the model call."
23
+ );
24
+ }
25
+ const fallbackEnabled = config.fallback !== false;
26
+ const choice = await client.chooseRoute({
27
+ system: typeof config.system === "string" ? config.system : void 0,
28
+ prompt: String(config.prompt ?? ctx.inputs ?? ""),
29
+ routes,
30
+ provider: typeof config.provider === "string" ? config.provider : void 0,
31
+ model: typeof config.model === "string" ? config.model : void 0,
32
+ credential: typeof config.credential === "string" ? config.credential : void 0
33
+ });
34
+ const offered = new Set(routes.map((r) => r.port));
35
+ let port = choice?.port ?? "";
36
+ let reason = choice?.reason;
37
+ if (!offered.has(port)) {
38
+ const safe = resolveFallbackPort(routes, fallbackEnabled);
39
+ ctx.emit({
40
+ type: "log",
41
+ nodeId: ctx.node.id,
42
+ level: "warn",
43
+ message: `llm_router: model returned "${port || "(nothing)"}", which is not a declared route. Routing to "${safe}".`
44
+ });
45
+ reason = reason ?? `unrecognised route "${port}"`;
46
+ port = safe;
47
+ }
48
+ return { __port: port, value: { route: port, reason, input: ctx.inputs } };
49
+ };
50
+
51
+ // src/registry/ports.ts
52
+ function resolvePortSpec(spec, config) {
53
+ if (spec === void 0) return void 0;
54
+ if (typeof spec !== "function") return spec;
55
+ try {
56
+ const resolved = spec(config);
57
+ return Array.isArray(resolved) ? resolved : void 0;
58
+ } catch {
59
+ return void 0;
60
+ }
61
+ }
62
+ function nodeConfig(node) {
63
+ return node.data?.config ?? {};
64
+ }
65
+ function resolveNodePorts(node, kind) {
66
+ const config = nodeConfig(node);
67
+ const data = node.data;
68
+ return {
69
+ inputs: data?.inputs ?? resolvePortSpec(kind?.inputs, config),
70
+ outputs: data?.outputs ?? resolvePortSpec(kind?.outputs, config)
71
+ };
72
+ }
73
+
74
+ // src/runtime/run-identity.ts
75
+ function escapeSegment(value) {
76
+ return value.replace(/%/g, "%25").replace(/\//g, "%2F").replace(/#/g, "%23");
77
+ }
78
+ function renderSegment(value, occurrence) {
79
+ const escaped = escapeSegment(value);
80
+ return occurrence === void 0 || occurrence === null ? escaped : `${escaped}#${occurrence}`;
81
+ }
82
+ function instant(value) {
83
+ const ms = Date.parse(value);
84
+ if (Number.isNaN(ms)) {
85
+ throw new Error(
86
+ `RunIdentity: firstAttemptAt is not a parseable timestamp: ${JSON.stringify(value)}`
87
+ );
88
+ }
89
+ return ms;
90
+ }
91
+ var RunIdentity = class _RunIdentity {
92
+ constructor(runKey, path = [], attempt = 1, firstAttemptAt = (/* @__PURE__ */ new Date()).toISOString()) {
93
+ if (!runKey || runKey.trim() === "") {
94
+ throw new Error("RunIdentity: runKey must be a non-empty string.");
95
+ }
96
+ this.runKey = runKey;
97
+ this.path = Object.freeze([...path]);
98
+ this.attempt = Math.max(1, Math.trunc(attempt));
99
+ this.firstAttemptAt = firstAttemptAt;
100
+ Object.freeze(this);
101
+ }
102
+ /**
103
+ * The identity of one execution of one node — stable across retries of that
104
+ * execution, distinct from every other execution of the same node.
105
+ *
106
+ * Pass `occurrence` when an executor runs the same node more than once at the
107
+ * same level (a loop body, one item of a fan-out it drives itself).
108
+ */
109
+ stepKey(nodeId, occurrence) {
110
+ return `${this.runKey}:${[...this.path, renderSegment(nodeId, occurrence)].join("/")}`;
111
+ }
112
+ /**
113
+ * A child identity for work nested inside this step.
114
+ *
115
+ * `subflow` pushes the invoking node's id, so a node inside the child graph
116
+ * cannot collide with a same-named node in the parent. Attempt and
117
+ * `firstAttemptAt` are carried down unchanged: the nested work happens inside
118
+ * this step's attempt, and shares its clock.
119
+ */
120
+ descend(segment, occurrence) {
121
+ return new _RunIdentity(
122
+ this.runKey,
123
+ [...this.path, renderSegment(segment, occurrence)],
124
+ this.attempt,
125
+ this.firstAttemptAt
126
+ );
127
+ }
128
+ /** A copy on a different attempt, first-attempt clock preserved. */
129
+ withAttempt(attempt, firstAttemptAt) {
130
+ return new _RunIdentity(
131
+ this.runKey,
132
+ this.path,
133
+ attempt,
134
+ firstAttemptAt ?? this.firstAttemptAt
135
+ );
136
+ }
137
+ /**
138
+ * May this attempt reuse the step key and still be deduplicated?
139
+ *
140
+ * Providers forget idempotency keys — Stripe after 24 hours. Past that
141
+ * window, resending the key creates a second charge and sending a fresh one
142
+ * creates a second charge, so **the caller must refuse rather than pick
143
+ * between them**: a loud stuck run beats a silent double write.
144
+ *
145
+ * `true` on attempt 1 whatever the elapsed time — nothing was sent on an
146
+ * earlier attempt, so there is nothing for the provider to have forgotten.
147
+ * That is what lets a run park on a human gate for a week and then write.
148
+ *
149
+ * `windowSeconds: null` means the provider does not expire keys. `0` means
150
+ * it does not dedupe at all, so no retry may reuse a key — it is a real
151
+ * window, not an absent one, and the two must not be conflated: reading `0`
152
+ * as `null` turns "this provider does not dedupe" into "this provider
153
+ * dedupes forever", which is the more dangerous of the two by a distance.
154
+ */
155
+ isReplaySafe(windowSeconds, now = /* @__PURE__ */ new Date()) {
156
+ if (this.attempt <= 1) return true;
157
+ if (windowSeconds === null || windowSeconds === void 0) return true;
158
+ if (windowSeconds <= 0) return false;
159
+ const nowMs = typeof now === "string" ? instant(now) : now.getTime();
160
+ const elapsedSeconds = Math.max(0, (nowMs - instant(this.firstAttemptAt)) / 1e3);
161
+ return elapsedSeconds <= windowSeconds;
162
+ }
163
+ toJSON() {
164
+ return {
165
+ runKey: this.runKey,
166
+ path: [...this.path],
167
+ attempt: this.attempt,
168
+ firstAttemptAt: this.firstAttemptAt
169
+ };
170
+ }
171
+ /** Rebuild from a queue payload. */
172
+ static from(value) {
173
+ if (value instanceof _RunIdentity) return value;
174
+ if (typeof value === "string") return new _RunIdentity(value);
175
+ return new _RunIdentity(
176
+ value.runKey,
177
+ value.path ?? [],
178
+ value.attempt ?? 1,
179
+ value.firstAttemptAt ?? (/* @__PURE__ */ new Date()).toISOString()
180
+ );
181
+ }
182
+ };
183
+
184
+ // src/components/FlowEditor/human-fields.ts
185
+ var HUMAN_FIELD_TYPE_ALIASES = {
186
+ text: "text",
187
+ string: "text",
188
+ str: "text",
189
+ input: "text",
190
+ textarea: "textarea",
191
+ long_text: "textarea",
192
+ longtext: "textarea",
193
+ "long-text": "textarea",
194
+ multiline: "textarea",
195
+ paragraph: "textarea",
196
+ markdown: "textarea",
197
+ number: "number",
198
+ numeric: "number",
199
+ integer: "number",
200
+ int: "number",
201
+ float: "number",
202
+ decimal: "number",
203
+ select: "select",
204
+ enum: "select",
205
+ choice: "select",
206
+ choices: "select",
207
+ dropdown: "select",
208
+ options: "select",
209
+ radio: "select",
210
+ switch: "switch",
211
+ bool: "switch",
212
+ boolean: "switch",
213
+ checkbox: "switch",
214
+ toggle: "switch",
215
+ date: "date",
216
+ datetime: "datetime",
217
+ "datetime-local": "datetime",
218
+ datetimelocal: "datetime",
219
+ timestamp: "datetime",
220
+ time: "time",
221
+ email: "email",
222
+ "e-mail": "email",
223
+ url: "url",
224
+ uri: "url",
225
+ link: "url",
226
+ tel: "tel",
227
+ phone: "tel",
228
+ telephone: "tel",
229
+ password: "password",
230
+ secret: "password"
231
+ };
232
+ function humanFieldType(raw) {
233
+ if (typeof raw !== "string") return "text";
234
+ return HUMAN_FIELD_TYPE_ALIASES[raw.trim().toLowerCase()] ?? "text";
235
+ }
236
+ function humanFieldOptions(raw) {
237
+ const entries = [];
238
+ if (Array.isArray(raw)) {
239
+ for (const item of raw) {
240
+ if (typeof item === "string" || typeof item === "number") {
241
+ const value = String(item);
242
+ if (value !== "") entries.push({ value, label: value });
243
+ continue;
244
+ }
245
+ if (item && typeof item === "object") {
246
+ const value = item.value;
247
+ if (value === void 0 || value === null || value === "") continue;
248
+ const label = item.label;
249
+ entries.push({
250
+ value: String(value),
251
+ label: typeof label === "string" && label !== "" ? label : String(value)
252
+ });
253
+ }
254
+ }
255
+ } else if (raw && typeof raw === "object") {
256
+ for (const [value, label] of Object.entries(raw)) {
257
+ if (value === "") continue;
258
+ entries.push({ value, label: typeof label === "string" && label !== "" ? label : value });
259
+ }
260
+ }
261
+ return entries.length ? entries : void 0;
262
+ }
263
+ function humanInputFields(config) {
264
+ const raw = Array.isArray(config?.fields) ? config.fields : [];
265
+ const fields = raw.filter((f) => f && typeof f === "object" && typeof f.key === "string" && f.key).map((f) => ({
266
+ key: f.key,
267
+ label: typeof f.label === "string" && f.label ? f.label : f.key,
268
+ type: humanFieldType(f.type),
269
+ required: !!f.required,
270
+ placeholder: typeof f.placeholder === "string" ? f.placeholder : void 0,
271
+ options: humanFieldOptions(f.options ?? f.choices),
272
+ default: f.default
273
+ }));
274
+ if (fields.length) return fields;
275
+ const title = typeof config?.title === "string" && config.title ? config.title : "Your answer";
276
+ return [{ key: "value", label: title, type: "textarea", required: true }];
277
+ }
278
+
279
+ // src/registry/subflow.ts
280
+ var DEFAULT_MAX_DEPTH = 8;
281
+ function subflowMode(config) {
282
+ const mode = config.mode;
283
+ return mode === "stream" || mode === "both" ? mode : "output";
284
+ }
285
+ function subflowPorts(config) {
286
+ const mode = subflowMode(config);
287
+ const ports = [{ id: "out", label: "result" }];
288
+ if (mode === "stream" || mode === "both") ports.unshift({ id: "stream", label: "stream" });
289
+ return ports;
290
+ }
291
+ var subflowExecutor = async (ctx) => {
292
+ const config = ctx.node.data?.config ?? {};
293
+ const ref = String(config.workflow ?? "").trim();
294
+ if (!ref) ctx.abort("subflow has no workflow reference configured");
295
+ const resolver = getWorkflowResolver();
296
+ if (!resolver) {
297
+ ctx.abort(
298
+ "No workflow resolver registered. Call registerWorkflowResolver() so subflow can find the workflow it references."
299
+ );
300
+ }
301
+ const maxDepth = Number.isFinite(config.maxDepth) ? Number(config.maxDepth) : DEFAULT_MAX_DEPTH;
302
+ const depth = ctx.depth ?? 0;
303
+ if (depth + 1 > maxDepth) {
304
+ ctx.abort(
305
+ `subflow depth limit reached (${maxDepth}) at "${ref}" \u2014 a workflow is referencing itself, directly or through a chain.`
306
+ );
307
+ }
308
+ const pinned = config.version === void 0 || config.version === "" ? void 0 : Number(config.version);
309
+ if (pinned !== void 0 && !Number.isInteger(pinned)) {
310
+ ctx.abort(`subflow "${ref}" has a non-integer version pin (${String(config.version)}).`);
311
+ }
312
+ const resolved = await resolver(ref, pinned);
313
+ const child = isResolutionFailure(resolved) ? ctx.abort(
314
+ resolved.reason === "version-mismatch" ? resolved.message ?? `subflow "${ref}" is pinned to version ${pinned}, but the host has ${resolved.available ?? "a different version"}.` : resolved.message ?? `subflow could not resolve workflow "${ref}"`
315
+ ) : resolved;
316
+ if (!child) ctx.abort(`subflow could not resolve workflow "${ref}"`);
317
+ const mode = subflowMode(config);
318
+ const streaming = mode === "stream" || mode === "both";
319
+ const forward = (event) => {
320
+ if (!streaming) return;
321
+ const detail = event.type === "node-status" ? `${event.nodeId} ${event.status}` : event.type === "run-end" ? `finished (${event.ok ? "ok" : "failed"})` : event.type;
322
+ ctx.emit({
323
+ type: "log",
324
+ nodeId: ctx.node.id,
325
+ level: "info",
326
+ message: `[${ref}] ${detail}`
327
+ });
328
+ };
329
+ const result = await runFlow(
330
+ child,
331
+ // Inherit the parent's registry, then let the graph layer its own on top.
332
+ // The inherited half is the fix; the layered half keeps a graph that
333
+ // deliberately hands its child extra or different executors working.
334
+ { ...ctx.executors ?? {}, ...config.executors ?? {} },
335
+ forward,
336
+ {
337
+ initialInputs: config.inputs ?? {
338
+ // With no explicit mapping, hand the parent's inputs to the child's
339
+ // entry points — the obvious default, and it makes the simple case
340
+ // require no configuration at all.
341
+ __parent: ctx.inputs
342
+ },
343
+ depth: depth + 1,
344
+ // Push THIS node onto the identity path, so a node inside the child graph
345
+ // cannot share an idempotency key with a same-named node in the parent —
346
+ // or with the same child graph invoked from a different parent node.
347
+ // Attempt and the first-attempt clock ride down unchanged: the child's
348
+ // work happens inside this node's attempt.
349
+ run: ctx.run?.descend(ctx.node.id)
350
+ }
351
+ );
352
+ if (!result.ok) {
353
+ ctx.abort(`subflow "${ref}" failed: ${result.error ?? "unknown error"}`);
354
+ }
355
+ if (mode === "stream") {
356
+ return { __port: "stream", value: result.outputs };
357
+ }
358
+ if (mode === "both") {
359
+ return result.outputs;
360
+ }
361
+ return { __port: "out", value: result.outputs };
362
+ };
363
+
364
+ // src/expressions/expr.ts
365
+ function wholeExpression(trimmed) {
366
+ if (trimmed.length < 4) return null;
367
+ if (!trimmed.startsWith("{{") || !trimmed.endsWith("}}")) return null;
368
+ return trimmed.slice(2, -2);
369
+ }
370
+ function interpolate(template, resolve2) {
371
+ let out = "";
372
+ let i = 0;
373
+ for (; ; ) {
374
+ const open = template.indexOf("{{", i);
375
+ if (open === -1) return out + template.slice(i);
376
+ const close = template.indexOf("}}", open + 2);
377
+ if (close === -1) return out + template.slice(i);
378
+ out += template.slice(i, open) + resolve2(template.slice(open + 2, close));
379
+ i = close + 2;
380
+ }
381
+ }
382
+ var FALSY_STRINGS = /* @__PURE__ */ new Set(["", "0", "false", "no", "off", "null"]);
383
+ var UnresolvedPathError = class extends Error {
384
+ constructor(path) {
385
+ super(
386
+ `Expression path "${path}" did not resolve. Under the "throw" policy an unresolvable path is an error rather than an empty string.`
387
+ );
388
+ this.path = path;
389
+ this.name = "UnresolvedPathError";
390
+ }
391
+ };
392
+ function tryResolvePath(path, context) {
393
+ const unresolved = { resolved: false, value: null };
394
+ const trimmed = path.trim();
395
+ if (trimmed === "") return unresolved;
396
+ const segments = trimmed.split(".");
397
+ let cursor;
398
+ const head = segments[0];
399
+ if (head === "$json" || head === "$input") {
400
+ cursor = context !== null && typeof context === "object" && "in" in context ? context.in : context;
401
+ segments.shift();
402
+ } else {
403
+ cursor = context;
404
+ }
405
+ for (const segment of segments) {
406
+ if (cursor === null || cursor === void 0) return unresolved;
407
+ if (typeof cursor !== "object") return unresolved;
408
+ const next = cursor[segment];
409
+ if (next === void 0) return unresolved;
410
+ cursor = next;
411
+ }
412
+ return { resolved: true, value: cursor === void 0 ? null : cursor };
413
+ }
414
+ function resolvePath(path, context) {
415
+ return tryResolvePath(path, context).value;
416
+ }
417
+ function evaluateExpression(template, context, options = {}) {
418
+ if (typeof template !== "string") return template;
419
+ const policy = options.onUnresolved ?? "empty";
420
+ const whole = wholeExpression(template.trim());
421
+ if (whole !== null) {
422
+ const r = tryResolvePath(whole, context);
423
+ if (r.resolved) return r.value;
424
+ if (policy === "throw") throw new UnresolvedPathError(whole);
425
+ return policy === "keep" ? template : null;
426
+ }
427
+ return interpolate(template, (path) => {
428
+ const r = tryResolvePath(path, context);
429
+ if (r.resolved) return stringify(r.value);
430
+ if (policy === "throw") throw new UnresolvedPathError(path);
431
+ return policy === "keep" ? `{{${path}}}` : "";
432
+ });
433
+ }
434
+ function truthy(value) {
435
+ if (typeof value === "boolean") return value;
436
+ if (value === null || value === void 0) return false;
437
+ if (typeof value === "string") return !FALSY_STRINGS.has(value.trim().toLowerCase());
438
+ if (Array.isArray(value)) return value.length > 0;
439
+ if (typeof value === "number") return value !== 0;
440
+ return Boolean(value);
441
+ }
442
+ function text(value) {
443
+ return stringify(value);
444
+ }
445
+ function stringify(value) {
446
+ if (typeof value === "string") return value;
447
+ if (typeof value === "boolean") return value ? "true" : "false";
448
+ if (value === null || value === void 0) return "";
449
+ if (typeof value === "number" || typeof value === "bigint") return String(value);
450
+ try {
451
+ return JSON.stringify(value) ?? "";
452
+ } catch {
453
+ return "";
454
+ }
455
+ }
456
+ function evaluateConfig(config, context, options = {}) {
457
+ const out = {};
458
+ for (const [key, value] of Object.entries(config)) {
459
+ out[key] = Array.isArray(value) ? value.map((v) => evaluateExpression(v, context, options)) : value !== null && typeof value === "object" ? evaluateConfig(value, context, options) : evaluateExpression(value, context, options);
460
+ }
461
+ return out;
462
+ }
463
+
464
+ // src/registry/logic.ts
465
+ function configOf(node) {
466
+ return node?.data?.config ?? {};
467
+ }
468
+ function contextOf(inputs) {
469
+ return inputs;
470
+ }
471
+ function resolve(value, inputs) {
472
+ if (typeof value !== "string") return value;
473
+ return evaluateExpression(value, contextOf(inputs));
474
+ }
475
+ function conditionHolds(row, inputs) {
476
+ const left = resolve(row.left, inputs);
477
+ const right = resolve(row.right, inputs);
478
+ const operator = typeof row.operator === "string" ? row.operator : "eq";
479
+ const asText = (v) => text(v);
480
+ const asNumber = (v) => {
481
+ const n = typeof v === "number" ? v : Number(asText(v));
482
+ return Number.isFinite(n) ? n : NaN;
483
+ };
484
+ const numeric = (fn) => {
485
+ const a = asNumber(left);
486
+ const b = asNumber(right);
487
+ return Number.isFinite(a) && Number.isFinite(b) ? fn(a, b) : false;
488
+ };
489
+ const isEmpty = (v) => v === null || v === void 0 || v === "" || Array.isArray(v) && v.length === 0 || typeof v === "object" && !Array.isArray(v) && Object.keys(v).length === 0;
490
+ switch (operator) {
491
+ // Compared as TEXT, deliberately. `right` is a text field in the schema, so
492
+ // `status == "2"` must match the number 2 an upstream node produced —
493
+ // strict equality there would fail on a difference the author cannot see.
494
+ case "eq":
495
+ return asText(left) === asText(right);
496
+ case "neq":
497
+ return asText(left) !== asText(right);
498
+ case "contains":
499
+ return asText(left).includes(asText(right));
500
+ case "not_contains":
501
+ return !asText(left).includes(asText(right));
502
+ case "gt":
503
+ return numeric((a, b) => a > b);
504
+ case "gte":
505
+ return numeric((a, b) => a >= b);
506
+ case "lt":
507
+ return numeric((a, b) => a < b);
508
+ case "lte":
509
+ return numeric((a, b) => a <= b);
510
+ case "truthy":
511
+ return truthy(left);
512
+ case "falsy":
513
+ return !truthy(left);
514
+ case "empty":
515
+ return isEmpty(left);
516
+ case "not_empty":
517
+ return !isEmpty(left);
518
+ default:
519
+ return false;
520
+ }
521
+ }
522
+ var branchExecutor = (ctx) => {
523
+ const config = configOf(ctx.node);
524
+ const inputs = ctx.inputs;
525
+ let taken;
526
+ const raw = config.condition;
527
+ if (typeof raw === "string" && raw.trim() !== "") {
528
+ taken = truthy(resolve(raw, inputs));
529
+ } else {
530
+ const rows = Array.isArray(config.conditions) ? config.conditions : [];
531
+ if (rows.length === 0) {
532
+ taken = false;
533
+ } else {
534
+ const results = rows.map((row) => conditionHolds(row, inputs));
535
+ taken = config.match === "any" ? results.some(Boolean) : results.every(Boolean);
536
+ }
537
+ }
538
+ return { __port: taken ? "true" : "false", value: ctx.inputs.in ?? ctx.inputs };
539
+ };
540
+ var transformExecutor = (ctx) => {
541
+ const config = configOf(ctx.node);
542
+ const inputs = ctx.inputs;
543
+ const passthrough = ctx.inputs.in ?? ctx.inputs;
544
+ if (config.mode === "expression") {
545
+ const expression = config.expression;
546
+ if (typeof expression !== "string" || expression.trim() === "") return passthrough;
547
+ return resolve(expression, inputs);
548
+ }
549
+ const rows = Array.isArray(config.fields) ? config.fields : [];
550
+ if (rows.length === 0) return passthrough;
551
+ const out = {};
552
+ let wrote = false;
553
+ for (const row of rows) {
554
+ const key = typeof row.key === "string" ? row.key.trim() : "";
555
+ if (key === "") continue;
556
+ out[key] = resolve(row.value, inputs);
557
+ wrote = true;
558
+ }
559
+ return wrote ? out : passthrough;
560
+ };
561
+ var mergeExecutor = (ctx) => {
562
+ const entries = Object.entries(ctx.inputs);
563
+ if (configOf(ctx.node).mode === "concat") {
564
+ const out = [];
565
+ for (const [, value] of entries) {
566
+ if (value === null || value === void 0) continue;
567
+ if (Array.isArray(value)) out.push(...value);
568
+ else out.push(value);
569
+ }
570
+ return out;
571
+ }
572
+ const merged = {};
573
+ for (const [port, value] of entries) {
574
+ if (value === null || value === void 0) continue;
575
+ if (typeof value === "object" && !Array.isArray(value)) Object.assign(merged, value);
576
+ else merged[port] = value;
577
+ }
578
+ return merged;
579
+ };
580
+ var forEachExecutor = (ctx) => {
581
+ const source = resolve(configOf(ctx.node).source, ctx.inputs);
582
+ let items;
583
+ if (Array.isArray(source)) items = source;
584
+ else if (source === null || source === void 0) items = [];
585
+ else if (typeof source === "object") items = Object.values(source);
586
+ else items = [source];
587
+ return { items, count: items.length };
588
+ };
589
+
590
+ // src/registry/terminal-nodes.ts
591
+ var DEFAULT_TIMEOUT_MS = 12e4;
592
+ var ENTER = "\r";
593
+ function configOf2(node) {
594
+ return node?.data?.config ?? {};
595
+ }
596
+ function text2(config, key) {
597
+ const value = config[key];
598
+ return typeof value === "string" ? value : "";
599
+ }
600
+ function millis(config, key, fallback) {
601
+ const value = Number(config[key]);
602
+ return Number.isFinite(value) && value >= 0 ? value : fallback;
603
+ }
604
+ function exitMarker(nonce) {
605
+ const token = `__fancy_flow_exit_${nonce}__`;
606
+ return {
607
+ token,
608
+ // The expanded status is required to be digits. The shell ECHOES the typed
609
+ // command back first, and that echo contains the marker with `$?`
610
+ // unexpanded — so requiring digits is what stops the node matching its own
611
+ // command line and reporting success before anything has run.
612
+ pattern: new RegExp(`${token}:(\\d+)`)
613
+ };
614
+ }
615
+ var counter = 0;
616
+ function newNonce() {
617
+ counter += 1;
618
+ return `${Date.now().toString(36)}${counter.toString(36)}${Math.random().toString(36).slice(2, 8)}`;
619
+ }
620
+ function withoutMarker(output, token) {
621
+ return output.split("\n").filter((line) => !line.includes(token)).join("\n").trim();
622
+ }
623
+ function fail(ctx, message) {
624
+ ctx.abort(message);
625
+ throw new Error(message);
626
+ }
627
+ function requireTerminal(ctx) {
628
+ if (!ctx.terminal) {
629
+ fail(
630
+ ctx,
631
+ `"${String(ctx.node.data?.label ?? ctx.node.id)}" is a terminal node but is not inside a terminal lane. Drag it into one \u2014 a terminal node outside a lane has no session to talk to, and opening a private shell for it would defeat the point of the lane.`
632
+ );
633
+ }
634
+ return ctx.terminal;
635
+ }
636
+ function compilePattern(ctx, raw, config) {
637
+ const flags = typeof config.flags === "string" ? config.flags.replace(/[gy]/g, "") : "";
638
+ try {
639
+ return config.mode === "regex" ? new RegExp(raw, flags) : new RegExp(raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), flags);
640
+ } catch (e) {
641
+ return fail(ctx, `terminal_await has an invalid regex: ${e instanceof Error ? e.message : String(e)}`);
642
+ }
643
+ }
644
+ var terminalRunExecutor = async (ctx) => {
645
+ const config = configOf2(ctx.node);
646
+ const command = text2(config, "command").trim();
647
+ if (!command) fail(ctx, "terminal_run has no command configured");
648
+ const terminal = requireTerminal(ctx);
649
+ const session = await terminal.session();
650
+ const transcript = await terminal.transcript();
651
+ const { token, pattern } = exitMarker(newNonce());
652
+ const timeoutMs = millis(config, "timeoutMs", DEFAULT_TIMEOUT_MS);
653
+ transcript.clear();
654
+ await session.write(`${command}; printf '${token}:%s\\n' "$?"${ENTER}`);
655
+ const result = await transcript.waitFor(pattern, { timeoutMs, exited: session.exited });
656
+ if (result.status === "exited") {
657
+ fail(
658
+ ctx,
659
+ `The terminal exited (code ${result.exitCode}${result.signal ? `, signal ${result.signal}` : ""}) while running "${command}". The lane's session is gone, so every later node in this lane would fail too.`
660
+ );
661
+ }
662
+ if (result.status === "timeout") {
663
+ fail(
664
+ ctx,
665
+ `"${command}" did not finish within ${timeoutMs}ms. Raise the node's timeout if it is genuinely slow \u2014 but a command that waits for input never finishes at all, and needs terminal_send + terminal_await.`
666
+ );
667
+ }
668
+ const exitCode = Number(result.match[1]);
669
+ const output = withoutMarker(result.text, token);
670
+ const failOnNonZero = config.failOnNonZero !== false;
671
+ if (failOnNonZero && exitCode !== 0) {
672
+ ctx.abort(`"${command}" exited ${exitCode}.
673
+ ${output}`);
674
+ }
675
+ return { output, exitCode, command };
676
+ };
677
+ var terminalSendExecutor = async (ctx) => {
678
+ const config = configOf2(ctx.node);
679
+ const terminal = requireTerminal(ctx);
680
+ const session = await terminal.session();
681
+ const body = text2(config, "text");
682
+ const submit = config.submit !== false;
683
+ if (config.clearFirst === true) {
684
+ (await terminal.transcript()).clear();
685
+ }
686
+ await session.write(submit ? `${body}${ENTER}` : body);
687
+ return { sent: body, submitted: submit };
688
+ };
689
+ var terminalAwaitExecutor = async (ctx) => {
690
+ const config = configOf2(ctx.node);
691
+ const terminal = requireTerminal(ctx);
692
+ const session = await terminal.session();
693
+ const transcript = await terminal.transcript();
694
+ const raw = text2(config, "pattern").trim();
695
+ if (!raw) fail(ctx, "terminal_await has no pattern configured \u2014 there is nothing for it to wait for");
696
+ const pattern = compilePattern(ctx, raw, config);
697
+ const timeoutMs = millis(config, "timeoutMs", DEFAULT_TIMEOUT_MS);
698
+ const result = await transcript.waitFor(pattern, {
699
+ timeoutMs,
700
+ exited: session.exited
701
+ });
702
+ if (result.status === "exited") {
703
+ fail(
704
+ ctx,
705
+ `The terminal exited (code ${result.exitCode}${result.signal ? `, signal ${result.signal}` : ""}) while waiting for ${JSON.stringify(raw)}. It never appeared, and the lane's session is gone.`
706
+ );
707
+ }
708
+ if (result.status === "timeout") {
709
+ if (config.onTimeout === "continue") {
710
+ ctx.emit({
711
+ type: "log",
712
+ nodeId: ctx.node.id,
713
+ level: "warn",
714
+ message: `terminal_await: ${JSON.stringify(raw)} did not appear within ${timeoutMs}ms; continuing as configured.`
715
+ });
716
+ return { matched: false, output: result.text, groups: [] };
717
+ }
718
+ fail(
719
+ ctx,
720
+ `${JSON.stringify(raw)} did not appear within ${timeoutMs}ms. Last output:
721
+ ${result.text.slice(-2e3)}`
722
+ );
723
+ }
724
+ return {
725
+ matched: true,
726
+ output: result.text,
727
+ matchedText: result.match[0],
728
+ // Capture groups are the reason to use regex mode at all — a prompt that
729
+ // reports a session id or a file path is only useful if the value comes out.
730
+ groups: result.match.slice(1)
731
+ };
732
+ };
733
+
734
+ // src/registry/builtin-kinds.ts
735
+ var HUMAN_FIELD_OUTPUT_TYPE = {
736
+ text: "string",
737
+ textarea: "string",
738
+ select: "string",
739
+ date: "string",
740
+ datetime: "string",
741
+ time: "string",
742
+ email: "string",
743
+ url: "string",
744
+ tel: "string",
745
+ password: "string",
746
+ number: "number",
747
+ switch: "boolean"
748
+ };
749
+ function casePorts(cases) {
750
+ const byPort = /* @__PURE__ */ new Map();
751
+ if (cases && typeof cases === "object" && !Array.isArray(cases)) {
752
+ for (const [match, port] of Object.entries(cases)) {
753
+ if (typeof port !== "string" || port === "" || port === "default") continue;
754
+ const matches = byPort.get(port) ?? [];
755
+ matches.push(match);
756
+ byPort.set(port, matches);
757
+ }
758
+ }
759
+ const ports = [...byPort].map(([id, matches]) => ({
760
+ id,
761
+ label: matches.join("|")
762
+ }));
763
+ return [...ports, { id: "default", label: "default" }];
764
+ }
765
+ function routePorts(routes, fallback) {
766
+ const ports = [];
767
+ const seen = /* @__PURE__ */ new Set();
768
+ if (Array.isArray(routes)) {
769
+ for (const route of routes) {
770
+ const id = route?.port;
771
+ if (typeof id !== "string" || id.trim() === "" || seen.has(id)) continue;
772
+ seen.add(id);
773
+ ports.push({ id, label: id });
774
+ }
775
+ }
776
+ if (fallback !== false && !seen.has("fallback")) {
777
+ ports.push({ id: "fallback", label: "fallback" });
778
+ }
779
+ if (ports.length === 0) ports.push({ id: "out" });
780
+ return ports;
781
+ }
782
+ var HTTP_METHODS = [
783
+ { type: "select", key: "method", label: "Method", options: [
784
+ { value: "GET", label: "GET" },
785
+ { value: "POST", label: "POST" },
786
+ { value: "PUT", label: "PUT" },
787
+ { value: "PATCH", label: "PATCH" },
788
+ { value: "DELETE", label: "DELETE" }
789
+ ], default: "GET", required: true }
790
+ ];
791
+ var KINDS = [
792
+ // ───────────── Triggers ─────────────
793
+ {
794
+ name: "@particle-academy/manual_trigger",
795
+ // Returns the raw inputs MAP, not the `in` port -- flat at an entry
796
+ // point, port-keyed the moment the node has an inbound edge.
797
+ emits: "input-map-merged",
798
+ aliases: ["manual_trigger", "@fancy/manual_trigger"],
799
+ category: "trigger",
800
+ label: "Manual",
801
+ description: "Entry point fired when the user clicks Run.",
802
+ icon: "\u26A1",
803
+ inputs: [],
804
+ outputs: [{ id: "out" }]
805
+ },
806
+ {
807
+ name: "@particle-academy/webhook_trigger",
808
+ aliases: ["webhook_trigger", "@fancy/webhook_trigger"],
809
+ category: "trigger",
810
+ label: "Webhook",
811
+ description: "Triggered by an inbound HTTP request to a host-provided URL.",
812
+ icon: "\u{1F4E1}",
813
+ inputs: [],
814
+ outputs: [{ id: "out", label: "payload" }],
815
+ configSchema: [
816
+ { type: "text", key: "path", label: "Path", placeholder: "/hooks/my-flow", required: true },
817
+ { type: "select", key: "method", label: "Method", options: [
818
+ { value: "POST", label: "POST" },
819
+ { value: "GET", label: "GET" }
820
+ ], default: "POST" },
821
+ { type: "credential", key: "secret", label: "Verifying secret", credentialType: "webhook_secret" }
822
+ ]
823
+ },
824
+ {
825
+ name: "@particle-academy/schedule_trigger",
826
+ outputShape: [
827
+ { path: "cron", type: "string", description: "The cron expression that fired." },
828
+ { path: "timezone", type: "string", description: "The timezone it was evaluated in." }
829
+ ],
830
+ // Merges $ctx->inputs ITSELF beside cron/timezone -- the MAP, whose
831
+ // shape depends on position, not each port's payload the way `merge` does.
832
+ emits: "input-map-merged",
833
+ aliases: ["schedule_trigger", "@fancy/schedule_trigger"],
834
+ category: "trigger",
835
+ label: "Schedule",
836
+ description: "Fires on a cron schedule (host-implemented).",
837
+ icon: "\u23F1",
838
+ inputs: [],
839
+ outputs: [{ id: "out" }],
840
+ configSchema: [
841
+ {
842
+ type: "text",
843
+ key: "cron",
844
+ label: "Cron",
845
+ placeholder: "*/5 * * * *",
846
+ required: true,
847
+ description: "Standard 5-field cron expression."
848
+ },
849
+ { type: "text", key: "timezone", label: "Timezone", placeholder: "UTC", default: "UTC" }
850
+ ]
851
+ },
852
+ {
853
+ name: "@particle-academy/user_input",
854
+ // The keys an author declared on THIS node — the case a static list cannot
855
+ // express, and the one issue #5 named.
856
+ //
857
+ // The TYPE comes from the same normalizer the form renders with, so the
858
+ // variable picker agrees with what the run actually resolves. It used to say
859
+ // `string` for every field, which was wrong the moment a field was a number
860
+ // or a switch — the picker told an author `{{ $json.age }}` was text while
861
+ // the form handed the next node a number.
862
+ outputShape: (config) => (config.fields ?? []).filter((f) => typeof f.key === "string" && f.key !== "").map((f) => ({
863
+ path: f.key,
864
+ type: HUMAN_FIELD_OUTPUT_TYPE[humanFieldType(f.type)],
865
+ description: f.label
866
+ })),
867
+ aliases: ["user_input", "@fancy/user_input"],
868
+ pausesForHuman: "input",
869
+ category: "human",
870
+ label: "User Input",
871
+ description: "Pause the flow until the user submits the configured form.",
872
+ icon: "\u270E",
873
+ inputs: [{ id: "in" }],
874
+ outputs: [{ id: "out", label: "values" }],
875
+ configSchema: [
876
+ { type: "text", key: "title", label: "Form title", default: "Need your input" },
877
+ {
878
+ type: "repeater",
879
+ key: "fields",
880
+ label: "Fields",
881
+ description: "The form the run pauses on.",
882
+ titleKey: "label",
883
+ addLabel: "Add field",
884
+ minItems: 1,
885
+ fields: [
886
+ { type: "text", key: "key", label: "Key", required: true, placeholder: "answer" },
887
+ { type: "text", key: "label", label: "Label", required: true, placeholder: "Your answer" },
888
+ {
889
+ type: "select",
890
+ key: "type",
891
+ label: "Type",
892
+ default: "text",
893
+ // Every control `HumanPrompt` can render. A type the form supports
894
+ // but the panel cannot select is reachable only by hand-editing the
895
+ // workflow JSON — which is the audience this panel exists for.
896
+ options: [
897
+ { value: "text", label: "Text" },
898
+ { value: "textarea", label: "Long text" },
899
+ { value: "number", label: "Number" },
900
+ { value: "select", label: "Select" },
901
+ { value: "switch", label: "Switch (yes / no)" },
902
+ { value: "date", label: "Date" },
903
+ { value: "datetime", label: "Date + time" },
904
+ { value: "time", label: "Time" },
905
+ { value: "email", label: "Email" },
906
+ { value: "url", label: "URL" },
907
+ { value: "tel", label: "Phone" },
908
+ { value: "password", label: "Password" }
909
+ ]
910
+ },
911
+ {
912
+ // Without this a panel-authored select had nothing to choose from,
913
+ // so it rendered an empty dropdown — the type was declarable and
914
+ // unusable in the same breath.
915
+ type: "keyvalue",
916
+ key: "options",
917
+ label: "Choices",
918
+ description: "Select fields only. Left is the value the flow receives, right is what the person sees.",
919
+ keyLabel: "Value",
920
+ valueLabel: "Label",
921
+ keyPlaceholder: "small",
922
+ valuePlaceholder: "Small",
923
+ addLabel: "Add choice"
924
+ },
925
+ { type: "switch", key: "required", label: "Required", default: false }
926
+ ],
927
+ default: [{ key: "answer", label: "Your answer", type: "textarea", required: true }]
928
+ }
929
+ ]
930
+ },
931
+ {
932
+ name: "@particle-academy/rich_user_input",
933
+ aliases: ["rich_user_input", "@fancy/rich_user_input"],
934
+ pausesForHuman: "input",
935
+ category: "human",
936
+ label: "Rich User Input",
937
+ description: "Pause the flow on a fully authored page \u2014 content, required reading, multi-section forms.",
938
+ icon: "\u25A4",
939
+ inputs: [{ id: "in" }],
940
+ outputs: [{ id: "out", label: "values" }],
941
+ configSchema: [
942
+ { type: "text", key: "title", label: "Step title", default: "Please review" },
943
+ {
944
+ type: "document",
945
+ key: "document",
946
+ label: "Page content",
947
+ documentType: "stages",
948
+ description: "Authored with the host's document editor (fancy-cms Stages)."
949
+ },
950
+ { type: "switch", key: "requireConfirm", label: "Require explicit confirmation", default: true },
951
+ { type: "text", key: "submitLabel", label: "Submit button", default: "Continue" }
952
+ ]
953
+ },
954
+ {
955
+ name: "@particle-academy/subflow",
956
+ aliases: ["subflow", "@fancy/subflow"],
957
+ category: "logic",
958
+ label: "SubFlow",
959
+ description: "Run another workflow and bring its result \u2014 or its live progress \u2014 back into this one.",
960
+ icon: "\u29C9",
961
+ inputs: [{ id: "in" }],
962
+ // The stream port only exists when something actually streams.
963
+ outputs: (config) => subflowPorts(config ?? {}),
964
+ // Core, not marketplace: it runs a child graph through this same engine and
965
+ // needs nothing from outside except where workflows live.
966
+ executor: subflowExecutor,
967
+ configSchema: [
968
+ {
969
+ type: "text",
970
+ key: "workflow",
971
+ label: "Workflow",
972
+ required: true,
973
+ placeholder: "onboarding-v2",
974
+ description: "Reference resolved by the host's registerWorkflowResolver()."
975
+ },
976
+ {
977
+ type: "number",
978
+ key: "version",
979
+ label: "Pin to version",
980
+ description: "Optional. Leave blank to always run the child's current version. Pinning fails the run loudly if the child has moved on \u2014 without it, someone edits the child and this flow silently runs different logic."
981
+ },
982
+ {
983
+ type: "select",
984
+ key: "mode",
985
+ label: "Return",
986
+ default: "output",
987
+ options: [
988
+ { value: "output", label: "Output when it finishes" },
989
+ { value: "stream", label: "Stream progress as it runs" },
990
+ { value: "both", label: "Both \u2014 stream, then output" }
991
+ ],
992
+ description: "Streaming adds a second port so a parent can show progress instead of a spinner."
993
+ },
994
+ {
995
+ type: "keyvalue",
996
+ key: "inputs",
997
+ label: "Input mapping",
998
+ description: "Values handed to the child's entry points. Omit to pass this node's inputs straight through.",
999
+ keyLabel: "Name",
1000
+ valueLabel: "Value",
1001
+ addLabel: "Add input"
1002
+ },
1003
+ {
1004
+ type: "number",
1005
+ key: "maxDepth",
1006
+ label: "Max nesting depth",
1007
+ default: DEFAULT_MAX_DEPTH,
1008
+ min: 1,
1009
+ max: 32,
1010
+ description: "Guards against a workflow referencing itself."
1011
+ }
1012
+ ]
1013
+ },
1014
+ // ───────────── Logic ─────────────
1015
+ {
1016
+ name: "@particle-academy/branch",
1017
+ // returns its input on the chosen port
1018
+ emits: "input",
1019
+ aliases: ["branch", "@fancy/branch"],
1020
+ // Pure logic: no I/O to choose, so a default is safe. A host
1021
+ // executor still wins -- pickExecutor consults the registry first.
1022
+ executor: branchExecutor,
1023
+ category: "logic",
1024
+ label: "Branch",
1025
+ description: "Multi-way branch on a condition or value.",
1026
+ icon: "\u25C7",
1027
+ inputs: [{ id: "in" }],
1028
+ outputs: [{ id: "true", label: "true" }, { id: "false", label: "false" }],
1029
+ configSchema: [
1030
+ {
1031
+ type: "select",
1032
+ key: "match",
1033
+ label: "Match",
1034
+ default: "all",
1035
+ options: [
1036
+ { value: "all", label: "All conditions (AND)" },
1037
+ { value: "any", label: "Any condition (OR)" }
1038
+ ]
1039
+ },
1040
+ {
1041
+ type: "repeater",
1042
+ key: "conditions",
1043
+ label: "Conditions",
1044
+ description: "Routes to `true` when these match, otherwise `false`.",
1045
+ titleKey: "left",
1046
+ addLabel: "Add condition",
1047
+ minItems: 1,
1048
+ fields: [
1049
+ { type: "expression", key: "left", label: "Value", example: "{{ $json.status }}", required: true },
1050
+ {
1051
+ type: "select",
1052
+ key: "operator",
1053
+ label: "Is",
1054
+ default: "eq",
1055
+ options: [
1056
+ { value: "eq", label: "equal to" },
1057
+ { value: "neq", label: "not equal to" },
1058
+ { value: "contains", label: "contains" },
1059
+ { value: "not_contains", label: "does not contain" },
1060
+ { value: "gt", label: "greater than" },
1061
+ { value: "gte", label: "greater than or equal to" },
1062
+ { value: "lt", label: "less than" },
1063
+ { value: "lte", label: "less than or equal to" },
1064
+ { value: "truthy", label: "true" },
1065
+ { value: "falsy", label: "false" },
1066
+ { value: "empty", label: "empty" },
1067
+ { value: "not_empty", label: "not empty" }
1068
+ ]
1069
+ },
1070
+ { type: "text", key: "right", label: "Compared to", placeholder: "active" }
1071
+ ],
1072
+ default: [{ left: "", operator: "eq", right: "" }]
1073
+ },
1074
+ {
1075
+ type: "expression",
1076
+ key: "condition",
1077
+ label: "Raw expression (advanced)",
1078
+ example: "{{ $json.active && $json.score > 10 }}",
1079
+ description: "Escape hatch for logic the builder can't express. Overrides the conditions above when set."
1080
+ }
1081
+ ]
1082
+ },
1083
+ {
1084
+ name: "@particle-academy/switch_case",
1085
+ // returns its input on the chosen port
1086
+ emits: "input",
1087
+ aliases: ["switch_case", "@fancy/switch_case"],
1088
+ category: "logic",
1089
+ label: "Switch",
1090
+ description: "Route to one of N labelled outputs based on a key.",
1091
+ icon: "\u2933",
1092
+ inputs: [{ id: "in" }],
1093
+ // Ports ARE the config: every distinct port a case routes to becomes an
1094
+ // output handle, plus the always-present `default`. Editing the cases map
1095
+ // moves the ports on the canvas and the ports the runtime activates.
1096
+ outputs: (config) => casePorts(config?.cases),
1097
+ configSchema: [
1098
+ { type: "expression", key: "value", label: "Switch on", example: "{{ $json.kind }}", required: true },
1099
+ {
1100
+ type: "keyvalue",
1101
+ key: "cases",
1102
+ label: "Cases",
1103
+ description: "Match value \u2192 output port. Unmatched input takes `default`.",
1104
+ keyLabel: "When value is",
1105
+ valueLabel: "Route to port",
1106
+ keyPlaceholder: "a",
1107
+ valuePlaceholder: "case_a",
1108
+ addLabel: "Add case",
1109
+ default: { a: "case_a", b: "case_b" }
1110
+ }
1111
+ ]
1112
+ },
1113
+ {
1114
+ name: "@particle-academy/for_each",
1115
+ outputShape: [
1116
+ { path: "items", type: "array" },
1117
+ { path: "count", type: "number" }
1118
+ ],
1119
+ aliases: ["for_each", "@fancy/for_each"],
1120
+ // Pure logic: no I/O to choose, so a default is safe. A host
1121
+ // executor still wins -- pickExecutor consults the registry first.
1122
+ executor: forEachExecutor,
1123
+ category: "logic",
1124
+ label: "For Each",
1125
+ description: "Iterate over a list, emitting each item on `item`.",
1126
+ icon: "\u21BB",
1127
+ inputs: [{ id: "in" }],
1128
+ outputs: [{ id: "item", label: "item" }, { id: "done", label: "done" }],
1129
+ configSchema: [
1130
+ { type: "expression", key: "source", label: "List", example: "{{ $json.users }}", required: true },
1131
+ { type: "number", key: "concurrency", label: "Concurrency", default: 1, min: 1, max: 50 }
1132
+ ]
1133
+ },
1134
+ {
1135
+ name: "@particle-academy/merge",
1136
+ // `merge` mode unions every object input at the TOP level; `concat` builds
1137
+ // a list instead, whose elements are not addressable as fields. So concat
1138
+ // declares NOTHING rather than an empty list -- `[]` would claim "emits no
1139
+ // fields" of a kind that emits a list, which is false and refuses every
1140
+ // reference.
1141
+ emits: (config) => (config?.mode ?? "merge") === "concat" ? null : "inputs-merged",
1142
+ aliases: ["merge", "@fancy/merge"],
1143
+ // Pure logic: no I/O to choose, so a default is safe. A host
1144
+ // executor still wins -- pickExecutor consults the registry first.
1145
+ executor: mergeExecutor,
1146
+ category: "logic",
1147
+ label: "Merge",
1148
+ description: "Combine multiple inputs into one object or array.",
1149
+ icon: "\u2295",
1150
+ inputs: [{ id: "a" }, { id: "b" }],
1151
+ outputs: [{ id: "out" }],
1152
+ configSchema: [
1153
+ {
1154
+ type: "select",
1155
+ key: "mode",
1156
+ label: "Mode",
1157
+ default: "merge",
1158
+ options: [{ value: "merge", label: "Object merge" }, { value: "concat", label: "Array concat" }]
1159
+ }
1160
+ ]
1161
+ },
1162
+ {
1163
+ name: "@particle-academy/wait",
1164
+ outputShape: [
1165
+ { path: "waited", type: "string", description: "Which wait mode ran." },
1166
+ { path: "duration", type: "number", description: "How long it waited." },
1167
+ { path: "input", type: "unknown", description: "The value that arrived, carried forward." }
1168
+ ],
1169
+ aliases: ["wait", "@fancy/wait"],
1170
+ category: "logic",
1171
+ label: "Wait",
1172
+ description: "Sleep or wait for an external event.",
1173
+ icon: "\u23F8",
1174
+ configSchema: [
1175
+ {
1176
+ type: "select",
1177
+ key: "mode",
1178
+ label: "Mode",
1179
+ default: "duration",
1180
+ options: [{ value: "duration", label: "Duration" }, { value: "until", label: "Until timestamp" }, { value: "event", label: "External event" }]
1181
+ },
1182
+ { type: "text", key: "duration", label: "Duration", placeholder: "5s, 10m, 1h", description: "Used when mode = duration." }
1183
+ ]
1184
+ },
1185
+ {
1186
+ name: "@particle-academy/transform",
1187
+ // Two behaviours: the input unchanged when no expression is configured,
1188
+ // else the shape that expression names. So the RELATION is config-dependent.
1189
+ emits: (config) => (config?.expression ?? "") === "" ? "input" : "expression:expression",
1190
+ aliases: ["transform", "@fancy/transform"],
1191
+ // Pure logic: no I/O to choose, so a default is safe. A host
1192
+ // executor still wins -- pickExecutor consults the registry first.
1193
+ executor: transformExecutor,
1194
+ category: "logic",
1195
+ label: "Transform",
1196
+ description: "Reshape data with an expression.",
1197
+ icon: "\u0192",
1198
+ configSchema: [
1199
+ {
1200
+ type: "select",
1201
+ key: "mode",
1202
+ label: "Build the output",
1203
+ default: "fields",
1204
+ options: [
1205
+ { value: "fields", label: "Field by field" },
1206
+ { value: "expression", label: "One expression" }
1207
+ ]
1208
+ },
1209
+ {
1210
+ type: "repeater",
1211
+ key: "fields",
1212
+ label: "Output fields",
1213
+ description: "Each row becomes a key on the result.",
1214
+ titleKey: "key",
1215
+ addLabel: "Add field",
1216
+ fields: [
1217
+ { type: "text", key: "key", label: "Key", required: true, placeholder: "name" },
1218
+ { type: "expression", key: "value", label: "Value", example: "{{ $json.first }}", required: true }
1219
+ ],
1220
+ default: [{ key: "", value: "" }]
1221
+ },
1222
+ {
1223
+ type: "expression",
1224
+ key: "expression",
1225
+ label: "Expression (advanced)",
1226
+ example: "{{ { id: $json.id, name: $json.first + ' ' + $json.last } }}",
1227
+ description: "Used when the mode above is set to one expression."
1228
+ }
1229
+ ]
1230
+ },
1231
+ // ───────────── Data ─────────────
1232
+ {
1233
+ name: "@particle-academy/memory_store",
1234
+ aliases: ["memory_store", "@fancy/memory_store"],
1235
+ category: "data",
1236
+ label: "Memory Store",
1237
+ description: "Read or write per-conversation memory.",
1238
+ icon: "\u{1F9E0}",
1239
+ configSchema: [
1240
+ {
1241
+ type: "select",
1242
+ key: "operation",
1243
+ label: "Operation",
1244
+ required: true,
1245
+ default: "read",
1246
+ options: [{ value: "read", label: "Read" }, { value: "write", label: "Write" }, { value: "append", label: "Append" }]
1247
+ },
1248
+ { type: "text", key: "key", label: "Key", placeholder: "user.preferences", required: true },
1249
+ { type: "expression", key: "value", label: "Value (write/append only)", example: "{{ $json }}" },
1250
+ { type: "credential", key: "store", label: "Memory store", credentialType: "memory_store" }
1251
+ ]
1252
+ },
1253
+ {
1254
+ name: "@particle-academy/data_store",
1255
+ aliases: ["data_store", "@fancy/data_store"],
1256
+ category: "data",
1257
+ label: "Data Store",
1258
+ description: "Key-value or table read/write against a host store.",
1259
+ icon: "\u{1F5C3}",
1260
+ configSchema: [
1261
+ {
1262
+ type: "select",
1263
+ key: "operation",
1264
+ label: "Operation",
1265
+ required: true,
1266
+ default: "get",
1267
+ options: [
1268
+ { value: "get", label: "Get" },
1269
+ { value: "set", label: "Set" },
1270
+ { value: "delete", label: "Delete" },
1271
+ { value: "query", label: "Query" },
1272
+ { value: "list", label: "List" }
1273
+ ]
1274
+ },
1275
+ { type: "text", key: "table", label: "Table / collection", required: true },
1276
+ { type: "text", key: "key", label: "Key" },
1277
+ {
1278
+ type: "keyvalue",
1279
+ key: "where",
1280
+ label: "Where",
1281
+ description: "Field/value pairs to match. For query and list operations.",
1282
+ keyLabel: "Field",
1283
+ valueLabel: "Equals",
1284
+ addLabel: "Add filter"
1285
+ },
1286
+ { type: "expression", key: "value", label: "Value (set only)", example: "{{ $json }}" },
1287
+ { type: "credential", key: "store", label: "Data store", credentialType: "data_store" }
1288
+ ]
1289
+ },
1290
+ {
1291
+ name: "@particle-academy/variable",
1292
+ // evaluates the expression in config.value
1293
+ emits: "expression:value",
1294
+ aliases: ["variable", "@fancy/variable"],
1295
+ category: "data",
1296
+ label: "Variable",
1297
+ description: "Workflow-scoped value used by other nodes.",
1298
+ icon: "\u{1D4CD}",
1299
+ configSchema: [
1300
+ { type: "text", key: "name", label: "Name", required: true },
1301
+ { type: "expression", key: "value", label: "Value", required: true }
1302
+ ]
1303
+ },
1304
+ // ───────────── AI ─────────────
1305
+ {
1306
+ name: "@particle-academy/llm_call",
1307
+ // LlmClient::complete() -> array{text:string,data?:mixed,usage?:array,raw?:mixed}
1308
+ //
1309
+ // A FUNCTION of config, because `data` exists only when this node was asked
1310
+ // for a schema. Declaring it statically would offer `{{ $json.data }}` on
1311
+ // every llm_call in the kit, including the ones that emit nothing of the
1312
+ // sort — and a suggestion that resolves to null at runtime is worse than no
1313
+ // suggestion, since the picker is exactly what gave the author confidence.
1314
+ outputShape: (config) => {
1315
+ const asked = config?.response_schema != null && config.response_schema !== "" && !(Array.isArray(config.response_schema) && config.response_schema.length === 0);
1316
+ return [
1317
+ { path: "text", type: "string", description: "The model's completion." },
1318
+ ...asked ? [{ path: "data", type: "unknown", description: "The parsed, schema-checked result." }] : [],
1319
+ { path: "usage", type: "object", description: "Token counts, when the provider reports them." },
1320
+ { path: "raw", type: "unknown", description: "The provider's untouched response." }
1321
+ ];
1322
+ },
1323
+ aliases: ["llm_call", "@fancy/llm_call"],
1324
+ category: "ai",
1325
+ label: "LLM Call",
1326
+ description: "Send a prompt + context to a model and receive a response.",
1327
+ icon: "\u2726",
1328
+ configSchema: [
1329
+ {
1330
+ type: "select",
1331
+ key: "provider",
1332
+ label: "Provider",
1333
+ default: "anthropic",
1334
+ options: [
1335
+ { value: "anthropic", label: "Anthropic" },
1336
+ { value: "openai", label: "OpenAI" },
1337
+ { value: "custom", label: "Custom" }
1338
+ ]
1339
+ },
1340
+ { type: "text", key: "model", label: "Model", placeholder: "claude-sonnet-4-5", required: true },
1341
+ { type: "textarea", key: "system", label: "System prompt", rows: 4 },
1342
+ { type: "expression", key: "prompt", label: "User prompt", example: "{{ $json.question }}", required: true },
1343
+ { type: "number", key: "temperature", label: "Temperature", min: 0, max: 2, step: 0.1, default: 0.7 },
1344
+ { type: "number", key: "max_tokens", label: "Max tokens", min: 1, max: 8192, default: 1024 },
1345
+ {
1346
+ type: "repeater",
1347
+ key: "tools",
1348
+ label: "Tools",
1349
+ description: "Tools the model may call.",
1350
+ titleKey: "name",
1351
+ addLabel: "Add tool",
1352
+ fields: [
1353
+ { type: "text", key: "name", label: "Name", required: true, placeholder: "search_index" },
1354
+ { type: "text", key: "description", label: "When to use it" },
1355
+ {
1356
+ type: "json",
1357
+ key: "input_schema",
1358
+ label: "Input schema",
1359
+ description: "JSON Schema for the tool's arguments."
1360
+ }
1361
+ ]
1362
+ },
1363
+ {
1364
+ type: "json",
1365
+ key: "response_schema",
1366
+ label: "Response schema",
1367
+ description: "Optional JSON Schema. When set, the runtime asks the provider for schema-valid JSON and the node emits the parsed value as `data` \u2014 reference it as {{ $json.data }}. A response that cannot be parsed or does not match FAILS the node rather than passing an empty value downstream."
1368
+ },
1369
+ { type: "credential", key: "credential", label: "API credential", credentialType: "llm_credential" }
1370
+ ]
1371
+ },
1372
+ {
1373
+ name: "@particle-academy/llm_router",
1374
+ outputShape: [
1375
+ { path: "route", type: "string", description: "The port the model chose." },
1376
+ { path: "reason", type: "string", description: "Why the model chose it." },
1377
+ { path: "input", type: "unknown", description: "The value that arrived, carried forward." }
1378
+ ],
1379
+ // Every id this node has ever shipped under keeps resolving — MOIC's saved
1380
+ // flows carry the bare `llm_branch`.
1381
+ aliases: ["llm_router", "llm_branch", "@fancy/llm_branch", "@fancy/llm_router"],
1382
+ category: "ai",
1383
+ label: "LLM Router",
1384
+ description: "Let a model choose which route the flow takes.",
1385
+ icon: "\u2727",
1386
+ inputs: [{ id: "in" }],
1387
+ // Each declared route is a port. The executor returns `{ __port: id }`
1388
+ // (or `Port.only(id)` on the PHP runtime) to pick one.
1389
+ outputs: (config) => routePorts(config?.routes, config?.fallback),
1390
+ // A shuttle, not an engine: it carries the routes out to whatever LLM
1391
+ // client the host registered and carries the choice back. No provider SDK
1392
+ // reaches core, so this stays a builtin without adding a dependency.
1393
+ executor: llmRouterExecutor,
1394
+ configSchema: [
1395
+ {
1396
+ type: "textarea",
1397
+ key: "system",
1398
+ label: "System prompt",
1399
+ rows: 3,
1400
+ description: "Optional framing for the routing decision."
1401
+ },
1402
+ {
1403
+ type: "expression",
1404
+ key: "prompt",
1405
+ label: "What to route on",
1406
+ example: "{{ $json.message }}",
1407
+ required: true
1408
+ },
1409
+ {
1410
+ type: "repeater",
1411
+ key: "routes",
1412
+ label: "Routes",
1413
+ description: "The model picks exactly one. Descriptions are what it chooses between \u2014 make them distinct.",
1414
+ titleKey: "port",
1415
+ addLabel: "Add route",
1416
+ minItems: 2,
1417
+ fields: [
1418
+ { type: "text", key: "port", label: "Port", required: true, placeholder: "billing" },
1419
+ {
1420
+ type: "text",
1421
+ key: "description",
1422
+ label: "When to choose it",
1423
+ required: true,
1424
+ placeholder: "The user is asking about an invoice, refund, or payment."
1425
+ }
1426
+ ],
1427
+ default: [
1428
+ { port: "a", description: "Describe when the model should pick this route." },
1429
+ { port: "b", description: "Describe when the model should pick this route." }
1430
+ ]
1431
+ },
1432
+ {
1433
+ type: "select",
1434
+ key: "provider",
1435
+ label: "Provider",
1436
+ default: "anthropic",
1437
+ options: [
1438
+ { value: "anthropic", label: "Anthropic" },
1439
+ { value: "openai", label: "OpenAI" },
1440
+ { value: "custom", label: "Custom" }
1441
+ ]
1442
+ },
1443
+ { type: "text", key: "model", label: "Model", placeholder: "claude-sonnet-4-5" },
1444
+ {
1445
+ type: "switch",
1446
+ key: "fallback",
1447
+ label: "Add a `fallback` port",
1448
+ default: true,
1449
+ description: "Where the flow goes if the model returns no usable route."
1450
+ },
1451
+ { type: "credential", key: "credential", label: "API credential", credentialType: "llm_credential" }
1452
+ ]
1453
+ },
1454
+ {
1455
+ name: "@particle-academy/tool_use",
1456
+ aliases: ["tool_use", "@fancy/tool_use"],
1457
+ category: "ai",
1458
+ label: "Tool Use",
1459
+ description: "Hand control to a host-registered tool by name.",
1460
+ icon: "\u{1F6E0}",
1461
+ configSchema: [
1462
+ { type: "text", key: "tool", label: "Tool name", placeholder: "search_index", required: true },
1463
+ { type: "expression", key: "args", label: "Arguments", example: "{{ { query: $json.q } }}" }
1464
+ ]
1465
+ },
1466
+ {
1467
+ name: "@particle-academy/embed_search",
1468
+ outputShape: [
1469
+ { path: "query", type: "string" },
1470
+ { path: "matches", type: "array", description: "Ranked results from the vector store." }
1471
+ ],
1472
+ aliases: ["embed_search", "@fancy/embed_search"],
1473
+ category: "ai",
1474
+ label: "Embed & Search",
1475
+ description: "Embed a query and search a vector store.",
1476
+ icon: "\u273A",
1477
+ configSchema: [
1478
+ { type: "expression", key: "query", label: "Query", required: true, example: "{{ $json.question }}" },
1479
+ { type: "number", key: "topK", label: "Top K", default: 5, min: 1, max: 50 },
1480
+ { type: "credential", key: "vectorStore", label: "Vector store", credentialType: "vector_store" }
1481
+ ]
1482
+ },
1483
+ // ───────────── IO ─────────────
1484
+ {
1485
+ name: "@particle-academy/api_request",
1486
+ // HttpClient::send() -> array{status:int,headers:array,body:mixed}
1487
+ outputShape: [
1488
+ { path: "status", type: "number", description: "HTTP status code." },
1489
+ { path: "headers", type: "object" },
1490
+ { path: "body", type: "unknown", description: "Decoded JSON when the response is JSON, otherwise the raw body." }
1491
+ ],
1492
+ aliases: ["api_request", "@fancy/api_request"],
1493
+ category: "io",
1494
+ label: "API Request",
1495
+ description: "HTTP request to any URL.",
1496
+ icon: "\u2194",
1497
+ configSchema: [
1498
+ ...HTTP_METHODS,
1499
+ { type: "text", key: "url", label: "URL", placeholder: "https://api.example.com/...", required: true },
1500
+ {
1501
+ type: "keyvalue",
1502
+ key: "headers",
1503
+ label: "Headers",
1504
+ keyLabel: "Header",
1505
+ valueLabel: "Value",
1506
+ keyPlaceholder: "content-type",
1507
+ valuePlaceholder: "application/json",
1508
+ addLabel: "Add header",
1509
+ default: { "content-type": "application/json" }
1510
+ },
1511
+ { type: "json", key: "body", label: "Body" },
1512
+ { type: "credential", key: "auth", label: "Auth", credentialType: "api_credential" }
1513
+ ]
1514
+ },
1515
+ {
1516
+ name: "@particle-academy/webhook_out",
1517
+ outputShape: [
1518
+ { path: "sent", type: "boolean", description: "True once the request was made." },
1519
+ { path: "status", type: "number", description: "HTTP status, when the transport reported one." },
1520
+ { path: "response", type: "unknown", description: "The response body, when there was one." }
1521
+ ],
1522
+ aliases: ["webhook_out", "@fancy/webhook_out"],
1523
+ category: "io",
1524
+ label: "Send Webhook",
1525
+ description: "POST a payload to a configured URL.",
1526
+ icon: "\u2197",
1527
+ configSchema: [
1528
+ { type: "text", key: "url", label: "URL", required: true },
1529
+ {
1530
+ type: "keyvalue",
1531
+ key: "headers",
1532
+ label: "Headers",
1533
+ keyLabel: "Header",
1534
+ valueLabel: "Value",
1535
+ addLabel: "Add header"
1536
+ },
1537
+ { type: "expression", key: "payload", label: "Payload", required: true, example: "{{ $json }}" }
1538
+ ]
1539
+ },
1540
+ // ───────────── Human ─────────────
1541
+ {
1542
+ name: "@particle-academy/human_approval",
1543
+ // returns its input on approved/denied
1544
+ emits: "input",
1545
+ aliases: ["human_approval", "@fancy/human_approval"],
1546
+ pausesForHuman: "approval",
1547
+ category: "human",
1548
+ label: "Human Approval",
1549
+ description: "Pause until a human approves or denies.",
1550
+ icon: "\u2713",
1551
+ inputs: [{ id: "in" }],
1552
+ outputs: [{ id: "approved", label: "approved" }, { id: "denied", label: "denied" }],
1553
+ configSchema: [
1554
+ { type: "text", key: "title", label: "Approval title", default: "Approve action" },
1555
+ { type: "textarea", key: "description", label: "Description for approver", rows: 3 },
1556
+ { type: "credential", key: "channel", label: "Notify channel", credentialType: "notify_channel" }
1557
+ ]
1558
+ },
1559
+ {
1560
+ name: "@particle-academy/notify",
1561
+ outputShape: [
1562
+ { path: "sent", type: "boolean", description: "True once the message was handed to the channel." },
1563
+ { path: "channel", type: "string", description: "The channel it went to." },
1564
+ { path: "to", type: "string", description: "The recipient." },
1565
+ { path: "message", type: "string", description: "The rendered message." }
1566
+ ],
1567
+ aliases: ["notify", "@fancy/notify"],
1568
+ category: "human",
1569
+ label: "Notify",
1570
+ description: "Send a message via Slack / email / SMS / etc.",
1571
+ icon: "\u{1F514}",
1572
+ configSchema: [
1573
+ {
1574
+ type: "select",
1575
+ key: "channel",
1576
+ label: "Channel",
1577
+ default: "slack",
1578
+ options: [
1579
+ { value: "slack", label: "Slack" },
1580
+ { value: "email", label: "Email" },
1581
+ { value: "sms", label: "SMS" },
1582
+ { value: "discord", label: "Discord" }
1583
+ ]
1584
+ },
1585
+ { type: "text", key: "to", label: "To", required: true },
1586
+ { type: "expression", key: "message", label: "Message", required: true, example: "{{ $json.summary }}" }
1587
+ ]
1588
+ },
1589
+ // ───────────── Output ─────────────
1590
+ {
1591
+ name: "@particle-academy/output",
1592
+ // returns its input unchanged
1593
+ emits: "input",
1594
+ aliases: ["output", "@fancy/output"],
1595
+ category: "output",
1596
+ label: "Output",
1597
+ description: "Terminal node \u2014 captures the workflow's result.",
1598
+ icon: "\u25CF",
1599
+ inputs: [{ id: "in" }],
1600
+ outputs: []
1601
+ },
1602
+ {
1603
+ name: "@particle-academy/log",
1604
+ outputShape: [
1605
+ { path: "logged", type: "string", description: "The message that was written." },
1606
+ { path: "level", type: "string", description: "The level it was written at." }
1607
+ ],
1608
+ aliases: ["log", "@fancy/log"],
1609
+ category: "output",
1610
+ label: "Log",
1611
+ description: "Send to the run feed.",
1612
+ icon: "\u2261",
1613
+ inputs: [{ id: "in" }],
1614
+ outputs: [],
1615
+ configSchema: [
1616
+ {
1617
+ type: "select",
1618
+ key: "level",
1619
+ label: "Level",
1620
+ default: "info",
1621
+ options: [{ value: "info", label: "info" }, { value: "warn", label: "warn" }, { value: "error", label: "error" }]
1622
+ },
1623
+ { type: "expression", key: "message", label: "Message", required: true, example: "{{ $json }}" }
1624
+ ]
1625
+ },
1626
+ {
1627
+ // Swimlane — a portless, resizable container. Child nodes are parented into
1628
+ // it; it never runs (the runtime skips the `layout` category). Uses its own
1629
+ // renderer (LaneNode) instead of the default card.
1630
+ name: "@particle-academy/lane",
1631
+ aliases: ["lane", "@fancy/lane"],
1632
+ category: "layout",
1633
+ label: "Lane",
1634
+ description: "A resizable swimlane \u2014 drop nodes into it to group them.",
1635
+ icon: "\u25A4",
1636
+ inputs: [],
1637
+ outputs: [],
1638
+ configSchema: [
1639
+ { type: "text", key: "title", label: "Title", default: "Lane" },
1640
+ {
1641
+ type: "select",
1642
+ key: "orientation",
1643
+ label: "Orientation",
1644
+ default: "horizontal",
1645
+ options: [{ value: "horizontal", label: "Row" }, { value: "vertical", label: "Column" }]
1646
+ }
1647
+ ],
1648
+ defaultConfig: { title: "Lane", orientation: "horizontal" },
1649
+ resizable: { minWidth: 160, minHeight: 72 }
1650
+ },
1651
+ {
1652
+ // Terminal lane — a lane that OWNS a terminal for the length of the run.
1653
+ //
1654
+ // Visually a swimlane, and `layout` for the same reason the plain lane is:
1655
+ // a lane is not a step, so it never executes and never appears in topo
1656
+ // order. What is new is that it DECLARES a resource. The runtime opens its
1657
+ // terminal lazily, when the first terminal node inside it runs, and closes
1658
+ // it in the run's `finally` — so a graph that never reaches a terminal node
1659
+ // never spawns a process, and one that does gets exactly one session no
1660
+ // matter how many nodes use it.
1661
+ //
1662
+ // Membership is `parentId`, which already persists into the WorkflowSchema.
1663
+ // That is what lets a headless runtime resolve the same grouping the canvas
1664
+ // shows, without a second association to keep in sync.
1665
+ name: "@particle-academy/terminal_lane",
1666
+ aliases: ["terminal_lane", "@fancy/terminal_lane"],
1667
+ category: "layout",
1668
+ label: "Terminal lane",
1669
+ description: "A lane that owns one terminal. Opens at the first terminal node, closes when the run ends.",
1670
+ icon: "\u25A3",
1671
+ inputs: [],
1672
+ outputs: [],
1673
+ configSchema: [
1674
+ { type: "text", key: "title", label: "Title", default: "Terminal" },
1675
+ {
1676
+ type: "text",
1677
+ key: "command",
1678
+ label: "Command",
1679
+ placeholder: "Leave empty for the default shell"
1680
+ },
1681
+ { type: "text", key: "cwd", label: "Working directory", placeholder: "Host default" },
1682
+ { type: "keyvalue", key: "env", label: "Environment" },
1683
+ {
1684
+ type: "select",
1685
+ key: "orientation",
1686
+ label: "Orientation",
1687
+ default: "horizontal",
1688
+ options: [
1689
+ { value: "horizontal", label: "Row" },
1690
+ { value: "vertical", label: "Column" }
1691
+ ]
1692
+ }
1693
+ ],
1694
+ defaultConfig: { title: "Terminal", orientation: "horizontal" },
1695
+ resizable: { minWidth: 220, minHeight: 120 }
1696
+ },
1697
+ {
1698
+ // Terminal nodes. All three live inside a terminal lane and talk to the
1699
+ // session that lane owns; outside one they abort by name rather than
1700
+ // opening a shell of their own.
1701
+ name: "@particle-academy/terminal_run",
1702
+ aliases: ["terminal_run", "@fancy/terminal_run"],
1703
+ category: "io",
1704
+ label: "Run in terminal",
1705
+ description: "Run a shell command in the lane's terminal and wait for its exit code.",
1706
+ icon: "$",
1707
+ inputs: [{ id: "in" }],
1708
+ outputs: [{ id: "out", label: "output" }],
1709
+ executor: terminalRunExecutor,
1710
+ configSchema: [
1711
+ {
1712
+ type: "textarea",
1713
+ key: "command",
1714
+ label: "Command",
1715
+ rows: 3,
1716
+ required: true,
1717
+ placeholder: "npm test",
1718
+ description: "Runs in the lane's shell, so `cd` and exported variables persist between nodes. SHELL ONLY \u2014 a TUI never returns to a prompt, so use Send + Await for one."
1719
+ },
1720
+ {
1721
+ type: "number",
1722
+ key: "timeoutMs",
1723
+ label: "Timeout (ms)",
1724
+ default: 12e4,
1725
+ description: "How long to wait for the command to finish before failing the run."
1726
+ },
1727
+ {
1728
+ type: "switch",
1729
+ key: "failOnNonZero",
1730
+ label: "Fail the run on a non-zero exit",
1731
+ default: true,
1732
+ description: "On by default. Turning it off means a failed command lets the run report success \u2014 read the exit code on the output port and branch on it instead."
1733
+ }
1734
+ ],
1735
+ defaultConfig: { command: "", timeoutMs: 12e4, failOnNonZero: true }
1736
+ },
1737
+ {
1738
+ name: "@particle-academy/terminal_send",
1739
+ aliases: ["terminal_send", "@fancy/terminal_send"],
1740
+ category: "io",
1741
+ label: "Send to terminal",
1742
+ description: "Type text at whatever is running in the lane's terminal, without waiting.",
1743
+ icon: "\u2328",
1744
+ inputs: [{ id: "in" }],
1745
+ outputs: [{ id: "out", label: "sent" }],
1746
+ executor: terminalSendExecutor,
1747
+ configSchema: [
1748
+ {
1749
+ type: "textarea",
1750
+ key: "text",
1751
+ label: "Text",
1752
+ rows: 4,
1753
+ placeholder: "Summarise the failing test and propose a fix.",
1754
+ description: "What to type. This is how a graph prompts an agent TUI such as Claude Code or Codex."
1755
+ },
1756
+ {
1757
+ type: "switch",
1758
+ key: "submit",
1759
+ label: "Press Enter",
1760
+ default: true,
1761
+ description: "Off leaves the text on the input line \u2014 useful for building one up across several nodes."
1762
+ },
1763
+ {
1764
+ type: "switch",
1765
+ key: "clearFirst",
1766
+ label: "Forget earlier output first",
1767
+ default: false,
1768
+ description: "Discards anything the terminal said before this send, so a following Await cannot match a prompt left over from the previous exchange."
1769
+ }
1770
+ ],
1771
+ defaultConfig: { text: "", submit: true, clearFirst: false }
1772
+ },
1773
+ {
1774
+ name: "@particle-academy/terminal_await",
1775
+ aliases: ["terminal_await", "@fancy/terminal_await"],
1776
+ category: "io",
1777
+ label: "Await terminal output",
1778
+ description: "Wait until the lane's terminal prints something that matches.",
1779
+ icon: "\u23F1",
1780
+ inputs: [{ id: "in" }],
1781
+ outputs: [{ id: "out", label: "output" }],
1782
+ executor: terminalAwaitExecutor,
1783
+ configSchema: [
1784
+ {
1785
+ type: "text",
1786
+ key: "pattern",
1787
+ label: "Wait for",
1788
+ required: true,
1789
+ placeholder: "esc to interrupt",
1790
+ description: "Matched against the output with colour codes already stripped, so match what you SEE."
1791
+ },
1792
+ {
1793
+ type: "select",
1794
+ key: "mode",
1795
+ label: "Match as",
1796
+ default: "text",
1797
+ options: [
1798
+ { value: "text", label: "Plain text" },
1799
+ { value: "regex", label: "Regular expression" }
1800
+ ],
1801
+ description: "Regex mode also returns capture groups, which is how a value gets out of a prompt."
1802
+ },
1803
+ {
1804
+ type: "number",
1805
+ key: "timeoutMs",
1806
+ label: "Timeout (ms)",
1807
+ default: 12e4
1808
+ },
1809
+ {
1810
+ type: "select",
1811
+ key: "onTimeout",
1812
+ label: "If it never appears",
1813
+ default: "fail",
1814
+ options: [
1815
+ { value: "fail", label: "Fail the run" },
1816
+ { value: "continue", label: "Continue with matched: false" }
1817
+ ],
1818
+ description: "Failing is the default. Continuing lets the next node type at a process that never became ready while the run still reports success, so it has to be asked for."
1819
+ }
1820
+ ],
1821
+ defaultConfig: { pattern: "", mode: "text", timeoutMs: 12e4, onTimeout: "fail" }
1822
+ },
1823
+ {
1824
+ // Note — a sticky-note annotation. Portless + visual-only: the runtime skips
1825
+ // the `annotation` category, so a note's text NEVER reaches a runner — it
1826
+ // rides in the document purely for people, editors, and MCP tools. Uses its
1827
+ // own renderer (NoteNode); double-click on the canvas to edit in place.
1828
+ name: "@particle-academy/note",
1829
+ aliases: ["note", "@fancy/note"],
1830
+ category: "annotation",
1831
+ label: "Note",
1832
+ description: "A sticky note that documents the canvas. Never runs \u2014 editor + agent only.",
1833
+ icon: "\u{1F5D2}",
1834
+ inputs: [],
1835
+ outputs: [],
1836
+ configSchema: [
1837
+ { type: "text", key: "title", label: "Title", placeholder: "Optional heading" },
1838
+ { type: "textarea", key: "text", label: "Note", rows: 5, placeholder: "What does this part of the flow do?" },
1839
+ { type: "select", key: "color", label: "Color", default: "amber", options: [
1840
+ { value: "amber", label: "Amber" },
1841
+ { value: "sky", label: "Sky" },
1842
+ { value: "violet", label: "Violet" },
1843
+ { value: "emerald", label: "Emerald" },
1844
+ { value: "rose", label: "Rose" },
1845
+ { value: "slate", label: "Slate" }
1846
+ ] }
1847
+ ],
1848
+ defaultConfig: { text: "", color: "amber" },
1849
+ resizable: { minWidth: 140, minHeight: 80 }
1850
+ }
1851
+ ];
1852
+ function registerBuiltinKindData() {
1853
+ for (const k of KINDS) registerNodeKind(k);
1854
+ }
1855
+ var BUILTIN_KIND_DATA = KINDS;
1856
+
1857
+ // src/registry/registry.ts
1858
+ var kinds = /* @__PURE__ */ new Map();
1859
+ var aliases = /* @__PURE__ */ new Map();
1860
+ var listeners = /* @__PURE__ */ new Set();
1861
+ var overrides = /* @__PURE__ */ new Map();
1862
+ var builtinsEnsured = false;
1863
+ function ensureBuiltinKinds() {
1864
+ if (builtinsEnsured) return;
1865
+ builtinsEnsured = true;
1866
+ registerBuiltinKindData();
1867
+ }
1868
+ function registerNodeKind(definition) {
1869
+ ensureBuiltinKinds();
1870
+ kinds.set(definition.name, definition);
1871
+ for (const alias of definition.aliases ?? []) aliases.set(alias, definition.name);
1872
+ notify();
1873
+ return () => {
1874
+ if (kinds.get(definition.name) === definition) {
1875
+ kinds.delete(definition.name);
1876
+ for (const alias of definition.aliases ?? []) {
1877
+ if (aliases.get(alias) === definition.name) aliases.delete(alias);
1878
+ }
1879
+ notify();
1880
+ }
1881
+ };
1882
+ }
1883
+ function resolveKindId(id) {
1884
+ ensureBuiltinKinds();
1885
+ if (kinds.has(id)) return id;
1886
+ const canonical = aliases.get(id);
1887
+ return canonical && kinds.has(canonical) ? canonical : null;
1888
+ }
1889
+ function overrideNodeKind(name, patch) {
1890
+ const canonical = resolveKindId(name) ?? name;
1891
+ const previous = overrides.get(canonical);
1892
+ overrides.set(canonical, { ...previous, ...patch });
1893
+ notify();
1894
+ return () => {
1895
+ if (previous) {
1896
+ overrides.set(canonical, previous);
1897
+ } else {
1898
+ overrides.delete(canonical);
1899
+ }
1900
+ notify();
1901
+ };
1902
+ }
1903
+ function clearNodeKindOverrides() {
1904
+ if (overrides.size === 0) return;
1905
+ overrides.clear();
1906
+ notify();
1907
+ }
1908
+ function withOverride(kind) {
1909
+ if (!kind) return null;
1910
+ const patch = overrides.get(kind.name);
1911
+ return patch ? { ...kind, ...patch } : kind;
1912
+ }
1913
+ function getNodeKind(name) {
1914
+ const canonical = resolveKindId(name);
1915
+ return canonical ? withOverride(kinds.get(canonical)) : null;
1916
+ }
1917
+ function kindIds(kind) {
1918
+ return [kind.name, ...kind.aliases ?? []];
1919
+ }
1920
+ function listNodeKinds(category) {
1921
+ ensureBuiltinKinds();
1922
+ const all = Array.from(kinds.values()).map(
1923
+ (k) => withOverride(k)
1924
+ );
1925
+ return category ? all.filter((k) => k.category === category) : all;
1926
+ }
1927
+ function onNodeKindsChanged(listener) {
1928
+ listeners.add(listener);
1929
+ return () => listeners.delete(listener);
1930
+ }
1931
+ function notify() {
1932
+ for (const l of listeners) l();
1933
+ }
1934
+ function defaultConfigFor(kind) {
1935
+ const fromKind = kind.defaultConfig ? { ...kind.defaultConfig } : {};
1936
+ for (const field of kind.configSchema ?? []) {
1937
+ if (fromKind[field.key] !== void 0) continue;
1938
+ if ("default" in field && field.default !== void 0) {
1939
+ fromKind[field.key] = field.default;
1940
+ }
1941
+ }
1942
+ return fromKind;
1943
+ }
1944
+ function validateConfig(kind, config) {
1945
+ const issues = [];
1946
+ for (const field of kind.configSchema ?? []) {
1947
+ const value = config[field.key];
1948
+ if (field.required && (value === void 0 || value === null || value === "")) {
1949
+ issues.push({ key: field.key, message: `${field.label} is required` });
1950
+ continue;
1951
+ }
1952
+ if (value === void 0 || value === null) continue;
1953
+ const issue = validateField(field, value);
1954
+ if (issue) issues.push({ key: field.key, message: issue });
1955
+ }
1956
+ return issues;
1957
+ }
1958
+ function validateField(field, value) {
1959
+ switch (field.type) {
1960
+ case "text":
1961
+ case "textarea":
1962
+ case "expression":
1963
+ case "credential":
1964
+ return typeof value === "string" ? null : `${field.label} must be a string`;
1965
+ case "number": {
1966
+ if (typeof value !== "number" || !Number.isFinite(value)) return `${field.label} must be a number`;
1967
+ if (field.min !== void 0 && value < field.min) return `${field.label} must be >= ${field.min}`;
1968
+ if (field.max !== void 0 && value > field.max) return `${field.label} must be <= ${field.max}`;
1969
+ return null;
1970
+ }
1971
+ case "switch":
1972
+ return typeof value === "boolean" ? null : `${field.label} must be a boolean`;
1973
+ case "select": {
1974
+ const allowed = field.options.map((o) => o.value);
1975
+ return allowed.includes(String(value)) ? null : `${field.label} must be one of ${allowed.join(", ")}`;
1976
+ }
1977
+ case "json":
1978
+ return null;
1979
+ // permissive — just JSON-shaped
1980
+ case "repeater": {
1981
+ if (!Array.isArray(value)) return `${field.label} must be a list`;
1982
+ if (field.minItems !== void 0 && value.length < field.minItems) {
1983
+ return `${field.label} needs at least ${field.minItems}`;
1984
+ }
1985
+ if (field.maxItems !== void 0 && value.length > field.maxItems) {
1986
+ return `${field.label} allows at most ${field.maxItems}`;
1987
+ }
1988
+ for (let i = 0; i < value.length; i++) {
1989
+ const row = value[i];
1990
+ if (!row || typeof row !== "object" || Array.isArray(row)) {
1991
+ return `${field.label} item ${i + 1} must be an object`;
1992
+ }
1993
+ for (const sub of field.fields) {
1994
+ const cell = row[sub.key];
1995
+ if (sub.required && (cell === void 0 || cell === null || cell === "")) {
1996
+ return `${field.label} item ${i + 1}: ${sub.label} is required`;
1997
+ }
1998
+ if (cell === void 0 || cell === null) continue;
1999
+ const issue = validateField(sub, cell);
2000
+ if (issue) return `${field.label} item ${i + 1}: ${issue}`;
2001
+ }
2002
+ }
2003
+ return null;
2004
+ }
2005
+ case "keyvalue": {
2006
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
2007
+ return `${field.label} must be a key/value map`;
2008
+ }
2009
+ const allowed = field.valueOptions?.map((o) => o.value);
2010
+ for (const [k, v] of Object.entries(value)) {
2011
+ if (typeof v !== "string") return `${field.label}: "${k}" must be a string`;
2012
+ if (allowed && !allowed.includes(v)) {
2013
+ return `${field.label}: "${k}" must be one of ${allowed.join(", ")}`;
2014
+ }
2015
+ }
2016
+ return null;
2017
+ }
2018
+ case "document":
2019
+ return null;
2020
+ // opaque to fancy-flow — the host's editor owns its shape
2021
+ default:
2022
+ return null;
2023
+ }
2024
+ }
2025
+ function categoryAccent(category) {
2026
+ switch (category) {
2027
+ case "trigger":
2028
+ return "#10b981";
2029
+ case "logic":
2030
+ return "#f59e0b";
2031
+ case "data":
2032
+ return "#0ea5e9";
2033
+ case "ai":
2034
+ return "#8b5cf6";
2035
+ case "io":
2036
+ return "#3b82f6";
2037
+ case "human":
2038
+ return "#ec4899";
2039
+ case "output":
2040
+ return "#a855f7";
2041
+ case "layout":
2042
+ return "#64748b";
2043
+ case "annotation":
2044
+ return "#eab308";
2045
+ default:
2046
+ return "#71717a";
2047
+ }
2048
+ }
2049
+
2050
+ // src/runtime/terminal-transcript.ts
2051
+ var ESC = String.fromCharCode(27);
2052
+ var CSI = String.fromCharCode(155);
2053
+ var BEL = String.fromCharCode(7);
2054
+ var ANSI = new RegExp(
2055
+ `[${ESC}${CSI}](?:\\[[0-?]*[ -/]*[@-~]|\\][^${BEL}${ESC}]*(?:${BEL}|${ESC}\\\\)|[@-Z\\\\-_])`,
2056
+ "g"
2057
+ );
2058
+ var MAX_PENDING_ESCAPE = 1024;
2059
+ var MAX_BUFFER = 1e6;
2060
+ var TerminalTranscript = class {
2061
+ constructor() {
2062
+ /** Stripped, complete text not yet consumed by a wait. */
2063
+ this.text = "";
2064
+ /** Raw bytes held back because they may be the start of an escape sequence. */
2065
+ this.tail = "";
2066
+ this.waiters = /* @__PURE__ */ new Set();
2067
+ }
2068
+ /** Feed raw terminal output in. Safe to call with any chunking. */
2069
+ append(chunk) {
2070
+ this.tail += chunk;
2071
+ const safe = this.safeLength();
2072
+ if (safe > 0) {
2073
+ this.text += this.tail.slice(0, safe).replace(ANSI, "");
2074
+ this.tail = this.tail.slice(safe);
2075
+ }
2076
+ if (this.text.length > MAX_BUFFER) {
2077
+ this.text = this.text.slice(this.text.length - MAX_BUFFER);
2078
+ }
2079
+ for (const wake of [...this.waiters]) wake();
2080
+ }
2081
+ /**
2082
+ * How much of `tail` can be stripped now.
2083
+ *
2084
+ * Everything, unless the final `ESC` has not yet been terminated — in which
2085
+ * case processing stops there and resumes when the rest arrives.
2086
+ */
2087
+ safeLength() {
2088
+ const lastEsc = Math.max(this.tail.lastIndexOf(ESC), this.tail.lastIndexOf(CSI));
2089
+ if (lastEsc === -1) return this.tail.length;
2090
+ ANSI.lastIndex = lastEsc;
2091
+ const match = ANSI.exec(this.tail);
2092
+ ANSI.lastIndex = 0;
2093
+ if (match && match.index === lastEsc) return this.tail.length;
2094
+ if (this.tail.length - lastEsc > MAX_PENDING_ESCAPE) return this.tail.length;
2095
+ return lastEsc;
2096
+ }
2097
+ /** Unconsumed output, escape sequences removed. */
2098
+ peek() {
2099
+ return this.text;
2100
+ }
2101
+ /** Drop everything currently buffered — used before typing a new command. */
2102
+ clear() {
2103
+ this.text = "";
2104
+ this.tail = "";
2105
+ }
2106
+ /**
2107
+ * Wait until `pattern` matches the unconsumed text.
2108
+ *
2109
+ * Checks what has ALREADY arrived before subscribing, because the common case
2110
+ * is that the process answered while the previous node was still finishing.
2111
+ *
2112
+ * `exited` is raced deliberately. Without it, a shell that dies reports as
2113
+ * "timed out waiting for X" — sending whoever reads it to lengthen a timeout
2114
+ * for a process that is not running. Naming the exit is the difference
2115
+ * between a diagnosis and a wrong lead.
2116
+ */
2117
+ waitFor(pattern, options) {
2118
+ return new Promise((resolve2) => {
2119
+ let settled = false;
2120
+ let timer;
2121
+ const finish = (result) => {
2122
+ if (settled) return;
2123
+ settled = true;
2124
+ this.waiters.delete(check);
2125
+ if (timer) clearTimeout(timer);
2126
+ resolve2(result);
2127
+ };
2128
+ const check = () => {
2129
+ pattern.lastIndex = 0;
2130
+ const match = pattern.exec(this.text);
2131
+ if (!match) return;
2132
+ const through = match.index + (match[0].length || 1);
2133
+ const text3 = this.text.slice(0, through);
2134
+ this.text = this.text.slice(through);
2135
+ finish({ status: "matched", text: text3, match });
2136
+ };
2137
+ this.waiters.add(check);
2138
+ check();
2139
+ if (settled) return;
2140
+ if (options.timeoutMs > 0) {
2141
+ timer = setTimeout(() => {
2142
+ const text3 = this.text;
2143
+ this.text = "";
2144
+ finish({ status: "timeout", text: text3 });
2145
+ }, options.timeoutMs);
2146
+ timer.unref?.();
2147
+ }
2148
+ options.exited?.then(
2149
+ (exit) => {
2150
+ if (settled) return;
2151
+ const text3 = this.text;
2152
+ this.text = "";
2153
+ finish({ status: "exited", text: text3, exitCode: exit.exitCode, signal: exit.signal });
2154
+ },
2155
+ () => {
2156
+ }
2157
+ );
2158
+ });
2159
+ }
2160
+ };
2161
+
2162
+ // src/runtime/terminal-sessions.ts
2163
+ var TerminalSessions = class {
2164
+ constructor(graph) {
2165
+ this.graph = graph;
2166
+ this.open = /* @__PURE__ */ new Map();
2167
+ this.laneOf = /* @__PURE__ */ new Map();
2168
+ this.transcripts = /* @__PURE__ */ new Map();
2169
+ this.listening = /* @__PURE__ */ new Map();
2170
+ }
2171
+ /**
2172
+ * The terminal lane a node belongs to, or null.
2173
+ *
2174
+ * Walks up `parentId` rather than checking only the immediate parent, and
2175
+ * caches per node id — a graph is walked once per run, and the answer cannot
2176
+ * change while it runs.
2177
+ */
2178
+ laneFor(nodeId, isTerminalLane) {
2179
+ const cached = this.laneOf.get(nodeId);
2180
+ if (cached !== void 0) return cached;
2181
+ const byId = new Map(this.graph.nodes.map((n) => [n.id, n]));
2182
+ const seen = /* @__PURE__ */ new Set();
2183
+ let current = byId.get(nodeId);
2184
+ let answer = null;
2185
+ while (current) {
2186
+ if (seen.has(current.id)) break;
2187
+ seen.add(current.id);
2188
+ if (current.id !== nodeId && isTerminalLane(current)) {
2189
+ answer = current.id;
2190
+ break;
2191
+ }
2192
+ const parentId = current.parentId;
2193
+ current = parentId ? byId.get(parentId) : void 0;
2194
+ }
2195
+ this.laneOf.set(nodeId, answer);
2196
+ return answer;
2197
+ }
2198
+ /**
2199
+ * The session for a lane, opening it on first use.
2200
+ *
2201
+ * The PROMISE is cached, not the resolved session. Two nodes cannot run
2202
+ * concurrently in the current engine, but a caller that awaits a
2203
+ * half-finished open would otherwise start a second process — and two shells
2204
+ * where a graph says one is the failure this class exists to prevent, arriving
2205
+ * only under concurrency and therefore only sometimes.
2206
+ */
2207
+ session(laneId, spec) {
2208
+ const existing = this.open.get(laneId);
2209
+ if (existing) return existing;
2210
+ const host = getTerminalHost();
2211
+ if (!host) {
2212
+ const failed = Promise.reject(
2213
+ new Error(
2214
+ "No terminal host is registered. A terminal lane needs one \u2014 call registerTerminalHost() from the desktop app, or install @particle-academy/fancy-flow/terminal/fancy-term-host."
2215
+ )
2216
+ );
2217
+ this.open.set(laneId, failed);
2218
+ failed.catch(() => {
2219
+ });
2220
+ return failed;
2221
+ }
2222
+ const opening = Promise.resolve(host.open(spec)).then((session) => {
2223
+ const transcript = this.transcriptFor(laneId);
2224
+ this.listening.set(laneId, session.onData((chunk) => transcript.append(chunk)));
2225
+ return session;
2226
+ });
2227
+ this.open.set(laneId, opening);
2228
+ opening.catch(() => {
2229
+ });
2230
+ return opening;
2231
+ }
2232
+ /**
2233
+ * The accumulated output for a lane.
2234
+ *
2235
+ * Created on demand and kept for the run, so it exists before the session
2236
+ * resolves and survives every node that reads it. Consuming is the reader's
2237
+ * job — see `TerminalTranscript.waitFor`.
2238
+ */
2239
+ transcriptFor(laneId) {
2240
+ const existing = this.transcripts.get(laneId);
2241
+ if (existing) return existing;
2242
+ const created = new TerminalTranscript();
2243
+ this.transcripts.set(laneId, created);
2244
+ return created;
2245
+ }
2246
+ /** True once a lane has a session — used to avoid opening one during teardown. */
2247
+ isOpen(laneId) {
2248
+ return this.open.has(laneId);
2249
+ }
2250
+ /**
2251
+ * Close every session this run opened.
2252
+ *
2253
+ * Every close is attempted even if one throws: a host that fails to close one
2254
+ * PTY must not strand the others, which would leave processes alive after the
2255
+ * run reported that it had finished. Errors are returned rather than thrown,
2256
+ * because teardown runs in a `finally` and throwing there would replace the
2257
+ * run's real error with a cleanup error.
2258
+ */
2259
+ async closeAll() {
2260
+ const errors = [];
2261
+ for (const [laneId, pending] of this.open) {
2262
+ try {
2263
+ this.listening.get(laneId)?.();
2264
+ this.listening.delete(laneId);
2265
+ const session = await pending;
2266
+ await session.close();
2267
+ } catch (e) {
2268
+ errors.push(e instanceof Error ? e : new Error(`${laneId}: ${String(e)}`));
2269
+ }
2270
+ }
2271
+ this.open.clear();
2272
+ this.listening.clear();
2273
+ this.transcripts.clear();
2274
+ return errors;
2275
+ }
2276
+ };
2277
+ function specForLane(lane) {
2278
+ const config = lane.data?.config ?? {};
2279
+ const text3 = (key) => {
2280
+ const value = config[key];
2281
+ return typeof value === "string" && value !== "" ? value : void 0;
2282
+ };
2283
+ const env = config.env;
2284
+ return {
2285
+ command: text3("command"),
2286
+ cwd: text3("cwd"),
2287
+ env: env && typeof env === "object" && !Array.isArray(env) ? Object.fromEntries(
2288
+ Object.entries(env).map(([k, v]) => [k, String(v)])
2289
+ ) : void 0
2290
+ };
2291
+ }
2292
+
2293
+ // src/runtime/workflow-props.ts
2294
+ function typeOf(value) {
2295
+ if (value === null) return "null";
2296
+ if (Array.isArray(value)) return "array";
2297
+ return typeof value;
2298
+ }
2299
+ function resolveWorkflowProps(declared, passed) {
2300
+ const inputs = declared ?? [];
2301
+ const given = passed ?? {};
2302
+ const byName = new Map(inputs.map((input) => [input.name, input]));
2303
+ for (const name of Object.keys(given)) {
2304
+ if (!byName.has(name)) {
2305
+ const known = inputs.map((input) => input.name);
2306
+ const suffix = known.length === 0 ? "this workflow declares no inputs" : `known inputs: ${known.join(", ")}`;
2307
+ return {
2308
+ ok: false,
2309
+ code: "unknown_input",
2310
+ error: `Unknown workflow input "${name}" \u2014 ${suffix}.`
2311
+ };
2312
+ }
2313
+ }
2314
+ const resolved = {};
2315
+ for (const input of inputs) {
2316
+ const supplied = Object.prototype.hasOwnProperty.call(given, input.name);
2317
+ const hasDefault = Object.prototype.hasOwnProperty.call(input, "default");
2318
+ if (!supplied) {
2319
+ if (hasDefault) {
2320
+ resolved[input.name] = input.default;
2321
+ continue;
2322
+ }
2323
+ if (input.required) {
2324
+ return {
2325
+ ok: false,
2326
+ code: "missing_required",
2327
+ error: `Missing required workflow input "${input.name}"${input.type ? ` (${input.type})` : ""}.`
2328
+ };
2329
+ }
2330
+ continue;
2331
+ }
2332
+ const value = given[input.name];
2333
+ if (input.type !== void 0) {
2334
+ const actual = typeOf(value);
2335
+ if (actual !== input.type) {
2336
+ return {
2337
+ ok: false,
2338
+ code: "type_mismatch",
2339
+ error: `Workflow input "${input.name}" expects ${input.type}, got ${actual}.`
2340
+ };
2341
+ }
2342
+ }
2343
+ resolved[input.name] = value;
2344
+ }
2345
+ return { ok: true, props: resolved };
2346
+ }
2347
+
2348
+ // src/runtime/run-flow.ts
2349
+ async function runFlow(graph, executors, onEvent = () => {
2350
+ }, options = {}) {
2351
+ const { signal, initialInputs = {}, timeoutMs, depth = 0, resumeOutputs = {}, entryNodes } = options;
2352
+ const run = options.run === void 0 ? void 0 : RunIdentity.from(options.run);
2353
+ const outputs = {};
2354
+ const portValues = /* @__PURE__ */ new Map();
2355
+ const completed = /* @__PURE__ */ new Set();
2356
+ const errors = [];
2357
+ const order = topoSort(graph);
2358
+ if (order === null) {
2359
+ const msg = "Cycle detected in flow graph \u2014 aborting.";
2360
+ onEvent({ type: "run-error", error: msg });
2361
+ return { ok: false, outputs, error: msg };
2362
+ }
2363
+ const propsCheck = resolveWorkflowProps(graph.inputs, options.props);
2364
+ if (!propsCheck.ok) {
2365
+ onEvent({ type: "run-error", error: propsCheck.error });
2366
+ return { ok: false, outputs, error: propsCheck.error };
2367
+ }
2368
+ const props = propsCheck.props;
2369
+ const declaresProps = (graph.inputs?.length ?? 0) > 0;
2370
+ const incomingByNode = indexIncoming(graph.edges);
2371
+ const sessions = new TerminalSessions(graph);
2372
+ const isTerminalLane = (candidate) => getNodeKind(candidate.type ?? "")?.name === "@particle-academy/terminal_lane";
2373
+ const terminalAccessorFor = (node) => {
2374
+ const laneId = sessions.laneFor(node.id, isTerminalLane);
2375
+ if (laneId === null) return void 0;
2376
+ const lane = graph.nodes.find((n) => n.id === laneId);
2377
+ if (!lane) return void 0;
2378
+ return {
2379
+ session: () => sessions.session(laneId, specForLane(lane)),
2380
+ // Awaits the session first on purpose. A transcript handed out before
2381
+ // anything is feeding it would look perfectly healthy and match nothing,
2382
+ // and "waited and nothing came" is the hardest failure here to tell apart
2383
+ // from a process that is simply slow.
2384
+ transcript: async () => {
2385
+ await sessions.session(laneId, specForLane(lane));
2386
+ return sessions.transcriptFor(laneId);
2387
+ }
2388
+ };
2389
+ };
2390
+ const timer = timeoutMs ? setTimeout(() => errors.push(`Run timed out after ${timeoutMs}ms`), timeoutMs) : null;
2391
+ onEvent({ type: "run-start" });
2392
+ try {
2393
+ for (const node of order) {
2394
+ if (signal?.aborted) throw new Error("aborted");
2395
+ if (errors.length) break;
2396
+ if (Object.prototype.hasOwnProperty.call(resumeOutputs, node.id)) {
2397
+ const stored = resumeOutputs[node.id];
2398
+ outputs[node.id] = stored;
2399
+ const activated = activatedPorts(node, stored);
2400
+ for (const portId of activated.ports) {
2401
+ portValues.set(`${node.id}:${portId}`, activated.value);
2402
+ onEvent({ type: "node-output", nodeId: node.id, portId, value: activated.value });
2403
+ }
2404
+ completed.add(node.id);
2405
+ onEvent({ type: "node-status", nodeId: node.id, status: "done", text: "resumed" });
2406
+ continue;
2407
+ }
2408
+ const incoming = incomingByNode.get(node.id) ?? [];
2409
+ if (incoming.length === 0 && entryNodes !== void 0 && !entryNodes.includes(node.id)) {
2410
+ onEvent({ type: "node-status", nodeId: node.id, status: "idle", text: "skipped" });
2411
+ continue;
2412
+ }
2413
+ if (incoming.length > 0) {
2414
+ const anyActive = incoming.some((e) => portValues.has(`${e.source}:${e.sourceHandle ?? "out"}`));
2415
+ if (!anyActive) {
2416
+ onEvent({ type: "node-status", nodeId: node.id, status: "idle", text: "skipped" });
2417
+ continue;
2418
+ }
2419
+ }
2420
+ const visualKind = getNodeKind(node.type ?? "");
2421
+ const isLayout = visualKind?.category === "layout";
2422
+ const isAnnotation = node.type === "note" || visualKind?.category === "annotation";
2423
+ if (isLayout || isAnnotation) {
2424
+ onEvent({
2425
+ type: "node-status",
2426
+ nodeId: node.id,
2427
+ status: "idle",
2428
+ text: isLayout ? "lane" : "annotation"
2429
+ });
2430
+ continue;
2431
+ }
2432
+ onEvent({ type: "node-status", nodeId: node.id, status: "running" });
2433
+ announce(onEvent, node, "start");
2434
+ const inputs = collectInputs(node, incoming, portValues, initialInputs, props, declaresProps);
2435
+ const exec = pickExecutor(executors, node);
2436
+ if (!exec) {
2437
+ const tried = executorLookupIds(node);
2438
+ const msg = `No executor registered for kind=${node.type} \u2014 tried ${tried.map((id) => `"${id}"`).join(", ")}. Key your registry by one of those.`;
2439
+ errors.push(msg);
2440
+ onEvent({ type: "node-status", nodeId: node.id, status: "error", text: msg });
2441
+ onEvent({ type: "log", nodeId: node.id, level: "error", message: msg });
2442
+ break;
2443
+ }
2444
+ try {
2445
+ const result = await Promise.resolve(
2446
+ exec({
2447
+ node,
2448
+ inputs,
2449
+ abort: (reason) => {
2450
+ throw new Error(reason ?? "aborted");
2451
+ },
2452
+ emit: onEvent,
2453
+ executors,
2454
+ depth,
2455
+ run,
2456
+ terminal: terminalAccessorFor(node)
2457
+ })
2458
+ );
2459
+ outputs[node.id] = result;
2460
+ const activated = activatedPorts(node, result);
2461
+ for (const portId of activated.ports) {
2462
+ portValues.set(`${node.id}:${portId}`, activated.value);
2463
+ onEvent({ type: "node-output", nodeId: node.id, portId, value: activated.value });
2464
+ }
2465
+ completed.add(node.id);
2466
+ onEvent({ type: "node-status", nodeId: node.id, status: "done" });
2467
+ announce(onEvent, node, "end");
2468
+ } catch (e) {
2469
+ const msg = e instanceof Error ? e.message : String(e);
2470
+ errors.push(msg);
2471
+ onEvent({ type: "node-status", nodeId: node.id, status: "error", text: msg });
2472
+ onEvent({ type: "log", nodeId: node.id, level: "error", message: msg });
2473
+ break;
2474
+ }
2475
+ }
2476
+ } finally {
2477
+ if (timer) clearTimeout(timer);
2478
+ for (const error of await sessions.closeAll()) {
2479
+ onEvent({ type: "log", level: "warn", message: `terminal close failed: ${error.message}` });
2480
+ }
2481
+ }
2482
+ const ok = errors.length === 0;
2483
+ onEvent({ type: "run-end", ok });
2484
+ return ok ? { ok, outputs } : { ok, outputs, error: errors[0] };
2485
+ }
2486
+ function indexIncoming(edges) {
2487
+ const map = /* @__PURE__ */ new Map();
2488
+ for (const e of edges) {
2489
+ const list = map.get(e.target) ?? [];
2490
+ list.push(e);
2491
+ map.set(e.target, list);
2492
+ }
2493
+ return map;
2494
+ }
2495
+ function topoSort(graph) {
2496
+ const inDegree = /* @__PURE__ */ new Map();
2497
+ for (const n of graph.nodes) inDegree.set(n.id, 0);
2498
+ for (const e of graph.edges) inDegree.set(e.target, (inDegree.get(e.target) ?? 0) + 1);
2499
+ const queue = [];
2500
+ for (const [id, d] of inDegree) if (d === 0) queue.push(id);
2501
+ const ordered = [];
2502
+ while (queue.length) {
2503
+ const id = queue.shift();
2504
+ ordered.push(id);
2505
+ for (const e of graph.edges) {
2506
+ if (e.source !== id) continue;
2507
+ const next = (inDegree.get(e.target) ?? 0) - 1;
2508
+ inDegree.set(e.target, next);
2509
+ if (next === 0) queue.push(e.target);
2510
+ }
2511
+ }
2512
+ if (ordered.length !== graph.nodes.length) return null;
2513
+ const byId = new Map(graph.nodes.map((n) => [n.id, n]));
2514
+ return ordered.map((id) => byId.get(id)).filter(Boolean);
2515
+ }
2516
+ function collectInputs(node, incoming, portValues, initial, props, declaresProps) {
2517
+ const inputs = { ...initial[node.id] ?? {} };
2518
+ if (incoming.length === 0) {
2519
+ for (const [name, value] of Object.entries(props)) {
2520
+ if (!(name in inputs)) inputs[name] = value;
2521
+ }
2522
+ }
2523
+ for (const e of incoming) {
2524
+ const portId = e.targetHandle ?? "in";
2525
+ const key = `${e.source}:${e.sourceHandle ?? "out"}`;
2526
+ if (!portValues.has(key)) continue;
2527
+ inputs[portId] = portValues.get(key);
2528
+ if (e.targetHandle == null && !(e.source in inputs)) {
2529
+ inputs[e.source] = portValues.get(key);
2530
+ }
2531
+ }
2532
+ if (declaresProps) inputs.$props = props;
2533
+ return inputs;
2534
+ }
2535
+ function executorLookupIds(node) {
2536
+ const ids = [node.id];
2537
+ if (node.type) ids.push(node.type);
2538
+ const typeKind = node.type ? getNodeKind(node.type) : null;
2539
+ if (typeKind) {
2540
+ for (const id of kindIds(typeKind)) ids.push(id);
2541
+ } else {
2542
+ const declared = node.data?.kind;
2543
+ const kindName = typeof declared === "string" && declared !== "" ? declared : null;
2544
+ if (kindName) {
2545
+ ids.push(kindName);
2546
+ const dataKind = getNodeKind(kindName);
2547
+ if (dataKind) {
2548
+ for (const id of kindIds(dataKind)) ids.push(id);
2549
+ }
2550
+ }
2551
+ }
2552
+ ids.push("*");
2553
+ return [...new Set(ids)];
2554
+ }
2555
+ function pickExecutor(executors, node) {
2556
+ for (const id of executorLookupIds(node)) {
2557
+ if (executors[id]) return executors[id];
2558
+ }
2559
+ return getNodeKind(node.type ?? "")?.executor;
2560
+ }
2561
+ function activatedPorts(node, result) {
2562
+ if (result && typeof result === "object") {
2563
+ const r = result;
2564
+ if (typeof r.__port === "string") {
2565
+ return { ports: [r.__port], value: r.value };
2566
+ }
2567
+ if (typeof r.branch === "string") {
2568
+ return { ports: [r.branch], value: Object.prototype.hasOwnProperty.call(r, "value") ? r.value : r };
2569
+ }
2570
+ }
2571
+ const kind = getNodeKind(node.data?.kind ?? node.type ?? "") ?? void 0;
2572
+ const declared = resolveNodePorts(node, kind).outputs?.map((p) => p.id);
2573
+ return { ports: declared?.length ? declared : ["out"], value: result };
2574
+ }
2575
+ function announce(onEvent, node, phase) {
2576
+ const data = node.data;
2577
+ const raw = phase === "start" ? data?.startingMsg : data?.stoppingMsg;
2578
+ if (typeof raw !== "string") return;
2579
+ const message = raw.trim();
2580
+ if (message === "") return;
2581
+ onEvent({ type: "node-message", nodeId: node.id, phase, message });
2582
+ }
2583
+
2584
+ export { BUILTIN_KIND_DATA, DEFAULT_MAX_DEPTH, RunIdentity, UnresolvedPathError, categoryAccent, clearNodeKindOverrides, declaredRoutes, defaultConfigFor, ensureBuiltinKinds, escapeSegment, evaluateConfig, evaluateExpression, getNodeKind, humanInputFields, kindIds, listNodeKinds, llmRouterExecutor, nodeConfig, onNodeKindsChanged, overrideNodeKind, registerNodeKind, resolveFallbackPort, resolveKindId, resolveNodePorts, resolvePath, resolvePortSpec, runFlow, subflowExecutor, subflowMode, subflowPorts, text, truthy, tryResolvePath, validateConfig };
2585
+ //# sourceMappingURL=chunk-27RZJAE2.js.map
2586
+ //# sourceMappingURL=chunk-27RZJAE2.js.map