From 4405475c9ba1889de5ba4713742b94e5d1c54d2d Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Fri, 26 Dec 2025 19:54:26 -0800 Subject: [PATCH] feat: Add prefix-based routing support for beads (gt-hrgpg) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add internal/beads/routes.go with helpers to manage routes.jsonl - Update gt rig add to auto-append routes for new rigs - Add prefix-conflict check to gt doctor bd already has prefix routing via routes.jsonl - this wires up Gas Town to maintain routes when rigs are added and detect conflicts. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/beads/routes.go | 156 +++++++++++++++++++++++++++++++++ internal/cmd/doctor.go | 1 + internal/cmd/rig.go | 12 +++ internal/doctor/beads_check.go | 67 ++++++++++++++ 4 files changed, 236 insertions(+) create mode 100644 internal/beads/routes.go diff --git a/internal/beads/routes.go b/internal/beads/routes.go new file mode 100644 index 00000000..320520bb --- /dev/null +++ b/internal/beads/routes.go @@ -0,0 +1,156 @@ +// Package beads provides routing helpers for prefix-based beads resolution. +package beads + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// Route represents a prefix-to-path routing rule. +// This mirrors the structure in bd's internal/routing package. +type Route struct { + Prefix string `json:"prefix"` // Issue ID prefix (e.g., "gt-") + Path string `json:"path"` // Relative path to .beads directory from town root +} + +// RoutesFileName is the name of the routes configuration file. +const RoutesFileName = "routes.jsonl" + +// LoadRoutes loads routes from routes.jsonl in the given beads directory. +// Returns an empty slice if the file doesn't exist. +func LoadRoutes(beadsDir string) ([]Route, error) { + routesPath := filepath.Join(beadsDir, RoutesFileName) + file, err := os.Open(routesPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil // No routes file is not an error + } + return nil, err + } + defer file.Close() + + var routes []Route + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue // Skip empty lines and comments + } + + var route Route + if err := json.Unmarshal([]byte(line), &route); err != nil { + continue // Skip malformed lines + } + if route.Prefix != "" && route.Path != "" { + routes = append(routes, route) + } + } + + return routes, scanner.Err() +} + +// AppendRoute appends a route to routes.jsonl in the town's beads directory. +// If the prefix already exists, it updates the path. +func AppendRoute(townRoot string, route Route) error { + beadsDir := filepath.Join(townRoot, ".beads") + + // Load existing routes + routes, err := LoadRoutes(beadsDir) + if err != nil { + return fmt.Errorf("loading routes: %w", err) + } + + // Check if prefix already exists + found := false + for i, r := range routes { + if r.Prefix == route.Prefix { + routes[i].Path = route.Path + found = true + break + } + } + + if !found { + routes = append(routes, route) + } + + // Write back + return WriteRoutes(beadsDir, routes) +} + +// RemoveRoute removes a route by prefix from routes.jsonl. +func RemoveRoute(townRoot string, prefix string) error { + beadsDir := filepath.Join(townRoot, ".beads") + + // Load existing routes + routes, err := LoadRoutes(beadsDir) + if err != nil { + return fmt.Errorf("loading routes: %w", err) + } + + // Filter out the prefix + var filtered []Route + for _, r := range routes { + if r.Prefix != prefix { + filtered = append(filtered, r) + } + } + + // Write back + return WriteRoutes(beadsDir, filtered) +} + +// WriteRoutes writes routes to routes.jsonl, overwriting existing content. +func WriteRoutes(beadsDir string, routes []Route) error { + routesPath := filepath.Join(beadsDir, RoutesFileName) + + file, err := os.Create(routesPath) + if err != nil { + return fmt.Errorf("creating routes file: %w", err) + } + defer file.Close() + + for _, r := range routes { + data, err := json.Marshal(r) + if err != nil { + return fmt.Errorf("marshaling route: %w", err) + } + if _, err := file.Write(data); err != nil { + return fmt.Errorf("writing route: %w", err) + } + if _, err := file.WriteString("\n"); err != nil { + return fmt.Errorf("writing newline: %w", err) + } + } + + return nil +} + +// FindConflictingPrefixes checks for duplicate prefixes in routes. +// Returns a map of prefix -> list of paths that use it. +func FindConflictingPrefixes(beadsDir string) (map[string][]string, error) { + routes, err := LoadRoutes(beadsDir) + if err != nil { + return nil, err + } + + // Group by prefix + prefixPaths := make(map[string][]string) + for _, r := range routes { + prefixPaths[r.Prefix] = append(prefixPaths[r.Prefix], r.Path) + } + + // Filter to only conflicts (more than one path per prefix) + conflicts := make(map[string][]string) + for prefix, paths := range prefixPaths { + if len(paths) > 1 { + conflicts[prefix] = paths + } + } + + return conflicts, nil +} diff --git a/internal/cmd/doctor.go b/internal/cmd/doctor.go index 6cdc23cf..a00c68fb 100644 --- a/internal/cmd/doctor.go +++ b/internal/cmd/doctor.go @@ -69,6 +69,7 @@ func runDoctor(cmd *cobra.Command, args []string) error { d.Register(doctor.NewTownGitCheck()) d.Register(doctor.NewDaemonCheck()) d.Register(doctor.NewBeadsDatabaseCheck()) + d.Register(doctor.NewPrefixConflictCheck()) d.Register(doctor.NewOrphanSessionCheck()) d.Register(doctor.NewOrphanProcessCheck()) d.Register(doctor.NewWispGCCheck()) diff --git a/internal/cmd/rig.go b/internal/cmd/rig.go index c63cb78e..ceadebbf 100644 --- a/internal/cmd/rig.go +++ b/internal/cmd/rig.go @@ -211,6 +211,18 @@ func runRigAdd(cmd *cobra.Command, args []string) error { return fmt.Errorf("saving rigs config: %w", err) } + // Add route to town-level routes.jsonl for prefix-based routing + if newRig.Config.Prefix != "" { + route := beads.Route{ + Prefix: newRig.Config.Prefix + "-", + Path: name + "/mayor/rig", + } + if err := beads.AppendRoute(townRoot, route); err != nil { + // Non-fatal: routing will still work, just not from town root + fmt.Printf(" %s Could not update routes.jsonl: %v\n", style.Warning.Render("!"), err) + } + } + elapsed := time.Since(startTime) fmt.Printf("\n%s Rig created in %.1fs\n", style.Success.Render("✓"), elapsed.Seconds()) diff --git a/internal/doctor/beads_check.go b/internal/doctor/beads_check.go index c1d670e9..60ab4913 100644 --- a/internal/doctor/beads_check.go +++ b/internal/doctor/beads_check.go @@ -2,9 +2,13 @@ package doctor import ( "bytes" + "fmt" "os" "os/exec" "path/filepath" + "strings" + + "github.com/steveyegge/gastown/internal/beads" ) // BeadsDatabaseCheck verifies that the beads database is properly initialized. @@ -157,3 +161,66 @@ func (c *BeadsDatabaseCheck) Fix(ctx *CheckContext) error { return nil } + +// PrefixConflictCheck detects duplicate prefixes across rigs in routes.jsonl. +// Duplicate prefixes break prefix-based routing. +type PrefixConflictCheck struct { + BaseCheck +} + +// NewPrefixConflictCheck creates a new prefix conflict check. +func NewPrefixConflictCheck() *PrefixConflictCheck { + return &PrefixConflictCheck{ + BaseCheck: BaseCheck{ + CheckName: "prefix-conflict", + CheckDescription: "Check for duplicate beads prefixes across rigs", + }, + } +} + +// Run checks for duplicate prefixes in routes.jsonl. +func (c *PrefixConflictCheck) Run(ctx *CheckContext) *CheckResult { + beadsDir := filepath.Join(ctx.TownRoot, ".beads") + + // Check if routes.jsonl exists + routesPath := filepath.Join(beadsDir, beads.RoutesFileName) + if _, err := os.Stat(routesPath); os.IsNotExist(err) { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "No routes.jsonl file (prefix routing not configured)", + } + } + + // Find conflicts + conflicts, err := beads.FindConflictingPrefixes(beadsDir) + if err != nil { + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: fmt.Sprintf("Could not check routes.jsonl: %v", err), + } + } + + if len(conflicts) == 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "No prefix conflicts found", + } + } + + // Build details + var details []string + for prefix, paths := range conflicts { + details = append(details, fmt.Sprintf("Prefix %q used by: %s", prefix, strings.Join(paths, ", "))) + } + + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: fmt.Sprintf("%d prefix conflict(s) found in routes.jsonl", len(conflicts)), + Details: details, + FixHint: "Use 'bd rename-prefix ' in one of the conflicting rigs to resolve", + } +}