Enhance bd doctor with operational health checks (bd-40a0)
This commit is contained in:
304
cmd/bd/doctor.go
304
cmd/bd/doctor.go
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads"
|
||||
"github.com/steveyegge/beads/internal/daemon"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
@@ -49,6 +50,11 @@ This command checks:
|
||||
- Database version and schema compatibility
|
||||
- Whether using hash-based vs sequential IDs
|
||||
- If CLI version is current (checks GitHub releases)
|
||||
- Multiple database files
|
||||
- Multiple JSONL files
|
||||
- Daemon health (version mismatches, stale processes)
|
||||
- Database-JSONL sync status
|
||||
- File permissions
|
||||
|
||||
Examples:
|
||||
bd doctor # Check current directory
|
||||
@@ -123,6 +129,41 @@ func runDiagnostics(path string) doctorResult {
|
||||
result.Checks = append(result.Checks, versionCheck)
|
||||
// Don't fail overall check for outdated CLI, just warn
|
||||
|
||||
// Check 5: Multiple database files
|
||||
multiDBCheck := checkMultipleDatabases(path)
|
||||
result.Checks = append(result.Checks, multiDBCheck)
|
||||
if multiDBCheck.Status == statusWarning || multiDBCheck.Status == statusError {
|
||||
result.OverallOK = false
|
||||
}
|
||||
|
||||
// Check 6: Multiple JSONL files
|
||||
multiJSONLCheck := checkMultipleJSONLFiles(path)
|
||||
result.Checks = append(result.Checks, multiJSONLCheck)
|
||||
if multiJSONLCheck.Status == statusWarning || multiJSONLCheck.Status == statusError {
|
||||
result.OverallOK = false
|
||||
}
|
||||
|
||||
// Check 7: Daemon health
|
||||
daemonCheck := checkDaemonStatus(path)
|
||||
result.Checks = append(result.Checks, daemonCheck)
|
||||
if daemonCheck.Status == statusWarning || daemonCheck.Status == statusError {
|
||||
result.OverallOK = false
|
||||
}
|
||||
|
||||
// Check 8: Database-JSONL sync
|
||||
syncCheck := checkDatabaseJSONLSync(path)
|
||||
result.Checks = append(result.Checks, syncCheck)
|
||||
if syncCheck.Status == statusWarning || syncCheck.Status == statusError {
|
||||
result.OverallOK = false
|
||||
}
|
||||
|
||||
// Check 9: Permissions
|
||||
permCheck := checkPermissions(path)
|
||||
result.Checks = append(result.Checks, permCheck)
|
||||
if permCheck.Status == statusError {
|
||||
result.OverallOK = false
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -495,6 +536,269 @@ func printDiagnostics(result doctorResult) {
|
||||
}
|
||||
}
|
||||
|
||||
func checkMultipleDatabases(path string) doctorCheck {
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
|
||||
// Find all .db files (excluding backups and vc.db)
|
||||
files, err := filepath.Glob(filepath.Join(beadsDir, "*.db"))
|
||||
if err != nil {
|
||||
return doctorCheck{
|
||||
Name: "Database Files",
|
||||
Status: statusError,
|
||||
Message: "Unable to check for multiple databases",
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out backups and vc.db
|
||||
var dbFiles []string
|
||||
for _, f := range files {
|
||||
base := filepath.Base(f)
|
||||
if !strings.HasSuffix(base, ".backup.db") && base != "vc.db" {
|
||||
dbFiles = append(dbFiles, base)
|
||||
}
|
||||
}
|
||||
|
||||
if len(dbFiles) == 0 {
|
||||
return doctorCheck{
|
||||
Name: "Database Files",
|
||||
Status: statusOK,
|
||||
Message: "No database files (JSONL-only mode)",
|
||||
}
|
||||
}
|
||||
|
||||
if len(dbFiles) == 1 {
|
||||
return doctorCheck{
|
||||
Name: "Database Files",
|
||||
Status: statusOK,
|
||||
Message: "Single database file",
|
||||
}
|
||||
}
|
||||
|
||||
// Multiple databases found
|
||||
return doctorCheck{
|
||||
Name: "Database Files",
|
||||
Status: statusWarning,
|
||||
Message: fmt.Sprintf("Multiple database files found: %s", strings.Join(dbFiles, ", ")),
|
||||
Fix: "Run 'bd migrate' to consolidate databases or manually remove old .db files",
|
||||
}
|
||||
}
|
||||
|
||||
func checkMultipleJSONLFiles(path string) doctorCheck {
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
|
||||
var jsonlFiles []string
|
||||
for _, name := range []string{"issues.jsonl", "beads.jsonl"} {
|
||||
jsonlPath := filepath.Join(beadsDir, name)
|
||||
if _, err := os.Stat(jsonlPath); err == nil {
|
||||
jsonlFiles = append(jsonlFiles, name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(jsonlFiles) == 0 {
|
||||
return doctorCheck{
|
||||
Name: "JSONL Files",
|
||||
Status: statusOK,
|
||||
Message: "No JSONL files found (database-only mode)",
|
||||
}
|
||||
}
|
||||
|
||||
if len(jsonlFiles) == 1 {
|
||||
return doctorCheck{
|
||||
Name: "JSONL Files",
|
||||
Status: statusOK,
|
||||
Message: fmt.Sprintf("Using %s", jsonlFiles[0]),
|
||||
}
|
||||
}
|
||||
|
||||
// Multiple JSONL files found
|
||||
return doctorCheck{
|
||||
Name: "JSONL Files",
|
||||
Status: statusWarning,
|
||||
Message: fmt.Sprintf("Multiple JSONL files found: %s", strings.Join(jsonlFiles, ", ")),
|
||||
Fix: "Standardize on one JSONL file (issues.jsonl recommended). Delete or rename the other.",
|
||||
}
|
||||
}
|
||||
|
||||
func checkDaemonStatus(path string) doctorCheck {
|
||||
// Import daemon discovery from internal package
|
||||
daemons, err := daemon.DiscoverDaemons([]string{path})
|
||||
if err != nil {
|
||||
return doctorCheck{
|
||||
Name: "Daemon Health",
|
||||
Status: statusWarning,
|
||||
Message: "Unable to check daemon health",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
// Filter to this workspace
|
||||
var workspaceDaemons []daemon.DaemonInfo
|
||||
for _, d := range daemons {
|
||||
if d.WorkspacePath == path {
|
||||
workspaceDaemons = append(workspaceDaemons, d)
|
||||
}
|
||||
}
|
||||
|
||||
if len(workspaceDaemons) == 0 {
|
||||
return doctorCheck{
|
||||
Name: "Daemon Health",
|
||||
Status: statusOK,
|
||||
Message: "No daemon running (will auto-start on next command)",
|
||||
}
|
||||
}
|
||||
|
||||
// Check for version mismatches
|
||||
for _, d := range workspaceDaemons {
|
||||
if !d.Alive {
|
||||
return doctorCheck{
|
||||
Name: "Daemon Health",
|
||||
Status: statusWarning,
|
||||
Message: "Stale daemon detected",
|
||||
Fix: "Run 'bd daemons killall' to clean up stale daemons",
|
||||
}
|
||||
}
|
||||
|
||||
if d.Version != Version {
|
||||
return doctorCheck{
|
||||
Name: "Daemon Health",
|
||||
Status: statusWarning,
|
||||
Message: fmt.Sprintf("Version mismatch (daemon: %s, CLI: %s)", d.Version, Version),
|
||||
Fix: "Run 'bd daemons killall' to restart daemons with current version",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return doctorCheck{
|
||||
Name: "Daemon Health",
|
||||
Status: statusOK,
|
||||
Message: fmt.Sprintf("Daemon running (PID %d, version %s)", workspaceDaemons[0].PID, workspaceDaemons[0].Version),
|
||||
}
|
||||
}
|
||||
|
||||
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"} {
|
||||
path := filepath.Join(beadsDir, name)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
jsonlPath = path
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If no database, skip this check
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
return doctorCheck{
|
||||
Name: "DB-JSONL Sync",
|
||||
Status: statusOK,
|
||||
Message: "N/A (no database)",
|
||||
}
|
||||
}
|
||||
|
||||
// If no JSONL, skip this check
|
||||
if jsonlPath == "" {
|
||||
return doctorCheck{
|
||||
Name: "DB-JSONL Sync",
|
||||
Status: statusOK,
|
||||
Message: "N/A (no JSONL file)",
|
||||
}
|
||||
}
|
||||
|
||||
// Compare modification times
|
||||
dbInfo, err := os.Stat(dbPath)
|
||||
if err != nil {
|
||||
return doctorCheck{
|
||||
Name: "DB-JSONL Sync",
|
||||
Status: statusWarning,
|
||||
Message: "Unable to check database file",
|
||||
}
|
||||
}
|
||||
|
||||
jsonlInfo, err := os.Stat(jsonlPath)
|
||||
if err != nil {
|
||||
return doctorCheck{
|
||||
Name: "DB-JSONL Sync",
|
||||
Status: statusWarning,
|
||||
Message: "Unable to check JSONL file",
|
||||
}
|
||||
}
|
||||
|
||||
// If JSONL is newer, warn about potential sync issue
|
||||
if jsonlInfo.ModTime().After(dbInfo.ModTime()) {
|
||||
timeDiff := jsonlInfo.ModTime().Sub(dbInfo.ModTime())
|
||||
if timeDiff > 30*time.Second {
|
||||
return doctorCheck{
|
||||
Name: "DB-JSONL Sync",
|
||||
Status: statusWarning,
|
||||
Message: "JSONL is newer than database",
|
||||
Fix: "Run 'bd sync --import-only' to import JSONL updates",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return doctorCheck{
|
||||
Name: "DB-JSONL Sync",
|
||||
Status: statusOK,
|
||||
Message: "Database and JSONL are in sync",
|
||||
}
|
||||
}
|
||||
|
||||
func checkPermissions(path string) doctorCheck {
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
|
||||
// Check if .beads/ is writable
|
||||
testFile := filepath.Join(beadsDir, ".doctor-test-write")
|
||||
if err := os.WriteFile(testFile, []byte("test"), 0600); err != nil {
|
||||
return doctorCheck{
|
||||
Name: "Permissions",
|
||||
Status: statusError,
|
||||
Message: ".beads/ directory is not writable",
|
||||
Fix: fmt.Sprintf("Fix permissions: chmod u+w %s", beadsDir),
|
||||
}
|
||||
}
|
||||
_ = os.Remove(testFile) // Clean up test file (intentionally ignore error)
|
||||
|
||||
// Check database permissions
|
||||
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
||||
if _, err := os.Stat(dbPath); err == nil {
|
||||
// Try to open database
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return doctorCheck{
|
||||
Name: "Permissions",
|
||||
Status: statusError,
|
||||
Message: "Database file exists but cannot be opened",
|
||||
Fix: fmt.Sprintf("Check database permissions: %s", dbPath),
|
||||
}
|
||||
}
|
||||
_ = db.Close() // Intentionally ignore close error
|
||||
|
||||
// Try a write test
|
||||
db, err = sql.Open("sqlite", dbPath)
|
||||
if err == nil {
|
||||
_, err = db.Exec("SELECT 1")
|
||||
_ = db.Close() // Intentionally ignore close error
|
||||
if err != nil {
|
||||
return doctorCheck{
|
||||
Name: "Permissions",
|
||||
Status: statusError,
|
||||
Message: "Database file is not readable",
|
||||
Fix: fmt.Sprintf("Fix permissions: chmod u+rw %s", dbPath),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return doctorCheck{
|
||||
Name: "Permissions",
|
||||
Status: statusOK,
|
||||
Message: "All permissions OK",
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
doctorCmd.Flags().Bool("json", false, "Output JSON format")
|
||||
rootCmd.AddCommand(doctorCmd)
|
||||
|
||||
@@ -171,3 +171,205 @@ func TestCompareVersions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckMultipleDatabases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dbFiles []string
|
||||
expectedStatus string
|
||||
expectWarning bool
|
||||
}{
|
||||
{
|
||||
name: "no databases",
|
||||
dbFiles: []string{},
|
||||
expectedStatus: statusOK,
|
||||
expectWarning: false,
|
||||
},
|
||||
{
|
||||
name: "single database",
|
||||
dbFiles: []string{"beads.db"},
|
||||
expectedStatus: statusOK,
|
||||
expectWarning: false,
|
||||
},
|
||||
{
|
||||
name: "multiple databases",
|
||||
dbFiles: []string{"beads.db", "old.db"},
|
||||
expectedStatus: statusWarning,
|
||||
expectWarning: true,
|
||||
},
|
||||
{
|
||||
name: "backup files ignored",
|
||||
dbFiles: []string{"beads.db", "beads.backup.db"},
|
||||
expectedStatus: statusOK,
|
||||
expectWarning: false,
|
||||
},
|
||||
{
|
||||
name: "vc.db ignored",
|
||||
dbFiles: []string{"beads.db", "vc.db"},
|
||||
expectedStatus: statusOK,
|
||||
expectWarning: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.Mkdir(beadsDir, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create test database files
|
||||
for _, dbFile := range tc.dbFiles {
|
||||
path := filepath.Join(beadsDir, dbFile)
|
||||
if err := os.WriteFile(path, []byte{}, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
check := checkMultipleDatabases(tmpDir)
|
||||
|
||||
if check.Status != tc.expectedStatus {
|
||||
t.Errorf("Expected status %s, got %s", tc.expectedStatus, check.Status)
|
||||
}
|
||||
|
||||
if tc.expectWarning && check.Fix == "" {
|
||||
t.Error("Expected fix message for warning status")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckMultipleJSONLFiles(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
jsonlFiles []string
|
||||
expectedStatus string
|
||||
expectWarning bool
|
||||
}{
|
||||
{
|
||||
name: "no JSONL files",
|
||||
jsonlFiles: []string{},
|
||||
expectedStatus: statusOK,
|
||||
expectWarning: false,
|
||||
},
|
||||
{
|
||||
name: "single issues.jsonl",
|
||||
jsonlFiles: []string{"issues.jsonl"},
|
||||
expectedStatus: statusOK,
|
||||
expectWarning: false,
|
||||
},
|
||||
{
|
||||
name: "single beads.jsonl",
|
||||
jsonlFiles: []string{"beads.jsonl"},
|
||||
expectedStatus: statusOK,
|
||||
expectWarning: false,
|
||||
},
|
||||
{
|
||||
name: "both JSONL files",
|
||||
jsonlFiles: []string{"issues.jsonl", "beads.jsonl"},
|
||||
expectedStatus: statusWarning,
|
||||
expectWarning: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.Mkdir(beadsDir, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create test JSONL files
|
||||
for _, jsonlFile := range tc.jsonlFiles {
|
||||
path := filepath.Join(beadsDir, jsonlFile)
|
||||
if err := os.WriteFile(path, []byte{}, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
check := checkMultipleJSONLFiles(tmpDir)
|
||||
|
||||
if check.Status != tc.expectedStatus {
|
||||
t.Errorf("Expected status %s, got %s", tc.expectedStatus, check.Status)
|
||||
}
|
||||
|
||||
if tc.expectWarning && check.Fix == "" {
|
||||
t.Error("Expected fix message for warning status")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPermissions(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.Mkdir(beadsDir, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
check := checkPermissions(tmpDir)
|
||||
|
||||
if check.Status != statusOK {
|
||||
t.Errorf("Expected ok status for writable directory, got %s: %s", check.Status, check.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckDatabaseJSONLSync(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hasDB bool
|
||||
hasJSONL bool
|
||||
expectedStatus string
|
||||
}{
|
||||
{
|
||||
name: "no database",
|
||||
hasDB: false,
|
||||
hasJSONL: true,
|
||||
expectedStatus: statusOK,
|
||||
},
|
||||
{
|
||||
name: "no JSONL",
|
||||
hasDB: true,
|
||||
hasJSONL: false,
|
||||
expectedStatus: statusOK,
|
||||
},
|
||||
{
|
||||
name: "both present",
|
||||
hasDB: true,
|
||||
hasJSONL: true,
|
||||
expectedStatus: statusOK,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.Mkdir(beadsDir, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if tc.hasDB {
|
||||
dbPath := filepath.Join(beadsDir, "beads.db")
|
||||
if err := os.WriteFile(dbPath, []byte{}, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if tc.hasJSONL {
|
||||
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||
if err := os.WriteFile(jsonlPath, []byte{}, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
check := checkDatabaseJSONLSync(tmpDir)
|
||||
|
||||
if check.Status != tc.expectedStatus {
|
||||
t.Errorf("Expected status %s, got %s", tc.expectedStatus, check.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user