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:
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user