Files
gastown/internal/cmd/costs_workdir_test.go
JJ b1a5241430 fix(beads): align agent bead prefixes and force multi-hyphen IDs (#482)
* fix(beads): align agent bead prefixes and force multi-hyphen IDs

* fix(checkpoint): treat threshold as stale at boundary
2026-01-16 12:33:51 -08:00

256 lines
8.5 KiB
Go

package cmd
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/steveyegge/gastown/internal/workspace"
)
// filterGTEnv removes GT_* and BD_* environment variables to isolate test subprocess.
// This prevents tests from inheriting the parent workspace's Gas Town configuration.
func filterGTEnv(env []string) []string {
filtered := make([]string, 0, len(env))
for _, e := range env {
if strings.HasPrefix(e, "GT_") || strings.HasPrefix(e, "BD_") {
continue
}
filtered = append(filtered, e)
}
return filtered
}
// 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")
}
// Skip when running inside a Gas Town workspace - this integration test
// creates a separate workspace and the subprocesses can interact with
// the parent workspace's daemon, causing hangs.
if os.Getenv("GT_TOWN_ROOT") != "" || os.Getenv("BD_ACTOR") != "" {
t.Skip("skipping integration test inside Gas Town workspace (use 'go test' outside workspace)")
}
// 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
// Clear GT environment variables to isolate test from parent workspace
gtInstallCmd := exec.Command("gt", "install")
gtInstallCmd.Dir = townRoot
gtInstallCmd.Env = filterGTEnv(os.Environ())
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
rigAddCmd.Env = filterGTEnv(os.Environ())
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
townEventCmd.Env = filterGTEnv(os.Environ())
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
rigEventCmd.Env = filterGTEnv(os.Environ())
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
townListCmd.Env = filterGTEnv(os.Environ())
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
rigListCmd.Env = filterGTEnv(os.Environ())
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)
}
normalizePath := func(path string) string {
resolved, err := filepath.EvalSymlinks(path)
if err != nil {
return filepath.Clean(path)
}
return resolved
}
if normalizePath(foundTownRoot) != normalizePath(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")
}
}