Merge pull request #805 from kraitsura/feature/bd-doctor-enhancements

feat: Enhance bd doctor with force repair and source selection
This commit is contained in:
Steve Yegge
2025-12-30 10:34:41 -08:00
committed by GitHub
4 changed files with 250 additions and 21 deletions

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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
}

View File

@@ -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":