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:
@@ -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())
|
||||
|
||||
458
internal/doctor/workspace_check.go
Normal file
458
internal/doctor/workspace_check.go
Normal 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(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user