From 1a4f06ef8c1f2945f256703815d5a690cc902413 Mon Sep 17 00:00:00 2001 From: Jordan Hubbard Date: Fri, 26 Dec 2025 04:29:29 -0400 Subject: [PATCH] doctor: harden corruption repair and JSONL config Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- cmd/bd/doctor.go | 22 +++++++++ cmd/bd/doctor/config_values.go | 4 ++ cmd/bd/doctor/config_values_test.go | 15 ++++++ cmd/bd/doctor/fix/common.go | 7 +++ cmd/bd/doctor/fix/daemon.go | 3 +- cmd/bd/doctor/fix/database_config.go | 26 +++++++++- cmd/bd/doctor/fix/database_config_test.go | 50 +++++++++++++++++++ cmd/bd/doctor/fix/database_integrity.go | 60 ++++++----------------- cmd/bd/doctor/fix/hooks.go | 2 +- cmd/bd/doctor/fix/migrate.go | 7 ++- cmd/bd/doctor/fix/repo_fingerprint.go | 11 ++--- cmd/bd/doctor/fix/sync.go | 34 +++++++++---- cmd/bd/doctor/fix/sync_branch.go | 3 +- cmd/bd/doctor/legacy.go | 32 +++++++++--- cmd/bd/doctor/legacy_test.go | 43 ++++++++++++++++ cmd/bd/doctor_repair_chaos_test.go | 8 +++ cmd/bd/main.go | 17 ++++++- 17 files changed, 264 insertions(+), 80 deletions(-) diff --git a/cmd/bd/doctor.go b/cmd/bd/doctor.go index ea03cc99..f113a078 100644 --- a/cmd/bd/doctor.go +++ b/cmd/bd/doctor.go @@ -353,6 +353,28 @@ 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, + } + slices.SortStableFunc(fixes, func(a, b doctorCheck) int { + pa, oka := priority[a.Name] + if !oka { + pa = 1000 + } + pb, okb := priority[b.Name] + if !okb { + pb = 1000 + } + if pa < pb { + return -1 + } + if pa > pb { + return 1 + } + return 0 + }) + fixedCount := 0 errorCount := 0 diff --git a/cmd/bd/doctor/config_values.go b/cmd/bd/doctor/config_values.go index 45e7c995..1f81c4a0 100644 --- a/cmd/bd/doctor/config_values.go +++ b/cmd/bd/doctor/config_values.go @@ -316,6 +316,10 @@ func checkMetadataConfigValues(repoPath string) []string { // Validate jsonl_export filename if cfg.JSONLExport != "" { + switch cfg.JSONLExport { + case "deletions.jsonl", "interactions.jsonl", "molecules.jsonl": + issues = append(issues, fmt.Sprintf("metadata.json jsonl_export: %q is a system file and should not be configured as a JSONL export (expected issues.jsonl)", cfg.JSONLExport)) + } if strings.Contains(cfg.JSONLExport, string(os.PathSeparator)) || strings.Contains(cfg.JSONLExport, "/") { issues = append(issues, fmt.Sprintf("metadata.json jsonl_export: %q should be a filename, not a path", cfg.JSONLExport)) } diff --git a/cmd/bd/doctor/config_values_test.go b/cmd/bd/doctor/config_values_test.go index 04bf60ba..1fc844d8 100644 --- a/cmd/bd/doctor/config_values_test.go +++ b/cmd/bd/doctor/config_values_test.go @@ -213,6 +213,21 @@ func TestCheckMetadataConfigValues(t *testing.T) { t.Error("expected issues for wrong jsonl extension") } }) + + t.Run("jsonl_export cannot be system file", func(t *testing.T) { + metadataContent := `{ + "database": "beads.db", + "jsonl_export": "interactions.jsonl" +}` + if err := os.WriteFile(filepath.Join(beadsDir, "metadata.json"), []byte(metadataContent), 0644); err != nil { + t.Fatalf("failed to write metadata.json: %v", err) + } + + issues := checkMetadataConfigValues(tmpDir) + if len(issues) == 0 { + t.Error("expected issues for system jsonl_export") + } + }) } func contains(s, substr string) bool { diff --git a/cmd/bd/doctor/fix/common.go b/cmd/bd/doctor/fix/common.go index f7276f3b..771f38f2 100644 --- a/cmd/bd/doctor/fix/common.go +++ b/cmd/bd/doctor/fix/common.go @@ -12,6 +12,13 @@ import ( // This prevents fork bombs when tests call functions that execute bd subcommands. var ErrTestBinary = fmt.Errorf("running as test binary - cannot execute bd subcommands") +func newBdCmd(bdBinary string, args ...string) *exec.Cmd { + fullArgs := append([]string{"--no-daemon"}, args...) + cmd := exec.Command(bdBinary, fullArgs...) // #nosec G204 -- bdBinary from validated executable path + cmd.Env = append(os.Environ(), "BEADS_NO_DAEMON=1") + return cmd +} + // getBdBinary returns the path to the bd binary to use for fix operations. // It prefers the current executable to avoid command injection attacks. // Returns ErrTestBinary if running as a test binary to prevent fork bombs. diff --git a/cmd/bd/doctor/fix/daemon.go b/cmd/bd/doctor/fix/daemon.go index 79a892de..e48c41df 100644 --- a/cmd/bd/doctor/fix/daemon.go +++ b/cmd/bd/doctor/fix/daemon.go @@ -3,7 +3,6 @@ package fix import ( "fmt" "os" - "os/exec" "path/filepath" ) @@ -36,7 +35,7 @@ func Daemon(path string) error { } // Run bd daemons killall to clean up stale daemons - cmd := exec.Command(bdBinary, "daemons", "killall") // #nosec G204 -- bdBinary from validated executable path + cmd := newBdCmd(bdBinary, "daemons", "killall") cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr diff --git a/cmd/bd/doctor/fix/database_config.go b/cmd/bd/doctor/fix/database_config.go index 2c8bc539..34d9686d 100644 --- a/cmd/bd/doctor/fix/database_config.go +++ b/cmd/bd/doctor/fix/database_config.go @@ -32,6 +32,13 @@ func DatabaseConfig(path string) error { fixed := false + // Never treat system JSONL files as a JSONL export configuration. + if isSystemJSONLFilename(cfg.JSONLExport) { + fmt.Printf(" Updating jsonl_export: %s → issues.jsonl\n", cfg.JSONLExport) + cfg.JSONLExport = "issues.jsonl" + fixed = true + } + // Check if configured JSONL exists if cfg.JSONLExport != "" { jsonlPath := cfg.JSONLPath(beadsDir) @@ -99,7 +106,15 @@ func findActualJSONLFile(beadsDir string) string { strings.Contains(lowerName, ".orig") || strings.Contains(lowerName, ".bak") || strings.Contains(lowerName, "~") || - strings.HasPrefix(lowerName, "backup_") { + strings.HasPrefix(lowerName, "backup_") || + // System files are not JSONL exports. + name == "deletions.jsonl" || + name == "interactions.jsonl" || + name == "molecules.jsonl" || + // Git merge conflict artifacts (e.g., issues.base.jsonl, issues.left.jsonl) + strings.Contains(lowerName, ".base.jsonl") || + strings.Contains(lowerName, ".left.jsonl") || + strings.Contains(lowerName, ".right.jsonl") { continue } @@ -121,6 +136,15 @@ func findActualJSONLFile(beadsDir string) string { return candidates[0] } +func isSystemJSONLFilename(name string) bool { + switch name { + case "deletions.jsonl", "interactions.jsonl", "molecules.jsonl": + return true + default: + return false + } +} + // LegacyJSONLConfig migrates from legacy beads.jsonl to canonical issues.jsonl. // This renames the file, updates metadata.json, and updates .gitattributes if present. // bd-6xd: issues.jsonl is the canonical filename diff --git a/cmd/bd/doctor/fix/database_config_test.go b/cmd/bd/doctor/fix/database_config_test.go index 42f2642b..5ae00a2a 100644 --- a/cmd/bd/doctor/fix/database_config_test.go +++ b/cmd/bd/doctor/fix/database_config_test.go @@ -220,3 +220,53 @@ func TestLegacyJSONLConfig_UpdatesGitattributes(t *testing.T) { t.Errorf("Expected .gitattributes to reference issues.jsonl, got: %q", string(content)) } } + +// TestFindActualJSONLFile_SkipsSystemFiles ensures system JSONL files are never treated as JSONL exports. +func TestFindActualJSONLFile_SkipsSystemFiles(t *testing.T) { + tmpDir := t.TempDir() + + // Only system files → no candidates. + if err := os.WriteFile(filepath.Join(tmpDir, "interactions.jsonl"), []byte(`{"id":"x"}`), 0644); err != nil { + t.Fatal(err) + } + if got := findActualJSONLFile(tmpDir); got != "" { + t.Fatalf("expected empty result, got %q", got) + } + + // System + legacy export → legacy wins. + if err := os.WriteFile(filepath.Join(tmpDir, "beads.jsonl"), []byte(`{"id":"x"}`), 0644); err != nil { + t.Fatal(err) + } + if got := findActualJSONLFile(tmpDir); got != "beads.jsonl" { + t.Fatalf("expected beads.jsonl, got %q", got) + } +} + +func TestDatabaseConfigFix_RejectsSystemJSONLExport(t *testing.T) { + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.Mkdir(beadsDir, 0755); err != nil { + t.Fatalf("Failed to create .beads dir: %v", err) + } + + if err := os.WriteFile(filepath.Join(beadsDir, "interactions.jsonl"), []byte(`{"id":"x"}`), 0644); err != nil { + t.Fatalf("Failed to create interactions.jsonl: %v", err) + } + + cfg := &configfile.Config{Database: "beads.db", JSONLExport: "interactions.jsonl"} + if err := cfg.Save(beadsDir); err != nil { + t.Fatalf("Failed to save config: %v", err) + } + + if err := DatabaseConfig(tmpDir); err != nil { + t.Fatalf("DatabaseConfig failed: %v", err) + } + + updated, err := configfile.Load(beadsDir) + if err != nil { + t.Fatalf("Failed to load updated config: %v", err) + } + if updated.JSONLExport != "issues.jsonl" { + t.Fatalf("expected issues.jsonl, got %q", updated.JSONLExport) + } +} diff --git a/cmd/bd/doctor/fix/database_integrity.go b/cmd/bd/doctor/fix/database_integrity.go index 8c8e39c9..5791ae11 100644 --- a/cmd/bd/doctor/fix/database_integrity.go +++ b/cmd/bd/doctor/fix/database_integrity.go @@ -1,22 +1,18 @@ package fix import ( - "bufio" - "encoding/json" "fmt" "os" - "os/exec" "path/filepath" "time" "github.com/steveyegge/beads/internal/beads" "github.com/steveyegge/beads/internal/configfile" - "github.com/steveyegge/beads/internal/utils" ) // DatabaseIntegrity attempts to recover from database corruption by: // 1. Backing up the corrupt database (and WAL/SHM if present) -// 2. Re-initializing the database from the git-tracked JSONL export +// 2. Re-initializing the database from the working tree JSONL export // // This is intentionally conservative: it will not delete JSONL, and it preserves the // original DB as a backup for forensic recovery. @@ -74,61 +70,35 @@ func DatabaseIntegrity(path string) error { } } - // Rebuild via bd init, pointing at the same db path. + // Rebuild by importing from the working tree JSONL into a fresh database. bdBinary, err := getBdBinary() if err != nil { return err } - args := []string{"--db", dbPath, "init", "--quiet", "--force", "--skip-hooks", "--skip-merge-driver"} - if prefix := detectPrefixFromJSONL(jsonlPath); prefix != "" { - args = append(args, "--prefix", prefix) - } - - cmd := exec.Command(bdBinary, args...) // #nosec G204 -- bdBinary is a validated executable path + // Use import (not init) so we always hydrate from the working tree JSONL, not git-tracked blobs. + args := []string{"--db", dbPath, "import", "-i", jsonlPath, "--force", "--no-git-history"} + cmd := newBdCmd(bdBinary, args...) cmd.Dir = absPath cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - cmd.Env = os.Environ() if err := cmd.Run(); err != nil { - // Best-effort rollback: if init didn't recreate the db, restore the backup. - if _, statErr := os.Stat(dbPath); os.IsNotExist(statErr) { - _ = os.Rename(backupDB, dbPath) + // Best-effort rollback: attempt to restore the backup, preserving any partial init output. + failedTS := time.Now().UTC().Format("20060102T150405Z") + if _, statErr := os.Stat(dbPath); statErr == nil { + failedDB := dbPath + "." + failedTS + ".failed.init.db" + _ = os.Rename(dbPath, failedDB) for _, suffix := range []string{"-wal", "-shm", "-journal"} { - _ = os.Rename(backupDB+suffix, dbPath+suffix) + _ = os.Rename(dbPath+suffix, failedDB+suffix) } } + _ = os.Rename(backupDB, dbPath) + for _, suffix := range []string{"-wal", "-shm", "-journal"} { + _ = os.Rename(backupDB+suffix, dbPath+suffix) + } return fmt.Errorf("failed to rebuild database from JSONL: %w (backup: %s)", err, backupDB) } return nil } - -func detectPrefixFromJSONL(jsonlPath string) string { - f, err := os.Open(jsonlPath) // #nosec G304 -- jsonlPath is within the workspace - if err != nil { - return "" - } - defer f.Close() - - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := scanner.Bytes() - if len(line) == 0 { - continue - } - var issue struct { - ID string `json:"id"` - } - if err := json.Unmarshal(line, &issue); err != nil { - continue - } - if issue.ID == "" { - continue - } - return utils.ExtractIssuePrefix(issue.ID) - } - - return "" -} diff --git a/cmd/bd/doctor/fix/hooks.go b/cmd/bd/doctor/fix/hooks.go index 12cc67fc..d46131b1 100644 --- a/cmd/bd/doctor/fix/hooks.go +++ b/cmd/bd/doctor/fix/hooks.go @@ -28,7 +28,7 @@ func GitHooks(path string) error { } // Run bd hooks install - cmd := exec.Command(bdBinary, "hooks", "install") // #nosec G204 -- bdBinary from validated executable path + cmd := newBdCmd(bdBinary, "hooks", "install") 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/migrate.go b/cmd/bd/doctor/fix/migrate.go index f03d112f..b9eca8af 100644 --- a/cmd/bd/doctor/fix/migrate.go +++ b/cmd/bd/doctor/fix/migrate.go @@ -3,7 +3,6 @@ package fix import ( "fmt" "os" - "os/exec" "path/filepath" ) @@ -28,7 +27,7 @@ func DatabaseVersion(path string) error { 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 := exec.Command(bdBinary, "init") // #nosec G204 -- bdBinary from validated executable path + cmd := newBdCmd(bdBinary, "init") cmd.Dir = path cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -41,8 +40,8 @@ func DatabaseVersion(path string) error { } // Database exists - run bd migrate - cmd := exec.Command(bdBinary, "migrate") // #nosec G204 -- bdBinary from validated executable path - cmd.Dir = path // Set working directory without changing process dir + cmd := newBdCmd(bdBinary, "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/repo_fingerprint.go b/cmd/bd/doctor/fix/repo_fingerprint.go index 3a689071..4ca9644c 100644 --- a/cmd/bd/doctor/fix/repo_fingerprint.go +++ b/cmd/bd/doctor/fix/repo_fingerprint.go @@ -3,7 +3,6 @@ package fix import ( "fmt" "os" - "os/exec" "path/filepath" "strings" ) @@ -31,9 +30,9 @@ func readLineUnbuffered() (string, error) { // RepoFingerprint fixes repo fingerprint mismatches by prompting the user // for which action to take. This is interactive because the consequences // differ significantly between options: -// 1. Update repo ID (if URL changed or bd upgraded) -// 2. Reinitialize database (if wrong database was copied) -// 3. Skip (do nothing) +// 1. Update repo ID (if URL changed or bd upgraded) +// 2. Reinitialize database (if wrong database was copied) +// 3. Skip (do nothing) func RepoFingerprint(path string) error { // Validate workspace if err := validateBeadsWorkspace(path); err != nil { @@ -67,7 +66,7 @@ func RepoFingerprint(path string) error { case "1": // Run bd migrate --update-repo-id fmt.Println(" → Running 'bd migrate --update-repo-id'...") - cmd := exec.Command(bdBinary, "migrate", "--update-repo-id") // #nosec G204 -- bdBinary from validated executable path + cmd := newBdCmd(bdBinary, "migrate", "--update-repo-id") cmd.Dir = path cmd.Stdin = os.Stdin // Allow user to respond to migrate's confirmation prompt cmd.Stdout = os.Stdout @@ -105,7 +104,7 @@ func RepoFingerprint(path string) error { _ = os.Remove(dbPath + "-shm") fmt.Println(" → Running 'bd init'...") - cmd := exec.Command(bdBinary, "init", "--quiet") // #nosec G204 -- bdBinary from validated executable path + cmd := newBdCmd(bdBinary, "init", "--quiet") cmd.Dir = path 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 4024cce6..6801f762 100644 --- a/cmd/bd/doctor/fix/sync.go +++ b/cmd/bd/doctor/fix/sync.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "os" - "os/exec" "path/filepath" _ "github.com/ncruces/go-sqlite3/driver" @@ -102,21 +101,36 @@ func DBJSONLSync(path string) error { return err } - // Run the appropriate sync command - var cmd *exec.Cmd if syncDirection == "export" { // Export DB to JSONL file (must specify -o to write to file, not stdout) jsonlOutputPath := filepath.Join(beadsDir, "issues.jsonl") - cmd = exec.Command(bdBinary, "export", "-o", jsonlOutputPath, "--force") // #nosec G204 -- bdBinary from validated executable path - } else { - cmd = exec.Command(bdBinary, "sync", "--import-only") // #nosec G204 -- bdBinary from validated executable path + exportCmd := newBdCmd(bdBinary, "export", "-o", jsonlOutputPath, "--force") + exportCmd.Dir = path // Set working directory without changing process dir + exportCmd.Stdout = os.Stdout + exportCmd.Stderr = os.Stderr + if err := exportCmd.Run(); err != nil { + return fmt.Errorf("failed to export database to JSONL: %w", err) + } + + // 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.Dir = path + markFreshCmd.Stdout = os.Stdout + markFreshCmd.Stderr = os.Stderr + if err := markFreshCmd.Run(); err != nil { + return fmt.Errorf("failed to mark database as fresh after export: %w", err) + } + + return nil } - cmd.Dir = path // Set working directory without changing process dir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + importCmd := newBdCmd(bdBinary, "sync", "--import-only") + importCmd.Dir = path // Set working directory without changing process dir + importCmd.Stdout = os.Stdout + importCmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { + if err := importCmd.Run(); err != nil { return fmt.Errorf("failed to sync database with JSONL: %w", err) } diff --git a/cmd/bd/doctor/fix/sync_branch.go b/cmd/bd/doctor/fix/sync_branch.go index 06a2388d..88ac1bcc 100644 --- a/cmd/bd/doctor/fix/sync_branch.go +++ b/cmd/bd/doctor/fix/sync_branch.go @@ -32,8 +32,7 @@ func SyncBranchConfig(path string) error { } // Set sync.branch using bd config set - // #nosec G204 - bdBinary is controlled by getBdBinary() which returns os.Executable() - setCmd := exec.Command(bdBinary, "config", "set", "sync.branch", currentBranch) + setCmd := newBdCmd(bdBinary, "config", "set", "sync.branch", currentBranch) setCmd.Dir = path if output, err := setCmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to set sync.branch: %w\nOutput: %s", err, string(output)) diff --git a/cmd/bd/doctor/legacy.go b/cmd/bd/doctor/legacy.go index 589d3b9b..0098afd5 100644 --- a/cmd/bd/doctor/legacy.go +++ b/cmd/bd/doctor/legacy.go @@ -53,7 +53,7 @@ func CheckLegacyBeadsSlashCommands(repoPath string) DoctorCheck { Name: "Legacy Commands", Status: "warning", Message: fmt.Sprintf("Old beads integration detected in %s", strings.Join(filesWithLegacyCommands, ", ")), - Detail: "Found: /beads:* slash command references (deprecated)\n" + + Detail: "Found: /beads:* slash command references (deprecated)\n" + " These commands are token-inefficient (~10.5k tokens per session)", Fix: "Migrate to bd prime hooks for better token efficiency:\n" + "\n" + @@ -104,7 +104,7 @@ func CheckAgentDocumentation(repoPath string) DoctorCheck { Name: "Agent Documentation", Status: "warning", Message: "No agent documentation found", - Detail: "Missing: AGENTS.md or CLAUDE.md\n" + + Detail: "Missing: AGENTS.md or CLAUDE.md\n" + " Documenting workflow helps AI agents work more effectively", Fix: "Add agent documentation:\n" + " • Run 'bd onboard' to create AGENTS.md with workflow guidance\n" + @@ -187,7 +187,7 @@ func CheckLegacyJSONLFilename(repoPath string) DoctorCheck { Name: "JSONL Files", Status: "warning", Message: fmt.Sprintf("Multiple JSONL files found: %s", strings.Join(realJSONLFiles, ", ")), - 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.", Fix: "Determine which file is current and remove the others:\n" + " 1. Check 'bd stats' to see which file is being used\n" + @@ -235,7 +235,7 @@ func CheckLegacyJSONLConfig(repoPath string) DoctorCheck { Name: "JSONL Config", Status: "warning", Message: "Using legacy beads.jsonl filename", - Detail: "The canonical filename is now issues.jsonl (bd-6xd).\n" + + Detail: "The canonical filename is now issues.jsonl (bd-6xd).\n" + " Legacy beads.jsonl is still supported but should be migrated.", Fix: "Run 'bd doctor --fix' to auto-migrate, or manually:\n" + " 1. git mv .beads/beads.jsonl .beads/issues.jsonl\n" + @@ -251,7 +251,7 @@ func CheckLegacyJSONLConfig(repoPath string) DoctorCheck { Status: "warning", Message: "Config references beads.jsonl but issues.jsonl exists", Detail: "metadata.json says beads.jsonl but the actual file is issues.jsonl", - Fix: "Run 'bd doctor --fix' to update the configuration", + Fix: "Run 'bd doctor --fix' to update the configuration", } } } @@ -303,6 +303,16 @@ func CheckDatabaseConfig(repoPath string) DoctorCheck { // Check if configured JSONL exists if cfg.JSONLExport != "" { + if cfg.JSONLExport == "deletions.jsonl" || cfg.JSONLExport == "interactions.jsonl" || cfg.JSONLExport == "molecules.jsonl" { + return DoctorCheck{ + Name: "Database Config", + Status: "error", + Message: fmt.Sprintf("Invalid jsonl_export %q (system file)", cfg.JSONLExport), + Detail: "metadata.json jsonl_export must reference the git-tracked issues export (typically issues.jsonl), not a system log file.", + Fix: "Run 'bd doctor --fix' to reset metadata.json jsonl_export to issues.jsonl, then commit the change.", + } + } + jsonlPath := cfg.JSONLPath(beadsDir) if _, err := os.Stat(jsonlPath); os.IsNotExist(err) { // Check if other .jsonl files exist @@ -315,7 +325,15 @@ func CheckDatabaseConfig(repoPath string) DoctorCheck { lowerName := strings.ToLower(name) if !strings.Contains(lowerName, "backup") && !strings.Contains(lowerName, ".orig") && - !strings.Contains(lowerName, ".bak") { + !strings.Contains(lowerName, ".bak") && + !strings.Contains(lowerName, "~") && + !strings.HasPrefix(lowerName, "backup_") && + name != "deletions.jsonl" && + name != "interactions.jsonl" && + name != "molecules.jsonl" && + !strings.Contains(lowerName, ".base.jsonl") && + !strings.Contains(lowerName, ".left.jsonl") && + !strings.Contains(lowerName, ".right.jsonl") { otherJSONLs = append(otherJSONLs, name) } } @@ -421,7 +439,7 @@ func CheckFreshClone(repoPath string) DoctorCheck { Name: "Fresh Clone", Status: "warning", Message: fmt.Sprintf("Fresh clone detected (%d issues in %s, no database)", issueCount, jsonlName), - Detail: "This appears to be a freshly cloned repository.\n" + + Detail: "This appears to be a freshly cloned repository.\n" + " The JSONL file contains issues but no local database exists.\n" + " Run 'bd init' to create the database and import existing issues.", Fix: fmt.Sprintf("Run '%s' to initialize the database and import issues", fixCmd), diff --git a/cmd/bd/doctor/legacy_test.go b/cmd/bd/doctor/legacy_test.go index 241c9d75..9c5fb49d 100644 --- a/cmd/bd/doctor/legacy_test.go +++ b/cmd/bd/doctor/legacy_test.go @@ -410,6 +410,49 @@ func TestCheckLegacyJSONLConfig(t *testing.T) { } } +func TestCheckDatabaseConfig_IgnoresSystemJSONLs(t *testing.T) { + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.Mkdir(beadsDir, 0750); err != nil { + t.Fatal(err) + } + + // Configure issues.jsonl, but only create interactions.jsonl. + metadataPath := filepath.Join(beadsDir, "metadata.json") + if err := os.WriteFile(metadataPath, []byte(`{"database":"beads.db","jsonl_export":"issues.jsonl"}`), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(beadsDir, "interactions.jsonl"), []byte(`{"id":"x"}`), 0644); err != nil { + t.Fatal(err) + } + + check := CheckDatabaseConfig(tmpDir) + if check.Status != "ok" { + t.Fatalf("expected ok, got %s: %s\n%s", check.Status, check.Message, check.Detail) + } +} + +func TestCheckDatabaseConfig_SystemJSONLExportIsError(t *testing.T) { + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.Mkdir(beadsDir, 0750); err != nil { + t.Fatal(err) + } + + metadataPath := filepath.Join(beadsDir, "metadata.json") + if err := os.WriteFile(metadataPath, []byte(`{"database":"beads.db","jsonl_export":"interactions.jsonl"}`), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(beadsDir, "interactions.jsonl"), []byte(`{"id":"x"}`), 0644); err != nil { + t.Fatal(err) + } + + check := CheckDatabaseConfig(tmpDir) + if check.Status != "error" { + t.Fatalf("expected error, got %s: %s", check.Status, check.Message) + } +} + func TestCheckFreshClone(t *testing.T) { tests := []struct { name string diff --git a/cmd/bd/doctor_repair_chaos_test.go b/cmd/bd/doctor_repair_chaos_test.go index 792327d4..e9d9ee7f 100644 --- a/cmd/bd/doctor_repair_chaos_test.go +++ b/cmd/bd/doctor_repair_chaos_test.go @@ -67,6 +67,14 @@ func TestDoctorRepair_CorruptDatabase_NoJSONL_FixFails(t *testing.T) { if !strings.Contains(out, "cannot auto-recover") { t.Fatalf("expected auto-recover error, got:\n%s", out) } + + // Ensure we don't mis-configure jsonl_export to a system file during failure. + metadata, readErr := os.ReadFile(filepath.Join(ws, ".beads", "metadata.json")) + if readErr == nil { + if strings.Contains(string(metadata), "interactions.jsonl") { + t.Fatalf("unexpected metadata.json jsonl_export set to interactions.jsonl:\n%s", string(metadata)) + } + } } func TestDoctorRepair_CorruptDatabase_BacksUpSidecars(t *testing.T) { diff --git a/cmd/bd/main.go b/cmd/bd/main.go index 12f27cf5..49f220c8 100644 --- a/cmd/bd/main.go +++ b/cmd/bd/main.go @@ -623,6 +623,13 @@ var rootCmd = &cobra.Command{ FallbackReason: FallbackNone, } + // Doctor should always run in direct mode. It's specifically used to diagnose and + // repair daemon/DB issues, so attempting to connect to (or auto-start) a daemon + // can add noise and timeouts. + if cmd.Name() == "doctor" { + noDaemon = true + } + // Try to connect to daemon first (unless --no-daemon flag is set or worktree safety check fails) if noDaemon { daemonStatus.FallbackReason = FallbackFlagNoDaemon @@ -917,8 +924,14 @@ var rootCmd = &cobra.Command{ if store != nil { _ = store.Close() } - if profileFile != nil { pprof.StopCPUProfile(); _ = profileFile.Close() } - if traceFile != nil { trace.Stop(); _ = traceFile.Close() } + if profileFile != nil { + pprof.StopCPUProfile() + _ = profileFile.Close() + } + if traceFile != nil { + trace.Stop() + _ = traceFile.Close() + } // Cancel the signal context to clean up resources if rootCancel != nil {