package routing import ( "fmt" "strings" "github.com/rockliang/poimen/workflows/pkg/db" ) // CanvasValidator validates React Flow canvas (nodes + edges) type CanvasValidator struct { activityRegistry map[string]bool } // NewCanvasValidator creates validator with activity registry func NewCanvasValidator() *CanvasValidator { return &CanvasValidator{ activityRegistry: map[string]bool{ "clone-repo": true, "analyze-code": true, "security-scan": true, "generate-report": true, "deployment-precheck": true, "notify-status": true, "approve-workflow": true, "archive-results": true, "retrieve-memory": true, "assume-role": true, "llm-inference": true, "llm-batch-inference": true, }, } } // ValidateCanvas checks canvas structure, connectivity, and DAG func (cv *CanvasValidator) ValidateCanvas(canvas *db.Canvas) error { if canvas == nil { return fmt.Errorf("canvas is nil") } if len(canvas.Nodes) == 0 { return fmt.Errorf("canvas has no nodes") } // Step 1: Validate nodes if err := cv.validateNodes(canvas.Nodes); err != nil { return fmt.Errorf("node validation failed: %w", err) } // Step 2: Validate edges if err := cv.validateEdges(canvas.Nodes, canvas.Edges); err != nil { return fmt.Errorf("edge validation failed: %w", err) } // Step 3: Check for cycles (must be DAG) if err := cv.detectCycles(canvas.Nodes, canvas.Edges); err != nil { return fmt.Errorf("cycle detected: %w", err) } // Step 4: Check connectivity (all nodes reachable from start) if err := cv.validateConnectivity(canvas.Nodes, canvas.Edges); err != nil { return fmt.Errorf("connectivity check failed: %w", err) } return nil } // validateNodes checks each node has required fields and valid type func (cv *CanvasValidator) validateNodes(nodes []db.WorkflowNode) error { if len(nodes) == 0 { return fmt.Errorf("no nodes in canvas") } nodeIds := make(map[string]bool) for i, node := range nodes { // Check required fields if node.ID == "" { return fmt.Errorf("node[%d] has empty ID", i) } if nodeIds[node.ID] { return fmt.Errorf("node[%d] has duplicate ID: %s", i, node.ID) } nodeIds[node.ID] = true if node.Label == "" { return fmt.Errorf("node[%d] (%s) has empty label", i, node.ID) } if node.Position == nil { return fmt.Errorf("node[%d] (%s) has no position", i, node.ID) } // Check activity type (if present) if node.Type != "" && !cv.activityRegistry[strings.ToLower(node.Type)] { return fmt.Errorf("node[%d] (%s) has unknown activity type: %s", i, node.ID, node.Type) } // Check data structure if node.Data == nil { return fmt.Errorf("node[%d] (%s) has no data", i, node.ID) } } return nil } // validateEdges checks edges reference valid nodes func (cv *CanvasValidator) validateEdges(nodes []db.WorkflowNode, edges []db.WorkflowEdge) error { nodeIds := make(map[string]bool) for _, node := range nodes { nodeIds[node.ID] = true } for i, edge := range edges { // Check required fields if edge.Source == "" { return fmt.Errorf("edge[%d] has empty source", i) } if edge.Target == "" { return fmt.Errorf("edge[%d] has empty target", i) } // Check source node exists if !nodeIds[edge.Source] { return fmt.Errorf("edge[%d] references unknown source node: %s", i, edge.Source) } // Check target node exists if !nodeIds[edge.Target] { return fmt.Errorf("edge[%d] references unknown target node: %s", i, edge.Target) } // Check self-loops (discouraged but allow for now) if edge.Source == edge.Target { // Could warn here but not fail } } return nil } // detectCycles checks for cycles in the DAG (must be acyclic) func (cv *CanvasValidator) detectCycles(nodes []db.WorkflowNode, edges []db.WorkflowEdge) error { // Build adjacency list graph := make(map[string][]string) inDegree := make(map[string]int) for _, node := range nodes { graph[node.ID] = []string{} inDegree[node.ID] = 0 } for _, edge := range edges { graph[edge.Source] = append(graph[edge.Source], edge.Target) inDegree[edge.Target]++ } // Kahn's algorithm: topological sort queue := []string{} for _, node := range nodes { if inDegree[node.ID] == 0 { queue = append(queue, node.ID) } } processed := 0 for len(queue) > 0 { // Dequeue current := queue[0] queue = queue[1:] processed++ // Visit neighbors for _, neighbor := range graph[current] { inDegree[neighbor]-- if inDegree[neighbor] == 0 { queue = append(queue, neighbor) } } } // If we didn't process all nodes, there's a cycle if processed != len(nodes) { return fmt.Errorf("graph has cycle (processed %d/%d nodes)", processed, len(nodes)) } return nil } // validateConnectivity checks all nodes are reachable from start nodes func (cv *CanvasValidator) validateConnectivity(nodes []db.WorkflowNode, edges []db.WorkflowEdge) error { if len(nodes) == 0 { return nil } // Build adjacency list graph := make(map[string][]string) inDegree := make(map[string]int) for _, node := range nodes { graph[node.ID] = []string{} inDegree[node.ID] = 0 } for _, edge := range edges { graph[edge.Source] = append(graph[edge.Source], edge.Target) inDegree[edge.Target]++ } // Find start nodes (in-degree 0) startNodes := []string{} for _, node := range nodes { if inDegree[node.ID] == 0 { startNodes = append(startNodes, node.ID) } } if len(startNodes) == 0 { return fmt.Errorf("no start nodes found (all nodes have incoming edges)") } // BFS from all start nodes visited := make(map[string]bool) queue := startNodes for len(queue) > 0 { // Dequeue current := queue[0] queue = queue[1:] if visited[current] { continue } visited[current] = true // Visit neighbors for _, neighbor := range graph[current] { if !visited[neighbor] { queue = append(queue, neighbor) } } } // Check all nodes were visited if len(visited) != len(nodes) { unreached := []string{} for _, node := range nodes { if !visited[node.ID] { unreached = append(unreached, node.ID) } } return fmt.Errorf("unreachable nodes: %v", unreached) } return nil } // TopoSort returns nodes in topological order (execution order) func (cv *CanvasValidator) TopoSort(nodes []db.WorkflowNode, edges []db.WorkflowEdge) ([]db.WorkflowNode, error) { if len(nodes) == 0 { return []db.WorkflowNode{}, nil } // Build adjacency list and in-degree map graph := make(map[string][]string) inDegree := make(map[string]int) nodeMap := make(map[string]db.WorkflowNode) for _, node := range nodes { graph[node.ID] = []string{} inDegree[node.ID] = 0 nodeMap[node.ID] = node } for _, edge := range edges { graph[edge.Source] = append(graph[edge.Source], edge.Target) inDegree[edge.Target]++ } // Kahn's algorithm queue := []string{} for _, node := range nodes { if inDegree[node.ID] == 0 { queue = append(queue, node.ID) } } result := []db.WorkflowNode{} processed := make(map[string]bool) for len(queue) > 0 { // Dequeue current := queue[0] queue = queue[1:] result = append(result, nodeMap[current]) processed[current] = true // Visit neighbors for _, neighbor := range graph[current] { inDegree[neighbor]-- if inDegree[neighbor] == 0 { queue = append(queue, neighbor) } } } if len(result) != len(nodes) { return nil, fmt.Errorf("topological sort failed: graph has cycle") } return result, nil }