From 4bbf97ab8266ae3dd18c3514bf96a84e9789bcc4 Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Mon, 12 Jan 2026 07:03:50 +0000 Subject: [PATCH] fix(costs): query all beads locations for session events (#374) * test(costs): add failing test for multi-location session event query Add integration test that verifies querySessionEvents finds session.ended events from both town-level and rig-level beads databases. The test demonstrates the bug: events created by rig-level agents (polecats, witness, etc.) are stored in the rig's .beads database, but querySessionEvents only queries the town-level beads, missing rig-level events. Test setup: - Creates town with gt install - Adds rig with gt rig add (separate beads DB) - Creates session.ended event in town beads (simulating mayor) - Creates session.ended event in rig beads (simulating polecat) - Verifies querySessionEvents finds both events Co-Authored-By: Claude Opus 4.5 * fix(costs): query all beads locations for session events querySessionEvents previously only queried the town-level beads database, missing session.ended events created by rig-level agents (polecats, witness, refinery, crew) which are stored in each rig's own .beads database. The fix: - Load rigs from mayor/rigs.json - Query each rig's beads location in addition to town-level beads - Merge and deduplicate results by session ID + timestamp This ensures `gt costs` finds all session cost events regardless of which agent's beads database they were recorded in. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: julianknutsen Co-authored-by: Claude Opus 4.5 --- internal/cmd/costs.go | 67 ++++++++- internal/cmd/costs_workdir_test.go | 220 +++++++++++++++++++++++++++++ 2 files changed, 282 insertions(+), 5 deletions(-) create mode 100644 internal/cmd/costs_workdir_test.go diff --git a/internal/cmd/costs.go b/internal/cmd/costs.go index 886a5192..0967a541 100644 --- a/internal/cmd/costs.go +++ b/internal/cmd/costs.go @@ -6,15 +6,18 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "regexp" "sort" "strings" "time" "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/constants" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/tmux" + "github.com/steveyegge/gastown/internal/workspace" ) var ( @@ -275,10 +278,7 @@ func runCostsFromLedger() error { } else { // No time filter: query both digests and legacy session.ended events // (for backwards compatibility during migration) - entries, err = querySessionEvents() - if err != nil { - return fmt.Errorf("querying session events: %w", err) - } + entries = querySessionEvents() } if len(entries) == 0 { @@ -353,7 +353,62 @@ type EventListItem struct { } // querySessionEvents queries beads for session.ended events and converts them to CostEntry. -func querySessionEvents() ([]CostEntry, error) { +// It queries both town-level beads and all rig-level beads to find all session events. +// Errors from individual locations are logged (if verbose) but don't fail the query. +func querySessionEvents() []CostEntry { + // Discover town root for cwd-based bd discovery + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + // Not in a Gas Town workspace - return empty list + return nil + } + + // Collect all beads locations to query + beadsLocations := []string{townRoot} + + // Load rigs to find all rig beads locations + rigsConfigPath := filepath.Join(townRoot, constants.DirMayor, constants.FileRigsJSON) + rigsConfig, err := config.LoadRigsConfig(rigsConfigPath) + if err == nil && rigsConfig != nil { + for rigName := range rigsConfig.Rigs { + rigPath := filepath.Join(townRoot, rigName) + // Verify rig has a beads database + rigBeadsPath := filepath.Join(rigPath, constants.DirBeads) + if _, statErr := os.Stat(rigBeadsPath); statErr == nil { + beadsLocations = append(beadsLocations, rigPath) + } + } + } + + // Query each beads location and merge results + var allEntries []CostEntry + seenIDs := make(map[string]bool) + + for _, location := range beadsLocations { + entries, err := querySessionEventsFromLocation(location) + if err != nil { + // Log but continue with other locations + if costsVerbose { + fmt.Fprintf(os.Stderr, "[costs] query from %s failed: %v\n", location, err) + } + continue + } + + // Deduplicate by event ID (use SessionID as key) + for _, entry := range entries { + key := entry.SessionID + entry.EndedAt.String() + if !seenIDs[key] { + seenIDs[key] = true + allEntries = append(allEntries, entry) + } + } + } + + return allEntries +} + +// querySessionEventsFromLocation queries a single beads location for session.ended events. +func querySessionEventsFromLocation(location string) ([]CostEntry, error) { // Step 1: Get list of event IDs listArgs := []string{ "list", @@ -364,6 +419,7 @@ func querySessionEvents() ([]CostEntry, error) { } listCmd := exec.Command("bd", listArgs...) + listCmd.Dir = location listOutput, err := listCmd.Output() if err != nil { // If bd fails (e.g., no beads database), return empty list @@ -387,6 +443,7 @@ func querySessionEvents() ([]CostEntry, error) { } showCmd := exec.Command("bd", showArgs...) + showCmd.Dir = location showOutput, err := showCmd.Output() if err != nil { return nil, fmt.Errorf("showing events: %w", err) diff --git a/internal/cmd/costs_workdir_test.go b/internal/cmd/costs_workdir_test.go new file mode 100644 index 00000000..d43894d0 --- /dev/null +++ b/internal/cmd/costs_workdir_test.go @@ -0,0 +1,220 @@ +package cmd + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/steveyegge/gastown/internal/workspace" +) + +// TestQuerySessionEvents_FindsEventsFromAllLocations verifies that querySessionEvents +// finds session.ended events from both town-level and rig-level beads databases. +// +// Bug: Events created by rig-level agents (polecats, witness, etc.) are stored in +// the rig's .beads database. Events created by town-level agents (mayor, deacon) +// are stored in the town's .beads database. querySessionEvents must query ALL +// beads locations to find all events. +// +// This test: +// 1. Creates a town with a rig +// 2. Creates session.ended events in both town and rig beads +// 3. Verifies querySessionEvents finds events from both locations +func TestQuerySessionEvents_FindsEventsFromAllLocations(t *testing.T) { + // Skip if gt and bd are not installed + if _, err := exec.LookPath("gt"); err != nil { + t.Skip("gt not installed, skipping integration test") + } + if _, err := exec.LookPath("bd"); err != nil { + t.Skip("bd not installed, skipping integration test") + } + + // Create a temporary directory structure + tmpDir := t.TempDir() + townRoot := filepath.Join(tmpDir, "test-town") + + // Create town directory + if err := os.MkdirAll(townRoot, 0755); err != nil { + t.Fatalf("creating town directory: %v", err) + } + + // Initialize a git repo (required for gt install) + gitInitCmd := exec.Command("git", "init") + gitInitCmd.Dir = townRoot + if out, err := gitInitCmd.CombinedOutput(); err != nil { + t.Fatalf("git init: %v\n%s", err, out) + } + + // Use gt install to set up the town + gtInstallCmd := exec.Command("gt", "install") + gtInstallCmd.Dir = townRoot + if out, err := gtInstallCmd.CombinedOutput(); err != nil { + t.Fatalf("gt install: %v\n%s", err, out) + } + + // Create a bare repo to use as the rig source + bareRepo := filepath.Join(tmpDir, "bare-repo.git") + bareInitCmd := exec.Command("git", "init", "--bare", bareRepo) + if out, err := bareInitCmd.CombinedOutput(); err != nil { + t.Fatalf("git init --bare: %v\n%s", err, out) + } + + // Create a temporary clone to add initial content (bare repos need content) + tempClone := filepath.Join(tmpDir, "temp-clone") + cloneCmd := exec.Command("git", "clone", bareRepo, tempClone) + if out, err := cloneCmd.CombinedOutput(); err != nil { + t.Fatalf("git clone bare: %v\n%s", err, out) + } + + // Add initial commit to bare repo + initFileCmd := exec.Command("bash", "-c", "echo 'test' > README.md && git add . && git commit -m 'init'") + initFileCmd.Dir = tempClone + if out, err := initFileCmd.CombinedOutput(); err != nil { + t.Fatalf("initial commit: %v\n%s", err, out) + } + pushCmd := exec.Command("git", "push", "origin", "main") + pushCmd.Dir = tempClone + // Try main first, fall back to master + if _, err := pushCmd.CombinedOutput(); err != nil { + pushCmd2 := exec.Command("git", "push", "origin", "master") + pushCmd2.Dir = tempClone + if out, err := pushCmd2.CombinedOutput(); err != nil { + t.Fatalf("git push: %v\n%s", err, out) + } + } + + // Add rig using gt rig add + rigAddCmd := exec.Command("gt", "rig", "add", "testrig", bareRepo, "--prefix=tr") + rigAddCmd.Dir = townRoot + if out, err := rigAddCmd.CombinedOutput(); err != nil { + t.Fatalf("gt rig add: %v\n%s", err, out) + } + + // Find the rig path + rigPath := filepath.Join(townRoot, "testrig") + + // Verify rig has its own .beads + rigBeadsPath := filepath.Join(rigPath, ".beads") + if _, err := os.Stat(rigBeadsPath); os.IsNotExist(err) { + t.Fatalf("rig .beads not created at %s", rigBeadsPath) + } + + // Create a session.ended event in TOWN beads (simulating mayor/deacon) + townEventPayload := `{"cost_usd":1.50,"session_id":"hq-mayor","role":"mayor","ended_at":"2026-01-12T10:00:00Z"}` + townEventCmd := exec.Command("bd", "create", + "--type=event", + "--title=Town session ended", + "--event-category=session.ended", + "--event-payload="+townEventPayload, + "--json", + ) + townEventCmd.Dir = townRoot + townOut, err := townEventCmd.CombinedOutput() + if err != nil { + t.Fatalf("creating town event: %v\n%s", err, townOut) + } + t.Logf("Created town event: %s", string(townOut)) + + // Create a session.ended event in RIG beads (simulating polecat) + rigEventPayload := `{"cost_usd":2.50,"session_id":"gt-testrig-toast","role":"polecat","rig":"testrig","worker":"toast","ended_at":"2026-01-12T11:00:00Z"}` + rigEventCmd := exec.Command("bd", "create", + "--type=event", + "--title=Rig session ended", + "--event-category=session.ended", + "--event-payload="+rigEventPayload, + "--json", + ) + rigEventCmd.Dir = rigPath + rigOut, err := rigEventCmd.CombinedOutput() + if err != nil { + t.Fatalf("creating rig event: %v\n%s", err, rigOut) + } + t.Logf("Created rig event: %s", string(rigOut)) + + // Verify events are in separate databases by querying each directly + townListCmd := exec.Command("bd", "list", "--type=event", "--all", "--json") + townListCmd.Dir = townRoot + townListOut, err := townListCmd.CombinedOutput() + if err != nil { + t.Fatalf("listing town events: %v\n%s", err, townListOut) + } + + rigListCmd := exec.Command("bd", "list", "--type=event", "--all", "--json") + rigListCmd.Dir = rigPath + rigListOut, err := rigListCmd.CombinedOutput() + if err != nil { + t.Fatalf("listing rig events: %v\n%s", err, rigListOut) + } + + var townEvents, rigEvents []struct{ ID string } + json.Unmarshal(townListOut, &townEvents) + json.Unmarshal(rigListOut, &rigEvents) + + t.Logf("Town beads has %d events", len(townEvents)) + t.Logf("Rig beads has %d events", len(rigEvents)) + + // Both should have events (they're in separate DBs) + if len(townEvents) == 0 { + t.Error("Expected town beads to have events") + } + if len(rigEvents) == 0 { + t.Error("Expected rig beads to have events") + } + + // Save current directory and change to town root for query + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("getting current directory: %v", err) + } + defer func() { + if err := os.Chdir(origDir); err != nil { + t.Errorf("restoring directory: %v", err) + } + }() + + if err := os.Chdir(townRoot); err != nil { + t.Fatalf("changing to town root: %v", err) + } + + // Verify workspace discovery works + foundTownRoot, wsErr := workspace.FindFromCwdOrError() + if wsErr != nil { + t.Fatalf("workspace.FindFromCwdOrError failed: %v", wsErr) + } + if foundTownRoot != townRoot { + t.Errorf("workspace.FindFromCwdOrError returned %s, expected %s", foundTownRoot, townRoot) + } + + // Call querySessionEvents - this should find events from ALL locations + entries := querySessionEvents() + + t.Logf("querySessionEvents returned %d entries", len(entries)) + + // We created 2 session.ended events (one town, one rig) + // The fix should find BOTH + if len(entries) < 2 { + t.Errorf("querySessionEvents found %d entries, expected at least 2 (one from town, one from rig)", len(entries)) + t.Log("This indicates the bug: querySessionEvents only queries town-level beads, missing rig-level events") + } + + // Verify we found both the mayor and polecat sessions + var foundMayor, foundPolecat bool + for _, e := range entries { + t.Logf(" Entry: session=%s role=%s cost=$%.2f", e.SessionID, e.Role, e.CostUSD) + if e.Role == "mayor" { + foundMayor = true + } + if e.Role == "polecat" { + foundPolecat = true + } + } + + if !foundMayor { + t.Error("Missing mayor session from town beads") + } + if !foundPolecat { + t.Error("Missing polecat session from rig beads") + } +}