Replace findInProgressMolecules (which loads full subgraphs) with findInProgressMoleculeIDs (which only returns IDs). This ensures auto-discovery is efficient for mega-molecules. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
214 lines
6.0 KiB
Go
214 lines
6.0 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/beads/internal/storage"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
"github.com/steveyegge/beads/internal/ui"
|
|
"github.com/steveyegge/beads/internal/utils"
|
|
)
|
|
|
|
var molProgressCmd = &cobra.Command{
|
|
Use: "progress [molecule-id]",
|
|
Short: "Show molecule progress summary",
|
|
Long: `Show efficient progress summary for a molecule.
|
|
|
|
This command uses indexed queries to count progress without loading all steps,
|
|
making it suitable for very large molecules (millions of steps).
|
|
|
|
If no molecule-id is given, shows progress for any molecule you're working on.
|
|
|
|
Output includes:
|
|
- Progress: completed / total (percentage)
|
|
- Current step: the in-progress step (if any)
|
|
- Rate: steps/hour based on closure times
|
|
- ETA: estimated time to completion
|
|
|
|
Example:
|
|
bd mol progress bd-hanoi-xyz`,
|
|
Args: cobra.MaximumNArgs(1),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
ctx := rootCtx
|
|
|
|
// mol progress requires direct store access
|
|
if store == nil {
|
|
if daemonClient != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: mol progress requires direct database access\n")
|
|
fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon mol progress\n")
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
|
|
}
|
|
os.Exit(1)
|
|
}
|
|
|
|
var moleculeID string
|
|
if len(args) == 1 {
|
|
// Explicit molecule ID given
|
|
resolved, err := utils.ResolvePartialID(ctx, store, args[0])
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: molecule '%s' not found\n", args[0])
|
|
os.Exit(1)
|
|
}
|
|
moleculeID = resolved
|
|
} else {
|
|
// Infer from in_progress work - use lightweight discovery
|
|
moleculeIDs := findInProgressMoleculeIDs(ctx, store, actor)
|
|
if len(moleculeIDs) == 0 {
|
|
if jsonOutput {
|
|
outputJSON([]interface{}{})
|
|
return
|
|
}
|
|
fmt.Println("No molecules in progress.")
|
|
fmt.Println("\nUse: bd mol progress <molecule-id>")
|
|
return
|
|
}
|
|
// Show progress for first molecule
|
|
moleculeID = moleculeIDs[0]
|
|
}
|
|
|
|
stats, err := store.GetMoleculeProgress(ctx, moleculeID)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if jsonOutput {
|
|
// Add computed fields for JSON output
|
|
output := map[string]interface{}{
|
|
"molecule_id": stats.MoleculeID,
|
|
"molecule_title": stats.MoleculeTitle,
|
|
"total": stats.Total,
|
|
"completed": stats.Completed,
|
|
"in_progress": stats.InProgress,
|
|
"current_step_id": stats.CurrentStepID,
|
|
}
|
|
if stats.Total > 0 {
|
|
output["percent"] = float64(stats.Completed) * 100 / float64(stats.Total)
|
|
}
|
|
if stats.FirstClosed != nil && stats.LastClosed != nil && stats.Completed > 1 {
|
|
duration := stats.LastClosed.Sub(*stats.FirstClosed)
|
|
if duration > 0 {
|
|
rate := float64(stats.Completed-1) / duration.Hours()
|
|
output["rate_per_hour"] = rate
|
|
remaining := stats.Total - stats.Completed
|
|
if rate > 0 {
|
|
etaHours := float64(remaining) / rate
|
|
output["eta_hours"] = etaHours
|
|
}
|
|
}
|
|
}
|
|
outputJSON(output)
|
|
return
|
|
}
|
|
|
|
// Human-readable output
|
|
printMoleculeProgressStats(stats)
|
|
},
|
|
}
|
|
|
|
// findInProgressMoleculeIDs finds molecule IDs with in_progress steps for an agent.
|
|
// This is a lightweight version that only returns IDs without loading subgraphs.
|
|
func findInProgressMoleculeIDs(ctx context.Context, s storage.Storage, agent string) []string {
|
|
// Query for in_progress issues
|
|
status := types.StatusInProgress
|
|
filter := types.IssueFilter{Status: &status}
|
|
if agent != "" {
|
|
filter.Assignee = &agent
|
|
}
|
|
inProgressIssues, err := s.SearchIssues(ctx, "", filter)
|
|
if err != nil || len(inProgressIssues) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// For each in_progress issue, find its parent molecule
|
|
seen := make(map[string]bool)
|
|
var moleculeIDs []string
|
|
for _, issue := range inProgressIssues {
|
|
moleculeID := findParentMolecule(ctx, s, issue.ID)
|
|
if moleculeID != "" && !seen[moleculeID] {
|
|
seen[moleculeID] = true
|
|
moleculeIDs = append(moleculeIDs, moleculeID)
|
|
}
|
|
}
|
|
|
|
return moleculeIDs
|
|
}
|
|
|
|
// printMoleculeProgressStats prints molecule progress in human-readable format
|
|
func printMoleculeProgressStats(stats *types.MoleculeProgressStats) {
|
|
fmt.Printf("Molecule: %s (%s)\n", ui.RenderAccent(stats.MoleculeID), stats.MoleculeTitle)
|
|
|
|
// Progress bar
|
|
var percent float64
|
|
if stats.Total > 0 {
|
|
percent = float64(stats.Completed) * 100 / float64(stats.Total)
|
|
}
|
|
fmt.Printf("Progress: %s / %s (%.1f%%)\n",
|
|
formatNumber(stats.Completed),
|
|
formatNumber(stats.Total),
|
|
percent)
|
|
|
|
// Current step
|
|
if stats.CurrentStepID != "" {
|
|
fmt.Printf("Current step: %s\n", stats.CurrentStepID)
|
|
} else if stats.InProgress > 0 {
|
|
fmt.Printf("In progress: %d step(s)\n", stats.InProgress)
|
|
}
|
|
|
|
// Rate calculation
|
|
if stats.FirstClosed != nil && stats.LastClosed != nil && stats.Completed > 1 {
|
|
duration := stats.LastClosed.Sub(*stats.FirstClosed)
|
|
if duration > 0 {
|
|
// Rate is (completed - 1) because we need at least 2 points to measure rate
|
|
rate := float64(stats.Completed-1) / duration.Hours()
|
|
fmt.Printf("Rate: ~%.0f steps/hour\n", rate)
|
|
|
|
// ETA
|
|
remaining := stats.Total - stats.Completed
|
|
if rate > 0 && remaining > 0 {
|
|
etaHours := float64(remaining) / rate
|
|
fmt.Printf("ETA: %s remaining\n", formatDuration(etaHours))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// formatNumber formats large numbers with commas (handles millions)
|
|
func formatNumber(n int) string {
|
|
if n < 1000 {
|
|
return fmt.Sprintf("%d", n)
|
|
}
|
|
if n < 1000000 {
|
|
return fmt.Sprintf("%d,%03d", n/1000, n%1000)
|
|
}
|
|
millions := n / 1000000
|
|
thousands := (n % 1000000) / 1000
|
|
ones := n % 1000
|
|
return fmt.Sprintf("%d,%03d,%03d", millions, thousands, ones)
|
|
}
|
|
|
|
// formatDuration formats hours as a human-readable duration
|
|
func formatDuration(hours float64) string {
|
|
if hours < 1 {
|
|
minutes := hours * 60
|
|
return fmt.Sprintf("~%.0f minutes", minutes)
|
|
}
|
|
if hours < 24 {
|
|
return fmt.Sprintf("~%.1f hours", hours)
|
|
}
|
|
days := hours / 24
|
|
if days < 7 {
|
|
return fmt.Sprintf("~%.1f days", days)
|
|
}
|
|
weeks := days / 7
|
|
return fmt.Sprintf("~%.1f weeks", weeks)
|
|
}
|
|
|
|
func init() {
|
|
molCmd.AddCommand(molProgressCmd)
|
|
}
|