feat: database layer + canvas validator/converter + LLM inference activities
- 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
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user