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:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
105
cmd/bd/doctor/fix/jsonl_integrity.go
Normal file
105
cmd/bd/doctor/fix/jsonl_integrity.go
Normal 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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
123
cmd/bd/doctor/jsonl_integrity.go
Normal file
123
cmd/bd/doctor/jsonl_integrity.go
Normal 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
|
||||
}
|
||||
}
|
||||
43
cmd/bd/doctor/jsonl_integrity_test.go
Normal file
43
cmd/bd/doctor/jsonl_integrity_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
118
cmd/bd/test_repo_beads_guard_test.go
Normal file
118
cmd/bd/test_repo_beads_guard_test.go
Normal 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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user