diff --git a/internal/cmd/hook_slot_integration_test.go b/internal/cmd/hook_slot_integration_test.go new file mode 100644 index 00000000..16521387 --- /dev/null +++ b/internal/cmd/hook_slot_integration_test.go @@ -0,0 +1,488 @@ +//go:build integration + +// Package cmd contains integration tests for hook slot verification. +// +// Run with: go test -tags=integration ./internal/cmd -run TestHookSlot -v +package cmd + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/steveyegge/gastown/internal/beads" +) + +// setupHookTestTown creates a minimal Gas Town with a polecat for testing hooks. +// Returns townRoot and the path to the polecat's worktree. +func setupHookTestTown(t *testing.T) (townRoot, polecatDir string) { + t.Helper() + + townRoot = t.TempDir() + + // Create town-level .beads directory + townBeadsDir := filepath.Join(townRoot, ".beads") + if err := os.MkdirAll(townBeadsDir, 0755); err != nil { + t.Fatalf("mkdir town .beads: %v", err) + } + + // Create routes.jsonl + routes := []beads.Route{ + {Prefix: "hq-", Path: "."}, // Town-level beads + {Prefix: "gt-", Path: "gastown/mayor/rig"}, // Gastown rig + } + if err := beads.WriteRoutes(townBeadsDir, routes); err != nil { + t.Fatalf("write routes: %v", err) + } + + // Create gastown rig structure + gasRigPath := filepath.Join(townRoot, "gastown", "mayor", "rig") + if err := os.MkdirAll(gasRigPath, 0755); err != nil { + t.Fatalf("mkdir gastown: %v", err) + } + + // Create gastown .beads directory with its own config + gasBeadsDir := filepath.Join(gasRigPath, ".beads") + if err := os.MkdirAll(gasBeadsDir, 0755); err != nil { + t.Fatalf("mkdir gastown .beads: %v", err) + } + if err := os.WriteFile(filepath.Join(gasBeadsDir, "config.yaml"), []byte("prefix: gt\n"), 0644); err != nil { + t.Fatalf("write gastown config: %v", err) + } + + // Create polecat worktree with redirect + polecatDir = filepath.Join(townRoot, "gastown", "polecats", "toast") + if err := os.MkdirAll(polecatDir, 0755); err != nil { + t.Fatalf("mkdir polecats: %v", err) + } + + // Create redirect file for polecat -> mayor/rig/.beads + polecatBeadsDir := filepath.Join(polecatDir, ".beads") + if err := os.MkdirAll(polecatBeadsDir, 0755); err != nil { + t.Fatalf("mkdir polecat .beads: %v", err) + } + redirectContent := "../../mayor/rig/.beads" + if err := os.WriteFile(filepath.Join(polecatBeadsDir, "redirect"), []byte(redirectContent), 0644); err != nil { + t.Fatalf("write redirect: %v", err) + } + + return townRoot, polecatDir +} + +// initBeadsDB initializes the beads database by running bd init. +func initBeadsDB(t *testing.T, dir string) { + t.Helper() + + cmd := exec.Command("bd", "--no-daemon", "init") + cmd.Dir = dir + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("bd init failed: %v\n%s", err, output) + } +} + +// TestHookSlot_BasicHook verifies that a bead can be hooked to an agent. +func TestHookSlot_BasicHook(t *testing.T) { + // Skip if bd is not available + if _, err := exec.LookPath("bd"); err != nil { + t.Skip("bd not installed, skipping test") + } + + townRoot, polecatDir := setupHookTestTown(t) + _ = townRoot // Not used directly but shows test context + + // Initialize beads in the rig + rigDir := filepath.Join(polecatDir, "..", "..", "mayor", "rig") + initBeadsDB(t, rigDir) + + b := beads.New(rigDir) + + // Create a test bead + issue, err := b.Create(beads.CreateOptions{ + Title: "Test task for hooking", + Type: "task", + Priority: 2, + }) + if err != nil { + t.Fatalf("create bead: %v", err) + } + t.Logf("Created bead: %s", issue.ID) + + // Hook the bead to the polecat + agentID := "gastown/polecats/toast" + status := beads.StatusHooked + if err := b.Update(issue.ID, beads.UpdateOptions{ + Status: &status, + Assignee: &agentID, + }); err != nil { + t.Fatalf("hook bead: %v", err) + } + + // Verify the bead is hooked + hookedBeads, err := b.List(beads.ListOptions{ + Status: beads.StatusHooked, + Assignee: agentID, + Priority: -1, + }) + if err != nil { + t.Fatalf("list hooked beads: %v", err) + } + + if len(hookedBeads) != 1 { + t.Errorf("expected 1 hooked bead, got %d", len(hookedBeads)) + } + + if len(hookedBeads) > 0 && hookedBeads[0].ID != issue.ID { + t.Errorf("hooked bead ID = %s, want %s", hookedBeads[0].ID, issue.ID) + } +} + +// TestHookSlot_Singleton verifies that only one bead can be hooked per agent. +func TestHookSlot_Singleton(t *testing.T) { + if _, err := exec.LookPath("bd"); err != nil { + t.Skip("bd not installed, skipping test") + } + + townRoot, polecatDir := setupHookTestTown(t) + _ = townRoot + + rigDir := filepath.Join(polecatDir, "..", "..", "mayor", "rig") + initBeadsDB(t, rigDir) + + b := beads.New(rigDir) + agentID := "gastown/polecats/toast" + status := beads.StatusHooked + + // Create and hook first bead + issue1, err := b.Create(beads.CreateOptions{ + Title: "First task", + Type: "task", + Priority: 2, + }) + if err != nil { + t.Fatalf("create first bead: %v", err) + } + + if err := b.Update(issue1.ID, beads.UpdateOptions{ + Status: &status, + Assignee: &agentID, + }); err != nil { + t.Fatalf("hook first bead: %v", err) + } + + // Create second bead + issue2, err := b.Create(beads.CreateOptions{ + Title: "Second task", + Type: "task", + Priority: 2, + }) + if err != nil { + t.Fatalf("create second bead: %v", err) + } + + // Hook second bead to same agent + if err := b.Update(issue2.ID, beads.UpdateOptions{ + Status: &status, + Assignee: &agentID, + }); err != nil { + t.Fatalf("hook second bead: %v", err) + } + + // Query hooked beads - both should be hooked (bd allows multiple) + // The singleton constraint is enforced by gt hook, not bd itself + hookedBeads, err := b.List(beads.ListOptions{ + Status: beads.StatusHooked, + Assignee: agentID, + Priority: -1, + }) + if err != nil { + t.Fatalf("list hooked beads: %v", err) + } + + t.Logf("Found %d hooked beads for agent %s", len(hookedBeads), agentID) + for _, h := range hookedBeads { + t.Logf(" - %s: %s", h.ID, h.Title) + } + + // The test documents actual behavior: bd allows multiple hooked beads + // The gt hook command enforces singleton behavior + if len(hookedBeads) != 2 { + t.Errorf("expected 2 hooked beads (bd allows multiple), got %d", len(hookedBeads)) + } +} + +// TestHookSlot_Unhook verifies that a bead can be unhooked by changing status. +func TestHookSlot_Unhook(t *testing.T) { + if _, err := exec.LookPath("bd"); err != nil { + t.Skip("bd not installed, skipping test") + } + + townRoot, polecatDir := setupHookTestTown(t) + _ = townRoot + + rigDir := filepath.Join(polecatDir, "..", "..", "mayor", "rig") + initBeadsDB(t, rigDir) + + b := beads.New(rigDir) + agentID := "gastown/polecats/toast" + + // Create and hook a bead + issue, err := b.Create(beads.CreateOptions{ + Title: "Task to unhook", + Type: "task", + Priority: 2, + }) + if err != nil { + t.Fatalf("create bead: %v", err) + } + + status := beads.StatusHooked + if err := b.Update(issue.ID, beads.UpdateOptions{ + Status: &status, + Assignee: &agentID, + }); err != nil { + t.Fatalf("hook bead: %v", err) + } + + // Unhook by setting status back to open + openStatus := "open" + if err := b.Update(issue.ID, beads.UpdateOptions{ + Status: &openStatus, + }); err != nil { + t.Fatalf("unhook bead: %v", err) + } + + // Verify no hooked beads remain + hookedBeads, err := b.List(beads.ListOptions{ + Status: beads.StatusHooked, + Assignee: agentID, + Priority: -1, + }) + if err != nil { + t.Fatalf("list hooked beads: %v", err) + } + + if len(hookedBeads) != 0 { + t.Errorf("expected 0 hooked beads after unhook, got %d", len(hookedBeads)) + } +} + +// TestHookSlot_DifferentAgents verifies that different agents can have different hooks. +func TestHookSlot_DifferentAgents(t *testing.T) { + if _, err := exec.LookPath("bd"); err != nil { + t.Skip("bd not installed, skipping test") + } + + townRoot, polecatDir := setupHookTestTown(t) + + // Create second polecat directory + polecat2Dir := filepath.Join(townRoot, "gastown", "polecats", "nux") + if err := os.MkdirAll(polecat2Dir, 0755); err != nil { + t.Fatalf("mkdir polecat2: %v", err) + } + + rigDir := filepath.Join(polecatDir, "..", "..", "mayor", "rig") + initBeadsDB(t, rigDir) + + b := beads.New(rigDir) + agent1 := "gastown/polecats/toast" + agent2 := "gastown/polecats/nux" + status := beads.StatusHooked + + // Create and hook bead to first agent + issue1, err := b.Create(beads.CreateOptions{ + Title: "Toast's task", + Type: "task", + Priority: 2, + }) + if err != nil { + t.Fatalf("create bead 1: %v", err) + } + + if err := b.Update(issue1.ID, beads.UpdateOptions{ + Status: &status, + Assignee: &agent1, + }); err != nil { + t.Fatalf("hook bead to agent1: %v", err) + } + + // Create and hook bead to second agent + issue2, err := b.Create(beads.CreateOptions{ + Title: "Nux's task", + Type: "task", + Priority: 2, + }) + if err != nil { + t.Fatalf("create bead 2: %v", err) + } + + if err := b.Update(issue2.ID, beads.UpdateOptions{ + Status: &status, + Assignee: &agent2, + }); err != nil { + t.Fatalf("hook bead to agent2: %v", err) + } + + // Verify each agent has exactly one hook + agent1Hooks, err := b.List(beads.ListOptions{ + Status: beads.StatusHooked, + Assignee: agent1, + Priority: -1, + }) + if err != nil { + t.Fatalf("list agent1 hooks: %v", err) + } + + agent2Hooks, err := b.List(beads.ListOptions{ + Status: beads.StatusHooked, + Assignee: agent2, + Priority: -1, + }) + if err != nil { + t.Fatalf("list agent2 hooks: %v", err) + } + + if len(agent1Hooks) != 1 { + t.Errorf("agent1 should have 1 hook, got %d", len(agent1Hooks)) + } + if len(agent2Hooks) != 1 { + t.Errorf("agent2 should have 1 hook, got %d", len(agent2Hooks)) + } + + // Verify correct assignment + if len(agent1Hooks) > 0 && agent1Hooks[0].ID != issue1.ID { + t.Errorf("agent1 hook ID = %s, want %s", agent1Hooks[0].ID, issue1.ID) + } + if len(agent2Hooks) > 0 && agent2Hooks[0].ID != issue2.ID { + t.Errorf("agent2 hook ID = %s, want %s", agent2Hooks[0].ID, issue2.ID) + } +} + +// TestHookSlot_HookPersistence verifies that hooks persist across beads object recreation. +func TestHookSlot_HookPersistence(t *testing.T) { + if _, err := exec.LookPath("bd"); err != nil { + t.Skip("bd not installed, skipping test") + } + + townRoot, polecatDir := setupHookTestTown(t) + _ = townRoot + + rigDir := filepath.Join(polecatDir, "..", "..", "mayor", "rig") + initBeadsDB(t, rigDir) + + agentID := "gastown/polecats/toast" + status := beads.StatusHooked + + // Create first beads instance and hook a bead + b1 := beads.New(rigDir) + issue, err := b1.Create(beads.CreateOptions{ + Title: "Persistent task", + Type: "task", + Priority: 2, + }) + if err != nil { + t.Fatalf("create bead: %v", err) + } + + if err := b1.Update(issue.ID, beads.UpdateOptions{ + Status: &status, + Assignee: &agentID, + }); err != nil { + t.Fatalf("hook bead: %v", err) + } + + // Create new beads instance (simulates session restart) + b2 := beads.New(rigDir) + + // Verify hook persists + hookedBeads, err := b2.List(beads.ListOptions{ + Status: beads.StatusHooked, + Assignee: agentID, + Priority: -1, + }) + if err != nil { + t.Fatalf("list hooked beads with new instance: %v", err) + } + + if len(hookedBeads) != 1 { + t.Errorf("expected hook to persist, got %d hooked beads", len(hookedBeads)) + } + + if len(hookedBeads) > 0 && hookedBeads[0].ID != issue.ID { + t.Errorf("persisted hook ID = %s, want %s", hookedBeads[0].ID, issue.ID) + } +} + +// TestHookSlot_StatusTransitions tests valid status transitions for hooked beads. +func TestHookSlot_StatusTransitions(t *testing.T) { + if _, err := exec.LookPath("bd"); err != nil { + t.Skip("bd not installed, skipping test") + } + + townRoot, polecatDir := setupHookTestTown(t) + _ = townRoot + + rigDir := filepath.Join(polecatDir, "..", "..", "mayor", "rig") + initBeadsDB(t, rigDir) + + b := beads.New(rigDir) + agentID := "gastown/polecats/toast" + + // Create a bead + issue, err := b.Create(beads.CreateOptions{ + Title: "Status transition test", + Type: "task", + Priority: 2, + }) + if err != nil { + t.Fatalf("create bead: %v", err) + } + + // Test transitions: open -> hooked -> open -> hooked -> closed + transitions := []struct { + name string + status string + }{ + {"hook", beads.StatusHooked}, + {"unhook", "open"}, + {"rehook", beads.StatusHooked}, + } + + for _, trans := range transitions { + t.Run(trans.name, func(t *testing.T) { + status := trans.status + opts := beads.UpdateOptions{Status: &status} + if trans.status == beads.StatusHooked { + opts.Assignee = &agentID + } + + if err := b.Update(issue.ID, opts); err != nil { + t.Errorf("transition to %s failed: %v", trans.status, err) + } + + // Verify status + updated, err := b.Show(issue.ID) + if err != nil { + t.Errorf("show after %s: %v", trans.name, err) + return + } + if updated.Status != trans.status { + t.Errorf("status after %s = %s, want %s", trans.name, updated.Status, trans.status) + } + }) + } + + // Finally close the bead + if err := b.Close(issue.ID); err != nil { + t.Errorf("close hooked bead: %v", err) + } + + // Verify it's closed + closed, err := b.Show(issue.ID) + if err != nil { + t.Fatalf("show closed bead: %v", err) + } + if closed.Status != "closed" { + t.Errorf("final status = %s, want closed", closed.Status) + } +}