Files
poimen-workflows/internal/routing/canvas_validator.go
T
2026-09-05 00:54:22 -07:00

318 lines
7.4 KiB
Go

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,
"canvas-reasoner": 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
}