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 ") 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) }