fix(sling): resolve rig path for cross-rig bead hooking

gt sling failed when hooking rig-level beads from town root because
bd update doesn't support cross-database routing like bd show does.

The fix adds a ResolveHookDir helper that:
1. Extracts the prefix from bead ID (e.g., "ap-xxx" → "ap-")
2. Looks up the rig path from routes.jsonl
3. Falls back to townRoot if prefix not found

Also removes the BEADS_DIR environment override which was preventing
routing from working correctly.

Fixes #148
This commit is contained in:
mayor
2026-01-05 08:32:42 -05:00
committed by Steve Yegge
parent 201ef3a9c8
commit 9cb14cc41a
4 changed files with 270 additions and 27 deletions

View File

@@ -190,3 +190,60 @@ func FindConflictingPrefixes(beadsDir string) (map[string][]string, error) {
return conflicts, nil
}
// ExtractPrefix extracts the prefix from a bead ID.
// For example, "ap-qtsup.16" returns "ap-", "hq-cv-abc" returns "hq-".
// Returns empty string if no valid prefix found (empty input, no hyphen,
// or hyphen at position 0 which would indicate an invalid prefix).
func ExtractPrefix(beadID string) string {
if beadID == "" {
return ""
}
idx := strings.Index(beadID, "-")
if idx <= 0 {
return ""
}
return beadID[:idx+1]
}
// GetRigPathForPrefix returns the rig path for a given bead ID prefix.
// The townRoot should be the Gas Town root directory (e.g., ~/gt).
// Returns the full absolute path to the rig directory, or empty string if not found.
// For town-level beads (path="."), returns townRoot.
func GetRigPathForPrefix(townRoot, prefix string) string {
beadsDir := filepath.Join(townRoot, ".beads")
routes, err := LoadRoutes(beadsDir)
if err != nil || routes == nil {
return ""
}
for _, r := range routes {
if r.Prefix == prefix {
if r.Path == "." {
return townRoot // Town-level beads
}
return filepath.Join(townRoot, r.Path)
}
}
return ""
}
// ResolveHookDir determines the directory for running bd update on a bead.
// Since bd update doesn't support routing or redirects, we must resolve the
// actual rig directory from the bead's prefix. hookWorkDir is only used as
// a fallback if prefix resolution fails.
func ResolveHookDir(townRoot, beadID, hookWorkDir string) string {
// Always try prefix resolution first - bd update needs the actual rig dir
prefix := ExtractPrefix(beadID)
if rigPath := GetRigPathForPrefix(townRoot, prefix); rigPath != "" {
return rigPath
}
// Fallback to hookWorkDir if provided
if hookWorkDir != "" {
return hookWorkDir
}
return townRoot
}

View File

@@ -52,6 +52,143 @@ func TestGetPrefixForRig_NoRoutesFile(t *testing.T) {
}
}
func TestExtractPrefix(t *testing.T) {
tests := []struct {
beadID string
expected string
}{
{"ap-qtsup.16", "ap-"},
{"hq-cv-abc", "hq-"},
{"gt-mol-xyz", "gt-"},
{"bd-123", "bd-"},
{"", ""},
{"nohyphen", ""},
{"-startswithhyphen", ""}, // Leading hyphen = invalid prefix
{"-", ""}, // Just hyphen = invalid
{"a-", "a-"}, // Trailing hyphen is valid
}
for _, tc := range tests {
t.Run(tc.beadID, func(t *testing.T) {
result := ExtractPrefix(tc.beadID)
if result != tc.expected {
t.Errorf("ExtractPrefix(%q) = %q, want %q", tc.beadID, result, tc.expected)
}
})
}
}
func TestGetRigPathForPrefix(t *testing.T) {
// Create a temporary directory with routes.jsonl
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
routesContent := `{"prefix": "ap-", "path": "ai_platform/mayor/rig"}
{"prefix": "gt-", "path": "gastown/mayor/rig"}
{"prefix": "hq-", "path": "."}
`
if err := os.WriteFile(filepath.Join(beadsDir, "routes.jsonl"), []byte(routesContent), 0644); err != nil {
t.Fatal(err)
}
tests := []struct {
prefix string
expected string
}{
{"ap-", filepath.Join(tmpDir, "ai_platform/mayor/rig")},
{"gt-", filepath.Join(tmpDir, "gastown/mayor/rig")},
{"hq-", tmpDir}, // Town-level beads return townRoot
{"unknown-", ""}, // Unknown prefix returns empty
{"", ""}, // Empty prefix returns empty
}
for _, tc := range tests {
t.Run(tc.prefix, func(t *testing.T) {
result := GetRigPathForPrefix(tmpDir, tc.prefix)
if result != tc.expected {
t.Errorf("GetRigPathForPrefix(%q, %q) = %q, want %q", tmpDir, tc.prefix, result, tc.expected)
}
})
}
}
func TestGetRigPathForPrefix_NoRoutesFile(t *testing.T) {
tmpDir := t.TempDir()
// No routes.jsonl file
result := GetRigPathForPrefix(tmpDir, "ap-")
if result != "" {
t.Errorf("Expected empty string when no routes file, got %q", result)
}
}
func TestResolveHookDir(t *testing.T) {
// Create a temporary directory with routes.jsonl
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
routesContent := `{"prefix": "ap-", "path": "ai_platform/mayor/rig"}
{"prefix": "hq-", "path": "."}
`
if err := os.WriteFile(filepath.Join(beadsDir, "routes.jsonl"), []byte(routesContent), 0644); err != nil {
t.Fatal(err)
}
tests := []struct {
name string
beadID string
hookWorkDir string
expected string
}{
{
name: "prefix resolution takes precedence over hookWorkDir",
beadID: "ap-test",
hookWorkDir: "/custom/path",
expected: filepath.Join(tmpDir, "ai_platform/mayor/rig"),
},
{
name: "resolves rig path from prefix",
beadID: "ap-test",
hookWorkDir: "",
expected: filepath.Join(tmpDir, "ai_platform/mayor/rig"),
},
{
name: "town-level bead returns townRoot",
beadID: "hq-test",
hookWorkDir: "",
expected: tmpDir,
},
{
name: "unknown prefix uses hookWorkDir as fallback",
beadID: "xx-unknown",
hookWorkDir: "/fallback/path",
expected: "/fallback/path",
},
{
name: "unknown prefix without hookWorkDir falls back to townRoot",
beadID: "xx-unknown",
hookWorkDir: "",
expected: tmpDir,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := ResolveHookDir(tmpDir, tc.beadID, tc.hookWorkDir)
if result != tc.expected {
t.Errorf("ResolveHookDir(%q, %q, %q) = %q, want %q",
tmpDir, tc.beadID, tc.hookWorkDir, result, tc.expected)
}
})
}
}
func TestAgentBeadIDsWithPrefix(t *testing.T) {
tests := []struct {
name string