feat(doctor): add --server flag for Dolt server mode health checks (bd-dolt.2.3)
Adds bd doctor --server to diagnose Dolt server mode connections: - Server reachability (TCP connect to host:port) - Dolt version check (verifies it is Dolt, not vanilla MySQL) - Database exists and is accessible - Schema compatible (can query beads tables) - Connection pool health metrics Supports --json for machine-readable output. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
Steve Yegge
parent
433115725b
commit
66d994264b
@@ -58,6 +58,7 @@ var (
|
||||
doctorDeep bool // full graph integrity validation
|
||||
doctorGastown bool // running in gastown multi-workspace mode
|
||||
gastownDuplicatesThreshold int // duplicate tolerance threshold for gastown mode
|
||||
doctorServer bool // run server mode health checks
|
||||
)
|
||||
|
||||
// ConfigKeyHintsDoctor is the config key for suppressing doctor hints
|
||||
@@ -114,6 +115,14 @@ Deep Validation Mode (--deep):
|
||||
- Mail thread integrity: Thread IDs reference existing issues
|
||||
- Molecule integrity: Molecules have valid parent-child structures
|
||||
|
||||
Server Mode (--server):
|
||||
Run health checks for Dolt server mode connections (bd-dolt.2.3):
|
||||
- Server reachable: Can connect to configured host:port?
|
||||
- Dolt version: Is it a Dolt server (not vanilla MySQL)?
|
||||
- Database exists: Does the 'beads' database exist?
|
||||
- Schema compatible: Can query beads tables?
|
||||
- Connection pool: Pool health metrics
|
||||
|
||||
Examples:
|
||||
bd doctor # Check current directory
|
||||
bd doctor /path/to/repo # Check specific repository
|
||||
@@ -129,7 +138,8 @@ Examples:
|
||||
bd doctor --output diagnostics.json # Export diagnostics to file
|
||||
bd doctor --check=pollution # Show potential test issues
|
||||
bd doctor --check=pollution --clean # Delete test issues (with confirmation)
|
||||
bd doctor --deep # Full graph integrity validation`,
|
||||
bd doctor --deep # Full graph integrity validation
|
||||
bd doctor --server # Dolt server mode health checks`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
// Use global jsonOutput set by PersistentPreRun
|
||||
|
||||
@@ -177,6 +187,12 @@ Examples:
|
||||
return
|
||||
}
|
||||
|
||||
// Run server mode health checks if --server flag is set
|
||||
if doctorServer {
|
||||
runServerHealth(absPath)
|
||||
return
|
||||
}
|
||||
|
||||
// Run diagnostics
|
||||
result := runDiagnostics(absPath)
|
||||
|
||||
@@ -230,6 +246,7 @@ func init() {
|
||||
doctorCmd.Flags().StringVar(&doctorSource, "source", "auto", "Choose source of truth for recovery: auto (detect), jsonl (prefer JSONL), db (prefer database)")
|
||||
doctorCmd.Flags().BoolVar(&doctorGastown, "gastown", false, "Running in gastown multi-workspace mode (routes.jsonl is expected, higher duplicate tolerance)")
|
||||
doctorCmd.Flags().IntVar(&gastownDuplicatesThreshold, "gastown-duplicates-threshold", 1000, "Duplicate tolerance threshold for gastown mode (wisps are ephemeral)")
|
||||
doctorCmd.Flags().BoolVar(&doctorServer, "server", false, "Run Dolt server mode health checks (connectivity, version, schema)")
|
||||
}
|
||||
|
||||
func runDiagnostics(path string) doctorResult {
|
||||
|
||||
371
cmd/bd/doctor/server.go
Normal file
371
cmd/bd/doctor/server.go
Normal file
@@ -0,0 +1,371 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
// Import MySQL driver for server mode connections
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
|
||||
"github.com/steveyegge/beads/internal/configfile"
|
||||
)
|
||||
|
||||
// ServerHealthResult holds the results of all server health checks
|
||||
type ServerHealthResult struct {
|
||||
Checks []DoctorCheck `json:"checks"`
|
||||
OverallOK bool `json:"overall_ok"`
|
||||
}
|
||||
|
||||
// RunServerHealthChecks runs all server-mode health checks and returns the result.
|
||||
// This is called when `bd doctor --server` is used.
|
||||
func RunServerHealthChecks(path string) ServerHealthResult {
|
||||
result := ServerHealthResult{
|
||||
OverallOK: true,
|
||||
}
|
||||
|
||||
// Load config to check if server mode is configured
|
||||
_, beadsDir := getBackendAndBeadsDir(path)
|
||||
cfg, err := configfile.Load(beadsDir)
|
||||
if err != nil {
|
||||
result.Checks = append(result.Checks, DoctorCheck{
|
||||
Name: "Server Config",
|
||||
Status: StatusError,
|
||||
Message: "Failed to load config",
|
||||
Detail: err.Error(),
|
||||
Category: CategoryFederation,
|
||||
})
|
||||
result.OverallOK = false
|
||||
return result
|
||||
}
|
||||
|
||||
if cfg == nil {
|
||||
result.Checks = append(result.Checks, DoctorCheck{
|
||||
Name: "Server Config",
|
||||
Status: StatusError,
|
||||
Message: "No metadata.json found",
|
||||
Fix: "Run 'bd init' to initialize beads",
|
||||
Category: CategoryFederation,
|
||||
})
|
||||
result.OverallOK = false
|
||||
return result
|
||||
}
|
||||
|
||||
// Check if Dolt backend is configured
|
||||
if cfg.GetBackend() != configfile.BackendDolt {
|
||||
result.Checks = append(result.Checks, DoctorCheck{
|
||||
Name: "Server Config",
|
||||
Status: StatusWarning,
|
||||
Message: fmt.Sprintf("Backend is '%s', not Dolt", cfg.GetBackend()),
|
||||
Detail: "Server mode health checks are only relevant for Dolt backend",
|
||||
Fix: "Set backend: dolt in metadata.json to use Dolt server mode",
|
||||
Category: CategoryFederation,
|
||||
})
|
||||
result.OverallOK = false
|
||||
return result
|
||||
}
|
||||
|
||||
// Check if server mode is configured
|
||||
if !cfg.IsDoltServerMode() {
|
||||
result.Checks = append(result.Checks, DoctorCheck{
|
||||
Name: "Server Config",
|
||||
Status: StatusWarning,
|
||||
Message: fmt.Sprintf("Dolt mode is '%s' (not server)", cfg.GetDoltMode()),
|
||||
Detail: "Server health checks require dolt_mode: server in metadata.json",
|
||||
Fix: "Set dolt_mode: server in metadata.json and start dolt sql-server",
|
||||
Category: CategoryFederation,
|
||||
})
|
||||
result.OverallOK = false
|
||||
return result
|
||||
}
|
||||
|
||||
// Server mode is configured - run health checks
|
||||
host := cfg.GetDoltServerHost()
|
||||
port := cfg.GetDoltServerPort()
|
||||
|
||||
// Check 1: Server reachability (TCP connect)
|
||||
reachCheck := checkServerReachable(host, port)
|
||||
result.Checks = append(result.Checks, reachCheck)
|
||||
if reachCheck.Status == StatusError {
|
||||
result.OverallOK = false
|
||||
// Can't continue without connectivity
|
||||
return result
|
||||
}
|
||||
|
||||
// Check 2: Connect and verify it's Dolt (get version)
|
||||
versionCheck, db := checkDoltVersion(cfg)
|
||||
result.Checks = append(result.Checks, versionCheck)
|
||||
if versionCheck.Status == StatusError {
|
||||
result.OverallOK = false
|
||||
if db != nil {
|
||||
_ = db.Close()
|
||||
}
|
||||
return result
|
||||
}
|
||||
defer func() {
|
||||
if db != nil {
|
||||
_ = db.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
// Check 3: Database exists and is queryable
|
||||
dbExistsCheck := checkDatabaseExists(db, "beads")
|
||||
result.Checks = append(result.Checks, dbExistsCheck)
|
||||
if dbExistsCheck.Status == StatusError {
|
||||
result.OverallOK = false
|
||||
}
|
||||
|
||||
// Check 4: Schema compatible (can query beads tables)
|
||||
schemaCheck := checkSchemaCompatible(db)
|
||||
result.Checks = append(result.Checks, schemaCheck)
|
||||
if schemaCheck.Status == StatusError {
|
||||
result.OverallOK = false
|
||||
}
|
||||
|
||||
// Check 5: Connection pool health
|
||||
poolCheck := checkConnectionPool(db)
|
||||
result.Checks = append(result.Checks, poolCheck)
|
||||
if poolCheck.Status == StatusError {
|
||||
result.OverallOK = false
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// checkServerReachable checks if the server is reachable via TCP
|
||||
func checkServerReachable(host string, port int) DoctorCheck {
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Server Reachable",
|
||||
Status: StatusError,
|
||||
Message: fmt.Sprintf("Cannot connect to %s", addr),
|
||||
Detail: err.Error(),
|
||||
Fix: "Ensure dolt sql-server is running and accessible",
|
||||
Category: CategoryFederation,
|
||||
}
|
||||
}
|
||||
_ = conn.Close()
|
||||
|
||||
return DoctorCheck{
|
||||
Name: "Server Reachable",
|
||||
Status: StatusOK,
|
||||
Message: fmt.Sprintf("Connected to %s", addr),
|
||||
Category: CategoryFederation,
|
||||
}
|
||||
}
|
||||
|
||||
// checkDoltVersion connects to the server and checks if it's a Dolt server
|
||||
// Returns the DoctorCheck and an open database connection (caller must close)
|
||||
func checkDoltVersion(cfg *configfile.Config) (DoctorCheck, *sql.DB) {
|
||||
host := cfg.GetDoltServerHost()
|
||||
port := cfg.GetDoltServerPort()
|
||||
user := cfg.GetDoltServerUser()
|
||||
|
||||
// Build DSN without database (just to test server connectivity)
|
||||
var connStr string
|
||||
connStr = fmt.Sprintf("%s@tcp(%s:%d)/?parseTime=true&timeout=5s",
|
||||
user, host, port)
|
||||
|
||||
db, err := sql.Open("mysql", connStr)
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Dolt Version",
|
||||
Status: StatusError,
|
||||
Message: "Failed to open connection",
|
||||
Detail: err.Error(),
|
||||
Fix: "Check MySQL driver and connection settings",
|
||||
Category: CategoryFederation,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Set connection pool limits
|
||||
db.SetMaxOpenConns(2)
|
||||
db.SetMaxIdleConns(1)
|
||||
db.SetConnMaxLifetime(30 * time.Second)
|
||||
|
||||
// Test connectivity
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
_ = db.Close()
|
||||
return DoctorCheck{
|
||||
Name: "Dolt Version",
|
||||
Status: StatusError,
|
||||
Message: "Server not responding",
|
||||
Detail: err.Error(),
|
||||
Fix: "Ensure dolt sql-server is running",
|
||||
Category: CategoryFederation,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Query Dolt version
|
||||
var version string
|
||||
err = db.QueryRowContext(ctx, "SELECT dolt_version()").Scan(&version)
|
||||
if err != nil {
|
||||
// If dolt_version() doesn't exist, it's not a Dolt server
|
||||
if strings.Contains(err.Error(), "Unknown") || strings.Contains(err.Error(), "doesn't exist") {
|
||||
_ = db.Close()
|
||||
return DoctorCheck{
|
||||
Name: "Dolt Version",
|
||||
Status: StatusError,
|
||||
Message: "Server is not Dolt",
|
||||
Detail: "dolt_version() function not found - this may be a MySQL server, not Dolt",
|
||||
Fix: "Ensure you're connecting to a Dolt sql-server, not vanilla MySQL",
|
||||
Category: CategoryFederation,
|
||||
}, nil
|
||||
}
|
||||
_ = db.Close()
|
||||
return DoctorCheck{
|
||||
Name: "Dolt Version",
|
||||
Status: StatusError,
|
||||
Message: "Failed to query version",
|
||||
Detail: err.Error(),
|
||||
Category: CategoryFederation,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return DoctorCheck{
|
||||
Name: "Dolt Version",
|
||||
Status: StatusOK,
|
||||
Message: fmt.Sprintf("Dolt %s", version),
|
||||
Category: CategoryFederation,
|
||||
}, db
|
||||
}
|
||||
|
||||
// checkDatabaseExists checks if the beads database exists
|
||||
func checkDatabaseExists(db *sql.DB, database string) DoctorCheck {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Check if database exists
|
||||
var exists int
|
||||
query := fmt.Sprintf("SELECT COUNT(*) FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = '%s'", database)
|
||||
err := db.QueryRowContext(ctx, query).Scan(&exists)
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Database Exists",
|
||||
Status: StatusError,
|
||||
Message: "Failed to query databases",
|
||||
Detail: err.Error(),
|
||||
Category: CategoryFederation,
|
||||
}
|
||||
}
|
||||
|
||||
if exists == 0 {
|
||||
return DoctorCheck{
|
||||
Name: "Database Exists",
|
||||
Status: StatusError,
|
||||
Message: fmt.Sprintf("Database '%s' not found", database),
|
||||
Fix: fmt.Sprintf("Run 'bd init --backend dolt' to create the '%s' database", database),
|
||||
Category: CategoryFederation,
|
||||
}
|
||||
}
|
||||
|
||||
// Switch to the database
|
||||
_, err = db.ExecContext(ctx, fmt.Sprintf("USE %s", database))
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Database Exists",
|
||||
Status: StatusError,
|
||||
Message: fmt.Sprintf("Cannot access database '%s'", database),
|
||||
Detail: err.Error(),
|
||||
Category: CategoryFederation,
|
||||
}
|
||||
}
|
||||
|
||||
return DoctorCheck{
|
||||
Name: "Database Exists",
|
||||
Status: StatusOK,
|
||||
Message: fmt.Sprintf("Database '%s' accessible", database),
|
||||
Category: CategoryFederation,
|
||||
}
|
||||
}
|
||||
|
||||
// checkSchemaCompatible checks if the beads tables are queryable
|
||||
func checkSchemaCompatible(db *sql.DB) DoctorCheck {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Try to query the issues table
|
||||
var count int
|
||||
err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM issues").Scan(&count)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "doesn't exist") || strings.Contains(err.Error(), "Unknown table") {
|
||||
return DoctorCheck{
|
||||
Name: "Schema Compatible",
|
||||
Status: StatusError,
|
||||
Message: "Issues table not found",
|
||||
Fix: "Run 'bd init --backend dolt' to create schema",
|
||||
Category: CategoryFederation,
|
||||
}
|
||||
}
|
||||
return DoctorCheck{
|
||||
Name: "Schema Compatible",
|
||||
Status: StatusError,
|
||||
Message: "Cannot query issues table",
|
||||
Detail: err.Error(),
|
||||
Category: CategoryFederation,
|
||||
}
|
||||
}
|
||||
|
||||
// Query metadata table for bd_version
|
||||
var bdVersion string
|
||||
err = db.QueryRowContext(ctx, "SELECT value FROM metadata WHERE `key` = 'bd_version'").Scan(&bdVersion)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
if strings.Contains(err.Error(), "doesn't exist") || strings.Contains(err.Error(), "Unknown table") {
|
||||
return DoctorCheck{
|
||||
Name: "Schema Compatible",
|
||||
Status: StatusWarning,
|
||||
Message: fmt.Sprintf("%d issues found (no metadata table)", count),
|
||||
Fix: "Run 'bd migrate' to update schema",
|
||||
Category: CategoryFederation,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
detail := fmt.Sprintf("%d issues", count)
|
||||
if bdVersion != "" {
|
||||
detail = fmt.Sprintf("%d issues (bd %s)", count, bdVersion)
|
||||
}
|
||||
|
||||
return DoctorCheck{
|
||||
Name: "Schema Compatible",
|
||||
Status: StatusOK,
|
||||
Message: detail,
|
||||
Category: CategoryFederation,
|
||||
}
|
||||
}
|
||||
|
||||
// checkConnectionPool checks the connection pool health
|
||||
func checkConnectionPool(db *sql.DB) DoctorCheck {
|
||||
stats := db.Stats()
|
||||
|
||||
// Report pool statistics
|
||||
detail := fmt.Sprintf("open: %d, in_use: %d, idle: %d",
|
||||
stats.OpenConnections,
|
||||
stats.InUse,
|
||||
stats.Idle,
|
||||
)
|
||||
|
||||
// Check for connection errors
|
||||
if stats.MaxIdleClosed > 0 || stats.MaxLifetimeClosed > 0 {
|
||||
detail += fmt.Sprintf("\nclosed: idle=%d, lifetime=%d",
|
||||
stats.MaxIdleClosed,
|
||||
stats.MaxLifetimeClosed,
|
||||
)
|
||||
}
|
||||
|
||||
return DoctorCheck{
|
||||
Name: "Connection Pool",
|
||||
Status: StatusOK,
|
||||
Message: "Pool healthy",
|
||||
Detail: detail,
|
||||
Category: CategoryFederation,
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -106,6 +107,83 @@ func runDeepValidation(path string) {
|
||||
}
|
||||
}
|
||||
|
||||
// runServerHealth runs Dolt server mode health checks
|
||||
func runServerHealth(path string) {
|
||||
result := doctor.RunServerHealthChecks(path)
|
||||
|
||||
if jsonOutput {
|
||||
jsonBytes, _ := json.Marshal(result)
|
||||
fmt.Println(string(jsonBytes))
|
||||
} else {
|
||||
fmt.Println("Dolt Server Mode Health Check")
|
||||
fmt.Println()
|
||||
printServerHealthResult(result)
|
||||
}
|
||||
|
||||
if !result.OverallOK {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// printServerHealthResult prints the server health check results
|
||||
func printServerHealthResult(result doctor.ServerHealthResult) {
|
||||
var passCount, warnCount, failCount int
|
||||
|
||||
for _, check := range result.Checks {
|
||||
var statusIcon string
|
||||
switch check.Status {
|
||||
case statusOK:
|
||||
statusIcon = "✓"
|
||||
passCount++
|
||||
case statusWarning:
|
||||
statusIcon = "⚠"
|
||||
warnCount++
|
||||
case statusError:
|
||||
statusIcon = "✗"
|
||||
failCount++
|
||||
}
|
||||
|
||||
fmt.Printf(" %s %s", statusIcon, check.Name)
|
||||
if check.Message != "" {
|
||||
fmt.Printf(" %s", check.Message)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
if check.Detail != "" {
|
||||
// Indent detail lines
|
||||
for _, line := range strings.Split(check.Detail, "\n") {
|
||||
fmt.Printf(" └─ %s\n", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
// Summary line
|
||||
fmt.Printf("─────────────────────────────────────────\n")
|
||||
fmt.Printf("✓ %d passed ⚠ %d warnings ✗ %d failed\n", passCount, warnCount, failCount)
|
||||
|
||||
// Print fixes for any errors/warnings
|
||||
var fixes []doctor.DoctorCheck
|
||||
for _, check := range result.Checks {
|
||||
if check.Fix != "" && (check.Status == statusError || check.Status == statusWarning) {
|
||||
fixes = append(fixes, check)
|
||||
}
|
||||
}
|
||||
|
||||
if len(fixes) > 0 {
|
||||
fmt.Println()
|
||||
fmt.Println("⚠ FIXES NEEDED")
|
||||
for i, check := range fixes {
|
||||
fmt.Printf(" %d. %s: %s\n", i+1, check.Name, check.Message)
|
||||
fmt.Printf(" └─ %s\n", check.Fix)
|
||||
}
|
||||
} else if result.OverallOK {
|
||||
fmt.Println()
|
||||
fmt.Println("✓ All server health checks passed")
|
||||
}
|
||||
}
|
||||
|
||||
// printCheckHealthHint prints the health check hint and exits with error.
|
||||
func printCheckHealthHint(issues []string) {
|
||||
fmt.Fprintf(os.Stderr, "💡 bd doctor recommends a health check:\n")
|
||||
|
||||
Reference in New Issue
Block a user