When .beads is a symlink (e.g., ~/gt/.beads -> ~/gt/olympus/.beads), findTownRoutes() incorrectly used filepath.Dir() on the resolved symlink path to determine the town root. This caused route resolution to fail because the town root would be ~/gt/olympus instead of ~/gt, making routes point to non-existent directories. The fix adds findTownRootFromCWD() which walks up from the current working directory instead of the beads directory path. This finds the correct town root regardless of symlink resolution. Changes: - Add findTownRootFromCWD() function - Update findTownRoutes() to use CWD-based town root detection - Add fallback to filepath.Dir() for non-Gas Town repos - Add debug logging (BD_DEBUG_ROUTING=1) - Add comprehensive test case Test: go test ./internal/routing/...
446 lines
14 KiB
Go
446 lines
14 KiB
Go
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:<project>:<id>".
|
|
// 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 <townRoot>/.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
|
|
}
|