ci / test (push) Failing after 2m11s
- Add pkg/db models and CRUD methods for workflows - Add internal/routing canvas validator (DAG check, connectivity) - Add internal/routing canvas converter (Canvas → WorkflowSpec) - Register LLMInferenceActivity and LLMBatchInferenceActivity - Update api/server and cmd/server with database integration - Add K8s environment variable support - Update activity knowledge base with LLM activities - Add .env.example configuration template
317 lines
7.4 KiB
Go
317 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,
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|