@nodetool-ai/node-sdk 0.7.0-rc.23 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -0
- package/dist/.nodetool-build-stamp +1 -0
- package/dist/base-node.d.ts +285 -14
- package/dist/base-node.d.ts.map +1 -1
- package/dist/base-node.js +359 -49
- package/dist/base-node.js.map +1 -1
- package/dist/class-name-to-title.d.ts +26 -0
- package/dist/class-name-to-title.d.ts.map +1 -0
- package/dist/class-name-to-title.js +59 -0
- package/dist/class-name-to-title.js.map +1 -0
- package/dist/code-analysis.d.ts +234 -0
- package/dist/code-analysis.d.ts.map +1 -0
- package/dist/code-analysis.js +919 -0
- package/dist/code-analysis.js.map +1 -0
- package/dist/code-body.d.ts +61 -0
- package/dist/code-body.d.ts.map +1 -0
- package/dist/code-body.js +288 -0
- package/dist/code-body.js.map +1 -0
- package/dist/code-node-validation.d.ts +67 -0
- package/dist/code-node-validation.d.ts.map +1 -0
- package/dist/code-node-validation.js +540 -0
- package/dist/code-node-validation.js.map +1 -0
- package/dist/content-card.d.ts +30 -0
- package/dist/content-card.d.ts.map +1 -0
- package/dist/content-card.js +68 -0
- package/dist/content-card.js.map +1 -0
- package/dist/correlation-validation.d.ts +19 -0
- package/dist/correlation-validation.d.ts.map +1 -0
- package/dist/correlation-validation.js +101 -0
- package/dist/correlation-validation.js.map +1 -0
- package/dist/cost-estimate.d.ts +244 -0
- package/dist/cost-estimate.d.ts.map +1 -0
- package/dist/cost-estimate.js +637 -0
- package/dist/cost-estimate.js.map +1 -0
- package/dist/decorators.d.ts.map +1 -1
- package/dist/decorators.js +4 -1
- package/dist/decorators.js.map +1 -1
- package/dist/docs/nodes.d.ts.map +1 -1
- package/dist/docs/nodes.js +3 -2
- package/dist/docs/nodes.js.map +1 -1
- package/dist/docs/overview.d.ts.map +1 -1
- package/dist/docs/overview.js +4 -3
- package/dist/docs/overview.js.map +1 -1
- package/dist/docs/workflows.d.ts.map +1 -1
- package/dist/docs/workflows.js +10 -9
- package/dist/docs/workflows.js.map +1 -1
- package/dist/field-classification.d.ts +31 -0
- package/dist/field-classification.d.ts.map +1 -0
- package/dist/field-classification.js +58 -0
- package/dist/field-classification.js.map +1 -0
- package/dist/graph-validation.d.ts +239 -0
- package/dist/graph-validation.d.ts.map +1 -0
- package/dist/graph-validation.js +1174 -0
- package/dist/graph-validation.js.map +1 -0
- package/dist/index.d.ts +27 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +29 -1
- package/dist/index.js.map +1 -1
- package/dist/js-script-link.d.ts +53 -0
- package/dist/js-script-link.d.ts.map +1 -0
- package/dist/js-script-link.js +58 -0
- package/dist/js-script-link.js.map +1 -0
- package/dist/js-script-materialize.d.ts +52 -0
- package/dist/js-script-materialize.d.ts.map +1 -0
- package/dist/js-script-materialize.js +24 -0
- package/dist/js-script-materialize.js.map +1 -0
- package/dist/manifest-node-values.d.ts +106 -0
- package/dist/manifest-node-values.d.ts.map +1 -0
- package/dist/manifest-node-values.js +195 -0
- package/dist/manifest-node-values.js.map +1 -0
- package/dist/metadata.d.ts +75 -3
- package/dist/metadata.d.ts.map +1 -1
- package/dist/metadata.js +132 -49
- package/dist/metadata.js.map +1 -1
- package/dist/node-metadata.d.ts.map +1 -1
- package/dist/node-metadata.js +61 -56
- package/dist/node-metadata.js.map +1 -1
- package/dist/node-type-inventory.d.ts +51 -0
- package/dist/node-type-inventory.d.ts.map +1 -0
- package/dist/node-type-inventory.js +121 -0
- package/dist/node-type-inventory.js.map +1 -0
- package/dist/nodes/test-nodes.d.ts +77 -30
- package/dist/nodes/test-nodes.d.ts.map +1 -1
- package/dist/nodes/test-nodes.js +2 -34
- package/dist/nodes/test-nodes.js.map +1 -1
- package/dist/pack-loader.d.ts +202 -0
- package/dist/pack-loader.d.ts.map +1 -0
- package/dist/pack-loader.js +529 -0
- package/dist/pack-loader.js.map +1 -0
- package/dist/port-types.d.ts +15 -0
- package/dist/port-types.d.ts.map +1 -0
- package/dist/port-types.js +72 -0
- package/dist/port-types.js.map +1 -0
- package/dist/pricing-bundle.d.ts +83 -0
- package/dist/pricing-bundle.d.ts.map +1 -0
- package/dist/pricing-bundle.js +82 -0
- package/dist/pricing-bundle.js.map +1 -0
- package/dist/pricing-params.d.ts +59 -0
- package/dist/pricing-params.d.ts.map +1 -0
- package/dist/pricing-params.js +282 -0
- package/dist/pricing-params.js.map +1 -0
- package/dist/python-package-scan.d.ts +8 -0
- package/dist/python-package-scan.d.ts.map +1 -1
- package/dist/python-package-scan.js +21 -8
- package/dist/python-package-scan.js.map +1 -1
- package/dist/registry.d.ts +105 -2
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +403 -30
- package/dist/registry.js.map +1 -1
- package/dist/sandbox-bridge-packs.d.ts +39 -0
- package/dist/sandbox-bridge-packs.d.ts.map +1 -0
- package/dist/sandbox-bridge-packs.js +249 -0
- package/dist/sandbox-bridge-packs.js.map +1 -0
- package/dist/sandbox-catalog-host.d.ts +31 -0
- package/dist/sandbox-catalog-host.d.ts.map +1 -0
- package/dist/sandbox-catalog-host.js +78 -0
- package/dist/sandbox-catalog-host.js.map +1 -0
- package/dist/sandbox-module-catalog.d.ts +14 -0
- package/dist/sandbox-module-catalog.d.ts.map +1 -0
- package/dist/sandbox-module-catalog.js +402 -0
- package/dist/sandbox-module-catalog.js.map +1 -0
- package/dist/sandbox-npm-artifacts.d.ts +56 -0
- package/dist/sandbox-npm-artifacts.d.ts.map +1 -0
- package/dist/sandbox-npm-artifacts.js +17 -0
- package/dist/sandbox-npm-artifacts.js.map +1 -0
- package/dist/sandbox-pack-discovery.d.ts +93 -0
- package/dist/sandbox-pack-discovery.d.ts.map +1 -0
- package/dist/sandbox-pack-discovery.js +807 -0
- package/dist/sandbox-pack-discovery.js.map +1 -0
- package/dist/search.d.ts +29 -1
- package/dist/search.d.ts.map +1 -1
- package/dist/search.js +136 -9
- package/dist/search.js.map +1 -1
- package/dist/type-compat.d.ts +36 -0
- package/dist/type-compat.d.ts.map +1 -0
- package/dist/type-compat.js +98 -0
- package/dist/type-compat.js.map +1 -0
- package/dist/type-predicates.d.ts +25 -0
- package/dist/type-predicates.d.ts.map +1 -0
- package/dist/type-predicates.js +31 -0
- package/dist/type-predicates.js.map +1 -0
- package/dist/validation.d.ts +18 -0
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +70 -11
- package/dist/validation.js.map +1 -1
- package/dist/workflow-document-tools.d.ts +35 -0
- package/dist/workflow-document-tools.d.ts.map +1 -0
- package/dist/workflow-document-tools.js +402 -0
- package/dist/workflow-document-tools.js.map +1 -0
- package/dist/workflow-interface.d.ts +62 -0
- package/dist/workflow-interface.d.ts.map +1 -0
- package/dist/workflow-interface.js +351 -0
- package/dist/workflow-interface.js.map +1 -0
- package/package.json +67 -10
- package/dist/package-registry-client.d.ts +0 -12
- package/dist/package-registry-client.d.ts.map +0 -1
- package/dist/package-registry-client.js +0 -40
- package/dist/package-registry-client.js.map +0 -1
|
@@ -0,0 +1,1174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static workflow-graph validation.
|
|
3
|
+
*
|
|
4
|
+
* Checks a graph against the node registry WITHOUT executing it: unknown node
|
|
5
|
+
* types, duplicate ids, missing required / unselected-model properties, dangling
|
|
6
|
+
* edges, unknown edge handles, mis-spelled dynamic slot types, Code node bodies
|
|
7
|
+
* (syntax, inputs, outputs), and (best-effort) edge type mismatches. Catches the
|
|
8
|
+
* breakage that would otherwise only surface after a full workflow run.
|
|
9
|
+
*
|
|
10
|
+
* Takes a {@link GraphValidationRegistry} (the slice of NodeRegistry it needs)
|
|
11
|
+
* so it can be unit-tested with a fake and reused by the CLI and agent tools.
|
|
12
|
+
*/
|
|
13
|
+
import { isLoopFeedbackHandle, legacyGameNodeDiagnostic, migrateGraphNodeTypes } from "@nodetool-ai/protocol";
|
|
14
|
+
import { getProcessSandboxModuleCatalog } from "@nodetool-ai/runtime";
|
|
15
|
+
import { inferredCodeInputNames, inferredCodeOutputNames } from "./code-analysis.js";
|
|
16
|
+
import { isJsCodeNodeType, validateCodeNodeBody } from "./code-node-validation.js";
|
|
17
|
+
import { jsScriptPortMismatches, readJsScriptLink } from "./js-script-link.js";
|
|
18
|
+
import { portTypeAliases } from "./port-types.js";
|
|
19
|
+
import { isNonEmptyString, isObjectLike, isRecord, isString } from "./type-predicates.js";
|
|
20
|
+
import { slotTypeToString, typeMetaToString, typesIncompatible, valueIncompatibleWithType } from "./type-compat.js";
|
|
21
|
+
/**
|
|
22
|
+
* How to gather several values into one list. `Collect` gathers the items of
|
|
23
|
+
* one stream, so an agent reaching for it with N edges needs the list-typed
|
|
24
|
+
* fan-in target named.
|
|
25
|
+
*/
|
|
26
|
+
const FAN_IN_REMEDY = "To gather several values into one list, wire each edge into a list-typed " +
|
|
27
|
+
"input instead: `value` on nodetool.constant.List accepts any number of " +
|
|
28
|
+
"edges and outputs them as one list. nodetool.control.Collect gathers the " +
|
|
29
|
+
"items of a single stream.";
|
|
30
|
+
/**
|
|
31
|
+
* Editor-only base nodes that carry no executable class — the graph loader
|
|
32
|
+
* prunes them before a run, so they must not be flagged as "unknown". Their
|
|
33
|
+
* short names; matched against the `nodetool.workflows.base_node.*` namespace.
|
|
34
|
+
*/
|
|
35
|
+
const EDITOR_ONLY_NODE_NAMES = new Set([
|
|
36
|
+
"Comment",
|
|
37
|
+
"Group",
|
|
38
|
+
"Reroute"
|
|
39
|
+
]);
|
|
40
|
+
export function isEditorOnlyType(nodeType) {
|
|
41
|
+
const dot = nodeType.lastIndexOf(".");
|
|
42
|
+
const name = dot >= 0 ? nodeType.slice(dot + 1) : nodeType;
|
|
43
|
+
return (nodeType.includes(".workflows.base_node.") &&
|
|
44
|
+
EDITOR_ONLY_NODE_NAMES.has(name));
|
|
45
|
+
}
|
|
46
|
+
/** Handles like `__control__` / `__output__` are framework-internal, not props. */
|
|
47
|
+
function isReservedHandle(handle) {
|
|
48
|
+
return handle.startsWith("__") && handle.endsWith("__");
|
|
49
|
+
}
|
|
50
|
+
function asRecord(value) {
|
|
51
|
+
if (!isRecord(value)) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* `nodes` / `edges` as the arrays their types promise. The graph is parsed from
|
|
58
|
+
* untrusted JSON on every entry point — a file, a stored row, an LLM's inline
|
|
59
|
+
* graph — so a member that is a number or an object reaches here and has to be
|
|
60
|
+
* reported rather than thrown at `.map`. Absent stays absent: an empty half of
|
|
61
|
+
* a graph is what the other checks already read it as.
|
|
62
|
+
*/
|
|
63
|
+
function graphMembers(value, field, issues) {
|
|
64
|
+
if (value == null)
|
|
65
|
+
return [];
|
|
66
|
+
if (Array.isArray(value))
|
|
67
|
+
return value;
|
|
68
|
+
issues.push({
|
|
69
|
+
severity: "error",
|
|
70
|
+
code: "invalid_graph",
|
|
71
|
+
message: `graph.${field} must be an array, not ${isObjectLike(value) ? "an object" : `a ${typeof value}`}`
|
|
72
|
+
});
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Typed dynamic slot declarations of a node, from either graph shape: the
|
|
77
|
+
* kernel/runner node carries `dynamic_inputs` at the top level, a ReactFlow
|
|
78
|
+
* node carries it inside `data`.
|
|
79
|
+
*/
|
|
80
|
+
function readDynamicInputs(node) {
|
|
81
|
+
const own = asRecord(node.dynamic_inputs);
|
|
82
|
+
if (own)
|
|
83
|
+
return own;
|
|
84
|
+
const fromData = asRecord(node.data?.dynamic_inputs);
|
|
85
|
+
return (fromData ?? {});
|
|
86
|
+
}
|
|
87
|
+
/** Inline dynamic slot values, from either graph shape. */
|
|
88
|
+
function readDynamicProperties(node) {
|
|
89
|
+
return (asRecord(node.dynamic_properties) ??
|
|
90
|
+
asRecord(node.data?.dynamic_properties) ??
|
|
91
|
+
{});
|
|
92
|
+
}
|
|
93
|
+
/** Typed dynamic output declarations, from either graph shape. */
|
|
94
|
+
function readDynamicOutputs(node) {
|
|
95
|
+
return (asRecord(node.dynamic_outputs) ?? asRecord(node.data?.dynamic_outputs) ?? {});
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The declared properties of a node, from either graph shape.
|
|
99
|
+
*
|
|
100
|
+
* The editor's `NodeData` nests the props bag under `data.properties` (the same
|
|
101
|
+
* wrapper `readDynamicInputs` and friends unwrap). Reading `data` itself as the
|
|
102
|
+
* bag made every editor-exported node look like it was missing every required
|
|
103
|
+
* property. `data` is still the fallback for older/hand-written shapes that
|
|
104
|
+
* flattened the props onto it.
|
|
105
|
+
*/
|
|
106
|
+
function readProperties(node) {
|
|
107
|
+
return (asRecord(node.properties) ??
|
|
108
|
+
asRecord(node.data?.properties) ??
|
|
109
|
+
asRecord(node.data) ??
|
|
110
|
+
{});
|
|
111
|
+
}
|
|
112
|
+
/** True for a DSL wiring handle ({__handle: true, source, sourceHandle}). */
|
|
113
|
+
function isWiringHandle(value) {
|
|
114
|
+
return isRecord(value) && value["__handle"] === true;
|
|
115
|
+
}
|
|
116
|
+
/** Property paths holding a wiring handle, e.g. `tiles[0]`. */
|
|
117
|
+
function collectWiringHandlePaths(value, path, out, depth = 0) {
|
|
118
|
+
if (depth > MODEL_REF_MAX_DEPTH)
|
|
119
|
+
return;
|
|
120
|
+
if (Array.isArray(value)) {
|
|
121
|
+
value.forEach((item, index) => collectWiringHandlePaths(item, `${path}[${index}]`, out, depth + 1));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (!isRecord(value))
|
|
125
|
+
return;
|
|
126
|
+
if (isWiringHandle(value)) {
|
|
127
|
+
out.push(path);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
for (const [key, child] of Object.entries(value)) {
|
|
131
|
+
collectWiringHandlePaths(child, path ? `${path}.${key}` : key, out, depth + 1);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* A control edge is spelled `edge_type: "control"` in the kernel/runner shape
|
|
136
|
+
* and `type: "control"` / `data.edge_type: "control"` in the ReactFlow shape.
|
|
137
|
+
*/
|
|
138
|
+
function isControlEdge(raw) {
|
|
139
|
+
return (raw.edge_type === "control" ||
|
|
140
|
+
raw.type === "control" ||
|
|
141
|
+
raw.data?.edge_type === "control");
|
|
142
|
+
}
|
|
143
|
+
function normalizeEdge(raw, index) {
|
|
144
|
+
return {
|
|
145
|
+
id: isString(raw.id) ? raw.id : `edge-${index}`,
|
|
146
|
+
source: String(raw.source ?? ""),
|
|
147
|
+
sourceHandle: String(raw.sourceHandle ?? raw.source_handle ?? ""),
|
|
148
|
+
target: String(raw.target ?? ""),
|
|
149
|
+
targetHandle: String(raw.targetHandle ?? raw.target_handle ?? ""),
|
|
150
|
+
isControl: isControlEdge(raw)
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* True for a model-reference value: a tagged object whose `type` ends in
|
|
155
|
+
* `_model` (`image_model`, `asr_model`, `tts_model`, `language_model`, …).
|
|
156
|
+
* Keying on that suffix avoids reacting to any unrelated object that happens
|
|
157
|
+
* to carry a `provider` field.
|
|
158
|
+
*/
|
|
159
|
+
function isModelRef(value) {
|
|
160
|
+
// Null first, matching `asRecord` above: `typeof null === "object"`, so the
|
|
161
|
+
// check is load-bearing — without it `value.type` below throws on null.
|
|
162
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
const kind = value.type;
|
|
166
|
+
return typeof kind === "string" && kind.endsWith("_model");
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* How deep the property walk goes looking for model refs. A node's props are a
|
|
170
|
+
* shallow bag by convention; the depth exists for the shapes that nest one —
|
|
171
|
+
* a list of models, or a model inside a settings object — not to crawl payloads.
|
|
172
|
+
*/
|
|
173
|
+
const MODEL_REF_MAX_DEPTH = 4;
|
|
174
|
+
/**
|
|
175
|
+
* Every model reference reachable from `value`.
|
|
176
|
+
*
|
|
177
|
+
* Checking only top-level property values missed the ones the type system
|
|
178
|
+
* allows anywhere: a `list[image_model]` property, or a model nested in a
|
|
179
|
+
* config object. Those fail exactly like a top-level one does — once the node
|
|
180
|
+
* runs — so they are worth the same check.
|
|
181
|
+
*/
|
|
182
|
+
function collectModelRefs(value, path, propertyName, out, depth = 0) {
|
|
183
|
+
if (depth > MODEL_REF_MAX_DEPTH)
|
|
184
|
+
return;
|
|
185
|
+
if (Array.isArray(value)) {
|
|
186
|
+
value.forEach((item, index) => collectModelRefs(item, `${path}[${index}]`, propertyName, out, depth + 1));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (!isRecord(value))
|
|
190
|
+
return;
|
|
191
|
+
if (isModelRef(value)) {
|
|
192
|
+
out.push({ path, propertyName, ref: value });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
for (const [key, child] of Object.entries(value)) {
|
|
196
|
+
collectModelRefs(child, path ? `${path}.${key}` : key, propertyName, out, depth + 1);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/** Model references in one node's declared and dynamic property bags. */
|
|
200
|
+
function collectNodeModelRefs(node) {
|
|
201
|
+
const sites = [];
|
|
202
|
+
for (const [propName, value] of Object.entries(readProperties(node))) {
|
|
203
|
+
collectModelRefs(value, propName, propName, sites);
|
|
204
|
+
}
|
|
205
|
+
for (const [propName, value] of Object.entries(readDynamicProperties(node))) {
|
|
206
|
+
collectModelRefs(value, propName, propName, sites);
|
|
207
|
+
}
|
|
208
|
+
return sites;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Provider id of a model reference, or undefined when it names none. A blank
|
|
212
|
+
* provider is left to {@link collectModelSelectionIssues}, which reports it
|
|
213
|
+
* only when an id is present — an entirely empty ref is an unselected model,
|
|
214
|
+
* which the registry's own property check already covers.
|
|
215
|
+
*/
|
|
216
|
+
function modelProviderOf(ref) {
|
|
217
|
+
const provider = ref.provider;
|
|
218
|
+
if (!isNonEmptyString(provider))
|
|
219
|
+
return undefined;
|
|
220
|
+
return provider;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* The type an edge's source handle carries, reporting any handle it cannot
|
|
224
|
+
* resolve. An empty string means the edge is not type-checked at this end — no
|
|
225
|
+
* metadata, a reserved handle, or a dynamic output declaring no type.
|
|
226
|
+
*/
|
|
227
|
+
function resolveSourceHandleType(edge, sourceNode, sourceMeta, issues) {
|
|
228
|
+
if (!sourceMeta ||
|
|
229
|
+
!edge.sourceHandle ||
|
|
230
|
+
isReservedHandle(edge.sourceHandle)) {
|
|
231
|
+
return "";
|
|
232
|
+
}
|
|
233
|
+
const declared = sourceMeta.outputs.find((o) => o.name === edge.sourceHandle);
|
|
234
|
+
if (declared)
|
|
235
|
+
return typeMetaToString(declared.type);
|
|
236
|
+
// Metadata, not instance state: `reactFlowNodeToGraphNode` writes
|
|
237
|
+
// `dynamic_outputs: {}` on every node, so testing the instance field
|
|
238
|
+
// disabled this check for nearly every saved graph. Mirrors the target
|
|
239
|
+
// side's use of `supports_dynamic_inputs`.
|
|
240
|
+
if (sourceMeta.supports_dynamic_outputs !== true) {
|
|
241
|
+
issues.push({
|
|
242
|
+
severity: "error",
|
|
243
|
+
code: "unknown_handle",
|
|
244
|
+
edgeId: edge.id,
|
|
245
|
+
nodeId: edge.source,
|
|
246
|
+
nodeType: String(sourceNode.type ?? ""),
|
|
247
|
+
message: `Edge "${edge.id}" references output "${edge.sourceHandle}" not found on ${String(sourceNode.type)}`
|
|
248
|
+
});
|
|
249
|
+
return "";
|
|
250
|
+
}
|
|
251
|
+
// An empty map is a node that never declared any dynamic output (the
|
|
252
|
+
// ReactFlow → graph conversion writes `{}` on every node), so only a
|
|
253
|
+
// populated one can say a handle is missing. A name the body emits counts as
|
|
254
|
+
// declared: the editor shows it and an agent can connect to it.
|
|
255
|
+
const dynamicOutputs = readDynamicOutputs(sourceNode);
|
|
256
|
+
const names = Object.keys(dynamicOutputs);
|
|
257
|
+
const inferred = isJsCodeNodeType(String(sourceNode.type ?? ""))
|
|
258
|
+
? inferredCodeOutputNames(String(readProperties(sourceNode).code ?? ""))
|
|
259
|
+
: [];
|
|
260
|
+
if (names.length > 0 &&
|
|
261
|
+
!new Set([...names, ...inferred]).has(edge.sourceHandle)) {
|
|
262
|
+
issues.push({
|
|
263
|
+
severity: "error",
|
|
264
|
+
code: "unknown_handle",
|
|
265
|
+
edgeId: edge.id,
|
|
266
|
+
nodeId: edge.source,
|
|
267
|
+
nodeType: String(sourceNode.type ?? ""),
|
|
268
|
+
message: `Edge "${edge.id}" references output "${edge.sourceHandle}" on ${String(sourceNode.type)}, which declares ${names.map((n) => `"${n}"`).join(", ")}`
|
|
269
|
+
});
|
|
270
|
+
return "";
|
|
271
|
+
}
|
|
272
|
+
return typeMetaToString(dynamicOutputs[edge.sourceHandle]);
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* The type an edge's target handle expects — the mirror of
|
|
276
|
+
* {@link resolveSourceHandleType}, except that a dynamic slot declaring no
|
|
277
|
+
* type is reported rather than passed over in silence.
|
|
278
|
+
*/
|
|
279
|
+
function resolveTargetHandleType(edge, targetNode, targetMeta, issues) {
|
|
280
|
+
if (!targetMeta ||
|
|
281
|
+
!edge.targetHandle ||
|
|
282
|
+
isReservedHandle(edge.targetHandle)) {
|
|
283
|
+
return { type: "", typedDynamicSlot: false };
|
|
284
|
+
}
|
|
285
|
+
const declared = targetMeta.properties.find((p) => p.name === edge.targetHandle);
|
|
286
|
+
if (declared) {
|
|
287
|
+
return { type: typeMetaToString(declared.type), typedDynamicSlot: false };
|
|
288
|
+
}
|
|
289
|
+
if (targetMeta.supports_dynamic_inputs !== true) {
|
|
290
|
+
issues.push({
|
|
291
|
+
severity: "error",
|
|
292
|
+
code: "unknown_handle",
|
|
293
|
+
edgeId: edge.id,
|
|
294
|
+
nodeId: edge.target,
|
|
295
|
+
nodeType: String(targetNode.type ?? ""),
|
|
296
|
+
message: `Edge "${edge.id}" targets input "${edge.targetHandle}" not found on ${String(targetNode.type)}`
|
|
297
|
+
});
|
|
298
|
+
return { type: "", typedDynamicSlot: false };
|
|
299
|
+
}
|
|
300
|
+
const slotType = slotTypeToString(readDynamicInputs(targetNode)[edge.targetHandle]);
|
|
301
|
+
if (slotType) {
|
|
302
|
+
return { type: slotType, typedDynamicSlot: true };
|
|
303
|
+
}
|
|
304
|
+
issues.push({
|
|
305
|
+
severity: "info",
|
|
306
|
+
code: "untyped_dynamic_slot",
|
|
307
|
+
edgeId: edge.id,
|
|
308
|
+
nodeId: edge.target,
|
|
309
|
+
nodeType: String(targetNode.type ?? ""),
|
|
310
|
+
message: `Edge "${edge.id}" targets dynamic input "${edge.targetHandle}" on ${String(targetNode.type)}, which declares no type — the connection is not type-checked`
|
|
311
|
+
});
|
|
312
|
+
return { type: "", typedDynamicSlot: false };
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Self-loops and cycles — both fatal at run time, neither visible before it.
|
|
316
|
+
*
|
|
317
|
+
* `Graph.validateEdgeEndpoints` (kernel) rejects a self-loop on *any* edge:
|
|
318
|
+
* the node waits on an input only its own completion can close, so the run
|
|
319
|
+
* hangs. `analyzeCorrelation` requires the **data** subgraph to be a DAG, and
|
|
320
|
+
* its topological sort skips control edges — so cycles are looked for over
|
|
321
|
+
* data edges only, matching the kernel exactly.
|
|
322
|
+
*
|
|
323
|
+
* Kahn's algorithm, iterative: a deep graph must not blow the stack.
|
|
324
|
+
*/
|
|
325
|
+
function detectCycles(byId, normEdges) {
|
|
326
|
+
const issues = [];
|
|
327
|
+
const selfLooped = new Set();
|
|
328
|
+
for (const e of normEdges) {
|
|
329
|
+
if (e.source !== e.target || !byId.has(e.source))
|
|
330
|
+
continue;
|
|
331
|
+
if (selfLooped.has(e.source))
|
|
332
|
+
continue;
|
|
333
|
+
selfLooped.add(e.source);
|
|
334
|
+
issues.push({
|
|
335
|
+
severity: "error",
|
|
336
|
+
code: "cycle",
|
|
337
|
+
nodeId: e.source,
|
|
338
|
+
nodeType: String(byId.get(e.source)?.type ?? ""),
|
|
339
|
+
edgeId: e.id,
|
|
340
|
+
message: `Node "${e.source}" is connected to itself by edge "${e.id}"; self-loop edges are not supported and deadlock the run`
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
const incoming = new Map();
|
|
344
|
+
const outgoing = new Map();
|
|
345
|
+
for (const id of byId.keys()) {
|
|
346
|
+
incoming.set(id, 0);
|
|
347
|
+
outgoing.set(id, []);
|
|
348
|
+
}
|
|
349
|
+
for (const e of normEdges) {
|
|
350
|
+
if (e.isControl)
|
|
351
|
+
continue;
|
|
352
|
+
if (e.source === e.target)
|
|
353
|
+
continue;
|
|
354
|
+
if (!byId.has(e.source) || !byId.has(e.target))
|
|
355
|
+
continue;
|
|
356
|
+
if (isLoopFeedbackHandle(String(byId.get(e.target)?.type ?? ""), e.targetHandle)) {
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
outgoing.get(e.source).push(e.target);
|
|
360
|
+
incoming.set(e.target, (incoming.get(e.target) ?? 0) + 1);
|
|
361
|
+
}
|
|
362
|
+
const queue = [];
|
|
363
|
+
for (const [id, count] of incoming) {
|
|
364
|
+
if (count === 0)
|
|
365
|
+
queue.push(id);
|
|
366
|
+
}
|
|
367
|
+
let ordered = 0;
|
|
368
|
+
// Index instead of shift(): shift() on a large array is O(n) per call.
|
|
369
|
+
for (let head = 0; head < queue.length; head++) {
|
|
370
|
+
ordered++;
|
|
371
|
+
for (const target of outgoing.get(queue[head]) ?? []) {
|
|
372
|
+
const next = (incoming.get(target) ?? 0) - 1;
|
|
373
|
+
incoming.set(target, next);
|
|
374
|
+
if (next === 0)
|
|
375
|
+
queue.push(target);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (ordered < byId.size) {
|
|
379
|
+
const seen = new Set(queue);
|
|
380
|
+
const remaining = [...byId.keys()].filter((id) => !seen.has(id));
|
|
381
|
+
issues.push({
|
|
382
|
+
severity: "error",
|
|
383
|
+
code: "cycle",
|
|
384
|
+
nodeId: remaining[0],
|
|
385
|
+
nodeType: String(byId.get(remaining[0])?.type ?? ""),
|
|
386
|
+
message: `Cycle detected in graph; a cycle may only close on the "next" or "condition" input of a Loop node. Involved nodes: ${remaining.join(", ")}`
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
return issues;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Model id of a model reference. A blank id is an unselected model, which the
|
|
393
|
+
* registry's own property check already reports.
|
|
394
|
+
*/
|
|
395
|
+
function modelIdOf(ref) {
|
|
396
|
+
const id = ref.id;
|
|
397
|
+
if (!isNonEmptyString(id))
|
|
398
|
+
return undefined;
|
|
399
|
+
return id;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* The `_model` type tag of a model reference. Which catalog an id has to appear
|
|
403
|
+
* in depends on it: a provider's ASR ids and its image ids are separate lists,
|
|
404
|
+
* and only some are knowable offline.
|
|
405
|
+
*/
|
|
406
|
+
function modelKindOf(ref) {
|
|
407
|
+
return String(ref.type);
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* The few known ids closest to `id`, to turn "not a model" into a fix. Manifest
|
|
411
|
+
* ids are long and slash-separated (`fal-ai/ltx-2/image-to-video/fast`), so a
|
|
412
|
+
* shared prefix locates a near-miss far better than edit distance over the
|
|
413
|
+
* whole string; ties break toward the closest length.
|
|
414
|
+
*/
|
|
415
|
+
function nearestModelIds(id, known, limit = 3) {
|
|
416
|
+
const needle = id.toLowerCase();
|
|
417
|
+
const prefixLen = (candidate) => {
|
|
418
|
+
const other = candidate.toLowerCase();
|
|
419
|
+
let i = 0;
|
|
420
|
+
while (i < needle.length && i < other.length && needle[i] === other[i])
|
|
421
|
+
i++;
|
|
422
|
+
return i;
|
|
423
|
+
};
|
|
424
|
+
return ([...known]
|
|
425
|
+
.map((candidate) => ({ candidate, score: prefixLen(candidate) }))
|
|
426
|
+
// A single shared character is noise, not a suggestion.
|
|
427
|
+
.filter((entry) => entry.score >= 3)
|
|
428
|
+
.sort((a, b) => b.score - a.score ||
|
|
429
|
+
Math.abs(a.candidate.length - id.length) -
|
|
430
|
+
Math.abs(b.candidate.length - id.length) ||
|
|
431
|
+
a.candidate.localeCompare(b.candidate))
|
|
432
|
+
.slice(0, limit)
|
|
433
|
+
.map((entry) => entry.candidate));
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Every provider/model id in `graph` that the runtime cannot honour.
|
|
437
|
+
*
|
|
438
|
+
* Split out of {@link validateGraph} so a caller that wants only this — a run
|
|
439
|
+
* preflight, an agent about to save a graph — pays for a property walk instead
|
|
440
|
+
* of a full graph validation, and so all of them report the same thing. The
|
|
441
|
+
* checks stay in `validateGraph` too; this is the same code, not a second
|
|
442
|
+
* opinion.
|
|
443
|
+
*
|
|
444
|
+
* Both checks fail toward silence. An empty provider list means the registry
|
|
445
|
+
* could not be reached, not that zero providers exist; an undefined model list
|
|
446
|
+
* means the catalog is only knowable online. Neither is evidence of a bad id.
|
|
447
|
+
*/
|
|
448
|
+
export function collectModelSelectionIssues(graph, registry) {
|
|
449
|
+
const providerIds = registry.listProviderIds?.();
|
|
450
|
+
const knownProviders = providerIds && providerIds.length > 0 ? new Set(providerIds) : null;
|
|
451
|
+
if (!knownProviders)
|
|
452
|
+
return [];
|
|
453
|
+
const issues = [];
|
|
454
|
+
for (const node of graph.nodes ?? []) {
|
|
455
|
+
const nodeId = String(node.id ?? "");
|
|
456
|
+
const nodeType = String(node.type ?? "");
|
|
457
|
+
for (const { path, ref } of collectNodeModelRefs(node)) {
|
|
458
|
+
const modelId = modelIdOf(ref);
|
|
459
|
+
const provider = modelProviderOf(ref);
|
|
460
|
+
if (provider === undefined) {
|
|
461
|
+
// No provider and no id is an unselected model — the registry's own
|
|
462
|
+
// property check reports that one. An id without a provider is
|
|
463
|
+
// different: it names a model nothing can route to.
|
|
464
|
+
if (modelId !== undefined) {
|
|
465
|
+
issues.push({
|
|
466
|
+
severity: "error",
|
|
467
|
+
code: "missing_provider",
|
|
468
|
+
nodeId,
|
|
469
|
+
nodeType,
|
|
470
|
+
message: `Property "${path}" selects model "${modelId}" but names no ` +
|
|
471
|
+
"provider; the runtime cannot resolve a model id on its own"
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (!knownProviders.has(provider)) {
|
|
477
|
+
issues.push({
|
|
478
|
+
severity: "error",
|
|
479
|
+
code: "unknown_provider",
|
|
480
|
+
nodeId,
|
|
481
|
+
nodeType,
|
|
482
|
+
message: `Property "${path}" selects provider "${provider}", which is ` +
|
|
483
|
+
`not registered. Known providers: ${[...knownProviders]
|
|
484
|
+
.sort()
|
|
485
|
+
.join(", ")}`
|
|
486
|
+
});
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
// The provider is real; the id it names may still not be. A model id is
|
|
490
|
+
// only checkable where the catalog is known offline — undefined from the
|
|
491
|
+
// registry means unknown, so nothing is reported.
|
|
492
|
+
if (modelId === undefined)
|
|
493
|
+
continue;
|
|
494
|
+
const knownModels = registry.listModelIds?.(provider, modelKindOf(ref));
|
|
495
|
+
if (!knownModels || knownModels.length === 0)
|
|
496
|
+
continue;
|
|
497
|
+
if (knownModels.includes(modelId))
|
|
498
|
+
continue;
|
|
499
|
+
const suggestions = nearestModelIds(modelId, knownModels);
|
|
500
|
+
issues.push({
|
|
501
|
+
severity: "error",
|
|
502
|
+
code: "unknown_model",
|
|
503
|
+
nodeId,
|
|
504
|
+
nodeType,
|
|
505
|
+
message: `Property "${path}" selects model "${modelId}", which provider ` +
|
|
506
|
+
`"${provider}" does not offer.` +
|
|
507
|
+
(suggestions.length > 0
|
|
508
|
+
? ` Did you mean: ${suggestions.join(", ")}?`
|
|
509
|
+
: "")
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return issues;
|
|
514
|
+
}
|
|
515
|
+
/** A property value that reaches a provider as "the caller set nothing". */
|
|
516
|
+
function isBlankPropertyValue(value) {
|
|
517
|
+
if (value === undefined || value === null)
|
|
518
|
+
return true;
|
|
519
|
+
if (isString(value))
|
|
520
|
+
return value.trim().length === 0;
|
|
521
|
+
if (Array.isArray(value))
|
|
522
|
+
return value.length === 0;
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Properties a node leaves blank that the model it selects declares it will not
|
|
527
|
+
* run without.
|
|
528
|
+
*
|
|
529
|
+
* A generic media node takes one shape and routes it to whichever provider the
|
|
530
|
+
* `model` property names, and those providers disagree about what is optional:
|
|
531
|
+
* Veo 3.1 animates an image with no prompt, Kling refuses the same call with
|
|
532
|
+
* `500 This field is required` — no field named, no node named, after the
|
|
533
|
+
* upstream half of the graph has been paid for. The requirement is already
|
|
534
|
+
* written down in the generated provider manifests; this is the check that
|
|
535
|
+
* reads it while the graph is still being authored.
|
|
536
|
+
*
|
|
537
|
+
* Reported at `info`, which is the honest severity: the graph is not wrong, it
|
|
538
|
+
* is *model-specific*. Pinned to Veo it runs; the finding is that swapping the
|
|
539
|
+
* model breaks it. `info` also keeps it below the `--warnings-as-errors`
|
|
540
|
+
* ratchet the shipped-examples gate runs at, so a generated manifest that marks
|
|
541
|
+
* something required by mistake cannot fail a build over a graph that works.
|
|
542
|
+
*
|
|
543
|
+
* Only where every part of the answer is known: the provider must enumerate its
|
|
544
|
+
* requirements offline, the node must declare a property of that name, nothing
|
|
545
|
+
* may feed it, and the model reference must be the node's own top-level
|
|
546
|
+
* property. Anything less is silence.
|
|
547
|
+
*/
|
|
548
|
+
function collectRequiredModelInputIssues(nodeId, nodeType, node, declaredProperties, connectedHandles, registry) {
|
|
549
|
+
if (!registry.listRequiredTextInputs)
|
|
550
|
+
return [];
|
|
551
|
+
const properties = readProperties(node);
|
|
552
|
+
const issues = [];
|
|
553
|
+
const reported = new Set();
|
|
554
|
+
for (const { path, propertyName, ref } of collectNodeModelRefs(node)) {
|
|
555
|
+
// A model nested in a settings object or a list configures something other
|
|
556
|
+
// than this node's own inputs, so its requirements say nothing about them.
|
|
557
|
+
if (path !== propertyName)
|
|
558
|
+
continue;
|
|
559
|
+
const provider = modelProviderOf(ref);
|
|
560
|
+
const modelId = modelIdOf(ref);
|
|
561
|
+
if (provider === undefined || modelId === undefined)
|
|
562
|
+
continue;
|
|
563
|
+
const required = registry.listRequiredTextInputs(provider, modelKindOf(ref), modelId);
|
|
564
|
+
if (!required)
|
|
565
|
+
continue;
|
|
566
|
+
for (const name of required) {
|
|
567
|
+
if (reported.has(name))
|
|
568
|
+
continue;
|
|
569
|
+
if (!declaredProperties.has(name))
|
|
570
|
+
continue;
|
|
571
|
+
if (connectedHandles.has(name))
|
|
572
|
+
continue;
|
|
573
|
+
if (!isBlankPropertyValue(properties[name]))
|
|
574
|
+
continue;
|
|
575
|
+
reported.add(name);
|
|
576
|
+
issues.push({
|
|
577
|
+
severity: "info",
|
|
578
|
+
code: "missing_required_model_input",
|
|
579
|
+
nodeId,
|
|
580
|
+
nodeType,
|
|
581
|
+
message: `Node "${nodeId}" leaves "${name}" empty, and model "${modelId}" ` +
|
|
582
|
+
`(${provider}) declares it required. Set "${name}" or wire it — ` +
|
|
583
|
+
"until then, swapping this node's model breaks the graph."
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return issues;
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Every credential name a graph's nodes declare, deduplicated per node.
|
|
591
|
+
*
|
|
592
|
+
* Split out of {@link collectMissingSecretIssues} so a host can resolve the
|
|
593
|
+
* names against its store first and hand back only the availability set —
|
|
594
|
+
* validation stays synchronous and store-shaped knowledge stays with the
|
|
595
|
+
* host that owns it.
|
|
596
|
+
*/
|
|
597
|
+
export function collectSecretRequirementSites(graph, registry) {
|
|
598
|
+
const sites = [];
|
|
599
|
+
const seen = new Set();
|
|
600
|
+
for (const node of graph.nodes ?? []) {
|
|
601
|
+
const nodeId = String(node.id ?? "");
|
|
602
|
+
const nodeType = String(node.type ?? "");
|
|
603
|
+
if (!nodeType || !registry.has(nodeType))
|
|
604
|
+
continue;
|
|
605
|
+
const meta = registry.getMetadata(nodeType);
|
|
606
|
+
for (const key of meta?.required_settings ?? []) {
|
|
607
|
+
if (!isNonEmptyString(key))
|
|
608
|
+
continue;
|
|
609
|
+
const dedupeKey = `${nodeId}\u0000${key}`;
|
|
610
|
+
if (seen.has(dedupeKey))
|
|
611
|
+
continue;
|
|
612
|
+
seen.add(dedupeKey);
|
|
613
|
+
sites.push({ nodeId, nodeType, key, source: "required_setting" });
|
|
614
|
+
}
|
|
615
|
+
if (!isJsCodeNodeType(nodeType))
|
|
616
|
+
continue;
|
|
617
|
+
const declaredSecrets = readProperties(node).secrets;
|
|
618
|
+
if (!Array.isArray(declaredSecrets))
|
|
619
|
+
continue;
|
|
620
|
+
for (const value of declaredSecrets) {
|
|
621
|
+
if (!isNonEmptyString(value))
|
|
622
|
+
continue;
|
|
623
|
+
const dedupeKey = `${nodeId}\u0000${value}`;
|
|
624
|
+
if (seen.has(dedupeKey))
|
|
625
|
+
continue;
|
|
626
|
+
seen.add(dedupeKey);
|
|
627
|
+
sites.push({ nodeId, nodeType, key: value, source: "code_secret" });
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return sites;
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Nodes whose declared credentials the supplied set cannot resolve.
|
|
634
|
+
*
|
|
635
|
+
* Both sources warn rather than error. A `required_settings` entry reaches
|
|
636
|
+
* `process()` as an empty string when absent, and some nodes treat that as
|
|
637
|
+
* optional at call time (a HuggingFace token widens the model catalog but is
|
|
638
|
+
* not the only way in), so absence does not prove a failed run. The Code
|
|
639
|
+
* node's list scopes reads rather than requiring them, for the same reason.
|
|
640
|
+
* The certain failure — a provider constructed without the credential its
|
|
641
|
+
* registration requires — is checked where the answer is certain, in the
|
|
642
|
+
* run preflight (`@nodetool-ai/execution`), against the provider registry.
|
|
643
|
+
*
|
|
644
|
+
* Fails toward silence like every catalog here: no set, nothing reported.
|
|
645
|
+
*/
|
|
646
|
+
export function collectMissingSecretIssues(graph, registry, availableSecrets) {
|
|
647
|
+
if (!availableSecrets)
|
|
648
|
+
return [];
|
|
649
|
+
const issues = [];
|
|
650
|
+
for (const site of collectSecretRequirementSites(graph, registry)) {
|
|
651
|
+
if (availableSecrets.has(site.key))
|
|
652
|
+
continue;
|
|
653
|
+
issues.push({
|
|
654
|
+
severity: "warning",
|
|
655
|
+
code: "missing_secret",
|
|
656
|
+
nodeId: site.nodeId,
|
|
657
|
+
nodeType: site.nodeType,
|
|
658
|
+
message: site.source === "code_secret"
|
|
659
|
+
? `Node "${site.nodeId}" declares secret "${site.key}", which this ` +
|
|
660
|
+
"install cannot resolve — the body's reads of it will fail."
|
|
661
|
+
: `Node "${site.nodeId}" (${site.nodeType}) needs "${site.key}", ` +
|
|
662
|
+
"which this install cannot resolve. Set it in Settings → " +
|
|
663
|
+
"Credentials, or the node may fail when it runs."
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
return issues;
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Distinct provider ids across every model reference in the graph, in walk
|
|
670
|
+
* order. The run preflight resolves each against the provider registry to
|
|
671
|
+
* refuse a graph whose credentials are missing before anything executes.
|
|
672
|
+
*/
|
|
673
|
+
export function collectModelProviders(graph) {
|
|
674
|
+
const providers = new Set();
|
|
675
|
+
const connectedInputs = new Set((graph.edges ?? [])
|
|
676
|
+
.map(normalizeEdge)
|
|
677
|
+
.filter((edge) => !edge.isControl && edge.target && edge.targetHandle)
|
|
678
|
+
.map((edge) => `${edge.target}\u0000${edge.targetHandle}`));
|
|
679
|
+
for (const node of graph.nodes ?? []) {
|
|
680
|
+
const nodeId = String(node.id ?? "");
|
|
681
|
+
for (const { propertyName, ref } of collectNodeModelRefs(node)) {
|
|
682
|
+
if (connectedInputs.has(`${nodeId}\u0000${propertyName}`))
|
|
683
|
+
continue;
|
|
684
|
+
const provider = modelProviderOf(ref);
|
|
685
|
+
if (provider !== undefined)
|
|
686
|
+
providers.add(provider);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return [...providers];
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Every pinned script link a graph's Code nodes carry, deduplicated. A host
|
|
693
|
+
* that wants the linked-script checks loads these first and hands
|
|
694
|
+
* {@link validateGraph} a lookup over the result.
|
|
695
|
+
*/
|
|
696
|
+
export function collectJsScriptLinks(graph) {
|
|
697
|
+
const seen = new Map();
|
|
698
|
+
for (const node of graph.nodes ?? []) {
|
|
699
|
+
if (!isJsCodeNodeType(String(node.type ?? "")))
|
|
700
|
+
continue;
|
|
701
|
+
const link = readJsScriptLink(readProperties(node));
|
|
702
|
+
if (!link)
|
|
703
|
+
continue;
|
|
704
|
+
seen.set(`${link.id}@${link.version}`, link);
|
|
705
|
+
}
|
|
706
|
+
return [...seen.values()];
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* DSL wiring handles stored as property values. The builders mint these to
|
|
710
|
+
* wire an edge; one that survives into a bag means the connection was never
|
|
711
|
+
* made, so the input is unconnected and the node producing it may be missing
|
|
712
|
+
* from the graph entirely. Runs before the registry guard: the marker is
|
|
713
|
+
* shape-level, not type-level.
|
|
714
|
+
*/
|
|
715
|
+
function collectLeftoverWiringHandleIssues(nodeId, nodeType, node) {
|
|
716
|
+
const issues = [];
|
|
717
|
+
for (const [propName, value] of Object.entries(readProperties(node))) {
|
|
718
|
+
const handlePaths = [];
|
|
719
|
+
collectWiringHandlePaths(value, propName, handlePaths);
|
|
720
|
+
for (const path of handlePaths) {
|
|
721
|
+
issues.push({
|
|
722
|
+
severity: "error",
|
|
723
|
+
code: "leftover_wiring_handle",
|
|
724
|
+
nodeId,
|
|
725
|
+
nodeType,
|
|
726
|
+
message: `Property "${path}" on node "${nodeId}" still holds a wiring ` +
|
|
727
|
+
"handle — the connection was never created, so the node " +
|
|
728
|
+
"producing this output may be missing from the graph. Re-wire " +
|
|
729
|
+
"it as an edge."
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return issues;
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Properties the node does not have. A misspelled name is dead data: the value
|
|
737
|
+
* sits in the graph, the real property keeps its default, and nothing reports
|
|
738
|
+
* it. A required property left unset is already an error elsewhere, so what
|
|
739
|
+
* reaches here is an optional one silently ignored — a warning, not a refusal.
|
|
740
|
+
* Nodes taking dynamic properties are exempt: an undeclared name is the
|
|
741
|
+
* feature there.
|
|
742
|
+
*/
|
|
743
|
+
function collectUnknownPropertyIssues(nodeId, nodeType, node, meta) {
|
|
744
|
+
if (meta.supports_dynamic_inputs === true)
|
|
745
|
+
return [];
|
|
746
|
+
const declaredNames = new Set(meta.properties.map((prop) => prop.name));
|
|
747
|
+
const dynamic = readDynamicProperties(node);
|
|
748
|
+
const unknown = Object.keys(readProperties(node)).filter((name) => !declaredNames.has(name) &&
|
|
749
|
+
!isReservedHandle(name) &&
|
|
750
|
+
dynamic[name] === undefined);
|
|
751
|
+
if (unknown.length === 0)
|
|
752
|
+
return [];
|
|
753
|
+
const plural = unknown.length > 1;
|
|
754
|
+
const named = unknown.map((name) => `"${name}"`).join(", ");
|
|
755
|
+
const known = [...declaredNames].sort().join(", ");
|
|
756
|
+
return [
|
|
757
|
+
{
|
|
758
|
+
severity: "warning",
|
|
759
|
+
code: "unknown_property",
|
|
760
|
+
nodeId,
|
|
761
|
+
nodeType,
|
|
762
|
+
message: `${named} ${plural ? "are" : "is"} not a property of ${nodeType}, so ${plural ? "they are" : "it is"} ignored at run time.${known ? ` It takes: ${known}.` : ""}`
|
|
763
|
+
}
|
|
764
|
+
];
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Dynamic slot types: a JSON-Schema/TypeScript spelling of a type NodeTool
|
|
768
|
+
* already has passes the transport schema, then silently refuses to connect.
|
|
769
|
+
* Custom names stay legal — only known aliases are flagged. Inputs are
|
|
770
|
+
* reported before outputs.
|
|
771
|
+
*/
|
|
772
|
+
function collectSlotTypeAliasIssues(nodeId, nodeType, node) {
|
|
773
|
+
const issues = [];
|
|
774
|
+
const check = (direction, slotName, slotType) => {
|
|
775
|
+
for (const alias of portTypeAliases(slotType)) {
|
|
776
|
+
issues.push({
|
|
777
|
+
severity: "error",
|
|
778
|
+
code: "slot_type_alias",
|
|
779
|
+
nodeId,
|
|
780
|
+
nodeType,
|
|
781
|
+
message: `Dynamic ${direction} "${slotName}" is typed "${alias.used}", which is not a ` +
|
|
782
|
+
`NodeTool type — use "${alias.canonical}". A handle typed with an unknown ` +
|
|
783
|
+
"name will not connect."
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
};
|
|
787
|
+
for (const [slotName, slot] of Object.entries(readDynamicInputs(node))) {
|
|
788
|
+
check("input", slotName, slot?.type);
|
|
789
|
+
}
|
|
790
|
+
for (const [slotName, slotType] of Object.entries(readDynamicOutputs(node))) {
|
|
791
|
+
check("output", slotName, slotType);
|
|
792
|
+
}
|
|
793
|
+
return issues;
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* A Code node's linked script: the node carries a *copy* of the pinned
|
|
797
|
+
* script's body, so the body itself is checked exactly like an inline one.
|
|
798
|
+
* What the link adds is a freshness question — does the node's shape still
|
|
799
|
+
* match the script it came from? Nothing here blocks a run, because a run no
|
|
800
|
+
* longer reads the script row.
|
|
801
|
+
*/
|
|
802
|
+
function collectJsScriptLinkIssues(nodeId, nodeType, node, link, connectedInputs, lookup) {
|
|
803
|
+
const resolved = lookup ? lookup(link) : undefined;
|
|
804
|
+
if (!resolved) {
|
|
805
|
+
return [
|
|
806
|
+
{
|
|
807
|
+
severity: "warning",
|
|
808
|
+
code: lookup ? "js_script_missing" : "js_script_unverified",
|
|
809
|
+
nodeId,
|
|
810
|
+
nodeType,
|
|
811
|
+
message: `Node "${nodeId}" is linked to JS script ${link.id} v${link.version}, ` +
|
|
812
|
+
(lookup
|
|
813
|
+
? "which no longer exists, so the link's freshness cannot be " +
|
|
814
|
+
"verified. The node runs the body it copied at link time."
|
|
815
|
+
: "whose freshness cannot be verified here — no script store is " +
|
|
816
|
+
"available. The node runs the body it copied at link time.")
|
|
817
|
+
}
|
|
818
|
+
];
|
|
819
|
+
}
|
|
820
|
+
// Compare the script to slots the node actually stores. Names the body reads
|
|
821
|
+
// are implicit handles for the editor; they must not hide a missing stored
|
|
822
|
+
// slot on a linked node.
|
|
823
|
+
const nodeInputs = [
|
|
824
|
+
...Object.keys(readDynamicInputs(node)),
|
|
825
|
+
...Object.keys(readDynamicProperties(node)),
|
|
826
|
+
...connectedInputs
|
|
827
|
+
].filter((name) => !isReservedHandle(name));
|
|
828
|
+
return jsScriptPortMismatches({
|
|
829
|
+
scriptInputs: resolved.document.inputs.map((port) => port.name),
|
|
830
|
+
scriptOutputs: resolved.document.outputs.map((port) => port.name),
|
|
831
|
+
nodeInputs,
|
|
832
|
+
nodeOutputs: Object.keys(readDynamicOutputs(node))
|
|
833
|
+
}).map((mismatch) => ({
|
|
834
|
+
severity: "error",
|
|
835
|
+
code: "js_script_ports",
|
|
836
|
+
nodeId,
|
|
837
|
+
nodeType,
|
|
838
|
+
message: `Node "${nodeId}": ${mismatch}.`
|
|
839
|
+
}));
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* Code nodes: the body is only checked by running it, and a run costs the
|
|
843
|
+
* whole graph. Everything decidable from the graph is decided here.
|
|
844
|
+
*/
|
|
845
|
+
function collectCodeNodeIssues(nodeId, nodeType, node, connectedHandles, consumedHandles, sandboxModuleCatalog, jsScriptLookup) {
|
|
846
|
+
const props = readProperties(node);
|
|
847
|
+
const code = String(props.code ?? "");
|
|
848
|
+
const connectedInputs = [...connectedHandles].filter((name) => !isReservedHandle(name));
|
|
849
|
+
// A named `inputs.foo` / `stream("foo")` read is a handle. The editor shows
|
|
850
|
+
// it; an agent can connect to it. Count it as declared so the body is not
|
|
851
|
+
// rejected for a slot the code itself just created.
|
|
852
|
+
const availableInputs = new Set([
|
|
853
|
+
...Object.keys(readDynamicInputs(node)),
|
|
854
|
+
...Object.keys(readDynamicProperties(node)),
|
|
855
|
+
...connectedInputs,
|
|
856
|
+
...inferredCodeInputNames(code)
|
|
857
|
+
]);
|
|
858
|
+
const link = readJsScriptLink(props);
|
|
859
|
+
const issues = link
|
|
860
|
+
? collectJsScriptLinkIssues(nodeId, nodeType, node, link, connectedInputs, jsScriptLookup)
|
|
861
|
+
: [];
|
|
862
|
+
for (const codeIssue of validateCodeNodeBody({
|
|
863
|
+
code: props.code,
|
|
864
|
+
availableInputs: [...availableInputs].filter((name) => !isReservedHandle(name)),
|
|
865
|
+
connectedInputs,
|
|
866
|
+
declaredOutputs: [
|
|
867
|
+
...new Set([
|
|
868
|
+
...Object.keys(readDynamicOutputs(node)),
|
|
869
|
+
...inferredCodeOutputNames(code)
|
|
870
|
+
])
|
|
871
|
+
],
|
|
872
|
+
connectedOutputs: [...consumedHandles],
|
|
873
|
+
sandboxModuleCatalog
|
|
874
|
+
})) {
|
|
875
|
+
issues.push({
|
|
876
|
+
severity: codeIssue.severity,
|
|
877
|
+
code: codeIssue.code,
|
|
878
|
+
nodeId,
|
|
879
|
+
nodeType,
|
|
880
|
+
message: `Node "${nodeId}": ${codeIssue.message}`
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
return issues;
|
|
884
|
+
}
|
|
885
|
+
export function validateGraph(rawGraph, registry, options = {}) {
|
|
886
|
+
// Validate what the runner will execute. `Graph.loadFromDict` rewrites
|
|
887
|
+
// removed node types through NODE_TYPE_MIGRATIONS on every load, so a saved
|
|
888
|
+
// graph carrying one runs fine — but validating the raw graph reported the
|
|
889
|
+
// migrated-away type as `unknown_node` and refused it. Two shipped examples
|
|
890
|
+
// (text_transforms_cli, text_regex_parse_cli) sat in exactly that gap.
|
|
891
|
+
const graph = migrateGraphNodeTypes(rawGraph);
|
|
892
|
+
const sandboxModuleCatalog = options.sandboxModuleCatalog !== undefined
|
|
893
|
+
? options.sandboxModuleCatalog
|
|
894
|
+
: getProcessSandboxModuleCatalog();
|
|
895
|
+
const issues = [];
|
|
896
|
+
const nodes = graphMembers(graph.nodes, "nodes", issues);
|
|
897
|
+
const edges = graphMembers(graph.edges, "edges", issues);
|
|
898
|
+
// ── Nodes: ids, types, properties ────────────────────────────────────────
|
|
899
|
+
const byId = new Map();
|
|
900
|
+
const seenIds = new Set();
|
|
901
|
+
for (const node of nodes) {
|
|
902
|
+
const id = String(node.id ?? "");
|
|
903
|
+
const type = String(node.type ?? "");
|
|
904
|
+
if (!id) {
|
|
905
|
+
// Nothing can address this node: no edge can name it, and the runner
|
|
906
|
+
// keys every message by node id.
|
|
907
|
+
issues.push({
|
|
908
|
+
severity: "error",
|
|
909
|
+
code: "missing_id",
|
|
910
|
+
nodeType: type,
|
|
911
|
+
message: `Node of type "${type || "(no type)"}" has no id`
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
if (id && seenIds.has(id)) {
|
|
915
|
+
issues.push({
|
|
916
|
+
severity: "error",
|
|
917
|
+
code: "duplicate_id",
|
|
918
|
+
nodeId: id,
|
|
919
|
+
nodeType: type,
|
|
920
|
+
message: `Duplicate node id "${id}"`
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
else if (id) {
|
|
924
|
+
seenIds.add(id);
|
|
925
|
+
byId.set(id, node);
|
|
926
|
+
}
|
|
927
|
+
if (!type) {
|
|
928
|
+
issues.push({
|
|
929
|
+
severity: "error",
|
|
930
|
+
code: "unknown_node",
|
|
931
|
+
nodeId: id,
|
|
932
|
+
message: `Node "${id}" has no type`
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
else if (!registry.has(type) && !isEditorOnlyType(type)) {
|
|
936
|
+
issues.push({
|
|
937
|
+
severity: "error",
|
|
938
|
+
code: "unknown_node",
|
|
939
|
+
nodeId: id,
|
|
940
|
+
nodeType: type,
|
|
941
|
+
message: legacyGameNodeDiagnostic(type) ?? `Unknown node type "${type}" (not in the registry; Python-only nodes are not validated statically)`
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
// Handles fed by an incoming edge get their value at runtime — don't flag
|
|
946
|
+
// them as missing required properties.
|
|
947
|
+
const connectedByNode = new Map();
|
|
948
|
+
// Source handles other nodes read — the output handles that have to exist.
|
|
949
|
+
const consumedByNode = new Map();
|
|
950
|
+
const normEdges = edges.map(normalizeEdge);
|
|
951
|
+
const addHandle = (map, nodeId, handle) => {
|
|
952
|
+
const set = map.get(nodeId);
|
|
953
|
+
if (set) {
|
|
954
|
+
set.add(handle);
|
|
955
|
+
}
|
|
956
|
+
else {
|
|
957
|
+
map.set(nodeId, new Set([handle]));
|
|
958
|
+
}
|
|
959
|
+
};
|
|
960
|
+
for (const e of normEdges) {
|
|
961
|
+
if (e.target && e.targetHandle) {
|
|
962
|
+
addHandle(connectedByNode, e.target, e.targetHandle);
|
|
963
|
+
}
|
|
964
|
+
if (e.source && e.sourceHandle && !isReservedHandle(e.sourceHandle)) {
|
|
965
|
+
addHandle(consumedByNode, e.source, e.sourceHandle);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
// `byId` holds one node per id — a duplicate id is already an error, and
|
|
969
|
+
// validating the shadowed copy's properties would report everything twice.
|
|
970
|
+
for (const [id, node] of byId) {
|
|
971
|
+
const type = String(node.type ?? "");
|
|
972
|
+
const connected = connectedByNode.get(id) ?? new Set();
|
|
973
|
+
issues.push(...collectLeftoverWiringHandleIssues(id, type, node));
|
|
974
|
+
if (!type || !registry.has(type))
|
|
975
|
+
continue;
|
|
976
|
+
const propIssues = registry.validateNode({
|
|
977
|
+
id,
|
|
978
|
+
type,
|
|
979
|
+
properties: readProperties(node),
|
|
980
|
+
// Without these the registry cannot reach validateDynamicSlots, so a
|
|
981
|
+
// slot declared `required` is never checked.
|
|
982
|
+
dynamic_inputs: readDynamicInputs(node),
|
|
983
|
+
dynamic_properties: readDynamicProperties(node)
|
|
984
|
+
}, connected);
|
|
985
|
+
for (const pi of propIssues) {
|
|
986
|
+
issues.push({
|
|
987
|
+
severity: "error",
|
|
988
|
+
code: "property",
|
|
989
|
+
nodeId: id,
|
|
990
|
+
nodeType: type,
|
|
991
|
+
message: pi.message
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
const meta = registry.getMetadata(type);
|
|
995
|
+
if (meta) {
|
|
996
|
+
// Properties blank that the selected model declares required. The
|
|
997
|
+
// registry's own check above knows which properties this node requires;
|
|
998
|
+
// it cannot know which ones the model behind `model` requires.
|
|
999
|
+
issues.push(...collectRequiredModelInputIssues(id, type, node, new Set(meta.properties.map((prop) => prop.name)), connected, registry), ...collectUnknownPropertyIssues(id, type, node, meta));
|
|
1000
|
+
}
|
|
1001
|
+
issues.push(...collectSlotTypeAliasIssues(id, type, node));
|
|
1002
|
+
// A `code` handle fed by an edge carries a body nothing here can see.
|
|
1003
|
+
if (isJsCodeNodeType(type) && !connected.has("code")) {
|
|
1004
|
+
issues.push(...collectCodeNodeIssues(id, type, node, connected, consumedByNode.get(id) ?? new Set(), sandboxModuleCatalog, options.jsScriptLookup));
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
// A model property naming a provider the runtime cannot construct, or an id
|
|
1008
|
+
// that provider does not offer, fails only once the node runs — after the
|
|
1009
|
+
// rest of the graph has already been paid for. Both are right there in the
|
|
1010
|
+
// saved properties.
|
|
1011
|
+
issues.push(...collectModelSelectionIssues({ nodes: [...byId.values()] }, registry));
|
|
1012
|
+
// Declared credentials the supplied availability set cannot resolve — a
|
|
1013
|
+
// warning per site, and nothing at all when no set was supplied.
|
|
1014
|
+
issues.push(...collectMissingSecretIssues({ nodes: [...byId.values()] }, registry, options.availableSecrets));
|
|
1015
|
+
// ── Fan-in: >1 edge into a handle is only legal when the handle's declared
|
|
1016
|
+
// type is a list. Mirrors the kernel's correlation-analysis rule so an
|
|
1017
|
+
// example that validates cannot die at run time on exactly this.
|
|
1018
|
+
const fanIn = new Map();
|
|
1019
|
+
for (const e of normEdges) {
|
|
1020
|
+
// Control edges carry no data — `analyzeCorrelation` filters them out
|
|
1021
|
+
// before counting, so counting them here would invent errors.
|
|
1022
|
+
if (e.isControl)
|
|
1023
|
+
continue;
|
|
1024
|
+
if (!e.target || !e.targetHandle)
|
|
1025
|
+
continue;
|
|
1026
|
+
const key = `${e.target}\u0000${e.targetHandle}`;
|
|
1027
|
+
fanIn.set(key, (fanIn.get(key) ?? 0) + 1);
|
|
1028
|
+
}
|
|
1029
|
+
for (const [key, count] of fanIn) {
|
|
1030
|
+
if (count < 2)
|
|
1031
|
+
continue;
|
|
1032
|
+
const [targetId, handle] = key.split("\u0000");
|
|
1033
|
+
const node = byId.get(targetId);
|
|
1034
|
+
const type = String(node?.type ?? "");
|
|
1035
|
+
if (!type || !registry.has(type))
|
|
1036
|
+
continue;
|
|
1037
|
+
const propType = registry
|
|
1038
|
+
.getMetadata(type)
|
|
1039
|
+
?.properties?.find((prop) => prop.name === handle)?.type;
|
|
1040
|
+
// A dynamic slot is not a static property. The kernel merges declared slot
|
|
1041
|
+
// types into `propertyTypes` (Graph.loadFromDict), so a slot declared
|
|
1042
|
+
// `list[...]` is a legal fan-in target — resolve it the same way here.
|
|
1043
|
+
const typeStr = typeMetaToString(propType) ||
|
|
1044
|
+
slotTypeToString(node ? readDynamicInputs(node)[handle] : undefined);
|
|
1045
|
+
if (!(typeStr === "list" || typeStr.startsWith("list["))) {
|
|
1046
|
+
issues.push({
|
|
1047
|
+
severity: "error",
|
|
1048
|
+
code: "fan_in",
|
|
1049
|
+
nodeId: targetId,
|
|
1050
|
+
nodeType: type,
|
|
1051
|
+
message: `Handle "${handle}" on node "${targetId}" receives ${count} edges but its ` +
|
|
1052
|
+
`type "${typeStr || "unknown"}" is not a list; the kernel's correlation ` +
|
|
1053
|
+
`analysis rejects this at run time. ${FAN_IN_REMEDY}`
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
// ── Cycles: only Loop feedback edges may close one ───────────────────────
|
|
1058
|
+
issues.push(...detectCycles(byId, normEdges));
|
|
1059
|
+
// ── Edges: endpoints, handles, type compatibility ────────────────────────
|
|
1060
|
+
for (const e of normEdges) {
|
|
1061
|
+
const sourceNode = byId.get(e.source);
|
|
1062
|
+
const targetNode = byId.get(e.target);
|
|
1063
|
+
if (!sourceNode) {
|
|
1064
|
+
issues.push({
|
|
1065
|
+
severity: "error",
|
|
1066
|
+
code: "dangling_edge",
|
|
1067
|
+
edgeId: e.id,
|
|
1068
|
+
message: `Edge "${e.id}" source node "${e.source}" does not exist`
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
if (!targetNode) {
|
|
1072
|
+
issues.push({
|
|
1073
|
+
severity: "error",
|
|
1074
|
+
code: "dangling_edge",
|
|
1075
|
+
edgeId: e.id,
|
|
1076
|
+
message: `Edge "${e.id}" target node "${e.target}" does not exist`
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
if (!sourceNode || !targetNode)
|
|
1080
|
+
continue;
|
|
1081
|
+
// Both endpoints exist but the edge names no handle: the kernel routes
|
|
1082
|
+
// messages by `${source}:${sourceHandle}`, so an empty handle matches
|
|
1083
|
+
// nothing and the edge silently carries no data. The handle checks below
|
|
1084
|
+
// are all gated on a non-empty handle, so without this the edge would
|
|
1085
|
+
// validate clean.
|
|
1086
|
+
if (!e.sourceHandle) {
|
|
1087
|
+
issues.push({
|
|
1088
|
+
severity: "error",
|
|
1089
|
+
code: "missing_handle",
|
|
1090
|
+
edgeId: e.id,
|
|
1091
|
+
nodeId: e.source,
|
|
1092
|
+
nodeType: String(sourceNode.type ?? ""),
|
|
1093
|
+
message: `Edge "${e.id}" names no source handle on node "${e.source}" — it cannot be routed`
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
if (!e.targetHandle) {
|
|
1097
|
+
issues.push({
|
|
1098
|
+
severity: "error",
|
|
1099
|
+
code: "missing_handle",
|
|
1100
|
+
edgeId: e.id,
|
|
1101
|
+
nodeId: e.target,
|
|
1102
|
+
nodeType: String(targetNode.type ?? ""),
|
|
1103
|
+
message: `Edge "${e.id}" names no target handle on node "${e.target}" — it cannot be routed`
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
const sourceType = resolveSourceHandleType(e, sourceNode, registry.getMetadata(String(sourceNode.type ?? "")), issues);
|
|
1107
|
+
const target = resolveTargetHandleType(e, targetNode, registry.getMetadata(String(targetNode.type ?? "")), issues);
|
|
1108
|
+
if (typesIncompatible(sourceType, target.type)) {
|
|
1109
|
+
issues.push({
|
|
1110
|
+
severity: target.typedDynamicSlot ? "error" : "warning",
|
|
1111
|
+
code: "type_mismatch",
|
|
1112
|
+
edgeId: e.id,
|
|
1113
|
+
nodeId: e.target,
|
|
1114
|
+
nodeType: String(targetNode.type ?? ""),
|
|
1115
|
+
message: target.typedDynamicSlot
|
|
1116
|
+
? `Edge "${e.id}" connects ${String(sourceNode.type)}.${e.sourceHandle} (${sourceType}) → dynamic input ${String(targetNode.type)}.${e.targetHandle}, declared as ${target.type}`
|
|
1117
|
+
: `Edge "${e.id}" connects ${String(sourceNode.type)}.${e.sourceHandle} (${sourceType}) → ${String(targetNode.type)}.${e.targetHandle} (${target.type}) — types may be incompatible`
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
// ── Inline dynamic values against their declared slot types ──────────────
|
|
1122
|
+
for (const node of nodes) {
|
|
1123
|
+
const slots = readDynamicInputs(node);
|
|
1124
|
+
const slotNames = Object.keys(slots);
|
|
1125
|
+
if (slotNames.length === 0)
|
|
1126
|
+
continue;
|
|
1127
|
+
const id = String(node.id ?? "");
|
|
1128
|
+
const values = readDynamicProperties(node);
|
|
1129
|
+
const connected = connectedByNode.get(id);
|
|
1130
|
+
for (const name of slotNames) {
|
|
1131
|
+
if (connected?.has(name))
|
|
1132
|
+
continue;
|
|
1133
|
+
const typeStr = slotTypeToString(slots[name]);
|
|
1134
|
+
if (valueIncompatibleWithType(values[name], typeStr)) {
|
|
1135
|
+
issues.push({
|
|
1136
|
+
severity: "warning",
|
|
1137
|
+
code: "dynamic_type_mismatch",
|
|
1138
|
+
nodeId: id,
|
|
1139
|
+
nodeType: String(node.type ?? ""),
|
|
1140
|
+
message: `Dynamic input "${name}" on node "${id}" is declared as ${typeStr} but holds a ${typeof values[name]}`
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
let errors = 0;
|
|
1146
|
+
let warnings = 0;
|
|
1147
|
+
let info = 0;
|
|
1148
|
+
for (const i of issues) {
|
|
1149
|
+
if (i.severity === "error")
|
|
1150
|
+
errors++;
|
|
1151
|
+
else if (i.severity === "warning")
|
|
1152
|
+
warnings++;
|
|
1153
|
+
else if (i.severity === "info")
|
|
1154
|
+
info++;
|
|
1155
|
+
}
|
|
1156
|
+
return {
|
|
1157
|
+
ok: errors === 0,
|
|
1158
|
+
nodeCount: nodes.length,
|
|
1159
|
+
edgeCount: edges.length,
|
|
1160
|
+
counts: { errors, warnings, info },
|
|
1161
|
+
issues
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
/** One-line summary headline for human output. */
|
|
1165
|
+
export function validationHeadline(report) {
|
|
1166
|
+
if (report.ok && report.counts.warnings === 0) {
|
|
1167
|
+
return `Workflow is valid — ${report.nodeCount} node(s), ${report.edgeCount} edge(s).`;
|
|
1168
|
+
}
|
|
1169
|
+
if (report.ok) {
|
|
1170
|
+
return `Workflow is valid with ${report.counts.warnings} warning(s).`;
|
|
1171
|
+
}
|
|
1172
|
+
return `Workflow has ${report.counts.errors} error(s)${report.counts.warnings ? ` and ${report.counts.warnings} warning(s)` : ""}.`;
|
|
1173
|
+
}
|
|
1174
|
+
//# sourceMappingURL=graph-validation.js.map
|