import { buildAdjacencyList, parseExtractableSubgraphSelection, type ExtractableErrorResult, type ExtractableSubgraphData, type IConnectionAdjacencyList, } from './graph/graph-utils'; import { NodeConnectionTypes, type IConnections, type INode, type INodeInputConfiguration, type INodeOutputConfiguration, type INodeTypeDescription, type IWorkflowGroup, type NodeConnectionType, } from './interfaces'; import { isTriggerNode } from './node-helpers'; type NodeIo = NodeConnectionType | INodeInputConfiguration | INodeOutputConfiguration; type IODirection = 'inputs' | 'outputs'; export type NodeGroupingValidationInput = { nodes: TNode[]; connectionsBySourceNode: IConnections; getNodeType: (node: TNode) => INodeTypeDescription | null | undefined; existingNodeGroups?: IWorkflowGroup[]; getNodeInputs?: ( node: TNode, nodeType: INodeTypeDescription, ) => Array; getNodeOutputs?: ( node: TNode, nodeType: INodeTypeDescription, ) => Array; }; export type NodeSelectionValidationResult = | { valid: true; subGraph: TNode[]; subGraphData: ExtractableSubgraphData } | { valid: false; reason: 'trigger-selected'; triggers: string[] } | { valid: false; reason: 'invalid-subgraph'; errors: ExtractableErrorResult[] } | { valid: false; reason: 'multiple-input-branches'; node: string } | { valid: false; reason: 'multiple-output-branches'; node: string }; export type NodeGroupValidationResult = | NodeSelectionValidationResult | { valid: false; reason: 'too-few-nodes' } | { valid: false; reason: 'node-already-grouped'; nodeIds: string[] } | { valid: false; reason: 'non-main-boundary'; connection: { source: string; target: string; type: string }; }; export function validateNodeSelectionForExtraction({ nodes, connectionsBySourceNode, getNodeType, getNodeInputs, getNodeOutputs, }: NodeGroupingValidationInput): NodeSelectionValidationResult { const triggers = nodes.filter((node) => { const nodeType = getNodeType(node); return nodeType ? isTriggerNode(nodeType) : false; }); if (triggers.length > 0) { return { valid: false, reason: 'trigger-selected', triggers: triggers.map((node) => node.name), }; } const adjacencyList = buildAdjacencyList(connectionsBySourceNode); const selectedNodeNames = new Set(nodes.map((node) => node.name)); const selection = parseExtractableSubgraphSelection(selectedNodeNames, adjacencyList); if (Array.isArray(selection)) { return { valid: false, reason: 'invalid-subgraph', errors: selection }; } const disconnectedSelectionError = findDisconnectedSelectionError( selectedNodeNames, adjacencyList, ); if (disconnectedSelectionError) { return { valid: false, reason: 'invalid-subgraph', errors: [disconnectedSelectionError] }; } const nodesByName = new Map(nodes.map((node) => [node.name, node])); const { start, end } = selection; if ( start && !hasSingleMainIO(start, 'inputs', nodesByName, getNodeType, getNodeInputs, getNodeOutputs) ) { return { valid: false, reason: 'multiple-input-branches', node: start }; } if ( end && !hasSingleMainIO(end, 'outputs', nodesByName, getNodeType, getNodeInputs, getNodeOutputs) ) { return { valid: false, reason: 'multiple-output-branches', node: end }; } return { valid: true, subGraph: nodes, subGraphData: selection }; } export function validateNodeSelectionForGrouping( input: NodeGroupingValidationInput, ): NodeGroupValidationResult { if (input.nodes.length < 2) { return { valid: false, reason: 'too-few-nodes' }; } const alreadyGroupedNodeIds = findAlreadyGroupedNodeIds( input.nodes.map((node) => node.id), input.existingNodeGroups ?? [], ); if (alreadyGroupedNodeIds.length > 0) { return { valid: false, reason: 'node-already-grouped', nodeIds: alreadyGroupedNodeIds }; } const extractableResult = validateNodeSelectionForExtraction(input); if (!extractableResult.valid) return extractableResult; const nodeNames = new Set(extractableResult.subGraph.map((node) => node.name)); const boundaryConnection = findNonMainBoundaryConnection( nodeNames, input.connectionsBySourceNode, ); if (boundaryConnection) { return { valid: false, reason: 'non-main-boundary', connection: boundaryConnection }; } return extractableResult; } function findAlreadyGroupedNodeIds( selectionNodeIds: string[], existingNodeGroups: IWorkflowGroup[], ): string[] { const groupedNodeIds = new Set(existingNodeGroups.flatMap((group) => group.nodeIds)); return selectionNodeIds.filter((nodeId) => groupedNodeIds.has(nodeId)); } function hasSingleMainIO( nodeName: string, direction: IODirection, nodesByName: Map, getNodeType: (node: TNode) => INodeTypeDescription | null | undefined, getNodeInputs?: ( node: TNode, nodeType: INodeTypeDescription, ) => Array, getNodeOutputs?: ( node: TNode, nodeType: INodeTypeDescription, ) => Array, ): boolean { const node = nodesByName.get(nodeName); if (!node) return true; const nodeType = getNodeType(node); if (!nodeType) return true; const ios = direction === 'inputs' ? (getNodeInputs?.(node, nodeType) ?? nodeType.inputs) : (getNodeOutputs?.(node, nodeType) ?? nodeType.outputs); if (!Array.isArray(ios)) return true; return ios.filter(isMainIo).length <= 1; } function isMainIo(io: NodeIo): boolean { return typeof io === 'string' ? io === NodeConnectionTypes.Main : io.type === NodeConnectionTypes.Main; } function findDisconnectedSelectionError( nodeNames: Set, currentAdjacencyList: IConnectionAdjacencyList, ): ExtractableErrorResult | null { if (nodeNames.size <= 1) return null; const neighborsByNodeName = new Map>(); for (const nodeName of nodeNames) { neighborsByNodeName.set(nodeName, new Set()); } for (const [sourceNodeName, connections] of currentAdjacencyList.entries()) { if (!nodeNames.has(sourceNodeName)) continue; const sourceNeighbors = neighborsByNodeName.get(sourceNodeName); if (!sourceNeighbors) continue; for (const connection of connections) { const targetNodeName = connection.node; if (!nodeNames.has(targetNodeName) || targetNodeName === sourceNodeName) continue; sourceNeighbors.add(targetNodeName); neighborsByNodeName.get(targetNodeName)?.add(sourceNodeName); } } const firstNodeName = nodeNames.values().next().value; if (!firstNodeName) return null; const visited = new Set([firstNodeName]); const queue = [firstNodeName]; while (queue.length > 0) { const currentNodeName = queue.shift(); if (!currentNodeName) continue; for (const neighborNodeName of neighborsByNodeName.get(currentNodeName) ?? []) { if (visited.has(neighborNodeName)) continue; visited.add(neighborNodeName); queue.push(neighborNodeName); } } if (visited.size === nodeNames.size) return null; const disconnectedNodeName = Array.from(nodeNames).find((nodeName) => !visited.has(nodeName)); if (!disconnectedNodeName) return null; return { errorCode: 'No Continuous Path From Root To Leaf In Selection', start: firstNodeName, end: disconnectedNodeName, }; } function findNonMainBoundaryConnection( nodeNames: Set, connectionsBySourceNode: IConnections, ): { source: string; target: string; type: string } | null { for (const [sourceNodeName, sourceConnections] of Object.entries(connectionsBySourceNode)) { const sourceInside = nodeNames.has(sourceNodeName); for (const [type, connectionsByOutputIndex] of Object.entries(sourceConnections)) { if (type === NodeConnectionTypes.Main) continue; for (const connections of connectionsByOutputIndex) { for (const connection of connections ?? []) { if (sourceInside === nodeNames.has(connection.node)) continue; return { source: sourceNodeName, target: connection.node, type }; } } } } return null; }