fix: ignore hidden directories when enumerating polecats (#258)
* fix(sling): route bd mol commands to target rig directory * Fix daemon polecat enumeration to ignore hidden dirs * Ignore hidden dirs when discovering rig polecats * Fix CI: enable beads custom types during install --------- Co-authored-by: joshuavial <git@codewithjv.com>
This commit is contained in:
@@ -339,6 +339,13 @@ func initTownBeads(townPath string) error {
|
||||
fmt.Printf(" %s Could not register custom types: %v\n", style.Dim.Render("⚠"), err)
|
||||
}
|
||||
|
||||
// Ensure routes.jsonl has an explicit town-level mapping for hq-* beads.
|
||||
// This keeps hq-* operations stable even when invoked from rig worktrees.
|
||||
if err := beads.AppendRoute(townPath, beads.Route{Prefix: "hq-", Path: "."}); err != nil {
|
||||
// Non-fatal: routing still works in many contexts, but explicit mapping is preferred.
|
||||
fmt.Printf(" %s Could not update routes.jsonl: %v\n", style.Dim.Render("⚠"), err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -390,6 +397,13 @@ func ensureCustomTypes(beadsPath string) error {
|
||||
func initTownAgentBeads(townPath string) error {
|
||||
bd := beads.New(townPath)
|
||||
|
||||
// bd init doesn't enable "custom" issue types by default, but Gas Town uses
|
||||
// agent/role beads during install and runtime. Ensure these types are enabled
|
||||
// before attempting to create any town-level system beads.
|
||||
if err := ensureBeadsCustomTypes(townPath, []string{"agent", "role", "rig", "convoy", "slot"}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Role beads (global templates)
|
||||
roleDefs := []struct {
|
||||
id string
|
||||
@@ -508,3 +522,17 @@ func initTownAgentBeads(townPath string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureBeadsCustomTypes(workDir string, types []string) error {
|
||||
if len(types) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := exec.Command("bd", "config", "set", "types.custom", strings.Join(types, ","))
|
||||
cmd.Dir = workDir
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("bd config set types.custom failed: %s", strings.TrimSpace(string(output)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -387,8 +387,14 @@ func runSling(cmd *cobra.Command, args []string) error {
|
||||
if formulaName != "" {
|
||||
fmt.Printf(" Instantiating formula %s...\n", formulaName)
|
||||
|
||||
// Route bd mutations (cook/wisp/bond) to the correct beads context for the target bead.
|
||||
// Some bd mol commands don't support prefix routing, so we must run them from the
|
||||
// rig directory that owns the bead's database.
|
||||
formulaWorkDir := beads.ResolveHookDir(townRoot, beadID, hookWorkDir)
|
||||
|
||||
// Step 1: Cook the formula (ensures proto exists)
|
||||
cookCmd := exec.Command("bd", "--no-daemon", "cook", formulaName)
|
||||
cookCmd.Dir = formulaWorkDir
|
||||
cookCmd.Stderr = os.Stderr
|
||||
if err := cookCmd.Run(); err != nil {
|
||||
return fmt.Errorf("cooking formula %s: %w", formulaName, err)
|
||||
@@ -398,6 +404,7 @@ func runSling(cmd *cobra.Command, args []string) error {
|
||||
featureVar := fmt.Sprintf("feature=%s", info.Title)
|
||||
wispArgs := []string{"--no-daemon", "mol", "wisp", formulaName, "--var", featureVar, "--json"}
|
||||
wispCmd := exec.Command("bd", wispArgs...)
|
||||
wispCmd.Dir = formulaWorkDir
|
||||
wispCmd.Stderr = os.Stderr
|
||||
wispOut, err := wispCmd.Output()
|
||||
if err != nil {
|
||||
@@ -415,6 +422,7 @@ func runSling(cmd *cobra.Command, args []string) error {
|
||||
// Use --no-daemon for mol bond (requires direct database access)
|
||||
bondArgs := []string{"--no-daemon", "mol", "bond", wispRootID, beadID, "--json"}
|
||||
bondCmd := exec.Command("bd", bondArgs...)
|
||||
bondCmd.Dir = formulaWorkDir
|
||||
bondCmd.Stderr = os.Stderr
|
||||
bondOut, err := bondCmd.Output()
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package cmd
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseWispIDFromJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -183,3 +188,158 @@ func TestFormatTrackBeadIDConsumerCompatibility(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlingFormulaOnBeadRoutesBDCommandsToTargetRig(t *testing.T) {
|
||||
townRoot := t.TempDir()
|
||||
|
||||
// Minimal workspace marker so workspace.FindFromCwd() succeeds.
|
||||
if err := os.MkdirAll(filepath.Join(townRoot, "mayor", "rig"), 0755); err != nil {
|
||||
t.Fatalf("mkdir mayor/rig: %v", err)
|
||||
}
|
||||
|
||||
// Create a rig path that owns gt-* beads, and a routes.jsonl pointing to it.
|
||||
rigDir := filepath.Join(townRoot, "gastown", "mayor", "rig")
|
||||
if err := os.MkdirAll(filepath.Join(townRoot, ".beads"), 0755); err != nil {
|
||||
t.Fatalf("mkdir .beads: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(rigDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir rigDir: %v", err)
|
||||
}
|
||||
routes := strings.Join([]string{
|
||||
`{"prefix":"gt-","path":"gastown/mayor/rig"}`,
|
||||
`{"prefix":"hq-","path":"."}`,
|
||||
"",
|
||||
}, "\n")
|
||||
if err := os.WriteFile(filepath.Join(townRoot, ".beads", "routes.jsonl"), []byte(routes), 0644); err != nil {
|
||||
t.Fatalf("write routes.jsonl: %v", err)
|
||||
}
|
||||
|
||||
// Stub bd so we can observe the working directory for cook/wisp/bond.
|
||||
binDir := filepath.Join(townRoot, "bin")
|
||||
if err := os.MkdirAll(binDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir binDir: %v", err)
|
||||
}
|
||||
logPath := filepath.Join(townRoot, "bd.log")
|
||||
bdPath := filepath.Join(binDir, "bd")
|
||||
bdScript := `#!/bin/sh
|
||||
set -e
|
||||
echo "$(pwd)|$*" >> "${BD_LOG}"
|
||||
if [ "$1" = "--no-daemon" ]; then
|
||||
shift
|
||||
fi
|
||||
cmd="$1"
|
||||
shift || true
|
||||
case "$cmd" in
|
||||
show)
|
||||
echo '[{"title":"Test issue","status":"open","assignee":"","description":""}]'
|
||||
;;
|
||||
formula)
|
||||
# formula show <name>
|
||||
exit 0
|
||||
;;
|
||||
cook)
|
||||
exit 0
|
||||
;;
|
||||
mol)
|
||||
sub="$1"
|
||||
shift || true
|
||||
case "$sub" in
|
||||
wisp)
|
||||
echo '{"new_epic_id":"gt-wisp-xyz"}'
|
||||
;;
|
||||
bond)
|
||||
echo '{"root_id":"gt-wisp-xyz"}'
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
`
|
||||
if err := os.WriteFile(bdPath, []byte(bdScript), 0755); err != nil {
|
||||
t.Fatalf("write bd stub: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("BD_LOG", logPath)
|
||||
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
t.Setenv(EnvGTRole, "mayor")
|
||||
t.Setenv("GT_POLECAT", "")
|
||||
t.Setenv("GT_CREW", "")
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
||||
if err := os.Chdir(filepath.Join(townRoot, "mayor", "rig")); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
|
||||
// Ensure we don't leak global flag state across tests.
|
||||
prevOn := slingOnTarget
|
||||
prevVars := slingVars
|
||||
prevDryRun := slingDryRun
|
||||
prevNoConvoy := slingNoConvoy
|
||||
t.Cleanup(func() {
|
||||
slingOnTarget = prevOn
|
||||
slingVars = prevVars
|
||||
slingDryRun = prevDryRun
|
||||
slingNoConvoy = prevNoConvoy
|
||||
})
|
||||
|
||||
slingDryRun = false
|
||||
slingNoConvoy = true
|
||||
slingVars = nil
|
||||
slingOnTarget = "gt-abc123"
|
||||
|
||||
if err := runSling(nil, []string{"mol-review"}); err != nil {
|
||||
t.Fatalf("runSling: %v", err)
|
||||
}
|
||||
|
||||
logBytes, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read bd log: %v", err)
|
||||
}
|
||||
logLines := strings.Split(strings.TrimSpace(string(logBytes)), "\n")
|
||||
|
||||
wantDir := rigDir
|
||||
if resolved, err := filepath.EvalSymlinks(wantDir); err == nil {
|
||||
wantDir = resolved
|
||||
}
|
||||
gotCook := false
|
||||
gotWisp := false
|
||||
gotBond := false
|
||||
|
||||
for _, line := range logLines {
|
||||
parts := strings.SplitN(line, "|", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
dir := parts[0]
|
||||
if resolved, err := filepath.EvalSymlinks(dir); err == nil {
|
||||
dir = resolved
|
||||
}
|
||||
args := parts[1]
|
||||
|
||||
switch {
|
||||
case strings.Contains(args, " cook "):
|
||||
gotCook = true
|
||||
if dir != wantDir {
|
||||
t.Fatalf("bd cook ran in %q, want %q (args: %q)", dir, wantDir, args)
|
||||
}
|
||||
case strings.Contains(args, " mol wisp "):
|
||||
gotWisp = true
|
||||
if dir != wantDir {
|
||||
t.Fatalf("bd mol wisp ran in %q, want %q (args: %q)", dir, wantDir, args)
|
||||
}
|
||||
case strings.Contains(args, " mol bond "):
|
||||
gotBond = true
|
||||
if dir != wantDir {
|
||||
t.Fatalf("bd mol bond ran in %q, want %q (args: %q)", dir, wantDir, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !gotCook || !gotWisp || !gotBond {
|
||||
t.Fatalf("missing expected bd commands: cook=%v wisp=%v bond=%v (log: %q)", gotCook, gotWisp, gotBond, string(logBytes))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -879,6 +879,18 @@ func TestRuntimeConfigBuildCommandWithPrompt(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildAgentStartupCommand(t *testing.T) {
|
||||
// BuildAgentStartupCommand auto-detects town root from cwd when rigPath is empty.
|
||||
// Use a temp directory to ensure we exercise the fallback default config path.
|
||||
origWD, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmpWD := t.TempDir()
|
||||
if err := os.Chdir(tmpWD); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(origWD) })
|
||||
|
||||
// Test without rig config (uses defaults)
|
||||
cmd := BuildAgentStartupCommand("witness", "gastown/witness", "", "")
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -439,7 +440,7 @@ func (d *Daemon) ensureRefineriesRunning() {
|
||||
// ensureRefineryRunning ensures the refinery for a specific rig is running.
|
||||
// Discover, don't track: uses Manager.Start() which checks tmux directly (gt-zecmc).
|
||||
func (d *Daemon) ensureRefineryRunning(rigName string) {
|
||||
// Check rig operational state before auto-starting
|
||||
// Check rig operational state before auto-starting
|
||||
if operational, reason := d.isRigOperational(rigName); !operational {
|
||||
d.logger.Printf("Skipping refinery auto-start for %s: %s", rigName, reason)
|
||||
return
|
||||
@@ -697,18 +698,35 @@ func (d *Daemon) checkPolecatSessionHealth() {
|
||||
func (d *Daemon) checkRigPolecatHealth(rigName string) {
|
||||
// Get polecat directories for this rig
|
||||
polecatsDir := filepath.Join(d.config.TownRoot, rigName, "polecats")
|
||||
entries, err := os.ReadDir(polecatsDir)
|
||||
polecats, err := listPolecatWorktrees(polecatsDir)
|
||||
if err != nil {
|
||||
return // No polecats directory - rig might not have polecats
|
||||
}
|
||||
|
||||
for _, polecatName := range polecats {
|
||||
d.checkPolecatHealth(rigName, polecatName)
|
||||
}
|
||||
}
|
||||
|
||||
func listPolecatWorktrees(polecatsDir string) ([]string, error) {
|
||||
entries, err := os.ReadDir(polecatsDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
polecats := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
polecatName := entry.Name()
|
||||
d.checkPolecatHealth(rigName, polecatName)
|
||||
name := entry.Name()
|
||||
if strings.HasPrefix(name, ".") {
|
||||
continue
|
||||
}
|
||||
polecats = append(polecats, name)
|
||||
}
|
||||
|
||||
return polecats, nil
|
||||
}
|
||||
|
||||
// checkPolecatHealth checks a single polecat's session health.
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -206,6 +207,33 @@ func TestSaveLoadState_Roundtrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPolecatWorktrees_SkipsHiddenDirs(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
polecatsDir := filepath.Join(tmpDir, "some-rig", "polecats")
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(polecatsDir, ".claude"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(polecatsDir, "furiosa"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(polecatsDir, "not-a-dir.txt"), []byte("x"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
polecats, err := listPolecatWorktrees(polecatsDir)
|
||||
if err != nil {
|
||||
t.Fatalf("listPolecatWorktrees returned error: %v", err)
|
||||
}
|
||||
|
||||
if slices.Contains(polecats, ".claude") {
|
||||
t.Fatalf("expected hidden dir .claude to be ignored, got %v", polecats)
|
||||
}
|
||||
if !slices.Contains(polecats, "furiosa") {
|
||||
t.Fatalf("expected furiosa to be included, got %v", polecats)
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: TestIsWitnessSession removed - isWitnessSession function was deleted
|
||||
// as part of ZFC cleanup. Witness poking is now Deacon's responsibility.
|
||||
|
||||
|
||||
@@ -120,9 +120,14 @@ func (m *Manager) loadRig(name string, entry config.RigEntry) (*Rig, error) {
|
||||
polecatsDir := filepath.Join(rigPath, "polecats")
|
||||
if entries, err := os.ReadDir(polecatsDir); err == nil {
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
rig.Polecats = append(rig.Polecats, e.Name())
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if strings.HasPrefix(name, ".") {
|
||||
continue
|
||||
}
|
||||
rig.Polecats = append(rig.Polecats, name)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package rig
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -55,6 +56,10 @@ func createTestRig(t *testing.T, root, name string) {
|
||||
t.Fatalf("mkdir polecat: %v", err)
|
||||
}
|
||||
}
|
||||
// Create a shared support dir that should not be treated as a polecat worktree.
|
||||
if err := os.MkdirAll(filepath.Join(polecatsDir, ".claude"), 0755); err != nil {
|
||||
t.Fatalf("mkdir polecats/.claude: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverRigs(t *testing.T) {
|
||||
@@ -84,6 +89,9 @@ func TestDiscoverRigs(t *testing.T) {
|
||||
if len(rig.Polecats) != 2 {
|
||||
t.Errorf("Polecats count = %d, want 2", len(rig.Polecats))
|
||||
}
|
||||
if slices.Contains(rig.Polecats, ".claude") {
|
||||
t.Errorf("expected polecats/.claude to be ignored, got %v", rig.Polecats)
|
||||
}
|
||||
if !rig.HasWitness {
|
||||
t.Error("expected HasWitness = true")
|
||||
}
|
||||
@@ -430,17 +438,17 @@ func TestIsValidBeadsPrefix(t *testing.T) {
|
||||
{"a-b-c", true},
|
||||
|
||||
// Invalid prefixes
|
||||
{"", false}, // empty
|
||||
{"1abc", false}, // starts with number
|
||||
{"-abc", false}, // starts with hyphen
|
||||
{"abc def", false}, // contains space
|
||||
{"abc;ls", false}, // shell injection attempt
|
||||
{"$(whoami)", false}, // command substitution
|
||||
{"`id`", false}, // backtick command
|
||||
{"abc|cat", false}, // pipe
|
||||
{"../etc/passwd", false}, // path traversal
|
||||
{"", false}, // empty
|
||||
{"1abc", false}, // starts with number
|
||||
{"-abc", false}, // starts with hyphen
|
||||
{"abc def", false}, // contains space
|
||||
{"abc;ls", false}, // shell injection attempt
|
||||
{"$(whoami)", false}, // command substitution
|
||||
{"`id`", false}, // backtick command
|
||||
{"abc|cat", false}, // pipe
|
||||
{"../etc/passwd", false}, // path traversal
|
||||
{"aaaaaaaaaaaaaaaaaaaaa", false}, // too long (21 chars, >20 limit)
|
||||
{"valid-but-with-$var", false}, // variable reference
|
||||
{"valid-but-with-$var", false}, // variable reference
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
Reference in New Issue
Block a user