From 8166207eb4739cdbab03c8870971652f2d33f0d0 Mon Sep 17 00:00:00 2001 From: Jordan Hubbard Date: Fri, 26 Dec 2025 08:18:25 -0400 Subject: [PATCH] doctor: add JSONL integrity check/fix and harden repairs Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- cmd/bd/doctor.go | 29 +++- cmd/bd/doctor/database.go | 36 +++-- cmd/bd/doctor/fix/database_integrity.go | 26 +++- cmd/bd/doctor/fix/jsonl_integrity.go | 105 +++++++++++++++ cmd/bd/doctor/fix/migrate.go | 12 +- cmd/bd/doctor/fix/sync.go | 30 +++-- cmd/bd/doctor/jsonl_integrity.go | 123 +++++++++++++++++ cmd/bd/doctor/jsonl_integrity_test.go | 43 ++++++ cmd/bd/doctor_repair_chaos_test.go | 172 ++++++++++++++++++++++++ cmd/bd/test_repo_beads_guard_test.go | 118 ++++++++++++++++ 10 files changed, 663 insertions(+), 31 deletions(-) create mode 100644 cmd/bd/doctor/fix/jsonl_integrity.go create mode 100644 cmd/bd/doctor/jsonl_integrity.go create mode 100644 cmd/bd/doctor/jsonl_integrity_test.go create mode 100644 cmd/bd/test_repo_beads_guard_test.go diff --git a/cmd/bd/doctor.go b/cmd/bd/doctor.go index f113a078..90fcf541 100644 --- a/cmd/bd/doctor.go +++ b/cmd/bd/doctor.go @@ -353,9 +353,23 @@ func applyFixesInteractive(path string, issues []doctorCheck) { // applyFixList applies a list of fixes and reports results func applyFixList(path string, fixes []doctorCheck) { - // Run corruption recovery before any operations that need a healthy SQLite DB. - priority := map[string]int{ - "Database Integrity": 0, + // Apply fixes in a dependency-aware order. + // Rough dependency chain: + // permissions/daemon cleanup → config sanity → DB integrity/migrations → DB↔JSONL sync. + order := []string{ + "Permissions", + "Daemon Health", + "Database Config", + "JSONL Config", + "Database Integrity", + "Database", + "Schema Compatibility", + "JSONL Integrity", + "DB-JSONL Sync", + } + priority := make(map[string]int, len(order)) + for i, name := range order { + priority[name] = i } slices.SortStableFunc(fixes, func(a, b doctorCheck) int { pa, oka := priority[a.Name] @@ -411,6 +425,8 @@ func applyFixList(path string, fixes []doctorCheck) { err = fix.DatabaseConfig(path) case "JSONL Config": err = fix.LegacyJSONLConfig(path) + case "JSONL Integrity": + err = fix.JSONLIntegrity(path) case "Deletions Manifest": err = fix.MigrateTombstones(path) case "Untracked Files": @@ -711,6 +727,13 @@ func runDiagnostics(path string) doctorResult { result.Checks = append(result.Checks, configValuesCheck) // Don't fail overall check for config value warnings, just warn + // Check 7b: JSONL integrity (malformed lines, missing IDs) + jsonlIntegrityCheck := convertWithCategory(doctor.CheckJSONLIntegrity(path), doctor.CategoryData) + result.Checks = append(result.Checks, jsonlIntegrityCheck) + if jsonlIntegrityCheck.Status == statusWarning || jsonlIntegrityCheck.Status == statusError { + result.OverallOK = false + } + // Check 8: Daemon health daemonCheck := convertWithCategory(doctor.CheckDaemonStatus(path, Version), doctor.CategoryRuntime) result.Checks = append(result.Checks, daemonCheck) diff --git a/cmd/bd/doctor/database.go b/cmd/bd/doctor/database.go index 0ecf4492..d0952984 100644 --- a/cmd/bd/doctor/database.go +++ b/cmd/bd/doctor/database.go @@ -301,15 +301,30 @@ func CheckDatabaseIntegrity(path string) DoctorCheck { // CheckDatabaseJSONLSync checks if database and JSONL are in sync func CheckDatabaseJSONLSync(path string) DoctorCheck { beadsDir := filepath.Join(path, ".beads") - dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName) - // Find JSONL file - var jsonlPath string - for _, name := range []string{"issues.jsonl", "beads.jsonl"} { - testPath := filepath.Join(beadsDir, name) - if _, err := os.Stat(testPath); err == nil { - jsonlPath = testPath - break + // Resolve database path (respects metadata.json override). + dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName) + if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" { + dbPath = cfg.DatabasePath(beadsDir) + } + + // Find JSONL file (respects metadata.json override when set). + jsonlPath := "" + if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil { + if cfg.JSONLExport != "" && !isSystemJSONLFilename(cfg.JSONLExport) { + p := cfg.JSONLPath(beadsDir) + if _, err := os.Stat(p); err == nil { + jsonlPath = p + } + } + } + if jsonlPath == "" { + for _, name := range []string{"issues.jsonl", "beads.jsonl"} { + testPath := filepath.Join(beadsDir, name) + if _, err := os.Stat(testPath); err == nil { + jsonlPath = testPath + break + } } } @@ -392,11 +407,16 @@ func CheckDatabaseJSONLSync(path string) DoctorCheck { // Use JSONL error if we got it earlier if jsonlErr != nil { + fixMsg := "Run 'bd doctor --fix' to attempt recovery" + if strings.Contains(jsonlErr.Error(), "malformed") { + fixMsg = "Run 'bd doctor --fix' to back up and regenerate the JSONL from the database" + } return DoctorCheck{ Name: "DB-JSONL Sync", Status: StatusWarning, Message: "Unable to read JSONL file", Detail: jsonlErr.Error(), + Fix: fixMsg, } } diff --git a/cmd/bd/doctor/fix/database_integrity.go b/cmd/bd/doctor/fix/database_integrity.go index 5791ae11..aadcbd05 100644 --- a/cmd/bd/doctor/fix/database_integrity.go +++ b/cmd/bd/doctor/fix/database_integrity.go @@ -28,6 +28,9 @@ func DatabaseIntegrity(path string) error { beadsDir := filepath.Join(absPath, ".beads") + // Best-effort: stop any running daemon to reduce the chance of DB file locks. + _ = Daemon(absPath) + // Resolve database path (respects metadata.json database override). var dbPath string if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" { @@ -39,9 +42,11 @@ func DatabaseIntegrity(path string) error { // Find JSONL source of truth. jsonlPath := "" if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil { - candidate := cfg.JSONLPath(beadsDir) - if _, err := os.Stat(candidate); err == nil { - jsonlPath = candidate + if cfg.JSONLExport != "" && !isSystemJSONLFilename(cfg.JSONLExport) { + candidate := cfg.JSONLPath(beadsDir) + if _, err := os.Stat(candidate); err == nil { + jsonlPath = candidate + } } } if jsonlPath == "" { @@ -61,7 +66,12 @@ func DatabaseIntegrity(path string) error { ts := time.Now().UTC().Format("20060102T150405Z") backupDB := dbPath + "." + ts + ".corrupt.backup.db" if err := os.Rename(dbPath, backupDB); err != nil { - return fmt.Errorf("failed to back up database: %w", err) + // Retry once after attempting to kill daemons again (helps on platforms with strict file locks). + _ = Daemon(absPath) + if err2 := os.Rename(dbPath, backupDB); err2 != nil { + // Prefer the original error (more likely root cause). + return fmt.Errorf("failed to back up database: %w", err) + } } for _, suffix := range []string{"-wal", "-shm", "-journal"} { sidecar := dbPath + suffix @@ -84,7 +94,7 @@ func DatabaseIntegrity(path string) error { cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { - // Best-effort rollback: attempt to restore the backup, preserving any partial init output. + // Best-effort rollback: attempt to restore the original DB, while preserving the backup. failedTS := time.Now().UTC().Format("20060102T150405Z") if _, statErr := os.Stat(dbPath); statErr == nil { failedDB := dbPath + "." + failedTS + ".failed.init.db" @@ -93,9 +103,11 @@ func DatabaseIntegrity(path string) error { _ = os.Rename(dbPath+suffix, failedDB+suffix) } } - _ = os.Rename(backupDB, dbPath) + _ = copyFile(backupDB, dbPath) for _, suffix := range []string{"-wal", "-shm", "-journal"} { - _ = os.Rename(backupDB+suffix, dbPath+suffix) + if _, statErr := os.Stat(backupDB + suffix); statErr == nil { + _ = copyFile(backupDB+suffix, dbPath+suffix) + } } return fmt.Errorf("failed to rebuild database from JSONL: %w (backup: %s)", err, backupDB) } diff --git a/cmd/bd/doctor/fix/jsonl_integrity.go b/cmd/bd/doctor/fix/jsonl_integrity.go new file mode 100644 index 00000000..20f14923 --- /dev/null +++ b/cmd/bd/doctor/fix/jsonl_integrity.go @@ -0,0 +1,105 @@ +package fix + +import ( + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/steveyegge/beads/internal/beads" + "github.com/steveyegge/beads/internal/configfile" + "github.com/steveyegge/beads/internal/utils" +) + +// JSONLIntegrity backs up a malformed JSONL export and regenerates it from the database. +// This is safe only when a database exists and is readable. +func JSONLIntegrity(path string) error { + if err := validateBeadsWorkspace(path); err != nil { + return err + } + + absPath, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("failed to resolve path: %w", err) + } + + beadsDir := filepath.Join(absPath, ".beads") + + // Resolve db path. + dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName) + if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" { + dbPath = cfg.DatabasePath(beadsDir) + } + if _, err := os.Stat(dbPath); os.IsNotExist(err) { + return fmt.Errorf("cannot auto-repair JSONL: no database found") + } + + // Resolve JSONL export path. + jsonlPath := "" + if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil { + if cfg.JSONLExport != "" && !isSystemJSONLFilename(cfg.JSONLExport) { + p := cfg.JSONLPath(beadsDir) + if _, err := os.Stat(p); err == nil { + jsonlPath = p + } + } + } + if jsonlPath == "" { + p := utils.FindJSONLInDir(beadsDir) + if _, err := os.Stat(p); err == nil { + jsonlPath = p + } + } + if jsonlPath == "" { + return fmt.Errorf("cannot auto-repair JSONL: no JSONL file found") + } + + // Back up the JSONL. + ts := time.Now().UTC().Format("20060102T150405Z") + backup := jsonlPath + "." + ts + ".corrupt.backup.jsonl" + if err := os.Rename(jsonlPath, backup); err != nil { + return fmt.Errorf("failed to back up JSONL: %w", err) + } + + binary, err := getBdBinary() + if err != nil { + _ = os.Rename(backup, jsonlPath) + return err + } + + // Re-export from DB. + cmd := newBdCmd(binary, "--db", dbPath, "export", "-o", jsonlPath, "--force") + cmd.Dir = absPath + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + // Best-effort rollback: restore the original JSONL, but keep the backup. + failedTS := time.Now().UTC().Format("20060102T150405Z") + if _, statErr := os.Stat(jsonlPath); statErr == nil { + failed := jsonlPath + "." + failedTS + ".failed.regen.jsonl" + _ = os.Rename(jsonlPath, failed) + } + _ = copyFile(backup, jsonlPath) + return fmt.Errorf("failed to regenerate JSONL from database: %w (backup: %s)", err, backup) + } + + return nil +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) // #nosec G304 -- src is within the workspace + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer func() { _ = out.Close() }() + if _, err := io.Copy(out, in); err != nil { + return err + } + return out.Close() +} diff --git a/cmd/bd/doctor/fix/migrate.go b/cmd/bd/doctor/fix/migrate.go index b9eca8af..2c20abb4 100644 --- a/cmd/bd/doctor/fix/migrate.go +++ b/cmd/bd/doctor/fix/migrate.go @@ -4,6 +4,9 @@ import ( "fmt" "os" "path/filepath" + + "github.com/steveyegge/beads/internal/beads" + "github.com/steveyegge/beads/internal/configfile" ) // DatabaseVersion fixes database version mismatches by running bd migrate, @@ -22,12 +25,15 @@ func DatabaseVersion(path string) error { // Check if database exists - if not, run init instead of migrate (bd-4h9) beadsDir := filepath.Join(path, ".beads") - dbPath := filepath.Join(beadsDir, "beads.db") + dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName) + if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" { + dbPath = cfg.DatabasePath(beadsDir) + } if _, err := os.Stat(dbPath); os.IsNotExist(err) { // No database - this is a fresh clone, run bd init fmt.Println("→ No database found, running 'bd init' to hydrate from JSONL...") - cmd := newBdCmd(bdBinary, "init") + cmd := newBdCmd(bdBinary, "--db", dbPath, "init") cmd.Dir = path cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -40,7 +46,7 @@ func DatabaseVersion(path string) error { } // Database exists - run bd migrate - cmd := newBdCmd(bdBinary, "migrate") + cmd := newBdCmd(bdBinary, "--db", dbPath, "migrate") cmd.Dir = path // Set working directory without changing process dir cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr diff --git a/cmd/bd/doctor/fix/sync.go b/cmd/bd/doctor/fix/sync.go index 6801f762..dfc3a1e3 100644 --- a/cmd/bd/doctor/fix/sync.go +++ b/cmd/bd/doctor/fix/sync.go @@ -37,13 +37,23 @@ func DBJSONLSync(path string) error { // Find JSONL file var jsonlPath string - issuesJSONL := filepath.Join(beadsDir, "issues.jsonl") - beadsJSONL := filepath.Join(beadsDir, "beads.jsonl") + if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil { + if cfg.JSONLExport != "" && !isSystemJSONLFilename(cfg.JSONLExport) { + p := cfg.JSONLPath(beadsDir) + if _, err := os.Stat(p); err == nil { + jsonlPath = p + } + } + } + if jsonlPath == "" { + issuesJSONL := filepath.Join(beadsDir, "issues.jsonl") + beadsJSONL := filepath.Join(beadsDir, "beads.jsonl") - if _, err := os.Stat(issuesJSONL); err == nil { - jsonlPath = issuesJSONL - } else if _, err := os.Stat(beadsJSONL); err == nil { - jsonlPath = beadsJSONL + if _, err := os.Stat(issuesJSONL); err == nil { + jsonlPath = issuesJSONL + } else if _, err := os.Stat(beadsJSONL); err == nil { + jsonlPath = beadsJSONL + } } // Check if both database and JSONL exist @@ -103,8 +113,8 @@ func DBJSONLSync(path string) error { if syncDirection == "export" { // Export DB to JSONL file (must specify -o to write to file, not stdout) - jsonlOutputPath := filepath.Join(beadsDir, "issues.jsonl") - exportCmd := newBdCmd(bdBinary, "export", "-o", jsonlOutputPath, "--force") + jsonlOutputPath := jsonlPath + exportCmd := newBdCmd(bdBinary, "--db", dbPath, "export", "-o", jsonlOutputPath, "--force") exportCmd.Dir = path // Set working directory without changing process dir exportCmd.Stdout = os.Stdout exportCmd.Stderr = os.Stderr @@ -114,7 +124,7 @@ func DBJSONLSync(path string) error { // Staleness check uses last_import_time. After exporting, JSONL mtime is newer, // so mark the DB as fresh by running a no-op import (skip existing issues). - markFreshCmd := newBdCmd(bdBinary, "import", "-i", jsonlOutputPath, "--force", "--skip-existing", "--no-git-history") + markFreshCmd := newBdCmd(bdBinary, "--db", dbPath, "import", "-i", jsonlOutputPath, "--force", "--skip-existing", "--no-git-history") markFreshCmd.Dir = path markFreshCmd.Stdout = os.Stdout markFreshCmd.Stderr = os.Stderr @@ -125,7 +135,7 @@ func DBJSONLSync(path string) error { return nil } - importCmd := newBdCmd(bdBinary, "sync", "--import-only") + importCmd := newBdCmd(bdBinary, "--db", dbPath, "sync", "--import-only") importCmd.Dir = path // Set working directory without changing process dir importCmd.Stdout = os.Stdout importCmd.Stderr = os.Stderr diff --git a/cmd/bd/doctor/jsonl_integrity.go b/cmd/bd/doctor/jsonl_integrity.go new file mode 100644 index 00000000..1c84f862 --- /dev/null +++ b/cmd/bd/doctor/jsonl_integrity.go @@ -0,0 +1,123 @@ +package doctor + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/steveyegge/beads/internal/beads" + "github.com/steveyegge/beads/internal/configfile" + "github.com/steveyegge/beads/internal/utils" +) + +func CheckJSONLIntegrity(path string) DoctorCheck { + beadsDir := filepath.Join(path, ".beads") + + // Resolve JSONL path. + jsonlPath := "" + if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil { + if cfg.JSONLExport != "" && !isSystemJSONLFilename(cfg.JSONLExport) { + p := cfg.JSONLPath(beadsDir) + if _, err := os.Stat(p); err == nil { + jsonlPath = p + } + } + } + if jsonlPath == "" { + // Fall back to a best-effort discovery within .beads/. + p := utils.FindJSONLInDir(beadsDir) + if _, err := os.Stat(p); err == nil { + jsonlPath = p + } + } + if jsonlPath == "" { + return DoctorCheck{Name: "JSONL Integrity", Status: StatusOK, Message: "N/A (no JSONL file)"} + } + + // Best-effort scan for malformed lines. + f, err := os.Open(jsonlPath) // #nosec G304 -- jsonlPath is within the workspace + if err != nil { + return DoctorCheck{ + Name: "JSONL Integrity", + Status: StatusWarning, + Message: "Unable to read JSONL file", + Detail: err.Error(), + } + } + defer f.Close() + + var malformed int + var examples []string + scanner := bufio.NewScanner(f) + lineNo := 0 + for scanner.Scan() { + lineNo++ + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var v struct { + ID string `json:"id"` + } + if err := json.Unmarshal([]byte(line), &v); err != nil || v.ID == "" { + malformed++ + if len(examples) < 5 { + if err != nil { + examples = append(examples, fmt.Sprintf("line %d: %v", lineNo, err)) + } else { + examples = append(examples, fmt.Sprintf("line %d: missing id", lineNo)) + } + } + } + } + if err := scanner.Err(); err != nil { + return DoctorCheck{ + Name: "JSONL Integrity", + Status: StatusWarning, + Message: "Unable to scan JSONL file", + Detail: err.Error(), + } + } + if malformed == 0 { + return DoctorCheck{ + Name: "JSONL Integrity", + Status: StatusOK, + Message: fmt.Sprintf("%s looks valid", filepath.Base(jsonlPath)), + } + } + + // If we have a database, we can auto-repair by re-exporting from DB. + dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName) + if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" { + dbPath = cfg.DatabasePath(beadsDir) + } + if _, err := os.Stat(dbPath); os.IsNotExist(err) { + return DoctorCheck{ + Name: "JSONL Integrity", + Status: StatusError, + Message: fmt.Sprintf("%s has %d malformed line(s)", filepath.Base(jsonlPath), malformed), + Detail: strings.Join(examples, "\n"), + Fix: "Restore the JSONL file from git or from a backup (no database available for auto-repair).", + } + } + + return DoctorCheck{ + Name: "JSONL Integrity", + Status: StatusError, + Message: fmt.Sprintf("%s has %d malformed line(s)", filepath.Base(jsonlPath), malformed), + Detail: strings.Join(examples, "\n"), + Fix: "Run 'bd doctor --fix' to back up the JSONL and regenerate it from the database.", + } +} + +func isSystemJSONLFilename(name string) bool { + switch name { + case "deletions.jsonl", "interactions.jsonl", "molecules.jsonl": + return true + default: + return false + } +} diff --git a/cmd/bd/doctor/jsonl_integrity_test.go b/cmd/bd/doctor/jsonl_integrity_test.go new file mode 100644 index 00000000..772e16a5 --- /dev/null +++ b/cmd/bd/doctor/jsonl_integrity_test.go @@ -0,0 +1,43 @@ +package doctor + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCheckJSONLIntegrity_MalformedLine(t *testing.T) { + ws := t.TempDir() + beadsDir := filepath.Join(ws, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatal(err) + } + jsonlPath := filepath.Join(beadsDir, "issues.jsonl") + if err := os.WriteFile(jsonlPath, []byte("{\"id\":\"t-1\"}\n{not json}\n"), 0644); err != nil { + t.Fatal(err) + } + // Ensure DB exists so check suggests auto-repair. + if err := os.WriteFile(filepath.Join(beadsDir, "beads.db"), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + + check := CheckJSONLIntegrity(ws) + if check.Status != StatusError { + t.Fatalf("expected StatusError, got %v (%s)", check.Status, check.Message) + } + if check.Fix == "" { + t.Fatalf("expected Fix guidance") + } +} + +func TestCheckJSONLIntegrity_NoJSONL(t *testing.T) { + ws := t.TempDir() + beadsDir := filepath.Join(ws, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatal(err) + } + check := CheckJSONLIntegrity(ws) + if check.Status != StatusOK { + t.Fatalf("expected StatusOK, got %v (%s)", check.Status, check.Message) + } +} diff --git a/cmd/bd/doctor_repair_chaos_test.go b/cmd/bd/doctor_repair_chaos_test.go index e9d9ee7f..fa469c6f 100644 --- a/cmd/bd/doctor_repair_chaos_test.go +++ b/cmd/bd/doctor_repair_chaos_test.go @@ -3,10 +3,14 @@ package main import ( + "bytes" + "io" "os" + "os/exec" "path/filepath" "strings" "testing" + "time" ) func TestDoctorRepair_CorruptDatabase_NotADatabase_RebuildFromJSONL(t *testing.T) { @@ -131,3 +135,171 @@ func TestDoctorRepair_CorruptDatabase_BacksUpSidecars(t *testing.T) { } } } + +func TestDoctorRepair_CorruptDatabase_WithRunningDaemon_FixSucceeds(t *testing.T) { + bdExe := buildBDForTest(t) + ws := mkTmpDirInTmp(t, "bd-doctor-chaos-daemon-*") + dbPath := filepath.Join(ws, ".beads", "beads.db") + jsonlPath := filepath.Join(ws, ".beads", "issues.jsonl") + + if _, err := runBDSideDB(t, bdExe, ws, dbPath, "init", "--prefix", "chaos", "--quiet"); err != nil { + t.Fatalf("bd init failed: %v", err) + } + if _, err := runBDSideDB(t, bdExe, ws, dbPath, "create", "Chaos issue", "-p", "1"); err != nil { + t.Fatalf("bd create failed: %v", err) + } + if _, err := runBDSideDB(t, bdExe, ws, dbPath, "export", "-o", jsonlPath, "--force"); err != nil { + t.Fatalf("bd export failed: %v", err) + } + + cmd := startDaemonForChaosTest(t, bdExe, ws, dbPath) + defer func() { + if cmd.Process != nil && (cmd.ProcessState == nil || !cmd.ProcessState.Exited()) { + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + } + }() + + // Corrupt the DB. + if err := os.WriteFile(dbPath, []byte("not a database"), 0644); err != nil { + t.Fatalf("corrupt db: %v", err) + } + + if _, err := runBDSideDB(t, bdExe, ws, dbPath, "doctor", "--fix", "--yes"); err != nil { + t.Fatalf("bd doctor --fix failed: %v", err) + } + + // Ensure we can cleanly stop the daemon afterwards (repair shouldn't wedge it). + if cmd.Process != nil { + _ = cmd.Process.Kill() + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case <-time.After(3 * time.Second): + t.Fatalf("expected daemon to exit when killed") + case <-done: + // ok + } + } +} + +func TestDoctorRepair_JSONLIntegrity_MalformedLine_ReexportFromDB(t *testing.T) { + bdExe := buildBDForTest(t) + ws := mkTmpDirInTmp(t, "bd-doctor-chaos-jsonl-*") + dbPath := filepath.Join(ws, ".beads", "beads.db") + jsonlPath := filepath.Join(ws, ".beads", "issues.jsonl") + + if _, err := runBDSideDB(t, bdExe, ws, dbPath, "init", "--prefix", "chaos", "--quiet"); err != nil { + t.Fatalf("bd init failed: %v", err) + } + if _, err := runBDSideDB(t, bdExe, ws, dbPath, "create", "Chaos issue", "-p", "1"); err != nil { + t.Fatalf("bd create failed: %v", err) + } + if _, err := runBDSideDB(t, bdExe, ws, dbPath, "export", "-o", jsonlPath, "--force"); err != nil { + t.Fatalf("bd export failed: %v", err) + } + + // Corrupt JSONL (leave DB intact). + f, err := os.OpenFile(jsonlPath, os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + t.Fatalf("open jsonl: %v", err) + } + if _, err := f.WriteString("{not json}\n"); err != nil { + _ = f.Close() + t.Fatalf("append corrupt jsonl: %v", err) + } + _ = f.Close() + + if _, err := runBDSideDB(t, bdExe, ws, dbPath, "doctor", "--fix", "--yes"); err != nil { + t.Fatalf("bd doctor --fix failed: %v", err) + } + + data, err := os.ReadFile(jsonlPath) + if err != nil { + t.Fatalf("read jsonl: %v", err) + } + if strings.Contains(string(data), "{not json}") { + t.Fatalf("expected JSONL to be regenerated without corrupt line") + } +} + +func TestDoctorRepair_CorruptDatabase_ReadOnlyBeadsDir_PermissionsFixMakesWritable(t *testing.T) { + bdExe := buildBDForTest(t) + ws := mkTmpDirInTmp(t, "bd-doctor-chaos-readonly-*") + beadsDir := filepath.Join(ws, ".beads") + dbPath := filepath.Join(beadsDir, "beads.db") + jsonlPath := filepath.Join(beadsDir, "issues.jsonl") + + if _, err := runBDSideDB(t, bdExe, ws, dbPath, "init", "--prefix", "chaos", "--quiet"); err != nil { + t.Fatalf("bd init failed: %v", err) + } + if _, err := runBDSideDB(t, bdExe, ws, dbPath, "create", "Chaos issue", "-p", "1"); err != nil { + t.Fatalf("bd create failed: %v", err) + } + if _, err := runBDSideDB(t, bdExe, ws, dbPath, "export", "-o", jsonlPath, "--force"); err != nil { + t.Fatalf("bd export failed: %v", err) + } + + // Corrupt the DB. + if err := os.Truncate(dbPath, 64); err != nil { + t.Fatalf("truncate db: %v", err) + } + + // Make .beads read-only; the Permissions fix should make it writable again. + if err := os.Chmod(beadsDir, 0555); err != nil { + t.Fatalf("chmod beads dir: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(beadsDir, 0755) }) + + if out, err := runBDSideDB(t, bdExe, ws, dbPath, "doctor", "--fix", "--yes"); err != nil { + t.Fatalf("expected bd doctor --fix to succeed (permissions auto-fix), got: %v\n%s", err, out) + } + info, err := os.Stat(beadsDir) + if err != nil { + t.Fatalf("stat beads dir: %v", err) + } + if info.Mode().Perm()&0200 == 0 { + t.Fatalf("expected .beads to be writable after permissions fix, mode=%v", info.Mode().Perm()) + } +} + +func startDaemonForChaosTest(t *testing.T, bdExe, ws, dbPath string) *exec.Cmd { + t.Helper() + cmd := exec.Command(bdExe, "--db", dbPath, "daemon", "--start", "--foreground", "--local", "--interval", "10m") + cmd.Dir = ws + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + // Inherit environment, but explicitly ensure daemon mode is allowed. + env := make([]string, 0, len(os.Environ())+1) + for _, e := range os.Environ() { + if strings.HasPrefix(e, "BEADS_NO_DAEMON=") { + continue + } + env = append(env, e) + } + cmd.Env = env + + if err := cmd.Start(); err != nil { + t.Fatalf("start daemon: %v", err) + } + + // Wait for socket to appear. + sock := filepath.Join(ws, ".beads", "bd.sock") + deadline := time.Now().Add(8 * time.Second) + for time.Now().Before(deadline) { + if _, err := os.Stat(sock); err == nil { + // Put the process back into the caller's control. + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + return cmd + } + time.Sleep(50 * time.Millisecond) + } + + _ = cmd.Process.Kill() + _ = cmd.Wait() + t.Fatalf("daemon failed to start (no socket: %s)\nstdout:\n%s\nstderr:\n%s", sock, stdout.String(), stderr.String()) + return nil +} diff --git a/cmd/bd/test_repo_beads_guard_test.go b/cmd/bd/test_repo_beads_guard_test.go new file mode 100644 index 00000000..9f7adddf --- /dev/null +++ b/cmd/bd/test_repo_beads_guard_test.go @@ -0,0 +1,118 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" +) + +// Guardrail: ensure the cmd/bd test suite does not touch the real repo .beads state. +// Disable with BEADS_TEST_GUARD_DISABLE=1 (useful when running tests while actively using beads). +func TestMain(m *testing.M) { + if os.Getenv("BEADS_TEST_GUARD_DISABLE") != "" { + os.Exit(m.Run()) + } + + repoRoot := findRepoRoot() + if repoRoot == "" { + os.Exit(m.Run()) + } + + repoBeadsDir := filepath.Join(repoRoot, ".beads") + if _, err := os.Stat(repoBeadsDir); err != nil { + os.Exit(m.Run()) + } + + watch := []string{ + "beads.db", + "beads.db-wal", + "beads.db-shm", + "beads.db-journal", + "issues.jsonl", + "beads.jsonl", + "metadata.json", + "interactions.jsonl", + "deletions.jsonl", + "molecules.jsonl", + "daemon.lock", + "daemon.pid", + "bd.sock", + } + + before := snapshotFiles(repoBeadsDir, watch) + code := m.Run() + after := snapshotFiles(repoBeadsDir, watch) + + if diff := diffSnapshots(before, after); diff != "" { + fmt.Fprintf(os.Stderr, "ERROR: test suite modified repo .beads state:\n%s\n", diff) + if code == 0 { + code = 1 + } + } + + os.Exit(code) +} + +type fileSnap struct { + exists bool + size int64 + modUnix int64 +} + +func snapshotFiles(dir string, names []string) map[string]fileSnap { + out := make(map[string]fileSnap, len(names)) + for _, name := range names { + p := filepath.Join(dir, name) + info, err := os.Stat(p) + if err != nil { + out[name] = fileSnap{exists: false} + continue + } + out[name] = fileSnap{exists: true, size: info.Size(), modUnix: info.ModTime().UnixNano()} + } + return out +} + +func diffSnapshots(before, after map[string]fileSnap) string { + var out string + for name, b := range before { + a := after[name] + if b.exists != a.exists { + out += fmt.Sprintf("- %s: exists %v → %v\n", name, b.exists, a.exists) + continue + } + if !b.exists { + continue + } + if b.size != a.size || b.modUnix != a.modUnix { + out += fmt.Sprintf("- %s: size %d → %d, mtime %s → %s\n", + name, + b.size, + a.size, + time.Unix(0, b.modUnix).UTC().Format(time.RFC3339Nano), + time.Unix(0, a.modUnix).UTC().Format(time.RFC3339Nano), + ) + } + } + return out +} + +func findRepoRoot() string { + wd, err := os.Getwd() + if err != nil { + return "" + } + for i := 0; i < 25; i++ { + if _, err := os.Stat(filepath.Join(wd, "go.mod")); err == nil { + return wd + } + parent := filepath.Dir(wd) + if parent == wd { + break + } + wd = parent + } + return "" +}