diff --git a/cmd/bd/doctor.go b/cmd/bd/doctor.go index 7fc9c512..7b2abb3c 100644 --- a/cmd/bd/doctor.go +++ b/cmd/bd/doctor.go @@ -48,6 +48,8 @@ var ( doctorOutput string // export diagnostics to file doctorFixChildParent bool // opt-in fix for child→parent deps doctorVerbose bool // show detailed output during fixes + doctorForce bool // force repair mode, bypass validation where safe + doctorSource string // source of truth selection: auto, jsonl, db perfMode bool checkHealthMode bool doctorCheckFlag string // run specific check (e.g., "pollution") @@ -117,6 +119,8 @@ Examples: bd doctor --fix --yes # Automatically fix issues (no confirmation) bd doctor --fix -i # Confirm each fix individually bd doctor --fix --fix-child-parent # Also fix child→parent deps (opt-in) + bd doctor --fix --force # Force repair even when database can't be opened + bd doctor --fix --source=jsonl # Rebuild database from JSONL (source of truth) bd doctor --dry-run # Preview what --fix would do without making changes bd doctor --perf # Performance diagnostics bd doctor --output diagnostics.json # Export diagnostics to file @@ -219,6 +223,8 @@ func init() { doctorCmd.Flags().BoolVar(&doctorDryRun, "dry-run", false, "Preview fixes without making changes") doctorCmd.Flags().BoolVar(&doctorFixChildParent, "fix-child-parent", false, "Remove child→parent dependencies (opt-in)") doctorCmd.Flags().BoolVarP(&doctorVerbose, "verbose", "v", false, "Show detailed output during fixes (e.g., list each removed dependency)") + doctorCmd.Flags().BoolVar(&doctorForce, "force", false, "Force repair mode: attempt recovery even when database cannot be opened") + doctorCmd.Flags().StringVar(&doctorSource, "source", "auto", "Choose source of truth for recovery: auto (detect), jsonl (prefer JSONL), db (prefer database)") } func runDiagnostics(path string) doctorResult { diff --git a/cmd/bd/doctor/database.go b/cmd/bd/doctor/database.go index c90a7747..47e23fb7 100644 --- a/cmd/bd/doctor/database.go +++ b/cmd/bd/doctor/database.go @@ -249,28 +249,66 @@ func CheckDatabaseIntegrity(path string) DoctorCheck { // Open database in read-only mode for integrity check db, err := sql.Open("sqlite3", sqliteConnString(dbPath, true)) if err != nil { + // Classify the error type for better recovery guidance + errMsg := err.Error() + var errorType string + var recoverySteps string + // 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", + // Classify error type + if strings.Contains(errMsg, "database is locked") { + errorType = "Database is locked" + recoverySteps = "1. Check for running bd processes: ps aux | grep bd\n" + + "2. Kill any stale processes\n" + + "3. Remove stale locks: rm .beads/beads.db-shm .beads/beads.db-wal .beads/daemon.lock\n" + + "4. Retry: bd doctor --fix" + } else if strings.Contains(errMsg, "not a database") || strings.Contains(errMsg, "file is not a database") { + errorType = "File is not a valid SQLite database" + if jsonlErr == nil && jsonlCount > 0 { + recoverySteps = fmt.Sprintf("Database file is corrupted beyond repair.\n\n"+ + "Recovery steps:\n"+ + "1. Backup corrupt database: mv .beads/beads.db .beads/beads.db.broken\n"+ + "2. Rebuild from JSONL (%d issues): bd doctor --fix --force --source=jsonl\n"+ + "3. Verify: bd stats", jsonlCount) + } else { + recoverySteps = "Database file is corrupted and no JSONL backup found.\n" + + "Manual recovery required:\n" + + "1. Restore from git: git checkout HEAD -- .beads/issues.jsonl\n" + + "2. Rebuild database: bd doctor --fix --force" + } + } else if strings.Contains(errMsg, "migration") || strings.Contains(errMsg, "validation failed") { + errorType = "Database migration or validation failed" + if jsonlErr == nil && jsonlCount > 0 { + recoverySteps = fmt.Sprintf("Database has validation errors (possibly orphaned dependencies).\n\n"+ + "Recovery steps:\n"+ + "1. Backup database: mv .beads/beads.db .beads/beads.db.broken\n"+ + "2. Rebuild from JSONL (%d issues): bd doctor --fix --force --source=jsonl\n"+ + "3. Verify: bd stats\n\n"+ + "Alternative: bd doctor --fix --force (attempts to repair in-place)", jsonlCount) + } else { + recoverySteps = "Database validation failed and no JSONL backup available.\n" + + "Try: bd doctor --fix --force" + } + } else { + errorType = "Failed to open database" + if jsonlErr == nil && jsonlCount > 0 { + recoverySteps = fmt.Sprintf("Run 'bd doctor --fix --source=jsonl' to rebuild from JSONL (%d issues)", jsonlCount) + } else { + recoverySteps = "Run 'bd doctor --fix --force' to attempt recovery" } } return DoctorCheck{ Name: "Database Integrity", Status: StatusError, - Message: "Failed to open database for integrity check", - Detail: err.Error(), - Fix: "Run 'bd doctor --fix' to back up the corrupt DB and rebuild from JSONL (if available), or restore from backup", + Message: errorType, + Detail: fmt.Sprintf("%s\n\nError: %s", recoverySteps, errMsg), + Fix: "See recovery steps above", } } defer db.Close() @@ -279,28 +317,66 @@ func CheckDatabaseIntegrity(path string) DoctorCheck { // This checks the entire database for corruption rows, err := db.Query("PRAGMA integrity_check") if err != nil { + // Classify the error type for better recovery guidance + errMsg := err.Error() + var errorType string + var recoverySteps string + // 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", + // Classify error type (same logic as opening errors) + if strings.Contains(errMsg, "database is locked") { + errorType = "Database is locked" + recoverySteps = "1. Check for running bd processes: ps aux | grep bd\n" + + "2. Kill any stale processes\n" + + "3. Remove stale locks: rm .beads/beads.db-shm .beads/beads.db-wal .beads/daemon.lock\n" + + "4. Retry: bd doctor --fix" + } else if strings.Contains(errMsg, "not a database") || strings.Contains(errMsg, "file is not a database") { + errorType = "File is not a valid SQLite database" + if jsonlErr == nil && jsonlCount > 0 { + recoverySteps = fmt.Sprintf("Database file is corrupted beyond repair.\n\n"+ + "Recovery steps:\n"+ + "1. Backup corrupt database: mv .beads/beads.db .beads/beads.db.broken\n"+ + "2. Rebuild from JSONL (%d issues): bd doctor --fix --force --source=jsonl\n"+ + "3. Verify: bd stats", jsonlCount) + } else { + recoverySteps = "Database file is corrupted and no JSONL backup found.\n" + + "Manual recovery required:\n" + + "1. Restore from git: git checkout HEAD -- .beads/issues.jsonl\n" + + "2. Rebuild database: bd doctor --fix --force" + } + } else if strings.Contains(errMsg, "migration") || strings.Contains(errMsg, "validation failed") { + errorType = "Database migration or validation failed" + if jsonlErr == nil && jsonlCount > 0 { + recoverySteps = fmt.Sprintf("Database has validation errors (possibly orphaned dependencies).\n\n"+ + "Recovery steps:\n"+ + "1. Backup database: mv .beads/beads.db .beads/beads.db.broken\n"+ + "2. Rebuild from JSONL (%d issues): bd doctor --fix --force --source=jsonl\n"+ + "3. Verify: bd stats\n\n"+ + "Alternative: bd doctor --fix --force (attempts to repair in-place)", jsonlCount) + } else { + recoverySteps = "Database validation failed and no JSONL backup available.\n" + + "Try: bd doctor --fix --force" + } + } else { + errorType = "Failed to run integrity check" + if jsonlErr == nil && jsonlCount > 0 { + recoverySteps = fmt.Sprintf("Run 'bd doctor --fix --force --source=jsonl' to rebuild from JSONL (%d issues)", jsonlCount) + } else { + recoverySteps = "Run 'bd doctor --fix --force' to attempt recovery" } } return DoctorCheck{ Name: "Database Integrity", Status: StatusError, - Message: "Failed to run integrity check", - Detail: err.Error(), - Fix: "Run 'bd doctor --fix' to back up the corrupt DB and rebuild from JSONL (if available), or restore from backup", + Message: errorType, + Detail: fmt.Sprintf("%s\n\nError: %s", recoverySteps, errMsg), + Fix: "See recovery steps above", } } defer rows.Close() diff --git a/cmd/bd/doctor/fix/recovery.go b/cmd/bd/doctor/fix/recovery.go index 180fcdcc..93ca51f8 100644 --- a/cmd/bd/doctor/fix/recovery.go +++ b/cmd/bd/doctor/fix/recovery.go @@ -79,3 +79,149 @@ func DatabaseCorruptionRecovery(path string) error { fmt.Printf(" Recovered %d issues from JSONL backup\n", issueCount) return nil } + +// DatabaseCorruptionRecoveryWithOptions recovers a corrupted database with force and source selection support. +// +// Parameters: +// - path: workspace path +// - force: if true, bypasses validation and forces recovery even when database can't be opened +// - source: source of truth selection ("auto", "jsonl", "db") +// +// Force mode is useful when the database has validation errors that prevent normal opening. +// Source selection allows choosing between JSONL and database when both exist but diverge. +func DatabaseCorruptionRecoveryWithOptions(path string, force bool, source 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 + dbExists := false + if _, err := os.Stat(dbPath); err == nil { + dbExists = true + } + + // Find JSONL file + jsonlPath := findJSONLPath(beadsDir) + jsonlExists := jsonlPath != "" + + // Determine source of truth based on --source flag and availability + var useJSONL bool + switch source { + case "jsonl": + // Explicit JSONL preference + if !jsonlExists { + return fmt.Errorf("--source=jsonl specified but no JSONL file found") + } + useJSONL = true + fmt.Println(" Using JSONL as source of truth (--source=jsonl)") + case "db": + // Explicit database preference + if !dbExists { + return fmt.Errorf("--source=db specified but no database found") + } + useJSONL = false + fmt.Println(" Using database as source of truth (--source=db)") + case "auto": + // Auto-detect: prefer JSONL if database is corrupted, otherwise prefer database + if !dbExists && jsonlExists { + useJSONL = true + fmt.Println(" Using JSONL as source of truth (database missing)") + } else if dbExists && !jsonlExists { + useJSONL = false + fmt.Println(" Using database as source of truth (JSONL missing)") + } else if !dbExists && !jsonlExists { + return fmt.Errorf("neither database nor JSONL found - cannot recover") + } else { + // Both exist - prefer JSONL for recovery since we're in corruption recovery + useJSONL = true + fmt.Println(" Using JSONL as source of truth (auto-detected, database appears corrupted)") + } + default: + return fmt.Errorf("invalid --source value: %s (valid values: auto, jsonl, db)", source) + } + + // If force mode is enabled, always use JSONL recovery (bypass database validation) + if force { + useJSONL = true + fmt.Println(" Force mode enabled: bypassing database validation, using JSONL recovery") + } + + // If using database as source, just run migration (no recovery needed) + if !useJSONL { + fmt.Println(" Database is the source of truth - skipping recovery") + return nil + } + + // JSONL recovery path + 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 existing database if it exists + if dbExists { + backupPath := dbPath + ".corrupt" + fmt.Printf(" Backing up database to %s\n", filepath.Base(backupPath)) + if err := os.Rename(dbPath, backupPath); err != nil { + return fmt.Errorf("failed to backup database: %w", err) + } + } + + // Get bd binary path + bdBinary, err := getBdBinary() + if err != nil { + // Restore database on failure if it existed + if dbExists { + backupPath := dbPath + ".corrupt" + _ = 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)) + importArgs := []string{"import", "-i", jsonlPath, "--rename-on-import"} + if force { + // Force mode: skip git history checks, import from working tree + importArgs = append(importArgs, "--force", "--no-git-history") + } + + cmd := exec.Command(bdBinary, importArgs...) // #nosec G204 + cmd.Dir = path + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + // Keep backup on failure + if dbExists { + backupPath := dbPath + ".corrupt" + fmt.Printf(" Warning: recovery failed, 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_fix.go b/cmd/bd/doctor_fix.go index c5cc44ac..67fe9a5c 100644 --- a/cmd/bd/doctor_fix.go +++ b/cmd/bd/doctor_fix.go @@ -237,7 +237,8 @@ func applyFixList(path string, fixes []doctorCheck) { err = fix.DatabaseVersion(path) case "Database Integrity": // Corruption detected - try recovery from JSONL - err = fix.DatabaseCorruptionRecovery(path) + // Pass force and source flags for enhanced recovery + err = fix.DatabaseCorruptionRecoveryWithOptions(path, doctorForce, doctorSource) case "Schema Compatibility": err = fix.SchemaCompatibility(path) case "Repo Fingerprint":