diff --git a/internal/cmd/doctor.go b/internal/cmd/doctor.go index 39654458..62469d04 100644 --- a/internal/cmd/doctor.go +++ b/internal/cmd/doctor.go @@ -33,6 +33,9 @@ Clone divergence checks: - persistent-role-branches Detect crew/witness/refinery not on main - clone-divergence Detect clones significantly behind origin/main +Routing checks (fixable): + - routes-config Check beads routing configuration + Patrol checks: - patrol-molecules-exist Verify patrol molecules exist - patrol-hooks-wired Verify daemon triggers patrols @@ -74,6 +77,7 @@ func runDoctor(cmd *cobra.Command, args []string) error { d.Register(doctor.NewDaemonCheck()) d.Register(doctor.NewBeadsDatabaseCheck()) d.Register(doctor.NewPrefixConflictCheck()) + d.Register(doctor.NewRoutesCheck()) d.Register(doctor.NewOrphanSessionCheck()) d.Register(doctor.NewOrphanProcessCheck()) d.Register(doctor.NewWispGCCheck()) diff --git a/internal/doctor/routes_check.go b/internal/doctor/routes_check.go new file mode 100644 index 00000000..2e8bb619 --- /dev/null +++ b/internal/doctor/routes_check.go @@ -0,0 +1,258 @@ +package doctor + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/steveyegge/gastown/internal/beads" + "github.com/steveyegge/gastown/internal/config" +) + +// RoutesCheck verifies that beads routing is properly configured. +// It checks that routes.jsonl exists, all rigs have routing entries, +// and all routes point to valid locations. +type RoutesCheck struct { + FixableCheck +} + +// NewRoutesCheck creates a new routes configuration check. +func NewRoutesCheck() *RoutesCheck { + return &RoutesCheck{ + FixableCheck: FixableCheck{ + BaseCheck: BaseCheck{ + CheckName: "routes-config", + CheckDescription: "Check beads routing configuration", + }, + }, + } +} + +// Run checks the beads routing configuration. +func (c *RoutesCheck) Run(ctx *CheckContext) *CheckResult { + beadsDir := filepath.Join(ctx.TownRoot, ".beads") + routesPath := filepath.Join(beadsDir, beads.RoutesFileName) + + // Check if .beads directory exists + if _, err := os.Stat(beadsDir); os.IsNotExist(err) { + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: "No .beads directory at town root", + FixHint: "Run 'bd init' to initialize beads", + } + } + + // Check if routes.jsonl exists + if _, err := os.Stat(routesPath); os.IsNotExist(err) { + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: "No routes.jsonl file (prefix routing not configured)", + FixHint: "Run 'gt doctor --fix' to create routes.jsonl", + } + } + + // Load existing routes + routes, err := beads.LoadRoutes(beadsDir) + if err != nil { + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: fmt.Sprintf("Failed to load routes.jsonl: %v", err), + } + } + + // Build maps of existing routes + routeByPrefix := make(map[string]string) // prefix -> path + routeByPath := make(map[string]string) // path -> prefix + for _, r := range routes { + routeByPrefix[r.Prefix] = r.Path + routeByPath[r.Path] = r.Prefix + } + + // Load rigs registry + rigsPath := filepath.Join(ctx.TownRoot, "mayor", "rigs.json") + rigsConfig, err := config.LoadRigsConfig(rigsPath) + if err != nil { + // No rigs config is fine - just check existing routes are valid + return c.checkRoutesValid(ctx, routes) + } + + var details []string + var missingRigs []string + var invalidRoutes []string + + // Check each rig has a route (by path, not just prefix from rigs.json) + for rigName, rigEntry := range rigsConfig.Rigs { + expectedPath := rigName + "/mayor/rig" + + // Check if there's already a route for this rig (by path) + if _, hasRoute := routeByPath[expectedPath]; hasRoute { + // Rig already has a route, even if prefix differs from rigs.json + continue + } + + // No route by path - check by prefix from rigs.json + prefix := "" + if rigEntry.BeadsConfig != nil && rigEntry.BeadsConfig.Prefix != "" { + prefix = rigEntry.BeadsConfig.Prefix + "-" + } + + if prefix != "" { + if _, found := routeByPrefix[prefix]; !found { + missingRigs = append(missingRigs, rigName) + details = append(details, fmt.Sprintf("Rig '%s' (prefix: %s) has no routing entry", rigName, prefix)) + } + } + } + + // Check each route points to a valid location + for _, r := range routes { + rigPath := filepath.Join(ctx.TownRoot, r.Path) + beadsPath := filepath.Join(rigPath, ".beads") + + // Special case: "." path is town root, already checked + if r.Path == "." { + continue + } + + // Check if the path exists + if _, err := os.Stat(rigPath); os.IsNotExist(err) { + invalidRoutes = append(invalidRoutes, r.Prefix) + details = append(details, fmt.Sprintf("Route %s -> %s: path does not exist", r.Prefix, r.Path)) + continue + } + + // Check if .beads directory exists (or redirect file) + redirectPath := filepath.Join(beadsPath, "redirect") + _, beadsErr := os.Stat(beadsPath) + _, redirectErr := os.Stat(redirectPath) + + if os.IsNotExist(beadsErr) && os.IsNotExist(redirectErr) { + invalidRoutes = append(invalidRoutes, r.Prefix) + details = append(details, fmt.Sprintf("Route %s -> %s: no .beads directory", r.Prefix, r.Path)) + } + } + + // Determine result + if len(missingRigs) > 0 || len(invalidRoutes) > 0 { + status := StatusWarning + message := "" + + if len(missingRigs) > 0 && len(invalidRoutes) > 0 { + message = fmt.Sprintf("%d rig(s) missing routes, %d invalid route(s)", len(missingRigs), len(invalidRoutes)) + } else if len(missingRigs) > 0 { + message = fmt.Sprintf("%d rig(s) missing routing entries", len(missingRigs)) + } else { + message = fmt.Sprintf("%d invalid route(s) in routes.jsonl", len(invalidRoutes)) + } + + return &CheckResult{ + Name: c.Name(), + Status: status, + Message: message, + Details: details, + FixHint: "Run 'gt doctor --fix' to add missing routes", + } + } + + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: fmt.Sprintf("Routes configured correctly (%d routes)", len(routes)), + } +} + +// checkRoutesValid checks that existing routes point to valid locations. +func (c *RoutesCheck) checkRoutesValid(ctx *CheckContext, routes []beads.Route) *CheckResult { + var details []string + var invalidCount int + + for _, r := range routes { + if r.Path == "." { + continue // Town root is valid + } + + rigPath := filepath.Join(ctx.TownRoot, r.Path) + if _, err := os.Stat(rigPath); os.IsNotExist(err) { + invalidCount++ + details = append(details, fmt.Sprintf("Route %s -> %s: path does not exist", r.Prefix, r.Path)) + } + } + + if invalidCount > 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: fmt.Sprintf("%d invalid route(s) in routes.jsonl", invalidCount), + Details: details, + FixHint: "Remove invalid routes or recreate the missing rigs", + } + } + + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: fmt.Sprintf("Routes configured correctly (%d routes)", len(routes)), + } +} + +// Fix attempts to add missing routing entries. +func (c *RoutesCheck) Fix(ctx *CheckContext) error { + beadsDir := filepath.Join(ctx.TownRoot, ".beads") + + // Ensure .beads directory exists + if _, err := os.Stat(beadsDir); os.IsNotExist(err) { + return fmt.Errorf(".beads directory does not exist; run 'bd init' first") + } + + // Load existing routes + routes, err := beads.LoadRoutes(beadsDir) + if err != nil { + routes = []beads.Route{} // Start fresh if can't load + } + + // Build map of existing prefixes + routeMap := make(map[string]bool) + for _, r := range routes { + routeMap[r.Prefix] = true + } + + // Load rigs registry + rigsPath := filepath.Join(ctx.TownRoot, "mayor", "rigs.json") + rigsConfig, err := config.LoadRigsConfig(rigsPath) + if err != nil { + // No rigs config, nothing to fix + return nil + } + + // Add missing routes for each rig + modified := false + for rigName, rigEntry := range rigsConfig.Rigs { + prefix := "" + if rigEntry.BeadsConfig != nil && rigEntry.BeadsConfig.Prefix != "" { + prefix = rigEntry.BeadsConfig.Prefix + "-" + } + + if prefix != "" && !routeMap[prefix] { + // Verify the rig path exists before adding + rigPath := filepath.Join(ctx.TownRoot, rigName, "mayor", "rig") + if _, err := os.Stat(rigPath); err == nil { + route := beads.Route{ + Prefix: prefix, + Path: rigName + "/mayor/rig", + } + routes = append(routes, route) + routeMap[prefix] = true + modified = true + } + } + } + + if modified { + return beads.WriteRoutes(beadsDir, routes) + } + + return nil +}