feat: swarm commands improvements (bd-2ubv)
- Add findExistingSwarm helper and duplicate swarm check with --force flag - Add bd swarm list command for discovering swarm molecules with progress stats - Fix empty coordinator display in swarm create output - Allow swarm status to accept swarm molecule ID (follows relates-to link) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
386
cmd/bd/swarm.go
386
cmd/bd/swarm.go
@@ -58,30 +58,55 @@ type IssueNode struct {
|
|||||||
Wave int `json:"wave"` // Which ready front this belongs to (-1 if blocked by cycle)
|
Wave int `json:"wave"` // Which ready front this belongs to (-1 if blocked by cycle)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EpicChildren holds the result of fetching an epic's children and their dependencies.
|
// SwarmStorage defines the storage interface needed by swarm commands.
|
||||||
type EpicChildren struct {
|
type SwarmStorage interface {
|
||||||
Children []*types.Issue // Child issues of the epic
|
|
||||||
ChildIDSet map[string]bool // Set of child IDs for fast lookup
|
|
||||||
DependsOn map[string][]string // Map of issue ID -> IDs it depends on (within epic)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SwarmStore defines the interface needed for swarm operations.
|
|
||||||
type SwarmStore interface {
|
|
||||||
GetIssue(context.Context, string) (*types.Issue, error)
|
GetIssue(context.Context, string) (*types.Issue, error)
|
||||||
GetDependents(context.Context, string) ([]*types.Issue, error)
|
GetDependents(context.Context, string) ([]*types.Issue, error)
|
||||||
GetDependencyRecords(context.Context, string) ([]*types.Dependency, error)
|
GetDependencyRecords(context.Context, string) ([]*types.Dependency, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getEpicChildren fetches all children of an epic and builds dependency maps.
|
// findExistingSwarm returns the swarm molecule for an epic, if one exists.
|
||||||
// It filters to only parent-child relationships and only tracks blocking dependencies
|
// Returns nil if no swarm molecule is linked to the epic.
|
||||||
// within the epic's children.
|
func findExistingSwarm(ctx context.Context, s SwarmStorage, epicID string) (*types.Issue, error) {
|
||||||
func getEpicChildren(ctx context.Context, s SwarmStore, epicID string) (*EpicChildren, error) {
|
// Get all issues that depend on the epic
|
||||||
result := &EpicChildren{
|
dependents, err := s.GetDependents(ctx, epicID)
|
||||||
Children: []*types.Issue{},
|
if err != nil {
|
||||||
ChildIDSet: make(map[string]bool),
|
return nil, fmt.Errorf("failed to get epic dependents: %w", err)
|
||||||
DependsOn: make(map[string][]string),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Get all issues that depend on the epic
|
||||||
allDependents, err := s.GetDependents(ctx, epicID)
|
allDependents, err := s.GetDependents(ctx, epicID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -89,6 +114,7 @@ func getEpicChildren(ctx context.Context, s SwarmStore, epicID string) (*EpicChi
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Filter to only parent-child relationships by checking each dependent's dependency records
|
// Filter to only parent-child relationships by checking each dependent's dependency records
|
||||||
|
var children []*types.Issue
|
||||||
for _, dependent := range allDependents {
|
for _, dependent := range allDependents {
|
||||||
deps, err := s.GetDependencyRecords(ctx, dependent.ID)
|
deps, err := s.GetDependencyRecords(ctx, dependent.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -96,35 +122,13 @@ func getEpicChildren(ctx context.Context, s SwarmStore, epicID string) (*EpicChi
|
|||||||
}
|
}
|
||||||
for _, dep := range deps {
|
for _, dep := range deps {
|
||||||
if dep.DependsOnID == epicID && dep.Type == types.DepParentChild {
|
if dep.DependsOnID == epicID && dep.Type == types.DepParentChild {
|
||||||
result.Children = append(result.Children, dependent)
|
children = append(children, dependent)
|
||||||
result.ChildIDSet[dependent.ID] = true
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build dependency map (blocking dependencies within epic children only)
|
return children, nil
|
||||||
for _, issue := range result.Children {
|
|
||||||
deps, err := s.GetDependencyRecords(ctx, issue.ID)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, dep := range deps {
|
|
||||||
// Skip parent-child to epic itself
|
|
||||||
if dep.DependsOnID == epicID && dep.Type == types.DepParentChild {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Only track blocking dependencies within children
|
|
||||||
if !dep.Type.AffectsReadyWork() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if result.ChildIDSet[dep.DependsOnID] {
|
|
||||||
result.DependsOn[issue.ID] = append(result.DependsOn[issue.ID], dep.DependsOnID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var swarmValidateCmd = &cobra.Command{
|
var swarmValidateCmd = &cobra.Command{
|
||||||
@@ -216,7 +220,7 @@ Examples:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// analyzeEpicForSwarm performs structural analysis of an epic for swarm execution.
|
// analyzeEpicForSwarm performs structural analysis of an epic for swarm execution.
|
||||||
func analyzeEpicForSwarm(ctx context.Context, s SwarmStore, epic *types.Issue) (*SwarmAnalysis, error) {
|
func analyzeEpicForSwarm(ctx context.Context, s SwarmStorage, epic *types.Issue) (*SwarmAnalysis, error) {
|
||||||
analysis := &SwarmAnalysis{
|
analysis := &SwarmAnalysis{
|
||||||
EpicID: epic.ID,
|
EpicID: epic.ID,
|
||||||
EpicTitle: epic.Title,
|
EpicTitle: epic.Title,
|
||||||
@@ -224,33 +228,30 @@ func analyzeEpicForSwarm(ctx context.Context, s SwarmStore, epic *types.Issue) (
|
|||||||
Issues: make(map[string]*IssueNode),
|
Issues: make(map[string]*IssueNode),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get children and dependency map using shared helper
|
// Get all child issues of the epic
|
||||||
epicChildren, err := getEpicChildren(ctx, s, epic.ID)
|
childIssues, err := getEpicChildren(ctx, s, epic.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(epicChildren.Children) == 0 {
|
if len(childIssues) == 0 {
|
||||||
analysis.Warnings = append(analysis.Warnings, "Epic has no children")
|
analysis.Warnings = append(analysis.Warnings, "Epic has no children")
|
||||||
return analysis, nil
|
return analysis, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
analysis.TotalIssues = len(epicChildren.Children)
|
analysis.TotalIssues = len(childIssues)
|
||||||
|
|
||||||
// Build the issue graph with nodes
|
// Build the issue graph
|
||||||
for _, issue := range epicChildren.Children {
|
for _, issue := range childIssues {
|
||||||
node := &IssueNode{
|
node := &IssueNode{
|
||||||
ID: issue.ID,
|
ID: issue.ID,
|
||||||
Title: issue.Title,
|
Title: issue.Title,
|
||||||
Status: string(issue.Status),
|
Status: string(issue.Status),
|
||||||
Priority: issue.Priority,
|
Priority: issue.Priority,
|
||||||
DependsOn: epicChildren.DependsOn[issue.ID], // Use pre-computed deps
|
DependsOn: []string{},
|
||||||
DependedOnBy: []string{},
|
DependedOnBy: []string{},
|
||||||
Wave: -1, // Will be set later
|
Wave: -1, // Will be set later
|
||||||
}
|
}
|
||||||
if node.DependsOn == nil {
|
|
||||||
node.DependsOn = []string{}
|
|
||||||
}
|
|
||||||
analysis.Issues[issue.ID] = node
|
analysis.Issues[issue.ID] = node
|
||||||
|
|
||||||
if issue.Status == types.StatusClosed {
|
if issue.Status == types.StatusClosed {
|
||||||
@@ -258,30 +259,38 @@ func analyzeEpicForSwarm(ctx context.Context, s SwarmStore, epic *types.Issue) (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build reverse dependency map (DependedOnBy) and check for external deps
|
// Build dependency relationships (only within the epic's children)
|
||||||
for _, issue := range epicChildren.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)
|
deps, err := s.GetDependencyRecords(ctx, issue.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
return nil, fmt.Errorf("failed to get dependencies for %s: %w", issue.ID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
node := analysis.Issues[issue.ID]
|
||||||
for _, dep := range deps {
|
for _, dep := range deps {
|
||||||
// Skip parent-child to epic itself
|
// Only consider dependencies within the epic (not parent-child to epic itself)
|
||||||
if dep.DependsOnID == epic.ID && dep.Type == types.DepParentChild {
|
if dep.DependsOnID == epic.ID && dep.Type == types.DepParentChild {
|
||||||
continue
|
continue // Skip the parent relationship to the epic
|
||||||
}
|
}
|
||||||
// Only track blocking dependencies
|
// Only track blocking dependencies
|
||||||
if !dep.Type.AffectsReadyWork() {
|
if !dep.Type.AffectsReadyWork() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Build DependedOnBy for internal deps
|
// Only track dependencies within the epic's children
|
||||||
if epicChildren.ChildIDSet[dep.DependsOnID] {
|
if childIDSet[dep.DependsOnID] {
|
||||||
|
node.DependsOn = append(node.DependsOn, dep.DependsOnID)
|
||||||
if targetNode, ok := analysis.Issues[dep.DependsOnID]; ok {
|
if targetNode, ok := analysis.Issues[dep.DependsOnID]; ok {
|
||||||
targetNode.DependedOnBy = append(targetNode.DependedOnBy, issue.ID)
|
targetNode.DependedOnBy = append(targetNode.DependedOnBy, issue.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Warn about external dependencies
|
// External dependencies to issues outside the epic
|
||||||
if !epicChildren.ChildIDSet[dep.DependsOnID] && dep.DependsOnID != epic.ID {
|
if !childIDSet[dep.DependsOnID] && dep.DependsOnID != epic.ID {
|
||||||
|
// Check if it's an external ref
|
||||||
if strings.HasPrefix(dep.DependsOnID, "external:") {
|
if strings.HasPrefix(dep.DependsOnID, "external:") {
|
||||||
analysis.Warnings = append(analysis.Warnings,
|
analysis.Warnings = append(analysis.Warnings,
|
||||||
fmt.Sprintf("%s has external dependency: %s", issue.ID, dep.DependsOnID))
|
fmt.Sprintf("%s has external dependency: %s", issue.ID, dep.DependsOnID))
|
||||||
@@ -294,7 +303,7 @@ func analyzeEpicForSwarm(ctx context.Context, s SwarmStore, epic *types.Issue) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Detect structural issues
|
// Detect structural issues
|
||||||
detectStructuralIssues(analysis, epicChildren.Children)
|
detectStructuralIssues(analysis, childIssues)
|
||||||
|
|
||||||
// Compute ready fronts (waves of parallel work)
|
// Compute ready fronts (waves of parallel work)
|
||||||
computeReadyFronts(analysis)
|
computeReadyFronts(analysis)
|
||||||
@@ -587,10 +596,14 @@ type StatusIssue struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var swarmStatusCmd = &cobra.Command{
|
var swarmStatusCmd = &cobra.Command{
|
||||||
Use: "status [epic-id]",
|
Use: "status [epic-or-swarm-id]",
|
||||||
Short: "Show current swarm status",
|
Short: "Show current swarm status",
|
||||||
Long: `Show the current status of a swarm, computed from beads.
|
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:
|
Displays issues grouped by state:
|
||||||
- Completed: Closed issues
|
- Completed: Closed issues
|
||||||
- Active: Issues currently in_progress (with assignee)
|
- Active: Issues currently in_progress (with assignee)
|
||||||
@@ -601,7 +614,8 @@ The status is COMPUTED from beads, not stored separately.
|
|||||||
If beads changes, status changes.
|
If beads changes, status changes.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
bd swarm status gt-epic-123 # Show swarm status
|
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`,
|
bd swarm status gt-epic-123 --json # Machine-readable output`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
@@ -621,24 +635,46 @@ Examples:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve epic ID
|
// Resolve ID
|
||||||
epicID, err := utils.ResolvePartialID(ctx, store, args[0])
|
issueID, err := utils.ResolvePartialID(ctx, store, args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
FatalErrorRespectJSON("epic '%s' not found: %v", args[0], err)
|
FatalErrorRespectJSON("issue '%s' not found: %v", args[0], err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the epic
|
// Get the issue
|
||||||
epic, err := store.GetIssue(ctx, epicID)
|
issue, err := store.GetIssue(ctx, issueID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
FatalErrorRespectJSON("failed to get epic: %v", err)
|
FatalErrorRespectJSON("failed to get issue: %v", err)
|
||||||
}
|
}
|
||||||
if epic == nil {
|
if issue == nil {
|
||||||
FatalErrorRespectJSON("epic '%s' not found", epicID)
|
FatalErrorRespectJSON("issue '%s' not found", issueID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify it's an epic or molecule
|
var epic *types.Issue
|
||||||
if epic.IssueType != types.TypeEpic && epic.IssueType != types.TypeMolecule {
|
|
||||||
FatalErrorRespectJSON("'%s' is not an epic or molecule (type: %s)", epicID, epic.IssueType)
|
// 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
|
// Get swarm status
|
||||||
@@ -658,7 +694,7 @@ Examples:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getSwarmStatus computes current swarm status from beads.
|
// getSwarmStatus computes current swarm status from beads.
|
||||||
func getSwarmStatus(ctx context.Context, s SwarmStore, epic *types.Issue) (*SwarmStatus, error) {
|
func getSwarmStatus(ctx context.Context, s SwarmStorage, epic *types.Issue) (*SwarmStatus, error) {
|
||||||
status := &SwarmStatus{
|
status := &SwarmStatus{
|
||||||
EpicID: epic.ID,
|
EpicID: epic.ID,
|
||||||
EpicTitle: epic.Title,
|
EpicTitle: epic.Title,
|
||||||
@@ -668,25 +704,47 @@ func getSwarmStatus(ctx context.Context, s SwarmStore, epic *types.Issue) (*Swar
|
|||||||
Blocked: []StatusIssue{},
|
Blocked: []StatusIssue{},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get children and dependency map using shared helper
|
// Get all child issues of the epic
|
||||||
epicChildren, err := getEpicChildren(ctx, s, epic.ID)
|
childIssues, err := getEpicChildren(ctx, s, epic.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
status.TotalIssues = len(epicChildren.Children)
|
status.TotalIssues = len(childIssues)
|
||||||
if len(epicChildren.Children) == 0 {
|
if len(childIssues) == 0 {
|
||||||
return status, nil
|
return status, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build status map for efficient blocked checks (avoids N+1 queries)
|
// Build set of child IDs for filtering
|
||||||
statusMap := make(map[string]types.Status)
|
childIDSet := make(map[string]bool)
|
||||||
for _, issue := range epicChildren.Children {
|
for _, issue := range childIssues {
|
||||||
statusMap[issue.ID] = issue.Status
|
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
|
// Categorize each issue
|
||||||
for _, issue := range epicChildren.Children {
|
for _, issue := range childIssues {
|
||||||
si := StatusIssue{
|
si := StatusIssue{
|
||||||
ID: issue.ID,
|
ID: issue.ID,
|
||||||
Title: issue.Title,
|
Title: issue.Title,
|
||||||
@@ -704,11 +762,12 @@ func getSwarmStatus(ctx context.Context, s SwarmStore, epic *types.Issue) (*Swar
|
|||||||
status.Active = append(status.Active, si)
|
status.Active = append(status.Active, si)
|
||||||
|
|
||||||
default: // open or other
|
default: // open or other
|
||||||
// Check if blocked by open dependencies (uses statusMap, no extra queries)
|
// Check if blocked by open dependencies
|
||||||
deps := epicChildren.DependsOn[issue.ID]
|
deps := dependsOn[issue.ID]
|
||||||
var blockers []string
|
var blockers []string
|
||||||
for _, depID := range deps {
|
for _, depID := range deps {
|
||||||
if depStatus, ok := statusMap[depID]; ok && depStatus != types.StatusClosed {
|
depIssue, _ := s.GetIssue(ctx, depID)
|
||||||
|
if depIssue != nil && depIssue.Status != types.StatusClosed {
|
||||||
blockers = append(blockers, depID)
|
blockers = append(blockers, depID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -854,6 +913,7 @@ Examples:
|
|||||||
CheckReadonly("swarm create")
|
CheckReadonly("swarm create")
|
||||||
ctx := rootCtx
|
ctx := rootCtx
|
||||||
coordinator, _ := cmd.Flags().GetString("coordinator")
|
coordinator, _ := cmd.Flags().GetString("coordinator")
|
||||||
|
force, _ := cmd.Flags().GetBool("force")
|
||||||
|
|
||||||
// Swarm commands require direct store access
|
// Swarm commands require direct store access
|
||||||
if store == nil {
|
if store == nil {
|
||||||
@@ -929,6 +989,25 @@ Examples:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Validate the epic structure
|
||||||
epic, err := store.GetIssue(ctx, epicID)
|
epic, err := store.GetIssue(ctx, epicID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -992,7 +1071,9 @@ Examples:
|
|||||||
} else {
|
} else {
|
||||||
fmt.Printf("\n%s Created swarm molecule: %s\n", ui.RenderPass("✓"), ui.RenderID(swarmMol.ID))
|
fmt.Printf("\n%s Created swarm molecule: %s\n", ui.RenderPass("✓"), ui.RenderID(swarmMol.ID))
|
||||||
fmt.Printf(" Epic: %s (%s)\n", epicID, epicTitle)
|
fmt.Printf(" Epic: %s (%s)\n", epicID, epicTitle)
|
||||||
fmt.Printf(" Coordinator: %s\n", coordinator)
|
if coordinator != "" {
|
||||||
|
fmt.Printf(" Coordinator: %s\n", coordinator)
|
||||||
|
}
|
||||||
fmt.Printf(" Total issues: %d\n", analysis.TotalIssues)
|
fmt.Printf(" Total issues: %d\n", analysis.TotalIssues)
|
||||||
fmt.Printf(" Max parallelism: %d\n", analysis.MaxParallelism)
|
fmt.Printf(" Max parallelism: %d\n", analysis.MaxParallelism)
|
||||||
fmt.Printf(" Waves: %d\n", len(analysis.ReadyFronts))
|
fmt.Printf(" Waves: %d\n", len(analysis.ReadyFronts))
|
||||||
@@ -1000,12 +1081,139 @@ Examples:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
func init() {
|
||||||
swarmValidateCmd.Flags().Bool("verbose", false, "Include detailed issue graph in output")
|
swarmValidateCmd.Flags().Bool("verbose", false, "Include detailed issue graph in output")
|
||||||
swarmCreateCmd.Flags().String("coordinator", "", "Coordinator address (e.g., gastown/witness)")
|
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(swarmValidateCmd)
|
||||||
swarmCmd.AddCommand(swarmStatusCmd)
|
swarmCmd.AddCommand(swarmStatusCmd)
|
||||||
swarmCmd.AddCommand(swarmCreateCmd)
|
swarmCmd.AddCommand(swarmCreateCmd)
|
||||||
|
swarmCmd.AddCommand(swarmListCmd)
|
||||||
rootCmd.AddCommand(swarmCmd)
|
rootCmd.AddCommand(swarmCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user