354 lines
8.1 KiB
Go
354 lines
8.1 KiB
Go
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 show <epicID> --json to get epic with children
|
|
cmd := exec.Command("bd", "show", 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 show: %s", strings.TrimSpace(stderr.String()))
|
|
}
|
|
|
|
// Parse JSON output - bd show returns an array
|
|
var issues []struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Status string `json:"status"`
|
|
Dependents []struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Status string `json:"status"`
|
|
DependencyType string `json:"dependency_type"`
|
|
} `json:"dependents"`
|
|
}
|
|
|
|
if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil {
|
|
return nil, fmt.Errorf("parsing bd output: %w", err)
|
|
}
|
|
|
|
if len(issues) == 0 {
|
|
return nil, fmt.Errorf("epic not found: %s", epicID)
|
|
}
|
|
|
|
// Extract dependents as tasks (issues that depend on/are blocked by this epic)
|
|
// Accept both "parent-child" and "blocks" relationships
|
|
var tasks []SwarmTask
|
|
for _, dep := range issues[0].Dependents {
|
|
if dep.DependencyType != "parent-child" && dep.DependencyType != "blocks" {
|
|
continue
|
|
}
|
|
|
|
state := TaskPending
|
|
switch dep.Status {
|
|
case "in_progress":
|
|
state = TaskInProgress
|
|
case "closed":
|
|
state = TaskMerged
|
|
}
|
|
|
|
tasks = append(tasks, SwarmTask{
|
|
IssueID: dep.ID,
|
|
Title: dep.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
|
|
}
|