feat(doctor): add database corruption recovery to --fix
Adds automatic database recovery when bd doctor --fix detects corruption: - Detects SQLite corruption (malformed database, SQLITE_CORRUPT errors) - Backs up corrupted database before recovery attempt - Rebuilds from JSONL if available (issues.jsonl, deletions.jsonl) - Falls back to fresh database if JSONL unavailable - Reports recovery results (issues imported, success/failure) Recovery is triggered automatically by --fix when corruption is detected. No manual intervention required.
This commit is contained in:
@@ -375,6 +375,9 @@ func applyFixList(path string, fixes []doctorCheck) {
|
|||||||
err = fix.DatabaseVersion(path)
|
err = fix.DatabaseVersion(path)
|
||||||
case "Schema Compatibility":
|
case "Schema Compatibility":
|
||||||
err = fix.SchemaCompatibility(path)
|
err = fix.SchemaCompatibility(path)
|
||||||
|
case "Database Integrity":
|
||||||
|
// Corruption detected - try recovery from JSONL
|
||||||
|
err = fix.DatabaseCorruptionRecovery(path)
|
||||||
case "Repo Fingerprint":
|
case "Repo Fingerprint":
|
||||||
err = fix.RepoFingerprint(path)
|
err = fix.RepoFingerprint(path)
|
||||||
case "Git Merge Driver":
|
case "Git Merge Driver":
|
||||||
@@ -432,6 +435,10 @@ func applyFixList(path string, fixes []doctorCheck) {
|
|||||||
// No auto-fix: compaction requires agent review
|
// No auto-fix: compaction requires agent review
|
||||||
fmt.Printf(" ⚠ Run 'bd compact --analyze' to review candidates\n")
|
fmt.Printf(" ⚠ Run 'bd compact --analyze' to review candidates\n")
|
||||||
continue
|
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:
|
default:
|
||||||
fmt.Printf(" ⚠ No automatic fix available for %s\n", check.Name)
|
fmt.Printf(" ⚠ No automatic fix available for %s\n", check.Name)
|
||||||
fmt.Printf(" Manual fix: %s\n", check.Fix)
|
fmt.Printf(" Manual fix: %s\n", check.Fix)
|
||||||
@@ -837,6 +844,12 @@ func runDiagnostics(path string) doctorResult {
|
|||||||
result.Checks = append(result.Checks, compactionCheck)
|
result.Checks = append(result.Checks, compactionCheck)
|
||||||
// Info only, not a warning - compaction requires human review
|
// 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
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -246,11 +246,28 @@ func CheckDatabaseIntegrity(path string) DoctorCheck {
|
|||||||
// Open database in read-only mode for integrity check
|
// Open database in read-only mode for integrity check
|
||||||
db, err := sql.Open("sqlite3", "file:"+dbPath+"?mode=ro&_pragma=busy_timeout(30000)")
|
db, err := sql.Open("sqlite3", "file:"+dbPath+"?mode=ro&_pragma=busy_timeout(30000)")
|
||||||
if err != nil {
|
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{
|
return DoctorCheck{
|
||||||
Name: "Database Integrity",
|
Name: "Database Integrity",
|
||||||
Status: StatusError,
|
Status: StatusError,
|
||||||
Message: "Failed to open database for integrity check",
|
Message: "Failed to open database for integrity check",
|
||||||
Detail: err.Error(),
|
Detail: err.Error(),
|
||||||
|
Fix: "Database may be corrupted. Restore JSONL from git history, then run 'bd doctor --fix'",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
@@ -259,11 +276,28 @@ func CheckDatabaseIntegrity(path string) DoctorCheck {
|
|||||||
// This checks the entire database for corruption
|
// This checks the entire database for corruption
|
||||||
rows, err := db.Query("PRAGMA integrity_check")
|
rows, err := db.Query("PRAGMA integrity_check")
|
||||||
if err != nil {
|
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{
|
return DoctorCheck{
|
||||||
Name: "Database Integrity",
|
Name: "Database Integrity",
|
||||||
Status: StatusError,
|
Status: StatusError,
|
||||||
Message: "Failed to run integrity check",
|
Message: "Failed to run integrity check",
|
||||||
Detail: err.Error(),
|
Detail: err.Error(),
|
||||||
|
Fix: "Database may be corrupted. Restore JSONL from git history, then run 'bd doctor --fix'",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
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{
|
return DoctorCheck{
|
||||||
Name: "Database Integrity",
|
Name: "Database Integrity",
|
||||||
Status: StatusError,
|
Status: StatusError,
|
||||||
Message: "Database corruption detected",
|
Message: "Database corruption detected",
|
||||||
Detail: strings.Join(results, "; "),
|
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
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
81
cmd/bd/doctor/fix/recovery.go
Normal file
81
cmd/bd/doctor/fix/recovery.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -145,6 +145,8 @@ func CheckSyncBranchHookCompatibility(path string) DoctorCheck {
|
|||||||
Status: StatusWarning,
|
Status: StatusWarning,
|
||||||
Message: "Pre-push hook is not a bd hook",
|
Message: "Pre-push hook is not a bd hook",
|
||||||
Detail: "Cannot verify sync-branch compatibility with custom hooks",
|
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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ func CheckLegacyJSONLFilename(repoPath string) DoctorCheck {
|
|||||||
Detail: "Having multiple JSONL files can cause sync and merge conflicts.\n" +
|
Detail: "Having multiple JSONL files can cause sync and merge conflicts.\n" +
|
||||||
" Only one JSONL file should be used per repository.",
|
" Only one JSONL file should be used per repository.",
|
||||||
Fix: "Determine which file is current and remove the others:\n" +
|
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" +
|
" 2. Verify with 'git log .beads/*.jsonl' to see commit history\n" +
|
||||||
" 3. Remove the unused file(s): git rm .beads/<unused>.jsonl\n" +
|
" 3. Remove the unused file(s): git rm .beads/<unused>.jsonl\n" +
|
||||||
" 4. Commit the change",
|
" 4. Commit the change",
|
||||||
|
|||||||
Reference in New Issue
Block a user