Skip to content

Commit 7a0845a

Browse files
authored
Merge pull request #5352 from nodetool-ai/refactor/simplify-derive-workflow-interface
2 parents 92d3974 + 912c1d1 commit 7a0845a

2 files changed

Lines changed: 361 additions & 79 deletions

File tree

packages/node-sdk/src/workflow-interface.ts

Lines changed: 129 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -252,25 +252,72 @@ function outputTypeForNode(
252252
: null;
253253
}
254254

255-
export function deriveWorkflowInterfaceV1(args: {
256-
workflowId: string;
257-
etag?: string | null;
258-
graph: WorkflowInterfaceGraph;
259-
registry: WorkflowInterfaceRegistry;
260-
}): WorkflowInterfaceV1 {
261-
const diagnostics: WorkflowInterfaceDiagnostic[] = [];
262-
const inputs: WorkflowInterfaceInput[] = [];
263-
const outputs: WorkflowInterfaceOutput[] = [];
264-
const nodes = Array.isArray(args.graph.nodes) ? args.graph.nodes : [];
265-
const edges = Array.isArray(args.graph.edges) ? args.graph.edges : [];
266-
const nodesById = new Map<string, WorkflowInterfaceGraphNode>();
267-
const inputNames = new Set<string>();
268-
const outputNames = new Set<string>();
255+
/**
256+
* The type behind a generic `Output` node: what its single incoming `value`
257+
* edge carries. Every failure path pushes a diagnostic before returning null.
258+
*/
259+
function resolveOutputSourceType(
260+
edge: WorkflowInterfaceGraphEdge,
261+
nodesById: ReadonlyMap<string, WorkflowInterfaceGraphNode>,
262+
registry: WorkflowInterfaceRegistry,
263+
outputNodeId: string,
264+
outputName: string,
265+
diagnostics: WorkflowInterfaceDiagnostic[]
266+
): { type: TypeMetadata; stream: boolean } | null {
267+
const source = isString(edge.source)
268+
? nodesById.get(edge.source)
269+
: undefined;
270+
if (!source) {
271+
diagnostics.push({
272+
severity: "error",
273+
code: "missing_source_node",
274+
message: `Workflow output '${outputName}' references a source node that is not present in the graph.`,
275+
node_id: outputNodeId,
276+
pin_name: outputName
277+
});
278+
return null;
279+
}
269280

270-
for (const node of nodes) {
271-
if (isString(node.id)) nodesById.set(node.id, node);
281+
const handle = String(edge.sourceHandle ?? edge.source_handle ?? "");
282+
if (!handle) {
283+
diagnostics.push({
284+
severity: "error",
285+
code: "missing_source_handle",
286+
message: `Workflow output '${outputName}' has an incoming edge without a source handle.`,
287+
node_id: outputNodeId,
288+
pin_name: outputName
289+
});
290+
return null;
272291
}
273292

293+
const resolved = outputTypeForNode(source, handle, registry);
294+
if (resolved) return resolved;
295+
296+
const sourceType = isString(source.type) ? source.type : "";
297+
const hasDynamicHandle =
298+
record(record(source.dynamic_outputs)?.[handle]) !== null;
299+
const sourceMetadata = registry.resolveMetadata(sourceType);
300+
const packUnavailable = !hasDynamicHandle && !sourceMetadata;
301+
diagnostics.push({
302+
severity: "error",
303+
code: packUnavailable ? "missing_node_metadata" : "missing_output_handle",
304+
message: packUnavailable
305+
? `Node metadata for workflow output source '${sourceType}' is unavailable. The node pack may be disabled or not loaded.`
306+
: `Source handle '${handle}' is not declared by node '${sourceType}'.`,
307+
node_id: isString(source.id) ? source.id : outputNodeId,
308+
pin_name: handle
309+
});
310+
return null;
311+
}
312+
313+
function collectInputs(
314+
nodes: readonly WorkflowInterfaceGraphNode[],
315+
registry: WorkflowInterfaceRegistry,
316+
diagnostics: WorkflowInterfaceDiagnostic[]
317+
): WorkflowInterfaceInput[] {
318+
const inputs: WorkflowInterfaceInput[] = [];
319+
const seenNames = new Set<string>();
320+
274321
for (const node of nodes) {
275322
const nodeId = isString(node.id) ? node.id : "";
276323
const nodeType = isString(node.type) ? node.type : "";
@@ -286,7 +333,7 @@ export function deriveWorkflowInterfaceV1(args: {
286333
});
287334
continue;
288335
}
289-
if (inputNames.has(name)) {
336+
if (seenNames.has(name)) {
290337
diagnostics.push({
291338
severity: "error",
292339
code: "duplicate_input_name",
@@ -296,9 +343,9 @@ export function deriveWorkflowInterfaceV1(args: {
296343
});
297344
continue;
298345
}
299-
inputNames.add(name);
346+
seenNames.add(name);
300347

301-
const resolved = outputTypeForNode(node, "output", args.registry);
348+
const resolved = outputTypeForNode(node, "output", registry);
302349
if (!resolved) {
303350
diagnostics.push({
304351
severity: "error",
@@ -334,6 +381,19 @@ export function deriveWorkflowInterfaceV1(args: {
334381
inputs.push(input);
335382
}
336383

384+
return inputs;
385+
}
386+
387+
function collectOutputs(
388+
nodes: readonly WorkflowInterfaceGraphNode[],
389+
edges: readonly WorkflowInterfaceGraphEdge[],
390+
nodesById: ReadonlyMap<string, WorkflowInterfaceGraphNode>,
391+
registry: WorkflowInterfaceRegistry,
392+
diagnostics: WorkflowInterfaceDiagnostic[]
393+
): WorkflowInterfaceOutput[] {
394+
const outputs: WorkflowInterfaceOutput[] = [];
395+
const seenNames = new Set<string>();
396+
337397
for (const node of nodes) {
338398
const nodeId = isString(node.id) ? node.id : "";
339399
const nodeType = isString(node.type) ? node.type : "";
@@ -350,7 +410,7 @@ export function deriveWorkflowInterfaceV1(args: {
350410
});
351411
continue;
352412
}
353-
if (outputNames.has(name)) {
413+
if (seenNames.has(name)) {
354414
diagnostics.push({
355415
severity: "error",
356416
code: "duplicate_output_name",
@@ -360,66 +420,30 @@ export function deriveWorkflowInterfaceV1(args: {
360420
});
361421
continue;
362422
}
363-
outputNames.add(name);
364-
365-
let resolved: { type: TypeMetadata; stream: boolean } | null = dedicatedType
423+
seenNames.add(name);
424+
425+
// A dedicated media output carries its own type, so its edges are never
426+
// inspected — and a bad one is never reported.
427+
const incoming = dedicatedType
428+
? []
429+
: edges.filter(
430+
(edge) =>
431+
edge.target === nodeId &&
432+
String(edge.targetHandle ?? edge.target_handle ?? "") === "value"
433+
);
434+
const resolved = dedicatedType
366435
? { type: dedicatedType, stream: false }
367-
: null;
368-
let incoming: WorkflowInterfaceGraphEdge[] = [];
369-
if (!dedicatedType) {
370-
incoming = edges.filter(
371-
(edge) =>
372-
edge.target === nodeId &&
373-
String(edge.targetHandle ?? edge.target_handle ?? "") === "value"
374-
);
375-
if (incoming.length === 1) {
376-
const edge = incoming[0]!;
377-
const source =
378-
isString(edge.source)
379-
? nodesById.get(edge.source)
380-
: undefined;
381-
const handle = String(edge.sourceHandle ?? edge.source_handle ?? "");
382-
if (!source) {
383-
diagnostics.push({
384-
severity: "error",
385-
code: "missing_source_node",
386-
message: `Workflow output '${name}' references a source node that is not present in the graph.`,
387-
node_id: nodeId,
388-
pin_name: name
389-
});
390-
} else if (!handle) {
391-
diagnostics.push({
392-
severity: "error",
393-
code: "missing_source_handle",
394-
message: `Workflow output '${name}' has an incoming edge without a source handle.`,
395-
node_id: nodeId,
396-
pin_name: name
397-
});
398-
} else {
399-
resolved = outputTypeForNode(source, handle, args.registry);
400-
if (!resolved) {
401-
const dynamic = record(source.dynamic_outputs);
402-
const hasDynamicHandle = record(dynamic?.[handle]) !== null;
403-
const sourceType =
404-
isString(source.type) ? source.type : "";
405-
const sourceMetadata = args.registry.resolveMetadata(sourceType);
406-
diagnostics.push({
407-
severity: "error",
408-
code:
409-
!hasDynamicHandle && !sourceMetadata
410-
? "missing_node_metadata"
411-
: "missing_output_handle",
412-
message:
413-
!hasDynamicHandle && !sourceMetadata
414-
? `Node metadata for workflow output source '${sourceType}' is unavailable. The node pack may be disabled or not loaded.`
415-
: `Source handle '${handle}' is not declared by node '${sourceType}'.`,
416-
node_id: isString(source.id) ? source.id : nodeId,
417-
pin_name: handle
418-
});
419-
}
420-
}
421-
}
422-
}
436+
: incoming.length === 1
437+
? resolveOutputSourceType(
438+
incoming[0]!,
439+
nodesById,
440+
registry,
441+
nodeId,
442+
name,
443+
diagnostics
444+
)
445+
: null;
446+
423447
if (!resolved) {
424448
diagnostics.push({
425449
severity: "error",
@@ -441,6 +465,32 @@ export function deriveWorkflowInterfaceV1(args: {
441465
});
442466
}
443467

468+
return outputs;
469+
}
470+
471+
export function deriveWorkflowInterfaceV1(args: {
472+
workflowId: string;
473+
etag?: string | null;
474+
graph: WorkflowInterfaceGraph;
475+
registry: WorkflowInterfaceRegistry;
476+
}): WorkflowInterfaceV1 {
477+
const diagnostics: WorkflowInterfaceDiagnostic[] = [];
478+
const nodes = Array.isArray(args.graph.nodes) ? args.graph.nodes : [];
479+
const edges = Array.isArray(args.graph.edges) ? args.graph.edges : [];
480+
const nodesById = new Map<string, WorkflowInterfaceGraphNode>();
481+
for (const node of nodes) {
482+
if (isString(node.id)) nodesById.set(node.id, node);
483+
}
484+
485+
const inputs = collectInputs(nodes, args.registry, diagnostics);
486+
const outputs = collectOutputs(
487+
nodes,
488+
edges,
489+
nodesById,
490+
args.registry,
491+
diagnostics
492+
);
493+
444494
return {
445495
version: 1,
446496
workflow_id: args.workflowId,

0 commit comments

Comments
 (0)