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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user