package routing import ( "bufio" "context" "encoding/json" "fmt" "os" "path/filepath" "strings" "github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/storage/sqlite" ) // RoutesFileName is the name of the routes configuration file const RoutesFileName = "routes.jsonl" // Route represents a prefix-to-path routing rule type Route struct { Prefix string `json:"prefix"` // Issue ID prefix (e.g., "gt-") Path string `json:"path"` // Relative path to .beads directory } // 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) //nolint:gosec // routesPath is constructed from known beadsDir 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() } // ExtractPrefix extracts the prefix from an issue ID. // For "gt-abc123", returns "gt-". // For "bd-abc123", returns "bd-". // Returns empty string if no prefix found. func ExtractPrefix(id string) string { idx := strings.Index(id, "-") if idx < 0 { return "" } return id[:idx+1] // Include the hyphen } // ExtractProjectFromPath extracts the project name from a route path. // For "beads/mayor/rig", returns "beads". // For "gastown/crew/max", returns "gastown". func ExtractProjectFromPath(path string) string { // Get the first component of the path parts := strings.Split(path, "/") if len(parts) > 0 && parts[0] != "" { return parts[0] } return "" } // LookupRigByName finds a route by rig name (first path component). // For example, LookupRigByName("beads", beadsDir) would find the route // with path "beads/mayor/rig" and return it. // // Returns the matching route and true if found, or zero Route and false if not. func LookupRigByName(rigName, beadsDir string) (Route, bool) { routes, err := LoadRoutes(beadsDir) if err != nil || len(routes) == 0 { return Route{}, false } for _, route := range routes { project := ExtractProjectFromPath(route.Path) if project == rigName { return route, true } } return Route{}, false } // LookupRigForgiving finds a route using flexible matching. // Accepts any of these formats and normalizes them: // - "bd-" (exact prefix) // - "bd" (prefix without hyphen, will try "bd-") // - "beads" (rig name) // // This provides good agent UX - meet them where they are. // It searches for routes.jsonl in the current beads dir first, then at the town level. func LookupRigForgiving(input, beadsDir string) (Route, bool) { route, _, found := lookupRigForgivingWithTown(input, beadsDir) return route, found } // lookupRigForgivingWithTown finds a route with flexible matching and returns the town root. // Returns (route, townRoot, found). func lookupRigForgivingWithTown(input, beadsDir string) (Route, string, bool) { routes, townRoot := findTownRoutes(beadsDir) if len(routes) == 0 { return Route{}, "", false } // Normalize: remove trailing hyphen for comparison normalized := strings.TrimSuffix(input, "-") for _, route := range routes { // Try exact prefix match (with or without hyphen) prefixBase := strings.TrimSuffix(route.Prefix, "-") if normalized == prefixBase || input == route.Prefix { return route, townRoot, true } // Try rig name match project := ExtractProjectFromPath(route.Path) if input == project { return route, townRoot, true } } return Route{}, "", false } // ResolveBeadsDirForRig returns the beads directory for a given rig identifier. // This is used by --rig and --prefix flags to create issues in a different rig. // // The input is forgiving - accepts any of: // - "beads", "gastown" (rig names) // - "bd-", "gt-" (exact prefixes) // - "bd", "gt" (prefixes without hyphen) // // Parameters: // - rigOrPrefix: rig name or prefix in any format // - currentBeadsDir: the current .beads directory (used to find routes.jsonl) // // Returns: // - beadsDir: the target .beads directory path // - prefix: the issue prefix for that rig (e.g., "bd-") // - err: error if rig not found or path doesn't exist func ResolveBeadsDirForRig(rigOrPrefix, currentBeadsDir string) (beadsDir string, prefix string, err error) { route, townRoot, found := lookupRigForgivingWithTown(rigOrPrefix, currentBeadsDir) if !found { return "", "", fmt.Errorf("rig or prefix %q not found in routes.jsonl", rigOrPrefix) } // Resolve the target beads directory var targetPath string if route.Path == "." { // Special case: "." means the town beads directory targetPath = filepath.Join(townRoot, ".beads") } else { // Normal path resolution relative to town root targetPath = filepath.Join(townRoot, route.Path, ".beads") } // Follow redirect if present targetPath = resolveRedirect(targetPath) // Verify the target exists if info, statErr := os.Stat(targetPath); statErr != nil || !info.IsDir() { return "", "", fmt.Errorf("rig %q beads directory not found: %s", rigOrPrefix, targetPath) } if os.Getenv("BD_DEBUG_ROUTING") != "" { fmt.Fprintf(os.Stderr, "[routing] Rig %q -> prefix %s, path %s (townRoot=%s)\n", rigOrPrefix, route.Prefix, targetPath, townRoot) } return targetPath, route.Prefix, nil } // ResolveToExternalRef attempts to convert a foreign issue ID to an external reference // using routes.jsonl for prefix-based routing. // // If the ID's prefix matches a route, returns "external::". // Otherwise, returns empty string (no route found). // // Example: If routes.jsonl has {"prefix": "bd-", "path": "beads/mayor/rig"} // then ResolveToExternalRef("bd-abc", beadsDir) returns "external:beads:bd-abc" func ResolveToExternalRef(id, beadsDir string) string { routes, err := LoadRoutes(beadsDir) if err != nil || len(routes) == 0 { return "" } prefix := ExtractPrefix(id) if prefix == "" { return "" } for _, route := range routes { if route.Prefix == prefix { project := ExtractProjectFromPath(route.Path) if project != "" { return fmt.Sprintf("external:%s:%s", project, id) } } } return "" } // ResolveBeadsDirForID determines which beads directory contains the given issue ID. // It first checks the local beads directory, then consults routes.jsonl for prefix-based routing. // If routes.jsonl is not found locally, it searches up to the town root. // // Parameters: // - ctx: context for database operations // - id: the issue ID to look up // - currentBeadsDir: the current/local .beads directory path // // Returns: // - beadsDir: the resolved .beads directory path // - routed: true if the ID was routed to a different directory // - err: any error encountered func ResolveBeadsDirForID(ctx context.Context, id, currentBeadsDir string) (string, bool, error) { // Step 1: Check for routes.jsonl based on ID prefix // First try local, then walk up to find town-level routes routes, townRoot := findTownRoutes(currentBeadsDir) if len(routes) > 0 { prefix := ExtractPrefix(id) if prefix != "" { for _, route := range routes { if route.Prefix == prefix { // Found a matching route - resolve the path var targetPath string if route.Path == "." { // Special case: "." means the town beads directory targetPath = filepath.Join(townRoot, ".beads") } else { // Normal path resolution relative to town root targetPath = filepath.Join(townRoot, route.Path, ".beads") } // Follow redirect if present targetPath = resolveRedirect(targetPath) // Verify the target exists if info, err := os.Stat(targetPath); err == nil && info.IsDir() { // Debug logging if os.Getenv("BD_DEBUG_ROUTING") != "" { fmt.Fprintf(os.Stderr, "[routing] ID %s matched prefix %s -> %s (townRoot=%s)\n", id, prefix, targetPath, townRoot) } return targetPath, true, nil } else if os.Getenv("BD_DEBUG_ROUTING") != "" { fmt.Fprintf(os.Stderr, "[routing] ID %s matched prefix %s but target %s not found: %v\n", id, prefix, targetPath, err) } } } } } // Step 2: No route matched or no routes file - use local store return currentBeadsDir, false, nil } // findTownRoot walks up from startDir looking for a town root. // Returns the town root path, or empty string if not found. // A town root is identified by the presence of mayor/town.json. func findTownRoot(startDir string) string { current := startDir for { // Check for primary marker (mayor/town.json) if _, err := os.Stat(filepath.Join(current, "mayor", "town.json")); err == nil { return current } parent := filepath.Dir(current) if parent == current { return "" // Reached filesystem root } current = parent } } // findTownRootFromCWD walks up from the current working directory looking for a town root. // This is used to handle symlinked .beads directories correctly. // By starting from CWD instead of the beads directory path, we find the correct // town root even when .beads is a symlink that points elsewhere. func findTownRootFromCWD() string { cwd, err := os.Getwd() if err != nil { return "" } return findTownRoot(cwd) } // findTownRoutes searches for routes.jsonl at the town level. // It walks up from currentBeadsDir to find the town root, then loads routes // from /.beads/routes.jsonl. // Returns (routes, townRoot). Returns nil routes if not in an orchestrator town or no routes found. // // IMPORTANT: This function handles symlinked .beads directories correctly. // When .beads is a symlink (e.g., ~/gt/.beads -> ~/gt/olympus/.beads), we must // use findTownRoot() starting from CWD to determine the actual town root rather // than starting from currentBeadsDir, which may be the resolved symlink path. func findTownRoutes(currentBeadsDir string) ([]Route, string) { // First try the current beads dir (works if we're already at town level) routes, err := LoadRoutes(currentBeadsDir) if err == nil && len(routes) > 0 { // Use findTownRoot() starting from CWD to determine the actual town root. // We must NOT use currentBeadsDir as the starting point because if .beads // is a symlink (e.g., ~/gt/.beads -> ~/gt/olympus/.beads), currentBeadsDir // will be the resolved path (e.g., ~/gt/olympus/.beads) and walking up // from there would find ~/gt/olympus as the town root instead of ~/gt. townRoot := findTownRootFromCWD() if townRoot != "" { if os.Getenv("BD_DEBUG_ROUTING") != "" { fmt.Fprintf(os.Stderr, "[routing] findTownRoutes: found routes in %s, townRoot=%s (via findTownRootFromCWD)\n", currentBeadsDir, townRoot) } return routes, townRoot } // Fallback to parent dir if not in a town structure (for non-Gas Town repos) if os.Getenv("BD_DEBUG_ROUTING") != "" { fmt.Fprintf(os.Stderr, "[routing] findTownRoutes: found routes in %s, townRoot=%s (fallback to parent dir)\n", currentBeadsDir, filepath.Dir(currentBeadsDir)) } return routes, filepath.Dir(currentBeadsDir) } // Walk up from CWD to find town root townRoot := findTownRootFromCWD() if townRoot == "" { return nil, "" // Not in a town } // Load routes from town beads townBeadsDir := filepath.Join(townRoot, ".beads") routes, err = LoadRoutes(townBeadsDir) if err != nil || len(routes) == 0 { return nil, "" // No town routes } if os.Getenv("BD_DEBUG_ROUTING") != "" { fmt.Fprintf(os.Stderr, "[routing] findTownRoutes: loaded routes from %s, townRoot=%s\n", townBeadsDir, townRoot) } return routes, townRoot } // resolveRedirect checks for a redirect file in the beads directory // and resolves the redirect path if present. func resolveRedirect(beadsDir string) string { redirectFile := filepath.Join(beadsDir, "redirect") data, err := os.ReadFile(redirectFile) //nolint:gosec // redirectFile is constructed from known beadsDir if err != nil { if os.Getenv("BD_DEBUG_ROUTING") != "" { fmt.Fprintf(os.Stderr, "[routing] No redirect file at %s: %v\n", redirectFile, err) } return beadsDir // No redirect } redirectPath := strings.TrimSpace(string(data)) if os.Getenv("BD_DEBUG_ROUTING") != "" { fmt.Fprintf(os.Stderr, "[routing] Read redirect: %q from %s\n", redirectPath, redirectFile) } if redirectPath == "" { return beadsDir } // Handle relative paths if !filepath.IsAbs(redirectPath) { redirectPath = filepath.Join(beadsDir, redirectPath) } // Clean and resolve the path redirectPath = filepath.Clean(redirectPath) if os.Getenv("BD_DEBUG_ROUTING") != "" { fmt.Fprintf(os.Stderr, "[routing] Resolved redirect path: %s\n", redirectPath) } // Verify the redirect target exists if info, err := os.Stat(redirectPath); err == nil && info.IsDir() { if os.Getenv("BD_DEBUG_ROUTING") != "" { fmt.Fprintf(os.Stderr, "[routing] Followed redirect from %s -> %s\n", beadsDir, redirectPath) } return redirectPath } else if os.Getenv("BD_DEBUG_ROUTING") != "" { fmt.Fprintf(os.Stderr, "[routing] Redirect target check failed: %v\n", err) } return beadsDir } // RoutedStorage represents a storage connection that may have been routed // to a different beads directory than the local one. type RoutedStorage struct { Storage storage.Storage BeadsDir string Routed bool // true if this is a routed (non-local) storage } // Close closes the storage connection func (rs *RoutedStorage) Close() error { if rs.Storage != nil { return rs.Storage.Close() } return nil } // GetRoutedStorageForID returns a storage connection for the given issue ID. // If the ID matches a route, it opens a connection to the routed database. // Otherwise, it returns nil (caller should use their existing storage). // // The caller is responsible for closing the returned RoutedStorage. func GetRoutedStorageForID(ctx context.Context, id, currentBeadsDir string) (*RoutedStorage, error) { beadsDir, routed, err := ResolveBeadsDirForID(ctx, id, currentBeadsDir) if err != nil { return nil, err } if !routed { return nil, nil // No routing needed, caller should use existing storage } // Open storage for the routed directory dbPath := filepath.Join(beadsDir, "beads.db") store, err := sqlite.New(ctx, dbPath) if err != nil { return nil, err } return &RoutedStorage{ Storage: store, BeadsDir: beadsDir, Routed: true, }, nil }