Fixes CI lint failures by adding appropriate nolint directives for: - G204 (subprocess with variable) - git commands with trusted inputs - G304 (file inclusion via variable) - paths from internal helpers - G302/G306 (file permissions) - .gitignore needs 0644 - errcheck (unchecked return values) - fmt.Fprint* to stdout/stderr - unparam (unused parameters) - reserved for future use
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 != types.TypeMolecule {
|
|
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 != types.TypeMolecule {
|
|
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 == types.TypeMolecule && 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 == types.TypeMolecule {
|
|
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 == types.TypeMolecule {
|
|
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: types.TypeMolecule,
|
|
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)
|
|
}
|