Remove Gas Town-specific type constants (TypeMolecule, TypeGate, TypeConvoy, TypeMergeRequest, TypeSlot, TypeAgent, TypeRole, TypeRig, TypeEvent, TypeMessage) from internal/types/types.go. Beads now only has core work types built-in: - bug, feature, task, epic, chore All Gas Town types are now purely custom types with no special handling in beads. Use string literals like "gate" or "molecule" when needed, and configure types.custom in config.yaml for validation. Changes: - Remove Gas Town type constants from types.go - Remove mr/mol aliases from Normalize() - Update bd types command to only show core types - Replace all constant usages with string literals throughout codebase - Update tests to use string literals This decouples beads from Gas Town, making it a generic issue tracker. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1222 lines
35 KiB
Go
1222 lines
35 KiB
Go
// Package main implements the bd CLI swarm management commands.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
"github.com/steveyegge/beads/internal/ui"
|
|
"github.com/steveyegge/beads/internal/utils"
|
|
)
|
|
|
|
var swarmCmd = &cobra.Command{
|
|
Use: "swarm",
|
|
GroupID: "deps",
|
|
Short: "Swarm management for structured epics",
|
|
Long: `Swarm management commands for coordinating parallel work on epics.
|
|
|
|
A swarm is a structured body of work defined by an epic and its children,
|
|
with dependencies forming a DAG (directed acyclic graph) of work.`,
|
|
}
|
|
|
|
// SwarmAnalysis holds the results of analyzing an epic's structure for swarming.
|
|
type SwarmAnalysis struct {
|
|
EpicID string `json:"epic_id"`
|
|
EpicTitle string `json:"epic_title"`
|
|
TotalIssues int `json:"total_issues"`
|
|
ClosedIssues int `json:"closed_issues"`
|
|
ReadyFronts []ReadyFront `json:"ready_fronts"`
|
|
MaxParallelism int `json:"max_parallelism"`
|
|
EstimatedSessions int `json:"estimated_sessions"`
|
|
Warnings []string `json:"warnings"`
|
|
Errors []string `json:"errors"`
|
|
Swarmable bool `json:"swarmable"`
|
|
Issues map[string]*IssueNode `json:"issues,omitempty"` // Only included with --verbose
|
|
}
|
|
|
|
// ReadyFront represents a group of issues that can be worked on in parallel.
|
|
type ReadyFront struct {
|
|
Wave int `json:"wave"`
|
|
Issues []string `json:"issues"`
|
|
Titles []string `json:"titles,omitempty"` // Only for human output
|
|
}
|
|
|
|
// IssueNode represents an issue in the dependency graph.
|
|
type IssueNode struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Status string `json:"status"`
|
|
Priority int `json:"priority"`
|
|
DependsOn []string `json:"depends_on"` // What this issue depends on
|
|
DependedOnBy []string `json:"depended_on_by"` // What depends on this issue
|
|
Wave int `json:"wave"` // Which ready front this belongs to (-1 if blocked by cycle)
|
|
}
|
|
|
|
// SwarmStorage defines the storage interface needed by swarm commands.
|
|
type SwarmStorage interface {
|
|
GetIssue(context.Context, string) (*types.Issue, error)
|
|
GetDependents(context.Context, string) ([]*types.Issue, error)
|
|
GetDependencyRecords(context.Context, string) ([]*types.Dependency, error)
|
|
}
|
|
|
|
// findExistingSwarm returns the swarm molecule for an epic, if one exists.
|
|
// Returns nil if no swarm molecule is linked to the epic.
|
|
func findExistingSwarm(ctx context.Context, s SwarmStorage, epicID string) (*types.Issue, error) {
|
|
// Get all issues that depend on the epic
|
|
dependents, err := s.GetDependents(ctx, epicID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get epic dependents: %w", err)
|
|
}
|
|
|
|
// Find a swarm molecule with relates-to dependency to this epic
|
|
for _, dep := range dependents {
|
|
// Only consider molecules (GetDependents doesn't populate mol_type, so we fetch full issue)
|
|
if dep.IssueType != "molecule" {
|
|
continue
|
|
}
|
|
|
|
// Get full issue to check mol_type
|
|
fullIssue, err := s.GetIssue(ctx, dep.ID)
|
|
if err != nil || fullIssue == nil {
|
|
continue
|
|
}
|
|
if fullIssue.MolType != types.MolTypeSwarm {
|
|
continue
|
|
}
|
|
|
|
// Verify it's linked via relates-to
|
|
deps, err := s.GetDependencyRecords(ctx, dep.ID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, d := range deps {
|
|
if d.DependsOnID == epicID && d.Type == types.DepRelatesTo {
|
|
return fullIssue, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
// getEpicChildren returns all child issues of an epic (via parent-child dependencies).
|
|
func getEpicChildren(ctx context.Context, s SwarmStorage, epicID string) ([]*types.Issue, error) {
|
|
// Get all issues that depend on the epic
|
|
allDependents, err := s.GetDependents(ctx, epicID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get epic dependents: %w", err)
|
|
}
|
|
|
|
// Filter to only parent-child relationships by checking each dependent's dependency records
|
|
var children []*types.Issue
|
|
for _, dependent := range allDependents {
|
|
deps, err := s.GetDependencyRecords(ctx, dependent.ID)
|
|
if err != nil {
|
|
continue // Skip issues we can't query
|
|
}
|
|
for _, dep := range deps {
|
|
if dep.DependsOnID == epicID && dep.Type == types.DepParentChild {
|
|
children = append(children, dependent)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return children, nil
|
|
}
|
|
|
|
var swarmValidateCmd = &cobra.Command{
|
|
Use: "validate [epic-id]",
|
|
Short: "Validate epic structure for swarming",
|
|
Long: `Validate an epic's structure to ensure it's ready for swarm execution.
|
|
|
|
Checks for:
|
|
- Correct dependency direction (requirement-based, not temporal)
|
|
- Orphaned issues (roots with no dependents)
|
|
- Missing dependencies (leaves that should depend on something)
|
|
- Cycles (impossible to resolve)
|
|
- Disconnected subgraphs
|
|
|
|
Reports:
|
|
- Ready fronts (waves of parallel work)
|
|
- Estimated worker-sessions
|
|
- Maximum parallelism
|
|
- Warnings for potential issues
|
|
|
|
Examples:
|
|
bd swarm validate gt-epic-123 # Validate epic structure
|
|
bd swarm validate gt-epic-123 --verbose # Include detailed issue graph`,
|
|
Args: cobra.ExactArgs(1),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
ctx := rootCtx
|
|
verbose, _ := cmd.Flags().GetBool("verbose")
|
|
|
|
// Swarm commands require direct store access
|
|
if store == nil {
|
|
if daemonClient != nil {
|
|
var err error
|
|
store, err = sqlite.New(ctx, dbPath)
|
|
if err != nil {
|
|
FatalErrorRespectJSON("failed to open database: %v", err)
|
|
}
|
|
defer func() { _ = store.Close() }()
|
|
} else {
|
|
FatalErrorRespectJSON("no database connection")
|
|
}
|
|
}
|
|
|
|
// Resolve epic ID
|
|
epicID, err := utils.ResolvePartialID(ctx, store, args[0])
|
|
if err != nil {
|
|
FatalErrorRespectJSON("epic '%s' not found: %v", args[0], err)
|
|
}
|
|
|
|
// Get the epic
|
|
epic, err := store.GetIssue(ctx, epicID)
|
|
if err != nil {
|
|
FatalErrorRespectJSON("failed to get epic: %v", err)
|
|
}
|
|
if epic == nil {
|
|
FatalErrorRespectJSON("epic '%s' not found", epicID)
|
|
}
|
|
|
|
// Verify it's an epic
|
|
if epic.IssueType != types.TypeEpic && epic.IssueType != "molecule" {
|
|
FatalErrorRespectJSON("'%s' is not an epic or molecule (type: %s)", epicID, epic.IssueType)
|
|
}
|
|
|
|
// Analyze the epic structure
|
|
analysis, err := analyzeEpicForSwarm(ctx, store, epic)
|
|
if err != nil {
|
|
FatalErrorRespectJSON("failed to analyze epic: %v", err)
|
|
}
|
|
|
|
// Include detailed graph only in verbose mode
|
|
if !verbose {
|
|
analysis.Issues = nil
|
|
}
|
|
|
|
if jsonOutput {
|
|
outputJSON(analysis)
|
|
if !analysis.Swarmable {
|
|
os.Exit(1)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Human-readable output
|
|
renderSwarmAnalysis(analysis)
|
|
|
|
if !analysis.Swarmable {
|
|
os.Exit(1)
|
|
}
|
|
},
|
|
}
|
|
|
|
// analyzeEpicForSwarm performs structural analysis of an epic for swarm execution.
|
|
func analyzeEpicForSwarm(ctx context.Context, s SwarmStorage, epic *types.Issue) (*SwarmAnalysis, error) {
|
|
analysis := &SwarmAnalysis{
|
|
EpicID: epic.ID,
|
|
EpicTitle: epic.Title,
|
|
Swarmable: true,
|
|
Issues: make(map[string]*IssueNode),
|
|
}
|
|
|
|
// Get all child issues of the epic
|
|
childIssues, err := getEpicChildren(ctx, s, epic.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(childIssues) == 0 {
|
|
analysis.Warnings = append(analysis.Warnings, "Epic has no children")
|
|
return analysis, nil
|
|
}
|
|
|
|
analysis.TotalIssues = len(childIssues)
|
|
|
|
// Build the issue graph
|
|
for _, issue := range childIssues {
|
|
node := &IssueNode{
|
|
ID: issue.ID,
|
|
Title: issue.Title,
|
|
Status: string(issue.Status),
|
|
Priority: issue.Priority,
|
|
DependsOn: []string{},
|
|
DependedOnBy: []string{},
|
|
Wave: -1, // Will be set later
|
|
}
|
|
analysis.Issues[issue.ID] = node
|
|
|
|
if issue.Status == types.StatusClosed {
|
|
analysis.ClosedIssues++
|
|
}
|
|
}
|
|
|
|
// Build dependency relationships (only within the epic's children)
|
|
childIDSet := make(map[string]bool)
|
|
for _, issue := range childIssues {
|
|
childIDSet[issue.ID] = true
|
|
}
|
|
|
|
for _, issue := range childIssues {
|
|
deps, err := s.GetDependencyRecords(ctx, issue.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get dependencies for %s: %w", issue.ID, err)
|
|
}
|
|
|
|
node := analysis.Issues[issue.ID]
|
|
for _, dep := range deps {
|
|
// Only consider dependencies within the epic (not parent-child to epic itself)
|
|
if dep.DependsOnID == epic.ID && dep.Type == types.DepParentChild {
|
|
continue // Skip the parent relationship to the epic
|
|
}
|
|
// Only track blocking dependencies
|
|
if !dep.Type.AffectsReadyWork() {
|
|
continue
|
|
}
|
|
// Only track dependencies within the epic's children
|
|
if childIDSet[dep.DependsOnID] {
|
|
node.DependsOn = append(node.DependsOn, dep.DependsOnID)
|
|
if targetNode, ok := analysis.Issues[dep.DependsOnID]; ok {
|
|
targetNode.DependedOnBy = append(targetNode.DependedOnBy, issue.ID)
|
|
}
|
|
}
|
|
// External dependencies to issues outside the epic
|
|
if !childIDSet[dep.DependsOnID] && dep.DependsOnID != epic.ID {
|
|
// Check if it's an external ref
|
|
if strings.HasPrefix(dep.DependsOnID, "external:") {
|
|
analysis.Warnings = append(analysis.Warnings,
|
|
fmt.Sprintf("%s has external dependency: %s", issue.ID, dep.DependsOnID))
|
|
} else {
|
|
analysis.Warnings = append(analysis.Warnings,
|
|
fmt.Sprintf("%s depends on %s (outside epic)", issue.ID, dep.DependsOnID))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Detect structural issues
|
|
detectStructuralIssues(analysis, childIssues)
|
|
|
|
// Compute ready fronts (waves of parallel work)
|
|
computeReadyFronts(analysis)
|
|
|
|
// Set swarmable based on errors
|
|
analysis.Swarmable = len(analysis.Errors) == 0
|
|
|
|
return analysis, nil
|
|
}
|
|
|
|
// detectStructuralIssues looks for common problems in the dependency graph.
|
|
//
|
|
//nolint:unparam // issues reserved for future use
|
|
func detectStructuralIssues(analysis *SwarmAnalysis, _ []*types.Issue) {
|
|
// 1. Find roots (issues with no dependencies within the epic)
|
|
// These are the starting points. Having multiple roots is normal.
|
|
var roots []string
|
|
for id, node := range analysis.Issues {
|
|
if len(node.DependsOn) == 0 {
|
|
roots = append(roots, id)
|
|
}
|
|
}
|
|
|
|
// 2. Find leaves (issues that nothing depends on within the epic)
|
|
// Multiple leaves might indicate missing dependencies or just multiple end points.
|
|
var leaves []string
|
|
for id, node := range analysis.Issues {
|
|
if len(node.DependedOnBy) == 0 {
|
|
leaves = append(leaves, id)
|
|
}
|
|
}
|
|
|
|
// 3. Detect potential dependency inversions
|
|
// Heuristic: If a "foundation" or "setup" issue has no dependents, it might be inverted.
|
|
// Heuristic: If an "integration" or "final" issue depends on nothing, it might be inverted.
|
|
for id, node := range analysis.Issues {
|
|
lowerTitle := strings.ToLower(node.Title)
|
|
|
|
// Foundation-like issues should have dependents
|
|
if len(node.DependedOnBy) == 0 {
|
|
if strings.Contains(lowerTitle, "foundation") ||
|
|
strings.Contains(lowerTitle, "setup") ||
|
|
strings.Contains(lowerTitle, "base") ||
|
|
strings.Contains(lowerTitle, "core") {
|
|
analysis.Warnings = append(analysis.Warnings,
|
|
fmt.Sprintf("%s (%s) has no dependents - should other issues depend on it?",
|
|
id, node.Title))
|
|
}
|
|
}
|
|
|
|
// Integration-like issues should have dependencies
|
|
if len(node.DependsOn) == 0 {
|
|
if strings.Contains(lowerTitle, "integration") ||
|
|
strings.Contains(lowerTitle, "final") ||
|
|
strings.Contains(lowerTitle, "test") {
|
|
analysis.Warnings = append(analysis.Warnings,
|
|
fmt.Sprintf("%s (%s) has no dependencies - should it depend on implementation?",
|
|
id, node.Title))
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Check for disconnected subgraphs
|
|
// Start from roots and see if we can reach all nodes
|
|
visited := make(map[string]bool)
|
|
var dfs func(id string)
|
|
dfs = func(id string) {
|
|
if visited[id] {
|
|
return
|
|
}
|
|
visited[id] = true
|
|
if node, ok := analysis.Issues[id]; ok {
|
|
for _, depID := range node.DependedOnBy {
|
|
dfs(depID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Visit from all roots
|
|
for _, root := range roots {
|
|
dfs(root)
|
|
}
|
|
|
|
// Check for unvisited nodes (disconnected from roots)
|
|
var disconnected []string
|
|
for id := range analysis.Issues {
|
|
if !visited[id] {
|
|
disconnected = append(disconnected, id)
|
|
}
|
|
}
|
|
|
|
if len(disconnected) > 0 {
|
|
analysis.Warnings = append(analysis.Warnings,
|
|
fmt.Sprintf("Disconnected issues (not reachable from roots): %v", disconnected))
|
|
}
|
|
|
|
// 5. Detect cycles using simple DFS
|
|
// (The main DetectCycles in storage is more sophisticated, but we do a simple check here)
|
|
inProgress := make(map[string]bool)
|
|
completed := make(map[string]bool)
|
|
var cyclePath []string
|
|
hasCycle := false
|
|
|
|
var detectCycle func(id string) bool
|
|
detectCycle = func(id string) bool {
|
|
if completed[id] {
|
|
return false
|
|
}
|
|
if inProgress[id] {
|
|
hasCycle = true
|
|
return true
|
|
}
|
|
inProgress[id] = true
|
|
cyclePath = append(cyclePath, id)
|
|
|
|
if node, ok := analysis.Issues[id]; ok {
|
|
for _, depID := range node.DependsOn {
|
|
if detectCycle(depID) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
cyclePath = cyclePath[:len(cyclePath)-1]
|
|
inProgress[id] = false
|
|
completed[id] = true
|
|
return false
|
|
}
|
|
|
|
for id := range analysis.Issues {
|
|
if !completed[id] {
|
|
if detectCycle(id) {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if hasCycle {
|
|
analysis.Errors = append(analysis.Errors,
|
|
fmt.Sprintf("Dependency cycle detected involving: %v", cyclePath))
|
|
}
|
|
}
|
|
|
|
// computeReadyFronts calculates the waves of parallel work.
|
|
func computeReadyFronts(analysis *SwarmAnalysis) {
|
|
if len(analysis.Errors) > 0 {
|
|
// Can't compute ready fronts if there are cycles
|
|
return
|
|
}
|
|
|
|
// Use Kahn's algorithm for topological sort with level tracking
|
|
inDegree := make(map[string]int)
|
|
for id, node := range analysis.Issues {
|
|
inDegree[id] = len(node.DependsOn)
|
|
}
|
|
|
|
// Start with all nodes that have no dependencies (wave 0)
|
|
var currentWave []string
|
|
for id, degree := range inDegree {
|
|
if degree == 0 {
|
|
currentWave = append(currentWave, id)
|
|
analysis.Issues[id].Wave = 0
|
|
}
|
|
}
|
|
|
|
wave := 0
|
|
for len(currentWave) > 0 {
|
|
// Sort for deterministic output
|
|
sort.Strings(currentWave)
|
|
|
|
// Build titles for this wave
|
|
var titles []string
|
|
for _, id := range currentWave {
|
|
if node, ok := analysis.Issues[id]; ok {
|
|
titles = append(titles, node.Title)
|
|
}
|
|
}
|
|
|
|
front := ReadyFront{
|
|
Wave: wave,
|
|
Issues: currentWave,
|
|
Titles: titles,
|
|
}
|
|
analysis.ReadyFronts = append(analysis.ReadyFronts, front)
|
|
|
|
// Track max parallelism
|
|
if len(currentWave) > analysis.MaxParallelism {
|
|
analysis.MaxParallelism = len(currentWave)
|
|
}
|
|
|
|
// Find next wave
|
|
var nextWave []string
|
|
for _, id := range currentWave {
|
|
if node, ok := analysis.Issues[id]; ok {
|
|
for _, dependentID := range node.DependedOnBy {
|
|
inDegree[dependentID]--
|
|
if inDegree[dependentID] == 0 {
|
|
nextWave = append(nextWave, dependentID)
|
|
analysis.Issues[dependentID].Wave = wave + 1
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
currentWave = nextWave
|
|
wave++
|
|
}
|
|
|
|
// Estimated sessions = total issues (each issue is roughly one session)
|
|
analysis.EstimatedSessions = analysis.TotalIssues
|
|
}
|
|
|
|
// renderSwarmAnalysis outputs human-readable analysis.
|
|
func renderSwarmAnalysis(analysis *SwarmAnalysis) {
|
|
fmt.Printf("\n%s Swarm Analysis: %s\n", ui.RenderAccent("🐝"), analysis.EpicTitle)
|
|
fmt.Printf(" Epic ID: %s\n", analysis.EpicID)
|
|
fmt.Printf(" Total issues: %d (%d closed)\n", analysis.TotalIssues, analysis.ClosedIssues)
|
|
|
|
if analysis.TotalIssues == 0 {
|
|
fmt.Printf("\n%s Epic has no children to swarm\n\n", ui.RenderWarn("⚠"))
|
|
return
|
|
}
|
|
|
|
// Ready fronts
|
|
if len(analysis.ReadyFronts) > 0 {
|
|
fmt.Printf("\n%s Ready Fronts (waves of parallel work):\n", ui.RenderPass("📊"))
|
|
for _, front := range analysis.ReadyFronts {
|
|
fmt.Printf(" Wave %d: %d issues\n", front.Wave+1, len(front.Issues))
|
|
for i, id := range front.Issues {
|
|
title := ""
|
|
if i < len(front.Titles) {
|
|
title = front.Titles[i]
|
|
}
|
|
fmt.Printf(" • %s: %s\n", ui.RenderID(id), title)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Summary stats
|
|
fmt.Printf("\n%s Summary:\n", ui.RenderAccent("📈"))
|
|
fmt.Printf(" Estimated worker-sessions: %d\n", analysis.EstimatedSessions)
|
|
fmt.Printf(" Max parallelism: %d\n", analysis.MaxParallelism)
|
|
fmt.Printf(" Total waves: %d\n", len(analysis.ReadyFronts))
|
|
|
|
// Warnings
|
|
if len(analysis.Warnings) > 0 {
|
|
fmt.Printf("\n%s Warnings:\n", ui.RenderWarn("⚠"))
|
|
for _, warning := range analysis.Warnings {
|
|
fmt.Printf(" • %s\n", warning)
|
|
}
|
|
}
|
|
|
|
// Errors
|
|
if len(analysis.Errors) > 0 {
|
|
fmt.Printf("\n%s Errors:\n", ui.RenderFail("❌"))
|
|
for _, err := range analysis.Errors {
|
|
fmt.Printf(" • %s\n", err)
|
|
}
|
|
}
|
|
|
|
// Final verdict
|
|
fmt.Println()
|
|
if analysis.Swarmable {
|
|
fmt.Printf("%s Swarmable: YES\n\n", ui.RenderPass("✓"))
|
|
} else {
|
|
fmt.Printf("%s Swarmable: NO (fix errors first)\n\n", ui.RenderFail("✗"))
|
|
}
|
|
}
|
|
|
|
// SwarmStatus holds the current status of a swarm (computed from beads).
|
|
type SwarmStatus struct {
|
|
EpicID string `json:"epic_id"`
|
|
EpicTitle string `json:"epic_title"`
|
|
TotalIssues int `json:"total_issues"`
|
|
Completed []StatusIssue `json:"completed"`
|
|
Active []StatusIssue `json:"active"`
|
|
Ready []StatusIssue `json:"ready"`
|
|
Blocked []StatusIssue `json:"blocked"`
|
|
Progress float64 `json:"progress_percent"`
|
|
ActiveCount int `json:"active_count"`
|
|
ReadyCount int `json:"ready_count"`
|
|
BlockedCount int `json:"blocked_count"`
|
|
}
|
|
|
|
// StatusIssue represents an issue in swarm status output.
|
|
type StatusIssue struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Assignee string `json:"assignee,omitempty"`
|
|
BlockedBy []string `json:"blocked_by,omitempty"`
|
|
ClosedAt string `json:"closed_at,omitempty"`
|
|
}
|
|
|
|
var swarmStatusCmd = &cobra.Command{
|
|
Use: "status [epic-or-swarm-id]",
|
|
Short: "Show current swarm status",
|
|
Long: `Show the current status of a swarm, computed from beads.
|
|
|
|
Accepts either:
|
|
- An epic ID (shows status for that epic's children)
|
|
- A swarm molecule ID (follows the link to find the epic)
|
|
|
|
Displays issues grouped by state:
|
|
- Completed: Closed issues
|
|
- Active: Issues currently in_progress (with assignee)
|
|
- Ready: Open issues with all dependencies satisfied
|
|
- Blocked: Open issues waiting on dependencies
|
|
|
|
The status is COMPUTED from beads, not stored separately.
|
|
If beads changes, status changes.
|
|
|
|
Examples:
|
|
bd swarm status gt-epic-123 # Show swarm status by epic
|
|
bd swarm status gt-swarm-456 # Show status via swarm molecule
|
|
bd swarm status gt-epic-123 --json # Machine-readable output`,
|
|
Args: cobra.ExactArgs(1),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
ctx := rootCtx
|
|
|
|
// Swarm commands require direct store access
|
|
if store == nil {
|
|
if daemonClient != nil {
|
|
var err error
|
|
store, err = sqlite.New(ctx, dbPath)
|
|
if err != nil {
|
|
FatalErrorRespectJSON("failed to open database: %v", err)
|
|
}
|
|
defer func() { _ = store.Close() }()
|
|
} else {
|
|
FatalErrorRespectJSON("no database connection")
|
|
}
|
|
}
|
|
|
|
// Resolve ID
|
|
issueID, err := utils.ResolvePartialID(ctx, store, args[0])
|
|
if err != nil {
|
|
FatalErrorRespectJSON("issue '%s' not found: %v", args[0], err)
|
|
}
|
|
|
|
// Get the issue
|
|
issue, err := store.GetIssue(ctx, issueID)
|
|
if err != nil {
|
|
FatalErrorRespectJSON("failed to get issue: %v", err)
|
|
}
|
|
if issue == nil {
|
|
FatalErrorRespectJSON("issue '%s' not found", issueID)
|
|
}
|
|
|
|
var epic *types.Issue
|
|
|
|
// Check if it's a swarm molecule - if so, follow the link to the epic
|
|
if issue.IssueType == "molecule" && issue.MolType == types.MolTypeSwarm {
|
|
// Find linked epic via relates-to dependency
|
|
deps, err := store.GetDependencyRecords(ctx, issue.ID)
|
|
if err != nil {
|
|
FatalErrorRespectJSON("failed to get swarm dependencies: %v", err)
|
|
}
|
|
for _, dep := range deps {
|
|
if dep.Type == types.DepRelatesTo {
|
|
epic, err = store.GetIssue(ctx, dep.DependsOnID)
|
|
if err != nil {
|
|
FatalErrorRespectJSON("failed to get linked epic: %v", err)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
if epic == nil {
|
|
FatalErrorRespectJSON("swarm molecule '%s' has no linked epic", issueID)
|
|
}
|
|
} else if issue.IssueType == types.TypeEpic || issue.IssueType == "molecule" {
|
|
epic = issue
|
|
} else {
|
|
FatalErrorRespectJSON("'%s' is not an epic or swarm molecule (type: %s)", issueID, issue.IssueType)
|
|
}
|
|
|
|
// Get swarm status
|
|
status, err := getSwarmStatus(ctx, store, epic)
|
|
if err != nil {
|
|
FatalErrorRespectJSON("failed to get swarm status: %v", err)
|
|
}
|
|
|
|
if jsonOutput {
|
|
outputJSON(status)
|
|
return
|
|
}
|
|
|
|
// Human-readable output
|
|
renderSwarmStatus(status)
|
|
},
|
|
}
|
|
|
|
// getSwarmStatus computes current swarm status from beads.
|
|
func getSwarmStatus(ctx context.Context, s SwarmStorage, epic *types.Issue) (*SwarmStatus, error) {
|
|
status := &SwarmStatus{
|
|
EpicID: epic.ID,
|
|
EpicTitle: epic.Title,
|
|
Completed: []StatusIssue{},
|
|
Active: []StatusIssue{},
|
|
Ready: []StatusIssue{},
|
|
Blocked: []StatusIssue{},
|
|
}
|
|
|
|
// Get all child issues of the epic
|
|
childIssues, err := getEpicChildren(ctx, s, epic.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
status.TotalIssues = len(childIssues)
|
|
if len(childIssues) == 0 {
|
|
return status, nil
|
|
}
|
|
|
|
// Build set of child IDs for filtering
|
|
childIDSet := make(map[string]bool)
|
|
for _, issue := range childIssues {
|
|
childIDSet[issue.ID] = true
|
|
}
|
|
|
|
// Build dependency map (within epic children only)
|
|
dependsOn := make(map[string][]string)
|
|
for _, issue := range childIssues {
|
|
deps, err := s.GetDependencyRecords(ctx, issue.ID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, dep := range deps {
|
|
// Skip parent-child to epic itself
|
|
if dep.DependsOnID == epic.ID && dep.Type == types.DepParentChild {
|
|
continue
|
|
}
|
|
// Only track blocking dependencies within children
|
|
if !dep.Type.AffectsReadyWork() {
|
|
continue
|
|
}
|
|
if childIDSet[dep.DependsOnID] {
|
|
dependsOn[issue.ID] = append(dependsOn[issue.ID], dep.DependsOnID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Categorize each issue
|
|
for _, issue := range childIssues {
|
|
si := StatusIssue{
|
|
ID: issue.ID,
|
|
Title: issue.Title,
|
|
Assignee: issue.Assignee,
|
|
}
|
|
|
|
switch issue.Status {
|
|
case types.StatusClosed:
|
|
if issue.ClosedAt != nil {
|
|
si.ClosedAt = issue.ClosedAt.Format("2006-01-02 15:04")
|
|
}
|
|
status.Completed = append(status.Completed, si)
|
|
|
|
case types.StatusInProgress:
|
|
status.Active = append(status.Active, si)
|
|
|
|
default: // open or other
|
|
// Check if blocked by open dependencies
|
|
deps := dependsOn[issue.ID]
|
|
var blockers []string
|
|
for _, depID := range deps {
|
|
depIssue, _ := s.GetIssue(ctx, depID)
|
|
if depIssue != nil && depIssue.Status != types.StatusClosed {
|
|
blockers = append(blockers, depID)
|
|
}
|
|
}
|
|
|
|
if len(blockers) > 0 {
|
|
si.BlockedBy = blockers
|
|
status.Blocked = append(status.Blocked, si)
|
|
} else {
|
|
status.Ready = append(status.Ready, si)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort each category by ID for consistent output
|
|
sort.Slice(status.Completed, func(i, j int) bool {
|
|
return status.Completed[i].ID < status.Completed[j].ID
|
|
})
|
|
sort.Slice(status.Active, func(i, j int) bool {
|
|
return status.Active[i].ID < status.Active[j].ID
|
|
})
|
|
sort.Slice(status.Ready, func(i, j int) bool {
|
|
return status.Ready[i].ID < status.Ready[j].ID
|
|
})
|
|
sort.Slice(status.Blocked, func(i, j int) bool {
|
|
return status.Blocked[i].ID < status.Blocked[j].ID
|
|
})
|
|
|
|
// Compute counts and progress
|
|
status.ActiveCount = len(status.Active)
|
|
status.ReadyCount = len(status.Ready)
|
|
status.BlockedCount = len(status.Blocked)
|
|
if status.TotalIssues > 0 {
|
|
status.Progress = float64(len(status.Completed)) / float64(status.TotalIssues) * 100
|
|
}
|
|
|
|
return status, nil
|
|
}
|
|
|
|
// renderSwarmStatus outputs human-readable swarm status.
|
|
func renderSwarmStatus(status *SwarmStatus) {
|
|
fmt.Printf("\n%s Ready Front Analysis: %s\n\n", ui.RenderAccent("🐝"), status.EpicTitle)
|
|
|
|
// Completed
|
|
fmt.Printf("Completed: ")
|
|
if len(status.Completed) == 0 {
|
|
fmt.Printf("(none)\n")
|
|
} else {
|
|
for i, issue := range status.Completed {
|
|
if i > 0 {
|
|
fmt.Printf(" ")
|
|
}
|
|
fmt.Printf("%s %s\n", ui.RenderPass("✓"), ui.RenderID(issue.ID))
|
|
}
|
|
}
|
|
|
|
// Active
|
|
fmt.Printf("Active: ")
|
|
if len(status.Active) == 0 {
|
|
fmt.Printf("(none)\n")
|
|
} else {
|
|
var parts []string
|
|
for _, issue := range status.Active {
|
|
part := fmt.Sprintf("⟳ %s", issue.ID)
|
|
if issue.Assignee != "" {
|
|
part += fmt.Sprintf(" [%s]", issue.Assignee)
|
|
}
|
|
parts = append(parts, part)
|
|
}
|
|
fmt.Printf("%s\n", strings.Join(parts, ", "))
|
|
}
|
|
|
|
// Ready
|
|
fmt.Printf("Ready: ")
|
|
if len(status.Ready) == 0 {
|
|
if len(status.Blocked) > 0 {
|
|
// Find what's blocking
|
|
needed := make(map[string]bool)
|
|
for _, b := range status.Blocked {
|
|
for _, dep := range b.BlockedBy {
|
|
needed[dep] = true
|
|
}
|
|
}
|
|
var neededList []string
|
|
for dep := range needed {
|
|
neededList = append(neededList, dep)
|
|
}
|
|
sort.Strings(neededList)
|
|
fmt.Printf("(none - waiting for %s)\n", strings.Join(neededList, ", "))
|
|
} else {
|
|
fmt.Printf("(none)\n")
|
|
}
|
|
} else {
|
|
var parts []string
|
|
for _, issue := range status.Ready {
|
|
parts = append(parts, fmt.Sprintf("○ %s", issue.ID))
|
|
}
|
|
fmt.Printf("%s\n", strings.Join(parts, ", "))
|
|
}
|
|
|
|
// Blocked
|
|
fmt.Printf("Blocked: ")
|
|
if len(status.Blocked) == 0 {
|
|
fmt.Printf("(none)\n")
|
|
} else {
|
|
for i, issue := range status.Blocked {
|
|
if i > 0 {
|
|
fmt.Printf(" ")
|
|
}
|
|
blockerStr := strings.Join(issue.BlockedBy, ", ")
|
|
fmt.Printf("◌ %s (needs %s)\n", issue.ID, blockerStr)
|
|
}
|
|
}
|
|
|
|
// Progress summary
|
|
fmt.Printf("\nProgress: %d/%d complete", len(status.Completed), status.TotalIssues)
|
|
if status.ActiveCount > 0 {
|
|
fmt.Printf(", %d/%d active", status.ActiveCount, status.TotalIssues)
|
|
}
|
|
fmt.Printf(" (%.0f%%)\n\n", status.Progress)
|
|
}
|
|
|
|
var swarmCreateCmd = &cobra.Command{
|
|
Use: "create [epic-id]",
|
|
Short: "Create a swarm molecule from an epic",
|
|
Long: `Create a swarm molecule to orchestrate parallel work on an epic.
|
|
|
|
The swarm molecule:
|
|
- Links to the epic it orchestrates
|
|
- Has mol_type=swarm for discovery
|
|
- Specifies a coordinator (optional)
|
|
- Can be picked up by any coordinator agent
|
|
|
|
If given a single issue (not an epic), it will be auto-wrapped:
|
|
- Creates an epic with that issue as its only child
|
|
- Then creates the swarm molecule for that epic
|
|
|
|
Examples:
|
|
bd swarm create gt-epic-123 # Create swarm for epic
|
|
bd swarm create gt-epic-123 --coordinator=witness/ # With specific coordinator
|
|
bd swarm create gt-task-456 # Auto-wrap single issue`,
|
|
Args: cobra.ExactArgs(1),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
CheckReadonly("swarm create")
|
|
ctx := rootCtx
|
|
coordinator, _ := cmd.Flags().GetString("coordinator")
|
|
force, _ := cmd.Flags().GetBool("force")
|
|
|
|
// Swarm commands require direct store access
|
|
if store == nil {
|
|
if daemonClient != nil {
|
|
var err error
|
|
store, err = sqlite.New(ctx, dbPath)
|
|
if err != nil {
|
|
FatalErrorRespectJSON("failed to open database: %v", err)
|
|
}
|
|
defer func() { _ = store.Close() }()
|
|
} else {
|
|
FatalErrorRespectJSON("no database connection")
|
|
}
|
|
}
|
|
|
|
// Resolve the input ID
|
|
inputID, err := utils.ResolvePartialID(ctx, store, args[0])
|
|
if err != nil {
|
|
FatalErrorRespectJSON("issue '%s' not found: %v", args[0], err)
|
|
}
|
|
|
|
// Get the issue
|
|
issue, err := store.GetIssue(ctx, inputID)
|
|
if err != nil {
|
|
FatalErrorRespectJSON("failed to get issue: %v", err)
|
|
}
|
|
if issue == nil {
|
|
FatalErrorRespectJSON("issue '%s' not found", inputID)
|
|
}
|
|
|
|
var epicID string
|
|
var epicTitle string
|
|
|
|
// Check if it's an epic or single issue that needs wrapping
|
|
if issue.IssueType == types.TypeEpic || issue.IssueType == "molecule" {
|
|
epicID = issue.ID
|
|
epicTitle = issue.Title
|
|
} else {
|
|
// Auto-wrap: create an epic with this issue as child
|
|
if !jsonOutput {
|
|
fmt.Printf("Auto-wrapping single issue as epic...\n")
|
|
}
|
|
|
|
wrapperEpic := &types.Issue{
|
|
Title: fmt.Sprintf("Swarm Epic: %s", issue.Title),
|
|
Description: fmt.Sprintf("Auto-generated epic to wrap single issue %s for swarm execution.", issue.ID),
|
|
Status: types.StatusOpen,
|
|
Priority: issue.Priority,
|
|
IssueType: types.TypeEpic,
|
|
CreatedBy: actor,
|
|
}
|
|
|
|
if err := store.CreateIssue(ctx, wrapperEpic, actor); err != nil {
|
|
FatalErrorRespectJSON("failed to create wrapper epic: %v", err)
|
|
}
|
|
|
|
// Add parent-child dependency: issue depends on epic (epic is parent)
|
|
dep := &types.Dependency{
|
|
IssueID: issue.ID,
|
|
DependsOnID: wrapperEpic.ID,
|
|
Type: types.DepParentChild,
|
|
CreatedBy: actor,
|
|
}
|
|
if err := store.AddDependency(ctx, dep, actor); err != nil {
|
|
FatalErrorRespectJSON("failed to link issue to epic: %v", err)
|
|
}
|
|
|
|
epicID = wrapperEpic.ID
|
|
epicTitle = wrapperEpic.Title
|
|
|
|
if !jsonOutput {
|
|
fmt.Printf("Created wrapper epic: %s\n", epicID)
|
|
}
|
|
}
|
|
|
|
// Check for existing swarm molecule
|
|
existingSwarm, err := findExistingSwarm(ctx, store, epicID)
|
|
if err != nil {
|
|
FatalErrorRespectJSON("failed to check for existing swarm: %v", err)
|
|
}
|
|
if existingSwarm != nil && !force {
|
|
if jsonOutput {
|
|
outputJSON(map[string]interface{}{
|
|
"error": "swarm already exists",
|
|
"existing_id": existingSwarm.ID,
|
|
"existing_title": existingSwarm.Title,
|
|
})
|
|
} else {
|
|
fmt.Printf("%s Swarm already exists: %s\n", ui.RenderWarn("⚠"), ui.RenderID(existingSwarm.ID))
|
|
fmt.Printf(" Use --force to create another.\n")
|
|
}
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Validate the epic structure
|
|
epic, err := store.GetIssue(ctx, epicID)
|
|
if err != nil {
|
|
FatalErrorRespectJSON("failed to get epic: %v", err)
|
|
}
|
|
|
|
analysis, err := analyzeEpicForSwarm(ctx, store, epic)
|
|
if err != nil {
|
|
FatalErrorRespectJSON("failed to analyze epic: %v", err)
|
|
}
|
|
|
|
if !analysis.Swarmable {
|
|
if jsonOutput {
|
|
outputJSON(map[string]interface{}{
|
|
"error": "epic is not swarmable",
|
|
"analysis": analysis,
|
|
})
|
|
} else {
|
|
fmt.Printf("\n%s Epic is not swarmable. Fix errors first:\n", ui.RenderFail("✗"))
|
|
for _, e := range analysis.Errors {
|
|
fmt.Printf(" • %s\n", e)
|
|
}
|
|
}
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Create the swarm molecule
|
|
swarmMol := &types.Issue{
|
|
Title: fmt.Sprintf("Swarm: %s", epicTitle),
|
|
Description: fmt.Sprintf("Swarm molecule orchestrating epic %s.\n\nEpic: %s\nCoordinator: %s", epicID, epicID, coordinator),
|
|
Status: types.StatusOpen,
|
|
Priority: epic.Priority,
|
|
IssueType: "molecule",
|
|
MolType: types.MolTypeSwarm,
|
|
Assignee: coordinator,
|
|
CreatedBy: actor,
|
|
}
|
|
|
|
if err := store.CreateIssue(ctx, swarmMol, actor); err != nil {
|
|
FatalErrorRespectJSON("failed to create swarm molecule: %v", err)
|
|
}
|
|
|
|
// Link swarm molecule to epic with relates-to dependency
|
|
dep := &types.Dependency{
|
|
IssueID: swarmMol.ID,
|
|
DependsOnID: epicID,
|
|
Type: types.DepRelatesTo,
|
|
CreatedBy: actor,
|
|
}
|
|
if err := store.AddDependency(ctx, dep, actor); err != nil {
|
|
FatalErrorRespectJSON("failed to link swarm to epic: %v", err)
|
|
}
|
|
|
|
if jsonOutput {
|
|
outputJSON(map[string]interface{}{
|
|
"swarm_id": swarmMol.ID,
|
|
"epic_id": epicID,
|
|
"coordinator": coordinator,
|
|
"analysis": analysis,
|
|
})
|
|
} else {
|
|
fmt.Printf("\n%s Created swarm molecule: %s\n", ui.RenderPass("✓"), ui.RenderID(swarmMol.ID))
|
|
fmt.Printf(" Epic: %s (%s)\n", epicID, epicTitle)
|
|
if coordinator != "" {
|
|
fmt.Printf(" Coordinator: %s\n", coordinator)
|
|
}
|
|
fmt.Printf(" Total issues: %d\n", analysis.TotalIssues)
|
|
fmt.Printf(" Max parallelism: %d\n", analysis.MaxParallelism)
|
|
fmt.Printf(" Waves: %d\n", len(analysis.ReadyFronts))
|
|
}
|
|
},
|
|
}
|
|
|
|
var swarmListCmd = &cobra.Command{
|
|
Use: "list",
|
|
Short: "List all swarm molecules",
|
|
Long: `List all swarm molecules with their status.
|
|
|
|
Shows each swarm molecule with:
|
|
- Progress (completed/total issues)
|
|
- Active workers
|
|
- Epic ID and title
|
|
|
|
Examples:
|
|
bd swarm list # List all swarms
|
|
bd swarm list --json # Machine-readable output`,
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
ctx := rootCtx
|
|
|
|
// Swarm commands require direct store access
|
|
if store == nil {
|
|
if daemonClient != nil {
|
|
var err error
|
|
store, err = sqlite.New(ctx, dbPath)
|
|
if err != nil {
|
|
FatalErrorRespectJSON("failed to open database: %v", err)
|
|
}
|
|
defer func() { _ = store.Close() }()
|
|
} else {
|
|
FatalErrorRespectJSON("no database connection")
|
|
}
|
|
}
|
|
|
|
// Query for all swarm molecules
|
|
swarmType := types.MolTypeSwarm
|
|
filter := types.IssueFilter{
|
|
MolType: &swarmType,
|
|
}
|
|
swarms, err := store.SearchIssues(ctx, "", filter)
|
|
if err != nil {
|
|
FatalErrorRespectJSON("failed to list swarms: %v", err)
|
|
}
|
|
|
|
if len(swarms) == 0 {
|
|
if jsonOutput {
|
|
outputJSON(map[string]interface{}{"swarms": []interface{}{}})
|
|
} else {
|
|
fmt.Printf("No swarm molecules found.\n")
|
|
}
|
|
return
|
|
}
|
|
|
|
// Build output with status for each swarm
|
|
type SwarmListItem struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
EpicID string `json:"epic_id"`
|
|
EpicTitle string `json:"epic_title"`
|
|
Status string `json:"status"`
|
|
Coordinator string `json:"coordinator"`
|
|
Total int `json:"total_issues"`
|
|
Completed int `json:"completed_issues"`
|
|
Active int `json:"active_issues"`
|
|
Progress float64 `json:"progress_percent"`
|
|
}
|
|
|
|
var items []SwarmListItem
|
|
for _, swarm := range swarms {
|
|
item := SwarmListItem{
|
|
ID: swarm.ID,
|
|
Title: swarm.Title,
|
|
Status: string(swarm.Status),
|
|
Coordinator: swarm.Assignee,
|
|
}
|
|
|
|
// Find linked epic via relates-to dependency
|
|
deps, err := store.GetDependencyRecords(ctx, swarm.ID)
|
|
if err == nil {
|
|
for _, dep := range deps {
|
|
if dep.Type == types.DepRelatesTo {
|
|
item.EpicID = dep.DependsOnID
|
|
epic, err := store.GetIssue(ctx, dep.DependsOnID)
|
|
if err == nil && epic != nil {
|
|
item.EpicTitle = epic.Title
|
|
// Get swarm status for this epic
|
|
status, err := getSwarmStatus(ctx, store, epic)
|
|
if err == nil {
|
|
item.Total = status.TotalIssues
|
|
item.Completed = len(status.Completed)
|
|
item.Active = status.ActiveCount
|
|
item.Progress = status.Progress
|
|
}
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
items = append(items, item)
|
|
}
|
|
|
|
if jsonOutput {
|
|
outputJSON(map[string]interface{}{"swarms": items})
|
|
return
|
|
}
|
|
|
|
// Human-readable output
|
|
fmt.Printf("\n%s Active Swarms (%d)\n\n", ui.RenderAccent("🐝"), len(items))
|
|
for _, item := range items {
|
|
// Progress indicator
|
|
progressStr := fmt.Sprintf("%d/%d", item.Completed, item.Total)
|
|
if item.Active > 0 {
|
|
progressStr += fmt.Sprintf(", %d active", item.Active)
|
|
}
|
|
|
|
fmt.Printf("%s %s\n", ui.RenderID(item.ID), item.Title)
|
|
if item.EpicID != "" {
|
|
fmt.Printf(" Epic: %s (%s)\n", item.EpicID, item.EpicTitle)
|
|
}
|
|
fmt.Printf(" Progress: %s (%.0f%%)\n", progressStr, item.Progress)
|
|
if item.Coordinator != "" {
|
|
fmt.Printf(" Coordinator: %s\n", item.Coordinator)
|
|
}
|
|
fmt.Println()
|
|
}
|
|
},
|
|
}
|
|
|
|
func init() {
|
|
swarmValidateCmd.Flags().Bool("verbose", false, "Include detailed issue graph in output")
|
|
swarmCreateCmd.Flags().String("coordinator", "", "Coordinator address (e.g., gastown/witness)")
|
|
swarmCreateCmd.Flags().Bool("force", false, "Create new swarm even if one already exists")
|
|
|
|
swarmCmd.AddCommand(swarmValidateCmd)
|
|
swarmCmd.AddCommand(swarmStatusCmd)
|
|
swarmCmd.AddCommand(swarmCreateCmd)
|
|
swarmCmd.AddCommand(swarmListCmd)
|
|
rootCmd.AddCommand(swarmCmd)
|
|
}
|