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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -443,6 +443,73 @@ func TestBeadsRemoveRoute(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlingCrossRigRoutingResolution verifies that sling can resolve rig paths
|
||||
// for cross-rig bead hooking using ExtractPrefix and GetRigPathForPrefix.
|
||||
// This is the fix for https://github.com/steveyegge/gastown/issues/148
|
||||
func TestSlingCrossRigRoutingResolution(t *testing.T) {
|
||||
townRoot := setupRoutingTestTown(t)
|
||||
|
||||
tests := []struct {
|
||||
beadID string
|
||||
expectedPath string // Relative to townRoot, or "." for town-level
|
||||
}{
|
||||
{"gt-mol-abc", "gastown/mayor/rig"},
|
||||
{"tr-task-xyz", "testrig/mayor/rig"},
|
||||
{"hq-cv-123", "."}, // Town-level beads
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.beadID, func(t *testing.T) {
|
||||
// Step 1: Extract prefix from bead ID
|
||||
prefix := beads.ExtractPrefix(tc.beadID)
|
||||
if prefix == "" {
|
||||
t.Fatalf("ExtractPrefix(%q) returned empty", tc.beadID)
|
||||
}
|
||||
|
||||
// Step 2: Resolve rig path from prefix
|
||||
rigPath := beads.GetRigPathForPrefix(townRoot, prefix)
|
||||
if rigPath == "" {
|
||||
t.Fatalf("GetRigPathForPrefix(%q, %q) returned empty", townRoot, prefix)
|
||||
}
|
||||
|
||||
// Step 3: Verify the path is correct
|
||||
var expectedFull string
|
||||
if tc.expectedPath == "." {
|
||||
expectedFull = townRoot
|
||||
} else {
|
||||
expectedFull = filepath.Join(townRoot, tc.expectedPath)
|
||||
}
|
||||
|
||||
if rigPath != expectedFull {
|
||||
t.Errorf("GetRigPathForPrefix resolved to %q, want %q", rigPath, expectedFull)
|
||||
}
|
||||
|
||||
// Step 4: Verify the .beads directory exists at that path
|
||||
beadsDir := filepath.Join(rigPath, ".beads")
|
||||
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
||||
t.Errorf(".beads directory doesn't exist at resolved path: %s", beadsDir)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlingCrossRigUnknownPrefix verifies behavior for unknown prefixes.
|
||||
func TestSlingCrossRigUnknownPrefix(t *testing.T) {
|
||||
townRoot := setupRoutingTestTown(t)
|
||||
|
||||
// An unknown prefix should return empty string
|
||||
unknownBeadID := "xx-unknown-123"
|
||||
prefix := beads.ExtractPrefix(unknownBeadID)
|
||||
if prefix != "xx-" {
|
||||
t.Fatalf("ExtractPrefix(%q) = %q, want %q", unknownBeadID, prefix, "xx-")
|
||||
}
|
||||
|
||||
rigPath := beads.GetRigPathForPrefix(townRoot, prefix)
|
||||
if rigPath != "" {
|
||||
t.Errorf("GetRigPathForPrefix for unknown prefix returned %q, want empty", rigPath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBeadsGetPrefixForRig verifies prefix lookup by rig name.
|
||||
func TestBeadsGetPrefixForRig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
@@ -406,20 +406,10 @@ func runSling(cmd *cobra.Command, args []string) error {
|
||||
beadID = wispRootID
|
||||
}
|
||||
|
||||
// Hook the bead using bd update
|
||||
// For town-level beads (hq-*), set BEADS_DIR to town beads
|
||||
// For rig-level beads (gt-*, bd-*, etc.), use redirect-based routing from hookWorkDir
|
||||
// Hook the bead using bd update.
|
||||
// See: https://github.com/steveyegge/gastown/issues/148
|
||||
hookCmd := exec.Command("bd", "--no-daemon", "update", beadID, "--status=hooked", "--assignee="+targetAgent)
|
||||
if strings.HasPrefix(beadID, "hq-") {
|
||||
// Town-level bead: set BEADS_DIR explicitly
|
||||
hookCmd.Env = append(os.Environ(), "BEADS_DIR="+townBeadsDir)
|
||||
hookCmd.Dir = townRoot
|
||||
} else if hookWorkDir != "" {
|
||||
// Rig-level bead: use redirect from polecat's worktree
|
||||
hookCmd.Dir = hookWorkDir
|
||||
} else {
|
||||
hookCmd.Dir = townRoot
|
||||
}
|
||||
hookCmd.Dir = beads.ResolveHookDir(townRoot, beadID, hookWorkDir)
|
||||
hookCmd.Stderr = os.Stderr
|
||||
if err := hookCmd.Run(); err != nil {
|
||||
return fmt.Errorf("hooking bead: %w", err)
|
||||
@@ -939,11 +929,10 @@ func runSlingFormula(args []string) error {
|
||||
|
||||
fmt.Printf("%s Wisp created: %s\n", style.Bold.Render("✓"), wispResult.RootID)
|
||||
|
||||
// Step 3: Hook the wisp bead using bd update (discovery-based approach)
|
||||
// Set BEADS_DIR to town-level beads so hq-* beads are accessible
|
||||
// Step 3: Hook the wisp bead using bd update.
|
||||
// See: https://github.com/steveyegge/gastown/issues/148
|
||||
hookCmd := exec.Command("bd", "--no-daemon", "update", wispResult.RootID, "--status=hooked", "--assignee="+targetAgent)
|
||||
hookCmd.Env = append(os.Environ(), "BEADS_DIR="+townBeadsDir)
|
||||
hookCmd.Dir = townRoot
|
||||
hookCmd.Dir = beads.ResolveHookDir(townRoot, wispResult.RootID, "")
|
||||
hookCmd.Stderr = os.Stderr
|
||||
if err := hookCmd.Run(); err != nil {
|
||||
return fmt.Errorf("hooking wisp bead: %w", err)
|
||||
@@ -1432,17 +1421,10 @@ func runBatchSling(beadIDs []string, rigName string, townBeadsDir string) error
|
||||
}
|
||||
}
|
||||
|
||||
// Hook the bead
|
||||
// For town-level beads (hq-*), set BEADS_DIR; for rig-level beads use redirect
|
||||
// Hook the bead. See: https://github.com/steveyegge/gastown/issues/148
|
||||
townRoot := filepath.Dir(townBeadsDir)
|
||||
hookCmd := exec.Command("bd", "--no-daemon", "update", beadID, "--status=hooked", "--assignee="+targetAgent)
|
||||
if strings.HasPrefix(beadID, "hq-") {
|
||||
// Town-level bead: set BEADS_DIR and run from town root (parent of townBeadsDir)
|
||||
hookCmd.Env = append(os.Environ(), "BEADS_DIR="+townBeadsDir)
|
||||
hookCmd.Dir = filepath.Dir(townBeadsDir)
|
||||
} else if hookWorkDir != "" {
|
||||
// Rig-level bead: use redirect from polecat's worktree
|
||||
hookCmd.Dir = hookWorkDir
|
||||
}
|
||||
hookCmd.Dir = beads.ResolveHookDir(townRoot, beadID, hookWorkDir)
|
||||
hookCmd.Stderr = os.Stderr
|
||||
if err := hookCmd.Run(); err != nil {
|
||||
results = append(results, slingResult{beadID: beadID, polecat: spawnInfo.PolecatName, success: false, errMsg: "hook failed"})
|
||||
|
||||
Reference in New Issue
Block a user