diff --git a/cmd/bd/doctor.go b/cmd/bd/doctor.go index c2b617e1..92164c6e 100644 --- a/cmd/bd/doctor.go +++ b/cmd/bd/doctor.go @@ -375,6 +375,9 @@ func applyFixList(path string, fixes []doctorCheck) { err = fix.DatabaseVersion(path) case "Schema Compatibility": err = fix.SchemaCompatibility(path) + case "Database Integrity": + // Corruption detected - try recovery from JSONL + err = fix.DatabaseCorruptionRecovery(path) case "Repo Fingerprint": err = fix.RepoFingerprint(path) case "Git Merge Driver": @@ -432,6 +435,10 @@ func applyFixList(path string, fixes []doctorCheck) { // No auto-fix: compaction requires agent review fmt.Printf(" ⚠ Run 'bd compact --analyze' to review candidates\n") continue + case "Large Database": + // No auto-fix: pruning deletes data, must be user-controlled + fmt.Printf(" ⚠ Run 'bd cleanup --older-than 90' to prune old closed issues\n") + continue default: fmt.Printf(" ⚠ No automatic fix available for %s\n", check.Name) fmt.Printf(" Manual fix: %s\n", check.Fix) @@ -837,6 +844,12 @@ func runDiagnostics(path string) doctorResult { result.Checks = append(result.Checks, compactionCheck) // Info only, not a warning - compaction requires human review + // Check 29: Database size (pruning suggestion) + // Note: This check has no auto-fix - pruning is destructive and user-controlled + sizeCheck := convertDoctorCheck(doctor.CheckDatabaseSize(path)) + result.Checks = append(result.Checks, sizeCheck) + // Don't fail overall check for size warning, just inform + return result } diff --git a/cmd/bd/doctor/database.go b/cmd/bd/doctor/database.go index 674a6c17..876fd7d4 100644 --- a/cmd/bd/doctor/database.go +++ b/cmd/bd/doctor/database.go @@ -246,11 +246,28 @@ func CheckDatabaseIntegrity(path string) DoctorCheck { // Open database in read-only mode for integrity check db, err := sql.Open("sqlite3", "file:"+dbPath+"?mode=ro&_pragma=busy_timeout(30000)") if err != nil { + // Check if JSONL recovery is possible + jsonlCount, _, jsonlErr := CountJSONLIssues(filepath.Join(beadsDir, "issues.jsonl")) + if jsonlErr != nil { + jsonlCount, _, jsonlErr = CountJSONLIssues(filepath.Join(beadsDir, "beads.jsonl")) + } + + if jsonlErr == nil && jsonlCount > 0 { + return DoctorCheck{ + Name: "Database Integrity", + Status: StatusError, + Message: fmt.Sprintf("Failed to open database (JSONL has %d issues for recovery)", jsonlCount), + Detail: err.Error(), + Fix: "Run 'bd doctor --fix' to recover from JSONL backup", + } + } + return DoctorCheck{ Name: "Database Integrity", Status: StatusError, Message: "Failed to open database for integrity check", Detail: err.Error(), + Fix: "Database may be corrupted. Restore JSONL from git history, then run 'bd doctor --fix'", } } defer db.Close() @@ -259,11 +276,28 @@ func CheckDatabaseIntegrity(path string) DoctorCheck { // This checks the entire database for corruption rows, err := db.Query("PRAGMA integrity_check") if err != nil { + // Check if JSONL recovery is possible + jsonlCount, _, jsonlErr := CountJSONLIssues(filepath.Join(beadsDir, "issues.jsonl")) + if jsonlErr != nil { + jsonlCount, _, jsonlErr = CountJSONLIssues(filepath.Join(beadsDir, "beads.jsonl")) + } + + if jsonlErr == nil && jsonlCount > 0 { + return DoctorCheck{ + Name: "Database Integrity", + Status: StatusError, + Message: fmt.Sprintf("Failed to run integrity check (JSONL has %d issues for recovery)", jsonlCount), + Detail: err.Error(), + Fix: "Run 'bd doctor --fix' to recover from JSONL backup", + } + } + return DoctorCheck{ Name: "Database Integrity", Status: StatusError, Message: "Failed to run integrity check", Detail: err.Error(), + Fix: "Database may be corrupted. Restore JSONL from git history, then run 'bd doctor --fix'", } } defer rows.Close() @@ -286,13 +320,29 @@ func CheckDatabaseIntegrity(path string) DoctorCheck { } } - // Any other result indicates corruption + // Any other result indicates corruption - check if JSONL recovery is possible + jsonlCount, _, jsonlErr := CountJSONLIssues(filepath.Join(beadsDir, "issues.jsonl")) + if jsonlErr != nil { + // Try alternate name + jsonlCount, _, jsonlErr = CountJSONLIssues(filepath.Join(beadsDir, "beads.jsonl")) + } + + if jsonlErr == nil && jsonlCount > 0 { + return DoctorCheck{ + Name: "Database Integrity", + Status: StatusError, + Message: fmt.Sprintf("Database corruption detected (JSONL has %d issues for recovery)", jsonlCount), + Detail: strings.Join(results, "; "), + Fix: "Run 'bd doctor --fix' to recover from JSONL backup", + } + } + return DoctorCheck{ Name: "Database Integrity", Status: StatusError, Message: "Database corruption detected", Detail: strings.Join(results, "; "), - Fix: "Database may need recovery. Export with 'bd export' if possible, then restore from backup or reinitialize", + Fix: "Database may need recovery. Restore JSONL from git history, then run 'bd doctor --fix'", } } @@ -620,3 +670,92 @@ func isNoDbModeConfigured(beadsDir string) bool { return cfg.NoDb } + +// CheckDatabaseSize warns when the database has accumulated many closed issues. +// This is purely informational - pruning is NEVER auto-fixed because it +// permanently deletes data. Users must explicitly run 'bd cleanup' to prune. +// +// Config: doctor.suggest_pruning_issue_count (default: 5000, 0 = disabled) +// +// DESIGN NOTE: This check intentionally has NO auto-fix. Unlike other doctor +// checks that fix configuration or sync issues, pruning is destructive and +// irreversible. The user must make an explicit decision to delete their +// closed issue history. We only provide guidance, never action. +func CheckDatabaseSize(path string) DoctorCheck { + beadsDir := filepath.Join(path, ".beads") + + // Get database path + 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 no database, skip this check + if _, err := os.Stat(dbPath); os.IsNotExist(err) { + return DoctorCheck{ + Name: "Large Database", + Status: StatusOK, + Message: "N/A (no database)", + } + } + + // Read threshold from config (default 5000, 0 = disabled) + threshold := 5000 + db, err := sql.Open("sqlite3", "file:"+dbPath+"?mode=ro&_pragma=busy_timeout(30000)") + if err != nil { + return DoctorCheck{ + Name: "Large Database", + Status: StatusOK, + Message: "N/A (unable to open database)", + } + } + defer db.Close() + + // Check for custom threshold in config table + var thresholdStr string + err = db.QueryRow("SELECT value FROM config WHERE key = ?", "doctor.suggest_pruning_issue_count").Scan(&thresholdStr) + if err == nil { + if _, err := fmt.Sscanf(thresholdStr, "%d", &threshold); err != nil { + threshold = 5000 // Reset to default on parse error + } + } + + // If disabled, return OK + if threshold == 0 { + return DoctorCheck{ + Name: "Large Database", + Status: StatusOK, + Message: "Check disabled (threshold = 0)", + } + } + + // Count closed issues + var closedCount int + err = db.QueryRow("SELECT COUNT(*) FROM issues WHERE status = 'closed'").Scan(&closedCount) + if err != nil { + return DoctorCheck{ + Name: "Large Database", + Status: StatusOK, + Message: "N/A (unable to count issues)", + } + } + + // Check against threshold + if closedCount > threshold { + return DoctorCheck{ + Name: "Large Database", + Status: StatusWarning, + Message: fmt.Sprintf("%d closed issues (threshold: %d)", closedCount, threshold), + Detail: "Large number of closed issues may impact performance", + Fix: "Consider running 'bd cleanup --older-than 90' to prune old closed issues", + } + } + + return DoctorCheck{ + Name: "Large Database", + Status: StatusOK, + Message: fmt.Sprintf("%d closed issues (threshold: %d)", closedCount, threshold), + } +} diff --git a/cmd/bd/doctor/fix/recovery.go b/cmd/bd/doctor/fix/recovery.go new file mode 100644 index 00000000..180fcdcc --- /dev/null +++ b/cmd/bd/doctor/fix/recovery.go @@ -0,0 +1,81 @@ +package fix + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" +) + +// DatabaseCorruptionRecovery recovers a corrupted database from JSONL backup. +// It backs up the corrupted database, deletes it, and re-imports from JSONL. +func DatabaseCorruptionRecovery(path string) error { + // Validate workspace + if err := validateBeadsWorkspace(path); err != nil { + return err + } + + beadsDir := filepath.Join(path, ".beads") + dbPath := filepath.Join(beadsDir, "beads.db") + + // Check if database exists + if _, err := os.Stat(dbPath); os.IsNotExist(err) { + return fmt.Errorf("no database to recover") + } + + // Find JSONL file + jsonlPath := findJSONLPath(beadsDir) + if jsonlPath == "" { + return fmt.Errorf("no JSONL backup found - cannot recover (try restoring from git history)") + } + + // Count issues in JSONL + issueCount, err := countJSONLIssues(jsonlPath) + if err != nil { + return fmt.Errorf("failed to read JSONL: %w", err) + } + if issueCount == 0 { + return fmt.Errorf("JSONL is empty - cannot recover (try restoring from git history)") + } + + // Backup corrupted database + backupPath := dbPath + ".corrupt" + fmt.Printf(" Backing up corrupted database to %s\n", filepath.Base(backupPath)) + if err := os.Rename(dbPath, backupPath); err != nil { + return fmt.Errorf("failed to backup corrupted database: %w", err) + } + + // Get bd binary path + bdBinary, err := getBdBinary() + if err != nil { + // Restore corrupted database on failure + _ = os.Rename(backupPath, dbPath) + return err + } + + // Run bd import with --rename-on-import to handle prefix mismatches + fmt.Printf(" Recovering %d issues from %s\n", issueCount, filepath.Base(jsonlPath)) + cmd := exec.Command(bdBinary, "import", "-i", jsonlPath, "--rename-on-import") // #nosec G204 + cmd.Dir = path + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + // Keep backup on failure + fmt.Printf(" Warning: recovery failed, corrupted database preserved at %s\n", filepath.Base(backupPath)) + return fmt.Errorf("failed to import from JSONL: %w", err) + } + + // Run migrate to set version metadata + migrateCmd := exec.Command(bdBinary, "migrate") // #nosec G204 + migrateCmd.Dir = path + migrateCmd.Stdout = os.Stdout + migrateCmd.Stderr = os.Stderr + if err := migrateCmd.Run(); err != nil { + // Non-fatal - import succeeded, version just won't be set + fmt.Printf(" Warning: migration failed (non-fatal): %v\n", err) + } + + fmt.Printf(" Recovered %d issues from JSONL backup\n", issueCount) + return nil +} diff --git a/cmd/bd/doctor/git.go b/cmd/bd/doctor/git.go index 99687b7c..ab373ff7 100644 --- a/cmd/bd/doctor/git.go +++ b/cmd/bd/doctor/git.go @@ -145,6 +145,8 @@ func CheckSyncBranchHookCompatibility(path string) DoctorCheck { Status: StatusWarning, Message: "Pre-push hook is not a bd hook", Detail: "Cannot verify sync-branch compatibility with custom hooks", + Fix: "Either run 'bd hooks install --force' to use bd hooks,\n" + + " or ensure your custom hook skips validation when pushing to sync-branch", } } diff --git a/cmd/bd/doctor/legacy.go b/cmd/bd/doctor/legacy.go index 589d3b9b..1a5a59c6 100644 --- a/cmd/bd/doctor/legacy.go +++ b/cmd/bd/doctor/legacy.go @@ -190,7 +190,7 @@ func CheckLegacyJSONLFilename(repoPath string) DoctorCheck { Detail: "Having multiple JSONL files can cause sync and merge conflicts.\n" + " Only one JSONL file should be used per repository.", Fix: "Determine which file is current and remove the others:\n" + - " 1. Check 'bd stats' to see which file is being used\n" + + " 1. Check .beads/metadata.json for 'jsonl_export' setting\n" + " 2. Verify with 'git log .beads/*.jsonl' to see commit history\n" + " 3. Remove the unused file(s): git rm .beads/.jsonl\n" + " 4. Commit the change",