doctor: add JSONL integrity check/fix and harden repairs

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
Jordan Hubbard
2025-12-26 08:18:25 -04:00
parent 1a4f06ef8c
commit 8166207eb4
10 changed files with 663 additions and 31 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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