diff --git a/internal/swarm/manager.go b/internal/swarm/manager.go new file mode 100644 index 00000000..d5753633 --- /dev/null +++ b/internal/swarm/manager.go @@ -0,0 +1,337 @@ +package swarm + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os/exec" + "strings" + "time" + + "github.com/steveyegge/gastown/internal/rig" +) + +// Common errors +var ( + ErrSwarmNotFound = errors.New("swarm not found") + ErrSwarmExists = errors.New("swarm already exists") + ErrInvalidState = errors.New("invalid state transition") + ErrNoReadyTasks = errors.New("no ready tasks") + ErrBeadsNotFound = errors.New("beads not available") +) + +// Manager handles swarm lifecycle operations. +type Manager struct { + rig *rig.Rig + swarms map[string]*Swarm + workDir string +} + +// NewManager creates a new swarm manager for a rig. +func NewManager(r *rig.Rig) *Manager { + return &Manager{ + rig: r, + swarms: make(map[string]*Swarm), + workDir: r.Path, + } +} + +// Create creates a new swarm from an epic. +func (m *Manager) Create(epicID string, workers []string, targetBranch string) (*Swarm, error) { + if _, exists := m.swarms[epicID]; exists { + return nil, ErrSwarmExists + } + + // Get current git commit as base (optional - may not have git) + baseCommit, _ := m.getGitHead() + if baseCommit == "" { + baseCommit = "unknown" + } + + now := time.Now() + swarm := &Swarm{ + ID: epicID, + RigName: m.rig.Name, + EpicID: epicID, + BaseCommit: baseCommit, + Integration: fmt.Sprintf("swarm/%s", epicID), + TargetBranch: targetBranch, + State: SwarmCreated, + CreatedAt: now, + UpdatedAt: now, + Workers: workers, + Tasks: []SwarmTask{}, + } + + // Load tasks from beads + tasks, err := m.loadTasksFromBeads(epicID) + if err != nil { + // Non-fatal - swarm can start without tasks loaded + } else { + swarm.Tasks = tasks + } + + m.swarms[epicID] = swarm + return swarm, nil +} + +// Start activates a swarm, transitioning from Created to Active. +func (m *Manager) Start(swarmID string) error { + swarm, ok := m.swarms[swarmID] + if !ok { + return ErrSwarmNotFound + } + + if swarm.State != SwarmCreated { + return fmt.Errorf("%w: cannot start from state %s", ErrInvalidState, swarm.State) + } + + swarm.State = SwarmActive + swarm.UpdatedAt = time.Now() + return nil +} + +// UpdateState transitions the swarm to a new state. +func (m *Manager) UpdateState(swarmID string, state SwarmState) error { + swarm, ok := m.swarms[swarmID] + if !ok { + return ErrSwarmNotFound + } + + // Validate state transition + if !isValidTransition(swarm.State, state) { + return fmt.Errorf("%w: cannot transition from %s to %s", + ErrInvalidState, swarm.State, state) + } + + swarm.State = state + swarm.UpdatedAt = time.Now() + return nil +} + +// Cancel cancels a swarm with a reason. +func (m *Manager) Cancel(swarmID string, reason string) error { + swarm, ok := m.swarms[swarmID] + if !ok { + return ErrSwarmNotFound + } + + if swarm.State.IsTerminal() { + return fmt.Errorf("%w: swarm already in terminal state %s", + ErrInvalidState, swarm.State) + } + + swarm.State = SwarmCancelled + swarm.Error = reason + swarm.UpdatedAt = time.Now() + return nil +} + +// GetSwarm returns a swarm by ID. +func (m *Manager) GetSwarm(id string) (*Swarm, error) { + swarm, ok := m.swarms[id] + if !ok { + return nil, ErrSwarmNotFound + } + return swarm, nil +} + +// GetReadyTasks returns tasks ready to be assigned. +func (m *Manager) GetReadyTasks(swarmID string) ([]SwarmTask, error) { + swarm, ok := m.swarms[swarmID] + if !ok { + return nil, ErrSwarmNotFound + } + + var ready []SwarmTask + for _, task := range swarm.Tasks { + if task.State == TaskPending { + ready = append(ready, task) + } + } + + if len(ready) == 0 { + return nil, ErrNoReadyTasks + } + return ready, nil +} + +// GetActiveTasks returns tasks currently in progress. +func (m *Manager) GetActiveTasks(swarmID string) ([]SwarmTask, error) { + swarm, ok := m.swarms[swarmID] + if !ok { + return nil, ErrSwarmNotFound + } + + var active []SwarmTask + for _, task := range swarm.Tasks { + if task.State == TaskInProgress || task.State == TaskAssigned { + active = append(active, task) + } + } + return active, nil +} + +// IsComplete checks if all tasks are in terminal states. +func (m *Manager) IsComplete(swarmID string) (bool, error) { + swarm, ok := m.swarms[swarmID] + if !ok { + return false, ErrSwarmNotFound + } + + if len(swarm.Tasks) == 0 { + return false, nil + } + + for _, task := range swarm.Tasks { + if !task.State.IsComplete() { + return false, nil + } + } + return true, nil +} + +// AssignTask assigns a task to a worker. +func (m *Manager) AssignTask(swarmID, taskID, worker string) error { + swarm, ok := m.swarms[swarmID] + if !ok { + return ErrSwarmNotFound + } + + for i, task := range swarm.Tasks { + if task.IssueID == taskID { + swarm.Tasks[i].Assignee = worker + swarm.Tasks[i].State = TaskAssigned + swarm.Tasks[i].Branch = fmt.Sprintf("polecat/%s/%s", worker, taskID) + swarm.UpdatedAt = time.Now() + return nil + } + } + return fmt.Errorf("task %s not found in swarm", taskID) +} + +// UpdateTaskState updates a task's state. +func (m *Manager) UpdateTaskState(swarmID, taskID string, state TaskState) error { + swarm, ok := m.swarms[swarmID] + if !ok { + return ErrSwarmNotFound + } + + for i, task := range swarm.Tasks { + if task.IssueID == taskID { + swarm.Tasks[i].State = state + if state == TaskMerged { + now := time.Now() + swarm.Tasks[i].MergedAt = &now + } + swarm.UpdatedAt = time.Now() + return nil + } + } + return fmt.Errorf("task %s not found in swarm", taskID) +} + +// ListSwarms returns all swarms in the manager. +func (m *Manager) ListSwarms() []*Swarm { + swarms := make([]*Swarm, 0, len(m.swarms)) + for _, s := range m.swarms { + swarms = append(swarms, s) + } + return swarms +} + +// ListActiveSwarms returns non-terminal swarms. +func (m *Manager) ListActiveSwarms() []*Swarm { + var active []*Swarm + for _, s := range m.swarms { + if s.State.IsActive() { + active = append(active, s) + } + } + return active +} + +// isValidTransition checks if a state transition is allowed. +func isValidTransition(from, to SwarmState) bool { + transitions := map[SwarmState][]SwarmState{ + SwarmCreated: {SwarmActive, SwarmCancelled}, + SwarmActive: {SwarmMerging, SwarmFailed, SwarmCancelled}, + SwarmMerging: {SwarmLanded, SwarmFailed, SwarmCancelled}, + SwarmLanded: {}, // Terminal + SwarmFailed: {}, // Terminal + SwarmCancelled: {}, // Terminal + } + + allowed, ok := transitions[from] + if !ok { + return false + } + + for _, s := range allowed { + if s == to { + return true + } + } + return false +} + +// loadTasksFromBeads loads child issues from beads CLI. +func (m *Manager) loadTasksFromBeads(epicID string) ([]SwarmTask, error) { + // Run: bd list --parent --json + cmd := exec.Command("bd", "list", "--parent", epicID, "--json") + cmd.Dir = m.workDir + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("bd list: %s", strings.TrimSpace(stderr.String())) + } + + // Parse JSON output + var issues []struct { + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + } + + if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil { + return nil, fmt.Errorf("parsing bd output: %w", err) + } + + var tasks []SwarmTask + for _, issue := range issues { + state := TaskPending + switch issue.Status { + case "in_progress": + state = TaskInProgress + case "closed": + state = TaskMerged + } + + tasks = append(tasks, SwarmTask{ + IssueID: issue.ID, + Title: issue.Title, + State: state, + }) + } + + return tasks, nil +} + +// getGitHead returns the current HEAD commit. +func (m *Manager) getGitHead() (string, error) { + cmd := exec.Command("git", "rev-parse", "HEAD") + cmd.Dir = m.workDir + + var stdout bytes.Buffer + cmd.Stdout = &stdout + + if err := cmd.Run(); err != nil { + return "", err + } + + return strings.TrimSpace(stdout.String()), nil +} diff --git a/internal/swarm/manager_test.go b/internal/swarm/manager_test.go new file mode 100644 index 00000000..aef3b30c --- /dev/null +++ b/internal/swarm/manager_test.go @@ -0,0 +1,187 @@ +package swarm + +import ( + "testing" + + "github.com/steveyegge/gastown/internal/rig" +) + +func TestManagerCreate(t *testing.T) { + r := &rig.Rig{ + Name: "test-rig", + Path: "/tmp/test-rig", + } + m := NewManager(r) + + swarm, err := m.Create("epic-1", []string{"Toast", "Nux"}, "main") + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + if swarm.ID != "epic-1" { + t.Errorf("ID = %q, want %q", swarm.ID, "epic-1") + } + if swarm.State != SwarmCreated { + t.Errorf("State = %q, want %q", swarm.State, SwarmCreated) + } + if len(swarm.Workers) != 2 { + t.Errorf("Workers = %d, want 2", len(swarm.Workers)) + } +} + +func TestManagerCreateDuplicate(t *testing.T) { + r := &rig.Rig{ + Name: "test-rig", + Path: "/tmp/test-rig", + } + m := NewManager(r) + + _, err := m.Create("epic-1", []string{"Toast"}, "main") + if err != nil { + t.Fatalf("First Create failed: %v", err) + } + + _, err = m.Create("epic-1", []string{"Nux"}, "main") + if err != ErrSwarmExists { + t.Errorf("Create duplicate = %v, want ErrSwarmExists", err) + } +} + +func TestManagerStateTransitions(t *testing.T) { + r := &rig.Rig{ + Name: "test-rig", + Path: "/tmp/test-rig", + } + m := NewManager(r) + + swarm, _ := m.Create("epic-1", []string{"Toast"}, "main") + + // Start + if err := m.Start(swarm.ID); err != nil { + t.Errorf("Start failed: %v", err) + } + s, _ := m.GetSwarm(swarm.ID) + if s.State != SwarmActive { + t.Errorf("State after Start = %q, want %q", s.State, SwarmActive) + } + + // Can't start again + if err := m.Start(swarm.ID); err == nil { + t.Error("Start from Active should fail") + } + + // Transition to Merging + if err := m.UpdateState(swarm.ID, SwarmMerging); err != nil { + t.Errorf("UpdateState to Merging failed: %v", err) + } + + // Transition to Landed + if err := m.UpdateState(swarm.ID, SwarmLanded); err != nil { + t.Errorf("UpdateState to Landed failed: %v", err) + } + + // Can't transition from terminal + if err := m.UpdateState(swarm.ID, SwarmActive); err == nil { + t.Error("UpdateState from Landed should fail") + } +} + +func TestManagerCancel(t *testing.T) { + r := &rig.Rig{ + Name: "test-rig", + Path: "/tmp/test-rig", + } + m := NewManager(r) + + swarm, _ := m.Create("epic-1", []string{"Toast"}, "main") + m.Start(swarm.ID) + + if err := m.Cancel(swarm.ID, "user requested"); err != nil { + t.Errorf("Cancel failed: %v", err) + } + + s, _ := m.GetSwarm(swarm.ID) + if s.State != SwarmCancelled { + t.Errorf("State after Cancel = %q, want %q", s.State, SwarmCancelled) + } + if s.Error != "user requested" { + t.Errorf("Error = %q, want %q", s.Error, "user requested") + } +} + +func TestManagerTaskOperations(t *testing.T) { + r := &rig.Rig{ + Name: "test-rig", + Path: "/tmp/test-rig", + } + m := NewManager(r) + + swarm, _ := m.Create("epic-1", []string{"Toast"}, "main") + + // Manually add tasks (normally loaded from beads) + swarm.Tasks = []SwarmTask{ + {IssueID: "task-1", Title: "Task 1", State: TaskPending}, + {IssueID: "task-2", Title: "Task 2", State: TaskPending}, + } + + // Get ready tasks + ready, err := m.GetReadyTasks(swarm.ID) + if err != nil { + t.Errorf("GetReadyTasks failed: %v", err) + } + if len(ready) != 2 { + t.Errorf("GetReadyTasks = %d, want 2", len(ready)) + } + + // Assign task + if err := m.AssignTask(swarm.ID, "task-1", "Toast"); err != nil { + t.Errorf("AssignTask failed: %v", err) + } + + // Check assignment + s, _ := m.GetSwarm(swarm.ID) + if s.Tasks[0].Assignee != "Toast" { + t.Errorf("Assignee = %q, want %q", s.Tasks[0].Assignee, "Toast") + } + if s.Tasks[0].State != TaskAssigned { + t.Errorf("State = %q, want %q", s.Tasks[0].State, TaskAssigned) + } + + // Update state + if err := m.UpdateTaskState(swarm.ID, "task-1", TaskMerged); err != nil { + t.Errorf("UpdateTaskState failed: %v", err) + } + s, _ = m.GetSwarm(swarm.ID) + if s.Tasks[0].State != TaskMerged { + t.Errorf("State = %q, want %q", s.Tasks[0].State, TaskMerged) + } + if s.Tasks[0].MergedAt == nil { + t.Error("MergedAt should be set") + } +} + +func TestManagerIsComplete(t *testing.T) { + r := &rig.Rig{ + Name: "test-rig", + Path: "/tmp/test-rig", + } + m := NewManager(r) + + swarm, _ := m.Create("epic-1", []string{"Toast"}, "main") + swarm.Tasks = []SwarmTask{ + {IssueID: "task-1", State: TaskPending}, + {IssueID: "task-2", State: TaskMerged}, + } + + complete, _ := m.IsComplete(swarm.ID) + if complete { + t.Error("IsComplete should be false with pending task") + } + + // Complete the pending task + m.UpdateTaskState(swarm.ID, "task-1", TaskMerged) + complete, _ = m.IsComplete(swarm.ID) + if !complete { + t.Error("IsComplete should be true when all tasks merged") + } +}