feat(doctor): Add workspace-level health checks (gt-f9x.5)

Implements 6 workspace doctor checks:
- TownConfigExists: Verify mayor/town.json exists
- TownConfigValid: Validate town.json has required fields (type, version, name)
- RigsRegistryExists: Check mayor/rigs.json exists (fixable: creates empty)
- RigsRegistryValid: Verify registered rigs exist on disk (fixable: removes missing)
- MayorExists: Check mayor/ directory structure
- MayorStateValid: Validate mayor/state.json JSON (fixable: resets to default)

Added WorkspaceChecks() helper to return all workspace checks for registration.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
cheedo
2026-01-01 19:05:28 -08:00
committed by Steve Yegge
parent 1e2a068b2a
commit 3389687dc0
2 changed files with 469 additions and 0 deletions

View File

@@ -24,6 +24,14 @@ var doctorCmd = &cobra.Command{
Doctor checks for common configuration issues, missing files, Doctor checks for common configuration issues, missing files,
and other problems that could affect workspace operation. and other problems that could affect workspace operation.
Workspace checks:
- town-config-exists Check mayor/town.json exists
- town-config-valid Check mayor/town.json is valid
- rigs-registry-exists Check mayor/rigs.json exists (fixable)
- rigs-registry-valid Check registered rigs exist (fixable)
- mayor-exists Check mayor/ directory structure
- mayor-state-valid Check mayor/state.json is valid (fixable)
Infrastructure checks: Infrastructure checks:
- daemon Check if daemon is running (fixable) - daemon Check if daemon is running (fixable)
- boot-health Check Boot watchdog health (vet mode) - boot-health Check Boot watchdog health (vet mode)
@@ -85,6 +93,9 @@ func runDoctor(cmd *cobra.Command, args []string) error {
// Create doctor and register checks // Create doctor and register checks
d := doctor.NewDoctor() d := doctor.NewDoctor()
// Register workspace-level checks first (fundamental)
d.RegisterAll(doctor.WorkspaceChecks()...)
// Register built-in checks // Register built-in checks
d.Register(doctor.NewTownGitCheck()) d.Register(doctor.NewTownGitCheck())
d.Register(doctor.NewDaemonCheck()) d.Register(doctor.NewDaemonCheck())

View File

@@ -0,0 +1,458 @@
package doctor
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
// TownConfigExistsCheck verifies mayor/town.json exists.
type TownConfigExistsCheck struct {
BaseCheck
}
// NewTownConfigExistsCheck creates a new town config exists check.
func NewTownConfigExistsCheck() *TownConfigExistsCheck {
return &TownConfigExistsCheck{
BaseCheck: BaseCheck{
CheckName: "town-config-exists",
CheckDescription: "Check that mayor/town.json exists",
},
}
}
// Run checks if mayor/town.json exists.
func (c *TownConfigExistsCheck) Run(ctx *CheckContext) *CheckResult {
configPath := filepath.Join(ctx.TownRoot, "mayor", "town.json")
if _, err := os.Stat(configPath); os.IsNotExist(err) {
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: "mayor/town.json not found",
FixHint: "Run 'gt install' to initialize workspace",
}
}
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "mayor/town.json exists",
}
}
// TownConfigValidCheck verifies mayor/town.json is valid JSON with required fields.
type TownConfigValidCheck struct {
BaseCheck
}
// NewTownConfigValidCheck creates a new town config validation check.
func NewTownConfigValidCheck() *TownConfigValidCheck {
return &TownConfigValidCheck{
BaseCheck: BaseCheck{
CheckName: "town-config-valid",
CheckDescription: "Check that mayor/town.json is valid with required fields",
},
}
}
// townConfig represents the structure of mayor/town.json.
type townConfig struct {
Type string `json:"type"`
Version int `json:"version"`
Name string `json:"name"`
}
// Run validates mayor/town.json contents.
func (c *TownConfigValidCheck) Run(ctx *CheckContext) *CheckResult {
configPath := filepath.Join(ctx.TownRoot, "mayor", "town.json")
data, err := os.ReadFile(configPath)
if err != nil {
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: "Cannot read mayor/town.json",
Details: []string{err.Error()},
}
}
var config townConfig
if err := json.Unmarshal(data, &config); err != nil {
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: "mayor/town.json is not valid JSON",
Details: []string{err.Error()},
FixHint: "Fix JSON syntax in mayor/town.json",
}
}
var issues []string
if config.Type != "town" {
issues = append(issues, fmt.Sprintf("type should be 'town', got '%s'", config.Type))
}
if config.Version == 0 {
issues = append(issues, "version field is missing or zero")
}
if config.Name == "" {
issues = append(issues, "name field is missing or empty")
}
if len(issues) > 0 {
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: "mayor/town.json has invalid fields",
Details: issues,
FixHint: "Fix the field values in mayor/town.json",
}
}
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: fmt.Sprintf("mayor/town.json valid (name=%s, version=%d)", config.Name, config.Version),
}
}
// RigsRegistryExistsCheck verifies mayor/rigs.json exists.
type RigsRegistryExistsCheck struct {
FixableCheck
}
// NewRigsRegistryExistsCheck creates a new rigs registry exists check.
func NewRigsRegistryExistsCheck() *RigsRegistryExistsCheck {
return &RigsRegistryExistsCheck{
FixableCheck: FixableCheck{
BaseCheck: BaseCheck{
CheckName: "rigs-registry-exists",
CheckDescription: "Check that mayor/rigs.json exists",
},
},
}
}
// Run checks if mayor/rigs.json exists.
func (c *RigsRegistryExistsCheck) Run(ctx *CheckContext) *CheckResult {
rigsPath := filepath.Join(ctx.TownRoot, "mayor", "rigs.json")
if _, err := os.Stat(rigsPath); os.IsNotExist(err) {
return &CheckResult{
Name: c.Name(),
Status: StatusWarning,
Message: "mayor/rigs.json not found (no rigs registered)",
FixHint: "Run 'gt doctor --fix' to create empty rigs.json",
}
}
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "mayor/rigs.json exists",
}
}
// Fix creates an empty rigs.json file.
func (c *RigsRegistryExistsCheck) Fix(ctx *CheckContext) error {
rigsPath := filepath.Join(ctx.TownRoot, "mayor", "rigs.json")
emptyRigs := struct {
Version int `json:"version"`
Rigs map[string]interface{} `json:"rigs"`
}{
Version: 1,
Rigs: make(map[string]interface{}),
}
data, err := json.MarshalIndent(emptyRigs, "", " ")
if err != nil {
return fmt.Errorf("marshaling empty rigs.json: %w", err)
}
return os.WriteFile(rigsPath, data, 0644)
}
// RigsRegistryValidCheck verifies mayor/rigs.json is valid and rigs exist.
type RigsRegistryValidCheck struct {
FixableCheck
missingRigs []string // Cached for Fix
}
// NewRigsRegistryValidCheck creates a new rigs registry validation check.
func NewRigsRegistryValidCheck() *RigsRegistryValidCheck {
return &RigsRegistryValidCheck{
FixableCheck: FixableCheck{
BaseCheck: BaseCheck{
CheckName: "rigs-registry-valid",
CheckDescription: "Check that registered rigs exist on disk",
},
},
}
}
// rigsConfig represents the structure of mayor/rigs.json.
type rigsConfig struct {
Version int `json:"version"`
Rigs map[string]interface{} `json:"rigs"`
}
// Run validates mayor/rigs.json and checks that registered rigs exist.
func (c *RigsRegistryValidCheck) Run(ctx *CheckContext) *CheckResult {
rigsPath := filepath.Join(ctx.TownRoot, "mayor", "rigs.json")
data, err := os.ReadFile(rigsPath)
if err != nil {
if os.IsNotExist(err) {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "No rigs.json (skipping validation)",
}
}
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: "Cannot read mayor/rigs.json",
Details: []string{err.Error()},
}
}
var config rigsConfig
if err := json.Unmarshal(data, &config); err != nil {
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: "mayor/rigs.json is not valid JSON",
Details: []string{err.Error()},
FixHint: "Fix JSON syntax in mayor/rigs.json",
}
}
if len(config.Rigs) == 0 {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "No rigs registered",
}
}
// Check each registered rig exists
var missing []string
var found int
for rigName := range config.Rigs {
rigPath := filepath.Join(ctx.TownRoot, rigName)
if _, err := os.Stat(rigPath); os.IsNotExist(err) {
missing = append(missing, rigName)
} else {
found++
}
}
// Cache for Fix
c.missingRigs = missing
if len(missing) > 0 {
details := make([]string, len(missing))
for i, m := range missing {
details[i] = fmt.Sprintf("Missing rig directory: %s/", m)
}
return &CheckResult{
Name: c.Name(),
Status: StatusWarning,
Message: fmt.Sprintf("%d of %d registered rig(s) missing", len(missing), len(config.Rigs)),
Details: details,
FixHint: "Run 'gt doctor --fix' to remove missing rigs from registry",
}
}
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: fmt.Sprintf("All %d registered rig(s) exist", found),
}
}
// Fix removes missing rigs from the registry.
func (c *RigsRegistryValidCheck) Fix(ctx *CheckContext) error {
if len(c.missingRigs) == 0 {
return nil
}
rigsPath := filepath.Join(ctx.TownRoot, "mayor", "rigs.json")
data, err := os.ReadFile(rigsPath)
if err != nil {
return fmt.Errorf("reading rigs.json: %w", err)
}
var config rigsConfig
if err := json.Unmarshal(data, &config); err != nil {
return fmt.Errorf("parsing rigs.json: %w", err)
}
// Remove missing rigs
for _, rig := range c.missingRigs {
delete(config.Rigs, rig)
}
// Write back
newData, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("marshaling rigs.json: %w", err)
}
return os.WriteFile(rigsPath, newData, 0644)
}
// MayorExistsCheck verifies the mayor/ directory structure.
type MayorExistsCheck struct {
BaseCheck
}
// NewMayorExistsCheck creates a new mayor directory check.
func NewMayorExistsCheck() *MayorExistsCheck {
return &MayorExistsCheck{
BaseCheck: BaseCheck{
CheckName: "mayor-exists",
CheckDescription: "Check that mayor/ directory exists with required files",
},
}
}
// Run checks if mayor/ directory exists with expected contents.
func (c *MayorExistsCheck) Run(ctx *CheckContext) *CheckResult {
mayorPath := filepath.Join(ctx.TownRoot, "mayor")
info, err := os.Stat(mayorPath)
if os.IsNotExist(err) {
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: "mayor/ directory not found",
FixHint: "Run 'gt install' to initialize workspace",
}
}
if !info.IsDir() {
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: "mayor exists but is not a directory",
FixHint: "Remove mayor file and run 'gt install'",
}
}
// Check for expected files
var missing []string
expectedFiles := []string{"town.json"}
for _, f := range expectedFiles {
path := filepath.Join(mayorPath, f)
if _, err := os.Stat(path); os.IsNotExist(err) {
missing = append(missing, f)
}
}
if len(missing) > 0 {
return &CheckResult{
Name: c.Name(),
Status: StatusWarning,
Message: "mayor/ exists but missing expected files",
Details: missing,
}
}
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "mayor/ directory exists with required files",
}
}
// MayorStateValidCheck verifies mayor/state.json is valid JSON if it exists.
type MayorStateValidCheck struct {
FixableCheck
}
// NewMayorStateValidCheck creates a new mayor state validation check.
func NewMayorStateValidCheck() *MayorStateValidCheck {
return &MayorStateValidCheck{
FixableCheck: FixableCheck{
BaseCheck: BaseCheck{
CheckName: "mayor-state-valid",
CheckDescription: "Check that mayor/state.json is valid if it exists",
},
},
}
}
// Run validates mayor/state.json if it exists.
func (c *MayorStateValidCheck) Run(ctx *CheckContext) *CheckResult {
statePath := filepath.Join(ctx.TownRoot, "mayor", "state.json")
data, err := os.ReadFile(statePath)
if err != nil {
if os.IsNotExist(err) {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "mayor/state.json not present (optional)",
}
}
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: "Cannot read mayor/state.json",
Details: []string{err.Error()},
}
}
// Just verify it's valid JSON
var state interface{}
if err := json.Unmarshal(data, &state); err != nil {
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: "mayor/state.json is not valid JSON",
Details: []string{err.Error()},
FixHint: "Run 'gt doctor --fix' to reset to default state",
}
}
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "mayor/state.json is valid JSON",
}
}
// Fix resets mayor/state.json to default empty state.
func (c *MayorStateValidCheck) Fix(ctx *CheckContext) error {
statePath := filepath.Join(ctx.TownRoot, "mayor", "state.json")
// Default empty state
defaultState := map[string]interface{}{}
data, err := json.MarshalIndent(defaultState, "", " ")
if err != nil {
return fmt.Errorf("marshaling default state: %w", err)
}
return os.WriteFile(statePath, data, 0644)
}
// WorkspaceChecks returns all workspace-level health checks.
func WorkspaceChecks() []Check {
return []Check{
NewTownConfigExistsCheck(),
NewTownConfigValidCheck(),
NewRigsRegistryExistsCheck(),
NewRigsRegistryValidCheck(),
NewMayorExistsCheck(),
NewMayorStateValidCheck(),
}
}