@foresthubai/workflow-core 0.3.0 → 0.4.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.
- package/LICENSE +202 -202
- package/NOTICE +14 -14
- package/README.md +63 -63
- package/dist/api/workflow.d.ts +2 -2
- package/dist/api/workflow.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/api/index.ts +11 -11
- package/src/api/workflow.ts +607 -607
- package/src/channel/Channel.ts +11 -11
- package/src/channel/ChannelDefinition.ts +76 -76
- package/src/channel/index.ts +6 -6
- package/src/channel/serialization.ts +68 -68
- package/src/deploy/index.ts +1 -1
- package/src/deploy/requirements.test.ts +61 -61
- package/src/deploy/requirements.ts +41 -41
- package/src/diagnostics/__fixtures__/diagnosticFixtures.ts +158 -158
- package/src/diagnostics/diagnostics.test.ts +878 -878
- package/src/diagnostics/diagnostics.ts +936 -936
- package/src/diagnostics/index.ts +11 -11
- package/src/edge/Edge.ts +23 -23
- package/src/edge/EdgeDefinition.ts +45 -45
- package/src/edge/EdgeType.ts +19 -19
- package/src/edge/index.ts +8 -8
- package/src/edge/serialization.ts +83 -83
- package/src/expression/index.ts +4 -4
- package/src/expression/parser.ts +362 -362
- package/src/expression/types.ts +30 -30
- package/src/function/FunctionDeclaration.ts +54 -54
- package/src/function/index.ts +3 -3
- package/src/function/serialization.ts +40 -40
- package/src/globals.d.ts +9 -9
- package/src/id/index.ts +8 -8
- package/src/index.ts +22 -22
- package/src/memory/Memory.ts +15 -15
- package/src/memory/MemoryDefinition.ts +16 -16
- package/src/memory/MemoryFileDefinition.ts +37 -37
- package/src/memory/MemoryRegistry.ts +35 -35
- package/src/memory/VectorDatabaseDefinition.ts +21 -21
- package/src/memory/index.ts +8 -8
- package/src/memory/serialization.ts +47 -47
- package/src/migration/index.ts +4 -4
- package/src/migration/migrate.test.ts +44 -44
- package/src/migration/migrate.ts +58 -58
- package/src/migration/migrations.ts +24 -24
- package/src/migration/version.ts +9 -9
- package/src/model/LLMModelDefinition.ts +12 -12
- package/src/model/Model.ts +39 -39
- package/src/model/ModelDefinition.ts +15 -15
- package/src/model/ModelRegistry.ts +33 -33
- package/src/model/index.ts +7 -7
- package/src/model/serialization.ts +30 -30
- package/src/node/AgentNode.ts +82 -82
- package/src/node/DataNode.ts +41 -41
- package/src/node/FunctionNode.ts +76 -76
- package/src/node/InputNode.ts +185 -185
- package/src/node/LogicNode.ts +33 -33
- package/src/node/MqttNode.ts +127 -127
- package/src/node/Node.ts +61 -61
- package/src/node/NodeDefinition.ts +37 -37
- package/src/node/NodeRegistry.ts +85 -85
- package/src/node/OutputNode.ts +87 -87
- package/src/node/ToolNode.ts +32 -32
- package/src/node/TriggerNode.ts +272 -272
- package/src/node/constants.ts +16 -16
- package/src/node/index.ts +26 -26
- package/src/node/methods.ts +278 -278
- package/src/node/serialization.ts +544 -544
- package/src/parameter/OutputParameter.ts +68 -68
- package/src/parameter/Parameter.ts +243 -243
- package/src/parameter/index.ts +33 -33
- package/src/variable/Variable.ts +10 -10
- package/src/variable/index.ts +16 -16
- package/src/variable/operations.ts +106 -106
- package/src/workflow/Workflow.ts +41 -41
- package/src/workflow/index.ts +3 -3
- package/src/workflow/serialization.test.ts +240 -240
- package/src/workflow/serialization.ts +242 -242
|
@@ -1,936 +1,936 @@
|
|
|
1
|
-
import type { NodeDefinition, NodeData } from "../node";
|
|
2
|
-
import type { Expression } from "../api";
|
|
3
|
-
import { NodeCategory, NodeRegistry } from "../node";
|
|
4
|
-
import type { EdgeData, EdgeType } from "../edge";
|
|
5
|
-
import { getEdgeDefinition, isControlFlow } from "../edge";
|
|
6
|
-
import { getArguments, getNodeAvailableOutput, getOutputBinding, getPorts, isNodeUsedAsTool } from "../node/methods";
|
|
7
|
-
import type { Edge } from "../edge";
|
|
8
|
-
import type { Variable } from "../variable";
|
|
9
|
-
import { computeAvailableVariables, refToLookupKey } from "../variable";
|
|
10
|
-
import { isExpression, resolveExpression } from "../expression/types";
|
|
11
|
-
import { parseExpression } from "../expression/parser";
|
|
12
|
-
import { isParameterActive, isEmpty, resolveExpressionType, resolveChannelTypes, resolveMemoryTypes, resolveModelTypes } from "../parameter";
|
|
13
|
-
import type { ExpressionParam, ChannelSelectParam, MemorySelectParam, ModelSelectParam, OutputDeclaration } from "../parameter";
|
|
14
|
-
import type { Channel } from "../channel";
|
|
15
|
-
import { CHANNEL_DEFINITION } from "../channel";
|
|
16
|
-
import type { Memory } from "../memory";
|
|
17
|
-
import { MemoryRegistry } from "../memory";
|
|
18
|
-
import type { Model } from "../model";
|
|
19
|
-
import { ModelRegistry } from "../model";
|
|
20
|
-
import type { Schemas } from "../api";
|
|
21
|
-
import type { Reference } from "../api";
|
|
22
|
-
import { FunctionCallNode, buildFunctionNodeDef } from "../node/FunctionNode";
|
|
23
|
-
import type { FunctionDeclaration, FunctionInfo, OutputAssignment } from "../function";
|
|
24
|
-
import { toFunctionInfo } from "../function";
|
|
25
|
-
import { MAIN_CANVAS_ID, type Workflow, type Canvas } from "../workflow/Workflow";
|
|
26
|
-
|
|
27
|
-
// ============================================================================
|
|
28
|
-
// Types
|
|
29
|
-
// ============================================================================
|
|
30
|
-
|
|
31
|
-
export type DiagnosticSeverity = "error" | "warning";
|
|
32
|
-
|
|
33
|
-
export type DiagnosticCategory =
|
|
34
|
-
| "missing-required-param"
|
|
35
|
-
| "invalid-expression"
|
|
36
|
-
| "invalid-reference"
|
|
37
|
-
| "function-deleted"
|
|
38
|
-
| "function-stale"
|
|
39
|
-
| "unconnected-input"
|
|
40
|
-
| "unconnected-output"
|
|
41
|
-
| "tool-not-connected"
|
|
42
|
-
| "missing-output-assignment"
|
|
43
|
-
| "assign-type-mismatch"
|
|
44
|
-
| "duplicate-output-name";
|
|
45
|
-
|
|
46
|
-
export interface Diagnostic {
|
|
47
|
-
severity: DiagnosticSeverity;
|
|
48
|
-
category: DiagnosticCategory;
|
|
49
|
-
message: string;
|
|
50
|
-
/** Canvas this diagnostic belongs to. Omitted for project-scoped sources (e.g. channels). */
|
|
51
|
-
canvasId?: string;
|
|
52
|
-
nodeId?: string;
|
|
53
|
-
edgeId?: string;
|
|
54
|
-
/** Set when the diagnostic targets a project-scoped channel. */
|
|
55
|
-
channelId?: string;
|
|
56
|
-
/** Set when the diagnostic targets a project-scoped memory primitive. */
|
|
57
|
-
memoryId?: string;
|
|
58
|
-
/** Set when the diagnostic targets a project-scoped declared model. */
|
|
59
|
-
modelId?: string;
|
|
60
|
-
/** Set when the diagnostic targets a project-scoped function declaration. */
|
|
61
|
-
functionId?: string;
|
|
62
|
-
paramId?: string;
|
|
63
|
-
outputId?: string; // For output binding diagnostics
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Compute diagnostics for a single node.
|
|
68
|
-
*/
|
|
69
|
-
export function computeNodeDiagnostics(opts: {
|
|
70
|
-
canvasId: string;
|
|
71
|
-
nodeId: string;
|
|
72
|
-
nodeData: NodeData;
|
|
73
|
-
nodeDefinition: NodeDefinition | undefined;
|
|
74
|
-
availableVariables: Record<string, Variable>;
|
|
75
|
-
channels: Record<string, Channel>;
|
|
76
|
-
memory?: Record<string, Memory>;
|
|
77
|
-
/** Declared custom models (project-scoped). */
|
|
78
|
-
models?: Record<string, Model>;
|
|
79
|
-
/** Ids in the static model catalog (props-supplied). Undefined headlessly — catalog ids then aren't flagged. */
|
|
80
|
-
availableModelIds?: Set<string>;
|
|
81
|
-
edges: readonly Edge[];
|
|
82
|
-
isStale?: boolean;
|
|
83
|
-
isDeleted?: boolean;
|
|
84
|
-
}): Diagnostic[] {
|
|
85
|
-
const {
|
|
86
|
-
canvasId,
|
|
87
|
-
nodeId,
|
|
88
|
-
nodeData,
|
|
89
|
-
nodeDefinition,
|
|
90
|
-
availableVariables,
|
|
91
|
-
channels,
|
|
92
|
-
memory,
|
|
93
|
-
models,
|
|
94
|
-
availableModelIds,
|
|
95
|
-
edges,
|
|
96
|
-
isStale = false,
|
|
97
|
-
isDeleted = false,
|
|
98
|
-
} = opts;
|
|
99
|
-
|
|
100
|
-
const diags: Diagnostic[] = [];
|
|
101
|
-
|
|
102
|
-
// --- Function-specific diagnostics ---
|
|
103
|
-
if (isDeleted) {
|
|
104
|
-
diags.push({
|
|
105
|
-
severity: "error",
|
|
106
|
-
category: "function-deleted",
|
|
107
|
-
canvasId,
|
|
108
|
-
nodeId,
|
|
109
|
-
message: "Function has been deleted. Remove this node.",
|
|
110
|
-
});
|
|
111
|
-
} else if (isStale) {
|
|
112
|
-
diags.push({
|
|
113
|
-
severity: "warning",
|
|
114
|
-
category: "function-stale",
|
|
115
|
-
canvasId,
|
|
116
|
-
nodeId,
|
|
117
|
-
message: "Function definition has changed. Please update this node.",
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
if (!nodeDefinition) return diags;
|
|
122
|
-
|
|
123
|
-
const portDefinitions = getPorts(nodeData);
|
|
124
|
-
const parameters = getArguments(nodeData);
|
|
125
|
-
const usedAsToolInput = isNodeUsedAsTool(nodeId, nodeData, edges);
|
|
126
|
-
|
|
127
|
-
// --- Parameter diagnostics ---
|
|
128
|
-
for (const param of nodeDefinition.parameters) {
|
|
129
|
-
if (!isParameterActive(param, parameters, usedAsToolInput)) continue;
|
|
130
|
-
const value = parameters[param.id];
|
|
131
|
-
|
|
132
|
-
// invalid-expression (skip empty — caught by missing-required-param)
|
|
133
|
-
if (isExpression(value) && value.expression) {
|
|
134
|
-
const expr = resolveExpression(value, availableVariables);
|
|
135
|
-
// Resolve expressionType (static, args-only lambda, or derived from a referenced variable)
|
|
136
|
-
if (param.type === "expression") {
|
|
137
|
-
expr.expectedType = resolveExpressionType(param as ExpressionParam, parameters, availableVariables);
|
|
138
|
-
}
|
|
139
|
-
const parseRes = parseExpression(expr);
|
|
140
|
-
if (!parseRes.isValid) {
|
|
141
|
-
diags.push({
|
|
142
|
-
severity: "error",
|
|
143
|
-
category: "invalid-expression",
|
|
144
|
-
canvasId,
|
|
145
|
-
nodeId,
|
|
146
|
-
paramId: param.id,
|
|
147
|
-
message: `Invalid expression for "${param.label}": ${parseRes.errors.join(", ")}`,
|
|
148
|
-
});
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// missing-required-param
|
|
153
|
-
if (!param.optional) {
|
|
154
|
-
const isEmptyExpression = isExpression(value) && !value.expression;
|
|
155
|
-
const isEmptyReference =
|
|
156
|
-
param.type === "variableSelect" &&
|
|
157
|
-
(!value || (typeof value === "object" && value !== null && !(value as { varId?: string }).varId));
|
|
158
|
-
if (isEmpty(value) || isEmptyExpression || isEmptyReference) {
|
|
159
|
-
diags.push({
|
|
160
|
-
severity: "error",
|
|
161
|
-
category: "missing-required-param",
|
|
162
|
-
canvasId,
|
|
163
|
-
nodeId,
|
|
164
|
-
paramId: param.id,
|
|
165
|
-
message: `Missing required parameter "${param.label}"`,
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// invalid-reference: variableSelect points to deleted variable
|
|
171
|
-
if (param.type === "variableSelect" && value) {
|
|
172
|
-
const ref = value as Reference;
|
|
173
|
-
if (ref.varId) {
|
|
174
|
-
const key = refToLookupKey(ref);
|
|
175
|
-
if (!availableVariables[key]) {
|
|
176
|
-
diags.push({
|
|
177
|
-
severity: "error",
|
|
178
|
-
category: "invalid-reference",
|
|
179
|
-
canvasId,
|
|
180
|
-
nodeId,
|
|
181
|
-
paramId: param.id,
|
|
182
|
-
message: `"${param.label}" references a deleted variable`,
|
|
183
|
-
});
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// invalid-reference: channelSelect points to deleted or incompatible channel
|
|
189
|
-
if (param.type === "channelSelect" && value) {
|
|
190
|
-
const channelId = value as string;
|
|
191
|
-
const channel = Object.values(channels).find((v) => v.id === channelId);
|
|
192
|
-
if (!channel) {
|
|
193
|
-
diags.push({
|
|
194
|
-
severity: "error",
|
|
195
|
-
category: "invalid-reference",
|
|
196
|
-
canvasId,
|
|
197
|
-
nodeId,
|
|
198
|
-
paramId: param.id,
|
|
199
|
-
message: `"${param.label}" references a deleted channel`,
|
|
200
|
-
});
|
|
201
|
-
} else {
|
|
202
|
-
const channelParam = param as ChannelSelectParam;
|
|
203
|
-
const allowedTypes = resolveChannelTypes(channelParam, parameters);
|
|
204
|
-
if (!allowedTypes.includes(channel.type)) {
|
|
205
|
-
diags.push({
|
|
206
|
-
severity: "error",
|
|
207
|
-
category: "invalid-reference",
|
|
208
|
-
canvasId,
|
|
209
|
-
nodeId,
|
|
210
|
-
paramId: param.id,
|
|
211
|
-
message: `"${param.label}" references "${channel.label}" (${channel.type}), which is not a compatible channel type`,
|
|
212
|
-
});
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// invalid-reference: memory-refs entries point to deleted memory files
|
|
218
|
-
if (param.type === "memory-refs" && Array.isArray(value) && memory) {
|
|
219
|
-
const refs = value as Schemas["MemoryRef"][];
|
|
220
|
-
const knownIds = new Set(
|
|
221
|
-
Object.values(memory)
|
|
222
|
-
.filter((m) => m.type === "MemoryFile")
|
|
223
|
-
.map((m) => m.id),
|
|
224
|
-
);
|
|
225
|
-
const missing = refs.filter((r) => !r.id || !knownIds.has(r.id));
|
|
226
|
-
if (missing.length > 0) {
|
|
227
|
-
diags.push({
|
|
228
|
-
severity: "error",
|
|
229
|
-
category: "invalid-reference",
|
|
230
|
-
canvasId,
|
|
231
|
-
nodeId,
|
|
232
|
-
paramId: param.id,
|
|
233
|
-
message: `"${param.label}" references ${missing.length} deleted memory file${missing.length === 1 ? "" : "s"}`,
|
|
234
|
-
});
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// invalid-reference: memorySelect points to deleted or incompatible memory
|
|
239
|
-
if (param.type === "memorySelect" && value && memory) {
|
|
240
|
-
const memoryId = value as string;
|
|
241
|
-
const mem = Object.values(memory).find((m) => m.id === memoryId);
|
|
242
|
-
if (!mem) {
|
|
243
|
-
diags.push({
|
|
244
|
-
severity: "error",
|
|
245
|
-
category: "invalid-reference",
|
|
246
|
-
canvasId,
|
|
247
|
-
nodeId,
|
|
248
|
-
paramId: param.id,
|
|
249
|
-
message: `"${param.label}" references a deleted memory`,
|
|
250
|
-
});
|
|
251
|
-
} else {
|
|
252
|
-
const memoryParam = param as MemorySelectParam;
|
|
253
|
-
const allowedTypes = resolveMemoryTypes(memoryParam, parameters);
|
|
254
|
-
if (!allowedTypes.includes(mem.type)) {
|
|
255
|
-
diags.push({
|
|
256
|
-
severity: "error",
|
|
257
|
-
category: "invalid-reference",
|
|
258
|
-
canvasId,
|
|
259
|
-
nodeId,
|
|
260
|
-
paramId: param.id,
|
|
261
|
-
message: `"${param.label}" references "${mem.label}" (${mem.type}), which is not a compatible memory type`,
|
|
262
|
-
});
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// invalid-reference: modelSelect points to a deleted custom model or unknown catalog id
|
|
268
|
-
if (param.type === "modelSelect" && value && models) {
|
|
269
|
-
const modelId = value as string;
|
|
270
|
-
const custom = Object.values(models).find((m) => m.id === modelId);
|
|
271
|
-
if (custom) {
|
|
272
|
-
const modelParam = param as ModelSelectParam;
|
|
273
|
-
const allowedTypes = resolveModelTypes(modelParam, parameters);
|
|
274
|
-
if (!allowedTypes.includes(custom.type)) {
|
|
275
|
-
diags.push({
|
|
276
|
-
severity: "error",
|
|
277
|
-
category: "invalid-reference",
|
|
278
|
-
canvasId,
|
|
279
|
-
nodeId,
|
|
280
|
-
paramId: param.id,
|
|
281
|
-
message: `"${param.label}" references "${custom.label}" (${custom.type}), which is not a compatible model type`,
|
|
282
|
-
});
|
|
283
|
-
}
|
|
284
|
-
} else if (availableModelIds && !availableModelIds.has(modelId)) {
|
|
285
|
-
// Not a declared custom model and not in the supplied catalog → stale.
|
|
286
|
-
// Headlessly (no catalog) static ids can't be verified, so we don't flag.
|
|
287
|
-
diags.push({
|
|
288
|
-
severity: "error",
|
|
289
|
-
category: "invalid-reference",
|
|
290
|
-
canvasId,
|
|
291
|
-
nodeId,
|
|
292
|
-
paramId: param.id,
|
|
293
|
-
message: `"${param.label}" references a deleted model`,
|
|
294
|
-
});
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// --- Output binding diagnostics (skip when node is used as tool — outputs are scoped out) ---
|
|
300
|
-
if (!usedAsToolInput) {
|
|
301
|
-
const availableOutput = getNodeAvailableOutput(nodeData);
|
|
302
|
-
for (const outputId of Object.keys(availableOutput)) {
|
|
303
|
-
const binding = getOutputBinding(nodeData, outputId);
|
|
304
|
-
// Inactive bindings are discarded — nothing to validate.
|
|
305
|
-
if (!binding || !binding.active || binding.mode !== "assign") continue;
|
|
306
|
-
|
|
307
|
-
const outputDef = availableOutput[outputId];
|
|
308
|
-
if (!binding.target.srcId) {
|
|
309
|
-
diags.push({
|
|
310
|
-
severity: "error",
|
|
311
|
-
category: "assign-type-mismatch",
|
|
312
|
-
canvasId,
|
|
313
|
-
nodeId,
|
|
314
|
-
outputId,
|
|
315
|
-
message: `Output "${outputDef?.name ?? outputId}" has no variable selected`,
|
|
316
|
-
});
|
|
317
|
-
continue;
|
|
318
|
-
}
|
|
319
|
-
const key = refToLookupKey(binding.target);
|
|
320
|
-
const targetVar = availableVariables[key];
|
|
321
|
-
if (!targetVar) {
|
|
322
|
-
diags.push({
|
|
323
|
-
severity: "error",
|
|
324
|
-
category: "assign-type-mismatch",
|
|
325
|
-
canvasId,
|
|
326
|
-
nodeId,
|
|
327
|
-
outputId,
|
|
328
|
-
message: `Output "${outputDef?.name ?? outputId}" assigns to a deleted variable`,
|
|
329
|
-
});
|
|
330
|
-
} else if (outputDef && targetVar.dataType !== outputDef.dataType) {
|
|
331
|
-
diags.push({
|
|
332
|
-
severity: "error",
|
|
333
|
-
category: "assign-type-mismatch",
|
|
334
|
-
canvasId,
|
|
335
|
-
nodeId,
|
|
336
|
-
outputId,
|
|
337
|
-
message: `Output "${outputDef.name}" (${outputDef.dataType}) cannot assign to "${targetVar.name}" (${targetVar.dataType})`,
|
|
338
|
-
});
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
// --- List-output declaration diagnostics ---
|
|
343
|
-
// Walk each list-output entry. Two layers of validation:
|
|
344
|
-
// 1. Per-entry: name non-empty, assign target valid + type-compatible.
|
|
345
|
-
// 2. Per-list: names unique within the list — the `name` field doubles as the
|
|
346
|
-
// JSON property name in the LLM's structured response, so duplicates would
|
|
347
|
-
// silently collide. Required for both modes (emit and assign).
|
|
348
|
-
const listOutputs = (nodeDefinition.outputs ?? []).filter((o) => o.type === "list");
|
|
349
|
-
for (const out of listOutputs) {
|
|
350
|
-
const entries = ((nodeData.arguments as Record<string, unknown>)[out.id] as OutputDeclaration[] | undefined) ?? [];
|
|
351
|
-
|
|
352
|
-
// Build a name → indices map up-front; flag duplicates and empties on a per-entry basis.
|
|
353
|
-
const nameToIndices = new Map<string, number[]>();
|
|
354
|
-
entries.forEach((entry, index) => {
|
|
355
|
-
const arr = nameToIndices.get(entry.name);
|
|
356
|
-
if (arr) arr.push(index);
|
|
357
|
-
else nameToIndices.set(entry.name, [index]);
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
entries.forEach((entry, index) => {
|
|
361
|
-
const outputId = `${out.id}[${index}]`;
|
|
362
|
-
|
|
363
|
-
// missing name (both modes)
|
|
364
|
-
if (!entry.name || entry.name.trim() === "") {
|
|
365
|
-
diags.push({
|
|
366
|
-
severity: "error",
|
|
367
|
-
category: "missing-required-param",
|
|
368
|
-
canvasId,
|
|
369
|
-
nodeId,
|
|
370
|
-
outputId,
|
|
371
|
-
message: `${out.label} entry #${index + 1} has no name`,
|
|
372
|
-
});
|
|
373
|
-
} else {
|
|
374
|
-
const collisions = nameToIndices.get(entry.name) ?? [];
|
|
375
|
-
if (collisions.length > 1) {
|
|
376
|
-
diags.push({
|
|
377
|
-
severity: "error",
|
|
378
|
-
category: "duplicate-output-name",
|
|
379
|
-
canvasId,
|
|
380
|
-
nodeId,
|
|
381
|
-
outputId,
|
|
382
|
-
message: `${out.label} entry #${index + 1} has duplicate name "${entry.name}"`,
|
|
383
|
-
});
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
if (entry.mode !== "assign") return;
|
|
388
|
-
// Use a synthetic outputId: `<listId>[<index>]`. Stable per-position, surfaces the
|
|
389
|
-
// error on the node badge. (ListOutputSection could light up individual rows from
|
|
390
|
-
// this key in a follow-up.)
|
|
391
|
-
if (!entry.target.srcId) {
|
|
392
|
-
diags.push({
|
|
393
|
-
severity: "error",
|
|
394
|
-
category: "assign-type-mismatch",
|
|
395
|
-
canvasId,
|
|
396
|
-
nodeId,
|
|
397
|
-
outputId,
|
|
398
|
-
message: `${out.label} entry #${index + 1} has no variable selected`,
|
|
399
|
-
});
|
|
400
|
-
return;
|
|
401
|
-
}
|
|
402
|
-
const key = refToLookupKey(entry.target);
|
|
403
|
-
const targetVar = availableVariables[key];
|
|
404
|
-
if (!targetVar) {
|
|
405
|
-
diags.push({
|
|
406
|
-
severity: "error",
|
|
407
|
-
category: "assign-type-mismatch",
|
|
408
|
-
canvasId,
|
|
409
|
-
nodeId,
|
|
410
|
-
outputId,
|
|
411
|
-
message: `${out.label} entry #${index + 1} assigns to a deleted variable`,
|
|
412
|
-
});
|
|
413
|
-
} else if (targetVar.dataType !== entry.dataType) {
|
|
414
|
-
diags.push({
|
|
415
|
-
severity: "error",
|
|
416
|
-
category: "assign-type-mismatch",
|
|
417
|
-
canvasId,
|
|
418
|
-
nodeId,
|
|
419
|
-
outputId,
|
|
420
|
-
message: `${out.label} entry #${index + 1} (${entry.dataType}) cannot assign to "${targetVar.name}" (${targetVar.dataType})`,
|
|
421
|
-
});
|
|
422
|
-
}
|
|
423
|
-
});
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
// --- Port connectivity diagnostics ---
|
|
428
|
-
const controlInputs = portDefinitions.input.filter((p) => p.type === "control");
|
|
429
|
-
const toolInputPorts = portDefinitions.input.filter((p) => p.type === "tool");
|
|
430
|
-
const controlOutputs = portDefinitions.output.filter((p) => p.type === "control");
|
|
431
|
-
const connectedTargetHandles = new Set(edges.filter((e) => e.target === nodeId).map((e) => e.targetHandle));
|
|
432
|
-
const connectedSourceHandles = new Set(edges.filter((e) => e.source === nodeId).map((e) => e.sourceHandle));
|
|
433
|
-
|
|
434
|
-
// Check unconnected control inputs (applies to ALL nodes that have them)
|
|
435
|
-
if (controlInputs.length > 0) {
|
|
436
|
-
if (usedAsToolInput) {
|
|
437
|
-
for (const port of toolInputPorts) {
|
|
438
|
-
if (!connectedTargetHandles.has(port.id)) {
|
|
439
|
-
diags.push({
|
|
440
|
-
severity: "warning",
|
|
441
|
-
category: "unconnected-input",
|
|
442
|
-
canvasId,
|
|
443
|
-
nodeId,
|
|
444
|
-
message: `Node is not connected and will never run`,
|
|
445
|
-
});
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
} else {
|
|
449
|
-
for (const port of controlInputs) {
|
|
450
|
-
if (!connectedTargetHandles.has(port.id)) {
|
|
451
|
-
diags.push({
|
|
452
|
-
severity: "warning",
|
|
453
|
-
category: "unconnected-input",
|
|
454
|
-
canvasId,
|
|
455
|
-
nodeId,
|
|
456
|
-
message: `Node is not connected and will never run`,
|
|
457
|
-
});
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
} else if (toolInputPorts.length > 0) {
|
|
462
|
-
// Tool-only input nodes (e.g. WebSearchTool)
|
|
463
|
-
const hasAnyConnection = toolInputPorts.some((p) => connectedTargetHandles.has(p.id));
|
|
464
|
-
if (!hasAnyConnection) {
|
|
465
|
-
diags.push({
|
|
466
|
-
severity: "warning",
|
|
467
|
-
category: "tool-not-connected",
|
|
468
|
-
canvasId,
|
|
469
|
-
nodeId,
|
|
470
|
-
message: `"${nodeDefinition.label}" is not connected to an agent`,
|
|
471
|
-
});
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
// Check unconnected control outputs (triggers only — they are entry points that must lead somewhere)
|
|
476
|
-
if (controlOutputs.length > 0 && nodeDefinition.category === NodeCategory.Trigger) {
|
|
477
|
-
for (const port of controlOutputs) {
|
|
478
|
-
if (!connectedSourceHandles.has(port.id)) {
|
|
479
|
-
diags.push({
|
|
480
|
-
severity: "warning",
|
|
481
|
-
category: "unconnected-output",
|
|
482
|
-
canvasId,
|
|
483
|
-
nodeId,
|
|
484
|
-
message: `"${nodeDefinition.label}" has no outgoing connection — nothing will run after it`,
|
|
485
|
-
});
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
return diags;
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
/**
|
|
494
|
-
* Compute diagnostics for a single edge. Extracted from CustomEdge's useMemo.
|
|
495
|
-
*/
|
|
496
|
-
export function computeEdgeDiagnostics(opts: {
|
|
497
|
-
canvasId: string;
|
|
498
|
-
edgeId: string;
|
|
499
|
-
edgeType: EdgeType;
|
|
500
|
-
edgeData: EdgeData | undefined;
|
|
501
|
-
availableVariables: Record<string, Variable>;
|
|
502
|
-
sourceControlEdgeCount: number;
|
|
503
|
-
}): Diagnostic[] {
|
|
504
|
-
const { canvasId, edgeId, edgeType, edgeData, availableVariables, sourceControlEdgeCount } = opts;
|
|
505
|
-
const diags: Diagnostic[] = [];
|
|
506
|
-
const def = getEdgeDefinition(edgeType);
|
|
507
|
-
if (def.parameters.length === 0) return diags;
|
|
508
|
-
|
|
509
|
-
const isBranching = sourceControlEdgeCount > 1;
|
|
510
|
-
|
|
511
|
-
for (const param of def.parameters) {
|
|
512
|
-
// Description is optional on agent output edges when not branching
|
|
513
|
-
if (param.id === "description" && !isBranching && (edgeType === "agentChoice" || edgeType === "agentDelegate")) {
|
|
514
|
-
continue;
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
const value = edgeData?.[param.id];
|
|
518
|
-
|
|
519
|
-
if (param.type === "expression") {
|
|
520
|
-
const exprValue = value as Expression | undefined;
|
|
521
|
-
if (!exprValue?.expression) {
|
|
522
|
-
diags.push({
|
|
523
|
-
severity: "error",
|
|
524
|
-
category: "missing-required-param",
|
|
525
|
-
canvasId,
|
|
526
|
-
edgeId,
|
|
527
|
-
paramId: param.id,
|
|
528
|
-
message: `Missing required parameter "${param.label}" on edge`,
|
|
529
|
-
});
|
|
530
|
-
continue;
|
|
531
|
-
}
|
|
532
|
-
if (isExpression(exprValue)) {
|
|
533
|
-
const expr = resolveExpression(exprValue, availableVariables);
|
|
534
|
-
const parseRes = parseExpression(expr);
|
|
535
|
-
if (!parseRes.isValid) {
|
|
536
|
-
diags.push({
|
|
537
|
-
severity: "error",
|
|
538
|
-
category: "invalid-expression",
|
|
539
|
-
canvasId,
|
|
540
|
-
edgeId,
|
|
541
|
-
paramId: param.id,
|
|
542
|
-
message: `Invalid expression for "${param.label}": ${parseRes.errors.join(", ")}`,
|
|
543
|
-
});
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
} else {
|
|
547
|
-
if (!value) {
|
|
548
|
-
diags.push({
|
|
549
|
-
severity: "error",
|
|
550
|
-
category: "missing-required-param",
|
|
551
|
-
canvasId,
|
|
552
|
-
edgeId,
|
|
553
|
-
paramId: param.id,
|
|
554
|
-
message: `Missing required parameter "${param.label}" on edge`,
|
|
555
|
-
});
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
return diags;
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
/**
|
|
564
|
-
* Compute diagnostics for a single channel. Mirrors the parameter loop
|
|
565
|
-
* from computeNodeDiagnostics: filter to active params (per the type
|
|
566
|
-
* discriminator), then required-check each. Empty label is also flagged so
|
|
567
|
-
* the user has a non-blank name in `channelSelect` dropdowns.
|
|
568
|
-
*/
|
|
569
|
-
export function validateChannel(channel: Channel): Diagnostic[] {
|
|
570
|
-
const diags: Diagnostic[] = [];
|
|
571
|
-
|
|
572
|
-
if (!channel.label || channel.label.trim() === "") {
|
|
573
|
-
diags.push({
|
|
574
|
-
severity: "error",
|
|
575
|
-
category: "missing-required-param",
|
|
576
|
-
channelId: channel.id,
|
|
577
|
-
message: `Channel has no label`,
|
|
578
|
-
});
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
// `type` is mirrored into the args record so activation rules can read it.
|
|
582
|
-
const args: Record<string, unknown> = { ...channel.arguments, type: channel.type };
|
|
583
|
-
for (const param of CHANNEL_DEFINITION.parameters) {
|
|
584
|
-
if (param.id === "type") continue; // top-level discriminator, always set
|
|
585
|
-
if (!isParameterActive(param, args, false)) continue;
|
|
586
|
-
if (param.optional) continue;
|
|
587
|
-
|
|
588
|
-
const value = channel.arguments[param.id];
|
|
589
|
-
if (isEmpty(value)) {
|
|
590
|
-
diags.push({
|
|
591
|
-
severity: "error",
|
|
592
|
-
category: "missing-required-param",
|
|
593
|
-
channelId: channel.id,
|
|
594
|
-
paramId: param.id,
|
|
595
|
-
message: `Missing required parameter "${param.label}" on channel "${channel.label}"`,
|
|
596
|
-
});
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
return diags;
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
/**
|
|
604
|
-
* Compute diagnostics for a single memory primitive. Mirrors validateChannel:
|
|
605
|
-
* an empty label is flagged (so memorySelect/memory-refs dropdowns have a
|
|
606
|
-
* non-blank name), then each required parameter for the memory's type is checked.
|
|
607
|
-
*/
|
|
608
|
-
export function validateMemory(mem: Memory): Diagnostic[] {
|
|
609
|
-
const diags: Diagnostic[] = [];
|
|
610
|
-
|
|
611
|
-
if (!mem.label || mem.label.trim() === "") {
|
|
612
|
-
diags.push({
|
|
613
|
-
severity: "error",
|
|
614
|
-
category: "missing-required-param",
|
|
615
|
-
memoryId: mem.id,
|
|
616
|
-
message: `Memory has no label`,
|
|
617
|
-
});
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
const def = MemoryRegistry.getByType(mem.type);
|
|
621
|
-
for (const param of def?.parameters ?? []) {
|
|
622
|
-
if (!isParameterActive(param, mem.arguments, false)) continue;
|
|
623
|
-
if (param.optional) continue;
|
|
624
|
-
|
|
625
|
-
const value = mem.arguments[param.id];
|
|
626
|
-
if (isEmpty(value)) {
|
|
627
|
-
diags.push({
|
|
628
|
-
severity: "error",
|
|
629
|
-
category: "missing-required-param",
|
|
630
|
-
memoryId: mem.id,
|
|
631
|
-
paramId: param.id,
|
|
632
|
-
message: `Missing required parameter "${param.label}" on memory "${mem.label}"`,
|
|
633
|
-
});
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
return diags;
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
/**
|
|
641
|
-
* Compute diagnostics for a single declared (custom) model. Mirrors
|
|
642
|
-
* validateMemory: an empty label is flagged, then each required parameter for
|
|
643
|
-
* the model's type is checked (LLMModel has none today, so this is label-only).
|
|
644
|
-
*/
|
|
645
|
-
export function validateModel(model: Model): Diagnostic[] {
|
|
646
|
-
const diags: Diagnostic[] = [];
|
|
647
|
-
|
|
648
|
-
if (!model.label || model.label.trim() === "") {
|
|
649
|
-
diags.push({
|
|
650
|
-
severity: "error",
|
|
651
|
-
category: "missing-required-param",
|
|
652
|
-
modelId: model.id,
|
|
653
|
-
message: `Model has no label`,
|
|
654
|
-
});
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
const def = ModelRegistry.getByType(model.type);
|
|
658
|
-
for (const param of def?.parameters ?? []) {
|
|
659
|
-
if (!isParameterActive(param, model.arguments, false)) continue;
|
|
660
|
-
if (param.optional) continue;
|
|
661
|
-
|
|
662
|
-
const value = model.arguments[param.id];
|
|
663
|
-
if (isEmpty(value)) {
|
|
664
|
-
diags.push({
|
|
665
|
-
severity: "error",
|
|
666
|
-
category: "missing-required-param",
|
|
667
|
-
modelId: model.id,
|
|
668
|
-
paramId: param.id,
|
|
669
|
-
message: `Missing required parameter "${param.label}" on model "${model.label}"`,
|
|
670
|
-
});
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
return diags;
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
/**
|
|
678
|
-
* Validate a function's bundled output assignments, keyed by `outputId` so a caller
|
|
679
|
-
* can ring the specific row. The single home for this logic — shared by the live
|
|
680
|
-
* config panel, the project-scoped {@link validateFunction}, and the full
|
|
681
|
-
* {@link validateWorkflowState}.
|
|
682
|
-
*
|
|
683
|
-
* Without `availableVariables` (no body scope) it can only flag a *missing*
|
|
684
|
-
* expression (the engine-invariant check). Given the body's variables it also
|
|
685
|
-
* resolves + parses each expression, catching invalid references and type mismatches
|
|
686
|
-
* — the same `resolveExpression`/`parseExpression` path node expression params use.
|
|
687
|
-
*/
|
|
688
|
-
export function validateFunctionOutputs(
|
|
689
|
-
outputs: readonly OutputAssignment[],
|
|
690
|
-
availableVariables?: Record<string, Variable>,
|
|
691
|
-
): Diagnostic[] {
|
|
692
|
-
const diags: Diagnostic[] = [];
|
|
693
|
-
for (const out of outputs) {
|
|
694
|
-
const text = out.expression?.expression;
|
|
695
|
-
if (!text || text.trim() === "") {
|
|
696
|
-
diags.push({
|
|
697
|
-
severity: "error",
|
|
698
|
-
category: "missing-output-assignment",
|
|
699
|
-
outputId: out.uid,
|
|
700
|
-
message: `Missing return value assignment for "${out.name}"`,
|
|
701
|
-
});
|
|
702
|
-
} else if (availableVariables) {
|
|
703
|
-
const parseRes = parseExpression(resolveExpression(out.expression, availableVariables));
|
|
704
|
-
if (!parseRes.isValid) {
|
|
705
|
-
diags.push({
|
|
706
|
-
severity: "error",
|
|
707
|
-
category: "invalid-expression",
|
|
708
|
-
outputId: out.uid,
|
|
709
|
-
message: `Invalid expression for "${out.name}": ${parseRes.errors.join(", ")}`,
|
|
710
|
-
});
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
return diags;
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
/**
|
|
718
|
-
* Compute diagnostics for a single function declaration (the project-scoped
|
|
719
|
-
* signature + bundled output assignments, independent of the body canvas). Flags an
|
|
720
|
-
* empty name and any output without an assigned expression. Expression *validity*
|
|
721
|
-
* (which needs the body's variable scope) is left to the scoped callers — the config
|
|
722
|
-
* panel and validateWorkflowState (keyed by the function's canvas).
|
|
723
|
-
*/
|
|
724
|
-
export function validateFunction(fn: FunctionDeclaration, availableVariables?: Record<string, Variable>): Diagnostic[] {
|
|
725
|
-
const diags: Diagnostic[] = [];
|
|
726
|
-
|
|
727
|
-
if (!fn.name || fn.name.trim() === "") {
|
|
728
|
-
diags.push({
|
|
729
|
-
severity: "error",
|
|
730
|
-
category: "missing-required-param",
|
|
731
|
-
functionId: fn.id,
|
|
732
|
-
message: `Function has no name`,
|
|
733
|
-
});
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
// Pass the body scope through when the caller has it (the sidebar sync supplies the
|
|
737
|
-
// function's canvas variables) so invalid/typed expressions surface too, not just
|
|
738
|
-
// missing ones. Tag with functionId for the sidebar list ring / tab badge.
|
|
739
|
-
for (const d of validateFunctionOutputs(fn.outputs, availableVariables)) diags.push({ ...d, functionId: fn.id });
|
|
740
|
-
|
|
741
|
-
return diags;
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
// ============================================================================
|
|
745
|
-
// Full-Project Validation Result Types
|
|
746
|
-
// ============================================================================
|
|
747
|
-
|
|
748
|
-
export interface CanvasValidationResult {
|
|
749
|
-
canvasId: string;
|
|
750
|
-
canvasLabel: string;
|
|
751
|
-
diagnostics: Diagnostic[];
|
|
752
|
-
errorCount: number;
|
|
753
|
-
warningCount: number;
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
export interface ValidationResult {
|
|
757
|
-
canvases: CanvasValidationResult[];
|
|
758
|
-
/** Project-scoped channel diagnostics (no canvasId). */
|
|
759
|
-
channelDiagnostics: Diagnostic[];
|
|
760
|
-
/** Project-scoped memory diagnostics (no canvasId). */
|
|
761
|
-
memoryDiagnostics: Diagnostic[];
|
|
762
|
-
/** Project-scoped declared-model diagnostics (no canvasId). */
|
|
763
|
-
modelDiagnostics: Diagnostic[];
|
|
764
|
-
totalErrors: number;
|
|
765
|
-
totalWarnings: number;
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
/**
|
|
769
|
-
* Derive the function registry from a snapshot's canvases. Mirrors
|
|
770
|
-
* `computeAllFunctions()` in useFunctionRegistry exactly: every non-main
|
|
771
|
-
* canvas that carries a `functionInfo` is a function, keyed by canvas id
|
|
772
|
-
* (which equals the function id by invariant).
|
|
773
|
-
*
|
|
774
|
-
* Deriving from the snapshot rather than the module-level cache makes
|
|
775
|
-
* validation deterministic from its input alone — and removes the cache-lag
|
|
776
|
-
* window the store-bound path had.
|
|
777
|
-
*/
|
|
778
|
-
function deriveFunctionRegistry(functions: Record<string, FunctionDeclaration>): Record<string, FunctionInfo> {
|
|
779
|
-
const out: Record<string, FunctionInfo> = {};
|
|
780
|
-
for (const [id, decl] of Object.entries(functions)) out[id] = toFunctionInfo(decl);
|
|
781
|
-
return out;
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
/**
|
|
785
|
-
* Headless full-project validation. Pure: depends only on the passed
|
|
786
|
-
* {@link Workflow} (the in-memory domain shape) — no Zustand stores, no
|
|
787
|
-
* React, no DOM. Runnable in Node, a CLI, or a Claude Code skill.
|
|
788
|
-
*
|
|
789
|
-
* Two producers feed this: the editor reads its live stores into a
|
|
790
|
-
* `Workflow` literal; the CLI calls `deserialize(apiWorkflow)` from
|
|
791
|
-
* `../workflow/serialization` to convert the api JSON into this shape.
|
|
792
|
-
*/
|
|
793
|
-
export function validateWorkflowState(state: Workflow): ValidationResult {
|
|
794
|
-
const canvasData = state.canvases ?? {};
|
|
795
|
-
const functionDecls = state.functions ?? {};
|
|
796
|
-
const allFunctions = deriveFunctionRegistry(functionDecls);
|
|
797
|
-
const channels = state.channels ?? {};
|
|
798
|
-
const memory = state.memory ?? {};
|
|
799
|
-
const models = state.models ?? {};
|
|
800
|
-
|
|
801
|
-
const canvases: CanvasValidationResult[] = [];
|
|
802
|
-
let totalErrors = 0;
|
|
803
|
-
let totalWarnings = 0;
|
|
804
|
-
|
|
805
|
-
for (const [canvasId, canvas] of Object.entries(canvasData)) {
|
|
806
|
-
const { nodes, edges } = canvas;
|
|
807
|
-
|
|
808
|
-
// Each canvas is fully self-contained — function canvases do not see main-canvas variables.
|
|
809
|
-
const { lookup: availableVariables } = computeAvailableVariables(canvas.variables, edges);
|
|
810
|
-
|
|
811
|
-
const canvasDiags: Diagnostic[] = [];
|
|
812
|
-
|
|
813
|
-
// Compute node diagnostics
|
|
814
|
-
for (const node of nodes) {
|
|
815
|
-
const nodeData = node;
|
|
816
|
-
|
|
817
|
-
// Resolve node definition
|
|
818
|
-
let nodeDefinition: NodeDefinition | undefined;
|
|
819
|
-
let isStale = false;
|
|
820
|
-
let isDeleted = false;
|
|
821
|
-
|
|
822
|
-
if (nodeData.type === "FunctionCall") {
|
|
823
|
-
const fnNode = nodeData as FunctionCallNode;
|
|
824
|
-
const registryFn = allFunctions[fnNode.functionInfo.id];
|
|
825
|
-
isDeleted = !registryFn;
|
|
826
|
-
isStale = registryFn ? fnNode.functionInfo.version !== registryFn.version : false;
|
|
827
|
-
if (registryFn) {
|
|
828
|
-
nodeDefinition = buildFunctionNodeDef(registryFn) as NodeDefinition;
|
|
829
|
-
} else {
|
|
830
|
-
nodeDefinition = buildFunctionNodeDef(fnNode.functionInfo) as NodeDefinition;
|
|
831
|
-
}
|
|
832
|
-
} else {
|
|
833
|
-
nodeDefinition = NodeRegistry.getByType(nodeData.type);
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
const diags = computeNodeDiagnostics({
|
|
837
|
-
canvasId,
|
|
838
|
-
nodeId: node.id,
|
|
839
|
-
nodeData,
|
|
840
|
-
nodeDefinition,
|
|
841
|
-
availableVariables,
|
|
842
|
-
channels,
|
|
843
|
-
memory,
|
|
844
|
-
models,
|
|
845
|
-
edges,
|
|
846
|
-
isStale,
|
|
847
|
-
isDeleted,
|
|
848
|
-
});
|
|
849
|
-
|
|
850
|
-
canvasDiags.push(...diags);
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
// Compute edge diagnostics
|
|
854
|
-
const sourceControlCounts = new Map<string, number>();
|
|
855
|
-
for (const edge of edges) {
|
|
856
|
-
if (isControlFlow(edge.type as EdgeType)) {
|
|
857
|
-
sourceControlCounts.set(edge.source, (sourceControlCounts.get(edge.source) ?? 0) + 1);
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
for (const edge of edges) {
|
|
862
|
-
const edgeType = (edge.type ?? "control") as EdgeType;
|
|
863
|
-
const diags = computeEdgeDiagnostics({
|
|
864
|
-
canvasId,
|
|
865
|
-
edgeId: edge.id,
|
|
866
|
-
edgeType,
|
|
867
|
-
edgeData: edge.data,
|
|
868
|
-
availableVariables,
|
|
869
|
-
sourceControlEdgeCount: sourceControlCounts.get(edge.source) ?? 0,
|
|
870
|
-
});
|
|
871
|
-
|
|
872
|
-
canvasDiags.push(...diags);
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
// Output assignment diagnostics: the function's declaration (functions[canvasId])
|
|
876
|
-
// owns the bundled outputs; their expressions resolve against this body's scope.
|
|
877
|
-
const fnDecl = functionDecls[canvasId];
|
|
878
|
-
if (fnDecl) {
|
|
879
|
-
for (const d of validateFunctionOutputs(fnDecl.outputs, availableVariables)) {
|
|
880
|
-
canvasDiags.push({ ...d, canvasId });
|
|
881
|
-
}
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
// Only include canvases with issues
|
|
885
|
-
if (canvasDiags.length > 0) {
|
|
886
|
-
const errorCount = canvasDiags.filter((d) => d.severity === "error").length;
|
|
887
|
-
const warningCount = canvasDiags.filter((d) => d.severity === "warning").length;
|
|
888
|
-
|
|
889
|
-
// Derive canvas label
|
|
890
|
-
const canvasLabel = canvasId === MAIN_CANVAS_ID ? "Main" : (functionDecls[canvasId]?.name ?? canvasId);
|
|
891
|
-
|
|
892
|
-
canvases.push({
|
|
893
|
-
canvasId,
|
|
894
|
-
canvasLabel,
|
|
895
|
-
diagnostics: canvasDiags,
|
|
896
|
-
errorCount,
|
|
897
|
-
warningCount,
|
|
898
|
-
});
|
|
899
|
-
|
|
900
|
-
totalErrors += errorCount;
|
|
901
|
-
totalWarnings += warningCount;
|
|
902
|
-
}
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
// Project-scoped channel diagnostics — independent of canvas iteration.
|
|
906
|
-
const channelDiagnostics: Diagnostic[] = [];
|
|
907
|
-
for (const channel of Object.values(channels)) {
|
|
908
|
-
channelDiagnostics.push(...validateChannel(channel));
|
|
909
|
-
}
|
|
910
|
-
for (const d of channelDiagnostics) {
|
|
911
|
-
if (d.severity === "error") totalErrors++;
|
|
912
|
-
else totalWarnings++;
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
// Project-scoped memory diagnostics — independent of canvas iteration.
|
|
916
|
-
const memoryDiagnostics: Diagnostic[] = [];
|
|
917
|
-
for (const mem of Object.values(memory)) {
|
|
918
|
-
memoryDiagnostics.push(...validateMemory(mem));
|
|
919
|
-
}
|
|
920
|
-
for (const d of memoryDiagnostics) {
|
|
921
|
-
if (d.severity === "error") totalErrors++;
|
|
922
|
-
else totalWarnings++;
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
// Project-scoped declared-model diagnostics — independent of canvas iteration.
|
|
926
|
-
const modelDiagnostics: Diagnostic[] = [];
|
|
927
|
-
for (const model of Object.values(models)) {
|
|
928
|
-
modelDiagnostics.push(...validateModel(model));
|
|
929
|
-
}
|
|
930
|
-
for (const d of modelDiagnostics) {
|
|
931
|
-
if (d.severity === "error") totalErrors++;
|
|
932
|
-
else totalWarnings++;
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
return { canvases, channelDiagnostics, memoryDiagnostics, modelDiagnostics, totalErrors, totalWarnings };
|
|
936
|
-
}
|
|
1
|
+
import type { NodeDefinition, NodeData } from "../node";
|
|
2
|
+
import type { Expression } from "../api";
|
|
3
|
+
import { NodeCategory, NodeRegistry } from "../node";
|
|
4
|
+
import type { EdgeData, EdgeType } from "../edge";
|
|
5
|
+
import { getEdgeDefinition, isControlFlow } from "../edge";
|
|
6
|
+
import { getArguments, getNodeAvailableOutput, getOutputBinding, getPorts, isNodeUsedAsTool } from "../node/methods";
|
|
7
|
+
import type { Edge } from "../edge";
|
|
8
|
+
import type { Variable } from "../variable";
|
|
9
|
+
import { computeAvailableVariables, refToLookupKey } from "../variable";
|
|
10
|
+
import { isExpression, resolveExpression } from "../expression/types";
|
|
11
|
+
import { parseExpression } from "../expression/parser";
|
|
12
|
+
import { isParameterActive, isEmpty, resolveExpressionType, resolveChannelTypes, resolveMemoryTypes, resolveModelTypes } from "../parameter";
|
|
13
|
+
import type { ExpressionParam, ChannelSelectParam, MemorySelectParam, ModelSelectParam, OutputDeclaration } from "../parameter";
|
|
14
|
+
import type { Channel } from "../channel";
|
|
15
|
+
import { CHANNEL_DEFINITION } from "../channel";
|
|
16
|
+
import type { Memory } from "../memory";
|
|
17
|
+
import { MemoryRegistry } from "../memory";
|
|
18
|
+
import type { Model } from "../model";
|
|
19
|
+
import { ModelRegistry } from "../model";
|
|
20
|
+
import type { Schemas } from "../api";
|
|
21
|
+
import type { Reference } from "../api";
|
|
22
|
+
import { FunctionCallNode, buildFunctionNodeDef } from "../node/FunctionNode";
|
|
23
|
+
import type { FunctionDeclaration, FunctionInfo, OutputAssignment } from "../function";
|
|
24
|
+
import { toFunctionInfo } from "../function";
|
|
25
|
+
import { MAIN_CANVAS_ID, type Workflow, type Canvas } from "../workflow/Workflow";
|
|
26
|
+
|
|
27
|
+
// ============================================================================
|
|
28
|
+
// Types
|
|
29
|
+
// ============================================================================
|
|
30
|
+
|
|
31
|
+
export type DiagnosticSeverity = "error" | "warning";
|
|
32
|
+
|
|
33
|
+
export type DiagnosticCategory =
|
|
34
|
+
| "missing-required-param"
|
|
35
|
+
| "invalid-expression"
|
|
36
|
+
| "invalid-reference"
|
|
37
|
+
| "function-deleted"
|
|
38
|
+
| "function-stale"
|
|
39
|
+
| "unconnected-input"
|
|
40
|
+
| "unconnected-output"
|
|
41
|
+
| "tool-not-connected"
|
|
42
|
+
| "missing-output-assignment"
|
|
43
|
+
| "assign-type-mismatch"
|
|
44
|
+
| "duplicate-output-name";
|
|
45
|
+
|
|
46
|
+
export interface Diagnostic {
|
|
47
|
+
severity: DiagnosticSeverity;
|
|
48
|
+
category: DiagnosticCategory;
|
|
49
|
+
message: string;
|
|
50
|
+
/** Canvas this diagnostic belongs to. Omitted for project-scoped sources (e.g. channels). */
|
|
51
|
+
canvasId?: string;
|
|
52
|
+
nodeId?: string;
|
|
53
|
+
edgeId?: string;
|
|
54
|
+
/** Set when the diagnostic targets a project-scoped channel. */
|
|
55
|
+
channelId?: string;
|
|
56
|
+
/** Set when the diagnostic targets a project-scoped memory primitive. */
|
|
57
|
+
memoryId?: string;
|
|
58
|
+
/** Set when the diagnostic targets a project-scoped declared model. */
|
|
59
|
+
modelId?: string;
|
|
60
|
+
/** Set when the diagnostic targets a project-scoped function declaration. */
|
|
61
|
+
functionId?: string;
|
|
62
|
+
paramId?: string;
|
|
63
|
+
outputId?: string; // For output binding diagnostics
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Compute diagnostics for a single node.
|
|
68
|
+
*/
|
|
69
|
+
export function computeNodeDiagnostics(opts: {
|
|
70
|
+
canvasId: string;
|
|
71
|
+
nodeId: string;
|
|
72
|
+
nodeData: NodeData;
|
|
73
|
+
nodeDefinition: NodeDefinition | undefined;
|
|
74
|
+
availableVariables: Record<string, Variable>;
|
|
75
|
+
channels: Record<string, Channel>;
|
|
76
|
+
memory?: Record<string, Memory>;
|
|
77
|
+
/** Declared custom models (project-scoped). */
|
|
78
|
+
models?: Record<string, Model>;
|
|
79
|
+
/** Ids in the static model catalog (props-supplied). Undefined headlessly — catalog ids then aren't flagged. */
|
|
80
|
+
availableModelIds?: Set<string>;
|
|
81
|
+
edges: readonly Edge[];
|
|
82
|
+
isStale?: boolean;
|
|
83
|
+
isDeleted?: boolean;
|
|
84
|
+
}): Diagnostic[] {
|
|
85
|
+
const {
|
|
86
|
+
canvasId,
|
|
87
|
+
nodeId,
|
|
88
|
+
nodeData,
|
|
89
|
+
nodeDefinition,
|
|
90
|
+
availableVariables,
|
|
91
|
+
channels,
|
|
92
|
+
memory,
|
|
93
|
+
models,
|
|
94
|
+
availableModelIds,
|
|
95
|
+
edges,
|
|
96
|
+
isStale = false,
|
|
97
|
+
isDeleted = false,
|
|
98
|
+
} = opts;
|
|
99
|
+
|
|
100
|
+
const diags: Diagnostic[] = [];
|
|
101
|
+
|
|
102
|
+
// --- Function-specific diagnostics ---
|
|
103
|
+
if (isDeleted) {
|
|
104
|
+
diags.push({
|
|
105
|
+
severity: "error",
|
|
106
|
+
category: "function-deleted",
|
|
107
|
+
canvasId,
|
|
108
|
+
nodeId,
|
|
109
|
+
message: "Function has been deleted. Remove this node.",
|
|
110
|
+
});
|
|
111
|
+
} else if (isStale) {
|
|
112
|
+
diags.push({
|
|
113
|
+
severity: "warning",
|
|
114
|
+
category: "function-stale",
|
|
115
|
+
canvasId,
|
|
116
|
+
nodeId,
|
|
117
|
+
message: "Function definition has changed. Please update this node.",
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!nodeDefinition) return diags;
|
|
122
|
+
|
|
123
|
+
const portDefinitions = getPorts(nodeData);
|
|
124
|
+
const parameters = getArguments(nodeData);
|
|
125
|
+
const usedAsToolInput = isNodeUsedAsTool(nodeId, nodeData, edges);
|
|
126
|
+
|
|
127
|
+
// --- Parameter diagnostics ---
|
|
128
|
+
for (const param of nodeDefinition.parameters) {
|
|
129
|
+
if (!isParameterActive(param, parameters, usedAsToolInput)) continue;
|
|
130
|
+
const value = parameters[param.id];
|
|
131
|
+
|
|
132
|
+
// invalid-expression (skip empty — caught by missing-required-param)
|
|
133
|
+
if (isExpression(value) && value.expression) {
|
|
134
|
+
const expr = resolveExpression(value, availableVariables);
|
|
135
|
+
// Resolve expressionType (static, args-only lambda, or derived from a referenced variable)
|
|
136
|
+
if (param.type === "expression") {
|
|
137
|
+
expr.expectedType = resolveExpressionType(param as ExpressionParam, parameters, availableVariables);
|
|
138
|
+
}
|
|
139
|
+
const parseRes = parseExpression(expr);
|
|
140
|
+
if (!parseRes.isValid) {
|
|
141
|
+
diags.push({
|
|
142
|
+
severity: "error",
|
|
143
|
+
category: "invalid-expression",
|
|
144
|
+
canvasId,
|
|
145
|
+
nodeId,
|
|
146
|
+
paramId: param.id,
|
|
147
|
+
message: `Invalid expression for "${param.label}": ${parseRes.errors.join(", ")}`,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// missing-required-param
|
|
153
|
+
if (!param.optional) {
|
|
154
|
+
const isEmptyExpression = isExpression(value) && !value.expression;
|
|
155
|
+
const isEmptyReference =
|
|
156
|
+
param.type === "variableSelect" &&
|
|
157
|
+
(!value || (typeof value === "object" && value !== null && !(value as { varId?: string }).varId));
|
|
158
|
+
if (isEmpty(value) || isEmptyExpression || isEmptyReference) {
|
|
159
|
+
diags.push({
|
|
160
|
+
severity: "error",
|
|
161
|
+
category: "missing-required-param",
|
|
162
|
+
canvasId,
|
|
163
|
+
nodeId,
|
|
164
|
+
paramId: param.id,
|
|
165
|
+
message: `Missing required parameter "${param.label}"`,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// invalid-reference: variableSelect points to deleted variable
|
|
171
|
+
if (param.type === "variableSelect" && value) {
|
|
172
|
+
const ref = value as Reference;
|
|
173
|
+
if (ref.varId) {
|
|
174
|
+
const key = refToLookupKey(ref);
|
|
175
|
+
if (!availableVariables[key]) {
|
|
176
|
+
diags.push({
|
|
177
|
+
severity: "error",
|
|
178
|
+
category: "invalid-reference",
|
|
179
|
+
canvasId,
|
|
180
|
+
nodeId,
|
|
181
|
+
paramId: param.id,
|
|
182
|
+
message: `"${param.label}" references a deleted variable`,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// invalid-reference: channelSelect points to deleted or incompatible channel
|
|
189
|
+
if (param.type === "channelSelect" && value) {
|
|
190
|
+
const channelId = value as string;
|
|
191
|
+
const channel = Object.values(channels).find((v) => v.id === channelId);
|
|
192
|
+
if (!channel) {
|
|
193
|
+
diags.push({
|
|
194
|
+
severity: "error",
|
|
195
|
+
category: "invalid-reference",
|
|
196
|
+
canvasId,
|
|
197
|
+
nodeId,
|
|
198
|
+
paramId: param.id,
|
|
199
|
+
message: `"${param.label}" references a deleted channel`,
|
|
200
|
+
});
|
|
201
|
+
} else {
|
|
202
|
+
const channelParam = param as ChannelSelectParam;
|
|
203
|
+
const allowedTypes = resolveChannelTypes(channelParam, parameters);
|
|
204
|
+
if (!allowedTypes.includes(channel.type)) {
|
|
205
|
+
diags.push({
|
|
206
|
+
severity: "error",
|
|
207
|
+
category: "invalid-reference",
|
|
208
|
+
canvasId,
|
|
209
|
+
nodeId,
|
|
210
|
+
paramId: param.id,
|
|
211
|
+
message: `"${param.label}" references "${channel.label}" (${channel.type}), which is not a compatible channel type`,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// invalid-reference: memory-refs entries point to deleted memory files
|
|
218
|
+
if (param.type === "memory-refs" && Array.isArray(value) && memory) {
|
|
219
|
+
const refs = value as Schemas["MemoryRef"][];
|
|
220
|
+
const knownIds = new Set(
|
|
221
|
+
Object.values(memory)
|
|
222
|
+
.filter((m) => m.type === "MemoryFile")
|
|
223
|
+
.map((m) => m.id),
|
|
224
|
+
);
|
|
225
|
+
const missing = refs.filter((r) => !r.id || !knownIds.has(r.id));
|
|
226
|
+
if (missing.length > 0) {
|
|
227
|
+
diags.push({
|
|
228
|
+
severity: "error",
|
|
229
|
+
category: "invalid-reference",
|
|
230
|
+
canvasId,
|
|
231
|
+
nodeId,
|
|
232
|
+
paramId: param.id,
|
|
233
|
+
message: `"${param.label}" references ${missing.length} deleted memory file${missing.length === 1 ? "" : "s"}`,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// invalid-reference: memorySelect points to deleted or incompatible memory
|
|
239
|
+
if (param.type === "memorySelect" && value && memory) {
|
|
240
|
+
const memoryId = value as string;
|
|
241
|
+
const mem = Object.values(memory).find((m) => m.id === memoryId);
|
|
242
|
+
if (!mem) {
|
|
243
|
+
diags.push({
|
|
244
|
+
severity: "error",
|
|
245
|
+
category: "invalid-reference",
|
|
246
|
+
canvasId,
|
|
247
|
+
nodeId,
|
|
248
|
+
paramId: param.id,
|
|
249
|
+
message: `"${param.label}" references a deleted memory`,
|
|
250
|
+
});
|
|
251
|
+
} else {
|
|
252
|
+
const memoryParam = param as MemorySelectParam;
|
|
253
|
+
const allowedTypes = resolveMemoryTypes(memoryParam, parameters);
|
|
254
|
+
if (!allowedTypes.includes(mem.type)) {
|
|
255
|
+
diags.push({
|
|
256
|
+
severity: "error",
|
|
257
|
+
category: "invalid-reference",
|
|
258
|
+
canvasId,
|
|
259
|
+
nodeId,
|
|
260
|
+
paramId: param.id,
|
|
261
|
+
message: `"${param.label}" references "${mem.label}" (${mem.type}), which is not a compatible memory type`,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// invalid-reference: modelSelect points to a deleted custom model or unknown catalog id
|
|
268
|
+
if (param.type === "modelSelect" && value && models) {
|
|
269
|
+
const modelId = value as string;
|
|
270
|
+
const custom = Object.values(models).find((m) => m.id === modelId);
|
|
271
|
+
if (custom) {
|
|
272
|
+
const modelParam = param as ModelSelectParam;
|
|
273
|
+
const allowedTypes = resolveModelTypes(modelParam, parameters);
|
|
274
|
+
if (!allowedTypes.includes(custom.type)) {
|
|
275
|
+
diags.push({
|
|
276
|
+
severity: "error",
|
|
277
|
+
category: "invalid-reference",
|
|
278
|
+
canvasId,
|
|
279
|
+
nodeId,
|
|
280
|
+
paramId: param.id,
|
|
281
|
+
message: `"${param.label}" references "${custom.label}" (${custom.type}), which is not a compatible model type`,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
} else if (availableModelIds && !availableModelIds.has(modelId)) {
|
|
285
|
+
// Not a declared custom model and not in the supplied catalog → stale.
|
|
286
|
+
// Headlessly (no catalog) static ids can't be verified, so we don't flag.
|
|
287
|
+
diags.push({
|
|
288
|
+
severity: "error",
|
|
289
|
+
category: "invalid-reference",
|
|
290
|
+
canvasId,
|
|
291
|
+
nodeId,
|
|
292
|
+
paramId: param.id,
|
|
293
|
+
message: `"${param.label}" references a deleted model`,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// --- Output binding diagnostics (skip when node is used as tool — outputs are scoped out) ---
|
|
300
|
+
if (!usedAsToolInput) {
|
|
301
|
+
const availableOutput = getNodeAvailableOutput(nodeData);
|
|
302
|
+
for (const outputId of Object.keys(availableOutput)) {
|
|
303
|
+
const binding = getOutputBinding(nodeData, outputId);
|
|
304
|
+
// Inactive bindings are discarded — nothing to validate.
|
|
305
|
+
if (!binding || !binding.active || binding.mode !== "assign") continue;
|
|
306
|
+
|
|
307
|
+
const outputDef = availableOutput[outputId];
|
|
308
|
+
if (!binding.target.srcId) {
|
|
309
|
+
diags.push({
|
|
310
|
+
severity: "error",
|
|
311
|
+
category: "assign-type-mismatch",
|
|
312
|
+
canvasId,
|
|
313
|
+
nodeId,
|
|
314
|
+
outputId,
|
|
315
|
+
message: `Output "${outputDef?.name ?? outputId}" has no variable selected`,
|
|
316
|
+
});
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
const key = refToLookupKey(binding.target);
|
|
320
|
+
const targetVar = availableVariables[key];
|
|
321
|
+
if (!targetVar) {
|
|
322
|
+
diags.push({
|
|
323
|
+
severity: "error",
|
|
324
|
+
category: "assign-type-mismatch",
|
|
325
|
+
canvasId,
|
|
326
|
+
nodeId,
|
|
327
|
+
outputId,
|
|
328
|
+
message: `Output "${outputDef?.name ?? outputId}" assigns to a deleted variable`,
|
|
329
|
+
});
|
|
330
|
+
} else if (outputDef && targetVar.dataType !== outputDef.dataType) {
|
|
331
|
+
diags.push({
|
|
332
|
+
severity: "error",
|
|
333
|
+
category: "assign-type-mismatch",
|
|
334
|
+
canvasId,
|
|
335
|
+
nodeId,
|
|
336
|
+
outputId,
|
|
337
|
+
message: `Output "${outputDef.name}" (${outputDef.dataType}) cannot assign to "${targetVar.name}" (${targetVar.dataType})`,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// --- List-output declaration diagnostics ---
|
|
343
|
+
// Walk each list-output entry. Two layers of validation:
|
|
344
|
+
// 1. Per-entry: name non-empty, assign target valid + type-compatible.
|
|
345
|
+
// 2. Per-list: names unique within the list — the `name` field doubles as the
|
|
346
|
+
// JSON property name in the LLM's structured response, so duplicates would
|
|
347
|
+
// silently collide. Required for both modes (emit and assign).
|
|
348
|
+
const listOutputs = (nodeDefinition.outputs ?? []).filter((o) => o.type === "list");
|
|
349
|
+
for (const out of listOutputs) {
|
|
350
|
+
const entries = ((nodeData.arguments as Record<string, unknown>)[out.id] as OutputDeclaration[] | undefined) ?? [];
|
|
351
|
+
|
|
352
|
+
// Build a name → indices map up-front; flag duplicates and empties on a per-entry basis.
|
|
353
|
+
const nameToIndices = new Map<string, number[]>();
|
|
354
|
+
entries.forEach((entry, index) => {
|
|
355
|
+
const arr = nameToIndices.get(entry.name);
|
|
356
|
+
if (arr) arr.push(index);
|
|
357
|
+
else nameToIndices.set(entry.name, [index]);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
entries.forEach((entry, index) => {
|
|
361
|
+
const outputId = `${out.id}[${index}]`;
|
|
362
|
+
|
|
363
|
+
// missing name (both modes)
|
|
364
|
+
if (!entry.name || entry.name.trim() === "") {
|
|
365
|
+
diags.push({
|
|
366
|
+
severity: "error",
|
|
367
|
+
category: "missing-required-param",
|
|
368
|
+
canvasId,
|
|
369
|
+
nodeId,
|
|
370
|
+
outputId,
|
|
371
|
+
message: `${out.label} entry #${index + 1} has no name`,
|
|
372
|
+
});
|
|
373
|
+
} else {
|
|
374
|
+
const collisions = nameToIndices.get(entry.name) ?? [];
|
|
375
|
+
if (collisions.length > 1) {
|
|
376
|
+
diags.push({
|
|
377
|
+
severity: "error",
|
|
378
|
+
category: "duplicate-output-name",
|
|
379
|
+
canvasId,
|
|
380
|
+
nodeId,
|
|
381
|
+
outputId,
|
|
382
|
+
message: `${out.label} entry #${index + 1} has duplicate name "${entry.name}"`,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (entry.mode !== "assign") return;
|
|
388
|
+
// Use a synthetic outputId: `<listId>[<index>]`. Stable per-position, surfaces the
|
|
389
|
+
// error on the node badge. (ListOutputSection could light up individual rows from
|
|
390
|
+
// this key in a follow-up.)
|
|
391
|
+
if (!entry.target.srcId) {
|
|
392
|
+
diags.push({
|
|
393
|
+
severity: "error",
|
|
394
|
+
category: "assign-type-mismatch",
|
|
395
|
+
canvasId,
|
|
396
|
+
nodeId,
|
|
397
|
+
outputId,
|
|
398
|
+
message: `${out.label} entry #${index + 1} has no variable selected`,
|
|
399
|
+
});
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const key = refToLookupKey(entry.target);
|
|
403
|
+
const targetVar = availableVariables[key];
|
|
404
|
+
if (!targetVar) {
|
|
405
|
+
diags.push({
|
|
406
|
+
severity: "error",
|
|
407
|
+
category: "assign-type-mismatch",
|
|
408
|
+
canvasId,
|
|
409
|
+
nodeId,
|
|
410
|
+
outputId,
|
|
411
|
+
message: `${out.label} entry #${index + 1} assigns to a deleted variable`,
|
|
412
|
+
});
|
|
413
|
+
} else if (targetVar.dataType !== entry.dataType) {
|
|
414
|
+
diags.push({
|
|
415
|
+
severity: "error",
|
|
416
|
+
category: "assign-type-mismatch",
|
|
417
|
+
canvasId,
|
|
418
|
+
nodeId,
|
|
419
|
+
outputId,
|
|
420
|
+
message: `${out.label} entry #${index + 1} (${entry.dataType}) cannot assign to "${targetVar.name}" (${targetVar.dataType})`,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// --- Port connectivity diagnostics ---
|
|
428
|
+
const controlInputs = portDefinitions.input.filter((p) => p.type === "control");
|
|
429
|
+
const toolInputPorts = portDefinitions.input.filter((p) => p.type === "tool");
|
|
430
|
+
const controlOutputs = portDefinitions.output.filter((p) => p.type === "control");
|
|
431
|
+
const connectedTargetHandles = new Set(edges.filter((e) => e.target === nodeId).map((e) => e.targetHandle));
|
|
432
|
+
const connectedSourceHandles = new Set(edges.filter((e) => e.source === nodeId).map((e) => e.sourceHandle));
|
|
433
|
+
|
|
434
|
+
// Check unconnected control inputs (applies to ALL nodes that have them)
|
|
435
|
+
if (controlInputs.length > 0) {
|
|
436
|
+
if (usedAsToolInput) {
|
|
437
|
+
for (const port of toolInputPorts) {
|
|
438
|
+
if (!connectedTargetHandles.has(port.id)) {
|
|
439
|
+
diags.push({
|
|
440
|
+
severity: "warning",
|
|
441
|
+
category: "unconnected-input",
|
|
442
|
+
canvasId,
|
|
443
|
+
nodeId,
|
|
444
|
+
message: `Node is not connected and will never run`,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
} else {
|
|
449
|
+
for (const port of controlInputs) {
|
|
450
|
+
if (!connectedTargetHandles.has(port.id)) {
|
|
451
|
+
diags.push({
|
|
452
|
+
severity: "warning",
|
|
453
|
+
category: "unconnected-input",
|
|
454
|
+
canvasId,
|
|
455
|
+
nodeId,
|
|
456
|
+
message: `Node is not connected and will never run`,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
} else if (toolInputPorts.length > 0) {
|
|
462
|
+
// Tool-only input nodes (e.g. WebSearchTool)
|
|
463
|
+
const hasAnyConnection = toolInputPorts.some((p) => connectedTargetHandles.has(p.id));
|
|
464
|
+
if (!hasAnyConnection) {
|
|
465
|
+
diags.push({
|
|
466
|
+
severity: "warning",
|
|
467
|
+
category: "tool-not-connected",
|
|
468
|
+
canvasId,
|
|
469
|
+
nodeId,
|
|
470
|
+
message: `"${nodeDefinition.label}" is not connected to an agent`,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Check unconnected control outputs (triggers only — they are entry points that must lead somewhere)
|
|
476
|
+
if (controlOutputs.length > 0 && nodeDefinition.category === NodeCategory.Trigger) {
|
|
477
|
+
for (const port of controlOutputs) {
|
|
478
|
+
if (!connectedSourceHandles.has(port.id)) {
|
|
479
|
+
diags.push({
|
|
480
|
+
severity: "warning",
|
|
481
|
+
category: "unconnected-output",
|
|
482
|
+
canvasId,
|
|
483
|
+
nodeId,
|
|
484
|
+
message: `"${nodeDefinition.label}" has no outgoing connection — nothing will run after it`,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return diags;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Compute diagnostics for a single edge. Extracted from CustomEdge's useMemo.
|
|
495
|
+
*/
|
|
496
|
+
export function computeEdgeDiagnostics(opts: {
|
|
497
|
+
canvasId: string;
|
|
498
|
+
edgeId: string;
|
|
499
|
+
edgeType: EdgeType;
|
|
500
|
+
edgeData: EdgeData | undefined;
|
|
501
|
+
availableVariables: Record<string, Variable>;
|
|
502
|
+
sourceControlEdgeCount: number;
|
|
503
|
+
}): Diagnostic[] {
|
|
504
|
+
const { canvasId, edgeId, edgeType, edgeData, availableVariables, sourceControlEdgeCount } = opts;
|
|
505
|
+
const diags: Diagnostic[] = [];
|
|
506
|
+
const def = getEdgeDefinition(edgeType);
|
|
507
|
+
if (def.parameters.length === 0) return diags;
|
|
508
|
+
|
|
509
|
+
const isBranching = sourceControlEdgeCount > 1;
|
|
510
|
+
|
|
511
|
+
for (const param of def.parameters) {
|
|
512
|
+
// Description is optional on agent output edges when not branching
|
|
513
|
+
if (param.id === "description" && !isBranching && (edgeType === "agentChoice" || edgeType === "agentDelegate")) {
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const value = edgeData?.[param.id];
|
|
518
|
+
|
|
519
|
+
if (param.type === "expression") {
|
|
520
|
+
const exprValue = value as Expression | undefined;
|
|
521
|
+
if (!exprValue?.expression) {
|
|
522
|
+
diags.push({
|
|
523
|
+
severity: "error",
|
|
524
|
+
category: "missing-required-param",
|
|
525
|
+
canvasId,
|
|
526
|
+
edgeId,
|
|
527
|
+
paramId: param.id,
|
|
528
|
+
message: `Missing required parameter "${param.label}" on edge`,
|
|
529
|
+
});
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
if (isExpression(exprValue)) {
|
|
533
|
+
const expr = resolveExpression(exprValue, availableVariables);
|
|
534
|
+
const parseRes = parseExpression(expr);
|
|
535
|
+
if (!parseRes.isValid) {
|
|
536
|
+
diags.push({
|
|
537
|
+
severity: "error",
|
|
538
|
+
category: "invalid-expression",
|
|
539
|
+
canvasId,
|
|
540
|
+
edgeId,
|
|
541
|
+
paramId: param.id,
|
|
542
|
+
message: `Invalid expression for "${param.label}": ${parseRes.errors.join(", ")}`,
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
} else {
|
|
547
|
+
if (!value) {
|
|
548
|
+
diags.push({
|
|
549
|
+
severity: "error",
|
|
550
|
+
category: "missing-required-param",
|
|
551
|
+
canvasId,
|
|
552
|
+
edgeId,
|
|
553
|
+
paramId: param.id,
|
|
554
|
+
message: `Missing required parameter "${param.label}" on edge`,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return diags;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Compute diagnostics for a single channel. Mirrors the parameter loop
|
|
565
|
+
* from computeNodeDiagnostics: filter to active params (per the type
|
|
566
|
+
* discriminator), then required-check each. Empty label is also flagged so
|
|
567
|
+
* the user has a non-blank name in `channelSelect` dropdowns.
|
|
568
|
+
*/
|
|
569
|
+
export function validateChannel(channel: Channel): Diagnostic[] {
|
|
570
|
+
const diags: Diagnostic[] = [];
|
|
571
|
+
|
|
572
|
+
if (!channel.label || channel.label.trim() === "") {
|
|
573
|
+
diags.push({
|
|
574
|
+
severity: "error",
|
|
575
|
+
category: "missing-required-param",
|
|
576
|
+
channelId: channel.id,
|
|
577
|
+
message: `Channel has no label`,
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// `type` is mirrored into the args record so activation rules can read it.
|
|
582
|
+
const args: Record<string, unknown> = { ...channel.arguments, type: channel.type };
|
|
583
|
+
for (const param of CHANNEL_DEFINITION.parameters) {
|
|
584
|
+
if (param.id === "type") continue; // top-level discriminator, always set
|
|
585
|
+
if (!isParameterActive(param, args, false)) continue;
|
|
586
|
+
if (param.optional) continue;
|
|
587
|
+
|
|
588
|
+
const value = channel.arguments[param.id];
|
|
589
|
+
if (isEmpty(value)) {
|
|
590
|
+
diags.push({
|
|
591
|
+
severity: "error",
|
|
592
|
+
category: "missing-required-param",
|
|
593
|
+
channelId: channel.id,
|
|
594
|
+
paramId: param.id,
|
|
595
|
+
message: `Missing required parameter "${param.label}" on channel "${channel.label}"`,
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return diags;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Compute diagnostics for a single memory primitive. Mirrors validateChannel:
|
|
605
|
+
* an empty label is flagged (so memorySelect/memory-refs dropdowns have a
|
|
606
|
+
* non-blank name), then each required parameter for the memory's type is checked.
|
|
607
|
+
*/
|
|
608
|
+
export function validateMemory(mem: Memory): Diagnostic[] {
|
|
609
|
+
const diags: Diagnostic[] = [];
|
|
610
|
+
|
|
611
|
+
if (!mem.label || mem.label.trim() === "") {
|
|
612
|
+
diags.push({
|
|
613
|
+
severity: "error",
|
|
614
|
+
category: "missing-required-param",
|
|
615
|
+
memoryId: mem.id,
|
|
616
|
+
message: `Memory has no label`,
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const def = MemoryRegistry.getByType(mem.type);
|
|
621
|
+
for (const param of def?.parameters ?? []) {
|
|
622
|
+
if (!isParameterActive(param, mem.arguments, false)) continue;
|
|
623
|
+
if (param.optional) continue;
|
|
624
|
+
|
|
625
|
+
const value = mem.arguments[param.id];
|
|
626
|
+
if (isEmpty(value)) {
|
|
627
|
+
diags.push({
|
|
628
|
+
severity: "error",
|
|
629
|
+
category: "missing-required-param",
|
|
630
|
+
memoryId: mem.id,
|
|
631
|
+
paramId: param.id,
|
|
632
|
+
message: `Missing required parameter "${param.label}" on memory "${mem.label}"`,
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return diags;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Compute diagnostics for a single declared (custom) model. Mirrors
|
|
642
|
+
* validateMemory: an empty label is flagged, then each required parameter for
|
|
643
|
+
* the model's type is checked (LLMModel has none today, so this is label-only).
|
|
644
|
+
*/
|
|
645
|
+
export function validateModel(model: Model): Diagnostic[] {
|
|
646
|
+
const diags: Diagnostic[] = [];
|
|
647
|
+
|
|
648
|
+
if (!model.label || model.label.trim() === "") {
|
|
649
|
+
diags.push({
|
|
650
|
+
severity: "error",
|
|
651
|
+
category: "missing-required-param",
|
|
652
|
+
modelId: model.id,
|
|
653
|
+
message: `Model has no label`,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const def = ModelRegistry.getByType(model.type);
|
|
658
|
+
for (const param of def?.parameters ?? []) {
|
|
659
|
+
if (!isParameterActive(param, model.arguments, false)) continue;
|
|
660
|
+
if (param.optional) continue;
|
|
661
|
+
|
|
662
|
+
const value = model.arguments[param.id];
|
|
663
|
+
if (isEmpty(value)) {
|
|
664
|
+
diags.push({
|
|
665
|
+
severity: "error",
|
|
666
|
+
category: "missing-required-param",
|
|
667
|
+
modelId: model.id,
|
|
668
|
+
paramId: param.id,
|
|
669
|
+
message: `Missing required parameter "${param.label}" on model "${model.label}"`,
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
return diags;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Validate a function's bundled output assignments, keyed by `outputId` so a caller
|
|
679
|
+
* can ring the specific row. The single home for this logic — shared by the live
|
|
680
|
+
* config panel, the project-scoped {@link validateFunction}, and the full
|
|
681
|
+
* {@link validateWorkflowState}.
|
|
682
|
+
*
|
|
683
|
+
* Without `availableVariables` (no body scope) it can only flag a *missing*
|
|
684
|
+
* expression (the engine-invariant check). Given the body's variables it also
|
|
685
|
+
* resolves + parses each expression, catching invalid references and type mismatches
|
|
686
|
+
* — the same `resolveExpression`/`parseExpression` path node expression params use.
|
|
687
|
+
*/
|
|
688
|
+
export function validateFunctionOutputs(
|
|
689
|
+
outputs: readonly OutputAssignment[],
|
|
690
|
+
availableVariables?: Record<string, Variable>,
|
|
691
|
+
): Diagnostic[] {
|
|
692
|
+
const diags: Diagnostic[] = [];
|
|
693
|
+
for (const out of outputs) {
|
|
694
|
+
const text = out.expression?.expression;
|
|
695
|
+
if (!text || text.trim() === "") {
|
|
696
|
+
diags.push({
|
|
697
|
+
severity: "error",
|
|
698
|
+
category: "missing-output-assignment",
|
|
699
|
+
outputId: out.uid,
|
|
700
|
+
message: `Missing return value assignment for "${out.name}"`,
|
|
701
|
+
});
|
|
702
|
+
} else if (availableVariables) {
|
|
703
|
+
const parseRes = parseExpression(resolveExpression(out.expression, availableVariables));
|
|
704
|
+
if (!parseRes.isValid) {
|
|
705
|
+
diags.push({
|
|
706
|
+
severity: "error",
|
|
707
|
+
category: "invalid-expression",
|
|
708
|
+
outputId: out.uid,
|
|
709
|
+
message: `Invalid expression for "${out.name}": ${parseRes.errors.join(", ")}`,
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
return diags;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Compute diagnostics for a single function declaration (the project-scoped
|
|
719
|
+
* signature + bundled output assignments, independent of the body canvas). Flags an
|
|
720
|
+
* empty name and any output without an assigned expression. Expression *validity*
|
|
721
|
+
* (which needs the body's variable scope) is left to the scoped callers — the config
|
|
722
|
+
* panel and validateWorkflowState (keyed by the function's canvas).
|
|
723
|
+
*/
|
|
724
|
+
export function validateFunction(fn: FunctionDeclaration, availableVariables?: Record<string, Variable>): Diagnostic[] {
|
|
725
|
+
const diags: Diagnostic[] = [];
|
|
726
|
+
|
|
727
|
+
if (!fn.name || fn.name.trim() === "") {
|
|
728
|
+
diags.push({
|
|
729
|
+
severity: "error",
|
|
730
|
+
category: "missing-required-param",
|
|
731
|
+
functionId: fn.id,
|
|
732
|
+
message: `Function has no name`,
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Pass the body scope through when the caller has it (the sidebar sync supplies the
|
|
737
|
+
// function's canvas variables) so invalid/typed expressions surface too, not just
|
|
738
|
+
// missing ones. Tag with functionId for the sidebar list ring / tab badge.
|
|
739
|
+
for (const d of validateFunctionOutputs(fn.outputs, availableVariables)) diags.push({ ...d, functionId: fn.id });
|
|
740
|
+
|
|
741
|
+
return diags;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// ============================================================================
|
|
745
|
+
// Full-Project Validation Result Types
|
|
746
|
+
// ============================================================================
|
|
747
|
+
|
|
748
|
+
export interface CanvasValidationResult {
|
|
749
|
+
canvasId: string;
|
|
750
|
+
canvasLabel: string;
|
|
751
|
+
diagnostics: Diagnostic[];
|
|
752
|
+
errorCount: number;
|
|
753
|
+
warningCount: number;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
export interface ValidationResult {
|
|
757
|
+
canvases: CanvasValidationResult[];
|
|
758
|
+
/** Project-scoped channel diagnostics (no canvasId). */
|
|
759
|
+
channelDiagnostics: Diagnostic[];
|
|
760
|
+
/** Project-scoped memory diagnostics (no canvasId). */
|
|
761
|
+
memoryDiagnostics: Diagnostic[];
|
|
762
|
+
/** Project-scoped declared-model diagnostics (no canvasId). */
|
|
763
|
+
modelDiagnostics: Diagnostic[];
|
|
764
|
+
totalErrors: number;
|
|
765
|
+
totalWarnings: number;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Derive the function registry from a snapshot's canvases. Mirrors
|
|
770
|
+
* `computeAllFunctions()` in useFunctionRegistry exactly: every non-main
|
|
771
|
+
* canvas that carries a `functionInfo` is a function, keyed by canvas id
|
|
772
|
+
* (which equals the function id by invariant).
|
|
773
|
+
*
|
|
774
|
+
* Deriving from the snapshot rather than the module-level cache makes
|
|
775
|
+
* validation deterministic from its input alone — and removes the cache-lag
|
|
776
|
+
* window the store-bound path had.
|
|
777
|
+
*/
|
|
778
|
+
function deriveFunctionRegistry(functions: Record<string, FunctionDeclaration>): Record<string, FunctionInfo> {
|
|
779
|
+
const out: Record<string, FunctionInfo> = {};
|
|
780
|
+
for (const [id, decl] of Object.entries(functions)) out[id] = toFunctionInfo(decl);
|
|
781
|
+
return out;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Headless full-project validation. Pure: depends only on the passed
|
|
786
|
+
* {@link Workflow} (the in-memory domain shape) — no Zustand stores, no
|
|
787
|
+
* React, no DOM. Runnable in Node, a CLI, or a Claude Code skill.
|
|
788
|
+
*
|
|
789
|
+
* Two producers feed this: the editor reads its live stores into a
|
|
790
|
+
* `Workflow` literal; the CLI calls `deserialize(apiWorkflow)` from
|
|
791
|
+
* `../workflow/serialization` to convert the api JSON into this shape.
|
|
792
|
+
*/
|
|
793
|
+
export function validateWorkflowState(state: Workflow): ValidationResult {
|
|
794
|
+
const canvasData = state.canvases ?? {};
|
|
795
|
+
const functionDecls = state.functions ?? {};
|
|
796
|
+
const allFunctions = deriveFunctionRegistry(functionDecls);
|
|
797
|
+
const channels = state.channels ?? {};
|
|
798
|
+
const memory = state.memory ?? {};
|
|
799
|
+
const models = state.models ?? {};
|
|
800
|
+
|
|
801
|
+
const canvases: CanvasValidationResult[] = [];
|
|
802
|
+
let totalErrors = 0;
|
|
803
|
+
let totalWarnings = 0;
|
|
804
|
+
|
|
805
|
+
for (const [canvasId, canvas] of Object.entries(canvasData)) {
|
|
806
|
+
const { nodes, edges } = canvas;
|
|
807
|
+
|
|
808
|
+
// Each canvas is fully self-contained — function canvases do not see main-canvas variables.
|
|
809
|
+
const { lookup: availableVariables } = computeAvailableVariables(canvas.variables, edges);
|
|
810
|
+
|
|
811
|
+
const canvasDiags: Diagnostic[] = [];
|
|
812
|
+
|
|
813
|
+
// Compute node diagnostics
|
|
814
|
+
for (const node of nodes) {
|
|
815
|
+
const nodeData = node;
|
|
816
|
+
|
|
817
|
+
// Resolve node definition
|
|
818
|
+
let nodeDefinition: NodeDefinition | undefined;
|
|
819
|
+
let isStale = false;
|
|
820
|
+
let isDeleted = false;
|
|
821
|
+
|
|
822
|
+
if (nodeData.type === "FunctionCall") {
|
|
823
|
+
const fnNode = nodeData as FunctionCallNode;
|
|
824
|
+
const registryFn = allFunctions[fnNode.functionInfo.id];
|
|
825
|
+
isDeleted = !registryFn;
|
|
826
|
+
isStale = registryFn ? fnNode.functionInfo.version !== registryFn.version : false;
|
|
827
|
+
if (registryFn) {
|
|
828
|
+
nodeDefinition = buildFunctionNodeDef(registryFn) as NodeDefinition;
|
|
829
|
+
} else {
|
|
830
|
+
nodeDefinition = buildFunctionNodeDef(fnNode.functionInfo) as NodeDefinition;
|
|
831
|
+
}
|
|
832
|
+
} else {
|
|
833
|
+
nodeDefinition = NodeRegistry.getByType(nodeData.type);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const diags = computeNodeDiagnostics({
|
|
837
|
+
canvasId,
|
|
838
|
+
nodeId: node.id,
|
|
839
|
+
nodeData,
|
|
840
|
+
nodeDefinition,
|
|
841
|
+
availableVariables,
|
|
842
|
+
channels,
|
|
843
|
+
memory,
|
|
844
|
+
models,
|
|
845
|
+
edges,
|
|
846
|
+
isStale,
|
|
847
|
+
isDeleted,
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
canvasDiags.push(...diags);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Compute edge diagnostics
|
|
854
|
+
const sourceControlCounts = new Map<string, number>();
|
|
855
|
+
for (const edge of edges) {
|
|
856
|
+
if (isControlFlow(edge.type as EdgeType)) {
|
|
857
|
+
sourceControlCounts.set(edge.source, (sourceControlCounts.get(edge.source) ?? 0) + 1);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
for (const edge of edges) {
|
|
862
|
+
const edgeType = (edge.type ?? "control") as EdgeType;
|
|
863
|
+
const diags = computeEdgeDiagnostics({
|
|
864
|
+
canvasId,
|
|
865
|
+
edgeId: edge.id,
|
|
866
|
+
edgeType,
|
|
867
|
+
edgeData: edge.data,
|
|
868
|
+
availableVariables,
|
|
869
|
+
sourceControlEdgeCount: sourceControlCounts.get(edge.source) ?? 0,
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
canvasDiags.push(...diags);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// Output assignment diagnostics: the function's declaration (functions[canvasId])
|
|
876
|
+
// owns the bundled outputs; their expressions resolve against this body's scope.
|
|
877
|
+
const fnDecl = functionDecls[canvasId];
|
|
878
|
+
if (fnDecl) {
|
|
879
|
+
for (const d of validateFunctionOutputs(fnDecl.outputs, availableVariables)) {
|
|
880
|
+
canvasDiags.push({ ...d, canvasId });
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Only include canvases with issues
|
|
885
|
+
if (canvasDiags.length > 0) {
|
|
886
|
+
const errorCount = canvasDiags.filter((d) => d.severity === "error").length;
|
|
887
|
+
const warningCount = canvasDiags.filter((d) => d.severity === "warning").length;
|
|
888
|
+
|
|
889
|
+
// Derive canvas label
|
|
890
|
+
const canvasLabel = canvasId === MAIN_CANVAS_ID ? "Main" : (functionDecls[canvasId]?.name ?? canvasId);
|
|
891
|
+
|
|
892
|
+
canvases.push({
|
|
893
|
+
canvasId,
|
|
894
|
+
canvasLabel,
|
|
895
|
+
diagnostics: canvasDiags,
|
|
896
|
+
errorCount,
|
|
897
|
+
warningCount,
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
totalErrors += errorCount;
|
|
901
|
+
totalWarnings += warningCount;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Project-scoped channel diagnostics — independent of canvas iteration.
|
|
906
|
+
const channelDiagnostics: Diagnostic[] = [];
|
|
907
|
+
for (const channel of Object.values(channels)) {
|
|
908
|
+
channelDiagnostics.push(...validateChannel(channel));
|
|
909
|
+
}
|
|
910
|
+
for (const d of channelDiagnostics) {
|
|
911
|
+
if (d.severity === "error") totalErrors++;
|
|
912
|
+
else totalWarnings++;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Project-scoped memory diagnostics — independent of canvas iteration.
|
|
916
|
+
const memoryDiagnostics: Diagnostic[] = [];
|
|
917
|
+
for (const mem of Object.values(memory)) {
|
|
918
|
+
memoryDiagnostics.push(...validateMemory(mem));
|
|
919
|
+
}
|
|
920
|
+
for (const d of memoryDiagnostics) {
|
|
921
|
+
if (d.severity === "error") totalErrors++;
|
|
922
|
+
else totalWarnings++;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// Project-scoped declared-model diagnostics — independent of canvas iteration.
|
|
926
|
+
const modelDiagnostics: Diagnostic[] = [];
|
|
927
|
+
for (const model of Object.values(models)) {
|
|
928
|
+
modelDiagnostics.push(...validateModel(model));
|
|
929
|
+
}
|
|
930
|
+
for (const d of modelDiagnostics) {
|
|
931
|
+
if (d.severity === "error") totalErrors++;
|
|
932
|
+
else totalWarnings++;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
return { canvases, channelDiagnostics, memoryDiagnostics, modelDiagnostics, totalErrors, totalWarnings };
|
|
936
|
+
}
|