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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: julianknutsen <julianknutsen@users.noreply.github> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,15 +6,18 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
"github.com/steveyegge/gastown/internal/constants"
|
"github.com/steveyegge/gastown/internal/constants"
|
||||||
"github.com/steveyegge/gastown/internal/style"
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -275,10 +278,7 @@ func runCostsFromLedger() error {
|
|||||||
} else {
|
} else {
|
||||||
// No time filter: query both digests and legacy session.ended events
|
// No time filter: query both digests and legacy session.ended events
|
||||||
// (for backwards compatibility during migration)
|
// (for backwards compatibility during migration)
|
||||||
entries, err = querySessionEvents()
|
entries = querySessionEvents()
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("querying session events: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(entries) == 0 {
|
if len(entries) == 0 {
|
||||||
@@ -353,7 +353,62 @@ type EventListItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// querySessionEvents queries beads for session.ended events and converts them to CostEntry.
|
// 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
|
// Step 1: Get list of event IDs
|
||||||
listArgs := []string{
|
listArgs := []string{
|
||||||
"list",
|
"list",
|
||||||
@@ -364,6 +419,7 @@ func querySessionEvents() ([]CostEntry, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
listCmd := exec.Command("bd", listArgs...)
|
listCmd := exec.Command("bd", listArgs...)
|
||||||
|
listCmd.Dir = location
|
||||||
listOutput, err := listCmd.Output()
|
listOutput, err := listCmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If bd fails (e.g., no beads database), return empty list
|
// 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 := exec.Command("bd", showArgs...)
|
||||||
|
showCmd.Dir = location
|
||||||
showOutput, err := showCmd.Output()
|
showOutput, err := showCmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("showing events: %w", err)
|
return nil, fmt.Errorf("showing events: %w", err)
|
||||||
|
|||||||
220
internal/cmd/costs_workdir_test.go
Normal file
220
internal/cmd/costs_workdir_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user