feat: add bd mol progress command for efficient molecule monitoring (bd-8xnf)
Adds a new `bd mol progress` command that shows molecule progress using indexed queries instead of loading all steps into memory. This makes it suitable for mega-molecules with millions of steps. Features: - Efficient SQL-based counting via idx_dependencies_depends_on_type index - Progress display: completed / total (percentage) - Current step identification - Rate calculation from closure timestamps - ETA estimation - JSON output support New storage interface method: GetMoleculeProgress(ctx, moleculeID) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
Steve Yegge
parent
3593ba8d66
commit
0a96b10bba
+888
-870
File diff suppressed because one or more lines are too long
@@ -0,0 +1,183 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"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
|
||||||
|
molecules := findInProgressMolecules(ctx, store, actor)
|
||||||
|
if len(molecules) == 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 = molecules[0].MoleculeID
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -1663,6 +1663,55 @@ func (m *MemoryStorage) Path() string {
|
|||||||
return m.jsonlPath
|
return m.jsonlPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetMoleculeProgress returns progress stats for a molecule.
|
||||||
|
// For memory storage, this iterates through dependencies.
|
||||||
|
func (m *MemoryStorage) GetMoleculeProgress(ctx context.Context, moleculeID string) (*types.MoleculeProgressStats, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
issue, exists := m.issues[moleculeID]
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("molecule not found: %s", moleculeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := &types.MoleculeProgressStats{
|
||||||
|
MoleculeID: moleculeID,
|
||||||
|
MoleculeTitle: issue.Title,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all parent-child dependencies where moleculeID is the parent
|
||||||
|
for _, deps := range m.dependencies {
|
||||||
|
for _, dep := range deps {
|
||||||
|
if dep.Type == types.DepParentChild && dep.DependsOnID == moleculeID {
|
||||||
|
child, exists := m.issues[dep.IssueID]
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stats.Total++
|
||||||
|
switch child.Status {
|
||||||
|
case types.StatusClosed:
|
||||||
|
stats.Completed++
|
||||||
|
if child.ClosedAt != nil {
|
||||||
|
if stats.FirstClosed == nil || child.ClosedAt.Before(*stats.FirstClosed) {
|
||||||
|
stats.FirstClosed = child.ClosedAt
|
||||||
|
}
|
||||||
|
if stats.LastClosed == nil || child.ClosedAt.After(*stats.LastClosed) {
|
||||||
|
stats.LastClosed = child.ClosedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case types.StatusInProgress:
|
||||||
|
stats.InProgress++
|
||||||
|
if stats.CurrentStepID == "" {
|
||||||
|
stats.CurrentStepID = child.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
// UnderlyingDB returns nil for memory storage (no SQL database)
|
// UnderlyingDB returns nil for memory storage (no SQL database)
|
||||||
func (m *MemoryStorage) UnderlyingDB() *sql.DB {
|
func (m *MemoryStorage) UnderlyingDB() *sql.DB {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -207,3 +207,74 @@ func (s *SQLiteStorage) GetStatistics(ctx context.Context) (*types.Statistics, e
|
|||||||
|
|
||||||
return &stats, nil
|
return &stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetMoleculeProgress returns efficient progress stats for a molecule.
|
||||||
|
// Uses indexed queries on dependencies table instead of loading all steps.
|
||||||
|
func (s *SQLiteStorage) GetMoleculeProgress(ctx context.Context, moleculeID string) (*types.MoleculeProgressStats, error) {
|
||||||
|
// First get the molecule's title
|
||||||
|
var title string
|
||||||
|
err := s.db.QueryRowContext(ctx, `SELECT title FROM issues WHERE id = ?`, moleculeID).Scan(&title)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, fmt.Errorf("molecule not found: %s", moleculeID)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to get molecule: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := &types.MoleculeProgressStats{
|
||||||
|
MoleculeID: moleculeID,
|
||||||
|
MoleculeTitle: title,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get counts from direct children via parent-child dependency
|
||||||
|
// Uses idx_dependencies_depends_on_type index
|
||||||
|
err = s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
COALESCE(SUM(CASE WHEN i.status = 'closed' THEN 1 ELSE 0 END), 0) as completed,
|
||||||
|
COALESCE(SUM(CASE WHEN i.status = 'in_progress' THEN 1 ELSE 0 END), 0) as in_progress
|
||||||
|
FROM dependencies d
|
||||||
|
JOIN issues i ON d.issue_id = i.id
|
||||||
|
WHERE d.depends_on_id = ? AND d.type = 'parent-child'
|
||||||
|
`, moleculeID).Scan(&stats.Total, &stats.Completed, &stats.InProgress)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get child counts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get first in_progress step ID (for "current step" display)
|
||||||
|
var currentStepID sql.NullString
|
||||||
|
err = s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT i.id
|
||||||
|
FROM dependencies d
|
||||||
|
JOIN issues i ON d.issue_id = i.id
|
||||||
|
WHERE d.depends_on_id = ? AND d.type = 'parent-child' AND i.status = 'in_progress'
|
||||||
|
ORDER BY i.created_at
|
||||||
|
LIMIT 1
|
||||||
|
`, moleculeID).Scan(¤tStepID)
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
return nil, fmt.Errorf("failed to get current step: %w", err)
|
||||||
|
}
|
||||||
|
if currentStepID.Valid {
|
||||||
|
stats.CurrentStepID = currentStepID.String
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get first and last closure times for rate calculation
|
||||||
|
var firstClosed, lastClosed sql.NullTime
|
||||||
|
err = s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT MIN(i.closed_at), MAX(i.closed_at)
|
||||||
|
FROM dependencies d
|
||||||
|
JOIN issues i ON d.issue_id = i.id
|
||||||
|
WHERE d.depends_on_id = ? AND d.type = 'parent-child' AND i.status = 'closed'
|
||||||
|
`, moleculeID).Scan(&firstClosed, &lastClosed)
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
return nil, fmt.Errorf("failed to get closure times: %w", err)
|
||||||
|
}
|
||||||
|
if firstClosed.Valid {
|
||||||
|
stats.FirstClosed = &firstClosed.Time
|
||||||
|
}
|
||||||
|
if lastClosed.Valid {
|
||||||
|
stats.LastClosed = &lastClosed.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -126,6 +126,9 @@ type Storage interface {
|
|||||||
// Statistics
|
// Statistics
|
||||||
GetStatistics(ctx context.Context) (*types.Statistics, error)
|
GetStatistics(ctx context.Context) (*types.Statistics, error)
|
||||||
|
|
||||||
|
// Molecule progress (efficient for large molecules)
|
||||||
|
GetMoleculeProgress(ctx context.Context, moleculeID string) (*types.MoleculeProgressStats, error)
|
||||||
|
|
||||||
// Dirty tracking (for incremental JSONL export)
|
// Dirty tracking (for incremental JSONL export)
|
||||||
GetDirtyIssues(ctx context.Context) ([]string, error)
|
GetDirtyIssues(ctx context.Context) ([]string, error)
|
||||||
GetDirtyIssueHash(ctx context.Context, issueID string) (string, error) // For timestamp-only dedup (bd-164)
|
GetDirtyIssueHash(ctx context.Context, issueID string) (string, error) // For timestamp-only dedup (bd-164)
|
||||||
|
|||||||
@@ -125,6 +125,9 @@ func (m *mockStorage) GetCommentsForIssues(ctx context.Context, issueIDs []strin
|
|||||||
func (m *mockStorage) GetStatistics(ctx context.Context) (*types.Statistics, error) {
|
func (m *mockStorage) GetStatistics(ctx context.Context) (*types.Statistics, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
func (m *mockStorage) GetMoleculeProgress(ctx context.Context, moleculeID string) (*types.MoleculeProgressStats, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
func (m *mockStorage) GetDirtyIssues(ctx context.Context) ([]string, error) {
|
func (m *mockStorage) GetDirtyIssues(ctx context.Context) ([]string, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -677,6 +677,19 @@ type TreeNode struct {
|
|||||||
Truncated bool `json:"truncated"`
|
Truncated bool `json:"truncated"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MoleculeProgressStats provides efficient progress info for large molecules.
|
||||||
|
// This uses indexed queries instead of loading all steps into memory.
|
||||||
|
type MoleculeProgressStats struct {
|
||||||
|
MoleculeID string `json:"molecule_id"`
|
||||||
|
MoleculeTitle string `json:"molecule_title"`
|
||||||
|
Total int `json:"total"` // Total steps (direct children)
|
||||||
|
Completed int `json:"completed"` // Closed steps
|
||||||
|
InProgress int `json:"in_progress"` // Steps currently in progress
|
||||||
|
CurrentStepID string `json:"current_step_id"` // First in_progress step ID (if any)
|
||||||
|
FirstClosed *time.Time `json:"first_closed,omitempty"`
|
||||||
|
LastClosed *time.Time `json:"last_closed,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// Statistics provides aggregate metrics
|
// Statistics provides aggregate metrics
|
||||||
type Statistics struct {
|
type Statistics struct {
|
||||||
TotalIssues int `json:"total_issues"`
|
TotalIssues int `json:"total_issues"`
|
||||||
|
|||||||
Reference in New Issue
Block a user