@particle-academy/fancy-flow 0.59.0 → 0.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/{HumanPrompt-Bw09OyS3.d.ts → HumanPrompt-BKThiyjB.d.ts} +1 -1
  2. package/dist/{HumanPrompt-DV5-xnDa.d.cts → HumanPrompt-Bpp3JiVH.d.cts} +1 -1
  3. package/dist/{chunk-M7M6GLFV.js → chunk-5H54OTKT.js} +3 -3
  4. package/dist/{chunk-M7M6GLFV.js.map → chunk-5H54OTKT.js.map} +1 -1
  5. package/dist/chunk-77V4QC6Y.js +54 -0
  6. package/dist/chunk-77V4QC6Y.js.map +1 -0
  7. package/dist/{chunk-MX4SJDNI.js → chunk-EO6444T2.js} +4 -4
  8. package/dist/{chunk-MX4SJDNI.js.map → chunk-EO6444T2.js.map} +1 -1
  9. package/dist/{chunk-HBE4LVPX.js → chunk-JF6WCRBU.js} +9 -7
  10. package/dist/chunk-JF6WCRBU.js.map +1 -0
  11. package/dist/{chunk-LGLOBK6Q.js → chunk-MBVX4ZRB.js} +3 -3
  12. package/dist/{chunk-LGLOBK6Q.js.map → chunk-MBVX4ZRB.js.map} +1 -1
  13. package/dist/{chunk-A3TLZS5B.js → chunk-RIAFHQT5.js} +3 -3
  14. package/dist/{chunk-A3TLZS5B.js.map → chunk-RIAFHQT5.js.map} +1 -1
  15. package/dist/{chunk-BHM5HGXH.js → chunk-UM4C46AF.js} +38 -14
  16. package/dist/chunk-UM4C46AF.js.map +1 -0
  17. package/dist/{chunk-FGYG5JVW.js → chunk-W5DPKJY4.js} +5 -3
  18. package/dist/chunk-W5DPKJY4.js.map +1 -0
  19. package/dist/connectors.d.cts +1 -1
  20. package/dist/connectors.d.ts +1 -1
  21. package/dist/durable.cjs +7 -5
  22. package/dist/durable.cjs.map +1 -1
  23. package/dist/durable.js +2 -2
  24. package/dist/engine.cjs +189 -29
  25. package/dist/engine.cjs.map +1 -1
  26. package/dist/engine.d.cts +67 -5
  27. package/dist/engine.d.ts +67 -5
  28. package/dist/engine.js +7 -5
  29. package/dist/engine.js.map +1 -1
  30. package/dist/expr-BfXDrs4H.d.cts +149 -0
  31. package/dist/expr-BfXDrs4H.d.ts +149 -0
  32. package/dist/fields/react-fancy.d.cts +2 -2
  33. package/dist/fields/react-fancy.d.ts +2 -2
  34. package/dist/index.cjs +94 -16
  35. package/dist/index.cjs.map +1 -1
  36. package/dist/index.d.cts +7 -84
  37. package/dist/index.d.ts +7 -84
  38. package/dist/index.js +13 -12
  39. package/dist/index.js.map +1 -1
  40. package/dist/registry/index.d.cts +3 -3
  41. package/dist/registry/index.d.ts +3 -3
  42. package/dist/{registry-1qhmRtvC.d.cts → registry-CntAdUcY.d.cts} +1 -1
  43. package/dist/{registry-D2qocEsY.d.ts → registry-DYoV-MQi.d.ts} +1 -1
  44. package/dist/registry.cjs +7 -5
  45. package/dist/registry.cjs.map +1 -1
  46. package/dist/registry.js +2 -2
  47. package/dist/runtime.cjs +7 -5
  48. package/dist/runtime.cjs.map +1 -1
  49. package/dist/runtime.js +3 -3
  50. package/dist/schema.cjs +57 -5
  51. package/dist/schema.cjs.map +1 -1
  52. package/dist/schema.js +3 -2
  53. package/dist/screens.cjs +7 -5
  54. package/dist/screens.cjs.map +1 -1
  55. package/dist/screens.js +4 -4
  56. package/dist/{types-CLSUZW8c.d.ts → types-Ckhz-YwC.d.ts} +7 -2
  57. package/dist/{types-BLVpNNLN.d.cts → types-D71SKA5A.d.cts} +7 -2
  58. package/dist/ux.cjs +7 -5
  59. package/dist/ux.cjs.map +1 -1
  60. package/dist/ux.d.cts +1 -1
  61. package/dist/ux.d.ts +1 -1
  62. package/dist/ux.js +1 -1
  63. package/package.json +2 -2
  64. package/dist/chunk-BHM5HGXH.js.map +0 -1
  65. package/dist/chunk-FGYG5JVW.js.map +0 -1
  66. package/dist/chunk-HBE4LVPX.js.map +0 -1
@@ -0,0 +1,149 @@
1
+ /**
2
+ * `{{ }}` resolution — the TypeScript twin of `FancyFlow\Nodes\Support\Expr`.
3
+ *
4
+ * ## Why this is opt-in and not wired into `runFlow`
5
+ *
6
+ * The PHP runtime resolves expressions inside its batteries-included executors.
7
+ * The JS runtime never has: `runFlow` hands `node.data.config` to your executor
8
+ * verbatim, so every host that uses `{{ }}` today already resolves it itself.
9
+ * Turning resolution on inside `runFlow` would interpolate a second time over
10
+ * values those hosts had already substituted — silently, and only for graphs
11
+ * that happen to contain `{{` in their DATA.
12
+ *
13
+ * So this exports the semantics and changes no behaviour. Call it yourself:
14
+ *
15
+ * ```ts
16
+ * const url = evaluateExpression(node.data.config.url, inputs);
17
+ * ```
18
+ *
19
+ * ## The semantics are the PHP file's, deliberately
20
+ *
21
+ * This is not a general expression language and must not grow into one. It
22
+ * resolves a dot-path against a context and nothing else — no arithmetic, no
23
+ * comparisons, no calls. Hosts that want real expressions override the executor
24
+ * (the PHP docblock points at symfony/expression-language for the same reason).
25
+ *
26
+ * Divergence here is a correctness bug rather than a style difference: the same
27
+ * graph is authored once and may run on either runtime. `suites/shared/expr`
28
+ * in `@particle-academy/fancy-conformance` is the fixture table both sides run,
29
+ * so parity is a test result instead of a claim.
30
+ */
31
+ /** Anything a context or a resolved value can be. */
32
+ type ExprValue = unknown;
33
+ /** The context an expression resolves against — the executor's `inputs`. */
34
+ type ExprContext = Record<string, ExprValue>;
35
+ /**
36
+ * What a resolution attempt ANSWERS — did the path resolve, and to what.
37
+ *
38
+ * `resolvePath` cannot express this, and that is the defect it exists to fix:
39
+ * it returns `null` both for "this path does not exist" and for "this path
40
+ * exists and holds null". One value standing for two states.
41
+ *
42
+ * At the interpolation layer the collapse is worse, because `null` stringifies
43
+ * to `""`. A consumer put it exactly:
44
+ *
45
+ * > "An unresolvable path yields `''`, so a wrong field is indistinguishable
46
+ * > from an empty one at runtime."
47
+ *
48
+ * A misspelled field renders as an empty string, which looks precisely like a
49
+ * field that is legitimately empty. The graph runs, the node succeeds, and the
50
+ * output is quietly missing a value nobody is told about — worst on
51
+ * LLM-authored graphs, where the field name was guessed in the first place.
52
+ *
53
+ * Same shape as the four `??` collapses fixed across all four runtimes on
54
+ * 2026-08-26 (absent vs null), one layer up: **presence is the only correct
55
+ * test, and a return value that cannot express presence cannot be tested for
56
+ * it.** Hence a second return channel rather than a cleverer sentinel — every
57
+ * sentinel is a legal value for somebody.
58
+ */
59
+ interface Resolution {
60
+ /** Whether the path resolved at all. `false` means it does not exist. */
61
+ resolved: boolean;
62
+ /** The value, when `resolved`. `null` when not — do not read it blind. */
63
+ value: ExprValue;
64
+ }
65
+ /** Thrown by the `"throw"` policy when a path does not resolve. */
66
+ declare class UnresolvedPathError extends Error {
67
+ readonly path: string;
68
+ constructor(path: string);
69
+ }
70
+ /**
71
+ * What evaluation does with a path that does not resolve.
72
+ *
73
+ * - `"empty"` — today's behaviour, and the DEFAULT. Interpolates to `""`; a
74
+ * whole expression yields `null`. Unchanged so that widening this API breaks
75
+ * nobody: 40 call sites across the runtimes assume it.
76
+ * - `"keep"` — leave the `{{ … }}` text in place. The failure becomes VISIBLE
77
+ * in the output without stopping the run, which is what you want for
78
+ * human-reviewed content: a rendered `{{ in.recipient_naem }}` is self-
79
+ * diagnosing in a way an absence never is.
80
+ * - `"throw"` — refuse. For hosts that would rather fail a run than deliver a
81
+ * silently incomplete result.
82
+ *
83
+ * Opt-in before default at the request of the consumer who reported it — the
84
+ * host with the most LLM-authored graphs, and so both the biggest beneficiary
85
+ * and the right place for it to break first if it is going to.
86
+ */
87
+ type UnresolvedPolicy = "empty" | "keep" | "throw";
88
+ /** Options for {@link evaluateExpression} / {@link evaluateConfig}. */
89
+ interface EvaluateOptions {
90
+ /** What to do with a path that does not resolve. Default `"empty"`. */
91
+ onUnresolved?: UnresolvedPolicy;
92
+ }
93
+ /**
94
+ * Resolve a dot-path, reporting WHETHER it resolved.
95
+ *
96
+ * The same walk as `resolvePath` — deliberately, so the two can never disagree
97
+ * about what resolves; `resolvePath` is defined in terms of this one below.
98
+ *
99
+ * A note on JS having two absent values: a key present with the value
100
+ * `undefined` reports `resolved: false`, matching `resolvePath`'s long-standing
101
+ * behaviour. It cannot arise from graph data (JSON has no `undefined`), and
102
+ * changing it would make the two functions disagree for no reachable gain.
103
+ */
104
+ declare function tryResolvePath(path: string, context: ExprContext): Resolution;
105
+ /**
106
+ * Resolve a dot-path against the context, honouring the `$json` / `$input`
107
+ * alias.
108
+ *
109
+ * Both aliases point at the `in` port value when the context has one, and at
110
+ * the whole context otherwise — the same fallback the PHP does, which is what
111
+ * makes `{{ $json.x }}` work on a trigger node that has no upstream input.
112
+ *
113
+ * A path that does not resolve returns `null`, never `undefined`: PHP has one
114
+ * absent value and JS has two, and letting the difference leak would make the
115
+ * two runtimes disagree about `{{ missing }}` for no useful reason.
116
+ */
117
+ declare function resolvePath(path: string, context: ExprContext): ExprValue;
118
+ /**
119
+ * Evaluate a template against a context.
120
+ *
121
+ * A string that is EXACTLY one expression returns the resolved value with its
122
+ * type intact — `{{ $json.count }}` gives you a number, not `"3"`. Anything
123
+ * else interpolates each run as text. That distinction is load-bearing: it is
124
+ * what lets a config field carry either a value or a sentence.
125
+ *
126
+ * Non-string templates pass through untouched, so this is safe to map over a
127
+ * whole config object.
128
+ */
129
+ declare function evaluateExpression(template: ExprValue, context: ExprContext, options?: EvaluateOptions): ExprValue;
130
+ /**
131
+ * Truthiness for branch / switch decisions.
132
+ *
133
+ * Mirrors PHP's rules rather than JavaScript's, because the graph is authored
134
+ * once and may run on either side. The two disagree in exactly the places a
135
+ * workflow hits: `"0"` and `"false"` are truthy in JS and falsy here, and an
136
+ * empty array is truthy in JS and falsy here.
137
+ */
138
+ declare function truthy(value: ExprValue): boolean;
139
+ /** Coerce a value to text the way interpolation does. */
140
+ declare function text(value: ExprValue): string;
141
+ /**
142
+ * Resolve every string in a config object, one level of nesting at a time.
143
+ *
144
+ * The convenience most hosts actually want, and the shape their hand-rolled
145
+ * version usually takes. Opt-in like the rest of this module.
146
+ */
147
+ declare function evaluateConfig<T extends Record<string, ExprValue>>(config: T, context: ExprContext, options?: EvaluateOptions): T;
148
+
149
+ export { type EvaluateOptions as E, type Resolution as R, UnresolvedPathError as U, type ExprContext as a, type ExprValue as b, type UnresolvedPolicy as c, evaluateExpression as d, evaluateConfig as e, truthy as f, tryResolvePath as g, resolvePath as r, text as t };
@@ -0,0 +1,149 @@
1
+ /**
2
+ * `{{ }}` resolution — the TypeScript twin of `FancyFlow\Nodes\Support\Expr`.
3
+ *
4
+ * ## Why this is opt-in and not wired into `runFlow`
5
+ *
6
+ * The PHP runtime resolves expressions inside its batteries-included executors.
7
+ * The JS runtime never has: `runFlow` hands `node.data.config` to your executor
8
+ * verbatim, so every host that uses `{{ }}` today already resolves it itself.
9
+ * Turning resolution on inside `runFlow` would interpolate a second time over
10
+ * values those hosts had already substituted — silently, and only for graphs
11
+ * that happen to contain `{{` in their DATA.
12
+ *
13
+ * So this exports the semantics and changes no behaviour. Call it yourself:
14
+ *
15
+ * ```ts
16
+ * const url = evaluateExpression(node.data.config.url, inputs);
17
+ * ```
18
+ *
19
+ * ## The semantics are the PHP file's, deliberately
20
+ *
21
+ * This is not a general expression language and must not grow into one. It
22
+ * resolves a dot-path against a context and nothing else — no arithmetic, no
23
+ * comparisons, no calls. Hosts that want real expressions override the executor
24
+ * (the PHP docblock points at symfony/expression-language for the same reason).
25
+ *
26
+ * Divergence here is a correctness bug rather than a style difference: the same
27
+ * graph is authored once and may run on either runtime. `suites/shared/expr`
28
+ * in `@particle-academy/fancy-conformance` is the fixture table both sides run,
29
+ * so parity is a test result instead of a claim.
30
+ */
31
+ /** Anything a context or a resolved value can be. */
32
+ type ExprValue = unknown;
33
+ /** The context an expression resolves against — the executor's `inputs`. */
34
+ type ExprContext = Record<string, ExprValue>;
35
+ /**
36
+ * What a resolution attempt ANSWERS — did the path resolve, and to what.
37
+ *
38
+ * `resolvePath` cannot express this, and that is the defect it exists to fix:
39
+ * it returns `null` both for "this path does not exist" and for "this path
40
+ * exists and holds null". One value standing for two states.
41
+ *
42
+ * At the interpolation layer the collapse is worse, because `null` stringifies
43
+ * to `""`. A consumer put it exactly:
44
+ *
45
+ * > "An unresolvable path yields `''`, so a wrong field is indistinguishable
46
+ * > from an empty one at runtime."
47
+ *
48
+ * A misspelled field renders as an empty string, which looks precisely like a
49
+ * field that is legitimately empty. The graph runs, the node succeeds, and the
50
+ * output is quietly missing a value nobody is told about — worst on
51
+ * LLM-authored graphs, where the field name was guessed in the first place.
52
+ *
53
+ * Same shape as the four `??` collapses fixed across all four runtimes on
54
+ * 2026-08-26 (absent vs null), one layer up: **presence is the only correct
55
+ * test, and a return value that cannot express presence cannot be tested for
56
+ * it.** Hence a second return channel rather than a cleverer sentinel — every
57
+ * sentinel is a legal value for somebody.
58
+ */
59
+ interface Resolution {
60
+ /** Whether the path resolved at all. `false` means it does not exist. */
61
+ resolved: boolean;
62
+ /** The value, when `resolved`. `null` when not — do not read it blind. */
63
+ value: ExprValue;
64
+ }
65
+ /** Thrown by the `"throw"` policy when a path does not resolve. */
66
+ declare class UnresolvedPathError extends Error {
67
+ readonly path: string;
68
+ constructor(path: string);
69
+ }
70
+ /**
71
+ * What evaluation does with a path that does not resolve.
72
+ *
73
+ * - `"empty"` — today's behaviour, and the DEFAULT. Interpolates to `""`; a
74
+ * whole expression yields `null`. Unchanged so that widening this API breaks
75
+ * nobody: 40 call sites across the runtimes assume it.
76
+ * - `"keep"` — leave the `{{ … }}` text in place. The failure becomes VISIBLE
77
+ * in the output without stopping the run, which is what you want for
78
+ * human-reviewed content: a rendered `{{ in.recipient_naem }}` is self-
79
+ * diagnosing in a way an absence never is.
80
+ * - `"throw"` — refuse. For hosts that would rather fail a run than deliver a
81
+ * silently incomplete result.
82
+ *
83
+ * Opt-in before default at the request of the consumer who reported it — the
84
+ * host with the most LLM-authored graphs, and so both the biggest beneficiary
85
+ * and the right place for it to break first if it is going to.
86
+ */
87
+ type UnresolvedPolicy = "empty" | "keep" | "throw";
88
+ /** Options for {@link evaluateExpression} / {@link evaluateConfig}. */
89
+ interface EvaluateOptions {
90
+ /** What to do with a path that does not resolve. Default `"empty"`. */
91
+ onUnresolved?: UnresolvedPolicy;
92
+ }
93
+ /**
94
+ * Resolve a dot-path, reporting WHETHER it resolved.
95
+ *
96
+ * The same walk as `resolvePath` — deliberately, so the two can never disagree
97
+ * about what resolves; `resolvePath` is defined in terms of this one below.
98
+ *
99
+ * A note on JS having two absent values: a key present with the value
100
+ * `undefined` reports `resolved: false`, matching `resolvePath`'s long-standing
101
+ * behaviour. It cannot arise from graph data (JSON has no `undefined`), and
102
+ * changing it would make the two functions disagree for no reachable gain.
103
+ */
104
+ declare function tryResolvePath(path: string, context: ExprContext): Resolution;
105
+ /**
106
+ * Resolve a dot-path against the context, honouring the `$json` / `$input`
107
+ * alias.
108
+ *
109
+ * Both aliases point at the `in` port value when the context has one, and at
110
+ * the whole context otherwise — the same fallback the PHP does, which is what
111
+ * makes `{{ $json.x }}` work on a trigger node that has no upstream input.
112
+ *
113
+ * A path that does not resolve returns `null`, never `undefined`: PHP has one
114
+ * absent value and JS has two, and letting the difference leak would make the
115
+ * two runtimes disagree about `{{ missing }}` for no useful reason.
116
+ */
117
+ declare function resolvePath(path: string, context: ExprContext): ExprValue;
118
+ /**
119
+ * Evaluate a template against a context.
120
+ *
121
+ * A string that is EXACTLY one expression returns the resolved value with its
122
+ * type intact — `{{ $json.count }}` gives you a number, not `"3"`. Anything
123
+ * else interpolates each run as text. That distinction is load-bearing: it is
124
+ * what lets a config field carry either a value or a sentence.
125
+ *
126
+ * Non-string templates pass through untouched, so this is safe to map over a
127
+ * whole config object.
128
+ */
129
+ declare function evaluateExpression(template: ExprValue, context: ExprContext, options?: EvaluateOptions): ExprValue;
130
+ /**
131
+ * Truthiness for branch / switch decisions.
132
+ *
133
+ * Mirrors PHP's rules rather than JavaScript's, because the graph is authored
134
+ * once and may run on either side. The two disagree in exactly the places a
135
+ * workflow hits: `"0"` and `"false"` are truthy in JS and falsy here, and an
136
+ * empty array is truthy in JS and falsy here.
137
+ */
138
+ declare function truthy(value: ExprValue): boolean;
139
+ /** Coerce a value to text the way interpolation does. */
140
+ declare function text(value: ExprValue): string;
141
+ /**
142
+ * Resolve every string in a config object, one level of nesting at a time.
143
+ *
144
+ * The convenience most hosts actually want, and the shape their hand-rolled
145
+ * version usually takes. Opt-in like the rest of this module.
146
+ */
147
+ declare function evaluateConfig<T extends Record<string, ExprValue>>(config: T, context: ExprContext, options?: EvaluateOptions): T;
148
+
149
+ export { type EvaluateOptions as E, type Resolution as R, UnresolvedPathError as U, type ExprContext as a, type ExprValue as b, type UnresolvedPolicy as c, evaluateExpression as d, evaluateConfig as e, truthy as f, tryResolvePath as g, resolvePath as r, text as t };
@@ -1,8 +1,8 @@
1
- import { H as HumanFieldRenderers, C as ConfigFieldRenderFn } from '../HumanPrompt-DV5-xnDa.cjs';
1
+ import { H as HumanFieldRenderers, C as ConfigFieldRenderFn } from '../HumanPrompt-Bpp3JiVH.cjs';
2
2
  import 'react';
3
3
  import '../types-JFYjPJAG.cjs';
4
4
  import '@xyflow/react';
5
- import '../types-BLVpNNLN.cjs';
5
+ import '../types-D71SKA5A.cjs';
6
6
  import '../pause-9iT4tCEV.cjs';
7
7
 
8
8
  /**
@@ -1,8 +1,8 @@
1
- import { H as HumanFieldRenderers, C as ConfigFieldRenderFn } from '../HumanPrompt-Bw09OyS3.js';
1
+ import { H as HumanFieldRenderers, C as ConfigFieldRenderFn } from '../HumanPrompt-BKThiyjB.js';
2
2
  import 'react';
3
3
  import '../types-JFYjPJAG.js';
4
4
  import '@xyflow/react';
5
- import '../types-CLSUZW8c.js';
5
+ import '../types-Ckhz-YwC.js';
6
6
  import '../pause-9iT4tCEV.js';
7
7
 
8
8
  /**
package/dist/index.cjs CHANGED
@@ -11445,7 +11445,7 @@ function activatedPorts(node, result) {
11445
11445
  return { ports: [r.__port], value: r.value };
11446
11446
  }
11447
11447
  if (typeof r.branch === "string") {
11448
- return { ports: [r.branch], value: r.value ?? r };
11448
+ return { ports: [r.branch], value: Object.prototype.hasOwnProperty.call(r, "value") ? r.value : r };
11449
11449
  }
11450
11450
  }
11451
11451
  const kind = getNodeKind(node.data?.kind ?? node.type ?? "") ?? void 0;
@@ -11607,8 +11607,9 @@ var KINDS = [
11607
11607
  // ───────────── Triggers ─────────────
11608
11608
  {
11609
11609
  name: "@particle-academy/manual_trigger",
11610
- // returns the inputs it was started with
11611
- emits: "input",
11610
+ // Returns the raw inputs MAP, not the `in` port -- flat at an entry
11611
+ // point, port-keyed the moment the node has an inbound edge.
11612
+ emits: "input-map-merged",
11612
11613
  aliases: ["manual_trigger", "@fancy/manual_trigger"],
11613
11614
  category: "trigger",
11614
11615
  label: "Manual",
@@ -11641,8 +11642,9 @@ var KINDS = [
11641
11642
  { path: "cron", type: "string", description: "The cron expression that fired." },
11642
11643
  { path: "timezone", type: "string", description: "The timezone it was evaluated in." }
11643
11644
  ],
11644
- // merges its inputs into the TOP level beside cron/timezone
11645
- emits: "inputs-merged",
11645
+ // Merges $ctx->inputs ITSELF beside cron/timezone -- the MAP, whose
11646
+ // shape depends on position, not each port's payload the way `merge` does.
11647
+ emits: "input-map-merged",
11646
11648
  aliases: ["schedule_trigger", "@fancy/schedule_trigger"],
11647
11649
  category: "trigger",
11648
11650
  label: "Schedule",
@@ -14388,6 +14390,55 @@ function preview(v2) {
14388
14390
  }
14389
14391
  }
14390
14392
 
14393
+ // src/analysis/graph-connectivity.ts
14394
+ function checkGraphConnectivity(graph) {
14395
+ const { nodes, edges } = graph;
14396
+ const issues = [];
14397
+ const hasIncoming = /* @__PURE__ */ new Set();
14398
+ const hasOutgoing = /* @__PURE__ */ new Set();
14399
+ for (const edge of edges) {
14400
+ hasIncoming.add(edge.target);
14401
+ hasOutgoing.add(edge.source);
14402
+ }
14403
+ const single = nodes.length === 1;
14404
+ for (const node of nodes) {
14405
+ if (single || mayFloat(node)) continue;
14406
+ if (!hasIncoming.has(node.id) && !hasOutgoing.has(node.id)) {
14407
+ issues.push({
14408
+ level: "error",
14409
+ nodeId: node.id,
14410
+ message: `Node "${node.id}" is connected to nothing \u2014 no inbound edge and no outbound edge. It still RUNS (a node with no inbound edge is a root), but it receives nothing from the graph and reaches nobody in it, so it is either unwired or left behind by a deletion. Only a note, an annotation or a lane may float.`
14411
+ });
14412
+ }
14413
+ }
14414
+ const byId = new Map(nodes.map((n) => [n.id, n]));
14415
+ for (const edge of edges) {
14416
+ const source = byId.get(edge.source);
14417
+ if (!source || !isTerminator(source)) continue;
14418
+ issues.push({
14419
+ level: "error",
14420
+ edgeId: edge.id,
14421
+ message: `Edge "${edge.id}" reads from "${edge.source}", which is a TERMINAL node and publishes no output ports at all. Nothing can ever travel this edge: it does not fail at run time, it delivers nothing, and "${edge.target}" runs anyway with an empty input.`
14422
+ });
14423
+ }
14424
+ return issues;
14425
+ }
14426
+ function mayFloat(node) {
14427
+ const type = node.type;
14428
+ if (!type) return false;
14429
+ if (type === "note") return true;
14430
+ const kind = getNodeKind(type);
14431
+ if (!kind) return true;
14432
+ return kindIds(kind).includes("note") || kind.category === "annotation" || kind.category === "layout";
14433
+ }
14434
+ function isTerminator(node) {
14435
+ const own = node.data?.outputs;
14436
+ if (Array.isArray(own)) return own.length === 0;
14437
+ const kind = node.type ? getNodeKind(node.type) : null;
14438
+ if (!kind) return false;
14439
+ return Array.isArray(kind.outputs) && kind.outputs.length === 0;
14440
+ }
14441
+
14391
14442
  // src/schema/workflow-schema.ts
14392
14443
  var WORKFLOW_SCHEMA_VERSION = 1;
14393
14444
  var WORKFLOW_SCHEMA_URL = "https://particle.academy/schemas/workflow/v1.json";
@@ -14535,6 +14586,7 @@ function importWorkflow(schema, options = {}) {
14535
14586
  label: e.label
14536
14587
  };
14537
14588
  }).filter((e) => e !== null);
14589
+ issues.push(...checkGraphConnectivity({ nodes, edges }));
14538
14590
  const ok = issues.every((i) => i.level !== "error");
14539
14591
  const declaredInputs = Array.isArray(s.inputs) ? s.inputs.filter(
14540
14592
  (input) => input !== null && typeof input === "object" && typeof input.name === "string"
@@ -15874,9 +15926,19 @@ function interpolate(template, resolve) {
15874
15926
  }
15875
15927
  }
15876
15928
  var FALSY_STRINGS = /* @__PURE__ */ new Set(["", "0", "false", "no", "off", "null"]);
15877
- function resolvePath(path, context) {
15929
+ var UnresolvedPathError = class extends Error {
15930
+ constructor(path) {
15931
+ super(
15932
+ `Expression path "${path}" did not resolve. Under the "throw" policy an unresolvable path is an error rather than an empty string.`
15933
+ );
15934
+ this.path = path;
15935
+ this.name = "UnresolvedPathError";
15936
+ }
15937
+ };
15938
+ function tryResolvePath(path, context) {
15939
+ const unresolved = { resolved: false, value: null };
15878
15940
  const trimmed = path.trim();
15879
- if (trimmed === "") return null;
15941
+ if (trimmed === "") return unresolved;
15880
15942
  const segments = trimmed.split(".");
15881
15943
  let cursor;
15882
15944
  const head = segments[0];
@@ -15887,19 +15949,33 @@ function resolvePath(path, context) {
15887
15949
  cursor = context;
15888
15950
  }
15889
15951
  for (const segment of segments) {
15890
- if (cursor === null || cursor === void 0) return null;
15891
- if (typeof cursor !== "object") return null;
15952
+ if (cursor === null || cursor === void 0) return unresolved;
15953
+ if (typeof cursor !== "object") return unresolved;
15892
15954
  const next = cursor[segment];
15893
- if (next === void 0) return null;
15955
+ if (next === void 0) return unresolved;
15894
15956
  cursor = next;
15895
15957
  }
15896
- return cursor === void 0 ? null : cursor;
15958
+ return { resolved: true, value: cursor === void 0 ? null : cursor };
15897
15959
  }
15898
- function evaluateExpression(template, context) {
15960
+ function resolvePath(path, context) {
15961
+ return tryResolvePath(path, context).value;
15962
+ }
15963
+ function evaluateExpression(template, context, options = {}) {
15899
15964
  if (typeof template !== "string") return template;
15965
+ const policy = options.onUnresolved ?? "empty";
15900
15966
  const whole = wholeExpression(template.trim());
15901
- if (whole !== null) return resolvePath(whole, context);
15902
- return interpolate(template, (path) => stringify(resolvePath(path, context)));
15967
+ if (whole !== null) {
15968
+ const r = tryResolvePath(whole, context);
15969
+ if (r.resolved) return r.value;
15970
+ if (policy === "throw") throw new UnresolvedPathError(whole);
15971
+ return policy === "keep" ? template : null;
15972
+ }
15973
+ return interpolate(template, (path) => {
15974
+ const r = tryResolvePath(path, context);
15975
+ if (r.resolved) return stringify(r.value);
15976
+ if (policy === "throw") throw new UnresolvedPathError(path);
15977
+ return policy === "keep" ? `{{${path}}}` : "";
15978
+ });
15903
15979
  }
15904
15980
  function truthy(value) {
15905
15981
  if (typeof value === "boolean") return value;
@@ -15923,10 +15999,10 @@ function stringify(value) {
15923
15999
  return "";
15924
16000
  }
15925
16001
  }
15926
- function evaluateConfig(config, context) {
16002
+ function evaluateConfig(config, context, options = {}) {
15927
16003
  const out = {};
15928
16004
  for (const [key, value] of Object.entries(config)) {
15929
- out[key] = Array.isArray(value) ? value.map((v2) => evaluateExpression(v2, context)) : value !== null && typeof value === "object" ? evaluateConfig(value, context) : evaluateExpression(value, context);
16005
+ out[key] = Array.isArray(value) ? value.map((v2) => evaluateExpression(v2, context, options)) : value !== null && typeof value === "object" ? evaluateConfig(value, context, options) : evaluateExpression(value, context, options);
15930
16006
  }
15931
16007
  return out;
15932
16008
  }
@@ -15963,6 +16039,7 @@ exports.RegistryNode = RegistryNode;
15963
16039
  exports.RichInputPreview = RichInputPreview;
15964
16040
  exports.SubgraphNode = SubgraphNode;
15965
16041
  exports.TriggerNode = TriggerNode;
16042
+ exports.UnresolvedPathError = UnresolvedPathError;
15966
16043
  exports.WORKFLOW_SCHEMA_URL = WORKFLOW_SCHEMA_URL;
15967
16044
  exports.WORKFLOW_SCHEMA_VERSION = WORKFLOW_SCHEMA_VERSION;
15968
16045
  exports.alignNodes = alignNodes;
@@ -16014,6 +16091,7 @@ exports.runCohort = runCohort;
16014
16091
  exports.runFlow = runFlow;
16015
16092
  exports.text = text;
16016
16093
  exports.truthy = truthy;
16094
+ exports.tryResolvePath = tryResolvePath;
16017
16095
  exports.useFlowEditor = useFlowEditor;
16018
16096
  exports.useFlowEditorOptional = useFlowEditorOptional;
16019
16097
  exports.useFlowHistory = useFlowHistory;