const VALID_NODE_TYPES = new Set([ 'custom-model', 'for-each', 'generate-prompt', 'list', 'logic', 'model', 'remove-background', 'transform', 'user-approval', 'workflow', ]); type RawRef = { node?: unknown; conditional?: unknown; }; type RawInput = { name?: unknown; ref?: RawRef; items?: unknown[][]; }; type RawNode = { id?: unknown; type?: unknown; modelId?: unknown; workflowId?: unknown; logicType?: unknown; loopBodyNodeIds?: unknown; count?: unknown; dependsOn?: unknown; inputs?: unknown; }; /** * Validates the structural integrity of a workflow flow (array of nodes). * * Throws an `Error` with a human-readable message on the first violation found. * * Checks performed: * 1. Every node has a non-empty string `id` * 2. Node IDs are unique within the flow * 3. Every node `type` is a recognized workflow node type * 4. Per-type required fields: * - custom-model → modelId * - workflow → workflowId * - logic → logicType * - for-each → loopBodyNodeIds (non-empty) OR count (>0) * 5. Cross-references all point to node IDs that exist in the flow: * - dependsOn entries * - input ref.node (excluding "workflow" which is a reserved keyword) * - input ref.conditional entries * - loopBodyNodeIds entries */ export function validateWorkflowFlow(flow: unknown[]): void { const nodeIds = new Set(); const firstSeenAt = new Map(); // Pass 1 — per-node checks (ID, type, required fields) for (let i = 0; i < flow.length; i++) { const node = flow[i] as RawNode; if (typeof node.id !== 'string' || !node.id) { throw new Error(`Node at index ${i} is missing an "id" field`); } if (nodeIds.has(node.id)) { throw new Error( `Duplicate node id "${node.id}" at index ${i} (first seen at index ${firstSeenAt.get(node.id)})`, ); } nodeIds.add(node.id); firstSeenAt.set(node.id, i); const label = `"${node.id}"`; if (typeof node.type !== 'string' || !VALID_NODE_TYPES.has(node.type)) { throw new Error( `Node ${label} has invalid type "${node.type}". Valid types are: ${[...VALID_NODE_TYPES].join(', ')}`, ); } const type = node.type; if (type === 'custom-model' && !node.modelId) { throw new Error(`Node ${label} (custom-model) is missing required field "modelId"`); } if (type === 'workflow' && !node.workflowId) { throw new Error(`Node ${label} (workflow) is missing required field "workflowId"`); } if (type === 'logic' && !node.logicType) { throw new Error(`Node ${label} (logic) is missing required field "logicType"`); } if (type === 'for-each') { const hasLoopBody = Array.isArray(node.loopBodyNodeIds) && node.loopBodyNodeIds.length > 0; const hasCount = typeof node.count === 'number' && node.count > 0; if (!hasLoopBody && !hasCount) { throw new Error( `Node ${label} (for-each) requires either "loopBodyNodeIds" (non-empty array) or "count" (positive number)`, ); } } } // Pass 2 — cross-reference checks (all node IDs are now collected) for (const rawNode of flow) { const node = rawNode as RawNode; const nodeId = node.id as string; if (Array.isArray(node.dependsOn)) { for (const depId of node.dependsOn) { if (typeof depId === 'string' && !nodeIds.has(depId)) { throw new Error(`Node "${nodeId}" dependsOn unknown node "${depId}"`); } } } if (Array.isArray(node.inputs)) { for (const input of node.inputs) { validateInputRefs(nodeId, input as RawInput, nodeIds); } } if (Array.isArray(node.loopBodyNodeIds)) { for (const bodyId of node.loopBodyNodeIds as unknown[]) { if (typeof bodyId === 'string' && !nodeIds.has(bodyId)) { throw new Error(`Node "${nodeId}" loopBodyNodeIds references unknown node "${bodyId}"`); } } } } } function validateInputRefs(nodeId: string, input: RawInput, nodeIds: Set): void { const inputName = typeof input.name === 'string' && input.name ? input.name : '(unnamed)'; if (input.ref) { const { node: refNode, conditional } = input.ref; // "workflow" is a reserved keyword meaning "take from the workflow's own inputs" if (typeof refNode === 'string' && refNode !== 'workflow' && !nodeIds.has(refNode)) { throw new Error(`Node "${nodeId}" input "${inputName}" references unknown node "${refNode}"`); } if (Array.isArray(conditional)) { for (const condId of conditional) { if (typeof condId === 'string' && !nodeIds.has(condId)) { throw new Error( `Node "${nodeId}" input "${inputName}" has a conditional ref pointing to unknown node "${condId}"`, ); } } } } // Recurse into INPUTS_ARRAY sub-inputs if (Array.isArray(input.items)) { for (const item of input.items) { if (Array.isArray(item)) { for (const subInput of item) { validateInputRefs(nodeId, subInput as RawInput, nodeIds); } } } } }