package doctor import ( "context" "encoding/json" "fmt" "os" "path/filepath" "strings" "time" "github.com/steveyegge/beads/internal/beads" "github.com/steveyegge/beads/internal/configfile" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" ) // DefaultCleanupAgeDays is the default age threshold for cleanup suggestions const DefaultCleanupAgeDays = 30 // CheckStaleClosedIssues detects closed issues that could be cleaned up. // This consolidates the cleanup command into doctor checks. func CheckStaleClosedIssues(path string) DoctorCheck { // Follow redirect to resolve actual beads directory beadsDir := resolveBeadsDir(filepath.Join(path, ".beads")) // Check metadata.json first for custom database name var dbPath string if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" { dbPath = cfg.DatabasePath(beadsDir) } else { dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName) } if _, err := os.Stat(dbPath); os.IsNotExist(err) { return DoctorCheck{ Name: "Stale Closed Issues", Status: StatusOK, Message: "N/A (no database)", Category: CategoryMaintenance, } } ctx := context.Background() store, err := sqlite.New(ctx, dbPath) if err != nil { return DoctorCheck{ Name: "Stale Closed Issues", Status: StatusOK, Message: "N/A (unable to open database)", Category: CategoryMaintenance, } } defer func() { _ = store.Close() }() // Find closed issues older than threshold cutoff := time.Now().AddDate(0, 0, -DefaultCleanupAgeDays) statusClosed := types.StatusClosed filter := types.IssueFilter{ Status: &statusClosed, ClosedBefore: &cutoff, } issues, err := store.SearchIssues(ctx, "", filter) if err != nil { return DoctorCheck{ Name: "Stale Closed Issues", Status: StatusOK, Message: "N/A (query failed)", Category: CategoryMaintenance, } } // Filter out pinned issues var cleanable int for _, issue := range issues { if !issue.Pinned { cleanable++ } } if cleanable == 0 { return DoctorCheck{ Name: "Stale Closed Issues", Status: StatusOK, Message: "No stale closed issues", Category: CategoryMaintenance, } } return DoctorCheck{ Name: "Stale Closed Issues", Status: StatusWarning, Message: fmt.Sprintf("%d closed issue(s) older than %d days", cleanable, DefaultCleanupAgeDays), Detail: "These issues can be cleaned up to reduce database size", Fix: "Run 'bd doctor --fix' to cleanup, or 'bd cleanup --force' for more options", Category: CategoryMaintenance, } } // CheckExpiredTombstones detects tombstones that have exceeded their TTL. func CheckExpiredTombstones(path string) DoctorCheck { // Follow redirect to resolve actual beads directory beadsDir := resolveBeadsDir(filepath.Join(path, ".beads")) jsonlPath := filepath.Join(beadsDir, "issues.jsonl") if _, err := os.Stat(jsonlPath); os.IsNotExist(err) { return DoctorCheck{ Name: "Expired Tombstones", Status: StatusOK, Message: "N/A (no JSONL file)", Category: CategoryMaintenance, } } // Read JSONL and count expired tombstones file, err := os.Open(jsonlPath) // #nosec G304 - path constructed safely if err != nil { return DoctorCheck{ Name: "Expired Tombstones", Status: StatusOK, Message: "N/A (unable to read JSONL)", Category: CategoryMaintenance, } } defer file.Close() var expiredCount int decoder := json.NewDecoder(file) ttl := types.DefaultTombstoneTTL for { var issue types.Issue if err := decoder.Decode(&issue); err != nil { break } issue.SetDefaults() if issue.IsExpired(ttl) { expiredCount++ } } if expiredCount == 0 { return DoctorCheck{ Name: "Expired Tombstones", Status: StatusOK, Message: "No expired tombstones", Category: CategoryMaintenance, } } ttlDays := int(ttl.Hours() / 24) return DoctorCheck{ Name: "Expired Tombstones", Status: StatusWarning, Message: fmt.Sprintf("%d tombstone(s) older than %d days", expiredCount, ttlDays), Detail: "Expired tombstones can be pruned to reduce JSONL file size", Fix: "Run 'bd doctor --fix' to prune, or 'bd cleanup --force' for more options", Category: CategoryMaintenance, } } // CheckStaleMolecules detects complete-but-unclosed molecules. // A molecule is stale if all children are closed but the root is still open. func CheckStaleMolecules(path string) DoctorCheck { beadsDir := resolveBeadsDir(filepath.Join(path, ".beads")) // Check metadata.json first for custom database name var dbPath string if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" { dbPath = cfg.DatabasePath(beadsDir) } else { dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName) } if _, err := os.Stat(dbPath); os.IsNotExist(err) { return DoctorCheck{ Name: "Stale Molecules", Status: StatusOK, Message: "N/A (no database)", Category: CategoryMaintenance, } } ctx := context.Background() store, err := sqlite.New(ctx, dbPath) if err != nil { return DoctorCheck{ Name: "Stale Molecules", Status: StatusOK, Message: "N/A (unable to open database)", Category: CategoryMaintenance, } } defer func() { _ = store.Close() }() // Get all epics eligible for closure (complete but unclosed) epicStatuses, err := store.GetEpicsEligibleForClosure(ctx) if err != nil { return DoctorCheck{ Name: "Stale Molecules", Status: StatusOK, Message: "N/A (query failed)", Category: CategoryMaintenance, } } // Count stale molecules (eligible for close with at least 1 child) var staleCount int var staleIDs []string for _, es := range epicStatuses { if es.EligibleForClose && es.TotalChildren > 0 { staleCount++ if len(staleIDs) < 3 { staleIDs = append(staleIDs, es.Epic.ID) } } } if staleCount == 0 { return DoctorCheck{ Name: "Stale Molecules", Status: StatusOK, Message: "No stale molecules", Category: CategoryMaintenance, } } detail := fmt.Sprintf("Example: %v", staleIDs) if staleCount > 3 { detail += fmt.Sprintf(" (+%d more)", staleCount-3) } return DoctorCheck{ Name: "Stale Molecules", Status: StatusWarning, Message: fmt.Sprintf("%d complete-but-unclosed molecule(s)", staleCount), Detail: detail, Fix: "Run 'bd mol stale' to review, then 'bd close ' for each", Category: CategoryMaintenance, } } // CheckCompactionCandidates detects issues eligible for compaction. func CheckCompactionCandidates(path string) DoctorCheck { // Follow redirect to resolve actual beads directory beadsDir := resolveBeadsDir(filepath.Join(path, ".beads")) // Check metadata.json first for custom database name var dbPath string if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" { dbPath = cfg.DatabasePath(beadsDir) } else { dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName) } if _, err := os.Stat(dbPath); os.IsNotExist(err) { return DoctorCheck{ Name: "Compaction Candidates", Status: StatusOK, Message: "N/A (no database)", Category: CategoryMaintenance, } } ctx := context.Background() store, err := sqlite.New(ctx, dbPath) if err != nil { return DoctorCheck{ Name: "Compaction Candidates", Status: StatusOK, Message: "N/A (unable to open database)", Category: CategoryMaintenance, } } defer func() { _ = store.Close() }() tier1, err := store.GetTier1Candidates(ctx) if err != nil { return DoctorCheck{ Name: "Compaction Candidates", Status: StatusOK, Message: "N/A (query failed)", Category: CategoryMaintenance, } } if len(tier1) == 0 { return DoctorCheck{ Name: "Compaction Candidates", Status: StatusOK, Message: "No compaction candidates", Category: CategoryMaintenance, } } // Calculate total size var totalSize int for _, c := range tier1 { totalSize += c.OriginalSize } return DoctorCheck{ Name: "Compaction Candidates", Status: StatusOK, // Info only, not a warning Message: fmt.Sprintf("%d issue(s) eligible for compaction (%d bytes)", len(tier1), totalSize), Detail: "Compaction requires agent review; not auto-fixable", Fix: "Run 'bd compact --analyze' to review candidates", Category: CategoryMaintenance, } } // resolveBeadsDir follows a redirect file if present in the beads directory. // This handles Gas Town's redirect mechanism where .beads/redirect points to // the actual beads directory location. func resolveBeadsDir(beadsDir string) string { redirectFile := filepath.Join(beadsDir, "redirect") data, err := os.ReadFile(redirectFile) //nolint:gosec // redirect file path is constructed from known beadsDir if err != nil { // No redirect file - use original path return beadsDir } // Parse the redirect target target := strings.TrimSpace(string(data)) if target == "" { return beadsDir } // Skip comments lines := strings.Split(target, "\n") for _, line := range lines { line = strings.TrimSpace(line) if line != "" && !strings.HasPrefix(line, "#") { target = line break } } // Resolve relative paths from the parent of the .beads directory if !filepath.IsAbs(target) { projectRoot := filepath.Dir(beadsDir) target = filepath.Join(projectRoot, target) } // Verify the target exists if info, err := os.Stat(target); err != nil || !info.IsDir() { return beadsDir } return target } // CheckPersistentMolIssues detects mol- prefixed issues that should have been ephemeral. // When users run "bd mol pour" on formulas that should use "bd mol wisp", the resulting // issues get the "mol-" prefix but persist in JSONL. These should be cleaned up. func CheckPersistentMolIssues(path string) DoctorCheck { beadsDir := resolveBeadsDir(filepath.Join(path, ".beads")) jsonlPath := filepath.Join(beadsDir, "issues.jsonl") if _, err := os.Stat(jsonlPath); os.IsNotExist(err) { return DoctorCheck{ Name: "Persistent Mol Issues", Status: StatusOK, Message: "N/A (no JSONL file)", Category: CategoryMaintenance, } } // Read JSONL and count mol- prefixed issues that are not ephemeral file, err := os.Open(jsonlPath) // #nosec G304 - path constructed safely if err != nil { return DoctorCheck{ Name: "Persistent Mol Issues", Status: StatusOK, Message: "N/A (unable to read JSONL)", Category: CategoryMaintenance, } } defer file.Close() var molCount int var molIDs []string decoder := json.NewDecoder(file) for { var issue types.Issue if err := decoder.Decode(&issue); err != nil { break } // Skip deleted issues (tombstones) if issue.DeletedAt != nil { continue } // Look for mol- prefix that shouldn't be in JSONL // (ephemeral issues have Ephemeral=true and don't get exported) if strings.HasPrefix(issue.ID, "mol-") && !issue.Ephemeral { molCount++ if len(molIDs) < 3 { molIDs = append(molIDs, issue.ID) } } } if molCount == 0 { return DoctorCheck{ Name: "Persistent Mol Issues", Status: StatusOK, Message: "No persistent mol- issues", Category: CategoryMaintenance, } } detail := fmt.Sprintf("Example: %v", molIDs) if molCount > 3 { detail += fmt.Sprintf(" (+%d more)", molCount-3) } return DoctorCheck{ Name: "Persistent Mol Issues", Status: StatusWarning, Message: fmt.Sprintf("%d mol- issue(s) in JSONL should be ephemeral", molCount), Detail: detail, Fix: "Run 'bd delete --force' to remove, or use 'bd mol wisp' instead of 'bd mol pour'", Category: CategoryMaintenance, } }