diff --git a/internal/beads/beads.go b/internal/beads/beads.go index d1adbd4a..b8014599 100644 --- a/internal/beads/beads.go +++ b/internal/beads/beads.go @@ -114,6 +114,11 @@ type Beads struct { workDir string beadsDir string // Optional BEADS_DIR override for cross-database access isolated bool // If true, suppress inherited beads env vars (for test isolation) + + // Lazy-cached town root for routing resolution. + // Populated on first call to getTownRoot() to avoid filesystem walk on every operation. + townRoot string + searchedRoot bool } // New creates a new Beads wrapper for the given directory. @@ -144,6 +149,26 @@ func (b *Beads) getActor() string { return os.Getenv("BD_ACTOR") } +// getTownRoot returns the Gas Town root directory, using lazy caching. +// The town root is found by walking up from workDir looking for mayor/town.json. +// Returns empty string if not in a Gas Town project. +func (b *Beads) getTownRoot() string { + if !b.searchedRoot { + b.townRoot = FindTownRoot(b.workDir) + b.searchedRoot = true + } + return b.townRoot +} + +// getResolvedBeadsDir returns the beads directory this wrapper is operating on. +// This follows any redirects and returns the actual beads directory path. +func (b *Beads) getResolvedBeadsDir() string { + if b.beadsDir != "" { + return b.beadsDir + } + return ResolveBeadsDir(b.workDir) +} + // Init initializes a new beads database in the working directory. // This uses the same environment isolation as other commands. func (b *Beads) Init(prefix string) error { diff --git a/internal/beads/beads_agent.go b/internal/beads/beads_agent.go index 3374a246..9648302b 100644 --- a/internal/beads/beads_agent.go +++ b/internal/beads/beads_agent.go @@ -5,9 +5,32 @@ import ( "encoding/json" "errors" "fmt" + "os/exec" "strings" ) +// runSlotSet runs `bd slot set` from a specific directory. +// This is needed when the agent bead was created via routing to a different +// database than the Beads wrapper's default directory. +func runSlotSet(workDir, beadID, slotName, slotValue string) error { + cmd := exec.Command("bd", "slot", "set", beadID, slotName, slotValue) + cmd.Dir = workDir + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("%s: %w", strings.TrimSpace(string(output)), err) + } + return nil +} + +// runSlotClear runs `bd slot clear` from a specific directory. +func runSlotClear(workDir, beadID, slotName string) error { + cmd := exec.Command("bd", "slot", "clear", beadID, slotName) + cmd.Dir = workDir + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("%s: %w", strings.TrimSpace(string(output)), err) + } + return nil +} + // AgentFields holds structured fields for agent beads. // These are stored as "key: value" lines in the description. type AgentFields struct { @@ -125,7 +148,21 @@ func ParseAgentFields(description string) *AgentFields { // The ID format is: --- (e.g., gt-gastown-polecat-Toast) // Use AgentBeadID() helper to generate correct IDs. // The created_by field is populated from BD_ACTOR env var for provenance tracking. +// +// This function automatically ensures custom types are configured in the target +// database before creating the bead. This handles multi-repo routing scenarios +// where the bead may be routed to a different database than the one this wrapper +// is connected to. func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue, error) { + // Resolve where this bead will actually be written (handles multi-repo routing) + targetDir := ResolveRoutingTarget(b.getTownRoot(), id, b.getResolvedBeadsDir()) + + // Ensure target database has custom types configured + // This is cached (sentinel file + in-memory) so repeated calls are fast + if err := EnsureCustomTypes(targetDir); err != nil { + return nil, fmt.Errorf("prepare target for agent bead %s: %w", id, err) + } + description := FormatAgentDescription(title, fields) args := []string{"create", "--json", @@ -160,8 +197,9 @@ func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue, // Set the hook slot if specified (this is the authoritative storage) // This fixes the slot inconsistency bug where bead status is 'hooked' but // agent's hook slot is empty. See mi-619. + // Must run from targetDir since that's where the agent bead was created if fields != nil && fields.HookBead != "" { - if _, err := b.run("slot", "set", id, "hook", fields.HookBead); err != nil { + if err := runSlotSet(targetDir, id, "hook", fields.HookBead); err != nil { // Non-fatal: warn but continue - description text has the backup fmt.Printf("Warning: could not set hook slot: %v\n", err) } @@ -195,6 +233,9 @@ func (b *Beads) CreateOrReopenAgentBead(id, title string, fields *AgentFields) ( return nil, err } + // Resolve where this bead lives (for slot operations) + targetDir := ResolveRoutingTarget(b.getTownRoot(), id, b.getResolvedBeadsDir()) + // The bead already exists (should be closed from previous polecat lifecycle) // Reopen it and update its fields if _, reopenErr := b.run("reopen", id, "--reason=re-spawning agent"); reopenErr != nil { @@ -217,12 +258,14 @@ func (b *Beads) CreateOrReopenAgentBead(id, title string, fields *AgentFields) ( // Note: role slot no longer set - role definitions are config-based // Clear any existing hook slot (handles stale state from previous lifecycle) - _, _ = b.run("slot", "clear", id, "hook") + // Must run from targetDir since that's where the agent bead lives + _ = runSlotClear(targetDir, id, "hook") // Set the hook slot if specified + // Must run from targetDir since that's where the agent bead lives if fields != nil && fields.HookBead != "" { - if _, err := b.run("slot", "set", id, "hook", fields.HookBead); err != nil { - // Non-fatal: warn but continue + if err := runSlotSet(targetDir, id, "hook", fields.HookBead); err != nil { + // Non-fatal: warn but continue - description text has the backup fmt.Printf("Warning: could not set hook slot: %v\n", err) } } diff --git a/internal/beads/beads_types.go b/internal/beads/beads_types.go new file mode 100644 index 00000000..b8c0ba22 --- /dev/null +++ b/internal/beads/beads_types.go @@ -0,0 +1,129 @@ +// Package beads provides custom type management for agent beads. +package beads + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + + "github.com/steveyegge/gastown/internal/constants" +) + +// typesSentinel is a marker file indicating custom types have been configured. +// This persists across CLI invocations to avoid redundant bd config calls. +const typesSentinel = ".gt-types-configured" + +// ensuredDirs tracks which beads directories have been ensured this session. +// This provides fast in-memory caching for multiple creates in the same CLI run. +var ( + ensuredDirs = make(map[string]bool) + ensuredMu sync.Mutex +) + +// FindTownRoot walks up from startDir to find the Gas Town root directory. +// The town root is identified by the presence of mayor/town.json. +// Returns empty string if not found (reached filesystem root). +func FindTownRoot(startDir string) string { + dir := startDir + for { + townFile := filepath.Join(dir, "mayor", "town.json") + if _, err := os.Stat(townFile); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + return "" // Reached filesystem root + } + dir = parent + } +} + +// ResolveRoutingTarget determines which beads directory a bead ID will route to. +// It extracts the prefix from the bead ID and looks up the corresponding route. +// Returns the resolved beads directory path, following any redirects. +// +// If townRoot is empty or prefix is not found, falls back to the provided fallbackDir. +func ResolveRoutingTarget(townRoot, beadID, fallbackDir string) string { + if townRoot == "" { + return fallbackDir + } + + // Extract prefix from bead ID (e.g., "gt-gastown-polecat-Toast" -> "gt-") + prefix := ExtractPrefix(beadID) + if prefix == "" { + return fallbackDir + } + + // Look up rig path for this prefix + rigPath := GetRigPathForPrefix(townRoot, prefix) + if rigPath == "" { + return fallbackDir + } + + // Resolve redirects and get final beads directory + beadsDir := ResolveBeadsDir(rigPath) + if beadsDir == "" { + return fallbackDir + } + + return beadsDir +} + +// EnsureCustomTypes ensures the target beads directory has custom types configured. +// Uses a two-level caching strategy: +// - In-memory cache for multiple creates in the same CLI invocation +// - Sentinel file on disk for persistence across CLI invocations +// +// This function is thread-safe and idempotent. +func EnsureCustomTypes(beadsDir string) error { + if beadsDir == "" { + return fmt.Errorf("empty beads directory") + } + + ensuredMu.Lock() + defer ensuredMu.Unlock() + + // Fast path: in-memory cache (same CLI invocation) + if ensuredDirs[beadsDir] { + return nil + } + + // Fast path: sentinel file exists (previous CLI invocation) + sentinelPath := filepath.Join(beadsDir, typesSentinel) + if _, err := os.Stat(sentinelPath); err == nil { + ensuredDirs[beadsDir] = true + return nil + } + + // Verify beads directory exists + if _, err := os.Stat(beadsDir); os.IsNotExist(err) { + return fmt.Errorf("beads directory does not exist: %s", beadsDir) + } + + // Configure custom types via bd CLI + typesList := strings.Join(constants.BeadsCustomTypesList(), ",") + cmd := exec.Command("bd", "config", "set", "types.custom", typesList) + cmd.Dir = beadsDir + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("configure custom types in %s: %s: %w", + beadsDir, strings.TrimSpace(string(output)), err) + } + + // Write sentinel file (best effort - don't fail if this fails) + // The sentinel contains a version marker for future compatibility + _ = os.WriteFile(sentinelPath, []byte("v1\n"), 0644) + + ensuredDirs[beadsDir] = true + return nil +} + +// ResetEnsuredDirs clears the in-memory cache of ensured directories. +// This is primarily useful for testing. +func ResetEnsuredDirs() { + ensuredMu.Lock() + defer ensuredMu.Unlock() + ensuredDirs = make(map[string]bool) +} diff --git a/internal/beads/beads_types_test.go b/internal/beads/beads_types_test.go new file mode 100644 index 00000000..06294649 --- /dev/null +++ b/internal/beads/beads_types_test.go @@ -0,0 +1,234 @@ +package beads + +import ( + "os" + "path/filepath" + "testing" +) + +func TestFindTownRoot(t *testing.T) { + // Create a temporary town structure + tmpDir := t.TempDir() + mayorDir := filepath.Join(tmpDir, "mayor") + if err := os.MkdirAll(mayorDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(mayorDir, "town.json"), []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + + // Create nested directories + deepDir := filepath.Join(tmpDir, "rig1", "crew", "worker1") + if err := os.MkdirAll(deepDir, 0755); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + startDir string + expected string + }{ + {"from town root", tmpDir, tmpDir}, + {"from mayor dir", mayorDir, tmpDir}, + {"from deep nested dir", deepDir, tmpDir}, + {"from non-town dir", t.TempDir(), ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := FindTownRoot(tc.startDir) + if result != tc.expected { + t.Errorf("FindTownRoot(%q) = %q, want %q", tc.startDir, result, tc.expected) + } + }) + } +} + +func TestResolveRoutingTarget(t *testing.T) { + // Create a temporary town with routes + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatal(err) + } + + // Create mayor/town.json for FindTownRoot + mayorDir := filepath.Join(tmpDir, "mayor") + if err := os.MkdirAll(mayorDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(mayorDir, "town.json"), []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + + // Create routes.jsonl + routesContent := `{"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) + } + + // Create the rig beads directory + rigBeadsDir := filepath.Join(tmpDir, "gastown", "mayor", "rig", ".beads") + if err := os.MkdirAll(rigBeadsDir, 0755); err != nil { + t.Fatal(err) + } + + fallback := "/fallback/.beads" + + tests := []struct { + name string + townRoot string + beadID string + expected string + }{ + { + name: "rig-level bead routes to rig", + townRoot: tmpDir, + beadID: "gt-gastown-polecat-Toast", + expected: rigBeadsDir, + }, + { + name: "town-level bead routes to town", + townRoot: tmpDir, + beadID: "hq-mayor", + expected: beadsDir, + }, + { + name: "unknown prefix falls back", + townRoot: tmpDir, + beadID: "xx-unknown", + expected: fallback, + }, + { + name: "empty townRoot falls back", + townRoot: "", + beadID: "gt-gastown-polecat-Toast", + expected: fallback, + }, + { + name: "no prefix falls back", + townRoot: tmpDir, + beadID: "noprefixid", + expected: fallback, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := ResolveRoutingTarget(tc.townRoot, tc.beadID, fallback) + if result != tc.expected { + t.Errorf("ResolveRoutingTarget(%q, %q, %q) = %q, want %q", + tc.townRoot, tc.beadID, fallback, result, tc.expected) + } + }) + } +} + +func TestEnsureCustomTypes(t *testing.T) { + // Reset the in-memory cache before testing + ResetEnsuredDirs() + + t.Run("empty beads dir returns error", func(t *testing.T) { + err := EnsureCustomTypes("") + if err == nil { + t.Error("expected error for empty beads dir") + } + }) + + t.Run("non-existent beads dir returns error", func(t *testing.T) { + err := EnsureCustomTypes("/nonexistent/path/.beads") + if err == nil { + t.Error("expected error for non-existent beads dir") + } + }) + + t.Run("sentinel file triggers cache hit", func(t *testing.T) { + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatal(err) + } + + // Create sentinel file + sentinelPath := filepath.Join(beadsDir, typesSentinel) + if err := os.WriteFile(sentinelPath, []byte("v1\n"), 0644); err != nil { + t.Fatal(err) + } + + // Reset cache to ensure we're testing sentinel detection + ResetEnsuredDirs() + + // This should succeed without running bd (sentinel exists) + err := EnsureCustomTypes(beadsDir) + if err != nil { + t.Errorf("expected success with sentinel file, got: %v", err) + } + }) + + t.Run("in-memory cache prevents repeated calls", func(t *testing.T) { + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatal(err) + } + + // Create sentinel to avoid bd call + sentinelPath := filepath.Join(beadsDir, typesSentinel) + if err := os.WriteFile(sentinelPath, []byte("v1\n"), 0644); err != nil { + t.Fatal(err) + } + + ResetEnsuredDirs() + + // First call + if err := EnsureCustomTypes(beadsDir); err != nil { + t.Fatal(err) + } + + // Remove sentinel - second call should still succeed due to in-memory cache + os.Remove(sentinelPath) + + if err := EnsureCustomTypes(beadsDir); err != nil { + t.Errorf("expected cache hit, got: %v", err) + } + }) +} + +func TestBeads_getTownRoot(t *testing.T) { + // Create a temporary town + tmpDir := t.TempDir() + mayorDir := filepath.Join(tmpDir, "mayor") + if err := os.MkdirAll(mayorDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(mayorDir, "town.json"), []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + + // Create nested directory + rigDir := filepath.Join(tmpDir, "myrig", "mayor", "rig") + if err := os.MkdirAll(rigDir, 0755); err != nil { + t.Fatal(err) + } + + b := New(rigDir) + + // First call should find town root + root1 := b.getTownRoot() + if root1 != tmpDir { + t.Errorf("first getTownRoot() = %q, want %q", root1, tmpDir) + } + + // Second call should return cached value + root2 := b.getTownRoot() + if root2 != root1 { + t.Errorf("second getTownRoot() = %q, want cached %q", root2, root1) + } + + // Verify searchedRoot flag is set + if !b.searchedRoot { + t.Error("expected searchedRoot to be true after getTownRoot()") + } +}