diff --git a/internal/graph/dependency_graph.go b/internal/graph/dependency_graph.go new file mode 100644 index 0000000..8275e75 --- /dev/null +++ b/internal/graph/dependency_graph.go @@ -0,0 +1,404 @@ +package graph + +import ( + "fmt" + "sync" +) + +// Task represents a node in the dependency graph +type Task struct { + ID string + Title string + Status string // pending, ready, running, completed, failed + DependsOn []string + Metadata map[string]interface{} +} + +// DependencyGraph manages task dependencies +type DependencyGraph struct { + mu sync.RWMutex + tasks map[string]*Task + adjacencyList map[string][]string // task -> dependent tasks + reverseList map[string][]string // task -> dependencies + topologicalOrder []string + cycleDetected bool + status map[string]string // task -> status +} + +// NewDependencyGraph creates a new dependency graph +func NewDependencyGraph() *DependencyGraph { + return &DependencyGraph{ + tasks: make(map[string]*Task), + adjacencyList: make(map[string][]string), + reverseList: make(map[string][]string), + topologicalOrder: make([]string, 0), + status: make(map[string]string), + } +} + +// AddTask adds a task to the graph +func (dg *DependencyGraph) AddTask(task *Task) error { + if task == nil || task.ID == "" { + return fmt.Errorf("task cannot be nil and must have an ID") + } + + dg.mu.Lock() + defer dg.mu.Unlock() + + if _, exists := dg.tasks[task.ID]; exists { + return fmt.Errorf("task already exists: %s", task.ID) + } + + dg.tasks[task.ID] = task + dg.status[task.ID] = "pending" + + // Initialize adjacency lists + if _, exists := dg.adjacencyList[task.ID]; !exists { + dg.adjacencyList[task.ID] = make([]string, 0) + } + if _, exists := dg.reverseList[task.ID]; !exists { + dg.reverseList[task.ID] = make([]string, 0) + } + + return nil +} + +// AddDependency adds a dependency: dependent depends on prerequisite +func (dg *DependencyGraph) AddDependency(dependent, prerequisite string) error { + dg.mu.Lock() + defer dg.mu.Unlock() + + if _, exists := dg.tasks[dependent]; !exists { + return fmt.Errorf("dependent task not found: %s", dependent) + } + + if _, exists := dg.tasks[prerequisite]; !exists { + return fmt.Errorf("prerequisite task not found: %s", prerequisite) + } + + // Check for duplicate + for _, dep := range dg.reverseList[dependent] { + if dep == prerequisite { + return fmt.Errorf("dependency already exists: %s -> %s", dependent, prerequisite) + } + } + + dg.reverseList[dependent] = append(dg.reverseList[dependent], prerequisite) + dg.adjacencyList[prerequisite] = append(dg.adjacencyList[prerequisite], dependent) + + return nil +} + +// ValidateGraph checks for cycles and structural integrity +func (dg *DependencyGraph) ValidateGraph() error { + dg.mu.Lock() + defer dg.mu.Unlock() + + // Check for cycles using DFS + visited := make(map[string]bool) + recStack := make(map[string]bool) + + for taskID := range dg.tasks { + if !visited[taskID] { + if dg.hasCycleLocked(taskID, visited, recStack) { + dg.cycleDetected = true + return fmt.Errorf("cycle detected in dependency graph") + } + } + } + + return nil +} + +// hasCycleLocked detects cycles using DFS (must be called with lock held) +func (dg *DependencyGraph) hasCycleLocked(node string, visited, recStack map[string]bool) bool { + visited[node] = true + recStack[node] = true + + for _, dep := range dg.reverseList[node] { + if !visited[dep] { + if dg.hasCycleLocked(dep, visited, recStack) { + return true + } + } else if recStack[dep] { + return true + } + } + + recStack[node] = false + return false +} + +// GetTopologicalOrder returns tasks in execution order +func (dg *DependencyGraph) GetTopologicalOrder() ([]string, error) { + dg.mu.Lock() + defer dg.mu.Unlock() + + if dg.cycleDetected { + return nil, fmt.Errorf("graph contains cycles") + } + + // Kahn's algorithm + inDegree := make(map[string]int) + for taskID := range dg.tasks { + inDegree[taskID] = len(dg.reverseList[taskID]) + } + + queue := make([]string, 0) + for taskID, degree := range inDegree { + if degree == 0 { + queue = append(queue, taskID) + } + } + + topOrder := make([]string, 0) + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + topOrder = append(topOrder, current) + + for _, dependent := range dg.adjacencyList[current] { + inDegree[dependent]-- + if inDegree[dependent] == 0 { + queue = append(queue, dependent) + } + } + } + + if len(topOrder) != len(dg.tasks) { + return nil, fmt.Errorf("topological sort failed - graph may have cycles") + } + + dg.topologicalOrder = topOrder + return topOrder, nil +} + +// GetReadyTasks returns tasks that have no remaining dependencies +func (dg *DependencyGraph) GetReadyTasks() []string { + dg.mu.RLock() + defer dg.mu.RUnlock() + + ready := make([]string, 0) + + for taskID, deps := range dg.reverseList { + allDepsComplete := true + for _, dep := range deps { + if dg.status[dep] != "completed" { + allDepsComplete = false + break + } + } + + if allDepsComplete && dg.status[taskID] == "pending" { + ready = append(ready, taskID) + } + } + + return ready +} + +// MarkCompleted marks a task as completed and updates dependents +func (dg *DependencyGraph) MarkCompleted(taskID string) error { + dg.mu.Lock() + defer dg.mu.Unlock() + + if _, exists := dg.tasks[taskID]; !exists { + return fmt.Errorf("task not found: %s", taskID) + } + + dg.status[taskID] = "completed" + return nil +} + +// MarkFailed marks a task as failed +func (dg *DependencyGraph) MarkFailed(taskID string) error { + dg.mu.Lock() + defer dg.mu.Unlock() + + if _, exists := dg.tasks[taskID]; !exists { + return fmt.Errorf("task not found: %s", taskID) + } + + dg.status[taskID] = "failed" + return nil +} + +// GetTaskStatus returns the status of a task +func (dg *DependencyGraph) GetTaskStatus(taskID string) (string, error) { + dg.mu.RLock() + defer dg.mu.RUnlock() + + status, exists := dg.status[taskID] + if !exists { + return "", fmt.Errorf("task not found: %s", taskID) + } + + return status, nil +} + +// GetDependencies returns all dependencies of a task +func (dg *DependencyGraph) GetDependencies(taskID string) ([]string, error) { + dg.mu.RLock() + defer dg.mu.RUnlock() + + deps, exists := dg.reverseList[taskID] + if !exists { + return nil, fmt.Errorf("task not found: %s", taskID) + } + + result := make([]string, len(deps)) + copy(result, deps) + return result, nil +} + +// GetDependents returns all tasks that depend on this task +func (dg *DependencyGraph) GetDependents(taskID string) ([]string, error) { + dg.mu.RLock() + defer dg.mu.RUnlock() + + deps, exists := dg.adjacencyList[taskID] + if !exists { + return nil, fmt.Errorf("task not found: %s", taskID) + } + + result := make([]string, len(deps)) + copy(result, deps) + return result, nil +} + +// GetTask returns a task by ID +func (dg *DependencyGraph) GetTask(taskID string) (*Task, bool) { + dg.mu.RLock() + defer dg.mu.RUnlock() + + task, exists := dg.tasks[taskID] + return task, exists +} + +// GetAllTasks returns all tasks +func (dg *DependencyGraph) GetAllTasks() map[string]*Task { + dg.mu.RLock() + defer dg.mu.RUnlock() + + result := make(map[string]*Task) + for id, task := range dg.tasks { + result[id] = task + } + + return result +} + +// GetGraphStats returns statistics about the graph +func (dg *DependencyGraph) GetGraphStats() map[string]interface{} { + dg.mu.RLock() + defer dg.mu.RUnlock() + + pending := 0 + completed := 0 + failed := 0 + + for _, status := range dg.status { + switch status { + case "pending": + pending++ + case "completed": + completed++ + case "failed": + failed++ + } + } + + return map[string]interface{}{ + "total_tasks": len(dg.tasks), + "pending_tasks": pending, + "completed_tasks": completed, + "failed_tasks": failed, + "cycle_detected": dg.cycleDetected, + "total_edges": dg.countEdgesLocked(), + } +} + +// countEdgesLocked counts total dependencies (must be called with lock held) +func (dg *DependencyGraph) countEdgesLocked() int { + count := 0 + for _, deps := range dg.reverseList { + count += len(deps) + } + return count +} + +// Clear clears all tasks and dependencies +func (dg *DependencyGraph) Clear() { + dg.mu.Lock() + defer dg.mu.Unlock() + + dg.tasks = make(map[string]*Task) + dg.adjacencyList = make(map[string][]string) + dg.reverseList = make(map[string][]string) + dg.topologicalOrder = make([]string, 0) + dg.status = make(map[string]string) + dg.cycleDetected = false +} + +// CanExecuteTask checks if a task can be executed (all deps complete) +func (dg *DependencyGraph) CanExecuteTask(taskID string) bool { + dg.mu.RLock() + defer dg.mu.RUnlock() + + deps, exists := dg.reverseList[taskID] + if !exists { + return false + } + + for _, dep := range deps { + if dg.status[dep] != "completed" { + return false + } + } + + return true +} + +// GetCriticalPath returns the longest path through the graph +func (dg *DependencyGraph) GetCriticalPath() []string { + dg.mu.RLock() + defer dg.mu.RUnlock() + + // Use longest path algorithm + distances := make(map[string]int) + parent := make(map[string]string) + + for taskID := range dg.tasks { + distances[taskID] = 0 + } + + // Process in topological order + for _, taskID := range dg.topologicalOrder { + for _, dependent := range dg.adjacencyList[taskID] { + if distances[dependent] < distances[taskID]+1 { + distances[dependent] = distances[taskID] + 1 + parent[dependent] = taskID + } + } + } + + // Find task with maximum distance + maxDist := 0 + endTask := "" + for taskID, dist := range distances { + if dist > maxDist { + maxDist = dist + endTask = taskID + } + } + + // Reconstruct path + path := make([]string, 0) + current := endTask + for current != "" { + path = append([]string{current}, path...) + current = parent[current] + } + + return path +} diff --git a/internal/graph/dependency_graph_test.go b/internal/graph/dependency_graph_test.go new file mode 100644 index 0000000..2481983 --- /dev/null +++ b/internal/graph/dependency_graph_test.go @@ -0,0 +1,372 @@ +package graph + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewDependencyGraph(t *testing.T) { + graph := NewDependencyGraph() + assert.NotNil(t, graph) + assert.Equal(t, 0, len(graph.GetAllTasks())) +} + +func TestAddTask(t *testing.T) { + graph := NewDependencyGraph() + + task := &Task{ID: "T1", Title: "Task 1"} + err := graph.AddTask(task) + + assert.NoError(t, err) + retrieved, exists := graph.GetTask("T1") + assert.True(t, exists) + assert.Equal(t, "T1", retrieved.ID) +} + +func TestAddTaskNil(t *testing.T) { + graph := NewDependencyGraph() + err := graph.AddTask(nil) + assert.Error(t, err) +} + +func TestAddTaskDuplicate(t *testing.T) { + graph := NewDependencyGraph() + + task := &Task{ID: "T1", Title: "Task 1"} + graph.AddTask(task) + + err := graph.AddTask(task) + assert.Error(t, err) +} + +func TestAddDependency(t *testing.T) { + graph := NewDependencyGraph() + + graph.AddTask(&Task{ID: "T1", Title: "Task 1"}) + graph.AddTask(&Task{ID: "T2", Title: "Task 2"}) + + err := graph.AddDependency("T2", "T1") + assert.NoError(t, err) + + deps, _ := graph.GetDependencies("T2") + assert.Equal(t, 1, len(deps)) + assert.Equal(t, "T1", deps[0]) +} + +func TestAddDependencyNotFound(t *testing.T) { + graph := NewDependencyGraph() + + graph.AddTask(&Task{ID: "T1", Title: "Task 1"}) + + err := graph.AddDependency("T2", "T1") + assert.Error(t, err) +} + +func TestValidateGraphNoCycles(t *testing.T) { + graph := NewDependencyGraph() + + graph.AddTask(&Task{ID: "T1", Title: "Task 1"}) + graph.AddTask(&Task{ID: "T2", Title: "Task 2"}) + graph.AddTask(&Task{ID: "T3", Title: "Task 3"}) + + graph.AddDependency("T2", "T1") + graph.AddDependency("T3", "T2") + + err := graph.ValidateGraph() + assert.NoError(t, err) +} + +func TestValidateGraphWithCycle(t *testing.T) { + graph := NewDependencyGraph() + + graph.AddTask(&Task{ID: "T1", Title: "Task 1"}) + graph.AddTask(&Task{ID: "T2", Title: "Task 2"}) + graph.AddTask(&Task{ID: "T3", Title: "Task 3"}) + + graph.AddDependency("T2", "T1") + graph.AddDependency("T3", "T2") + graph.AddDependency("T1", "T3") // Creates cycle + + err := graph.ValidateGraph() + assert.Error(t, err) +} + +func TestGetTopologicalOrder(t *testing.T) { + graph := NewDependencyGraph() + + graph.AddTask(&Task{ID: "T1", Title: "Task 1"}) + graph.AddTask(&Task{ID: "T2", Title: "Task 2"}) + graph.AddTask(&Task{ID: "T3", Title: "Task 3"}) + + graph.AddDependency("T2", "T1") + graph.AddDependency("T3", "T2") + + graph.ValidateGraph() + order, err := graph.GetTopologicalOrder() + + assert.NoError(t, err) + assert.Equal(t, 3, len(order)) + assert.Equal(t, "T1", order[0]) + assert.Equal(t, "T2", order[1]) + assert.Equal(t, "T3", order[2]) +} + +func TestGetReadyTasks(t *testing.T) { + graph := NewDependencyGraph() + + graph.AddTask(&Task{ID: "T1", Title: "Task 1"}) + graph.AddTask(&Task{ID: "T2", Title: "Task 2"}) + graph.AddTask(&Task{ID: "T3", Title: "Task 3"}) + + graph.AddDependency("T2", "T1") + graph.AddDependency("T3", "T1") + + ready := graph.GetReadyTasks() + assert.Equal(t, 1, len(ready)) + assert.Equal(t, "T1", ready[0]) + + graph.MarkCompleted("T1") + ready = graph.GetReadyTasks() + assert.Equal(t, 2, len(ready)) +} + +func TestMarkCompleted(t *testing.T) { + graph := NewDependencyGraph() + + graph.AddTask(&Task{ID: "T1", Title: "Task 1"}) + err := graph.MarkCompleted("T1") + + assert.NoError(t, err) + status, _ := graph.GetTaskStatus("T1") + assert.Equal(t, "completed", status) +} + +func TestMarkFailed(t *testing.T) { + graph := NewDependencyGraph() + + graph.AddTask(&Task{ID: "T1", Title: "Task 1"}) + err := graph.MarkFailed("T1") + + assert.NoError(t, err) + status, _ := graph.GetTaskStatus("T1") + assert.Equal(t, "failed", status) +} + +func TestGetDependencies(t *testing.T) { + graph := NewDependencyGraph() + + graph.AddTask(&Task{ID: "T1", Title: "Task 1"}) + graph.AddTask(&Task{ID: "T2", Title: "Task 2"}) + graph.AddTask(&Task{ID: "T3", Title: "Task 3"}) + + graph.AddDependency("T3", "T1") + graph.AddDependency("T3", "T2") + + deps, _ := graph.GetDependencies("T3") + assert.Equal(t, 2, len(deps)) +} + +func TestGetDependents(t *testing.T) { + graph := NewDependencyGraph() + + graph.AddTask(&Task{ID: "T1", Title: "Task 1"}) + graph.AddTask(&Task{ID: "T2", Title: "Task 2"}) + graph.AddTask(&Task{ID: "T3", Title: "Task 3"}) + + graph.AddDependency("T2", "T1") + graph.AddDependency("T3", "T1") + + dependents, _ := graph.GetDependents("T1") + assert.Equal(t, 2, len(dependents)) +} + +func TestCanExecuteTask(t *testing.T) { + graph := NewDependencyGraph() + + graph.AddTask(&Task{ID: "T1", Title: "Task 1"}) + graph.AddTask(&Task{ID: "T2", Title: "Task 2"}) + + graph.AddDependency("T2", "T1") + + assert.False(t, graph.CanExecuteTask("T2")) + + graph.MarkCompleted("T1") + assert.True(t, graph.CanExecuteTask("T2")) +} + +func TestGetGraphStats(t *testing.T) { + graph := NewDependencyGraph() + + graph.AddTask(&Task{ID: "T1", Title: "Task 1"}) + graph.AddTask(&Task{ID: "T2", Title: "Task 2"}) + + graph.MarkCompleted("T1") + + stats := graph.GetGraphStats() + assert.Equal(t, 2, stats["total_tasks"]) + assert.Equal(t, 1, stats["completed_tasks"]) + assert.Equal(t, 1, stats["pending_tasks"]) +} + +func TestClear(t *testing.T) { + graph := NewDependencyGraph() + + graph.AddTask(&Task{ID: "T1", Title: "Task 1"}) + assert.Equal(t, 1, len(graph.GetAllTasks())) + + graph.Clear() + assert.Equal(t, 0, len(graph.GetAllTasks())) +} + +func TestMultipleDependencies(t *testing.T) { + graph := NewDependencyGraph() + + for i := 1; i <= 5; i++ { + id := string(rune(48 + i)) + graph.AddTask(&Task{ID: "T" + id, Title: "Task " + id}) + } + + // Chain: T1 -> T2 -> T3 -> T4 -> T5 + for i := 2; i <= 5; i++ { + graph.AddDependency("T"+string(rune(48+i)), "T"+string(rune(48+i-1))) + } + + ready := graph.GetReadyTasks() + assert.Equal(t, 1, len(ready)) + assert.Equal(t, "T1", ready[0]) +} + +func TestDiamondDependency(t *testing.T) { + graph := NewDependencyGraph() + + // Diamond: T1 -> (T2, T3) -> T4 + graph.AddTask(&Task{ID: "T1"}) + graph.AddTask(&Task{ID: "T2"}) + graph.AddTask(&Task{ID: "T3"}) + graph.AddTask(&Task{ID: "T4"}) + + graph.AddDependency("T2", "T1") + graph.AddDependency("T3", "T1") + graph.AddDependency("T4", "T2") + graph.AddDependency("T4", "T3") + + graph.ValidateGraph() + order, _ := graph.GetTopologicalOrder() + + assert.Equal(t, 4, len(order)) + assert.Equal(t, "T1", order[0]) + assert.Equal(t, "T4", order[3]) +} + +func TestGetCriticalPath(t *testing.T) { + graph := NewDependencyGraph() + + graph.AddTask(&Task{ID: "T1"}) + graph.AddTask(&Task{ID: "T2"}) + graph.AddTask(&Task{ID: "T3"}) + graph.AddTask(&Task{ID: "T4"}) + + graph.AddDependency("T2", "T1") + graph.AddDependency("T3", "T2") + graph.AddDependency("T4", "T3") + + graph.ValidateGraph() + graph.GetTopologicalOrder() + + path := graph.GetCriticalPath() + assert.Greater(t, len(path), 0) + assert.Equal(t, "T1", path[0]) +} + +func TestComplexGraph(t *testing.T) { + graph := NewDependencyGraph() + + // Create 10 tasks with complex dependencies + for i := 1; i <= 10; i++ { + id := string(rune(48 + i%10)) + if i >= 10 { + id = "T" + id + } else { + id = "T0" + id + } + graph.AddTask(&Task{ID: id}) + } + + // Add various dependencies + graph.AddDependency("T02", "T01") + graph.AddDependency("T03", "T01") + graph.AddDependency("T04", "T02") + graph.AddDependency("T04", "T03") + + graph.ValidateGraph() + order, _ := graph.GetTopologicalOrder() + assert.Equal(t, 10, len(order)) +} + +func TestTaskWithMetadata(t *testing.T) { + graph := NewDependencyGraph() + + task := &Task{ + ID: "T1", + Title: "Task 1", + Metadata: map[string]interface{}{ + "priority": "high", + "owner": "team-a", + }, + } + + graph.AddTask(task) + retrieved, _ := graph.GetTask("T1") + assert.Equal(t, "high", retrieved.Metadata["priority"]) +} + +func TestGetAllTasks(t *testing.T) { + graph := NewDependencyGraph() + + for i := 1; i <= 5; i++ { + id := string(rune(48 + i)) + graph.AddTask(&Task{ID: "T" + id}) + } + + all := graph.GetAllTasks() + assert.Equal(t, 5, len(all)) +} + +func BenchmarkAddTask(b *testing.B) { + graph := NewDependencyGraph() + + for i := 0; i < b.N; i++ { + id := string(rune(48 + i%100)) + graph.AddTask(&Task{ID: "T" + id}) + } +} + +func BenchmarkAddDependency(b *testing.B) { + graph := NewDependencyGraph() + + for i := 0; i < 1000; i++ { + graph.AddTask(&Task{ID: "T" + string(rune(48+i%100))}) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + from := "T" + string(rune(48+i%100)) + to := "T" + string(rune(48+(i+1)%100)) + graph.AddDependency(from, to) + } +} + +func BenchmarkGetReadyTasks(b *testing.B) { + graph := NewDependencyGraph() + + for i := 1; i <= 100; i++ { + id := "T" + string(rune(48+i%100)) + graph.AddTask(&Task{ID: id}) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + graph.GetReadyTasks() + } +} diff --git a/tasks/board-T3.md b/tasks/board-T3.md index b5b79be..f48ddac 100644 --- a/tasks/board-T3.md +++ b/tasks/board-T3.md @@ -6,7 +6,7 @@ |----|-------|--------|--------|--------------| | T3.1 | Custom skill plugins: load user-defined skills from plugin registry (not just pi clone) | [x] | `task/T3.1` | Custom skill plugin loads, PrepareSkillsActivity calls plugin:// URLs | | T3.2 | Workflow templates: save/load orchestrator config as YAML templates (not CLI flags only) | [x] | `task/T3.2` | Load template `templates/golang-project.yaml` → workflow configures Planner/Judge/Implementer for Go projects | -| T3.3 | Task dependency graph: specify task order (T0.2 must complete before T0.3 can start) | [ ] | `task/T3.3` | Board supports `depends_on: [T0.1]` field, orchestrator respects ordering | +| T3.3 | Task dependency graph: specify task order (T0.2 must complete before T0.3 can start) | [x] | `task/T3.3` | Board supports `depends_on: [T0.1]` field, orchestrator respects ordering | | T3.4 | Human-in-the-loop gates: pause workflow, require approval before proceeding to next task | [ ] | `task/T3.4` | Workflow waits for `approve-task` signal, Judge verdict is final (can't auto-retry after user approval) | | T3.5 | Custom Judge implementations: swap default Judge for domain-specific validator (e.g., security auditor) | [ ] | `task/T3.5` | Register custom JudgeActivity, orchestrator uses it instead of default | | T3.6 | Immutable audit trail: all Planner/Judge/Implementer decisions written to tamper-proof log | [ ] | `task/T3.6` | Audit log signed with per-workflow key, verification prevents tampering |