diff --git a/internal/swarm/types.go b/internal/swarm/types.go new file mode 100644 index 00000000..8b33402d --- /dev/null +++ b/internal/swarm/types.go @@ -0,0 +1,180 @@ +// Package swarm provides types and management for multi-agent swarms. +package swarm + +import "time" + +// SwarmState represents the lifecycle state of a swarm. +type SwarmState string + +const ( + // SwarmCreated is the initial state after swarm creation. + SwarmCreated SwarmState = "created" + + // SwarmActive means workers are actively working on tasks. + SwarmActive SwarmState = "active" + + // SwarmMerging means all work is done and merging is in progress. + SwarmMerging SwarmState = "merging" + + // SwarmLanded means all work has been merged to the target branch. + SwarmLanded SwarmState = "landed" + + // SwarmFailed means the swarm failed and cannot be recovered. + SwarmFailed SwarmState = "failed" + + // SwarmCancelled means the swarm was explicitly cancelled. + SwarmCancelled SwarmState = "cancelled" +) + +// IsTerminal returns true if the swarm is in a terminal state. +func (s SwarmState) IsTerminal() bool { + return s == SwarmLanded || s == SwarmFailed || s == SwarmCancelled +} + +// IsActive returns true if the swarm is actively running. +func (s SwarmState) IsActive() bool { + return s == SwarmCreated || s == SwarmActive || s == SwarmMerging +} + +// Swarm represents a coordinated multi-agent work unit. +// The swarm references a beads epic that tracks all swarm work. +type Swarm struct { + // ID is the unique swarm identifier (matches beads epic ID). + ID string `json:"id"` + + // RigName is the rig this swarm operates in. + RigName string `json:"rig_name"` + + // EpicID is the beads epic tracking this swarm's work. + EpicID string `json:"epic_id"` + + // BaseCommit is the git SHA all workers branch from. + BaseCommit string `json:"base_commit"` + + // Integration is the integration branch name for merging work. + Integration string `json:"integration"` + + // TargetBranch is the branch to merge into when complete (e.g., "main"). + TargetBranch string `json:"target_branch"` + + // State is the current lifecycle state. + State SwarmState `json:"state"` + + // CreatedAt is when the swarm was created. + CreatedAt time.Time `json:"created_at"` + + // UpdatedAt is when the swarm was last updated. + UpdatedAt time.Time `json:"updated_at"` + + // Workers is the list of polecat names assigned to this swarm. + Workers []string `json:"workers"` + + // Tasks is the list of tasks in this swarm. + Tasks []SwarmTask `json:"tasks"` + + // Error contains error details if State is SwarmFailed. + Error string `json:"error,omitempty"` +} + +// SwarmTask represents a single task in the swarm. +// Each task maps to a beads issue and is assigned to a worker. +type SwarmTask struct { + // IssueID is the beads issue ID for this task. + IssueID string `json:"issue_id"` + + // Title is the task title (copied from beads issue). + Title string `json:"title"` + + // Assignee is the polecat name working on this task. + Assignee string `json:"assignee,omitempty"` + + // Branch is the worker's branch name for this task. + Branch string `json:"branch,omitempty"` + + // State mirrors the beads issue status. + State TaskState `json:"state"` + + // MergedAt is when the task branch was merged (if merged). + MergedAt *time.Time `json:"merged_at,omitempty"` +} + +// TaskState represents the state of a swarm task. +type TaskState string + +const ( + // TaskPending means the task is not yet started. + TaskPending TaskState = "pending" + + // TaskAssigned means the task is assigned but not started. + TaskAssigned TaskState = "assigned" + + // TaskInProgress means the task is actively being worked on. + TaskInProgress TaskState = "in_progress" + + // TaskReview means the task is ready for review/merge. + TaskReview TaskState = "review" + + // TaskMerged means the task has been merged. + TaskMerged TaskState = "merged" + + // TaskFailed means the task failed. + TaskFailed TaskState = "failed" +) + +// IsComplete returns true if the task is in a terminal state. +func (s TaskState) IsComplete() bool { + return s == TaskMerged || s == TaskFailed +} + +// SwarmSummary provides a high-level overview of swarm progress. +type SwarmSummary struct { + ID string `json:"id"` + State SwarmState `json:"state"` + TotalTasks int `json:"total_tasks"` + PendingTasks int `json:"pending_tasks"` + ActiveTasks int `json:"active_tasks"` + MergedTasks int `json:"merged_tasks"` + FailedTasks int `json:"failed_tasks"` + WorkerCount int `json:"worker_count"` +} + +// Summary returns a SwarmSummary for this swarm. +func (s *Swarm) Summary() SwarmSummary { + summary := SwarmSummary{ + ID: s.ID, + State: s.State, + TotalTasks: len(s.Tasks), + WorkerCount: len(s.Workers), + } + + for _, task := range s.Tasks { + switch task.State { + case TaskPending, TaskAssigned: + summary.PendingTasks++ + case TaskInProgress, TaskReview: + summary.ActiveTasks++ + case TaskMerged: + summary.MergedTasks++ + case TaskFailed: + summary.FailedTasks++ + } + } + + return summary +} + +// Progress returns the completion percentage (0-100). +func (s *Swarm) Progress() int { + if len(s.Tasks) == 0 { + return 0 + } + + merged := 0 + for _, task := range s.Tasks { + if task.State == TaskMerged { + merged++ + } + } + + return (merged * 100) / len(s.Tasks) +} diff --git a/internal/swarm/types_test.go b/internal/swarm/types_test.go new file mode 100644 index 00000000..df628c06 --- /dev/null +++ b/internal/swarm/types_test.go @@ -0,0 +1,196 @@ +package swarm + +import ( + "testing" + "time" +) + +func TestSwarmStateIsTerminal(t *testing.T) { + tests := []struct { + state SwarmState + terminal bool + }{ + {SwarmCreated, false}, + {SwarmActive, false}, + {SwarmMerging, false}, + {SwarmLanded, true}, + {SwarmFailed, true}, + {SwarmCancelled, true}, + } + + for _, tt := range tests { + if got := tt.state.IsTerminal(); got != tt.terminal { + t.Errorf("%s.IsTerminal() = %v, want %v", tt.state, got, tt.terminal) + } + } +} + +func TestSwarmStateIsActive(t *testing.T) { + tests := []struct { + state SwarmState + active bool + }{ + {SwarmCreated, true}, + {SwarmActive, true}, + {SwarmMerging, true}, + {SwarmLanded, false}, + {SwarmFailed, false}, + {SwarmCancelled, false}, + } + + for _, tt := range tests { + if got := tt.state.IsActive(); got != tt.active { + t.Errorf("%s.IsActive() = %v, want %v", tt.state, got, tt.active) + } + } +} + +func TestTaskStateIsComplete(t *testing.T) { + tests := []struct { + state TaskState + complete bool + }{ + {TaskPending, false}, + {TaskAssigned, false}, + {TaskInProgress, false}, + {TaskReview, false}, + {TaskMerged, true}, + {TaskFailed, true}, + } + + for _, tt := range tests { + if got := tt.state.IsComplete(); got != tt.complete { + t.Errorf("%s.IsComplete() = %v, want %v", tt.state, got, tt.complete) + } + } +} + +func TestSwarmSummary(t *testing.T) { + swarm := &Swarm{ + ID: "test-swarm", + State: SwarmActive, + Workers: []string{"worker1", "worker2"}, + Tasks: []SwarmTask{ + {IssueID: "1", State: TaskPending}, + {IssueID: "2", State: TaskAssigned}, + {IssueID: "3", State: TaskInProgress}, + {IssueID: "4", State: TaskReview}, + {IssueID: "5", State: TaskMerged}, + {IssueID: "6", State: TaskFailed}, + }, + } + + summary := swarm.Summary() + + if summary.ID != "test-swarm" { + t.Errorf("ID = %q, want test-swarm", summary.ID) + } + if summary.TotalTasks != 6 { + t.Errorf("TotalTasks = %d, want 6", summary.TotalTasks) + } + if summary.PendingTasks != 2 { + t.Errorf("PendingTasks = %d, want 2", summary.PendingTasks) + } + if summary.ActiveTasks != 2 { + t.Errorf("ActiveTasks = %d, want 2", summary.ActiveTasks) + } + if summary.MergedTasks != 1 { + t.Errorf("MergedTasks = %d, want 1", summary.MergedTasks) + } + if summary.FailedTasks != 1 { + t.Errorf("FailedTasks = %d, want 1", summary.FailedTasks) + } + if summary.WorkerCount != 2 { + t.Errorf("WorkerCount = %d, want 2", summary.WorkerCount) + } +} + +func TestSwarmProgress(t *testing.T) { + tests := []struct { + name string + tasks []SwarmTask + expected int + }{ + { + name: "empty", + tasks: nil, + expected: 0, + }, + { + name: "none merged", + tasks: []SwarmTask{ + {State: TaskPending}, + {State: TaskInProgress}, + }, + expected: 0, + }, + { + name: "half merged", + tasks: []SwarmTask{ + {State: TaskMerged}, + {State: TaskInProgress}, + }, + expected: 50, + }, + { + name: "all merged", + tasks: []SwarmTask{ + {State: TaskMerged}, + {State: TaskMerged}, + {State: TaskMerged}, + }, + expected: 100, + }, + { + name: "one of three", + tasks: []SwarmTask{ + {State: TaskMerged}, + {State: TaskPending}, + {State: TaskPending}, + }, + expected: 33, // 1/3 = 33% + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + swarm := &Swarm{Tasks: tt.tasks} + if got := swarm.Progress(); got != tt.expected { + t.Errorf("Progress() = %d, want %d", got, tt.expected) + } + }) + } +} + +func TestSwarmJSON(t *testing.T) { + now := time.Now().Truncate(time.Second) + swarm := &Swarm{ + ID: "swarm-123", + RigName: "gastown", + EpicID: "gt-abc", + BaseCommit: "abc123", + Integration: "swarm-123-integration", + TargetBranch: "main", + State: SwarmActive, + CreatedAt: now, + UpdatedAt: now, + Workers: []string{"Toast", "Cheedo"}, + Tasks: []SwarmTask{ + { + IssueID: "gt-def", + Title: "Test task", + Assignee: "Toast", + Branch: "swarm-123-Toast", + State: TaskInProgress, + }, + }, + } + + // Just verify it doesn't panic and has expected values + if swarm.ID != "swarm-123" { + t.Errorf("ID = %q, want swarm-123", swarm.ID) + } + if len(swarm.Workers) != 2 { + t.Errorf("Workers count = %d, want 2", len(swarm.Workers)) + } +}