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:
beads/crew/emma
2026-01-23 20:29:12 -08:00
committed by Steve Yegge
parent 433115725b
commit 66d994264b
3 changed files with 467 additions and 1 deletions

View File

@@ -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
View 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,
}
}

View File

@@ -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")