From 3389687dc055be9cfda3bbbf61c582b7113faab1 Mon Sep 17 00:00:00 2001 From: cheedo Date: Thu, 1 Jan 2026 19:05:28 -0800 Subject: [PATCH] feat(doctor): Add workspace-level health checks (gt-f9x.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/cmd/doctor.go | 11 + internal/doctor/workspace_check.go | 458 +++++++++++++++++++++++++++++ 2 files changed, 469 insertions(+) create mode 100644 internal/doctor/workspace_check.go diff --git a/internal/cmd/doctor.go b/internal/cmd/doctor.go index aef06ca5..ccd623f2 100644 --- a/internal/cmd/doctor.go +++ b/internal/cmd/doctor.go @@ -24,6 +24,14 @@ var doctorCmd = &cobra.Command{ Doctor checks for common configuration issues, missing files, 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: - daemon Check if daemon is running (fixable) - 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 d := doctor.NewDoctor() + // Register workspace-level checks first (fundamental) + d.RegisterAll(doctor.WorkspaceChecks()...) + // Register built-in checks d.Register(doctor.NewTownGitCheck()) d.Register(doctor.NewDaemonCheck()) diff --git a/internal/doctor/workspace_check.go b/internal/doctor/workspace_check.go new file mode 100644 index 00000000..500c5368 --- /dev/null +++ b/internal/doctor/workspace_check.go @@ -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(), + } +}