diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..00dfced --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,9 @@ +# Default owners for everything in the repo. +* @RandomCodeSpace + +# Build, release, and CI configuration. +/Makefile @RandomCodeSpace +/.goreleaser.yml @RandomCodeSpace +/.github/ @RandomCodeSpace +/go.mod @RandomCodeSpace +/go.sum @RandomCodeSpace diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d269997 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,28 @@ +version: 2 + +updates: + - package-ecosystem: gomod + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + vendor: true + commit-message: + prefix: "chore(deps)" + groups: + charmbracelet: + patterns: + - "github.com/charmbracelet/*" + - "github.com/mattn/go-runewidth" + - "github.com/rivo/uniseg" + golang-x: + patterns: + - "golang.org/x/*" + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + commit-message: + prefix: "chore(ci)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ae62e9b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + build-test-lint: + name: Build, test, and lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Build + run: go build ./... + + - name: Vet + run: go vet ./... + + - name: Check gofmt + run: | + unformatted="$(gofmt -l . | grep -v '^vendor/' || true)" + if [ -n "$unformatted" ]; then + echo "The following files are not gofmt-clean:" + echo "$unformatted" + exit 1 + fi + + - name: Test + run: go test -race -cover ./... + + - name: Lint + uses: golangci/golangci-lint-action@9fae48acfc02a90574d7c304a1758ef9895495fa # v7 + with: + version: v2.1.6 + install-mode: goinstall + args: ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c0d2cc6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,41 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + id-token: write + packages: read + +jobs: + goreleaser: + name: GoReleaser + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Install Syft (SBOM) + uses: anchore/sbom-action/download-syft@df80a981bc6edbc4e220a492d3cbe9f5547a6e75 # v0.17.9 + + - name: Install Cosign + uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0 + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6 + with: + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 9678360..8d43616 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -30,7 +30,7 @@ jobs: cache: true - name: Install govulncheck - run: go install golang.org/x/vuln/cmd/govulncheck@latest + run: go install golang.org/x/vuln/cmd/govulncheck@v1.3.0 - name: Run govulncheck run: govulncheck ./... @@ -49,10 +49,10 @@ jobs: cache: true - name: Install gosec - run: go install github.com/securego/gosec/v2/cmd/gosec@latest + run: go install github.com/securego/gosec/v2/cmd/gosec@v2.26.1 - name: Run gosec - run: gosec -exclude-dir=.claude -fmt=sarif -out=gosec.sarif ./... + run: gosec -exclude-dir=.claude -exclude-dir=vendor -fmt=sarif -out=gosec.sarif ./... - name: Upload gosec SARIF if: always() diff --git a/.gitignore b/.gitignore index a783a86..29e4f4f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /bin/ +/dist/ *.test *.out coverage.* diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..e7de2a4 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,59 @@ +version: 2 + +project_name: uam + +before: + hooks: + - go mod download + +builds: + - id: uam + main: ./cmd/uam + binary: uam + env: + - CGO_ENABLED=0 + flags: + - -trimpath + ldflags: + - -s -w -X github.com/RandomCodeSpace/unified-agent-manager/internal/version.Override={{ .Version }} + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + +archives: + - id: uam + format: tar.gz + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + +checksum: + name_template: "SHA256SUMS" + algorithm: sha256 + +sboms: + - id: archive + artifacts: archive + +signs: + - id: checksum-cosign + cmd: cosign + artifacts: checksum + signature: "${artifact}.sig" + certificate: "${artifact}.pem" + args: + - sign-blob + - "--yes" + - "--output-signature=${signature}" + - "--output-certificate=${certificate}" + - "${artifact}" + output: true + +release: + draft: false + prerelease: auto + +changelog: + use: github + sort: asc diff --git a/Makefile b/Makefile index 400de79..19e4956 100644 --- a/Makefile +++ b/Makefile @@ -10,11 +10,11 @@ LDFLAGS := -X $(MODULE)/internal/version.Override=$(VERSION) all: build build: - go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) $(CMD) + CGO_ENABLED=0 go build -trimpath -ldflags "-s -w $(LDFLAGS)" -o bin/$(BINARY) $(CMD) install: mkdir -p $(GOBIN) - go build -ldflags "$(LDFLAGS)" -o $(GOBIN)/$(BINARY) $(CMD) + CGO_ENABLED=0 go build -trimpath -ldflags "-s -w $(LDFLAGS)" -o $(GOBIN)/$(BINARY) $(CMD) run: build ./bin/$(BINARY) diff --git a/internal/adapter/adapter.go b/internal/adapter/adapter.go index 0e485d5..761dc56 100644 --- a/internal/adapter/adapter.go +++ b/internal/adapter/adapter.go @@ -87,9 +87,12 @@ type ResumableAdapter interface { Resume(ctx Context, req ResumeRequest) (Session, error) } -type SessionEvent struct { - SessionID string - Kind string +// HasSessionAdapter reports whether the agent's underlying session for id is +// still live. Optional: Service.Stop probes it after a failed kill to avoid +// deleting/flagging a record whose pane is still running (F04). TmuxAgent +// implements it for free, so all tmux-backed providers inherit it. +type HasSessionAdapter interface { + HasSession(ctx Context, id string) bool } type DispatchRequest struct { @@ -109,6 +112,4 @@ type AgentAdapter interface { Reply(ctx Context, id, text string) error Attach(id string) (AttachSpec, error) Stop(ctx Context, id string) error - Rename(ctx Context, id, newName string) error - Subscribe(ctx Context) (<-chan SessionEvent, error) } diff --git a/internal/adapter/capture_cadence_test.go b/internal/adapter/capture_cadence_test.go new file mode 100644 index 0000000..cf261ed --- /dev/null +++ b/internal/adapter/capture_cadence_test.go @@ -0,0 +1,107 @@ +package adapter + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/tmux" +) + +// newCadenceAgent builds a TmuxAgent over a fake tmux that records every +// invocation (so capture-pane calls can be counted) and reports one live +// session whose pane prints a PR URL. +func newCadenceAgent(t *testing.T) (*TmuxAgent, string) { + t.Helper() + dir := t.TempDir() + tmuxPath := filepath.Join(dir, "tmux") + writeExecutable(t, tmuxPath, `#!/bin/sh +printf '%s\n' "$*" >> "$TMUX_LOG" +case "$*" in + *"list-sessions"*) echo "uam-fake-abc12345|1710000000|0|1|/tmp/repo|fakeagent" ;; + *"capture-pane"*) printf 'Thinking...\ncreated https://github.com/o/r/pull/7\n' ;; +esac +exit 0 +`) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + logPath := filepath.Join(dir, "tmux.log") + t.Setenv("TMUX_LOG", logPath) + client := tmux.New("uam") + client.Executable = tmuxPath + ag := NewTmuxAgent("fake", "Fake Agent", []CommandCandidate{{Display: "fakeagent", Args: []string{"fakeagent"}}}, []string{"--yolo"}, client) + return ag, logPath +} + +func countCaptures(t *testing.T, logPath string) int { + t.Helper() + b, _ := os.ReadFile(logPath) + n := 0 + for _, line := range strings.Split(string(b), "\n") { + if strings.Contains(line, "capture-pane") { + n++ + } + } + return n +} + +// F16 — capture-pane must NOT run on every List tick. After the first +// discovery, subsequent ticks within the rescan interval must reuse the prior +// result (no new capture), so the dashboard's 2s refresh doesn't fork a +// capture-pane per session per tick. +func TestListDoesNotCapturePerSessionEveryTick(t *testing.T) { + ag, logPath := newCadenceAgent(t) + clock := time.Unix(1710000000, 0) + ag.now = func() time.Time { return clock } + + // First List: discovers the session, captures once to scrape the PR. + if _, err := ag.List(context.Background()); err != nil { + t.Fatalf("List 1: %v", err) + } + if got := countCaptures(t, logPath); got != 1 { + t.Fatalf("first List should capture exactly once, got %d", got) + } + + // Several more ticks within the rescan interval: no additional capture. + for i := 0; i < 5; i++ { + clock = clock.Add(2 * time.Second) + ag.now = func() time.Time { return clock } + if _, err := ag.List(context.Background()); err != nil { + t.Fatalf("List tick %d: %v", i, err) + } + } + if got := countCaptures(t, logPath); got != 1 { + t.Fatalf("ticks within rescan interval must not re-capture, got %d captures", got) + } +} + +// F16 — once the rescan interval elapses, List must re-capture to pick up a PR +// URL that appeared after first discovery. +func TestListRescansForNewPRAfterInterval(t *testing.T) { + ag, logPath := newCadenceAgent(t) + clock := time.Unix(1710000000, 0) + ag.now = func() time.Time { return clock } + + if _, err := ag.List(context.Background()); err != nil { + t.Fatalf("List 1: %v", err) + } + if got := countCaptures(t, logPath); got != 1 { + t.Fatalf("first List should capture once, got %d", got) + } + + // Advance past the rescan interval. + clock = clock.Add(61 * time.Second) + ag.now = func() time.Time { return clock } + sessions, err := ag.List(context.Background()) + if err != nil { + t.Fatalf("List 2: %v", err) + } + if got := countCaptures(t, logPath); got != 2 { + t.Fatalf("List past rescan interval must re-capture, got %d captures", got) + } + if len(sessions) != 1 || sessions[0].PR == nil || sessions[0].PR.Number != 7 { + t.Fatalf("rescan should re-discover PR: %+v", sessions) + } +} diff --git a/internal/adapter/claude/claude.go b/internal/adapter/claude/claude.go index 6c76637..9888694 100644 --- a/internal/adapter/claude/claude.go +++ b/internal/adapter/claude/claude.go @@ -5,6 +5,21 @@ import ( "github.com/RandomCodeSpace/unified-agent-manager/internal/tmux" ) +// sessionArgs appends claude's `--continue` flag on resume so picking "Resume" +// on an Exited claude row reattaches to the agent's most recent session instead +// of relaunching a fresh one. claude has no flag for presetting its session ID +// at dispatch, so uam can't resume by uam-id directly; `--continue` picks +// claude's last session for the current cwd. The uam UUID is never passed. +func sessionArgs(_ adapter.ResumeRequest, activity string) []string { + if activity == "resumed" { + return []string{"--continue"} + } + return nil +} + func New(client *tmux.Client) adapter.AgentAdapter { - return adapter.NewTmuxAgent("claude", "Claude Code", []adapter.CommandCandidate{{Display: "claude", Args: []string{"claude"}}}, []string{"--dangerously-skip-permissions"}, client) + a := adapter.NewTmuxAgent("claude", "Claude Code", []adapter.CommandCandidate{{Display: "claude", Args: []string{"claude"}}}, []string{"--dangerously-skip-permissions"}, client) + a.SessionArgs = sessionArgs + a.SkipPromptOnResume = true + return a } diff --git a/internal/adapter/claude/claude_test.go b/internal/adapter/claude/claude_test.go index c319876..e85df79 100644 --- a/internal/adapter/claude/claude_test.go +++ b/internal/adapter/claude/claude_test.go @@ -1,6 +1,16 @@ package claude -import "testing" +import ( + "context" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/tmux" +) func TestNew(t *testing.T) { a := New(nil) @@ -8,3 +18,110 @@ func TestNew(t *testing.T) { t.Fatalf("bad adapter: %#v", a) } } + +// TestYoloArgs locks in claude's full-access flag exactly. A drift here +// silently changes whether dispatched sessions run with permissions skipped. +func TestYoloArgs(t *testing.T) { + ta, ok := New(nil).(*adapter.TmuxAgent) + if !ok { + t.Fatalf("expected *adapter.TmuxAgent") + } + if got, want := ta.YoloArgs, []string{"--dangerously-skip-permissions"}; !reflect.DeepEqual(got, want) { + t.Fatalf("YoloArgs = %v, want %v", got, want) + } +} + +// TestNewWiresSessionArgs asserts New installs the SessionArgs hook and +// SkipPromptOnResume. Without this wiring, picking "Resume" on a claude row +// would relaunch a fresh agent (no --continue) AND re-fire the original prompt. +func TestNewWiresSessionArgs(t *testing.T) { + ta, ok := New(nil).(*adapter.TmuxAgent) + if !ok { + t.Fatalf("expected *adapter.TmuxAgent") + } + if ta.SessionArgs == nil { + t.Fatal("expected SessionArgs to be wired") + } + if !ta.SkipPromptOnResume { + t.Fatal("expected SkipPromptOnResume to be true") + } +} + +// TestResumeAppendsContinueAndDoesNotReplayPrompt: resuming an Exited claude +// row must use claude's --continue (resume last session) and must NOT replay +// the original prompt into the restored session, nor pass the uam UUID. +func TestResumeAppendsContinueAndDoesNotReplayPrompt(t *testing.T) { + a, logPath := newTestClaudeAdapter(t) + resumable, ok := a.(adapter.ResumableAdapter) + if !ok { + t.Fatal("claude adapter should be resumable") + } + _, err := resumable.Resume(context.Background(), adapter.ResumeRequest{ID: "abc12345-dead-beef-cafe-0123456789ab", Prompt: "fix parser", Cwd: "/tmp", Mode: "yolo", TmuxSession: "uam-claude-abc12345"}) + if err != nil { + t.Fatalf("Resume: %v", err) + } + logText := readLog(t, logPath) + if !strings.Contains(logText, "claude --dangerously-skip-permissions --continue") { + t.Fatalf("claude resume should append --continue: %s", logText) + } + // The uam UUID may appear in the UAM_ID env var, but must never be passed + // as a flag argument to claude (no --resume / --continue ). + if strings.Contains(logText, "--continue abc12345-dead-beef-cafe-0123456789ab") || + strings.Contains(logText, "--resume") { + t.Fatalf("claude resume must not pass the uam UUID as a flag arg: %s", logText) + } + if strings.Contains(logText, "send-keys") || strings.Contains(logText, "fix parser") { + t.Fatalf("resume should not replay the original prompt: %s", logText) + } +} + +// TestDispatchUnchanged_sendsPromptNoContinue: dispatch keeps its byte-identical +// argv (no --continue) and still sends the prompt. +func TestDispatchUnchanged_sendsPromptNoContinue(t *testing.T) { + a, logPath := newTestClaudeAdapter(t) + _, err := a.Dispatch(context.Background(), adapter.DispatchRequest{Prompt: "fix parser", Cwd: "/tmp", Mode: "yolo"}) + if err != nil { + t.Fatalf("Dispatch: %v", err) + } + logText := readLog(t, logPath) + if strings.Contains(logText, "--continue") { + t.Fatalf("dispatch must not append --continue: %s", logText) + } + if !strings.Contains(logText, "send-keys") || !strings.Contains(logText, "fix parser") { + t.Fatalf("dispatch should send the prompt: %s", logText) + } +} + +func newTestClaudeAdapter(t *testing.T) (adapter.AgentAdapter, string) { + t.Helper() + dir := t.TempDir() + writeExecutable(t, filepath.Join(dir, "claude"), "#!/bin/sh\nexit 0\n") + tmuxPath := filepath.Join(dir, "tmux") + writeExecutable(t, tmuxPath, `#!/bin/sh +printf '%s\n' "$*" >> "$TMUX_LOG" +exit 0 +`) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + logPath := filepath.Join(dir, "tmux.log") + t.Setenv("TMUX_LOG", logPath) + + client := tmux.New("uam") + client.Executable = tmuxPath + return New(client), logPath +} + +func readLog(t *testing.T, path string) string { + t.Helper() + logData, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(logData) +} + +func writeExecutable(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatal(err) + } +} diff --git a/internal/adapter/codex/codex.go b/internal/adapter/codex/codex.go index 95c35d4..5d8a8f4 100644 --- a/internal/adapter/codex/codex.go +++ b/internal/adapter/codex/codex.go @@ -5,6 +5,21 @@ import ( "github.com/RandomCodeSpace/unified-agent-manager/internal/tmux" ) +// sessionArgs appends codex's `resume --last` subcommand on resume so picking +// "Resume" on an Exited codex row reattaches to the agent's most recent session +// instead of relaunching a fresh one. codex has no flag for presetting its +// session ID at dispatch, so uam can't resume by uam-id directly; `resume +// --last` picks codex's last session. The uam UUID is never passed. +func sessionArgs(_ adapter.ResumeRequest, activity string) []string { + if activity == "resumed" { + return []string{"resume", "--last"} + } + return nil +} + func New(client *tmux.Client) adapter.AgentAdapter { - return adapter.NewTmuxAgent("codex", "OpenAI Codex", []adapter.CommandCandidate{{Display: "codex", Args: []string{"codex"}}}, []string{"--sandbox", "danger-full-access"}, client) + a := adapter.NewTmuxAgent("codex", "OpenAI Codex", []adapter.CommandCandidate{{Display: "codex", Args: []string{"codex"}}}, []string{"--sandbox", "danger-full-access"}, client) + a.SessionArgs = sessionArgs + a.SkipPromptOnResume = true + return a } diff --git a/internal/adapter/codex/codex_test.go b/internal/adapter/codex/codex_test.go index b8bd7f8..41c2d2c 100644 --- a/internal/adapter/codex/codex_test.go +++ b/internal/adapter/codex/codex_test.go @@ -1,6 +1,16 @@ package codex -import "testing" +import ( + "context" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/tmux" +) func TestNew(t *testing.T) { a := New(nil) @@ -8,3 +18,110 @@ func TestNew(t *testing.T) { t.Fatalf("bad adapter: %#v", a) } } + +// TestYoloArgs locks in codex's full-access flags exactly. A drift here +// silently changes the sandbox posture of dispatched sessions. +func TestYoloArgs(t *testing.T) { + ta, ok := New(nil).(*adapter.TmuxAgent) + if !ok { + t.Fatalf("expected *adapter.TmuxAgent") + } + if got, want := ta.YoloArgs, []string{"--sandbox", "danger-full-access"}; !reflect.DeepEqual(got, want) { + t.Fatalf("YoloArgs = %v, want %v", got, want) + } +} + +// TestNewWiresSessionArgs asserts New installs the SessionArgs hook and +// SkipPromptOnResume. Without this wiring, picking "Resume" on a codex row +// would relaunch a fresh agent (no resume) AND re-fire the original prompt. +func TestNewWiresSessionArgs(t *testing.T) { + ta, ok := New(nil).(*adapter.TmuxAgent) + if !ok { + t.Fatalf("expected *adapter.TmuxAgent") + } + if ta.SessionArgs == nil { + t.Fatal("expected SessionArgs to be wired") + } + if !ta.SkipPromptOnResume { + t.Fatal("expected SkipPromptOnResume to be true") + } +} + +// TestResumeAppendsResumeLastAndDoesNotReplayPrompt: resuming an Exited codex +// row must use codex's `resume --last` and must NOT replay the original prompt +// into the restored session, nor pass the uam UUID. +func TestResumeAppendsResumeLastAndDoesNotReplayPrompt(t *testing.T) { + a, logPath := newTestCodexAdapter(t) + resumable, ok := a.(adapter.ResumableAdapter) + if !ok { + t.Fatal("codex adapter should be resumable") + } + _, err := resumable.Resume(context.Background(), adapter.ResumeRequest{ID: "abc12345-dead-beef-cafe-0123456789ab", Prompt: "fix parser", Cwd: "/tmp", Mode: "yolo", TmuxSession: "uam-codex-abc12345"}) + if err != nil { + t.Fatalf("Resume: %v", err) + } + logText := readLog(t, logPath) + if !strings.Contains(logText, "codex --sandbox danger-full-access resume --last") { + t.Fatalf("codex resume should append resume --last: %s", logText) + } + // The uam UUID may appear in the UAM_ID env var, but must never be passed + // as a flag argument to codex (no resume / --resume ). + if strings.Contains(logText, "resume --last abc12345-dead-beef-cafe-0123456789ab") || + strings.Contains(logText, "--resume") { + t.Fatalf("codex resume must not pass the uam UUID as a flag arg: %s", logText) + } + if strings.Contains(logText, "send-keys") || strings.Contains(logText, "fix parser") { + t.Fatalf("resume should not replay the original prompt: %s", logText) + } +} + +// TestDispatchUnchanged_sendsPromptNoResume: dispatch keeps its byte-identical +// argv (no resume) and still sends the prompt. +func TestDispatchUnchanged_sendsPromptNoResume(t *testing.T) { + a, logPath := newTestCodexAdapter(t) + _, err := a.Dispatch(context.Background(), adapter.DispatchRequest{Prompt: "fix parser", Cwd: "/tmp", Mode: "yolo"}) + if err != nil { + t.Fatalf("Dispatch: %v", err) + } + logText := readLog(t, logPath) + if strings.Contains(logText, "resume --last") { + t.Fatalf("dispatch must not append resume --last: %s", logText) + } + if !strings.Contains(logText, "send-keys") || !strings.Contains(logText, "fix parser") { + t.Fatalf("dispatch should send the prompt: %s", logText) + } +} + +func newTestCodexAdapter(t *testing.T) (adapter.AgentAdapter, string) { + t.Helper() + dir := t.TempDir() + writeExecutable(t, filepath.Join(dir, "codex"), "#!/bin/sh\nexit 0\n") + tmuxPath := filepath.Join(dir, "tmux") + writeExecutable(t, tmuxPath, `#!/bin/sh +printf '%s\n' "$*" >> "$TMUX_LOG" +exit 0 +`) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + logPath := filepath.Join(dir, "tmux.log") + t.Setenv("TMUX_LOG", logPath) + + client := tmux.New("uam") + client.Executable = tmuxPath + return New(client), logPath +} + +func readLog(t *testing.T, path string) string { + t.Helper() + logData, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(logData) +} + +func writeExecutable(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatal(err) + } +} diff --git a/internal/adapter/cwd_abs_test.go b/internal/adapter/cwd_abs_test.go new file mode 100644 index 0000000..3d3d71e --- /dev/null +++ b/internal/adapter/cwd_abs_test.go @@ -0,0 +1,53 @@ +package adapter + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/tmux" +) + +// C2-4 — a relative --cwd must be resolved to an absolute path exactly once in +// startSession, before BOTH CreateSession (the tmux -c arg) and the returned +// Session.Cwd. Otherwise the relative path is persisted verbatim and a later +// resume re-resolves it against uam's process cwd, launching the agent in the +// wrong directory. +func TestTmuxAgentDispatchRelativeCwdIsNormalized(t *testing.T) { + dir := t.TempDir() + writeExecutable(t, filepath.Join(dir, "fakeagent"), "#!/bin/sh\nexit 0\n") + tmuxPath := filepath.Join(dir, "tmux") + writeExecutable(t, tmuxPath, `#!/bin/sh +printf '%s\n' "$*" >> "$TMUX_LOG" +exit 0 +`) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + logPath := filepath.Join(dir, "tmux.log") + t.Setenv("TMUX_LOG", logPath) + + // Run from a known directory so filepath.Abs has a deterministic base. + base := t.TempDir() + t.Chdir(base) + wantAbs := filepath.Join(base, "sub", "project") + + client := tmux.New("uam") + client.Executable = tmuxPath + ag := NewTmuxAgent("fake", "Fake Agent", []CommandCandidate{{Display: "fakeagent", Args: []string{"fakeagent"}}}, []string{"--yolo"}, client) + + sess, err := ag.Dispatch(context.Background(), DispatchRequest{Prompt: "hi", Cwd: "sub/project", Mode: "yolo"}) + if err != nil { + t.Fatalf("Dispatch: %v", err) + } + if sess.Cwd != wantAbs { + t.Fatalf("returned Session.Cwd = %q, want absolute %q", sess.Cwd, wantAbs) + } + logText := func() string { b, _ := os.ReadFile(logPath); return string(b) }() + if !strings.Contains(logText, "-c "+wantAbs) { + t.Fatalf("tmux new-session -c arg must be the absolute cwd %q, log: %s", wantAbs, logText) + } + if strings.Contains(logText, "-c sub/project") { + t.Fatalf("relative cwd must not reach tmux verbatim, log: %s", logText) + } +} diff --git a/internal/adapter/registry_test.go b/internal/adapter/registry_test.go index 3545ae2..2071b50 100644 --- a/internal/adapter/registry_test.go +++ b/internal/adapter/registry_test.go @@ -45,10 +45,8 @@ func (f fakeAdapter) Available() (bool, string) { func (f fakeAdapter) Dispatch(ctx Context, req DispatchRequest) (Session, error) { return Session{}, nil } -func (f fakeAdapter) List(ctx Context) ([]Session, error) { return nil, nil } -func (f fakeAdapter) Peek(ctx Context, id string) (PeekResult, error) { return PeekResult{}, nil } -func (f fakeAdapter) Reply(ctx Context, id, text string) error { return nil } -func (f fakeAdapter) Attach(id string) (AttachSpec, error) { return AttachSpec{}, nil } -func (f fakeAdapter) Stop(ctx Context, id string) error { return nil } -func (f fakeAdapter) Rename(ctx Context, id, newName string) error { return nil } -func (f fakeAdapter) Subscribe(ctx Context) (<-chan SessionEvent, error) { return nil, nil } +func (f fakeAdapter) List(ctx Context) ([]Session, error) { return nil, nil } +func (f fakeAdapter) Peek(ctx Context, id string) (PeekResult, error) { return PeekResult{}, nil } +func (f fakeAdapter) Reply(ctx Context, id, text string) error { return nil } +func (f fakeAdapter) Attach(id string) (AttachSpec, error) { return AttachSpec{}, nil } +func (f fakeAdapter) Stop(ctx Context, id string) error { return nil } diff --git a/internal/adapter/tmux_adapter.go b/internal/adapter/tmux_adapter.go index 38304f5..57fe71f 100644 --- a/internal/adapter/tmux_adapter.go +++ b/internal/adapter/tmux_adapter.go @@ -9,11 +9,27 @@ import ( "os/exec" "path/filepath" "strings" + "sync" "time" + "github.com/RandomCodeSpace/unified-agent-manager/internal/log" "github.com/RandomCodeSpace/unified-agent-manager/internal/tmux" ) +// prRescanInterval is how stale a session's last PR scrape may grow before List +// re-captures its pane. The PR URL is the only thing the per-session capture is +// for, and it appears once early then never changes — so capturing every 2s +// refresh tick forked a capture-pane per session per tick for no new signal. +// First discovery still captures immediately; subsequent ticks within this +// window reuse the store-persisted PR (re-hydrated by mergeStoredMetadata), and +// a fresh capture only fires once the window elapses (F16). +const prRescanInterval = 60 * time.Second + +// prCaptureLines is the pane tail captured for PR scraping. A PR URL is emitted +// near where `gh`/the agent prints it, so a short tail is enough — the 200-line +// grab the peek path uses is wasteful here (F16). +const prCaptureLines = 40 + type CommandCandidate struct { Display string Args []string @@ -24,17 +40,24 @@ type TmuxAgent struct { DisplayNameValue string Candidates []CommandCandidate YoloArgs []string - SafeArgs []string Tmux *tmux.Client SessionArgs func(req ResumeRequest, activity string) []string SkipPromptOnResume bool + + // now is the clock used to throttle per-session PR captures; overridable in + // tests. lastPRScan records, per tmux session name, when its pane was last + // captured for PR scraping so List can skip the capture within + // prRescanInterval (F16). + now func() time.Time + prScanMu sync.Mutex + lastPRScan map[string]time.Time } func NewTmuxAgent(name, display string, candidates []CommandCandidate, yoloArgs []string, client *tmux.Client) *TmuxAgent { if client == nil { client = tmux.New("uam") } - return &TmuxAgent{NameValue: name, DisplayNameValue: display, Candidates: candidates, YoloArgs: yoloArgs, Tmux: client} + return &TmuxAgent{NameValue: name, DisplayNameValue: display, Candidates: candidates, YoloArgs: yoloArgs, Tmux: client, now: time.Now, lastPRScan: map[string]time.Time{}} } func (a *TmuxAgent) Name() string { return a.NameValue } @@ -68,9 +91,9 @@ func (a *TmuxAgent) commandForMode(mode string) ([]string, error) { if !ok { return nil, fmt.Errorf("%s unavailable", a.Name()) } - if mode == "safe" { - cmd = append(cmd, a.SafeArgs...) - } else { + // Safe mode launches the bare command; no flag is the safe default for + // claude/codex. Only non-safe modes append the provider's full-access args. + if mode != "safe" { cmd = append(cmd, a.YoloArgs...) } return cmd, nil @@ -100,24 +123,39 @@ func (a *TmuxAgent) startSession(ctx context.Context, req ResumeRequest, activit if cwd == "" { cwd, err = os.Getwd() if err != nil { - return Session{}, err + return Session{}, fmt.Errorf("resolve working directory: %w", err) } } + // Resolve the working directory to an absolute path once, before it is used + // for both CreateSession (the tmux -c arg) and the returned Session.Cwd that + // the store persists. A relative cwd persisted verbatim would be re-resolved + // against uam's process cwd on resume, relaunching the agent in the wrong + // directory (C2-4). + if abs, absErr := filepath.Abs(cwd); absErr == nil { + cwd = abs + } tmuxName := req.TmuxSession if tmuxName == "" { tmuxName = fmt.Sprintf("uam-%s-%s", a.Name(), req.ID[:min(8, len(req.ID))]) } env := map[string]string{"UAM_AGENT": a.Name(), "UAM_ID": req.ID} - // Best-effort: apply uam-friendly tmux server settings (mouse off, swallow - // Ctrl+Z). Failures don't prevent the session from being created. - _ = a.Tmux.EnsureServerConfig(ctx) if err := a.Tmux.CreateSession(ctx, tmuxName, cwd, env, cmd); err != nil { - return Session{}, err + return Session{}, fmt.Errorf("create tmux session %s: %w", tmuxName, err) } + // Best-effort: apply uam-friendly tmux server settings (mouse off, swallow + // Ctrl+Z). This runs AFTER CreateSession so the server exists — applying it + // first on the very first dispatch fails and used to latch that failure + // (F25). Failures here don't prevent the session from being created. + _ = a.Tmux.EnsureServerConfig(ctx) shouldSendPrompt := strings.TrimSpace(req.Prompt) != "" && (activity != "resumed" || !a.SkipPromptOnResume) if shouldSendPrompt { if err := a.Tmux.SendLine(ctx, tmuxName, req.Prompt); err != nil { - return Session{}, err + // The session is live but never received its prompt. Roll it back so + // it doesn't linger as an orphan the store records as Exited/closed. + // Use WithoutCancel so a cancelled dispatch context still tears the + // session down; the original SendLine error is what the caller sees. + _ = a.Tmux.Kill(context.WithoutCancel(ctx), tmuxName) + return Session{}, fmt.Errorf("send prompt to %s: %w", tmuxName, err) } } name := req.Name @@ -135,7 +173,7 @@ func (a *TmuxAgent) startSession(ctx context.Context, req ResumeRequest, activit func (a *TmuxAgent) List(ctx context.Context) ([]Session, error) { infos, err := a.Tmux.List(ctx) if err != nil { - return nil, err + return nil, fmt.Errorf("list %s sessions: %w", a.Name(), err) } var out []Session prefix := "uam-" + a.Name() + "-" @@ -144,19 +182,56 @@ func (a *TmuxAgent) List(ctx context.Context) ([]Session, error) { continue } id := strings.TrimPrefix(info.Name, prefix) - capture, _ := a.Tmux.Capture(ctx, info.Name, 200) state, alive := ClassifyPane(tmux.PaneAlive(info.PanePID)) created := time.Unix(info.CreatedUnix, 0) - out = append(out, Session{ID: id, AgentType: a.Name(), DisplayName: id, Cwd: info.CurrentPath, TmuxSession: info.Name, State: state, ProcAlive: alive, LastChange: time.Now(), CreatedAt: created, PR: ExtractPR(capture)}) + // Scrape the PR URL only on first discovery or once the rescan window + // elapses. On a throttled tick PR stays nil; service.mergeStoredMetadata + // re-hydrates it from the persisted record so the dashboard never loses + // it (F16). Captures are sequential by design — parallelizing would fork + // a burst of capture-pane subprocesses. + var prRef *PRRef + if a.shouldScanPR(info.Name) { + capture, capErr := a.Tmux.Capture(ctx, info.Name, prCaptureLines) + if capErr != nil { + // Per-session and non-fatal: a failed PR scrape just leaves PR nil + // for this tick (mergeStoredMetadata re-hydrates any known PR). Log + // at debug so it's diagnosable without spamming the dashboard (F52). + log.Debug("PR capture failed", "session", info.Name, "error", capErr) + } + prRef = ExtractPR(capture) + } + out = append(out, Session{ID: id, AgentType: a.Name(), DisplayName: id, Cwd: info.CurrentPath, TmuxSession: info.Name, State: state, ProcAlive: alive, LastChange: time.Now(), CreatedAt: created, PR: prRef}) } return out, nil } +// shouldScanPR reports whether the pane named tmuxName is due for a PR scrape +// and, if so, stamps the scan time. It is the per-session leaky bucket that +// keeps List from capturing every pane on every refresh tick (F16). +func (a *TmuxAgent) shouldScanPR(tmuxName string) bool { + clock := a.now + if clock == nil { + clock = time.Now + } + now := clock() + a.prScanMu.Lock() + defer a.prScanMu.Unlock() + if a.lastPRScan == nil { + a.lastPRScan = map[string]time.Time{} + } + last, seen := a.lastPRScan[tmuxName] + if seen && now.Sub(last) < prRescanInterval { + return false + } + a.lastPRScan[tmuxName] = now + return true +} + func (a *TmuxAgent) Peek(ctx context.Context, id string) (PeekResult, error) { target := a.target(id) capture, err := a.Tmux.Capture(ctx, target, 200) if err != nil { - return PeekResult{}, err + return PeekResult{}, fmt.Errorf("peek %s session %s: %w", a.Name(), id, err) } return PeekResult{TailText: capture}, nil } @@ -167,19 +242,25 @@ func (a *TmuxAgent) Reply(ctx context.Context, id, text string) error { func (a *TmuxAgent) Attach(id string) (AttachSpec, error) { argv, err := a.Tmux.AttachArgv(a.target(id)) if err != nil { - return AttachSpec{}, err + return AttachSpec{}, fmt.Errorf("attach %s session %s: %w", a.Name(), id, err) } return AttachSpec{Argv: argv}, nil } -func (a *TmuxAgent) Stop(ctx context.Context, id string) error { return a.Tmux.Kill(ctx, a.target(id)) } -func (a *TmuxAgent) Rename(ctx context.Context, id, newName string) error { return nil } -func (a *TmuxAgent) Subscribe(ctx context.Context) (<-chan SessionEvent, error) { return nil, nil } +func (a *TmuxAgent) Stop(ctx context.Context, id string) error { return a.Tmux.Kill(ctx, a.target(id)) } +func (a *TmuxAgent) HasSession(ctx context.Context, id string) bool { + return a.Tmux.HasSession(ctx, a.target(id)) +} +// target resolves an id to a tmux -t target. It anchors the name with tmux's +// `=` exact-match prefix so a longer neighbour that shares the truncated prefix +// is never hit by tmux's default prefix matching (F32). Human-facing prefix +// lookups stay in Service.Find; internal targeting is always exact. func (a *TmuxAgent) target(id string) string { - if strings.HasPrefix(id, "uam-") { - return id + name := id + if !strings.HasPrefix(id, "uam-") { + name = fmt.Sprintf("uam-%s-%s", a.Name(), id[:min(8, len(id))]) } - return fmt.Sprintf("uam-%s-%s", a.Name(), id[:min(8, len(id))]) + return "=" + name } func newID() string { diff --git a/internal/adapter/tmux_adapter_test.go b/internal/adapter/tmux_adapter_test.go index d780916..7f8103f 100644 --- a/internal/adapter/tmux_adapter_test.go +++ b/internal/adapter/tmux_adapter_test.go @@ -1,12 +1,15 @@ package adapter import ( + "bytes" "context" + "log/slog" "os" "path/filepath" "strings" "testing" + "github.com/RandomCodeSpace/unified-agent-manager/internal/log" "github.com/RandomCodeSpace/unified-agent-manager/internal/tmux" ) @@ -77,15 +80,11 @@ func assertAgentInteractions(t *testing.T, ag *TmuxAgent) { func() error { return ag.Reply(context.Background(), "abc12345", "ok") }, func() error { _, err := ag.Attach("abc12345"); return err }, func() error { return ag.Stop(context.Background(), "abc12345") }, - func() error { return ag.Rename(context.Background(), "abc12345", "name") }, } { if err := action(); err != nil { t.Fatal(err) } } - if ch, err := ag.Subscribe(context.Background()); err != nil || ch != nil { - t.Fatalf("Subscribe = %v %v", ch, err) - } } func assertTmuxLifecycleLog(t *testing.T, logPath string) { @@ -102,6 +101,46 @@ func assertTmuxLifecycleLog(t *testing.T, logPath string) { } } +// F52 — a PR-scrape capture-pane failure must be logged (debug) and stay +// per-session non-fatal: List still returns the session, just without a PR. +func TestListLogsCaptureFailureButKeepsSession(t *testing.T) { + dir := t.TempDir() + writeExecutable(t, filepath.Join(dir, "fakeagent"), "#!/bin/sh\nexit 0\n") + tmuxPath := filepath.Join(dir, "tmux") + // list-sessions succeeds; capture-pane fails for the PR scrape. + writeExecutable(t, tmuxPath, `#!/bin/sh +printf '%s\n' "$*" >> "$TMUX_LOG" +case "$*" in + *"list-sessions"*) echo "uam-fake-abc12345|1710000000|0|1|/tmp/repo|fakeagent" ;; + *"capture-pane"*) echo "capture boom" >&2; exit 1 ;; +esac +exit 0 +`) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("TMUX_LOG", filepath.Join(dir, "tmux.log")) + client := tmux.New("uam") + client.Executable = tmuxPath + ag := NewTmuxAgent("fake", "Fake Agent", []CommandCandidate{{Display: "fakeagent", Args: []string{"fakeagent"}}}, []string{"--yolo"}, client) + + var buf bytes.Buffer + prev := log.SetLogger(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + defer log.SetLogger(prev) + + list, err := ag.List(context.Background()) + if err != nil { + t.Fatalf("a per-session capture failure must not fail List: %v", err) + } + if len(list) != 1 { + t.Fatalf("session should still be listed despite capture failure, got %d", len(list)) + } + if list[0].PR != nil { + t.Fatalf("PR should be nil when capture failed, got %+v", list[0].PR) + } + if !strings.Contains(buf.String(), "capture") { + t.Fatalf("capture failure should be logged, got: %q", buf.String()) + } +} + func TestTmuxAgentResumeUsesPersistedMetadata(t *testing.T) { dir := t.TempDir() writeExecutable(t, filepath.Join(dir, "fakeagent"), "#!/bin/sh\nexit 0\n") @@ -133,6 +172,105 @@ exit 0 } } +// F19 — a resume/dispatch that creates the tmux session but then fails to send +// the prompt must roll back the live (prompt-less) session, otherwise it lingers +// as an orphan the store records as Exited/closed. +func TestStartSessionRollsBackTmuxOnSendLineFailure(t *testing.T) { + dir := t.TempDir() + writeExecutable(t, filepath.Join(dir, "fakeagent"), "#!/bin/sh\nexit 0\n") + tmuxPath := filepath.Join(dir, "tmux") + // send-keys fails; everything else (new-session, kill-session) succeeds. + writeExecutable(t, tmuxPath, `#!/bin/sh +printf '%s\n' "$*" >> "$TMUX_LOG" +case "$*" in + *"send-keys"*) echo "boom" >&2; exit 1 ;; +esac +exit 0 +`) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + logPath := filepath.Join(dir, "tmux.log") + t.Setenv("TMUX_LOG", logPath) + + client := tmux.New("uam") + client.Executable = tmuxPath + ag := NewTmuxAgent("fake", "Fake Agent", []CommandCandidate{{Display: "fakeagent", Args: []string{"fakeagent"}}}, []string{"--yolo"}, client) + _, err := ag.Dispatch(context.Background(), DispatchRequest{Prompt: "hello", Cwd: "/tmp", Mode: "yolo"}) + if err == nil { + t.Fatal("expected dispatch error when send-keys fails") + } + logData, _ := os.ReadFile(logPath) + logText := string(logData) + if !strings.Contains(logText, "new-session") { + t.Fatalf("session should have been created: %s", logText) + } + if !strings.Contains(logText, "kill-session") { + t.Fatalf("send-keys failure must roll back the created session via kill-session: %s", logText) + } +} + +// F19 trap — if the rollback Kill itself fails, the caller must still see the +// original SendLine error (not the kill error). +func TestStartSessionReturnsSendLineErrorWhenRollbackKillAlsoFails(t *testing.T) { + dir := t.TempDir() + writeExecutable(t, filepath.Join(dir, "fakeagent"), "#!/bin/sh\nexit 0\n") + tmuxPath := filepath.Join(dir, "tmux") + writeExecutable(t, tmuxPath, `#!/bin/sh +printf '%s\n' "$*" >> "$TMUX_LOG" +case "$*" in + *"send-keys"*) echo "sendboom" >&2; exit 1 ;; + *"kill-session"*) echo "killboom" >&2; exit 1 ;; +esac +exit 0 +`) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + logPath := filepath.Join(dir, "tmux.log") + t.Setenv("TMUX_LOG", logPath) + + client := tmux.New("uam") + client.Executable = tmuxPath + ag := NewTmuxAgent("fake", "Fake Agent", []CommandCandidate{{Display: "fakeagent", Args: []string{"fakeagent"}}}, []string{"--yolo"}, client) + _, err := ag.Dispatch(context.Background(), DispatchRequest{Prompt: "hello", Cwd: "/tmp", Mode: "yolo"}) + if err == nil { + t.Fatal("expected dispatch error") + } + if !strings.Contains(err.Error(), "sendboom") { + t.Fatalf("error should surface the original send-keys failure, got: %v", err) + } +} + +// F25 — startSession must create the tmux session BEFORE applying server +// config. On first dispatch the server doesn't exist yet, so configuring it +// first fails and (pre-fix) latched the failure forever. Assert new-session +// precedes set-option in the recorded command log. +func TestStartSessionConfiguresServerAfterCreate(t *testing.T) { + dir := t.TempDir() + writeExecutable(t, filepath.Join(dir, "fakeagent"), "#!/bin/sh\nexit 0\n") + tmuxPath := filepath.Join(dir, "tmux") + writeExecutable(t, tmuxPath, `#!/bin/sh +printf '%s\n' "$*" >> "$TMUX_LOG" +exit 0 +`) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + logPath := filepath.Join(dir, "tmux.log") + t.Setenv("TMUX_LOG", logPath) + + client := tmux.New("uam") + client.Executable = tmuxPath + ag := NewTmuxAgent("fake", "Fake Agent", []CommandCandidate{{Display: "fakeagent", Args: []string{"fakeagent"}}}, []string{"--yolo"}, client) + if _, err := ag.Dispatch(context.Background(), DispatchRequest{Prompt: "hello", Cwd: "/tmp", Mode: "yolo"}); err != nil { + t.Fatalf("Dispatch: %v", err) + } + logText := func() string { b, _ := os.ReadFile(logPath); return string(b) }() + nsIdx := strings.Index(logText, "new-session") + soIdx := strings.Index(logText, "set-option") + if nsIdx < 0 || soIdx < 0 { + t.Fatalf("expected both new-session and set-option in log: %s", logText) + } + if nsIdx > soIdx { + t.Fatalf("CreateSession must precede server config so the server exists: %s", logText) + } +} + func TestTmuxAgentDispatchWithoutPromptSkipsSendKeys(t *testing.T) { dir := t.TempDir() writeExecutable(t, filepath.Join(dir, "fakeagent"), "#!/bin/sh\nexit 0\n") @@ -161,6 +299,111 @@ exit 0 } } +// F32 — target() must use tmux exact-match (`=` prefix) so a neighbour session +// whose name shares the truncated prefix is never hit by `-t`. Drive Stop/Peek +// through a fake tmux and assert the recorded `-t` token is exact-anchored. +func newTargetingAgent(t *testing.T) (*TmuxAgent, string) { + t.Helper() + dir := t.TempDir() + tmuxPath := filepath.Join(dir, "tmux") + writeExecutable(t, tmuxPath, `#!/bin/sh +printf '%s\n' "$*" >> "$TMUX_LOG" +case "$*" in + *"capture-pane"*) printf 'tail\n' ;; +esac +exit 0 +`) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + logPath := filepath.Join(dir, "tmux.log") + t.Setenv("TMUX_LOG", logPath) + client := tmux.New("uam") + client.Executable = tmuxPath + return NewTmuxAgent("fake", "Fake Agent", []CommandCandidate{{Display: "fakeagent", Args: []string{"fakeagent"}}}, []string{"--yolo"}, client), logPath +} + +func TestTargetUsesExactMatchForFullUUID(t *testing.T) { + ag, logPath := newTargetingAgent(t) + // A full-UUID id whose first 8 chars name the session. + if err := ag.Stop(context.Background(), "abc12345-dead-beef-cafe-0123456789ab"); err != nil { + t.Fatalf("Stop: %v", err) + } + logData, _ := os.ReadFile(logPath) + logText := string(logData) + if !strings.Contains(logText, "-t =uam-fake-abc12345") { + t.Fatalf("kill must target the exact session, got: %s", logText) + } +} + +func TestTargetUsesExactMatchForCanonicalName(t *testing.T) { + ag, logPath := newTargetingAgent(t) + // A canonical (already uam-prefixed) name must also be exact-anchored so a + // longer neighbour ("uam-fake-abc123450" etc.) is never matched by prefix. + if _, err := ag.Peek(context.Background(), "uam-fake-abc12345"); err != nil { + t.Fatalf("Peek: %v", err) + } + logData, _ := os.ReadFile(logPath) + logText := string(logData) + if !strings.Contains(logText, "-t =uam-fake-abc12345") { + t.Fatalf("capture must target the exact session, got: %s", logText) + } +} + +// F57 — startSession must wrap a CreateSession failure with the tmux session +// name (boundary context) while preserving the underlying error for errors.Is. +func TestStartSessionWrapsCreateSessionError(t *testing.T) { + dir := t.TempDir() + writeExecutable(t, filepath.Join(dir, "fakeagent"), "#!/bin/sh\nexit 0\n") + tmuxPath := filepath.Join(dir, "tmux") + writeExecutable(t, tmuxPath, `#!/bin/sh +printf '%s\n' "$*" >> "$TMUX_LOG" +case "$*" in + *"new-session"*) echo "create boom" >&2; exit 1 ;; +esac +exit 0 +`) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("TMUX_LOG", filepath.Join(dir, "tmux.log")) + client := tmux.New("uam") + client.Executable = tmuxPath + ag := NewTmuxAgent("fake", "Fake Agent", []CommandCandidate{{Display: "fakeagent", Args: []string{"fakeagent"}}}, []string{"--yolo"}, client) + _, err := ag.Dispatch(context.Background(), DispatchRequest{Prompt: "hi", Cwd: "/tmp", Mode: "yolo"}) + if err == nil { + t.Fatal("expected dispatch error when new-session fails") + } + if !strings.Contains(err.Error(), "uam-fake-") { + t.Fatalf("CreateSession failure must be wrapped with the tmux session name, got: %v", err) + } + if !strings.Contains(err.Error(), "create boom") { + t.Fatalf("wrapped error must preserve the underlying cause, got: %v", err) + } +} + +// F57 — List must wrap a tmux list failure with the agent name so a caller +// (and the log) can tell which provider's scan failed. +func TestListWrapsTmuxListError(t *testing.T) { + dir := t.TempDir() + tmuxPath := filepath.Join(dir, "tmux") + writeExecutable(t, tmuxPath, `#!/bin/sh +case "$*" in + *"list-sessions"*) echo "protocol mismatch" >&2; exit 1 ;; +esac +exit 0 +`) + client := tmux.New("uam") + client.Executable = tmuxPath + ag := NewTmuxAgent("fake", "Fake Agent", []CommandCandidate{{Display: "fakeagent", Args: []string{"fakeagent"}}}, []string{"--yolo"}, client) + _, err := ag.List(context.Background()) + if err == nil { + t.Fatal("expected List error on a genuine list-sessions failure") + } + if !strings.Contains(err.Error(), "fake") { + t.Fatalf("List failure must be wrapped with the agent name, got: %v", err) + } + if !strings.Contains(err.Error(), "protocol mismatch") { + t.Fatalf("wrapped error must preserve the underlying cause, got: %v", err) + } +} + func TestDisplayNameFromDir(t *testing.T) { if got := displayNameFromDir("/home/dev/projects/uam"); got != "uam" { t.Fatalf("dir name = %q, want uam", got) diff --git a/internal/agents/agents.go b/internal/agents/agents.go new file mode 100644 index 0000000..2dfc578 --- /dev/null +++ b/internal/agents/agents.go @@ -0,0 +1,32 @@ +// Package agents is the single source of truth for the set of agent adapters +// uam manages. Both the TUI entrypoint (internal/app) and the CLI service +// wiring (internal/cli) build their registry from Default so the two can never +// drift — previously each hand-maintained its own list and app.New silently +// omitted hermes (F14). It is a leaf package: the providers import +// internal/adapter and internal/tmux, and nothing imports back into agents, so +// there is no import cycle. +package agents + +import ( + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/claude" + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/codex" + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/copilot" + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/hermes" + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/opencode" + "github.com/RandomCodeSpace/unified-agent-manager/internal/tmux" +) + +// Default returns every supported agent adapter, built against client. The +// returned slice is the pre-availability list (it is not LookPath-filtered); +// callers pass it to adapter.NewRegistry, which probes Available() and hides +// the ones whose CLI is not installed. +func Default(client *tmux.Client) []adapter.AgentAdapter { + return []adapter.AgentAdapter{ + claude.New(client), + codex.New(client), + copilot.New(client), + hermes.New(client), + opencode.New(client), + } +} diff --git a/internal/agents/agents_test.go b/internal/agents/agents_test.go new file mode 100644 index 0000000..c2d1946 --- /dev/null +++ b/internal/agents/agents_test.go @@ -0,0 +1,43 @@ +package agents + +import ( + "sort" + "testing" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/tmux" +) + +// F14 — Default is the single source of truth for the adapter list. It must +// build all five providers (claude, codex, copilot, hermes, opencode) regardless +// of whether their CLI is installed, because the names are compared on the raw +// pre-availability list (Enabled() would be LookPath-filtered to empty in CI). +func TestDefaultBuildsAllFiveProviders(t *testing.T) { + client := tmux.New("uam") + got := make([]string, 0) + for _, a := range Default(client) { + got = append(got, a.Name()) + } + sort.Strings(got) + + want := []string{"claude", "codex", "copilot", "hermes", "opencode"} + if len(got) != len(want) { + t.Fatalf("Default built %d adapters %v, want %d %v", len(got), got, len(want), want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("Default adapter set = %v, want %v", got, want) + } + } +} + +// F14 — the brief calls out hermes specifically because the old hand-maintained +// app.New list omitted it; assert it is present. +func TestDefaultIncludesHermes(t *testing.T) { + client := tmux.New("uam") + for _, a := range Default(client) { + if a.Name() == "hermes" { + return + } + } + t.Fatal("Default must include the hermes adapter") +} diff --git a/internal/app/app.go b/internal/app/app.go index 507ba9f..e90b841 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -3,6 +3,7 @@ package app import ( "context" "fmt" + "os" "os/exec" "path/filepath" "strings" @@ -12,10 +13,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" - "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/claude" - "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/codex" - "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/copilot" - "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/opencode" + "github.com/RandomCodeSpace/unified-agent-manager/internal/agents" "github.com/RandomCodeSpace/unified-agent-manager/internal/store" "github.com/RandomCodeSpace/unified-agent-manager/internal/tmux" "github.com/RandomCodeSpace/unified-agent-manager/internal/version" @@ -24,24 +22,71 @@ import ( type Model struct { width, height int quitting bool + loading bool service *Service sessions []adapter.Session selected int input string defaultAgent string message string + messageSetAt time.Time peekOpen bool peekText string - helpOpen bool - confirmStop bool - renaming bool - wizard bool - wizardStep int - wizardAgent string - wizardCwd string - groupByDir bool - execProcess func(*exec.Cmd, tea.ExecCallback) tea.Cmd -} + // peekTargetID is the session whose pane the open peek panel shows. While + // peek is open the command line doubles as a reply composer: typed text + + // Enter sends to this session via Service.Reply rather than dispatching a new + // agent. Snapshotting the id (mirroring renameTargetID) keeps a reorder under + // the cursor from misrouting the reply (F36). + peekTargetID string + helpOpen bool + confirmStop bool + confirmStopID string + renaming bool + renameTargetID string + wizard bool + wizardStep int + wizardAgent string + wizardCwd string + groupByDir bool + execProcess func(*exec.Cmd, tea.ExecCallback) tea.Cmd + // reorderSeq increments on every reorder; a debounced flush tick only + // persists when its seq still matches, so a held Shift+arrow coalesces into + // one store write instead of one fsync per step. reorderPending marks a + // scheduled-but-not-yet-flushed reorder so quit can flush it (F59). + reorderSeq int + reorderPending bool + // lastPeekAt records, per session id, when its pane was last captured for + // the peek panel. The peek-focus ticker re-polls the focused session at most + // once per peekFocusInterval; the map is keyed by id (not row index) because + // rows reorder every refresh tick (C2-11). peekClock is the injectable clock + // for that gate. + lastPeekAt map[string]time.Time + peekClock func() time.Time +} + +// messageTTL is how long a status/error line stays on screen before a refresh +// tick clears it. A just-emitted message must survive at least one 2s tick, so +// the TTL is several ticks long (F53). +const messageTTL = 8 * time.Second + +// reorderDebounce is how long a reorder waits for a follow-up move before it +// persists. A held Shift+arrow fires a move per repeat; without the debounce +// each one is a whole-file JSON encode + fsync + rename. Coalescing them into a +// single write after the keystrokes settle keeps the store off the hot path +// (F59). +const reorderDebounce = 500 * time.Millisecond + +// peekTickInterval drives the peek-focus poll. The tick is what makes an open +// peek panel update live without coupling peek freshness to the slower 2s +// session refresh (C2-11). +const peekTickInterval = time.Second + +// peekFocusInterval is the minimum spacing between captures of the focused +// session's pane while the peek panel is open. The peek-focus ticker fires +// every second; this gate (id-keyed) keeps a focused session from being +// captured faster than once per interval even if rows reorder under the cursor +// (C2-11). +const peekFocusInterval = time.Second type sessionsLoadedMsg struct { sessions []adapter.Session @@ -64,15 +109,60 @@ type attachSpecMsg struct { type attachFinishedMsg struct{ err error } type refreshMsg time.Time +// peekTickMsg is the peek-focus poll tick. When the peek panel is open it +// re-captures the focused session's pane (rate-limited per id) so the panel +// follows live output; when closed it just re-arms (C2-11). +type peekTickMsg time.Time + +// reorderFlushMsg is the debounced reorder-persist tick. It carries the seq of +// the reorder that scheduled it; the handler persists only when the seq still +// matches the latest move, dropping ticks superseded by a newer move (F59). +type reorderFlushMsg struct{ seq int } + +// promptEditedMsg carries the result of editing the wizard prompt in $EDITOR. +// The editor is launched via tea.ExecProcess (which suspends the TUI, restores +// the terminal, and resumes cleanly); when it exits this message loads the file +// contents back into the prompt buffer (C2-8). +type promptEditedMsg struct { + text string + err error +} + func New() Model { st, _ := store.Open(store.DefaultPath()) client := tmux.New("uam") - reg := adapter.NewRegistry([]adapter.AgentAdapter{claude.New(client), codex.New(client), copilot.New(client), opencode.New(client)}) + // Build the registry from the single shared adapter list so the TUI and the + // CLI service can never diverge (the old hand-rolled list here omitted + // hermes — F14). + reg := adapter.NewRegistry(agents.Default(client)) return NewWithDeps(st, reg) } func NewWithDeps(st *store.Store, reg *adapter.Registry) Model { - return Model{service: NewService(st, reg), defaultAgent: "claude", wizardCwd: ".", execProcess: tea.ExecProcess} + m := Model{service: NewService(st, reg), defaultAgent: "claude", wizardCwd: ".", execProcess: tea.ExecProcess, lastPeekAt: map[string]time.Time{}, peekClock: time.Now} + // The baked-in "claude" default may not be installed; reconcile it to an + // enabled provider so Enter-with-no-input and the prompt hint never point at + // a disabled agent (C2-9). + m.defaultAgent = m.validateDefaultAgent(m.defaultAgent) + return m +} + +// validateDefaultAgent returns candidate when it is an enabled agent, otherwise +// the registry's chosen default (Registry.Default falls back to the first +// enabled adapter). When nothing is enabled — or there is no registry — the +// candidate is returned unchanged so the selector degrades gracefully instead of +// panicking on a nil Default (C2-9). +func (m Model) validateDefaultAgent(candidate string) string { + if m.service == nil || m.service.Registry == nil { + return candidate + } + if _, ok := m.service.Registry.Get(candidate); ok { + return candidate + } + if a := m.service.Registry.Default(candidate); a != nil { + return a.Name() + } + return candidate } func NewWizard(st *store.Store, reg *adapter.Registry) Model { m := NewWithDeps(st, reg) @@ -80,12 +170,32 @@ func NewWizard(st *store.Store, reg *adapter.Registry) Model { return m } -func (m Model) Init() tea.Cmd { return tea.Batch(m.loadSessionsCmd(), refreshTick()) } +func (m Model) Init() tea.Cmd { + return tea.Batch(m.loadSessionsCmd(), refreshTick(), peekTick()) +} func refreshTick() tea.Cmd { return tea.Tick(2*time.Second, func(t time.Time) tea.Msg { return refreshMsg(t) }) } +func peekTick() tea.Cmd { + return tea.Tick(peekTickInterval, func(t time.Time) tea.Msg { return peekTickMsg(t) }) +} + +// refreshStep advances the refresh state machine for one tick. It always +// re-arms the ticker (the caller batches that in); it schedules a fresh +// loadSessionsCmd only when no load is in flight, marking loading=true. This +// keeps stacked ticks from overlapping loads while never stopping the ticker +// (F17). startedLoad reports whether a load was scheduled this tick. +func (m Model) refreshStep(now time.Time) (Model, bool) { + m.expireMessage(now) + if m.loading { + return m, false + } + m.loading = true + return m, true +} + func (m Model) loadSessionsCmd() tea.Cmd { return func() tea.Msg { sessions, cfg, err := m.service.LoadSessions(context.Background()) @@ -98,7 +208,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: return m.handleWindowSize(msg), nil case refreshMsg: - return m, tea.Batch(m.loadSessionsCmd(), refreshTick()) + next, startedLoad := m.refreshStep(time.Time(msg)) + // The ticker is re-armed unconditionally so refreshes never stop; the + // load is added only when one wasn't already in flight (F17). + if startedLoad { + return next, tea.Batch(next.loadSessionsCmd(), refreshTick()) + } + return next, refreshTick() + case peekTickMsg: + // Re-arm the peek ticker unconditionally; only re-capture the focused + // session when the panel is open and the per-id rate limit allows it + // (C2-11). + next, peekCmd := m.peekFocusStep(time.Time(msg)) + return next, tea.Batch(peekCmd, peekTick()) + case reorderFlushMsg: + // Persist only if this is the latest reorder; a superseded tick is dropped + // so a held Shift+arrow coalesces into one write (F59). + if msg.seq != m.reorderSeq { + return m, nil + } + return m, m.flushReorder() case sessionsLoadedMsg: return m.handleSessionsLoaded(msg), nil case peekLoadedMsg: @@ -109,6 +238,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.execAttachSpec(msg.spec, msg.err) case attachFinishedMsg: return m.handleAttachFinished(msg), m.loadSessionsCmd() + case promptEditedMsg: + return m.handlePromptEdited(msg), nil case tea.KeyMsg: return m.handleKey(msg) } @@ -120,9 +251,29 @@ func (m Model) handleWindowSize(msg tea.WindowSizeMsg) Model { return m } +// setMessage records a status/error line and stamps the time it was set so the +// refresh tick can TTL-expire it instead of blanket-clearing a just-emitted +// message (F53). +func (m *Model) setMessage(text string) { + m.message = text + m.messageSetAt = time.Now() +} + +// expireMessage clears the status line once it has been on screen longer than +// messageTTL. now is the refresh-tick timestamp. +func (m *Model) expireMessage(now time.Time) { + if m.message != "" && !m.messageSetAt.IsZero() && now.Sub(m.messageSetAt) >= messageTTL { + m.message = "" + m.messageSetAt = time.Time{} + } +} + func (m Model) handleSessionsLoaded(msg sessionsLoadedMsg) Model { + // Clear the in-flight guard unconditionally — even on error — or a single + // failed load wedges the refresh ticker forever (F17). + m.loading = false if msg.err != nil { - m.message = msg.err.Error() + m.setMessage(msg.err.Error()) return m } if msg.sessions != nil { @@ -130,7 +281,10 @@ func (m Model) handleSessionsLoaded(msg sessionsLoadedMsg) Model { m.groupByDir = msg.groupByDir } if msg.defaultAgent != "" { - m.defaultAgent = msg.defaultAgent + // A persisted default may name an agent whose CLI was since uninstalled; + // reconcile it to an enabled provider rather than dispatching to a + // disabled one (C2-9). + m.defaultAgent = m.validateDefaultAgent(msg.defaultAgent) } if m.selected >= len(m.sessions) { m.selected = max(0, len(m.sessions)-1) @@ -140,7 +294,7 @@ func (m Model) handleSessionsLoaded(msg sessionsLoadedMsg) Model { func (m Model) handlePeekLoaded(msg peekLoadedMsg) Model { if msg.err != nil { - m.message = msg.err.Error() + m.setMessage(msg.err.Error()) } else { m.peekText = msg.text } @@ -148,21 +302,42 @@ func (m Model) handlePeekLoaded(msg peekLoadedMsg) Model { } func (m Model) handleDispatched(msg dispatchedMsg) (tea.Model, tea.Cmd) { - if msg.err != nil { - m.message = msg.err.Error() + // A live session (non-empty ID) attaches even when msg.err is set: the agent + // is running and the error is advisory (e.g. the record failed to persist). + // Only a true dispatch failure — no session — aborts with the error (F03). + if msg.session.ID == "" { + if msg.err != nil { + m.setMessage(msg.err.Error()) + } return m, nil } - m.message = "attaching " + msg.session.ID + if msg.err != nil { + m.setMessage("attaching " + msg.session.ID + " (warning: " + msg.err.Error() + ")") + } else { + m.setMessage("attaching " + msg.session.ID) + } m.input = "" return m, m.attachSessionCmd(msg.session) } func (m Model) handleAttachFinished(msg attachFinishedMsg) Model { if msg.err != nil { - m.message = "session exited: " + msg.err.Error() + m.setMessage("session exited: " + msg.err.Error()) } else { - m.message = "returned to uam" + m.setMessage("returned to uam") + } + return m +} + +// handlePromptEdited loads the text the user composed in $EDITOR back into the +// wizard prompt buffer. On error the buffer is left untouched and the error is +// surfaced in the status line (C2-8). +func (m Model) handlePromptEdited(msg promptEditedMsg) Model { + if msg.err != nil { + m.setMessage("editor: " + msg.err.Error()) + return m } + m.input = strings.TrimRight(msg.text, "\n") return m } @@ -177,7 +352,7 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if handled, cmd := m.handleActionKey(key); handled { return m, cmd } - m.appendKeyInput(msg, key) + m.appendKeyInput(msg) return m, nil } @@ -191,10 +366,13 @@ func (m Model) handleModalKey(msg tea.KeyMsg, key string) (bool, tea.Model, tea. if m.confirmStop { if key == "y" || key == "enter" { m.confirmStop = false - return true, m, m.stopSelectedCmd(true) + id := m.confirmStopID + m.confirmStopID = "" + return true, m, m.stopTargetCmd(id, true) } if key == "n" || key == "esc" { m.confirmStop = false + m.confirmStopID = "" } return true, m, nil } @@ -212,11 +390,9 @@ func (m Model) handleModalKey(msg tea.KeyMsg, key string) (bool, tea.Model, tea. func (m *Model) handleMovementKey(key string) (bool, tea.Cmd) { switch key { case "up": - m.moveSelection(-1) - return true, nil + return true, m.moveSelectionPeek(-1) case "down": - m.moveSelection(1) - return true, nil + return true, m.moveSelectionPeek(1) case "shift+up": return true, m.moveSession(-1) case "shift+down": @@ -225,6 +401,26 @@ func (m *Model) handleMovementKey(key string) (bool, tea.Cmd) { return false, nil } +// moveSelectionPeek moves the cursor and, when the peek panel is open and the +// selection actually changed, re-fires the peek for the newly selected session. +// The stale peek text is blanked synchronously so the panel never shows a frame +// of the previous session's tail. Gated on peekOpen so plain navigation with the +// panel closed doesn't trigger an N+1 capture storm (C2-2). +func (m *Model) moveSelectionPeek(delta int) tea.Cmd { + prev := m.selected + m.moveSelection(delta) + if !m.peekOpen || m.selected == prev { + return nil + } + // The peek panel follows the cursor; keep the reply target in sync so a reply + // goes to the session the user is actually looking at (F36). + if sess, ok := m.selectedSession(); ok { + m.peekTargetID = sess.ID + } + m.peekText = "" + return m.peekSelectedCmd() +} + func (m *Model) moveSelection(delta int) { next := m.selected + delta if next >= 0 && next < len(m.sessions) { @@ -232,35 +428,117 @@ func (m *Model) moveSelection(delta int) { } } +// peekFocusStep handles a peek-focus tick: when the panel is open it re-captures +// the focused session's pane at most once per peekFocusInterval (gated by id so +// a reorder under the cursor can't double-capture), keeping the panel live. It +// returns the (possibly nil) peek command; the caller re-arms the ticker (C2-11). +func (m Model) peekFocusStep(now time.Time) (Model, tea.Cmd) { + if m.peekClock != nil { + now = m.peekClock() + } + if !m.peekOpen { + return m, nil + } + sess, ok := m.selectedSession() + if !ok { + return m, nil + } + if !m.shouldPollFocusedPeek(sess.ID, now) { + return m, nil + } + if m.lastPeekAt == nil { + m.lastPeekAt = map[string]time.Time{} + } + m.lastPeekAt[sess.ID] = now + return m, m.peekSelectedCmd() +} + +// shouldPollFocusedPeek reports whether the focused session id is due for a +// peek capture: never polled, or last polled at least peekFocusInterval ago. +// Keyed by id so the rate limit follows the session, not the row index (C2-11). +func (m Model) shouldPollFocusedPeek(id string, now time.Time) bool { + if id == "" { + return false + } + last, seen := m.lastPeekAt[id] + if !seen { + return true + } + return now.Sub(last) >= peekFocusInterval +} + func (m *Model) moveSession(delta int) tea.Cmd { next := m.selected + delta if next < 0 || next >= len(m.sessions) { return nil } + // SortSessions buckets rows by Closed, then Pinned, before honoring + // SortIndex. A swap that crosses either boundary is undone on the next + // refresh (the row snaps back to its partition), so reject it and give + // honest feedback instead of a move that silently reverts (F34). + if !samePartition(m.sessions[m.selected], m.sessions[next]) { + m.setMessage("can't reorder across the active/closed or pinned boundary") + return nil + } m.sessions[m.selected], m.sessions[next] = m.sessions[next], m.sessions[m.selected] m.selected = next + return m.scheduleReorderFlush() +} + +// scheduleReorderFlush bumps the reorder seq, marks a flush pending, and arms a +// debounced tick carrying the new seq. Only the tick whose seq is still current +// when it fires actually persists, so a burst of moves collapses to one write +// (F59). +func (m *Model) scheduleReorderFlush() tea.Cmd { + m.reorderSeq++ + m.reorderPending = true + seq := m.reorderSeq + return tea.Tick(reorderDebounce, func(time.Time) tea.Msg { return reorderFlushMsg{seq: seq} }) +} + +// flushReorder persists the current order if a reorder is pending, clearing the +// pending flag. It re-reads under flock via UpdateSortOrder (Store.Update), so +// the flush owns only the SortIndex keys and never clobbers a concurrent +// mutation with a stale snapshot (F59, F01). +func (m *Model) flushReorder() tea.Cmd { + if !m.reorderPending { + return nil + } + m.reorderPending = false return m.persistOrderCmd() } +// samePartition reports whether two rows sort into the same SortSessions +// partition — they share the same Closed and Pinned flags. Only within a +// partition does SortIndex (and therefore a manual reorder) take effect (F34). +func samePartition(a, b adapter.Session) bool { + return a.Closed == b.Closed && a.Pinned == b.Pinned +} + func (m *Model) handleActionKey(key string) (bool, tea.Cmd) { switch key { case "ctrl+c": m.quitting = true - return true, tea.Quit + // Flush any pending reorder before exiting so the debounce timer not yet + // having fired doesn't lose the manual order (F59). + return true, tea.Batch(m.flushReorder(), tea.Quit) case "tab": m.cycleDefaultAgent() - _ = m.service.SetDefaultAgent(m.defaultAgent) + return true, m.persistDefaultAgent() case "?": m.helpOpen = true case "ctrl+s": m.groupByDir = !m.groupByDir - _ = m.service.SetUI(func(ui *store.UISettings) { ui.GroupByDir = m.groupByDir }) + return true, m.persistGroupByDir() case "ctrl+t": return true, m.pinSelectedCmd() case "ctrl+r": m.startRename() case "ctrl+x": - m.confirmStop = len(m.sessions) > 0 + if sess, ok := m.selectedSession(); ok { + m.confirmStop = true + m.confirmStopID = sess.ID + } case " ": return true, m.handleSpaceKey(key) case "right", "enter": @@ -281,7 +559,10 @@ func (m *Model) handleActionKey(key string) (bool, tea.Cmd) { // then clear the command input, and finally quit the uam TUI. func (m *Model) handleEscKey() tea.Cmd { if m.peekOpen { + // Esc backs out of the peek/reply composer WITHOUT sending the in-progress + // reply (F36). m.peekOpen = false + m.peekTargetID = "" return nil } if m.input != "" { @@ -289,15 +570,18 @@ func (m *Model) handleEscKey() tea.Cmd { return nil } m.quitting = true - return tea.Quit + // Flush a pending reorder before exiting (F59). + return tea.Batch(m.flushReorder(), tea.Quit) } func (m *Model) startRename() { - if len(m.sessions) == 0 { + sess, ok := m.selectedSession() + if !ok { return } m.renaming = true - m.input = m.sessions[m.selected].DisplayName + m.renameTargetID = sess.ID + m.input = sess.DisplayName } func (m *Model) handleSpaceKey(key string) tea.Cmd { @@ -308,17 +592,31 @@ func (m *Model) handleSpaceKey(key string) tea.Cmd { // A stopped session has no live tmux pane to peek into — Space restarts it // in the background instead. if sess, ok := m.selectedSession(); ok && sess.ProcAlive == adapter.Exited { - m.message = "restarting " + firstNonEmpty(sess.DisplayName, sess.ID) + m.setMessage("restarting " + firstNonEmpty(sess.DisplayName, sess.ID)) return m.resumeSelectedCmd() } m.peekOpen = !m.peekOpen if m.peekOpen { + // Snapshot the peeked session so an Enter-to-reply routes to it even if a + // refresh reorders the list under the cursor (F36). + if sess, ok := m.selectedSession(); ok { + m.peekTargetID = sess.ID + } return m.peekSelectedCmd() } + m.peekTargetID = "" return nil } func (m *Model) handleEnterKey() tea.Cmd { + // Reply sub-mode: while the peek panel is open the command line is a reply + // composer. Non-empty input + Enter sends to the peeked session via + // Service.Reply and re-peeks, instead of dispatching a new agent. Checked + // before the dispatch/attach branch so peek+typed-text never spawns a session + // (F36). + if m.peekOpen && strings.TrimSpace(m.input) != "" { + return m.replyToPeekCmd() + } if strings.TrimSpace(m.input) != "" { spec := parseDispatchSpec(m.input, m.defaultAgent) return m.dispatchNamedCmd(spec.Agent, spec.Name, spec.Prompt) @@ -329,6 +627,27 @@ func (m *Model) handleEnterKey() tea.Cmd { return nil } +// replyToPeekCmd sends the typed input to the peeked session via Service.Reply, +// clears the composer, and re-peeks so the panel shows the agent's response. The +// reply target is the snapshotted peekTargetID (falling back to the selected +// session) so a reorder under the cursor can't misroute it (F36). +func (m *Model) replyToPeekCmd() tea.Cmd { + sess, ok := m.sessionByID(m.peekTargetID) + if !ok { + return nil + } + text := m.input + m.input = "" + id := sess.ID + return func() tea.Msg { + if err := m.service.Reply(context.Background(), id, text); err != nil { + return peekLoadedMsg{err: err} + } + p, err := m.service.Peek(context.Background(), id) + return peekLoadedMsg{text: p.TailText, err: err} + } +} + func (m *Model) handleEditKey(key string) { if strings.TrimSpace(m.input) != "" { m.input += key @@ -341,41 +660,56 @@ func (m *Model) handleEditKey(key string) { } func (m *Model) backspaceInput() { - if len(m.input) > 0 { - m.input = m.input[:len(m.input)-1] + if r := []rune(m.input); len(r) > 0 { + m.input = string(r[:len(r)-1]) } } -func (m *Model) appendKeyInput(msg tea.KeyMsg, key string) { - if msg.Type == tea.KeyRunes || len([]rune(key)) == 1 { - m.input += key - } +func (m *Model) appendKeyInput(msg tea.KeyMsg) { + m.editText(msg) } func (m Model) handleRenameKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() switch key { case "enter": - id := m.sessions[m.selected].ID + sess, ok := m.sessionByID(m.renameTargetID) name := m.input m.renaming = false + m.renameTargetID = "" m.input = "" + // The target session vanished (killed externally / list emptied) while the + // modal was open: close the modal without panicking (F27). + if !ok { + return m, nil + } + id := sess.ID return m, func() tea.Msg { return sessionsLoadedMsg{err: m.service.Rename(context.Background(), id, name)} } case "esc": m.renaming = false + m.renameTargetID = "" m.input = "" - case "backspace": - if len(m.input) > 0 { - m.input = m.input[:len(m.input)-1] - } default: - if len(key) == 1 || key == " " { - m.input += key - } + m.editText(msg) } return m, nil } +// editText applies a printable-input edit to m.input: it appends pasted/typed +// runes (KeyRunes without Alt — covers multibyte and bracketed paste), handles +// Space and Backspace, and ignores Alt-chords and control keys so they never +// leak as literal text (F29). +func (m *Model) editText(msg tea.KeyMsg) { + switch { + case msg.Type == tea.KeyBackspace: + m.backspaceInput() + case msg.Type == tea.KeySpace: + m.input += " " + case msg.Type == tea.KeyRunes && !msg.Alt: + m.input += string(msg.Runes) + } +} + func (m Model) handleWizardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() if key == "esc" { @@ -385,7 +719,7 @@ func (m Model) handleWizardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if cmd, done := m.handleWizardStepKey(key); done { return m, cmd } - m.editWizardInput(key) + m.editText(msg) return m, nil } @@ -410,9 +744,8 @@ func (m *Model) handleWizardAgentKey(key string) (tea.Cmd, bool) { switch key { case "tab": m.cycleDefaultAgent() - _ = m.service.SetDefaultAgent(m.defaultAgent) m.wizardAgent = m.defaultAgent - return nil, true + return m.persistDefaultAgent(), true case "enter": if m.wizardAgent == "" { m.wizardAgent = m.defaultAgent @@ -425,33 +758,129 @@ func (m *Model) handleWizardAgentKey(key string) (tea.Cmd, bool) { } func (m *Model) handleWizardCwdKey(key string) (tea.Cmd, bool) { - if key != "enter" { - return nil, false + switch key { + case "tab": + // Complete the typed path against the filesystem (C2-8). Marked done so + // the literal tab never leaks into the buffer. + m.input = globComplete(m.input) + return nil, true + case "enter": + m.wizardCwd = firstNonEmpty(m.input, ".") + m.wizardStep = 2 + m.input = "" + return nil, true } - m.wizardCwd = firstNonEmpty(m.input, ".") - m.wizardStep = 2 - m.input = "" - return nil, true + return nil, false } func (m *Model) handleWizardPromptKey(key string) (tea.Cmd, bool) { - if key != "enter" { - return nil, false + switch key { + case "ctrl+g": + // Compose the prompt in $EDITOR for multi-line input. Launched via + // tea.ExecProcess so the TUI screen state is restored cleanly — a raw + // exec.Command would corrupt the alt-screen (C2-8). + return m.editPromptCmd(), true + case "enter": + spec := parseDispatchSpec(m.input, firstNonEmpty(m.wizardAgent, m.defaultAgent)) + cwd := m.wizardCwd + m.closeWizard() + return m.dispatchWithNameCwdCmd(spec.Agent, spec.Name, spec.Prompt, cwd), true } - spec := parseDispatchSpec(m.input, firstNonEmpty(m.wizardAgent, m.defaultAgent)) - cwd := m.wizardCwd - m.closeWizard() - return m.dispatchWithNameCwdCmd(spec.Agent, spec.Name, spec.Prompt, cwd), true + return nil, false } -func (m *Model) editWizardInput(key string) { - if key == "backspace" { - m.backspaceInput() - return +// editPromptCmd composes the wizard prompt in $EDITOR. It seeds a temp file with +// the current buffer, launches the editor via the injected runner +// (tea.ExecProcess in production, which suspends/restores the alt-screen +// cleanly), and on exit loads the file back via promptEditedMsg. Using +// exec.Command directly instead would leave the terminal in raw mode and corrupt +// the TUI (C2-8). +func (m Model) editPromptCmd() tea.Cmd { + runner := m.execProcess + if runner == nil { + runner = tea.ExecProcess } - if len(key) == 1 || key == " " { - m.input += key + seed := m.input + f, err := os.CreateTemp("", "uam-prompt-*.txt") + if err != nil { + return func() tea.Msg { return promptEditedMsg{err: fmt.Errorf("create prompt buffer: %w", err)} } + } + path := f.Name() + if _, err := f.WriteString(seed); err != nil { + _ = f.Close() + return func() tea.Msg { return promptEditedMsg{err: fmt.Errorf("seed prompt buffer: %w", err)} } + } + if err := f.Close(); err != nil { + return func() tea.Msg { return promptEditedMsg{err: fmt.Errorf("close prompt buffer: %w", err)} } + } + editor := firstNonEmpty(os.Getenv("VISUAL"), os.Getenv("EDITOR"), "vi") + cmd := exec.Command(editor, path) // #nosec G204,G702 -- editor is the user's own $VISUAL/$EDITOR (their environment, not external input), path is a temp file we created; this is the standard "edit in $EDITOR" pattern (git/kubectl). + return runner(cmd, func(err error) tea.Msg { + defer func() { _ = os.Remove(path) }() + if err != nil { + return promptEditedMsg{err: fmt.Errorf("editor exited: %w", err)} + } + data, readErr := os.ReadFile(path) // #nosec G304 -- path is the temp file we just created above. + if readErr != nil { + return promptEditedMsg{err: fmt.Errorf("read edited prompt: %w", readErr)} + } + return promptEditedMsg{text: string(data)} + }) +} + +// isGitRepo reports whether dir is inside a git working tree by walking up the +// directory tree looking for a .git entry, the way git itself resolves the repo +// root. Used to warn in the wizard when dispatching outside a repo means there is +// no checkpoint to recover the agent's work from (C2-8). +func isGitRepo(dir string) bool { + d, err := filepath.Abs(dir) + if err != nil { + return false + } + for { + if _, statErr := os.Stat(filepath.Join(d, ".git")); statErr == nil { + return true + } + parent := filepath.Dir(d) + if parent == d { + return false + } + d = parent + } +} + +// globComplete completes a partially-typed path against the filesystem. It +// returns the longest unambiguous match (the sole match, or the shared prefix of +// several); when nothing matches it returns the input unchanged so Tab is a +// no-op rather than destructive (C2-8). +func globComplete(input string) string { + if input == "" { + return input + } + matches, err := filepath.Glob(input + "*") + if err != nil || len(matches) == 0 { + return input + } + if len(matches) == 1 { + return matches[0] + } + return longestCommonPrefix(matches) +} + +func longestCommonPrefix(items []string) string { + if len(items) == 0 { + return "" + } + prefix := items[0] + for _, s := range items[1:] { + for !strings.HasPrefix(s, prefix) { + prefix = prefix[:len(prefix)-1] + if prefix == "" { + return "" + } + } } + return prefix } func (m *Model) cycleDefaultAgent() { @@ -477,11 +906,6 @@ type dispatchSpec struct { Prompt string } -func parseDispatchInput(input, def string) (string, string) { - spec := parseDispatchSpec(input, def) - return spec.Prompt, spec.Agent -} - func parseDispatchSpec(input, def string) dispatchSpec { fields := strings.Fields(input) spec := dispatchSpec{Agent: def} @@ -497,15 +921,9 @@ func parseDispatchSpec(input, def string) dispatchSpec { return spec } -func (m Model) dispatchCmd(agent, prompt string) tea.Cmd { - return m.dispatchWithCwdCmd(agent, prompt, "") -} func (m Model) dispatchNamedCmd(agent, name, prompt string) tea.Cmd { return m.dispatchWithNameCwdCmd(agent, name, prompt, "") } -func (m Model) dispatchWithCwdCmd(agent, prompt, cwd string) tea.Cmd { - return m.dispatchWithNameCwdCmd(agent, "", prompt, cwd) -} func (m Model) dispatchWithNameCwdCmd(agent, name, prompt, cwd string) tea.Cmd { return func() tea.Msg { sess, err := m.service.DispatchNamed(context.Background(), agent, name, prompt, cwd, string(store.ModeYolo)) @@ -518,6 +936,22 @@ func (m Model) selectedSession() (adapter.Session, bool) { } return m.sessions[m.selected], true } + +// sessionByID returns the session with the given id, falling back to the +// selected row when id is empty. Modal flows (rename/stop-confirm) snapshot the +// target id at open time so a refresh that reorders the list mid-modal still +// acts on the originally-chosen session (C2-1, F29). +func (m Model) sessionByID(id string) (adapter.Session, bool) { + if id == "" { + return m.selectedSession() + } + for _, sess := range m.sessions { + if sess.ID == id { + return sess, true + } + } + return adapter.Session{}, false +} func (m Model) peekSelectedCmd() tea.Cmd { sess, ok := m.selectedSession() if !ok { @@ -544,7 +978,38 @@ func (m Model) resumeSelectedCmd() tea.Cmd { } } func (m Model) stopSelectedCmd(remove bool) tea.Cmd { - sess, ok := m.selectedSession() + return m.stopTargetCmd("", remove) +} + +// persistDefaultAgent persists the default-agent choice. On failure it surfaces +// the error in the status line instead of swallowing it; on success it returns a +// reload command so the UI reflects the stored config (F55). +func (m *Model) persistDefaultAgent() tea.Cmd { + if err := m.service.SetDefaultAgent(m.defaultAgent); err != nil { + m.setMessage("could not save default agent: " + err.Error()) + return nil + } + return m.loadSessionsCmd() +} + +// persistGroupByDir persists the group-by-dir toggle. On failure it surfaces the +// error and reverts the in-memory flag so the UI matches the unchanged stored +// state; on success it returns a reload command (F55). +func (m *Model) persistGroupByDir() tea.Cmd { + grouped := m.groupByDir + if err := m.service.SetUI(func(ui *store.UISettings) { ui.GroupByDir = grouped }); err != nil { + m.groupByDir = !grouped + m.setMessage("could not save view setting: " + err.Error()) + return nil + } + return m.loadSessionsCmd() +} + +// stopTargetCmd stops the session with the snapshotted id, falling back to the +// selected row when id is empty, so a refresh that reorders the list while the +// stop-confirm dialog is open still stops the originally-confirmed session (F29). +func (m Model) stopTargetCmd(id string, remove bool) tea.Cmd { + sess, ok := m.sessionByID(id) if !ok { return nil } @@ -623,6 +1088,7 @@ var ( taskColor = lipgloss.AdaptiveColor{Light: "#475569", Dark: "#AEBACD"} liveColor = lipgloss.AdaptiveColor{Light: "#047857", Dark: "#34D399"} failColor = lipgloss.AdaptiveColor{Light: "#DC2626", Dark: "#F87171"} + warnColor = lipgloss.AdaptiveColor{Light: "#B45309", Dark: "#FBBF24"} ) var ( @@ -633,6 +1099,7 @@ var ( dividerStyle = lipgloss.NewStyle().Foreground(dividerColor) taskStyle = lipgloss.NewStyle().Foreground(taskColor) selectedStyle = lipgloss.NewStyle().Bold(true).Foreground(accentColor) + warnStyle = lipgloss.NewStyle().Foreground(warnColor) ) // bar is the accent rule that marks the brand and command lines. @@ -799,23 +1266,36 @@ func renderRow(s adapter.Session, selected bool, nameWidth, taskWidth int, showT if selected { cursor = brandStyle.Render("▸") + " " } - glyph, gs := stateGlyph(s.State) + glyph, gs := sessionGlyph(s) pin := "" if s.Pinned { pin = "★ " } + // PR status dot in a fixed 1-column slot (blank when the session has no PR) + // so the task column stays aligned whether or not a PR is present. Distinct + // glyphs per status (not color-only) survive a no-color terminal (F26). + prCell := " " + if s.PR != nil { + prCell = prStatusStyle(s.PR.Status).Render(prStatusDot(s.PR.Status)) + } nameStyle := titleStyle if selected { nameStyle = selectedStyle } label := truncate(pin+firstNonEmpty(s.DisplayName, s.ID), nameWidth) if showTask { - cell := nameStyle.Render(fmt.Sprintf("%-*s", nameWidth, label)) - return cursor + gs.Render(glyph) + " " + cell + " " + taskStyle.Render(truncate(promptText(s), taskWidth)) + // Width-aware padding keeps the task column aligned even when the name + // holds wide (CJK/emoji) runes (F28). + cell := nameStyle.Render(padRight(label, nameWidth)) + return cursor + gs.Render(glyph) + " " + cell + " " + prCell + " " + taskStyle.Render(truncate(promptText(s), taskWidth)) } // Narrow layout: state glyph + name only — one line per row. The selected // session's task is carried by the details panel, so rows don't repeat it. - return cursor + gs.Render(glyph) + " " + nameStyle.Render(label) + row := cursor + gs.Render(glyph) + " " + nameStyle.Render(label) + if s.PR != nil { + row += " " + prCell + } + return row } func (m Model) renderPeek() string { @@ -827,6 +1307,15 @@ func (m Model) renderPrompt() string { b.WriteString("\n") if m.renaming { b.WriteString(bar() + " " + hintStyle.Render("rename") + " " + titleStyle.Render(m.input) + brandStyle.Render("▏") + "\n") + } else if m.peekOpen { + // The command line doubles as a reply composer while peek is open: label + // it so the sub-mode is discoverable (Enter sends, Esc closes) (F36). + field := hintStyle.Render("type a reply…") + if m.input != "" { + field = titleStyle.Render(m.input) + } + hints := hintStyle.Render("Enter send · Esc close") + b.WriteString(bar() + " " + hintStyle.Render("reply") + " " + brandStyle.Render("›") + " " + field + brandStyle.Render("▏") + " " + hints + "\n") } else { field := hintStyle.Render("type a command…") if m.input != "" { @@ -878,7 +1367,23 @@ func (m Model) visibleSessionWindow() (int, int) { } func promptText(sess adapter.Session) string { - return firstNonEmpty(sess.Prompt, stateLabel(sess.State), "idle") + // Fall back to a liveness-derived label (never the raw "Failed" State enum) + // so a reboot-survivor row doesn't read as failed when it has no prompt — it + // is resumable, not broken (F30). + return firstNonEmpty(sess.Prompt, livenessLabel(sess), "idle") +} + +// livenessLabel describes a prompt-less session by its liveness and Closed flag +// rather than its State enum. +func livenessLabel(sess adapter.Session) string { + switch { + case sess.ProcAlive == adapter.Alive: + return "running" + case sess.Closed: + return "closed" + default: + return "resumable" + } } // absCwd resolves a session's working directory to an absolute path. @@ -909,7 +1414,7 @@ func (m Model) renderHelp() string { } func (m Model) renderConfirm() string { - sess, _ := m.selectedSession() + sess, _ := m.sessionByID(m.confirmStopID) name := firstNonEmpty(sess.DisplayName, sess.ID, "session") return "\n " + sectionStyle.Render("Stop session") + "\n " + hintStyle.Render("Stop and remove ") + titleStyle.Render(name) + hintStyle.Render("?") + @@ -929,16 +1434,56 @@ func (m Model) renderWizard() string { var b strings.Builder b.WriteString("\n " + sectionStyle.Render("NEW SESSION") + " " + hintStyle.Render(fmt.Sprintf("step %d of 3", step+1)) + "\n") b.WriteString(" " + titleStyle.Render(steps[step]) + brandStyle.Render("▏") + "\n") // #nosec G602 -- step is clamped to [0, len(steps)) just above. - b.WriteString(" " + hintStyle.Render("Esc cancels") + "\n") + switch step { + case 1: + // Warn when the chosen working directory is not inside a git repo: there + // is no checkpoint to recover the agent's work from (C2-8). + dir := firstNonEmpty(m.input, ".") + if !isGitRepo(dir) { + b.WriteString(" " + warnStyle.Render("⚠ not a git repo — no checkpoint to recover the agent's work") + "\n") + } + b.WriteString(" " + hintStyle.Render("Tab completes a path · Esc cancels") + "\n") + case 2: + b.WriteString(" " + hintStyle.Render("Ctrl+G opens $EDITOR · Esc cancels") + "\n") + default: + b.WriteString(" " + hintStyle.Render("Esc cancels") + "\n") + } return b.String() } +// liveGlyphStyle / failGlyphStyle are hoisted to package vars so renderRow does +// not allocate a fresh lipgloss.Style per row per frame. They keep AdaptiveColor +// (resolved at render time, not pre-baked) so the palette still adapts to +// light/dark terminals (F58). +var ( + liveGlyphStyle = lipgloss.NewStyle().Bold(true).Foreground(liveColor) + failGlyphStyle = lipgloss.NewStyle().Bold(true).Foreground(failColor) +) + +// sessionGlyph picks the row glyph from the session's liveness and Closed flag +// rather than its State enum, so a reboot-survivor (Exited but not user-closed) +// renders as a neutral "resumable" dot instead of the red Failed glyph under the +// ACTIVE group (F30). +func sessionGlyph(s adapter.Session) (string, lipgloss.Style) { + switch { + case s.ProcAlive == adapter.Alive: + return "⟳", liveGlyphStyle + case s.Closed: + // User-retired, dead pane: muted resting dot in the CLOSED group. + return "•", hintStyle + default: + // Reboot-survivor / externally-killed but resumable: neutral paused + // glyph, NOT the red failure mark. + return "◦", hintStyle + } +} + func stateGlyph(s adapter.State) (string, lipgloss.Style) { switch s { case adapter.Active: - return "⟳", lipgloss.NewStyle().Bold(true).Foreground(liveColor) + return "⟳", liveGlyphStyle case adapter.Failed: - return "✕", lipgloss.NewStyle().Bold(true).Foreground(failColor) + return "✕", failGlyphStyle default: return "•", hintStyle } @@ -948,25 +1493,73 @@ func stateLabel(s adapter.State) string { return strings.ToLower(string(s)) } +// prStatusDot returns a distinct glyph per PR status (not color-only) so the PR +// state survives a monochrome terminal or a screen scrape: open=hollow circle, +// merged=filled circle, draft=half circle, closed=cross (F26). func prStatusDot(s adapter.PRStatus) string { switch s { + case adapter.PROpen: + return "○" case adapter.PRMerged: return "●" case adapter.PRDraft: return "◐" case adapter.PRClosed: - return "◯" - case adapter.PROpen: - return "●" + return "✕" default: return " " } } +// prStatusStyle colours the PR dot by status. Colour is a secondary cue; the +// glyph in prStatusDot is the primary, color-independent signal (F26). +func prStatusStyle(s adapter.PRStatus) lipgloss.Style { + switch s { + case adapter.PRMerged: + return liveGlyphStyle + case adapter.PRClosed: + return failGlyphStyle + default: + return hintStyle + } +} + +// truncate clips s to at most n display columns, measuring with lipgloss.Width +// so multibyte and wide (CJK/emoji) runes are counted by the columns they +// occupy rather than their byte length. When clipping happens an ellipsis is +// appended and the result still fits within n columns (F28). func truncate(s string, n int) string { s = strings.ReplaceAll(s, "\n", " ") - if len(s) > n { - return s[:max(0, n-1)] + "…" + if n <= 0 { + return "" + } + if lipgloss.Width(s) <= n { + return s + } + // Reserve one column for the ellipsis, then grow rune-by-rune until adding + // the next rune would overflow the budget. This keeps wide runes intact and + // never slices a multibyte sequence. + budget := n - 1 + var b strings.Builder + w := 0 + for _, r := range s { + rw := lipgloss.Width(string(r)) + if w+rw > budget { + break + } + b.WriteRune(r) + w += rw + } + return b.String() + "…" +} + +// padRight pads s with spaces to occupy exactly n display columns. If s already +// meets or exceeds n columns it is returned unchanged. Display-width padding +// keeps columns aligned when names contain wide runes, which byte-length-based +// fmt "%-*s" padding gets wrong (F28). +func padRight(s string, n int) string { + if pad := n - lipgloss.Width(s); pad > 0 { + return s + strings.Repeat(" ", pad) } return s } diff --git a/internal/app/app_cmd_test.go b/internal/app/app_cmd_test.go index 4e4daaa..5e8ed3f 100644 --- a/internal/app/app_cmd_test.go +++ b/internal/app/app_cmd_test.go @@ -89,7 +89,7 @@ func TestModelCommandFactories(t *testing.T) { if m.loadSessionsCmd()() == nil { t.Fatal("nil load msg") } - if msg := m.dispatchCmd("fake", "prompt")(); msg.(dispatchedMsg).err != nil { + if msg := m.dispatchNamedCmd("fake", "", "prompt")(); msg.(dispatchedMsg).err != nil { t.Fatalf("dispatch msg=%+v", msg) } if msg := m.peekSelectedCmd()(); msg.(peekLoadedMsg).text != "tail" { @@ -238,7 +238,7 @@ func keyMsg(s string) tea.KeyMsg { func TestServiceNilAndUnavailableBranches(t *testing.T) { svc := NewService(nil, nil) - if _, err := svc.Dispatch(context.Background(), "x", "", "", ""); err == nil { + if _, err := svc.DispatchNamed(context.Background(), "x", "", "", "", ""); err == nil { t.Fatal("want registry error") } if err := svc.SetUI(func(*store.UISettings) {}); err != nil { diff --git a/internal/app/app_more_test.go b/internal/app/app_more_test.go index 2ff2cef..c5db921 100644 --- a/internal/app/app_more_test.go +++ b/internal/app/app_more_test.go @@ -1,6 +1,7 @@ package app import ( + "errors" "os/exec" "reflect" "strings" @@ -72,8 +73,8 @@ func assertSelectionAfterKey(t *testing.T, m *Model, key tea.KeyType, want int) func assertDispatchParsing(t *testing.T, m Model) { t.Helper() m.input = "@fake do work" - if prompt, agent := parseDispatchInput(m.input, "claude"); prompt != "do work" || agent != "fake" { - t.Fatalf("%q %q", prompt, agent) + if spec := parseDispatchSpec(m.input, "claude"); spec.Prompt != "do work" || spec.Agent != "fake" { + t.Fatalf("%q %q", spec.Prompt, spec.Agent) } spec := parseDispatchSpec("@fake #my-session do work", "claude") if spec.Agent != "fake" || spec.Name != "my-session" || spec.Prompt != "do work" { @@ -83,8 +84,8 @@ func assertDispatchParsing(t *testing.T, m Model) { if spec.Agent != "fake" || spec.Name != "my-session" || spec.Prompt != "" { t.Fatalf("named no-prompt spec=%+v", spec) } - if prompt, agent := parseDispatchInput("do work", "claude"); prompt != "do work" || agent != "claude" { - t.Fatalf("%q %q", prompt, agent) + if spec := parseDispatchSpec("do work", "claude"); spec.Prompt != "do work" || spec.Agent != "claude" { + t.Fatalf("%q %q", spec.Prompt, spec.Agent) } if sess, ok := m.selectedSession(); !ok || sess.ID != "1" { t.Fatalf("selected session %+v %v", sess, ok) @@ -161,6 +162,39 @@ func TestDispatchedMessageAttachesNewSession(t *testing.T) { } } +// F03 — a dispatch that returns a live session alongside an advisory persist +// error must still attach (the session is running); the warning surfaces in the +// status line but does not abort the attach. +func TestDispatchedAttachesLiveSessionDespiteAdvisoryError(t *testing.T) { + fake := &svcFakeAdapter{name: "fake", available: true} + m := NewWithDeps(nil, adapter.NewRegistry([]adapter.AgentAdapter{fake})) + m.execProcess = func(cmd *exec.Cmd, cb tea.ExecCallback) tea.Cmd { + return func() tea.Msg { return cb(nil) } + } + model, cmd := m.Update(dispatchedMsg{session: adapter.Session{ID: "abc12345", AgentType: "fake"}, err: errors.New("persist boom")}) + m = model.(Model) + if cmd == nil { + t.Fatal("a live session must still attach even with an advisory error") + } + if _, ok := cmd().(attachSpecMsg); !ok { + t.Fatalf("expected attachSpecMsg from the attach command") + } +} + +// F03 — a dispatch failure with no live session (empty ID) must NOT attach; it +// only reports the error. +func TestDispatchedFailureWithoutSessionDoesNotAttach(t *testing.T) { + m := NewWithDeps(nil, nil) + model, cmd := m.Update(dispatchedMsg{session: adapter.Session{}, err: errors.New("agent unavailable")}) + m = model.(Model) + if cmd != nil { + t.Fatal("a failed dispatch with no live session must not attach") + } + if !strings.Contains(m.message, "agent unavailable") { + t.Fatalf("expected error in status line, got %q", m.message) + } +} + func TestViewShowsDetailsOnTopAndTaskInTable(t *testing.T) { m := NewWithDeps(nil, nil) m.sessions = []adapter.Session{ diff --git a/internal/app/default_agent_test.go b/internal/app/default_agent_test.go new file mode 100644 index 0000000..4cd9233 --- /dev/null +++ b/internal/app/default_agent_test.go @@ -0,0 +1,72 @@ +package app + +import ( + "testing" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" +) + +// C2-9 — the default-agent selector must never land on a disabled agent. When +// the hardcoded/persisted default is not in the enabled registry, NewWithDeps +// falls back to the registry's chosen default so Enter-with-no-input and the +// prompt hint target a provider that actually exists. +func TestNewWithDepsFallsBackToEnabledWhenDefaultNotEnabled(t *testing.T) { + // Only "codex" is enabled; the baked-in default "claude" is not. + reg := adapter.NewRegistry([]adapter.AgentAdapter{ + &svcFakeAdapter{name: "codex", available: true}, + &svcFakeAdapter{name: "claude", available: false}, + }) + m := NewWithDeps(nil, reg) + if m.defaultAgent != "codex" { + t.Fatalf("defaultAgent = %q, want the enabled fallback %q", m.defaultAgent, "codex") + } +} + +// C2-9 — a persisted DefaultAgent that is no longer enabled (CLI uninstalled) +// must be reconciled to an enabled provider when sessions load, not displayed +// and dispatched-to verbatim. +func TestHandleSessionsLoadedReconcilesDisabledDefaultAgent(t *testing.T) { + reg := adapter.NewRegistry([]adapter.AgentAdapter{ + &svcFakeAdapter{name: "opencode", available: true}, + }) + m := NewWithDeps(nil, reg) + m = m.handleSessionsLoaded(sessionsLoadedMsg{defaultAgent: "claude"}) + if m.defaultAgent != "opencode" { + t.Fatalf("loaded defaultAgent = %q, want enabled fallback %q", m.defaultAgent, "opencode") + } +} + +// C2-9 — when the persisted default IS enabled it must be honored as-is. +func TestHandleSessionsLoadedKeepsEnabledDefaultAgent(t *testing.T) { + reg := adapter.NewRegistry([]adapter.AgentAdapter{ + &svcFakeAdapter{name: "codex", available: true}, + &svcFakeAdapter{name: "claude", available: true}, + }) + m := NewWithDeps(nil, reg) + m = m.handleSessionsLoaded(sessionsLoadedMsg{defaultAgent: "claude"}) + if m.defaultAgent != "claude" { + t.Fatalf("loaded defaultAgent = %q, want %q (it is enabled)", m.defaultAgent, "claude") + } +} + +// C2-9 nil-guard — with no registry (or none enabled) validation must not panic +// and must leave the baked-in default in place. +func TestDefaultAgentValidationNilRegistryDoesNotPanic(t *testing.T) { + m := NewWithDeps(nil, nil) + if m.defaultAgent != "claude" { + t.Fatalf("nil-registry default = %q, want %q", m.defaultAgent, "claude") + } + m = m.handleSessionsLoaded(sessionsLoadedMsg{defaultAgent: "anything"}) + if m.defaultAgent != "anything" { + // With no registry to validate against, the loaded value is kept verbatim. + t.Fatalf("nil-registry loaded default = %q, want it kept verbatim", m.defaultAgent) + } + + // Registry with nothing enabled: Default returns nil, so the candidate is + // kept (no enabled agent to fall back to). + empty := adapter.NewRegistry([]adapter.AgentAdapter{&svcFakeAdapter{name: "codex", available: false}}) + m2 := NewWithDeps(nil, empty) + if m2.defaultAgent != "claude" { + t.Fatalf("none-enabled default = %q, want baked-in %q kept", m2.defaultAgent, "claude") + } +} diff --git a/internal/app/lifecycle_test.go b/internal/app/lifecycle_test.go new file mode 100644 index 0000000..1387f26 --- /dev/null +++ b/internal/app/lifecycle_test.go @@ -0,0 +1,103 @@ +package app + +import ( + "context" + "testing" + "time" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" +) + +// F20 Stage 1 — a refresh must bump LastSeenAt for every live (pane-alive) +// session so the staleness-based PruneOld never deletes a session that is still +// running. Without this, an old CreatedAt + never-updated LastSeenAt makes a +// long-running live session look prunable. +func TestLastSeenAtBumpedForLiveSessions(t *testing.T) { + live := adapter.Session{ID: "aaaa1111", AgentType: "fake", DisplayName: "A", Cwd: "/tmp", TmuxSession: "uam-fake-aaaa1111", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now().Add(-30 * 24 * time.Hour)} + svc, st, _ := newLoadService(t, []adapter.Session{live}) + + // Seed a stale record for the same live session: LastSeenAt far in the past. + key := store.Key("fake", "aaaa1111") + stale := time.Now().Add(-30 * 24 * time.Hour) + if err := st.Update(func(cfg *store.Config) error { + cfg.Sessions[key] = store.SessionRecord{ID: "aaaa1111", Agent: "fake", Name: "A", TmuxSession: "uam-fake-aaaa1111", Status: store.StatusActive, LastSeenAt: stale} + return nil + }); err != nil { + t.Fatalf("seed: %v", err) + } + + before := time.Now() + if _, _, err := svc.LoadSessions(context.Background()); err != nil { + t.Fatalf("LoadSessions: %v", err) + } + cfg, err := st.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + got := cfg.Sessions[key].LastSeenAt + if !got.After(before.Add(-time.Second)) { + t.Fatalf("LastSeenAt for live session not bumped: got %v, stale was %v", got, stale) + } +} + +// F20 Stage 2 — startup pruning must be server-down-safe: when no live sessions +// are visible (server down or empty, indistinguishable) it must NOT delete the +// persisted records, or a transient tmux-down would wipe the whole store. +func TestStartupPruneSkipsWhenServerDown(t *testing.T) { + // No live sessions: the fake reports an empty set, which is what a down + // server looks like to the service. + svc, st, _ := newLoadService(t, nil) + + key := store.Key("fake", "bbbb2222") + old := time.Now().Add(-90 * 24 * time.Hour) + if err := st.Update(func(cfg *store.Config) error { + cfg.Sessions[key] = store.SessionRecord{ID: "bbbb2222", Agent: "fake", Name: "B", TmuxSession: "uam-fake-bbbb2222", Status: store.StatusActive, LastSeenAt: old} + return nil + }); err != nil { + t.Fatalf("seed: %v", err) + } + + if err := svc.PruneStartup(context.Background()); err != nil { + t.Fatalf("PruneStartup: %v", err) + } + cfg, err := st.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if _, ok := cfg.Sessions[key]; !ok { + t.Fatalf("server-down startup prune deleted a record: %+v", cfg.Sessions) + } +} + +// F20 Stage 2 — when the server is up (at least one live session proves it), +// startup pruning removes a stale, dead-pane record but keeps the live one. +func TestStartupPruneRemovesStaleDeadRecordWhenServerUp(t *testing.T) { + live := adapter.Session{ID: "cccc3333", AgentType: "fake", DisplayName: "C", Cwd: "/tmp", TmuxSession: "uam-fake-cccc3333", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} + svc, st, _ := newLoadService(t, []adapter.Session{live}) + + liveKey := store.Key("fake", "cccc3333") + deadKey := store.Key("fake", "dddd4444") + old := time.Now().Add(-90 * 24 * time.Hour) + if err := st.Update(func(cfg *store.Config) error { + cfg.Sessions[liveKey] = store.SessionRecord{ID: "cccc3333", Agent: "fake", Name: "C", TmuxSession: "uam-fake-cccc3333", Status: store.StatusActive, LastSeenAt: time.Now()} + cfg.Sessions[deadKey] = store.SessionRecord{ID: "dddd4444", Agent: "fake", Name: "D", TmuxSession: "uam-fake-dddd4444", Status: store.StatusActive, LastSeenAt: old} + return nil + }); err != nil { + t.Fatalf("seed: %v", err) + } + + if err := svc.PruneStartup(context.Background()); err != nil { + t.Fatalf("PruneStartup: %v", err) + } + cfg, err := st.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if _, ok := cfg.Sessions[liveKey]; !ok { + t.Fatalf("startup prune removed the live session: %+v", cfg.Sessions) + } + if _, ok := cfg.Sessions[deadKey]; ok { + t.Fatalf("startup prune kept a stale dead record: %+v", cfg.Sessions) + } +} diff --git a/internal/app/livesessions_test.go b/internal/app/livesessions_test.go new file mode 100644 index 0000000..8543746 --- /dev/null +++ b/internal/app/livesessions_test.go @@ -0,0 +1,74 @@ +package app + +import ( + "bytes" + "context" + "errors" + "log/slog" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/log" + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" +) + +// captureWarn installs a capturing slog handler for the duration of fn and +// returns everything written, then restores the previous logger. +func captureLog(t *testing.T, fn func()) string { + t.Helper() + var buf bytes.Buffer + prev := log.SetLogger(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + defer log.SetLogger(prev) + fn() + return buf.String() +} + +// F12 — a single adapter whose List fails must be logged (not silently +// dropped) and must not blank the dashboard for the healthy adapters. +func TestLiveSessionsLogsAdapterListFailure(t *testing.T) { + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "sessions.json")) + if err != nil { + t.Fatal(err) + } + healthy := &svcFakeAdapter{name: "healthy", available: true, sessions: []adapter.Session{ + {ID: "live0001", AgentType: "healthy", DisplayName: "live", Cwd: "/tmp", TmuxSession: "uam-healthy-live0001", State: adapter.Active, CreatedAt: time.Now()}, + }} + broken := &svcFakeAdapter{name: "broken", available: true, listErr: errors.New("tmux exploded")} + svc := NewService(st, adapter.NewRegistry([]adapter.AgentAdapter{healthy, broken})) + + var sessions map[string]adapter.Session + out := captureLog(t, func() { + sessions = svc.liveSessions(context.Background()) + }) + + if len(sessions) != 1 || sessions[store.Key("healthy", "live0001")].ID == "" { + t.Fatalf("healthy adapter sessions must survive a sibling failure, got %d", len(sessions)) + } + if !strings.Contains(out, "broken") { + t.Fatalf("adapter List failure should be logged with the agent name, got: %q", out) + } +} + +// F12 trap — a benign (nil error) List must not emit a warning. +func TestLiveSessionsDoesNotWarnOnSuccess(t *testing.T) { + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "sessions.json")) + if err != nil { + t.Fatal(err) + } + healthy := &svcFakeAdapter{name: "healthy", available: true, sessions: []adapter.Session{ + {ID: "live0001", AgentType: "healthy", DisplayName: "live", Cwd: "/tmp", TmuxSession: "uam-healthy-live0001", State: adapter.Active, CreatedAt: time.Now()}, + }} + svc := NewService(st, adapter.NewRegistry([]adapter.AgentAdapter{healthy})) + + out := captureLog(t, func() { + svc.liveSessions(context.Background()) + }) + if strings.Contains(strings.ToUpper(out), "WARN") { + t.Fatalf("a successful List must not emit a warning, got: %q", out) + } +} diff --git a/internal/app/loadsessions_test.go b/internal/app/loadsessions_test.go new file mode 100644 index 0000000..03a7c6d --- /dev/null +++ b/internal/app/loadsessions_test.go @@ -0,0 +1,236 @@ +package app + +import ( + "context" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" +) + +// newLoadService builds a Service backed by a tempdir store and a single fake +// adapter whose live session set is taken from sessions. +func newLoadService(t *testing.T, sessions []adapter.Session) (*Service, *store.Store, *svcFakeAdapter) { + t.Helper() + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "sessions.json")) + if err != nil { + t.Fatalf("Open: %v", err) + } + fake := &svcFakeAdapter{name: "fake", available: true, sessions: sessions} + svc := NewService(st, adapter.NewRegistry([]adapter.AgentAdapter{fake})) + return svc, st, fake +} + +// F01 — a refresh that backfills a record must persist via an atomic re-read, +// never a whole-config Save that clobbers a concurrent TogglePin on a *different* +// session. Deterministic: prime a pinned record B, then run a refresh that owns +// only key A. After the fix the refresh re-reads inside the lock and writes only +// A, so B's pin survives. +func TestLoadSessionsRefreshDoesNotClobberConcurrentPin(t *testing.T) { + // Live session A has no store record -> refresh must backfill it (a write). + liveA := adapter.Session{ID: "aaaa1111", AgentType: "fake", DisplayName: "A", Cwd: "/tmp", TmuxSession: "uam-fake-aaaa1111", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} + svc, st, _ := newLoadService(t, []adapter.Session{liveA}) + + // Record B belongs to a different session and is already persisted, unpinned. + keyB := store.Key("fake", "bbbb2222") + if err := st.Update(func(cfg *store.Config) error { + cfg.Sessions[keyB] = store.SessionRecord{ID: "bbbb2222", Agent: "fake", Name: "B", TmuxSession: "uam-fake-bbbb2222"} + return nil + }); err != nil { + t.Fatalf("seed B: %v", err) + } + + // Simulate the lost-update window: an in-flight refresh has already loaded a + // stale cfg (B unpinned). Concurrently the user pins B. The refresh must not + // clobber that pin when it persists its own backfill of A. + cfgStale, err := st.Load() + if err != nil { + t.Fatalf("Load stale: %v", err) + } + if err := svc.TogglePin(context.Background(), "bbbb2222"); err != nil { + t.Fatalf("TogglePin B: %v", err) + } + + // Drive the refresh persistence with the stale snapshot. With the buggy + // whole-config Save this writes B unpinned and clobbers the pin; with the + // atomic re-read it touches only A. + live := map[string]adapter.Session{store.Key("fake", liveA.ID): liveA} + svc.mergeStoredSessions(live, cfgStale, time.Now()) + updates := svc.refreshSessionRecords(context.Background(), live, &cfgStale) + svc.persistRefresh(updates) + + cfg, err := st.Load() + if err != nil { + t.Fatalf("Load final: %v", err) + } + if !cfg.Sessions[keyB].Pinned { + t.Fatalf("concurrent pin on B was clobbered by the refresh save: %+v", cfg.Sessions[keyB]) + } + if _, ok := cfg.Sessions[store.Key("fake", "aaaa1111")]; !ok { + t.Fatalf("refresh did not persist the backfilled record A: %+v", cfg.Sessions) + } +} + +// F01 (-race) — concurrent LoadSessions refreshes and TogglePin calls must not +// race and must not lose the final pin state. +func TestLoadSessionsConcurrentWithTogglePin_Race(t *testing.T) { + liveA := adapter.Session{ID: "aaaa1111", AgentType: "fake", DisplayName: "A", Cwd: "/tmp", TmuxSession: "uam-fake-aaaa1111", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} + svc, st, _ := newLoadService(t, []adapter.Session{liveA}) + + keyB := store.Key("fake", "bbbb2222") + if err := st.Update(func(cfg *store.Config) error { + cfg.Sessions[keyB] = store.SessionRecord{ID: "bbbb2222", Agent: "fake", Name: "B", TmuxSession: "uam-fake-bbbb2222", Pinned: true} + return nil + }); err != nil { + t.Fatalf("seed B: %v", err) + } + + var wg sync.WaitGroup + for i := 0; i < 16; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if _, _, err := svc.LoadSessions(context.Background()); err != nil { + t.Errorf("LoadSessions: %v", err) + } + }() + } + wg.Wait() + + cfg, err := st.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + // B was never toggled by the refresh path; its pin must survive unchanged. + if !cfg.Sessions[keyB].Pinned { + t.Fatalf("B lost its pin under concurrent refresh: %+v", cfg.Sessions[keyB]) + } +} + +// C1-1 — a live session lacking a store record must be backfilled and persisted. +func TestRefreshBackfillsOrphanRecord(t *testing.T) { + live := adapter.Session{ID: "cccc3333", AgentType: "fake", DisplayName: "orphan", Cwd: "/tmp", TmuxSession: "uam-fake-cccc3333", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} + svc, st, _ := newLoadService(t, []adapter.Session{live}) + + if _, _, err := svc.LoadSessions(context.Background()); err != nil { + t.Fatalf("LoadSessions: %v", err) + } + cfg, err := st.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + rec, ok := cfg.Sessions[store.Key("fake", "cccc3333")] + if !ok { + t.Fatalf("orphan live session was not backfilled into the store: %+v", cfg.Sessions) + } + if rec.ID != "cccc3333" || rec.TmuxSession != "uam-fake-cccc3333" { + t.Fatalf("backfilled record is malformed: %+v", rec) + } +} + +// C1-1 — once a record exists, repeated refreshes with no real change must not +// rewrite the store (no write-storm of no-op saves). +func TestRefreshIsIdempotentNoRedundantSave(t *testing.T) { + live := adapter.Session{ID: "dddd4444", AgentType: "fake", DisplayName: "d", Cwd: "/tmp", TmuxSession: "uam-fake-dddd4444", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} + svc, st, _ := newLoadService(t, []adapter.Session{live}) + + // First load backfills. + if _, _, err := svc.LoadSessions(context.Background()); err != nil { + t.Fatalf("LoadSessions 1: %v", err) + } + + // Subsequent loads must report no owned changes -> empty update set. + cfg, err := st.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + liveMap := map[string]adapter.Session{store.Key("fake", live.ID): live} + svc.mergeStoredSessions(liveMap, cfg, time.Now()) + updates := svc.refreshSessionRecords(context.Background(), liveMap, &cfg) + if len(updates) != 0 { + t.Fatalf("idempotent refresh produced %d updates, want 0: %+v", len(updates), updates) + } +} + +// C1-1 fix-trap — a live session with an empty ID must never be persisted as an +// un-killable phantom record. +func TestRefreshDoesNotPersistEmptyIDRecord(t *testing.T) { + live := adapter.Session{ID: "", AgentType: "fake", DisplayName: "phantom", Cwd: "/tmp", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} + svc, st, _ := newLoadService(t, []adapter.Session{live}) + + if _, _, err := svc.LoadSessions(context.Background()); err != nil { + t.Fatalf("LoadSessions: %v", err) + } + cfg, err := st.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + for key, rec := range cfg.Sessions { + if rec.ID == "" { + t.Fatalf("empty-ID record persisted under key %q: %+v", key, rec) + } + } +} + +// F18 — a session the user closed but whose pane is still alive must reconcile +// to Active (it renders under ACTIVE, not CLOSED, because Closed => Exited). +func TestLoadSessionsReconcilesLiveUserClosedSessionToActive(t *testing.T) { + live := adapter.Session{ID: "eeee5555", AgentType: "fake", DisplayName: "e", Cwd: "/tmp", TmuxSession: "uam-fake-eeee5555", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} + svc, st, _ := newLoadService(t, []adapter.Session{live}) + + // Persist a record flagged closed-by-user even though the pane is alive. + key := store.Key("fake", "eeee5555") + if err := st.Update(func(cfg *store.Config) error { + cfg.Sessions[key] = store.SessionRecord{ID: "eeee5555", Agent: "fake", Name: "e", TmuxSession: "uam-fake-eeee5555", Status: store.StatusClosedByUser} + return nil + }); err != nil { + t.Fatalf("seed: %v", err) + } + + sessions, _, err := svc.LoadSessions(context.Background()) + if err != nil { + t.Fatalf("LoadSessions: %v", err) + } + var found bool + for _, s := range sessions { + if s.ID == "eeee5555" { + found = true + if s.Closed { + t.Fatalf("live user-closed session must not render Closed (Closed=>Exited): %+v", s) + } + } + } + if !found { + t.Fatalf("session not present: %+v", sessions) + } +} + +// F18 anti-flap — the persisted Status of a closed record whose pane survived +// the close must be reset to Active so it does not flap back to Closed. +func TestLoadSessionsResetsPersistedStatusWhenLivePaneSurvivesClose(t *testing.T) { + live := adapter.Session{ID: "ffff6666", AgentType: "fake", DisplayName: "f", Cwd: "/tmp", TmuxSession: "uam-fake-ffff6666", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} + svc, st, _ := newLoadService(t, []adapter.Session{live}) + + key := store.Key("fake", "ffff6666") + if err := st.Update(func(cfg *store.Config) error { + cfg.Sessions[key] = store.SessionRecord{ID: "ffff6666", Agent: "fake", Name: "f", TmuxSession: "uam-fake-ffff6666", Status: store.StatusClosedByUser} + return nil + }); err != nil { + t.Fatalf("seed: %v", err) + } + + if _, _, err := svc.LoadSessions(context.Background()); err != nil { + t.Fatalf("LoadSessions: %v", err) + } + cfg, err := st.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if got := cfg.Sessions[key].Status; got != store.StatusActive { + t.Fatalf("persisted status = %q, want %q (anti-flap reset)", got, store.StatusActive) + } +} diff --git a/internal/app/mergepr_test.go b/internal/app/mergepr_test.go new file mode 100644 index 0000000..f195e2c --- /dev/null +++ b/internal/app/mergepr_test.go @@ -0,0 +1,85 @@ +package app + +import ( + "testing" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" +) + +// --------------------------------------------------------------------------- +// C2-7 — the store schema does not persist PR Owner/Repo, only the URL. When a +// dead session's PR is rehydrated from the stored record, Owner/Repo must be +// re-derived from the (lossless) URL so downstream consumers that key on +// owner/repo are not handed empty strings. +// --------------------------------------------------------------------------- + +func TestMergeStoredMetadataReDerivesPROwnerRepoFromURL(t *testing.T) { + // A live session with no PR yet; the stored record carries a PR URL. + sess := adapter.Session{ID: "abc123", AgentType: "claude"} + rec := store.SessionRecord{ + ID: "abc123", + Agent: "claude", + PR: &store.PRRecord{ + URL: "https://github.com/acme/widgets/pull/42", + Number: 42, + LastStatus: "Open", + }, + } + + got := mergeStoredMetadata(sess, rec) + if got.PR == nil { + t.Fatalf("PR not rehydrated from stored record") + } + if got.PR.Owner != "acme" { + t.Fatalf("PR.Owner = %q, want %q (must be re-derived from URL)", got.PR.Owner, "acme") + } + if got.PR.Repo != "widgets" { + t.Fatalf("PR.Repo = %q, want %q (must be re-derived from URL)", got.PR.Repo, "widgets") + } + // The persisted fields must be preserved unchanged. + if got.PR.URL != rec.PR.URL || got.PR.Number != 42 || got.PR.Status != adapter.PRStatus("Open") { + t.Fatalf("persisted PR fields altered: %+v", got.PR) + } +} + +func TestMergeStoredMetadataPRWithUnparseableURLLeavesOwnerRepoEmpty(t *testing.T) { + // A record whose URL does not match the GitHub PR shape (shouldn't happen + // after validation, but be defensive) keeps the record's URL and leaves + // Owner/Repo empty rather than panicking. + sess := adapter.Session{ID: "abc123", AgentType: "claude"} + rec := store.SessionRecord{ + ID: "abc123", + Agent: "claude", + PR: &store.PRRecord{URL: "not-a-url", Number: 0, LastStatus: "Open"}, + } + got := mergeStoredMetadata(sess, rec) + if got.PR == nil { + t.Fatalf("PR dropped for unparseable URL") + } + if got.PR.Owner != "" || got.PR.Repo != "" { + t.Fatalf("expected empty owner/repo for unparseable URL, got %q/%q", got.PR.Owner, got.PR.Repo) + } + if got.PR.URL != "not-a-url" { + t.Fatalf("URL altered: %q", got.PR.URL) + } +} + +func TestMergeStoredMetadataDoesNotOverrideLivePR(t *testing.T) { + // When the live session already discovered a PR (Owner/Repo set), the stored + // record must not clobber it. + sess := adapter.Session{ + ID: "abc123", + AgentType: "claude", + PR: &adapter.PRRef{URL: "https://github.com/live/repo/pull/7", Owner: "live", Repo: "repo", Number: 7, Status: adapter.PROpen}, + } + rec := store.SessionRecord{ + ID: "abc123", + Agent: "claude", + PR: &store.PRRecord{URL: "https://github.com/stored/old/pull/1", Number: 1, LastStatus: "Closed"}, + } + got := mergeStoredMetadata(sess, rec) + if got.PR.Owner != "live" || got.PR.Repo != "repo" || got.PR.Number != 7 { + t.Fatalf("live PR was clobbered by stored record: %+v", got.PR) + } +} diff --git a/internal/app/peek_cadence_test.go b/internal/app/peek_cadence_test.go new file mode 100644 index 0000000..2637a1c --- /dev/null +++ b/internal/app/peek_cadence_test.go @@ -0,0 +1,87 @@ +package app + +import ( + "testing" + "time" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" +) + +func peekCadenceModel() Model { + m := NewWithDeps(nil, nil) + m.sessions = []adapter.Session{ + {ID: "one", AgentType: "fake", DisplayName: "one", ProcAlive: adapter.Alive}, + {ID: "two", AgentType: "fake", DisplayName: "two", ProcAlive: adapter.Alive}, + } + return m +} + +// C2-11 — Init must arm the peek-focus ticker so an open peek panel updates +// live, in addition to the session-refresh ticker. +func TestInitArmsPeekFocusTicker(t *testing.T) { + m := peekCadenceModel() + if m.Init() == nil { + t.Fatal("Init must return a command batch (refresh + peek-focus tickers)") + } + // A peek tick must always re-arm its ticker, even when the panel is closed, + // so the cadence never stops. + _, cmd := m.Update(peekTickMsg(time.Now())) + if cmd == nil { + t.Fatal("a peek tick must re-arm the peek-focus ticker unconditionally") + } +} + +// C2-11 — when the peek panel is open, a peek-focus tick re-fires the capture +// for the focused session, but only once per focus interval (1s) — keyed by +// session id, not row index, since rows reorder. +func TestPeekFocusTickRefiresFocusedSessionAtInterval(t *testing.T) { + m := peekCadenceModel() + m.peekOpen = true + m.selected = 0 + clock := time.Unix(1710000000, 0) + m.peekClock = func() time.Time { return clock } + + // First focus tick: focused session hasn't been polled → re-fire. + model, cmd := m.Update(peekTickMsg(clock)) + m = model.(Model) + if cmd == nil { + t.Fatal("first peek-focus tick with the panel open must re-fire the focused peek") + } + + // A tick within the focus interval: must NOT re-fire (only re-arm). + clock = clock.Add(500 * time.Millisecond) + m.peekClock = func() time.Time { return clock } + model, _ = m.Update(peekTickMsg(clock)) + m = model.(Model) + // Re-arming returns a non-nil cmd, so assert the focused capture was NOT + // stamped again by checking the gate directly. + if m.shouldPollFocusedPeek("one", clock) { + t.Fatal("a tick within the focus interval must not re-poll the focused session") + } + + // Past the focus interval: re-fire allowed again. + clock = clock.Add(1 * time.Second) + if !m.shouldPollFocusedPeek("one", clock) { + t.Fatal("past the focus interval the focused session must be pollable again") + } +} + +// C2-11 — a peek-focus tick with the panel closed must not capture anything +// (avoids a background N+1 capture storm). +func TestPeekFocusTickNoCaptureWhenPanelClosed(t *testing.T) { + m := peekCadenceModel() + m.peekOpen = false + clock := time.Unix(1710000000, 0) + m.peekClock = func() time.Time { return clock } + + model, cmd := m.Update(peekTickMsg(clock)) + m = model.(Model) + // The ticker is re-armed (non-nil), but no peek capture batched. Confirm via + // the focused-poll gate: with the panel closed nothing should be stamped. + if cmd == nil { + t.Fatal("peek ticker must re-arm even when closed") + } + if _, polled := m.lastPeekAt["one"]; polled { + t.Fatal("a closed panel must not poll/stamp any session") + } +} diff --git a/internal/app/pr_refresh_test.go b/internal/app/pr_refresh_test.go new file mode 100644 index 0000000..ca0fa1f --- /dev/null +++ b/internal/app/pr_refresh_test.go @@ -0,0 +1,93 @@ +package app + +import ( + "context" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" +) + +// writeFakeGH installs a fake `gh` binary at UAM_GH_BIN/PATH whose script body +// is given, returning nothing. Used to make pr.Check behavior deterministic. +func writeFakeGH(t *testing.T, body string) { + t.Helper() + dir := t.TempDir() + gh := filepath.Join(dir, "gh") + if err := os.WriteFile(gh, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("UAM_GH_BIN", gh) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +// F02 — when pr.Check times out (hung gh), updatePRRecord must still stamp +// LastChecked so the 60s guard arms and the next tick does NOT re-launch gh. +func TestUpdatePRRecordWritesLastCheckedOnTimeout(t *testing.T) { + // gh sleeps long enough to blow the per-check timeout. + writeFakeGH(t, "#!/bin/sh\nsleep 30\n") + + live := adapter.Session{ID: "aaaa1111", AgentType: "fake", DisplayName: "A", Cwd: "/tmp", TmuxSession: "uam-fake-aaaa1111", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now(), PR: &adapter.PRRef{URL: "https://github.com/o/r/pull/1", Number: 1, Status: adapter.PROpen}} + svc, st, _ := newLoadService(t, []adapter.Session{live}) + + before := time.Now() + if _, _, err := svc.LoadSessions(context.Background()); err != nil { + t.Fatalf("LoadSessions: %v", err) + } + cfg, err := st.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + rec, ok := cfg.Sessions[store.Key("fake", "aaaa1111")] + if !ok || rec.PR == nil { + t.Fatalf("PR record not persisted: %+v", cfg.Sessions) + } + if !rec.PR.LastChecked.After(before.Add(-time.Second)) { + t.Fatalf("LastChecked not stamped on the timeout path: %v", rec.PR.LastChecked) + } +} + +// F02 — concurrent LoadSessions must not stack overlapping pr.Check subprocesses. +// The fake gh uses an atomic mkdir lock to detect overlap: if a second gh runs +// while the first is still in flight, the lock fails and writes a sentinel. +func TestRefreshDoesNotStackConcurrentLoads(t *testing.T) { + dir := t.TempDir() + lockDir := filepath.Join(dir, "lock") + overlap := filepath.Join(dir, "overlap") + gh := filepath.Join(dir, "gh") + // mkdir is atomic: only one process can hold the lock at a time. If a second + // gh runs concurrently it can't create the dir, so it records overlap. + body := "#!/bin/sh\n" + + "if ! mkdir '" + lockDir + "' 2>/dev/null; then touch '" + overlap + "'; fi\n" + + "sleep 0.2\n" + + "rmdir '" + lockDir + "' 2>/dev/null\n" + + "echo '{\"state\":\"OPEN\",\"isDraft\":false,\"mergedAt\":null}'\n" + if err := os.WriteFile(gh, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("UAM_GH_BIN", gh) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + live := adapter.Session{ID: "bbbb2222", AgentType: "fake", DisplayName: "B", Cwd: "/tmp", TmuxSession: "uam-fake-bbbb2222", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now(), PR: &adapter.PRRef{URL: "https://github.com/o/r/pull/2", Number: 2, Status: adapter.PROpen}} + svc, _, _ := newLoadService(t, []adapter.Session{live}) + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if _, _, err := svc.LoadSessions(context.Background()); err != nil { + t.Errorf("LoadSessions: %v", err) + } + }() + } + wg.Wait() + + if _, err := os.Stat(overlap); err == nil { + t.Fatal("overlapping pr.Check subprocesses ran concurrently — in-flight guard missing") + } +} diff --git a/internal/app/refresh_inflight_test.go b/internal/app/refresh_inflight_test.go new file mode 100644 index 0000000..4312bea --- /dev/null +++ b/internal/app/refresh_inflight_test.go @@ -0,0 +1,63 @@ +package app + +import ( + "errors" + "testing" + "time" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" +) + +// F17 — a refresh tick must NOT dispatch a second loadSessionsCmd while one is +// already in flight, but it MUST keep re-arming the ticker unconditionally +// (otherwise refreshes stop forever after a single in-flight load). +func TestRefreshDoesNotReArmWhileLoadInFlight(t *testing.T) { + m := NewWithDeps(nil, nil) + + // First tick from idle: schedules a load and marks loading. + m1, started1 := m.refreshStep(time.Now()) + if !started1 { + t.Fatal("first refresh from idle must start a load") + } + if !m1.loading { + t.Fatal("first refresh must set loading=true") + } + + // Second tick while the first load is still in flight: must NOT start another + // load, but the returned model is still loading (tick is re-armed by Update). + m2, started2 := m1.refreshStep(time.Now()) + if started2 { + t.Fatal("refresh must not stack a second load while one is in flight") + } + if !m2.loading { + t.Fatal("loading must remain true until sessionsLoadedMsg clears it") + } + + // The Update wrapper must always re-arm the ticker, even while loading. + model, cmd := m1.Update(refreshMsg(time.Now())) + if cmd == nil { + t.Fatal("refresh tick must be re-armed unconditionally") + } + if !model.(Model).loading { + t.Fatal("loading must persist across an in-flight refresh tick") + } +} + +// F17 — sessionsLoadedMsg must clear the loading flag unconditionally, including +// the error path, or a single failed load wedges the ticker forever. +func TestSessionsLoadedAlwaysClearsInflightEvenOnError(t *testing.T) { + m := NewWithDeps(nil, nil) + m.loading = true + + model, _ := m.Update(sessionsLoadedMsg{err: errors.New("boom")}) + if model.(Model).loading { + t.Fatal("loading must be cleared even when sessionsLoadedMsg carries an error") + } + + m = NewWithDeps(nil, nil) + m.loading = true + model, _ = m.Update(sessionsLoadedMsg{sessions: []adapter.Session{{ID: "1"}}}) + if model.(Model).loading { + t.Fatal("loading must be cleared on a successful sessionsLoadedMsg") + } +} diff --git a/internal/app/registry_parity_test.go b/internal/app/registry_parity_test.go new file mode 100644 index 0000000..a563850 --- /dev/null +++ b/internal/app/registry_parity_test.go @@ -0,0 +1,47 @@ +package app + +import ( + "os" + "path/filepath" + "sort" + "testing" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/agents" + "github.com/RandomCodeSpace/unified-agent-manager/internal/tmux" +) + +// F14 — app.New builds its registry from the shared agents.Default list (it used +// to hand-roll one that omitted hermes). This asserts the TUI-side wiring sees +// the full five-provider set, the same set cli.NewService consumes, so the two +// can never drift again. Pre-availability Name() comparison: providers are +// stubbed on PATH so the registry actually enables them in CI. +func TestAppRegistryMatchesSharedAdapterSet(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{"claude", "codex", "copilot", "hermes", "opencode"} { + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + client := tmux.New("uam") + reg := adapter.NewRegistry(agents.Default(client)) + + got := make([]string, 0) + for _, a := range reg.Enabled() { + got = append(got, a.Name()) + } + sort.Strings(got) + + want := []string{"claude", "codex", "copilot", "hermes", "opencode"} + if len(got) != len(want) { + t.Fatalf("app registry %v != %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("app registry %v != %v", got, want) + } + } +} diff --git a/internal/app/rename_input_test.go b/internal/app/rename_input_test.go new file mode 100644 index 0000000..d49ce4c --- /dev/null +++ b/internal/app/rename_input_test.go @@ -0,0 +1,201 @@ +package app + +import ( + "path/filepath" + "testing" + "time" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" + tea "github.com/charmbracelet/bubbletea" +) + +// renameTestModel wires a real store backed by a fake adapter that lists two +// live sessions, so Service.Rename / Service.Stop resolve through the genuine +// Find path and persist against the correct store record. +func renameTestModel(t *testing.T, sessions []adapter.Session) (Model, *store.Store) { + t.Helper() + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "sessions.json")) + if err != nil { + t.Fatal(err) + } + fake := &svcFakeAdapter{name: "fake", available: true, sessions: sessions} + m := NewWithDeps(st, adapter.NewRegistry([]adapter.AgentAdapter{fake})) + m.sessions = append([]adapter.Session(nil), sessions...) + return m, st +} + +func recordName(t *testing.T, st *store.Store, agent, id string) string { + t.Helper() + cfg, err := st.Load() + if err != nil { + t.Fatal(err) + } + return cfg.Sessions[store.Key(agent, id)].Name +} + +// F27 — pressing Enter to confirm a rename must not panic when the session list +// has emptied (e.g. the renamed session was killed externally) between opening +// the modal and confirming. Clamping m.selected alone is still out-of-range on a +// zero-length slice, so the Enter branch must route through selectedSession(). +func TestRenameEnterOnEmptiedListDoesNotPanic(t *testing.T) { + m := NewWithDeps(nil, nil) + m.sessions = []adapter.Session{{ID: "1", AgentType: "fake", DisplayName: "old"}} + m.startRename() + if !m.renaming { + t.Fatal("expected rename mode to be active") + } + // The list empties out from under the modal. + m.sessions = nil + m.selected = 0 + + model, _ := m.handleRenameKey(tea.KeyMsg{Type: tea.KeyEnter}) + m = model.(Model) + if m.renaming { + t.Fatal("Enter on an emptied list should close the rename modal") + } + if m.input != "" { + t.Fatalf("Enter should clear the rename input, got %q", m.input) + } +} + +// C2-1 — a refresh that reorders the session list while the rename modal is open +// must not retarget the rename to whatever row now sits under m.selected. The +// target session id is snapshotted at startRename and resolved at Enter. +func TestRenameTargetsOriginalSessionAfterReorder(t *testing.T) { + live := []adapter.Session{ + {ID: "alpha", AgentType: "fake", DisplayName: "alpha", TmuxSession: "uam-fake-alpha", State: adapter.Active, CreatedAt: time.Now()}, + {ID: "beta", AgentType: "fake", DisplayName: "beta", TmuxSession: "uam-fake-beta", State: adapter.Active, CreatedAt: time.Now()}, + } + m, st := renameTestModel(t, live) + m.selected = 0 + m.startRename() + m.input = "alpha-renamed" + + // A refresh reorders the list: beta now sits under the cursor (index 0). + m.sessions = []adapter.Session{live[1], live[0]} + m.selected = 0 + + model, cmd := m.handleRenameKey(tea.KeyMsg{Type: tea.KeyEnter}) + m = model.(Model) + if cmd == nil { + t.Fatal("expected a rename command") + } + cmd() // execute the rename against the service + + if got := recordName(t, st, "fake", "alpha"); got != "alpha-renamed" { + t.Fatalf("rename should target the originally-selected session alpha, got name %q on alpha", got) + } + if got := recordName(t, st, "fake", "beta"); got == "alpha-renamed" { + t.Fatalf("rename leaked onto the reordered session beta: name=%q", got) + } +} + +// F29 — multibyte input and paste (a multi-rune KeyRunes) must reach the rename +// buffer verbatim. The old len(key)==1 byte check dropped both. +func TestRenameAcceptsMultibyteAndPaste(t *testing.T) { + cases := []struct { + name string + runes []rune + }{ + {"e-acute", []rune("é")}, + {"cjk", []rune("世界")}, + {"emoji", []rune("🚀")}, + {"paste", []rune("hello world")}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := NewWithDeps(nil, nil) + m.sessions = []adapter.Session{{ID: "1", DisplayName: "x"}} + m.renaming = true + m.input = "" + model, _ := m.handleRenameKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: tc.runes}) + m = model.(Model) + if m.input != string(tc.runes) { + t.Fatalf("rename input = %q, want %q", m.input, string(tc.runes)) + } + }) + } +} + +// F29 — Alt-chords (e.g. Alt+a) must not leak as literal text into the rename +// buffer. +func TestRenameIgnoresAltChord(t *testing.T) { + m := NewWithDeps(nil, nil) + m.sessions = []adapter.Session{{ID: "1", DisplayName: "x"}} + m.renaming = true + m.input = "" + model, _ := m.handleRenameKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a"), Alt: true}) + m = model.(Model) + if m.input != "" { + t.Fatalf("Alt+a should not type into the rename buffer, got %q", m.input) + } +} + +// F29 — the wizard prompt input must also accept multibyte/paste and ignore Alt. +func TestWizardInputAcceptsMultibyteAndIgnoresAlt(t *testing.T) { + m := NewWithDeps(nil, nil) + m.wizard = true + m.wizardStep = 2 + m.input = "" + model, _ := m.handleWizardKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("café 世界")}) + m = model.(Model) + if m.input != "café 世界" { + t.Fatalf("wizard input = %q, want %q", m.input, "café 世界") + } + model, _ = m.handleWizardKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("z"), Alt: true}) + m = model.(Model) + if m.input != "café 世界" { + t.Fatalf("Alt+z should not type into the wizard buffer, got %q", m.input) + } +} + +// F29 — the stop-confirm dialog must act on the session selected when it was +// opened, not whatever row a refresh reorder slid under the cursor. +func TestStopConfirmTargetsOriginalSessionAfterReorder(t *testing.T) { + live := []adapter.Session{ + {ID: "alpha", AgentType: "fake", DisplayName: "alpha", TmuxSession: "uam-fake-alpha", State: adapter.Active, CreatedAt: time.Now()}, + {ID: "beta", AgentType: "fake", DisplayName: "beta", TmuxSession: "uam-fake-beta", State: adapter.Active, CreatedAt: time.Now()}, + } + m, st := renameTestModel(t, live) + m.selected = 0 + // Seed both store records so a remove is observable. + if err := st.Update(func(cfg *store.Config) error { + cfg.Sessions[store.Key("fake", "alpha")] = RecordFromSession(live[0], store.ModeYolo) + cfg.Sessions[store.Key("fake", "beta")] = RecordFromSession(live[1], store.ModeYolo) + return nil + }); err != nil { + t.Fatal(err) + } + + // Open the confirm dialog on alpha (index 0). + if handled, cmd := m.handleActionKey("ctrl+x"); !handled || cmd != nil { + t.Fatalf("ctrl+x should open confirm: handled=%v cmd=%v", handled, cmd) + } + if !m.confirmStop { + t.Fatal("expected confirmStop") + } + + // Refresh reorders: beta now at index 0. + m.sessions = []adapter.Session{live[1], live[0]} + m.selected = 0 + + _, model, cmd := m.handleModalKey(tea.KeyMsg{Type: tea.KeyEnter}, "enter") + m = model.(Model) + if cmd == nil { + t.Fatal("expected a stop command from confirm Enter") + } + cmd() + + cfg, err := st.Load() + if err != nil { + t.Fatal(err) + } + if _, ok := cfg.Sessions[store.Key("fake", "alpha")]; ok { + t.Fatalf("stop should have removed the originally-confirmed session alpha; record still present") + } + if _, ok := cfg.Sessions[store.Key("fake", "beta")]; !ok { + t.Fatalf("stop targeted the reordered session beta instead of alpha") + } +} diff --git a/internal/app/render_cluster_test.go b/internal/app/render_cluster_test.go new file mode 100644 index 0000000..57e3884 --- /dev/null +++ b/internal/app/render_cluster_test.go @@ -0,0 +1,320 @@ +package app + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + "unicode/utf8" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var errTestBoom = errors.New("boom") + +func storeRecord(agent, id string) store.SessionRecord { + return store.SessionRecord{ID: id, Agent: agent, Name: id, Status: store.StatusActive} +} + +// readOnlyStore returns a store whose config dir is read-only after a warm-up +// write, so any subsequent Store.Update fails (used to drive the F55 error +// surfacing). It skips under root, where dir perms are not enforced. +func readOnlyStore(t *testing.T) *store.Store { + t.Helper() + if os.Geteuid() == 0 { + t.Skip("read-only dir is not enforced for root") + } + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "sessions.json")) + if err != nil { + t.Fatal(err) + } + // Warm-up write creates the lock + config files. + if err := st.Update(func(*store.Config) error { return nil }); err != nil { + t.Fatal(err) + } + if err := os.Chmod(dir, 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(dir, 0o700) }) + return st +} + +// F28 — truncate must measure display width (not byte length) so multibyte and +// wide (CJK/emoji) strings stay valid UTF-8 and never exceed the column budget. +func TestTruncateIsWidthAware(t *testing.T) { + cases := []struct { + name string + in string + n int + }{ + {"ascii-fits", "hello", 10}, + {"ascii-trunc", "abcdef", 4}, + {"accent", "café crème brûlée", 6}, + {"cjk", "你好世界你好世界", 5}, + {"emoji", "🚀🚀🚀🚀🚀🚀", 5}, + {"mixed", "fix 世界 bug 🚀 now", 8}, + {"zero", "anything", 0}, + {"one", "anything", 1}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := truncate(tc.in, tc.n) + if !utf8.ValidString(got) { + t.Fatalf("truncate(%q,%d)=%q is not valid UTF-8", tc.in, tc.n, got) + } + if w := lipgloss.Width(got); w > tc.n { + t.Fatalf("truncate(%q,%d)=%q has display width %d > %d", tc.in, tc.n, got, w, tc.n) + } + }) + } +} + +// F28 — truncate must not chop a multibyte rune mid-sequence (the old byte-slice +// path produced mojibake on the boundary). +func TestTruncateNeverEmitsMojibake(t *testing.T) { + in := "héllo wörld 世界" + for n := 0; n <= 20; n++ { + got := truncate(in, n) + if !utf8.ValidString(got) { + t.Fatalf("truncate(%q,%d)=%q is not valid UTF-8", in, n, got) + } + } +} + +// F28 — the task column must stay aligned even when a row's name contains wide +// characters: byte-length padding would push the task cell out of alignment. +func TestRenderRowAlignsTaskColumnForMultibyte(t *testing.T) { + asciiRow := renderRow(adapter.Session{ID: "1", DisplayName: "ascii", Prompt: "task", ProcAlive: adapter.Alive}, false, 14, 16, true) + wideRow := renderRow(adapter.Session{ID: "2", DisplayName: "世界世界", Prompt: "task", ProcAlive: adapter.Alive}, false, 14, 16, true) + // The cursor + glyph prefix is identical; the name cell must occupy the same + // number of display columns up to the task text in both rows. + asciiTaskIdx := lipgloss.Width(asciiRow[:strings.Index(asciiRow, "task")]) + wideTaskIdx := lipgloss.Width(wideRow[:strings.Index(wideRow, "task")]) + if asciiTaskIdx != wideTaskIdx { + t.Fatalf("task column misaligned for wide name: ascii col=%d wide col=%d\nascii=%q\nwide=%q", asciiTaskIdx, wideTaskIdx, asciiRow, wideRow) + } +} + +// F26 — the PR status dot must render in the row when the session has a PR, and +// the glyphs must be distinct per status (not color-only, so they survive a +// no-color terminal / screen-scrape). +func TestPRStatusDotGlyphsAreDistinct(t *testing.T) { + statuses := []adapter.PRStatus{adapter.PROpen, adapter.PRMerged, adapter.PRDraft, adapter.PRClosed} + seen := map[string]adapter.PRStatus{} + for _, st := range statuses { + g := strings.TrimSpace(prStatusDot(st)) + if g == "" { + t.Fatalf("status %q has no glyph", st) + } + if prev, ok := seen[g]; ok { + t.Fatalf("status %q and %q share glyph %q (not distinct)", prev, st, g) + } + seen[g] = st + } +} + +// F26 — a row whose session carries a PR must render its status dot; a PR-less +// row must not. +func TestRenderRowShowsPRDotOnlyWhenPRPresent(t *testing.T) { + withPR := renderRow(adapter.Session{ID: "1", DisplayName: "x", ProcAlive: adapter.Alive, PR: &adapter.PRRef{Status: adapter.PRMerged}}, false, 14, 16, true) + noPR := renderRow(adapter.Session{ID: "2", DisplayName: "x", ProcAlive: adapter.Alive}, false, 14, 16, true) + dot := strings.TrimSpace(prStatusDot(adapter.PRMerged)) + if !strings.Contains(withPR, dot) { + t.Fatalf("row with a PR should show its status dot %q: %q", dot, withPR) + } + if strings.Contains(noPR, dot) { + t.Fatalf("row without a PR should not show a PR dot: %q", noPR) + } +} + +// F26/F28 — adding the PR dot must not break column alignment between a row with +// a PR and a row without one. +func TestRenderRowColumnsAlignWithPRDot(t *testing.T) { + withPR := renderRow(adapter.Session{ID: "1", DisplayName: "name", Prompt: "task", ProcAlive: adapter.Alive, PR: &adapter.PRRef{Status: adapter.PROpen}}, false, 14, 16, true) + noPR := renderRow(adapter.Session{ID: "2", DisplayName: "name", Prompt: "task", ProcAlive: adapter.Alive}, false, 14, 16, true) + withIdx := lipgloss.Width(withPR[:strings.Index(withPR, "task")]) + noIdx := lipgloss.Width(noPR[:strings.Index(noPR, "task")]) + if withIdx != noIdx { + t.Fatalf("task column misaligned with/without PR dot: with=%d no=%d\nwith=%q\nno=%q", withIdx, noIdx, withPR, noPR) + } +} + +// F30 — a reboot-survivor dead session (Exited, not user-closed) must NOT show +// the red Failed glyph; it shows a neutral resumable glyph. A user-closed dead +// session and a live session each get their own glyph. +func TestStateGlyphDistinguishesResumableFromFailed(t *testing.T) { + live, _ := sessionGlyph(adapter.Session{ProcAlive: adapter.Alive}) + resumable, _ := sessionGlyph(adapter.Session{ProcAlive: adapter.Exited}) + closed, _ := sessionGlyph(adapter.Session{ProcAlive: adapter.Exited, Closed: true}) + if live == resumable { + t.Fatalf("a live session and a resumable dead session must use distinct glyphs (both %q)", live) + } + failGlyph, _ := stateGlyph(adapter.Failed) + if resumable == failGlyph { + t.Fatalf("a reboot-survivor resumable session must not use the red Failed glyph %q", failGlyph) + } + _ = closed +} + +// F30 — the rendered row for a reboot-survivor dead session must not contain the +// literal word "failed" nor the failure glyph. +func TestRenderRowResumableSessionNotMarkedFailed(t *testing.T) { + row := renderRow(adapter.Session{ID: "1", DisplayName: "rebooted", ProcAlive: adapter.Exited}, false, 14, 16, true) + if strings.Contains(strings.ToLower(row), "failed") { + t.Fatalf("resumable row should not show the literal \"failed\": %q", row) + } + failGlyph, _ := stateGlyph(adapter.Failed) + if strings.Contains(row, failGlyph) { + t.Fatalf("resumable row should not show the red Failed glyph %q: %q", failGlyph, row) + } +} + +// F30 — deadSessionFromRecord must not emit State=Active (the glyph is driven off +// ProcAlive/Closed, but the State invariant must hold for downstream code). +func TestDeadSessionFromRecordDoesNotEmitActiveState(t *testing.T) { + rec := storeRecord("a", "id1") + sess := deadSessionFromRecord(rec, time.Now()) + if sess.State == adapter.Active { + t.Fatalf("deadSessionFromRecord must not emit State=Active: %+v", sess) + } +} + +// F58 — the live/fail glyph styles are package-level vars (allocated once), not +// rebuilt per row per frame. +func TestGlyphStylesAreHoisted(t *testing.T) { + if _, ok := liveGlyphStyle.GetForeground().(lipgloss.AdaptiveColor); !ok { + t.Fatalf("liveGlyphStyle must keep an AdaptiveColor foreground, got %T", liveGlyphStyle.GetForeground()) + } + if _, ok := failGlyphStyle.GetForeground().(lipgloss.AdaptiveColor); !ok { + t.Fatalf("failGlyphStyle must keep an AdaptiveColor foreground, got %T", failGlyphStyle.GetForeground()) + } +} + +// C2-2 — moving the cursor up/down while the peek panel is open must re-fire the +// peek for the newly selected session and blank the stale text synchronously. +func TestUpDownRefiresPeekWhenPanelOpen(t *testing.T) { + m := NewWithDeps(nil, nil) + m.sessions = []adapter.Session{ + {ID: "1", AgentType: "fake", DisplayName: "one", ProcAlive: adapter.Alive}, + {ID: "2", AgentType: "fake", DisplayName: "two", ProcAlive: adapter.Alive}, + } + m.peekOpen = true + m.peekText = "stale tail from session one" + + handled, cmd := m.handleMovementKey("down") + if !handled { + t.Fatal("down should be handled") + } + if m.selected != 1 { + t.Fatalf("selection should advance to 1, got %d", m.selected) + } + if m.peekText != "" { + t.Fatalf("peek text should be blanked synchronously on move, got %q", m.peekText) + } + if cmd == nil { + t.Fatal("moving with the peek panel open should re-fire the peek command") + } +} + +// C2-2 — with the panel closed, up/down must NOT fire a peek (avoids an N+1 +// capture storm on every keystroke). +func TestUpDownDoesNotPeekWhenPanelClosed(t *testing.T) { + m := NewWithDeps(nil, nil) + m.sessions = []adapter.Session{ + {ID: "1", AgentType: "fake", DisplayName: "one", ProcAlive: adapter.Alive}, + {ID: "2", AgentType: "fake", DisplayName: "two", ProcAlive: adapter.Alive}, + } + m.peekOpen = false + + if _, cmd := m.handleMovementKey("down"); cmd != nil { + t.Fatalf("down with the peek panel closed should not fire a peek command, got %v", cmd) + } + if _, cmd := m.handleMovementKey("up"); cmd != nil { + t.Fatalf("up with the peek panel closed should not fire a peek command, got %v", cmd) + } +} + +// C2-2 — moving onto the same row (boundary no-op) must not re-fire the peek. +func TestPeekNotRefiredWhenSelectionUnchanged(t *testing.T) { + m := NewWithDeps(nil, nil) + m.sessions = []adapter.Session{{ID: "1", AgentType: "fake", DisplayName: "one", ProcAlive: adapter.Alive}} + m.peekOpen = true + m.peekText = "tail" + if _, cmd := m.handleMovementKey("up"); cmd != nil { // already at top → no-op + t.Fatalf("a no-op move should not re-fire the peek, got %v", cmd) + } + if m.peekText != "tail" { + t.Fatalf("a no-op move should not blank the peek text, got %q", m.peekText) + } +} + +// F53 — a message must persist across short refresh ticks and only expire after +// the TTL has elapsed. +func TestMessageExpiresOnlyAfterTTL(t *testing.T) { + m := NewWithDeps(nil, nil) + m.message = "just happened" + m.messageSetAt = time.Now() + + // A refresh tick well within the TTL must not clear the message. + model, _ := m.Update(refreshMsg(time.Now().Add(messageTTL / 2))) + m = model.(Model) + if m.message != "just happened" { + t.Fatalf("message cleared too early: %q", m.message) + } + + // A refresh tick past the TTL must clear it. + model, _ = m.Update(refreshMsg(time.Now().Add(messageTTL + time.Second))) + m = model.(Model) + if m.message != "" { + t.Fatalf("message should expire after the TTL, got %q", m.message) + } +} + +// F53 — a freshly emitted message must not be wiped by the very next 2s tick. +func TestFreshMessageSurvivesNextTick(t *testing.T) { + m := NewWithDeps(nil, nil) + // Simulate handleSessionsLoaded stamping a message right before a tick. + model, _ := m.Update(sessionsLoadedMsg{err: errTestBoom}) + m = model.(Model) + if m.message == "" { + t.Fatal("error should populate the status line") + } + model, _ = m.Update(refreshMsg(time.Now())) + m = model.(Model) + if m.message == "" { + t.Fatal("a just-emitted error must survive the next refresh tick (no blanket clear)") + } +} + +// F55 — a failed SetDefaultAgent (Tab) must surface the error in the status line +// instead of silently reporting success. +func TestTabSurfacesSetDefaultAgentError(t *testing.T) { + st := readOnlyStore(t) + m := NewWithDeps(st, adapter.NewRegistry([]adapter.AgentAdapter{ + &svcFakeAdapter{name: "a", available: true}, + &svcFakeAdapter{name: "b", available: true}, + })) + m.defaultAgent = "a" + model, _ := m.handleKey(keyMsg("tab")) + m = model.(Model) + if m.message == "" { + t.Fatal("a failed SetDefaultAgent must surface an error in the status line") + } +} + +// F55 — a failed SetUI (Ctrl+S group-by-dir toggle) must surface the error. +func TestGroupByDirToggleSurfacesSetUIError(t *testing.T) { + st := readOnlyStore(t) + m := NewWithDeps(st, adapter.NewRegistry([]adapter.AgentAdapter{&svcFakeAdapter{name: "a", available: true}})) + model, _ := m.handleKey(tea.KeyMsg{Type: tea.KeyCtrlS}) + m = model.(Model) + if m.message == "" { + t.Fatal("a failed SetUI must surface an error in the status line") + } +} diff --git a/internal/app/reorder_debounce_test.go b/internal/app/reorder_debounce_test.go new file mode 100644 index 0000000..90f7067 --- /dev/null +++ b/internal/app/reorder_debounce_test.go @@ -0,0 +1,112 @@ +package app + +import ( + "path/filepath" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" +) + +func twoLiveSessionModel(t *testing.T) (Model, *store.Store) { + t.Helper() + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "sessions.json")) + if err != nil { + t.Fatal(err) + } + fake := &svcFakeAdapter{name: "fake", available: true} + m := NewWithDeps(st, adapter.NewRegistry([]adapter.AgentAdapter{fake})) + m.sessions = []adapter.Session{ + {ID: "a", AgentType: "fake", DisplayName: "a", ProcAlive: adapter.Alive}, + {ID: "b", AgentType: "fake", DisplayName: "b", ProcAlive: adapter.Alive}, + } + return m, st +} + +// F59 — a reorder must not persist synchronously; it schedules a debounced +// flush keyed by a sequence number. A flush carrying a stale seq (a newer move +// arrived) must be dropped, so a held Shift+arrow coalesces into one write. +func TestReorderDebounceDropsStaleFlush(t *testing.T) { + m, _ := twoLiveSessionModel(t) + m.selected = 0 + + cmd := m.moveSession(1) + if cmd == nil { + t.Fatal("a within-partition move should schedule a debounced flush") + } + firstSeq := m.reorderSeq + + // A second move (e.g. Shift held) bumps the seq again before the first + // flush tick fires. + m.selected = 0 + m.sessions[0], m.sessions[1] = m.sessions[1], m.sessions[0] + m.moveSession(1) + if m.reorderSeq == firstSeq { + t.Fatal("a second move must bump the reorder seq") + } + + // The first (stale) flush tick must be dropped. + model, flushCmd := m.Update(reorderFlushMsg{seq: firstSeq}) + m = model.(Model) + if flushCmd != nil { + t.Fatal("a stale reorder flush must not persist") + } + + // The current flush tick persists. + model, flushCmd = m.Update(reorderFlushMsg{seq: m.reorderSeq}) + m = model.(Model) + if flushCmd == nil { + t.Fatal("the current reorder flush must persist the order") + } +} + +// F59 — quitting with a reorder still pending must flush it (the debounce timer +// hasn't fired yet) so the manual order isn't lost. +func TestQuitFlushesPendingReorder(t *testing.T) { + m, st := twoLiveSessionModel(t) + m.selected = 0 + if cmd := m.moveSession(1); cmd == nil { + t.Fatal("move should schedule a flush") + } + if !m.reorderPending { + t.Fatal("a scheduled-but-unflushed reorder must be marked pending") + } + + model, cmd := m.handleKey(keyMsg("ctrl+c")) + m = model.(Model) + if !m.quitting { + t.Fatal("ctrl+c should quit") + } + if cmd == nil { + t.Fatal("quit must batch a flush of the pending reorder") + } + // Drain the quit batch so the flush actually runs against the store. + drainCmd(cmd) + + cfg, err := st.Load() + if err != nil { + t.Fatal(err) + } + recB := cfg.Sessions[store.Key("fake", "b")] + recA := cfg.Sessions[store.Key("fake", "a")] + if recB.SortIndex != 0 || recA.SortIndex != 1 { + t.Fatalf("pending reorder must be flushed on quit: a=%d b=%d", recA.SortIndex, recB.SortIndex) + } +} + +// drainCmd executes a tea.Cmd and recursively runs any batched children so its +// side effects against the store run synchronously in the test. +func drainCmd(cmd tea.Cmd) { + if cmd == nil { + return + } + msg := cmd() + if batch, ok := msg.(tea.BatchMsg); ok { + for _, c := range batch { + drainCmd(c) + } + } +} diff --git a/internal/app/reorder_partition_test.go b/internal/app/reorder_partition_test.go new file mode 100644 index 0000000..0c9784c --- /dev/null +++ b/internal/app/reorder_partition_test.go @@ -0,0 +1,79 @@ +package app + +import ( + "testing" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" +) + +// F34 — Shift+up/down must not move a row across the Active/Closed (or pinned) +// partition boundary: SortSessions re-buckets by Closed then Pinned on the next +// refresh, so a cross-partition swap silently reverts. The move is a no-op at +// the boundary with honest feedback, and the rows stay put. +func TestMoveSessionAcrossActiveClosedBoundaryIsNoOp(t *testing.T) { + m := NewWithDeps(nil, nil) + m.sessions = []adapter.Session{ + {ID: "active", AgentType: "fake", DisplayName: "active", ProcAlive: adapter.Alive}, + {ID: "closed", AgentType: "fake", DisplayName: "closed", ProcAlive: adapter.Exited, Closed: true}, + } + m.selected = 0 + cmd := m.moveSession(1) // would cross into the Closed partition + if cmd != nil { + t.Fatal("a cross-partition move must not persist a new order") + } + if m.selected != 0 { + t.Fatalf("selection must stay put on a boundary move, got %d", m.selected) + } + if m.sessions[0].ID != "active" || m.sessions[1].ID != "closed" { + t.Fatalf("rows must not swap across the partition boundary: %+v", m.sessions) + } + if m.message == "" { + t.Fatal("a boundary move should give honest feedback, not silently no-op") + } +} + +// F34 — a pinned/unpinned boundary is also a partition boundary: SortSessions +// sorts Pinned above unpinned within the Active group, so swapping a pinned and +// an unpinned row would revert on refresh too. +func TestMoveSessionAcrossPinnedBoundaryIsNoOp(t *testing.T) { + m := NewWithDeps(nil, nil) + m.sessions = []adapter.Session{ + {ID: "pinned", AgentType: "fake", DisplayName: "pinned", ProcAlive: adapter.Alive, Pinned: true}, + {ID: "plain", AgentType: "fake", DisplayName: "plain", ProcAlive: adapter.Alive}, + } + m.selected = 0 + if cmd := m.moveSession(1); cmd != nil { + t.Fatal("a pinned/unpinned boundary move must not persist a new order") + } + if m.sessions[0].ID != "pinned" || m.sessions[1].ID != "plain" { + t.Fatalf("rows must not swap across the pinned boundary: %+v", m.sessions) + } +} + +// F34 — a within-partition move must still swap and persist, and the resulting +// SortIndex assignment must encode the new within-partition order so the move +// survives the next refresh. +func TestSortIndexEncodesWithinPartitionOrder(t *testing.T) { + m := NewWithDeps(nil, nil) + m.sessions = []adapter.Session{ + {ID: "a", AgentType: "fake", DisplayName: "a", ProcAlive: adapter.Alive}, + {ID: "b", AgentType: "fake", DisplayName: "b", ProcAlive: adapter.Alive}, + } + m.selected = 0 + cmd := m.moveSession(1) // same partition: legal swap + if cmd == nil { + t.Fatal("a within-partition move must persist the new order") + } + if m.selected != 1 || m.sessions[0].ID != "b" || m.sessions[1].ID != "a" { + t.Fatalf("within-partition move should swap rows: selected=%d %+v", m.selected, m.sessions) + } + // The persisted order, fed back through SortSessions, must keep b before a. + out := append([]adapter.Session(nil), m.sessions...) + for i := range out { + out[i].SortIndex = i + } + SortSessions(out) + if out[0].ID != "b" || out[1].ID != "a" { + t.Fatalf("SortIndex must encode within-partition order, got %+v", out) + } +} diff --git a/internal/app/reply_test.go b/internal/app/reply_test.go new file mode 100644 index 0000000..3bf7654 --- /dev/null +++ b/internal/app/reply_test.go @@ -0,0 +1,144 @@ +package app + +import ( + "path/filepath" + "strings" + "testing" + "time" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" +) + +func replyTestModel(t *testing.T) (Model, *svcFakeAdapter) { + t.Helper() + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "sessions.json")) + if err != nil { + t.Fatal(err) + } + sessions := []adapter.Session{{ID: "abc12345", AgentType: "fake", DisplayName: "live", Cwd: "/tmp", TmuxSession: "uam-fake-abc12345", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()}} + fake := &svcFakeAdapter{name: "fake", available: true, sessions: sessions} + m := NewWithDeps(st, adapter.NewRegistry([]adapter.AgentAdapter{fake})) + m.sessions = append([]adapter.Session(nil), sessions...) + m.defaultAgent = "fake" + return m, fake +} + +// F36 — when the peek panel is open, typing text and pressing Enter must send a +// reply to the peeked session via Service.Reply, not dispatch a new agent. The +// check must come before the dispatch/attach branch. +func TestReplyFromPeekSendsToService(t *testing.T) { + m, fake := replyTestModel(t) + m.peekOpen = true + m.peekTargetID = "abc12345" + m.input = "please continue" + + handled, cmd := m.handleActionKey("enter") + if !handled { + t.Fatal("enter should be handled while peek is open") + } + if cmd == nil { + t.Fatal("expected a reply command") + } + cmd() // run the reply against the service + if fake.replied != "please continue" { + t.Fatalf("reply text = %q, want %q", fake.replied, "please continue") + } +} + +// F36 — sending a reply clears the input so the composer is ready for the next +// line, and the peek panel stays open (re-peeked) to show the agent's response. +func TestReplyFromPeekClearsInputAndKeepsPeekOpen(t *testing.T) { + m, _ := replyTestModel(t) + m.peekOpen = true + m.peekTargetID = "abc12345" + m.input = "hi" + + _, cmd := m.handleActionKey("enter") + if cmd == nil { + t.Fatal("expected a reply command") + } + cmd() + // handleActionKey returns a *copy* via value receiver semantics on Model; the + // model mutated in-place because handleActionKey has a pointer receiver. + if m.input != "" { + t.Fatalf("reply should clear the input, got %q", m.input) + } + if !m.peekOpen { + t.Fatal("peek panel should stay open after sending a reply") + } +} + +// F36 — Esc while peek is open with a half-typed reply closes the panel WITHOUT +// sending. No reply must reach the service. +func TestReplyEscCancelsWithoutSending(t *testing.T) { + m, fake := replyTestModel(t) + m.peekOpen = true + m.peekTargetID = "abc12345" + m.input = "unsent draft" + + handled, _ := m.handleActionKey("esc") + if !handled { + t.Fatal("esc should be handled") + } + if m.peekOpen { + t.Fatal("esc should close the peek panel") + } + if fake.replied != "" { + t.Fatalf("esc must not send a reply, got %q", fake.replied) + } +} + +// F36 — pressing Enter with an empty input while peek is open must not send an +// empty reply; it falls through to the normal Enter behavior. +func TestReplyEmptyInputDoesNotSend(t *testing.T) { + m, fake := replyTestModel(t) + m.peekOpen = true + m.peekTargetID = "abc12345" + m.input = "" + + _, cmd := m.handleActionKey("enter") + if cmd != nil { + // An attach command may be returned; just make sure nothing replied. + cmd() + } + if fake.replied != "" { + t.Fatalf("empty input must not send a reply, got %q", fake.replied) + } +} + +// F36 — when the peek panel is open the prompt line presents itself as a reply +// composer so the sub-mode is discoverable, not a silent overload of the +// dispatch line. +func TestPeekPromptShowsReplyComposer(t *testing.T) { + m, _ := replyTestModel(t) + m.width = 90 + m.peekOpen = true + m.peekTargetID = "abc12345" + + out := m.renderPrompt() + if !strings.Contains(out, "reply") { + t.Fatalf("peek-open prompt should label itself a reply composer: %q", out) + } +} + +// F36 — with the peek panel CLOSED, typed-input + Enter must still dispatch a new +// session, not reply (the existing behavior must be preserved). +func TestEnterWithoutPeekStillDispatches(t *testing.T) { + m, fake := replyTestModel(t) + m.peekOpen = false + m.input = "@fake new task" + + _, cmd := m.handleActionKey("enter") + if cmd == nil { + t.Fatal("expected a dispatch command") + } + msg := cmd() + if _, ok := msg.(dispatchedMsg); !ok { + t.Fatalf("expected dispatchedMsg, got %T", msg) + } + if fake.replied != "" { + t.Fatalf("dispatch path must not reply, got %q", fake.replied) + } +} diff --git a/internal/app/service.go b/internal/app/service.go index fb9c2d3..f7e6f18 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -8,9 +8,11 @@ import ( "os" "sort" "strings" + "sync/atomic" "time" "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/log" "github.com/RandomCodeSpace/unified-agent-manager/internal/pr" "github.com/RandomCodeSpace/unified-agent-manager/internal/store" ) @@ -18,10 +20,33 @@ import ( type Service struct { Store *store.Store Registry *adapter.Registry + // prInFlight guards the PR-check pass of a refresh so overlapping refreshes + // (stacked 2s ticks) do not launch concurrent `gh` subprocesses. A refresh + // that fails to acquire it skips the network check and keeps the persisted + // PR record; the holder clears it on completion, success or error (F02). + prInFlight atomic.Bool } const agentUnavailableFormat = "agent %q unavailable" +// lastSeenRefresh is how stale a live session's persisted LastSeenAt may grow +// before a refresh re-stamps it. Bumping every tick would write the store on +// every 2s refresh; gating on staleness keeps LastSeenAt fresh enough that +// PruneOld never deletes a still-running session while avoiding a write-storm +// (F20 Stage 1, paired with the idempotent-refresh contract). +const lastSeenRefresh = time.Minute + +// pruneMaxAge is the age past which a dead-pane record is eligible for startup +// pruning so sessions.json does not grow unbounded. It is deliberately long: a +// record is only removed when its pane is gone AND it has not been seen for this +// long (F20 Stage 2). +const pruneMaxAge = 30 * 24 * time.Hour + +// prCheckTimeout bounds a single `gh pr view` call so a hung gh cannot wedge the +// 2s refresh. It is comfortably under the 60s PR re-check interval, so a +// timed-out check just defers to the next tick (F02). +const prCheckTimeout = 4 * time.Second + func NewService(st *store.Store, reg *adapter.Registry) *Service { return &Service{Store: st, Registry: reg} } @@ -33,15 +58,65 @@ func (s *Service) LoadSessions(ctx context.Context) ([]adapter.Session, store.Co } live := s.liveSessions(ctx) s.mergeStoredSessions(live, cfg, time.Now()) - mutated := s.refreshSessionRecords(ctx, live, &cfg) - if mutated && s.Store != nil { - _ = s.Store.Save(cfg) - } + // refreshSessionRecords runs pr.Check OUTSIDE any store lock and returns the + // records this refresh owns. persistRefresh re-reads inside the flock and + // writes only those keys, so a concurrent TogglePin/Rename on a different + // session is never clobbered by a stale whole-config Save (F01). + updates := s.refreshSessionRecords(ctx, live, &cfg) + s.persistRefresh(updates) out := sessionsFromMap(live) SortSessions(out) return out, cfg, nil } +// PruneStartup removes long-stale, dead-pane records from the store so +// sessions.json does not grow unbounded. It is server-down-safe: a tmux server +// that is down looks exactly like an empty one (zero live sessions), so with no +// live session to prove the server is up it skips pruning entirely rather than +// risk wiping every record during a transient outage. When at least one live +// session is visible the server is up, and PruneOld then drops records whose +// pane is gone and that have not been seen within pruneMaxAge (F20 Stage 2). +func (s *Service) PruneStartup(ctx context.Context) error { + if s.Store == nil { + return nil + } + live := s.liveSessions(ctx) + if len(live) == 0 { + // Unknown server state — don't prune. + return nil + } + liveTmux := make(map[string]struct{}, len(live)) + for _, sess := range live { + if sess.TmuxSession != "" { + liveTmux[sess.TmuxSession] = struct{}{} + } + } + exists := func(tmuxName string) bool { + _, ok := liveTmux[tmuxName] + return ok + } + return s.Store.Update(func(cfg *store.Config) error { + store.PruneOld(cfg, pruneMaxAge, exists) + return nil + }) +} + +// persistRefresh writes the records owned by a refresh back to the store via an +// atomic read-modify-write. It re-reads the on-disk config inside the lock and +// overwrites only the supplied keys, leaving every other record (and any +// concurrent mutation to it) intact. +func (s *Service) persistRefresh(updates map[string]store.SessionRecord) { + if len(updates) == 0 || s.Store == nil { + return + } + _ = s.Store.Update(func(cfg *store.Config) error { + for key, rec := range updates { + cfg.Sessions[key] = rec + } + return nil + }) +} + func (s *Service) loadConfig() (store.Config, error) { cfg := store.DefaultConfig() if s.Store == nil { @@ -58,6 +133,9 @@ func (s *Service) liveSessions(ctx context.Context) map[string]adapter.Session { for _, a := range s.Registry.Enabled() { sessions, err := a.List(ctx) if err != nil { + // One adapter's List failure must not blank the dashboard for the + // others, but it shouldn't vanish silently either (F12). + log.Warn("listing sessions for adapter failed", "agent", a.Name(), "error", err) continue } for _, sess := range sessions { @@ -83,9 +161,22 @@ func mergeStoredMetadata(sess adapter.Session, rec store.SessionRecord) adapter. sess.Pinned = rec.Pinned sess.Group = rec.Group sess.SortIndex = rec.SortIndex - sess.Closed = rec.Status == store.StatusClosedByUser + // Closed implies the pane has exited: a user-closed record whose pane is + // still alive renders under ACTIVE (the refresh reconciles its persisted + // status back to Active). This keeps a live session from showing under + // CLOSED with a live glyph (F18). + sess.Closed = rec.Status == store.StatusClosedByUser && sess.ProcAlive == adapter.Exited if rec.PR != nil && sess.PR == nil { - sess.PR = &adapter.PRRef{URL: rec.PR.URL, Number: rec.PR.Number, Status: adapter.PRStatus(rec.PR.LastStatus)} + // The store schema does not persist Owner/Repo, but the URL is lossless: + // re-derive them via the same GitHub PR regex the adapter uses so a + // store round-trip never strips them (C2-7). Keep the persisted + // Number/Status as the source of truth. + ref := &adapter.PRRef{URL: rec.PR.URL, Number: rec.PR.Number, Status: adapter.PRStatus(rec.PR.LastStatus)} + if derived := adapter.ExtractPR(rec.PR.URL); derived != nil { + ref.Owner = derived.Owner + ref.Repo = derived.Repo + } + sess.PR = ref } return sess } @@ -94,21 +185,62 @@ func deadSessionFromRecord(rec store.SessionRecord, now time.Time) adapter.Sessi return adapter.Session{ID: rec.ID, AgentType: rec.Agent, DisplayName: rec.Name, Prompt: rec.Prompt, Cwd: rec.Workdir, TmuxSession: rec.TmuxSession, State: adapter.Failed, ProcAlive: adapter.Exited, CreatedAt: rec.CreatedAt, LastChange: now, Pinned: rec.Pinned, Group: rec.Group, SortIndex: rec.SortIndex, Closed: rec.Status == store.StatusClosedByUser} } -func (s *Service) refreshSessionRecords(ctx context.Context, live map[string]adapter.Session, cfg *store.Config) bool { - mutated := false +// refreshSessionRecords reconciles live sessions against the loaded config and +// returns the records this refresh wants to persist (keyed by store key). It +// also updates the in-memory cfg and live maps so the returned snapshot is +// consistent. pr.Check runs here, OUTSIDE any store lock; persistence happens +// later via persistRefresh's atomic re-read. +func (s *Service) refreshSessionRecords(ctx context.Context, live map[string]adapter.Session, cfg *store.Config) map[string]store.SessionRecord { + updates := map[string]store.SessionRecord{} now := time.Now() + // Acquire the PR-check guard for the duration of this refresh's network pass. + // If another refresh already holds it, skip the gh calls (the persisted PR + // record is kept) so stacked ticks never launch overlapping gh subprocesses. + // Released unconditionally on completion so a single pass never wedges the + // guard (F02). + doPR := s.prInFlight.CompareAndSwap(false, true) + if doPR { + defer s.prInFlight.Store(false) + } for key, sess := range live { rec, ok := cfg.Sessions[key] + changed := false if !ok || rec.ID == "" { + // Backfill a record for a live session we have no (valid) record + // for. Guard against an empty session ID so we never persist an + // un-killable phantom (C1-1 fix-trap). + if sess.ID == "" { + continue + } rec = RecordFromSession(sess, store.ModeYolo) - mutated = true + cfg.PutSession(key, rec) + changed = true + } else if rec.Status == store.StatusClosedByUser && sess.ProcAlive == adapter.Alive { + // Anti-flap: the user closed this session but its pane is still + // alive, so it belongs in the Active group. Reset the persisted + // status to Active, otherwise the next merge re-flags it Closed + // and it flaps (F18). + rec.Status = store.StatusActive + cfg.Sessions[key] = rec + changed = true } - if updatePRRecord(ctx, key, &sess, &rec, live, now) { + // Keep LastSeenAt fresh for a still-running pane so staleness-based + // pruning never deletes a live session. Gated on the refresh interval so + // a long-lived session isn't re-saved on every tick (F20 Stage 1). + if sess.ProcAlive == adapter.Alive && now.Sub(rec.LastSeenAt) >= lastSeenRefresh { + rec.LastSeenAt = now cfg.Sessions[key] = rec - mutated = true + changed = true + } + if doPR && updatePRRecord(ctx, key, &sess, &rec, live, now) { + cfg.Sessions[key] = rec + changed = true + } + if changed { + updates[key] = rec } } - return mutated + return updates } func updatePRRecord(ctx context.Context, key string, sess *adapter.Session, rec *store.SessionRecord, live map[string]adapter.Session, now time.Time) bool { @@ -116,7 +248,13 @@ func updatePRRecord(ctx context.Context, key string, sess *adapter.Session, rec return false } status := string(sess.PR.Status) - if checked, err := pr.Check(ctx, sess.PR.URL); err == nil && checked != pr.None { + // Bound the gh call so a hung process cannot wedge the refresh. On timeout + // (or any error) the status is left at the last-known value and LastChecked is + // stamped below, arming the 60s guard so the next tick does not immediately + // re-launch gh (F02). + checkCtx, cancel := context.WithTimeout(ctx, prCheckTimeout) + defer cancel() + if checked, err := pr.Check(checkCtx, sess.PR.URL); err == nil && checked != pr.None { status = string(checked) sess.PR.Status = adapter.PRStatus(status) live[key] = *sess @@ -164,10 +302,6 @@ func SortSessions(sessions []adapter.Session) { }) } -func (s *Service) Dispatch(ctx context.Context, agentName, prompt, cwd, mode string) (adapter.Session, error) { - return s.DispatchNamed(ctx, agentName, "", prompt, cwd, mode) -} - func (s *Service) DispatchNamed(ctx context.Context, agentName, name, prompt, cwd, mode string) (adapter.Session, error) { if s.Registry == nil { return adapter.Session{}, errors.New("no registry configured") @@ -185,7 +319,15 @@ func (s *Service) DispatchNamed(ctx context.Context, agentName, name, prompt, cw } if s.Store != nil { rec := RecordFromSession(sess, store.Mode(mode)) - _ = s.Store.Update(func(cfg *store.Config) error { cfg.Sessions[store.Key(sess.AgentType, sess.ID)] = rec; return nil }) + if err := s.Store.Update(func(cfg *store.Config) error { + cfg.PutSession(store.Key(sess.AgentType, sess.ID), rec) + return nil + }); err != nil { + // Advisory only: the session is live. Killing it because the store + // hiccupped would discard the user's work; instead surface the live + // session plus a wrapped warning so the caller can still attach. + return sess, fmt.Errorf("dispatched %s but failed to persist its record: %w", sess.ID, err) + } } return sess, nil } @@ -197,7 +339,17 @@ func (s *Service) Stop(ctx context.Context, id string, remove bool) error { } if s.Registry != nil { if a, ok := s.Registry.Get(sess.AgentType); ok && sess.TmuxSession != "" { - _ = a.Stop(ctx, sess.ID) + if killErr := a.Stop(ctx, sess.ID); killErr != nil { + // The kill failed. If the pane is still alive, deleting/flagging + // the record would orphan a running session whose only handle is + // that record — so abort the mutation and surface the error. + // If the pane is already gone (probe says not-alive, or the + // adapter can't probe), proceed: this preserves `uam rm` cleanup + // of already-dead sessions. + if hs, ok := a.(adapter.HasSessionAdapter); ok && hs.HasSession(ctx, sess.ID) { + return fmt.Errorf("kill session %s: %w", sess.ID, killErr) + } + } } } if s.Store == nil { @@ -257,12 +409,7 @@ func (s *Service) Rename(ctx context.Context, id, name string) error { if err != nil { return err } - return s.Store.Update(func(cfg *store.Config) error { - rec := cfg.Sessions[store.Key(sess.AgentType, sess.ID)] - rec.Name = name - cfg.Sessions[store.Key(sess.AgentType, sess.ID)] = rec - return nil - }) + return s.applyRecordMutation(sess, func(rec *store.SessionRecord) { rec.Name = name }) } func (s *Service) TogglePin(ctx context.Context, id string) error { @@ -270,10 +417,26 @@ func (s *Service) TogglePin(ctx context.Context, id string) error { if err != nil { return err } + return s.applyRecordMutation(sess, func(rec *store.SessionRecord) { rec.Pinned = !rec.Pinned }) +} + +// applyRecordMutation applies mut to the store record for sess inside an atomic +// read-modify-write. If the record is missing (or a zero-value phantom with no +// ID) it is backfilled from the live session first, so Rename/TogglePin never +// persist an empty-ID record that would be un-killable once the pane dies +// (C1-2). It mirrors ResumeBackground's RecordFromSession backfill. +func (s *Service) applyRecordMutation(sess adapter.Session, mut func(*store.SessionRecord)) error { + if s.Store == nil { + return nil + } return s.Store.Update(func(cfg *store.Config) error { - rec := cfg.Sessions[store.Key(sess.AgentType, sess.ID)] - rec.Pinned = !rec.Pinned - cfg.Sessions[store.Key(sess.AgentType, sess.ID)] = rec + key := store.Key(sess.AgentType, sess.ID) + rec := cfg.Sessions[key] + if rec.ID == "" { + rec = RecordFromSession(sess, store.ModeYolo) + } + mut(&rec) + cfg.Sessions[key] = rec return nil }) } diff --git a/internal/app/service_test.go b/internal/app/service_test.go index 154bdd2..1486088 100644 --- a/internal/app/service_test.go +++ b/internal/app/service_test.go @@ -22,6 +22,13 @@ type svcFakeAdapter struct { stopped bool replied string resumed *adapter.ResumeRequest + // F04: simulate a failed kill (stopErr) and a still-live pane (alive). The + // fake implements adapter.HasSessionAdapter, returning alive from HasSession. + stopErr error + alive bool + // F12: simulate a per-adapter List failure so liveSessions can be tested for + // logging-then-continue (one bad adapter must not blank the dashboard). + listErr error } func (f *svcFakeAdapter) Name() string { return f.name } @@ -42,7 +49,9 @@ func (f *svcFakeAdapter) Resume(ctx adapter.Context, req adapter.ResumeRequest) f.resumed = &req return adapter.Session{ID: req.ID, AgentType: f.name, DisplayName: req.Name, Prompt: req.Prompt, Cwd: req.Cwd, TmuxSession: req.TmuxSession, State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()}, nil } -func (f *svcFakeAdapter) List(ctx adapter.Context) ([]adapter.Session, error) { return f.sessions, nil } +func (f *svcFakeAdapter) List(ctx adapter.Context) ([]adapter.Session, error) { + return f.sessions, f.listErr +} func (f *svcFakeAdapter) Peek(ctx adapter.Context, id string) (adapter.PeekResult, error) { return adapter.PeekResult{TailText: "tail"}, nil } @@ -53,11 +62,11 @@ func (f *svcFakeAdapter) Reply(ctx adapter.Context, id, text string) error { func (f *svcFakeAdapter) Attach(id string) (adapter.AttachSpec, error) { return adapter.AttachSpec{Argv: []string{"echo", id}}, nil } -func (f *svcFakeAdapter) Stop(ctx adapter.Context, id string) error { f.stopped = true; return nil } -func (f *svcFakeAdapter) Rename(ctx adapter.Context, id, newName string) error { return nil } -func (f *svcFakeAdapter) Subscribe(ctx adapter.Context) (<-chan adapter.SessionEvent, error) { - return nil, nil +func (f *svcFakeAdapter) Stop(ctx adapter.Context, id string) error { + f.stopped = true + return f.stopErr } +func (f *svcFakeAdapter) HasSession(ctx adapter.Context, id string) bool { return f.alive } func TestServiceWorkflow(t *testing.T) { svc, fake := newWorkflowService(t) @@ -80,7 +89,7 @@ func newWorkflowService(t *testing.T) (*Service, *svcFakeAdapter) { func assertWorkflowDispatch(t *testing.T, svc *Service) adapter.Session { t.Helper() - sess, err := svc.Dispatch(context.Background(), "fake", "hello", "/tmp", "yolo") + sess, err := svc.DispatchNamed(context.Background(), "fake", "", "hello", "/tmp", "yolo") if err != nil { t.Fatal(err) } @@ -142,7 +151,7 @@ func TestServicePrintListAndErrors(t *testing.T) { dir := t.TempDir() st, _ := store.Open(filepath.Join(dir, "sessions.json")) svc := NewService(st, adapter.NewRegistry([]adapter.AgentAdapter{&svcFakeAdapter{name: "fake", available: true}})) - if _, err := svc.Dispatch(context.Background(), "missing", "x", "", ""); err == nil { + if _, err := svc.DispatchNamed(context.Background(), "missing", "", "x", "", ""); err == nil { t.Fatal("want missing agent error") } if _, _, err := svc.Find(context.Background(), "missing"); err == nil { @@ -280,7 +289,7 @@ func TestStopSoftCloseFlagsRecordClosedByUser(t *testing.T) { } fake := &svcFakeAdapter{name: "fake", available: true} svc := NewService(st, adapter.NewRegistry([]adapter.AgentAdapter{fake})) - if _, err := svc.Dispatch(context.Background(), "fake", "hello", "/tmp", "yolo"); err != nil { + if _, err := svc.DispatchNamed(context.Background(), "fake", "", "hello", "/tmp", "yolo"); err != nil { t.Fatal(err) } // Soft-close (remove=false) must keep the record and flag it as closed. @@ -311,7 +320,7 @@ func TestStopRemoveDeletesRecord(t *testing.T) { } fake := &svcFakeAdapter{name: "fake", available: true} svc := NewService(st, adapter.NewRegistry([]adapter.AgentAdapter{fake})) - if _, err := svc.Dispatch(context.Background(), "fake", "hello", "/tmp", "yolo"); err != nil { + if _, err := svc.DispatchNamed(context.Background(), "fake", "", "hello", "/tmp", "yolo"); err != nil { t.Fatal(err) } if err := svc.Stop(context.Background(), "12345678", true); err != nil { @@ -326,6 +335,92 @@ func TestStopRemoveDeletesRecord(t *testing.T) { } } +// F04 — a failed adapter kill on a still-live pane must NOT delete/flag the +// record: the session is alive and the record is the only handle to kill it. +func TestStopRemoveKeepsRecordWhenKillFailsAndPaneAlive(t *testing.T) { + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "sessions.json")) + if err != nil { + t.Fatal(err) + } + fake := &svcFakeAdapter{name: "fake", available: true, stopErr: errors.New("kill boom"), alive: true} + svc := NewService(st, adapter.NewRegistry([]adapter.AgentAdapter{fake})) + if _, err := svc.DispatchNamed(context.Background(), "fake", "", "hello", "/tmp", "yolo"); err != nil { + t.Fatal(err) + } + err = svc.Stop(context.Background(), "12345678", true) + if err == nil { + t.Fatal("Stop should surface the kill failure when the pane is still alive") + } + cfg, _ := st.Load() + if _, ok := cfg.Sessions[store.Key("fake", "12345678")]; !ok { + t.Fatalf("record must survive when kill fails and pane is alive: %+v", cfg.Sessions) + } +} + +func TestStopSoftCloseKeepsRecordActiveWhenKillFailsAndPaneAlive(t *testing.T) { + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "sessions.json")) + if err != nil { + t.Fatal(err) + } + fake := &svcFakeAdapter{name: "fake", available: true, stopErr: errors.New("kill boom"), alive: true} + svc := NewService(st, adapter.NewRegistry([]adapter.AgentAdapter{fake})) + if _, err := svc.DispatchNamed(context.Background(), "fake", "", "hello", "/tmp", "yolo"); err != nil { + t.Fatal(err) + } + if err := svc.Stop(context.Background(), "12345678", false); err == nil { + t.Fatal("soft Stop should surface the kill failure when the pane is still alive") + } + cfg, _ := st.Load() + rec := cfg.Sessions[store.Key("fake", "12345678")] + if rec.Status == store.StatusClosedByUser { + t.Fatalf("record must not be flagged closed when the live pane survived the failed kill: %+v", rec) + } +} + +// F04 trap — if the kill fails but the pane is already GONE, Stop must still +// clean the record up (preserves `uam rm` cleanup of already-dead sessions). +func TestStopRemoveDeletesRecordWhenKillFailsButPaneGone(t *testing.T) { + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "sessions.json")) + if err != nil { + t.Fatal(err) + } + fake := &svcFakeAdapter{name: "fake", available: true, stopErr: errors.New("no such session"), alive: false} + svc := NewService(st, adapter.NewRegistry([]adapter.AgentAdapter{fake})) + if _, err := svc.DispatchNamed(context.Background(), "fake", "", "hello", "/tmp", "yolo"); err != nil { + t.Fatal(err) + } + if err := svc.Stop(context.Background(), "12345678", true); err != nil { + t.Fatalf("Stop should succeed when the pane is already gone: %v", err) + } + cfg, _ := st.Load() + if _, ok := cfg.Sessions[store.Key("fake", "12345678")]; ok { + t.Fatalf("record should be removed when pane is gone: %+v", cfg.Sessions) + } +} + +func TestStopSoftCloseFlagsRecordWhenKillFailsButPaneGone(t *testing.T) { + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "sessions.json")) + if err != nil { + t.Fatal(err) + } + fake := &svcFakeAdapter{name: "fake", available: true, stopErr: errors.New("no such session"), alive: false} + svc := NewService(st, adapter.NewRegistry([]adapter.AgentAdapter{fake})) + if _, err := svc.DispatchNamed(context.Background(), "fake", "", "hello", "/tmp", "yolo"); err != nil { + t.Fatal(err) + } + if err := svc.Stop(context.Background(), "12345678", false); err != nil { + t.Fatalf("soft Stop should succeed when the pane is already gone: %v", err) + } + cfg, _ := st.Load() + if cfg.Sessions[store.Key("fake", "12345678")].Status != store.StatusClosedByUser { + t.Fatalf("record should be flagged closed when pane is gone: %+v", cfg.Sessions[store.Key("fake", "12345678")]) + } +} + func TestNotifyClosedMarksMatchingRecord(t *testing.T) { dir := t.TempDir() st, err := store.Open(filepath.Join(dir, "sessions.json")) @@ -334,7 +429,7 @@ func TestNotifyClosedMarksMatchingRecord(t *testing.T) { } fake := &svcFakeAdapter{name: "fake", available: true} svc := NewService(st, adapter.NewRegistry([]adapter.AgentAdapter{fake})) - if _, err := svc.Dispatch(context.Background(), "fake", "hello", "/tmp", "yolo"); err != nil { + if _, err := svc.DispatchNamed(context.Background(), "fake", "", "hello", "/tmp", "yolo"); err != nil { t.Fatal(err) } @@ -362,6 +457,109 @@ func TestNotifyClosedMarksMatchingRecord(t *testing.T) { } } +// F03 — when persisting a freshly dispatched session fails, the live session +// must still be returned (advisory error), and the adapter must NOT be told to +// Stop it (the agent is running; killing it on a persist hiccup loses work). +func TestDispatchReturnsLiveSessionOnPersistFailure(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("read-only dir is not enforced for root") + } + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "sessions.json")) + if err != nil { + t.Fatal(err) + } + fake := &svcFakeAdapter{name: "fake", available: true} + svc := NewService(st, adapter.NewRegistry([]adapter.AgentAdapter{fake})) + // First dispatch creates the lock + config files. + if _, err := svc.DispatchNamed(context.Background(), "fake", "", "warmup", "/tmp", "yolo"); err != nil { + t.Fatal(err) + } + // Make the config dir read-only so the next store write (the tmp-file create) + // fails inside Store.Update. + if err := os.Chmod(dir, 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(dir, 0o700) }) + + sess, err := svc.DispatchNamed(context.Background(), "fake", "second", "do work", "/tmp", "yolo") + if err == nil { + t.Fatal("expected an advisory persist error") + } + if !strings.Contains(err.Error(), sess.ID) { + t.Fatalf("advisory error should reference the live session id %q: %v", sess.ID, err) + } + if sess.ID == "" || sess.TmuxSession == "" { + t.Fatalf("live session must be returned despite persist failure: %+v", sess) + } + if fake.stopped { + t.Fatal("adapter.Stop must NOT be called on a persist failure — the session is live") + } +} + +// F03 characterization — the dispatch mode is honoured (not silently forced to +// yolo) when persisting the record. +func TestDispatchPersistsRequestedMode(t *testing.T) { + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "sessions.json")) + if err != nil { + t.Fatal(err) + } + fake := &svcFakeAdapter{name: "fake", available: true} + svc := NewService(st, adapter.NewRegistry([]adapter.AgentAdapter{fake})) + if _, err := svc.DispatchNamed(context.Background(), "fake", "", "do work", "/tmp", "safe"); err != nil { + t.Fatal(err) + } + cfg, _ := st.Load() + if got := cfg.Sessions[store.Key("fake", "12345678")].Mode; got != store.ModeSafe { + t.Fatalf("persisted mode = %q, want %q (dispatch must not hardcode yolo)", got, store.ModeSafe) + } +} + +// C1-2 — renaming a live session whose store record is missing at mutation time +// must backfill a full record (real ID + tmux name) before applying the field +// change, not write a zero-value phantom that can never be killed once the pane +// dies. We drive the no-record path directly through renameRecord so the test is +// independent of the refresh backfill (which would otherwise mask the bug). +func TestRenameLiveSessionWithoutStoreRecordBackfillsRecord(t *testing.T) { + live := adapter.Session{ID: "aaaa1111", AgentType: "fake", DisplayName: "A", Cwd: "/tmp", TmuxSession: "uam-fake-aaaa1111", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} + svc, st, _ := newLoadService(t, []adapter.Session{live}) + // Empty store: no record for the live session. + if err := st.Save(store.DefaultConfig()); err != nil { + t.Fatal(err) + } + if err := svc.applyRecordMutation(live, func(rec *store.SessionRecord) { rec.Name = "renamed" }); err != nil { + t.Fatalf("rename mutation: %v", err) + } + cfg, _ := st.Load() + rec := cfg.Sessions[store.Key("fake", "aaaa1111")] + if rec.ID != "aaaa1111" || rec.TmuxSession != "uam-fake-aaaa1111" { + t.Fatalf("rename must backfill a full record, got %+v", rec) + } + if rec.Name != "renamed" { + t.Fatalf("rename did not apply, got name %q", rec.Name) + } +} + +func TestTogglePinLiveSessionWithoutStoreRecordBackfillsRecord(t *testing.T) { + live := adapter.Session{ID: "bbbb2222", AgentType: "fake", DisplayName: "B", Cwd: "/tmp", TmuxSession: "uam-fake-bbbb2222", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} + svc, st, _ := newLoadService(t, []adapter.Session{live}) + if err := st.Save(store.DefaultConfig()); err != nil { + t.Fatal(err) + } + if err := svc.applyRecordMutation(live, func(rec *store.SessionRecord) { rec.Pinned = !rec.Pinned }); err != nil { + t.Fatalf("toggle pin mutation: %v", err) + } + cfg, _ := st.Load() + rec := cfg.Sessions[store.Key("fake", "bbbb2222")] + if rec.ID != "bbbb2222" || rec.TmuxSession != "uam-fake-bbbb2222" { + t.Fatalf("toggle pin must backfill a full record, got %+v", rec) + } + if !rec.Pinned { + t.Fatalf("toggle pin did not apply, got %+v", rec) + } +} + func TestResumeBackgroundClearsClosedStatus(t *testing.T) { dir := t.TempDir() st, err := store.Open(filepath.Join(dir, "sessions.json")) @@ -370,7 +568,7 @@ func TestResumeBackgroundClearsClosedStatus(t *testing.T) { } fake := &svcFakeAdapter{name: "fake", available: true} svc := NewService(st, adapter.NewRegistry([]adapter.AgentAdapter{fake})) - if _, err := svc.Dispatch(context.Background(), "fake", "hello", "/tmp", "yolo"); err != nil { + if _, err := svc.DispatchNamed(context.Background(), "fake", "", "hello", "/tmp", "yolo"); err != nil { t.Fatal(err) } // User closes the session (soft close), then resumes it. diff --git a/internal/app/wizard_test.go b/internal/app/wizard_test.go new file mode 100644 index 0000000..21d59c4 --- /dev/null +++ b/internal/app/wizard_test.go @@ -0,0 +1,157 @@ +package app + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + tea "github.com/charmbracelet/bubbletea" +) + +// C2-8 — the workdir step must warn when the chosen directory is not inside a git +// repository: dispatching there means no checkpoint to recover the agent's work. +// isGitRepo walks up the tree the way git does. +func TestIsGitRepoWalksUp(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatal(err) + } + nested := filepath.Join(root, "a", "b", "c") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + if !isGitRepo(nested) { + t.Fatal("a nested dir under a .git root should be detected as a git repo") + } + + // A standalone temp dir with no .git anywhere up to its root is not a repo. + // Use a path guaranteed to have no .git: a fresh tempdir whose parents are + // system temp (not under this repo's checkout). + bare := t.TempDir() + // Defensive: only assert non-repo if no ancestor has .git (CI temp dirs do + // not, but be explicit so the test is deterministic). + if isGitRepo(bare) && !ancestorHasGit(bare) { + t.Fatal("isGitRepo disagreed with the ancestor walk") + } +} + +// helper for the test's own sanity check (independent walk). +func ancestorHasGit(dir string) bool { + d, err := filepath.Abs(dir) + if err != nil { + return false + } + for { + if fi, err := os.Stat(filepath.Join(d, ".git")); err == nil && fi.IsDir() { + return true + } + parent := filepath.Dir(d) + if parent == d { + return false + } + d = parent + } +} + +// C2-8 — Tab in the workdir step completes the typed path against the filesystem +// via filepath.Glob. +func TestGlobCompleteCompletesUniquePrefix(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "myproject") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatal(err) + } + got := globComplete(filepath.Join(dir, "mypr")) + if got != target { + t.Fatalf("globComplete = %q, want %q", got, target) + } +} + +// C2-8 — when nothing matches, globComplete returns the input unchanged (no-op). +func TestGlobCompleteNoMatchKeepsInput(t *testing.T) { + in := filepath.Join(t.TempDir(), "nonexistent-xyz") + if got := globComplete(in); got != in { + t.Fatalf("globComplete with no match = %q, want %q unchanged", got, in) + } +} + +// C2-8 — the workdir step shows a yellow "no checkpoint" warning when the chosen +// directory is not a git repo. +func TestWizardWorkdirShowsNoGitWarning(t *testing.T) { + m := NewWithDeps(nil, nil) + m.wizard = true + m.wizardStep = 1 + m.input = t.TempDir() // not a git repo + out := m.renderWizard() + if !strings.Contains(strings.ToLower(out), "checkpoint") && !strings.Contains(strings.ToLower(out), "not a git") { + t.Fatalf("workdir step should warn about the missing git checkpoint: %q", out) + } +} + +// C2-8 — Tab in the workdir step completes m.input via globComplete. +func TestWizardWorkdirTabCompletes(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "completed") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatal(err) + } + m := NewWithDeps(nil, nil) + m.wizard = true + m.wizardStep = 1 + m.input = filepath.Join(dir, "compl") + model, _ := m.handleWizardKey(keyMsg("tab")) + m = model.(Model) + if m.input != target { + t.Fatalf("Tab should complete the path to %q, got %q", target, m.input) + } +} + +// C2-8 — Ctrl+G in the prompt step opens $EDITOR via the injected exec runner +// (tea.ExecProcess in production) and the round-trip loads the edited file back +// into the prompt buffer. +func TestWizardCtrlGOpensEditorAndLoadsResult(t *testing.T) { + m := NewWithDeps(nil, adapter.NewRegistry([]adapter.AgentAdapter{&svcFakeAdapter{name: "fake", available: true}})) + m.wizard = true + m.wizardStep = 2 + m.input = "" + + // Capture the command tea.ExecProcess would have run; simulate the editor by + // writing into the temp file it was pointed at, then invoke the callback. + var ranArgs []string + m.execProcess = func(c *exec.Cmd, cb tea.ExecCallback) tea.Cmd { + ranArgs = append([]string(nil), c.Args...) + // The last arg is the temp file path the wizard created. + file := c.Args[len(c.Args)-1] + if err := os.WriteFile(file, []byte("edited prompt body\n"), 0o600); err != nil { + t.Fatal(err) + } + return func() tea.Msg { return cb(nil) } + } + + model, cmd := m.handleWizardKey(tea.KeyMsg{Type: tea.KeyCtrlG}) + m = model.(Model) + if cmd == nil { + t.Fatal("Ctrl+G should return an exec command") + } + if len(ranArgs) == 0 { + t.Fatal("Ctrl+G should have launched an editor process") + } + msg := cmd() + edited, ok := msg.(promptEditedMsg) + if !ok { + t.Fatalf("expected promptEditedMsg, got %T", msg) + } + if edited.err != nil { + t.Fatalf("editor round-trip error: %v", edited.err) + } + // Feed the message back through Update; the prompt buffer should hold the + // editor's content (trailing newline trimmed). + model, _ = m.Update(edited) + m = model.(Model) + if m.input != "edited prompt body" { + t.Fatalf("prompt buffer after editor = %q, want %q", m.input, "edited prompt body") + } +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index e716951..21e3267 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -6,6 +6,7 @@ import ( "errors" "flag" "fmt" + "io" "os" "os/exec" "strings" @@ -13,11 +14,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" - "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/claude" - "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/codex" - "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/copilot" - "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/hermes" - "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/opencode" + "github.com/RandomCodeSpace/unified-agent-manager/internal/agents" "github.com/RandomCodeSpace/unified-agent-manager/internal/app" "github.com/RandomCodeSpace/unified-agent-manager/internal/log" "github.com/RandomCodeSpace/unified-agent-manager/internal/store" @@ -60,6 +57,7 @@ func Usage() { fmt.Fprintln(os.Stderr, " uam peek ") fmt.Fprintln(os.Stderr, " uam stop ") fmt.Fprintln(os.Stderr, " uam rm ") + fmt.Fprintln(os.Stderr, " uam kill-all stop the private tmux server and all sessions") fmt.Fprintln(os.Stderr, " uam notify-closed (internal: tmux session-closed hook)") } @@ -76,6 +74,12 @@ func RunWithTUI(ctx context.Context, args []string, runTUI func(context.Context, } svc := NewService(st) if len(args) == 0 { + // Best-effort startup prune of long-stale dead records so sessions.json + // does not grow unbounded. Server-down-safe (a no-op when no live session + // is visible) and advisory: a prune failure must never block launch (F20). + if err := svc.PruneStartup(ctx); err != nil { + log.Warn("startup prune failed", "err", err) + } return runTUI(ctx, app.NewWithDeps(st, svc.Registry)) } return runCommand(ctx, svc, args, runTUI) @@ -101,6 +105,8 @@ func runCommand(ctx context.Context, svc *app.Service, args []string, runTUI fun return runStop(ctx, svc, args[0], args[1:]) case "notify-closed": return runNotifyClosed(svc, args[1:]) + case "kill-all": + return runKillAll(ctx, tmux.New("uam").KillServer) case "attach": id, err := requireArg(args[1:], "attach requires ") if err != nil { @@ -159,15 +165,45 @@ func runNotifyClosed(svc *app.Service, args []string) error { return svc.NotifyClosed(tmuxName) } +// runKillAll tears down the private tmux server (and every managed session) via +// the injected killer. uam never auto-kills on TUI quit — reboot-recovery of +// dead-pane sessions is intentional — so this explicit command is the only +// teardown path (F24). The killer is idempotent on an already-dead server. +func runKillAll(ctx context.Context, kill func(context.Context) error) error { + if err := kill(ctx); err != nil { + return fmt.Errorf("kill-all: %w", err) + } + fmt.Println("uam tmux server stopped") + return nil +} + func runLast(ctx context.Context, svc *app.Service, runTUI func(context.Context, tea.Model) error) error { - sessions, _, err := svc.LoadSessions(ctx) + // LoadSessions returns the persisted config; the "last" session is the one + // with the newest persisted LastSeenAt, not the top sorted row (whose order + // is driven by State/Pinned, not recency) (C1-6). + _, cfg, err := svc.LoadSessions(ctx) if err != nil { return err } - if len(sessions) == 0 { + id := lastSeenID(cfg) + if id == "" { return errors.New("no sessions") } - return execAttach(ctx, svc, sessions[0].ID, runTUI) + return execAttach(ctx, svc, id, runTUI) +} + +// lastSeenID returns the id of the record with the maximum persisted LastSeenAt. +// Ties are broken by the larger id so repeated `uam last` invocations are +// deterministic. Returns "" when there are no records (C1-6). +func lastSeenID(cfg store.Config) string { + var best store.SessionRecord + for _, rec := range cfg.Sessions { + if best.ID == "" || rec.LastSeenAt.After(best.LastSeenAt) || + (rec.LastSeenAt.Equal(best.LastSeenAt) && rec.ID > best.ID) { + best = rec + } + } + return best.ID } func requireArg(args []string, message string) (string, error) { @@ -180,7 +216,15 @@ func requireArg(args []string, message string) (string, error) { // NewService wires the app service and supported agent adapters. func NewService(st *store.Store) *app.Service { client := tmux.New("uam") - reg := adapter.NewRegistry([]adapter.AgentAdapter{claude.New(client), codex.New(client), copilot.New(client), hermes.New(client), opencode.New(client)}) + // Let migration distinguish reboot-survivors (live pane) from user-stopped + // sessions (dead pane) so a v1->v2 upgrade does not auto-resume the latter + // on attach (F07). The store stays tmux-free; this only injects the probe. + st.SetSessionProbe(func(name string) bool { + return client.HasSession(context.Background(), name) + }) + // Build the registry from the single shared adapter list so the CLI service + // and the TUI can never diverge on which providers exist (F14). + reg := adapter.NewRegistry(agents.Default(client)) return app.NewService(st, reg) } @@ -203,6 +247,18 @@ func RunDispatch(ctx context.Context, svc *app.Service, args []string) error { if len(rem) < 1 { return errors.New("dispatch requires [#session-name] [prompt]") } + // Go's flag parser stops at the first positional, so any flag placed AFTER + // lands in the agent or #name slot instead of taking effect — e.g. + // `dispatch fake --safe prompt` would silently fold --safe into the prompt. + // Reject a leftover "-"-prefixed token in those two slots. The prompt proper + // (rem[2:], or rem[1:] when unnamed) is left untouched so it may contain "--" + // (C2-3). + if strings.HasPrefix(rem[0], "-") { + return fmt.Errorf("dispatch: %q looks like a flag; flags must come before ", rem[0]) + } + if len(rem) > 1 && strings.HasPrefix(rem[1], "-") { + return fmt.Errorf("dispatch: %q looks like a flag; flags must come before ", rem[1]) + } mode := string(store.ModeYolo) if *safe { mode = string(store.ModeSafe) @@ -210,7 +266,12 @@ func RunDispatch(ctx context.Context, svc *app.Service, args []string) error { name, prompt := parseNameAndPrompt(rem[1:]) sess, err := svc.DispatchNamed(ctx, rem[0], name, prompt, *cwd, mode) if err != nil { - return err + // A non-empty session means the agent launched but the record failed to + // persist (advisory): report the warning, still emit the id, exit 0 (F03). + if sess.ID == "" { + return err + } + fmt.Fprintln(os.Stderr, "warning:", err) } fmt.Println(sess.ID) return nil @@ -224,26 +285,81 @@ func runNew(ctx context.Context, svc *app.Service) error { agent = a.Name() } fmt.Printf("provider [%s]: ", agent) - if line, _ := reader.ReadString('\n'); strings.TrimSpace(line) != "" { - agent = strings.TrimSpace(line) + if line, err := readLine(reader); err != nil { + return fmt.Errorf("read provider: %w", err) + } else if line != "" { + agent = line + } + // Re-validate the typed provider: if its CLI is not installed, reconcile it + // to an enabled one rather than failing the dispatch on an "unavailable" + // name. Registry.Default returns nil only when nothing is enabled, in which + // case the typed value is kept and the dispatch surfaces the real error + // (C2-9). + if a := svc.Registry.Default(agent); a != nil { + agent = a.Name() + } + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("resolve working directory: %w", err) } - cwd, _ := os.Getwd() fmt.Printf("workdir [%s]: ", cwd) - if line, _ := reader.ReadString('\n'); strings.TrimSpace(line) != "" { - cwd = strings.TrimSpace(line) + if line, err := readLine(reader); err != nil { + return fmt.Errorf("read workdir: %w", err) + } else if line != "" { + cwd = line } fmt.Print("prompt [#session-name prompt, optional]: ") - prompt, _ := reader.ReadString('\n') - prompt = strings.TrimSpace(prompt) - name, prompt := parseNameAndPrompt(strings.Fields(prompt)) + // Read the raw prompt line; data and io.EOF can co-arrive on the final read, + // so use the returned bytes even when err == io.EOF (F54). + raw, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return fmt.Errorf("read prompt: %w", err) + } + // Split off only a leading #name token; preserve the prompt remainder + // verbatim so interior whitespace the user typed is not collapsed (C1-3). + name, prompt := splitNameFromPrompt(strings.TrimRight(raw, "\r\n")) + if strings.TrimSpace(prompt) == "" { + return errors.New("new requires a prompt") + } sess, err := svc.DispatchNamed(ctx, agent, name, prompt, cwd, string(store.ModeYolo)) if err != nil { - return err + if sess.ID == "" { + return err + } + fmt.Fprintln(os.Stderr, "warning:", err) } fmt.Printf("dispatched %s (%s)\n", sess.ID, sess.TmuxSession) return nil } +// readLine reads one trimmed input line from r. A trailing io.EOF that arrives +// with the line's bytes is not an error (the line is still returned); any other +// read error is surfaced (F54). +func readLine(r *bufio.Reader) (string, error) { + line, err := r.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "", err + } + return strings.TrimSpace(line), nil +} + +// splitNameFromPrompt peels a single leading #name token off the front of a +// prompt and returns the remainder verbatim. Unlike parseNameAndPrompt it does +// not tokenize the prompt, so interior whitespace survives (C1-3). The leading +// "#name " separator (exactly one space) is consumed; everything after it is +// kept byte-for-byte. +func splitNameFromPrompt(line string) (name, prompt string) { + trimmed := strings.TrimLeft(line, " \t") + if !strings.HasPrefix(trimmed, "#") { + return "", line + } + rest := trimmed[1:] + if i := strings.IndexAny(rest, " \t"); i >= 0 { + return rest[:i], rest[i+1:] + } + return rest, "" +} + func parseNameAndPrompt(parts []string) (string, string) { if len(parts) == 0 { return "", "" diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 17ea288..3c25efd 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -44,11 +44,7 @@ func (f *cliFakeAdapter) Reply(ctx adapter.Context, id, text string) error { ret func (f *cliFakeAdapter) Attach(id string) (adapter.AttachSpec, error) { return adapter.AttachSpec{Argv: []string{"echo", id}}, nil } -func (f *cliFakeAdapter) Stop(ctx adapter.Context, id string) error { f.stopped = true; return nil } -func (f *cliFakeAdapter) Rename(ctx adapter.Context, id, newName string) error { return nil } -func (f *cliFakeAdapter) Subscribe(ctx adapter.Context) (<-chan adapter.SessionEvent, error) { - return nil, nil -} +func (f *cliFakeAdapter) Stop(ctx adapter.Context, id string) error { f.stopped = true; return nil } func TestRunDispatchListPeekAndStop(t *testing.T) { svc, fake := newCLITestService(t) diff --git a/internal/cli/dispatch_flag_test.go b/internal/cli/dispatch_flag_test.go new file mode 100644 index 0000000..be116c7 --- /dev/null +++ b/internal/cli/dispatch_flag_test.go @@ -0,0 +1,83 @@ +package cli + +import ( + "context" + "strings" + "testing" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" +) + +// C2-3 — Go's flag package stops parsing at the first positional, so a flag +// placed AFTER is silently swallowed into the prompt: --safe never takes +// effect and corrupts the prompt text. RunDispatch must reject a leftover +// "-"-prefixed token sitting in the agent or #name slot rather than treat it as +// prompt text. +func TestRunDispatchRejectsFlagsAfterAgent(t *testing.T) { + cases := []struct { + name string + args []string + }{ + {"safe after agent", []string{"fake", "--safe", "do", "work"}}, + {"short flag after agent", []string{"fake", "-x", "do", "work"}}, + {"flag in agent slot", []string{"--safe"}}, + {"cwd flag after agent", []string{"fake", "--cwd", "/tmp", "do", "work"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svc, fake := newCLITestService(t) + err := RunDispatch(context.Background(), svc, tc.args) + if err == nil { + t.Fatalf("expected RunDispatch to reject a misplaced flag, args=%v", tc.args) + } + if len(fake.sessions) != 0 { + t.Fatalf("a rejected dispatch must not launch a session, got %d", len(fake.sessions)) + } + }) + } +} + +// C2-3 — legitimate dispatch forms must keep working, and an interior "--" in +// the prompt must survive verbatim (the prompt-may-contain-"--" contract). +func TestRunDispatchAcceptsValidFormsAndPreservesInteriorDashes(t *testing.T) { + cases := []struct { + name string + args []string + wantName string + wantPrompt string + wantMode store.Mode + }{ + {"plain prompt", []string{"fake", "do", "work"}, "", "do work", store.ModeYolo}, + {"named prompt", []string{"fake", "#bugfix", "fix", "thing"}, "bugfix", "fix thing", store.ModeYolo}, + {"interior double dash", []string{"fake", "fix", "the", "--", "flag", "bug"}, "", "fix the -- flag bug", store.ModeYolo}, + {"safe before agent", []string{"--safe", "fake", "do", "work"}, "", "do work", store.ModeSafe}, + {"cwd before agent", []string{"--cwd", "/tmp", "fake", "go"}, "", "go", store.ModeYolo}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svc, fake := newCLITestService(t) + out := captureCLIStdout(t, func() { must(t, RunDispatch(context.Background(), svc, tc.args)) }) + if strings.TrimSpace(out) == "" { + t.Fatal("expected a dispatched session id on stdout") + } + if len(fake.sessions) != 1 { + t.Fatalf("expected exactly one dispatched session, got %d", len(fake.sessions)) + } + sess := fake.sessions[0] + if sess.Prompt != tc.wantPrompt { + t.Fatalf("prompt = %q, want %q", sess.Prompt, tc.wantPrompt) + } + cfg, err := svc.Store.Load() + if err != nil { + t.Fatal(err) + } + rec := cfg.Sessions[store.Key("fake", sess.ID)] + if tc.wantName != "" && rec.Name != tc.wantName { + t.Fatalf("name = %q, want %q", rec.Name, tc.wantName) + } + if rec.Mode != tc.wantMode { + t.Fatalf("mode = %q, want %q", rec.Mode, tc.wantMode) + } + }) + } +} diff --git a/internal/cli/killall_test.go b/internal/cli/killall_test.go new file mode 100644 index 0000000..7fac36d --- /dev/null +++ b/internal/cli/killall_test.go @@ -0,0 +1,58 @@ +package cli + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +// F24 — `uam kill-all` must invoke the tmux server teardown exactly once. +func TestRunKillAllInvokesServerTeardown(t *testing.T) { + calls := 0 + kill := func(ctx context.Context) error { + calls++ + return nil + } + if err := runKillAll(context.Background(), kill); err != nil { + t.Fatalf("runKillAll: %v", err) + } + if calls != 1 { + t.Fatalf("server teardown invoked %d times, want 1", calls) + } +} + +// F24 — a teardown error must propagate so the user learns the server is still +// up (idempotency on a dead server is handled inside KillServer, not here). +func TestRunKillAllPropagatesError(t *testing.T) { + kill := func(ctx context.Context) error { return errors.New("boom") } + if err := runKillAll(context.Background(), kill); err == nil { + t.Fatal("runKillAll must propagate a teardown error") + } +} + +// F24 — `kill-all` must be routed by runCommand to the default teardown path. +// A fake tmux (via UAM_TMUX_BIN) keeps the test off any real `uam` server and +// host-independent: it reports a dead server, which the idempotent KillServer +// treats as success. +func TestRunCommandKillAllDispatches(t *testing.T) { + svc, _ := newCLITestService(t) + dir := t.TempDir() + fakeTmux := filepath.Join(dir, "tmux") + if err := os.WriteFile(fakeTmux, []byte("#!/bin/sh\necho 'no server running on /tmp/tmux' >&2\nexit 1\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("UAM_TMUX_BIN", fakeTmux) + + out := captureCLIStdout(t, func() { + if err := runCommand(context.Background(), svc, []string{"kill-all"}, func(context.Context, tea.Model) error { return nil }); err != nil { + t.Fatalf("kill-all dispatch: %v", err) + } + }) + if out == "" { + t.Fatal("kill-all should print a confirmation line") + } +} diff --git a/internal/cli/new_default_agent_test.go b/internal/cli/new_default_agent_test.go new file mode 100644 index 0000000..e39d1a1 --- /dev/null +++ b/internal/cli/new_default_agent_test.go @@ -0,0 +1,33 @@ +package cli + +import ( + "context" + "strings" + "testing" +) + +// C2-9 — `uam new` must re-validate the typed provider against the registry. A +// provider whose CLI is not installed is reconciled to an enabled one +// (Registry.Default falls back to the first enabled adapter) so the wizard +// dispatches to a real agent instead of erroring out on an "unavailable" name. +func TestRunNewReconcilesDisabledTypedProvider(t *testing.T) { + svc, _ := newCLITestService(t) // only "fake" is enabled + out := captureCLIStdout(t, func() { + // Type a disabled provider ("claude") at the prompt. + withCLIStdin(t, "claude\n/tmp\ndo work\n", func() { must(t, runNew(context.Background(), svc)) }) + }) + if !strings.Contains(out, "dispatched") { + t.Fatalf("new should dispatch after reconciling the typed provider; out=%q", out) + } +} + +// C2-9 — an enabled typed provider is honored verbatim. +func TestRunNewKeepsEnabledTypedProvider(t *testing.T) { + svc, fake := newCLITestService(t) + captureCLIStdout(t, func() { + withCLIStdin(t, "fake\n/tmp\ndo work\n", func() { must(t, runNew(context.Background(), svc)) }) + }) + if len(fake.sessions) == 0 { + t.Fatal("dispatch to the enabled typed provider should have created a session") + } +} diff --git a/internal/cli/new_last_test.go b/internal/cli/new_last_test.go new file mode 100644 index 0000000..070f8a4 --- /dev/null +++ b/internal/cli/new_last_test.go @@ -0,0 +1,158 @@ +package cli + +import ( + "context" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" +) + +// C1-3 — `uam new` must preserve interior whitespace in the prompt. The old +// implementation rebuilt the prompt with strings.Fields + single-space rejoin, +// collapsing runs of spaces and tabs (corrupting code blocks, indentation, and +// aligned text the user typed). Only a leading #name token may be split off. +func TestRunNewPreservesPromptWhitespace(t *testing.T) { + cases := []struct { + name string + stdin string + wantName string + wantPrompt string + }{ + { + name: "double spaces preserved", + stdin: "fake\n/tmp\nfix the parser\n", + wantName: "", + wantPrompt: "fix the parser", + }, + { + name: "named prompt keeps interior spacing", + stdin: "fake\n/tmp\n#bugfix do this thing\n", + wantName: "bugfix", + wantPrompt: "do this thing", + }, + { + name: "leading whitespace after name preserved", + stdin: "fake\n/tmp\n#bugfix indented\n", + wantName: "bugfix", + wantPrompt: " indented", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svc, fake := newCLITestService(t) + withCLIStdin(t, tc.stdin, func() { + _ = captureCLIStdout(t, func() { must(t, runNew(context.Background(), svc)) }) + }) + if len(fake.sessions) != 1 { + t.Fatalf("expected one dispatched session, got %d", len(fake.sessions)) + } + if fake.sessions[0].Prompt != tc.wantPrompt { + t.Fatalf("prompt = %q, want %q", fake.sessions[0].Prompt, tc.wantPrompt) + } + if tc.wantName == "" { + return // unnamed sessions get a derived display name; only the prompt matters here + } + cfg, err := svc.Store.Load() + if err != nil { + t.Fatal(err) + } + rec := cfg.Sessions[store.Key("fake", fake.sessions[0].ID)] + if rec.Name != tc.wantName { + t.Fatalf("name = %q, want %q", rec.Name, tc.wantName) + } + }) + } +} + +// F54 — `uam new` must reject an empty final prompt (EOF / blank input) instead +// of dispatching an empty-prompt session and exiting 0. +func TestRunNewRejectsEmptyPrompt(t *testing.T) { + cases := []struct { + name string + stdin string + }{ + {"empty stdin (EOF)", ""}, + {"blank prompt line", "fake\n/tmp\n\n"}, + {"whitespace-only prompt", "fake\n/tmp\n \n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svc, fake := newCLITestService(t) + var err error + withCLIStdin(t, tc.stdin, func() { + _ = captureCLIStdout(t, func() { err = runNew(context.Background(), svc) }) + }) + if err == nil { + t.Fatal("expected runNew to reject an empty prompt") + } + if len(fake.sessions) != 0 { + t.Fatalf("a rejected new must not dispatch a session, got %d", len(fake.sessions)) + } + }) + } +} + +// F54 — data and io.EOF can co-arrive on the final read; the prompt typed on the +// last line (no trailing newline) must still be used. +func TestRunNewUsesPromptOnEOFWithoutNewline(t *testing.T) { + svc, fake := newCLITestService(t) + withCLIStdin(t, "fake\n/tmp\nlast line no newline", func() { + _ = captureCLIStdout(t, func() { must(t, runNew(context.Background(), svc)) }) + }) + if len(fake.sessions) != 1 { + t.Fatalf("expected one dispatched session, got %d", len(fake.sessions)) + } + if fake.sessions[0].Prompt != "last line no newline" { + t.Fatalf("prompt = %q, want %q", fake.sessions[0].Prompt, "last line no newline") + } +} + +// C1-6 — `uam last` must select the session with the maximum persisted +// LastSeenAt (with a deterministic id tiebreak), not the top sorted row. The +// selection logic lives in lastSeenID; assert it picks the newest-seen record. +func TestLastSeenIDSelectsMaxLastSeenAt(t *testing.T) { + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + cfg := store.Config{Sessions: map[string]store.SessionRecord{ + store.Key("fake", "aaaaaaaa"): {ID: "aaaaaaaa", Agent: "fake", TmuxSession: "uam-fake-aaaaaaaa", LastSeenAt: base}, + store.Key("fake", "bbbbbbbb"): {ID: "bbbbbbbb", Agent: "fake", TmuxSession: "uam-fake-bbbbbbbb", LastSeenAt: base.Add(2 * time.Hour)}, + store.Key("fake", "cccccccc"): {ID: "cccccccc", Agent: "fake", TmuxSession: "uam-fake-cccccccc", LastSeenAt: base.Add(time.Hour)}, + }} + if got := lastSeenID(cfg); got != "bbbbbbbb" { + t.Fatalf("lastSeenID = %q, want bbbbbbbb (max last_seen_at)", got) + } +} + +// C1-6 — equal LastSeenAt must resolve deterministically (largest id wins) so +// repeated `uam last` invocations are stable. +func TestLastSeenIDTiebreakIsDeterministic(t *testing.T) { + ts := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + cfg := store.Config{Sessions: map[string]store.SessionRecord{ + store.Key("fake", "aaaaaaaa"): {ID: "aaaaaaaa", Agent: "fake", LastSeenAt: ts}, + store.Key("fake", "zzzzzzzz"): {ID: "zzzzzzzz", Agent: "fake", LastSeenAt: ts}, + }} + first := lastSeenID(cfg) + for i := 0; i < 10; i++ { + if got := lastSeenID(cfg); got != first { + t.Fatalf("lastSeenID not deterministic: %q vs %q", got, first) + } + } + if first != "zzzzzzzz" { + t.Fatalf("tiebreak = %q, want zzzzzzzz", first) + } +} + +// C1-6 — `uam last` with no persisted records still surfaces the existing +// "no sessions" error rather than panicking on an empty selection. +func TestRunLastWithNoRecordsFails(t *testing.T) { + svc, _ := newCLITestService(t) + runTUI := func(context.Context, tea.Model) error { return nil } + if err := runLast(context.Background(), svc, runTUI); err == nil { + t.Fatal("expected runLast to fail when no sessions exist") + } +} + +var _ = strings.TrimSpace diff --git a/internal/cli/registry_parity_test.go b/internal/cli/registry_parity_test.go new file mode 100644 index 0000000..3e19857 --- /dev/null +++ b/internal/cli/registry_parity_test.go @@ -0,0 +1,59 @@ +package cli + +import ( + "os" + "path/filepath" + "sort" + "testing" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/agents" + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" + "github.com/RandomCodeSpace/unified-agent-manager/internal/tmux" +) + +// F14 — cli.NewService must register exactly the shared adapter set built by +// agents.Default (which both it and app.New consume). With every provider's CLI +// stubbed on PATH, the enabled registry must contain all five — including +// hermes, the one the old hand-rolled app.New list dropped. Comparing against +// agents.Default's pre-availability Name() set is what makes this a parity +// guard: if a future edit forks the CLI wiring off the shared list, this fails. +func TestNewServiceRegistryMatchesSharedAdapterSet(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{"claude", "codex", "copilot", "hermes", "opencode"} { + writeCLIExecutable(t, filepath.Join(dir, name)) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + st, err := store.Open(filepath.Join(t.TempDir(), "sessions.json")) + if err != nil { + t.Fatal(err) + } + svc := NewService(st) + + got := make([]string, 0) + for _, a := range svc.Registry.Enabled() { + got = append(got, a.Name()) + } + sort.Strings(got) + + want := make([]string, 0) + for _, a := range agents.Default(tmux.New("uam")) { + want = append(want, a.Name()) + } + sort.Strings(want) + + if len(got) != len(want) { + t.Fatalf("cli registry %v != shared adapter set %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("cli registry %v != shared adapter set %v", got, want) + } + } + for _, a := range got { + if a == "hermes" { + return + } + } + t.Fatal("cli registry must include hermes") +} diff --git a/internal/log/log.go b/internal/log/log.go index 12fa2c7..fce104e 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -30,6 +30,16 @@ func Init() (io.Closer, error) { func L() *slog.Logger { return current } +// SetLogger swaps the package logger and returns the previous one, so callers +// (chiefly tests) can install a capturing handler and restore it afterwards. +func SetLogger(l *slog.Logger) *slog.Logger { + prev := current + if l != nil { + current = l + } + return prev +} + func Debug(msg string, args ...any) { current.Debug(msg, args...) } func Info(msg string, args ...any) { current.Info(msg, args...) } func Warn(msg string, args ...any) { current.Warn(msg, args...) } diff --git a/internal/pr/check_argv_test.go b/internal/pr/check_argv_test.go new file mode 100644 index 0000000..92317f8 --- /dev/null +++ b/internal/pr/check_argv_test.go @@ -0,0 +1,104 @@ +package pr + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// writeRecordingGH installs a fake gh at dir/gh that records each argv element +// (one per line) to argvFile, then emits a valid OPEN-PR JSON so Check succeeds. +func writeRecordingGH(t *testing.T, dir, argvFile string) string { + t.Helper() + gh := filepath.Join(dir, "gh") + script := "#!/bin/sh\n" + + ": > " + argvFile + "\n" + + "for a in \"$@\"; do printf '%s\\n' \"$a\" >> " + argvFile + "; done\n" + + "echo '{\"state\":\"OPEN\",\"isDraft\":false,\"mergedAt\":null}'\n" + if err := os.WriteFile(gh, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + return gh +} + +func readArgv(t *testing.T, argvFile string) []string { + t.Helper() + data, err := os.ReadFile(argvFile) + if err != nil { + t.Fatal(err) + } + trimmed := strings.TrimRight(string(data), "\n") + if trimmed == "" { + return nil + } + return strings.Split(trimmed, "\n") +} + +func TestCheckPassesURLAfterEndOfOptionsSeparator(t *testing.T) { + dir := t.TempDir() + argvFile := filepath.Join(dir, "argv") + gh := writeRecordingGH(t, dir, argvFile) + t.Setenv("UAM_GH_BIN", gh) + + const url = "https://github.com/o/r/pull/1" + got, err := Check(context.Background(), url) + if err != nil || got != Open { + t.Fatalf("Check: got %s err %v", got, err) + } + + argv := readArgv(t, argvFile) + if len(argv) == 0 { + t.Fatal("fake gh recorded no argv") + } + if argv[len(argv)-1] != url { + t.Fatalf("url must be the last arg; got argv=%v", argv) + } + if len(argv) < 2 || argv[len(argv)-2] != "--" { + t.Fatalf("`--` must immediately precede the url; got argv=%v", argv) + } +} + +func TestCheckTreatsFlagLikeURLAsPositional(t *testing.T) { + dir := t.TempDir() + argvFile := filepath.Join(dir, "argv") + gh := writeRecordingGH(t, dir, argvFile) + t.Setenv("UAM_GH_BIN", gh) + + // A URL-shaped string whose host segment begins with '-'. It still matches + // the github PR shape but would be parsed as a flag by gh without `--`. + const url = "https://github.com/-o/r/pull/1" + if _, err := Check(context.Background(), url); err != nil { + t.Fatalf("Check: %v", err) + } + + argv := readArgv(t, argvFile) + sep := -1 + for i, a := range argv { + if a == "--" { + sep = i + break + } + } + if sep == -1 { + t.Fatalf("expected `--` separator; got argv=%v", argv) + } + if argv[len(argv)-1] != url { + t.Fatalf("flag-like url must be delivered after `--` as the last arg; got argv=%v", argv) + } +} + +func TestCheckRejectsNonGitHubURL(t *testing.T) { + dir := t.TempDir() + argvFile := filepath.Join(dir, "argv") + gh := writeRecordingGH(t, dir, argvFile) + t.Setenv("UAM_GH_BIN", gh) + + if _, err := Check(context.Background(), "https://evil.example.com/o/r/pull/1"); err == nil { + t.Fatal("non-github URL should be rejected before exec") + } + if _, err := os.Stat(argvFile); !os.IsNotExist(err) { + t.Fatalf("fake gh must not be invoked for a rejected URL (argv file exists: %v)", err) + } +} diff --git a/internal/pr/deadline_test.go b/internal/pr/deadline_test.go new file mode 100644 index 0000000..3992d94 --- /dev/null +++ b/internal/pr/deadline_test.go @@ -0,0 +1,44 @@ +package pr + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +// F02 — Check must honor a context deadline: a hung `gh` is cancelled when the +// caller's context expires rather than blocking the refresh indefinitely. +func TestCheckHonorsContextDeadline(t *testing.T) { + dir := t.TempDir() + gh := filepath.Join(dir, "gh") + // Sleep far longer than the deadline so the only way Check returns promptly + // is by honoring ctx cancellation. + if err := os.WriteFile(gh, []byte("#!/bin/sh\nsleep 30\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("UAM_GH_BIN", gh) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + + done := make(chan error, 1) + start := time.Now() + go func() { + _, err := Check(ctx, "https://github.com/o/r/pull/1") + done <- err + }() + select { + case err := <-done: + if err == nil { + t.Fatal("Check should fail when the context deadline is exceeded") + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("Check did not return promptly after deadline: %v", elapsed) + } + case <-time.After(5 * time.Second): + t.Fatal("Check blocked past the context deadline (gh not reaped)") + } +} diff --git a/internal/pr/gh.go b/internal/pr/gh.go index 041154c..e3ad8e4 100644 --- a/internal/pr/gh.go +++ b/internal/pr/gh.go @@ -3,14 +3,36 @@ package pr import ( "context" "encoding/json" + "errors" "fmt" "os" "os/exec" + "regexp" "strings" + "time" "github.com/RandomCodeSpace/unified-agent-manager/internal/execpath" ) +// ghWaitDelay bounds how long cmd.Wait blocks after the context is cancelled +// before the child's I/O pipes are force-closed. Without it, a hung `gh` (or a +// grandchild that inherited the stdout pipe) keeps cmd.Output blocked past the +// deadline even though CommandContext already signalled the process (F02). +const ghWaitDelay = 2 * time.Second + +var errInvalidURL = errors.New("not a github pull-request url") + +// prURLRE anchors the GitHub PR URL shape. It mirrors adapter.prRE (the regex +// used to scrape a PR URL out of pane text) but is anchored end-to-end so the +// whole string must be a well-formed PR URL, leaving no room for a flag-shaped +// argument to slip through to gh. +var prURLRE = regexp.MustCompile(`^https://github\.com/[^/\s]+/[^/\s]+/pull/\d+$`) + +// ValidURL reports whether url is a well-formed GitHub pull-request URL. +func ValidURL(url string) bool { + return prURLRE.MatchString(url) +} + type Status string const ( @@ -49,11 +71,22 @@ func ParseGHState(data []byte) (Status, error) { } func Check(ctx context.Context, url string) (Status, error) { + if !ValidURL(url) { + return None, fmt.Errorf("invalid PR URL %q: %w", url, errInvalidURL) + } exe, err := ghExecutable() if err != nil { return None, err } - cmd := exec.CommandContext(ctx, exe, "pr", "view", url, "--json", "state,isDraft,mergedAt") // #nosec G204 -- gh path is resolved from fixed system directories; URL is passed as an argv argument with no shell expansion. + // #nosec G204 -- exe is resolved from fixed system dirs (or a validated + // absolute UAM_GH_BIN); url is validated against prURLRE above and passed + // after the `--` end-of-options separator as the final argv argument, so it + // cannot be interpreted as a flag and there is no shell expansion. + cmd := exec.CommandContext(ctx, exe, "pr", "view", "--json", "state,isDraft,mergedAt", "--", url) + // Reap a hung gh promptly after the context is cancelled: WaitDelay caps how + // long cmd.Output waits for the (possibly inherited) I/O pipes before force- + // closing them, so a deadline actually unblocks the caller (F02). + cmd.WaitDelay = ghWaitDelay out, err := cmd.Output() if err != nil { return None, fmt.Errorf("gh pr view: %w", err) diff --git a/internal/refresh/scheduler.go b/internal/refresh/scheduler.go deleted file mode 100644 index 6af6b2a..0000000 --- a/internal/refresh/scheduler.go +++ /dev/null @@ -1,44 +0,0 @@ -package refresh - -import ( - "context" - "time" -) - -// Scheduler merges periodic refresh signals for the Bubble Tea app/service layer. -// The app currently consumes the regular Tick channel directly; this package keeps -// the refresh policy testable and centralized for CLI users that want to embed UAM. -type Scheduler struct { - PollInterval time.Duration - PeekInterval time.Duration - PRInterval time.Duration -} - -func DefaultScheduler() Scheduler { - return Scheduler{PollInterval: 2 * time.Second, PeekInterval: 5 * time.Second, PRInterval: 60 * time.Second} -} - -func (s Scheduler) Ticks(ctx context.Context) <-chan time.Time { - if s.PollInterval <= 0 { - s.PollInterval = 2 * time.Second - } - ch := make(chan time.Time) - go func() { - defer close(ch) - t := time.NewTicker(s.PollInterval) - defer t.Stop() - for { - select { - case <-ctx.Done(): - return - case now := <-t.C: - select { - case ch <- now: - case <-ctx.Done(): - return - } - } - } - }() - return ch -} diff --git a/internal/refresh/scheduler_test.go b/internal/refresh/scheduler_test.go deleted file mode 100644 index 6757863..0000000 --- a/internal/refresh/scheduler_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package refresh - -import ( - "context" - "testing" - "time" -) - -func TestDefaultSchedulerAndTicks(t *testing.T) { - s := DefaultScheduler() - if s.PollInterval == 0 || s.PRInterval == 0 || s.PeekInterval == 0 { - t.Fatalf("bad default %+v", s) - } - ctx, cancel := context.WithCancel(context.Background()) - s.PollInterval = time.Millisecond - ch := s.Ticks(ctx) - select { - case <-ch: - case <-time.After(time.Second): - t.Fatal("no tick") - } - cancel() - select { - case <-ch: - // Channel may yield a final tick or close after cancellation. - case <-time.After(time.Second): - t.Fatal("not closed") - } -} diff --git a/internal/store/integrity_test.go b/internal/store/integrity_test.go new file mode 100644 index 0000000..1452905 --- /dev/null +++ b/internal/store/integrity_test.go @@ -0,0 +1,327 @@ +package store + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// --------------------------------------------------------------------------- +// F07 — v1->v2 migration must not resurrect user-stopped (dead-pane) sessions +// --------------------------------------------------------------------------- + +func writeV1Config(t *testing.T, path string, sessions map[string]any) { + t.Helper() + old := map[string]any{"schema_version": 1, "sessions": sessions} + data, err := json.Marshal(old) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } +} + +func TestMigrateV1_DeadTmuxSession_BecomesClosedByUser(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sessions.json") + writeV1Config(t, path, map[string]any{ + "claude:dead1234": map[string]any{ + "id": "dead1234", + "agent": "claude", + "tmux_session": "uam-claude-dead1234", + }, + }) + s, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + // Probe reports the pane is gone -> the record was a user-stopped session. + s.SetSessionProbe(func(string) bool { return false }) + + cfg, err := s.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + rec, ok := cfg.Sessions["claude:dead1234"] + if !ok { + t.Fatalf("session lost during migration: %+v", cfg.Sessions) + } + if rec.Status != StatusClosedByUser { + t.Fatalf("status = %q, want %q (dead v1 pane must become closed-by-user)", rec.Status, StatusClosedByUser) + } +} + +func TestMigrateV1_LiveTmuxSession_StaysActive(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sessions.json") + writeV1Config(t, path, map[string]any{ + "claude:live1234": map[string]any{ + "id": "live1234", + "agent": "claude", + "tmux_session": "uam-claude-live1234", + }, + }) + s, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + // Probe reports the pane is alive (reboot survivor) -> keep it Active. + s.SetSessionProbe(func(name string) bool { return name == "uam-claude-live1234" }) + + cfg, err := s.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + rec := cfg.Sessions["claude:live1234"] + if rec.Status != StatusActive { + t.Fatalf("status = %q, want %q (live v1 pane must stay active)", rec.Status, StatusActive) + } +} + +func TestMigrateWithNilProbe_FallsBackToActive(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sessions.json") + writeV1Config(t, path, map[string]any{ + "claude:noprobe1": map[string]any{ + "id": "noprobe1", + "agent": "claude", + "tmux_session": "uam-claude-noprobe1", + }, + }) + s, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + // No probe set -> conservative fallback keeps the legacy Active behavior. + cfg, err := s.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if got := cfg.Sessions["claude:noprobe1"].Status; got != StatusActive { + t.Fatalf("status = %q, want %q (nil probe must fall back to active)", got, StatusActive) + } +} + +func TestMigrateV1_DoesNotOverrideExplicitStatus(t *testing.T) { + // A v1 record that already carries an explicit status must be left alone; + // reclassification only targets the Statusless records. + dir := t.TempDir() + path := filepath.Join(dir, "sessions.json") + writeV1Config(t, path, map[string]any{ + "claude:explicit": map[string]any{ + "id": "explicit", + "agent": "claude", + "tmux_session": "uam-claude-explicit", + "status": string(StatusActive), + }, + }) + s, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + s.SetSessionProbe(func(string) bool { return false }) + cfg, err := s.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if got := cfg.Sessions["claude:explicit"].Status; got != StatusActive { + t.Fatalf("status = %q, want %q (explicit status must survive)", got, StatusActive) + } +} + +// --------------------------------------------------------------------------- +// F33 — newer-schema file must be preserved, not clobbered by an older binary +// --------------------------------------------------------------------------- + +func TestLoadNewerSchemaIsPreservedNotClobbered(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sessions.json") + // A file written by a FUTURE binary: higher schema + an unknown field. + future := map[string]any{ + "schema_version": CurrentSchemaVersion + 1, + "default_agent": "codex", + "sessions": map[string]any{}, + "ui": map[string]any{"sort": "state", "peek_width": 60}, + "future_only": map[string]any{"some": "value"}, + } + data, err := json.MarshalIndent(future, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + original, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + s, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + cfg, err := s.Load() + if err != nil { + t.Fatalf("Load must not error on newer schema: %v", err) + } + if !cfg.ReadOnly { + t.Fatal("newer-schema config must be flagged read-only") + } + // A write attempt against a read-only config must be refused (no clobber). + if err := s.Save(cfg); err == nil { + t.Fatal("Save must refuse a read-only (newer-schema) config") + } + if err := s.Update(func(c *Config) error { c.DefaultAgent = "claude"; return nil }); err == nil { + t.Fatal("Update must refuse to write a read-only (newer-schema) config") + } + // The on-disk file must be byte-identical (unknown field intact). + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(original, after) { + t.Fatalf("newer-schema file mutated on disk:\nbefore=%s\nafter=%s", original, after) + } +} + +func TestEqualSchemaUnknownFieldsPreserved(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sessions.json") + // Same schema version but carries an unknown top-level field. + doc := map[string]any{ + "schema_version": CurrentSchemaVersion, + "default_agent": "claude", + "sessions": map[string]any{}, + "ui": map[string]any{"group_by_dir": false, "sort": "state", "peek_width": 60}, + "experimental": "keep-me", + } + data, err := json.MarshalIndent(doc, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + + s, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + cfg, err := s.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.ReadOnly { + t.Fatal("equal-schema config must NOT be read-only") + } + if err := s.Save(cfg); err != nil { + t.Fatalf("Save: %v", err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var m map[string]json.RawMessage + if err := json.Unmarshal(after, &m); err != nil { + t.Fatalf("re-saved file is not valid JSON: %v", err) + } + if _, ok := m["experimental"]; !ok { + t.Fatalf("unknown field 'experimental' dropped on round-trip: %s", after) + } +} + +func TestNormalConfigMarshalsByteIdentical(t *testing.T) { + // A config with NO unknown fields must marshal byte-for-byte the same as it + // did before the overflow machinery was introduced. + cfg := DefaultConfig() + cfg.Sessions[Key("claude", "abcd1234")] = SessionRecord{ + ID: "abcd1234", Agent: "claude", Name: "t", Mode: ModeYolo, Status: StatusActive, + } + + got, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + t.Fatalf("MarshalIndent: %v", err) + } + // Round-trip through unmarshal then marshal again must be stable. + var back Config + if err := json.Unmarshal(got, &back); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + again, err := json.MarshalIndent(back, "", " ") + if err != nil { + t.Fatalf("MarshalIndent (2): %v", err) + } + if !bytes.Equal(got, again) { + t.Fatalf("round-trip not stable:\nfirst=%s\nsecond=%s", got, again) + } +} + +// --------------------------------------------------------------------------- +// F44 — normalize() must clamp/coerce invalid enum/range values, never drop +// --------------------------------------------------------------------------- + +func TestNormalizeRejectsUnknownStatus(t *testing.T) { + cfg := Config{ + SchemaVersion: CurrentSchemaVersion, + Sessions: map[string]SessionRecord{ + "claude:weird001": {ID: "weird001", Agent: "claude", Status: Status("bananas")}, + }, + } + got := normalize(cfg) + rec, ok := got.Sessions["claude:weird001"] + if !ok { + t.Fatalf("normalize dropped the record with unknown status: %+v", got.Sessions) + } + if rec.Status != StatusActive { + t.Fatalf("status = %q, want %q (unknown status must coerce to Active, never Closed)", rec.Status, StatusActive) + } +} + +func TestNormalizeCoercesUnknownMode(t *testing.T) { + cfg := Config{ + SchemaVersion: CurrentSchemaVersion, + Sessions: map[string]SessionRecord{ + "claude:weirdmod": {ID: "weirdmod", Agent: "claude", Mode: Mode("turbo"), Status: StatusActive}, + }, + } + got := normalize(cfg) + if rec := got.Sessions["claude:weirdmod"]; rec.Mode != ModeYolo { + t.Fatalf("mode = %q, want %q (unknown mode must coerce to yolo)", rec.Mode, ModeYolo) + } +} + +func TestNormalizeResetsUnknownSort(t *testing.T) { + cfg := normalize(Config{UI: UISettings{Sort: "nonsense", PeekWidth: 60}}) + if cfg.UI.Sort != "state" { + t.Fatalf("sort = %q, want %q (unknown sort must reset to state)", cfg.UI.Sort, "state") + } +} + +func TestNormalizeClampsNegativePeekWidth(t *testing.T) { + cfg := normalize(Config{UI: UISettings{Sort: "state", PeekWidth: -10}}) + if cfg.UI.PeekWidth <= 0 { + t.Fatalf("peek_width = %d, want a positive default", cfg.UI.PeekWidth) + } +} + +func TestNormalizeNeverDropsSessions(t *testing.T) { + cfg := Config{ + SchemaVersion: CurrentSchemaVersion, + Sessions: map[string]SessionRecord{ + "a:1": {ID: "1", Agent: "a", Status: Status("x"), Mode: Mode("y")}, + "b:2": {ID: "2", Agent: "b", Status: StatusClosedByUser, Mode: ModeSafe}, + "c:3": {ID: "3", Agent: "c"}, + }, + } + got := normalize(cfg) + if len(got.Sessions) != 3 { + t.Fatalf("sessions = %d, want 3 (normalize must never delete records)", len(got.Sessions)) + } + // The deliberately-closed record must NOT be flipped to Active. + if got.Sessions["b:2"].Status != StatusClosedByUser { + t.Fatalf("b:2 status = %q, want %q (valid closed status must survive)", got.Sessions["b:2"].Status, StatusClosedByUser) + } +} diff --git a/internal/store/putsession_test.go b/internal/store/putsession_test.go new file mode 100644 index 0000000..82b7d8b --- /dev/null +++ b/internal/store/putsession_test.go @@ -0,0 +1,75 @@ +package store + +import "testing" + +// --------------------------------------------------------------------------- +// F22 — the 8-char ShortID map key is only 32 bits of entropy, so two distinct +// full IDs of the same agent can collide on the short key. PutSession is a +// guarded insert that refuses to silently overwrite an existing record whose +// full ID differs from the incoming one. +// --------------------------------------------------------------------------- + +func TestPutSessionRefusesShortKeyCollisionWithDifferentFullID(t *testing.T) { + cfg := DefaultConfig() + // Two distinct full UUIDs that share the same 8-char prefix collide on the + // short key Key() derives. + idA := "abcdef12-aaaa-1111-aaaa-111111111111" + idB := "abcdef12-bbbb-2222-bbbb-222222222222" + keyA := Key("claude", idA) + keyB := Key("claude", idB) + if keyA != keyB { + t.Fatalf("test precondition: expected colliding short keys, got %q vs %q", keyA, keyB) + } + + recA := SessionRecord{ID: idA, Agent: "claude", TmuxSession: "uam-claude-a"} + recB := SessionRecord{ID: idB, Agent: "claude", TmuxSession: "uam-claude-b"} + + if !cfg.PutSession(keyA, recA) { + t.Fatalf("first PutSession must succeed (no existing record)") + } + // A collision on the short key but a DIFFERENT full ID must be refused so the + // distinct session A is not silently clobbered. + if cfg.PutSession(keyB, recB) { + t.Fatalf("PutSession must refuse to overwrite a record with a different full ID") + } + if got := cfg.Sessions[keyA].ID; got != idA { + t.Fatalf("existing record clobbered: ID = %q, want %q", got, idA) + } +} + +func TestPutSessionUpdatesSameFullID(t *testing.T) { + cfg := DefaultConfig() + id := "abcdef12-aaaa-1111-aaaa-111111111111" + key := Key("claude", id) + if !cfg.PutSession(key, SessionRecord{ID: id, Agent: "claude", Name: "old"}) { + t.Fatalf("initial insert must succeed") + } + // Same full ID -> an in-place update of the live<->stored join must succeed. + if !cfg.PutSession(key, SessionRecord{ID: id, Agent: "claude", Name: "new"}) { + t.Fatalf("PutSession with the same full ID must succeed (update)") + } + if got := cfg.Sessions[key].Name; got != "new" { + t.Fatalf("record not updated: Name = %q, want %q", got, "new") + } +} + +func TestShortKeyJoinsLiveSessionToFullUUIDStoredRecord(t *testing.T) { + // The short key must remain the join between a live session (keyed by its + // full UUID via Key) and the stored record. PutSession must not break that: + // re-deriving the key from the same full UUID resolves the stored record. + cfg := DefaultConfig() + fullID := "abcdef12-aaaa-1111-aaaa-111111111111" + key := Key("claude", fullID) + rec := SessionRecord{ID: fullID, Agent: "claude", TmuxSession: "uam-claude-x"} + if !cfg.PutSession(key, rec) { + t.Fatalf("PutSession must succeed") + } + // A live session reporting the same full UUID derives the same short key. + got, ok := cfg.Sessions[Key("claude", fullID)] + if !ok { + t.Fatalf("short key did not join live session to stored record") + } + if got.ID != fullID { + t.Fatalf("joined record has wrong full ID: %q", got.ID) + } +} diff --git a/internal/store/quarantine_test.go b/internal/store/quarantine_test.go new file mode 100644 index 0000000..bc552dc --- /dev/null +++ b/internal/store/quarantine_test.go @@ -0,0 +1,80 @@ +package store + +import ( + "os" + "path/filepath" + "testing" +) + +// --------------------------------------------------------------------------- +// F43 — when quarantining a corrupt config fails (moveAside rename error), Load +// must NOT swallow the error and hand back DefaultConfig: returning a default +// would let the next Save overwrite the still-present original with no backup. +// Propagating the error makes the app refuse to clobber. +// --------------------------------------------------------------------------- + +func TestLoadCorruptJSONQuarantineFailurePreservesOriginal(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root can rename within a read-only directory; cannot force a moveAside failure") + } + dir := t.TempDir() + path := filepath.Join(dir, "sessions.json") + corrupt := []byte("{bad json") + if err := os.WriteFile(path, corrupt, 0o644); err != nil { + t.Fatal(err) + } + // Pre-create the lock file so the flock OpenFile succeeds on an EXISTING file + // even after the directory is made read-only: that isolates the failure to + // the moveAside rename (which needs write perm on the directory), which is + // exactly the path under test. + if err := os.WriteFile(path+".lock", nil, 0o600); err != nil { + t.Fatal(err) + } + s, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + // Make the parent directory read-only so the moveAside rename fails. + if err := os.Chmod(dir, 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(dir, 0o700) }) + + cfg, err := s.Load() + if err == nil { + t.Fatalf("Load must return an error when quarantine fails, got cfg=%+v", cfg) + } + // The original corrupt file must still be present (not silently lost) and + // untouched, so a later run can recover or the user can inspect it. + got, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatalf("original file vanished after failed quarantine: %v", readErr) + } + if string(got) != string(corrupt) { + t.Fatalf("original file mutated: %q", got) + } +} + +func TestLoadCorruptJSONQuarantineSuccessStartsFreshWithNonNilMap(t *testing.T) { + // The success path must still return a normalized DefaultConfig (non-nil map) + // and no error, preserving TestLoadCorruptJSONBacksUpAndStartsFresh behavior. + dir := t.TempDir() + path := filepath.Join(dir, "sessions.json") + if err := os.WriteFile(path, []byte("{bad json"), 0o644); err != nil { + t.Fatal(err) + } + s, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + cfg, err := s.Load() + if err != nil { + t.Fatalf("Load on a writable dir must succeed: %v", err) + } + if cfg.Sessions == nil { + t.Fatal("returned config has a nil Sessions map") + } + if cfg.SchemaVersion != CurrentSchemaVersion { + t.Fatalf("schema = %d, want %d", cfg.SchemaVersion, CurrentSchemaVersion) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 065036a..898d4e5 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -7,15 +7,33 @@ import ( "io" "os" "path/filepath" + "regexp" "strings" "syscall" "time" + "unicode" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/log" ) const CurrentSchemaVersion = 2 const configFileName = "sessions.json" +// UI defaults and bounds. normalize clamps/coerces out-of-range or unknown +// on-disk values so a hand-edited or corrupt config can never feed an invalid +// value downstream (F44). +const ( + defaultSort = "state" + defaultPeekWidth = 60 + minPeekWidth = 20 + maxPeekWidth = 200 +) + +// knownSorts is the set of sort modes the UI understands; anything else is +// reset to defaultSort. +var knownSorts = map[string]struct{}{defaultSort: {}} + type Mode string const ( @@ -38,6 +56,93 @@ type Config struct { DefaultAgent string `json:"default_agent"` Sessions map[string]SessionRecord `json:"sessions"` UI UISettings `json:"ui"` + + // unknown captures any top-level JSON fields written by a newer binary so + // they round-trip untouched instead of being silently dropped (F33). It is + // never persisted directly — MarshalJSON merges it back into the object. + unknown map[string]json.RawMessage + // ReadOnly is set when the on-disk file declares a SchemaVersion newer than + // this binary understands. The app must not write such a config (doing so + // would drop fields it does not model), so Save/Update refuse it (F33). It + // is in-memory only and never serialized. + ReadOnly bool +} + +// ErrReadOnly is returned by Save/Update when asked to write a config loaded +// from a newer on-disk schema. Refusing the write prevents an older binary +// from clobbering fields it does not understand (F33). +var ErrReadOnly = errors.New("store: config loaded from a newer schema is read-only") + +// configAlias mirrors Config without the custom marshaller (and without the +// in-memory-only fields) so (Un)MarshalJSON can delegate to the stdlib encoder +// without recursing. +type configAlias struct { + SchemaVersion int `json:"schema_version"` + DefaultAgent string `json:"default_agent"` + Sessions map[string]SessionRecord `json:"sessions"` + UI UISettings `json:"ui"` +} + +// knownConfigFields lists the JSON keys Config models directly; everything else +// is preserved verbatim via the unknown overflow. +var knownConfigFields = map[string]struct{}{ + "schema_version": {}, + "default_agent": {}, + "sessions": {}, + "ui": {}, +} + +func (c Config) MarshalJSON() ([]byte, error) { + base, err := json.Marshal(configAlias{ + SchemaVersion: c.SchemaVersion, + DefaultAgent: c.DefaultAgent, + Sessions: c.Sessions, + UI: c.UI, + }) + if err != nil { + return nil, err + } + if len(c.unknown) == 0 { + return base, nil + } + var merged map[string]json.RawMessage + if err := json.Unmarshal(base, &merged); err != nil { + return nil, err + } + for k, v := range c.unknown { + if _, known := knownConfigFields[k]; known { + continue + } + merged[k] = v + } + return json.Marshal(merged) +} + +func (c *Config) UnmarshalJSON(data []byte) error { + var alias configAlias + if err := json.Unmarshal(data, &alias); err != nil { + return err + } + c.SchemaVersion = alias.SchemaVersion + c.DefaultAgent = alias.DefaultAgent + c.Sessions = alias.Sessions + c.UI = alias.UI + + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + for k := range raw { + if _, known := knownConfigFields[k]; known { + delete(raw, k) + } + } + if len(raw) == 0 { + c.unknown = nil + } else { + c.unknown = raw + } + return nil } type UISettings struct { @@ -70,7 +175,13 @@ type PRRecord struct { LastChecked time.Time `json:"last_checked"` } -type Store struct{ path string } +type Store struct { + path string + // sessionExists, when set, reports whether a tmux session name is still + // live. It is injected by the caller (the store stays tmux-free) and is used + // only to reclassify Statusless v1 records during migration (F07). + sessionExists func(string) bool +} func Open(path string) (*Store, error) { if path == "" { @@ -82,6 +193,12 @@ func Open(path string) (*Store, error) { return &Store{path: path}, nil } +// SetSessionProbe injects a callback that reports whether a tmux session name is +// still live. Migration uses it to tell a reboot-survivor (live pane -> stays +// Active) apart from a user-stopped session (dead pane -> closed-by-user). When +// unset, migration conservatively keeps the legacy Active behavior (F07). +func (s *Store) SetSessionProbe(exists func(string) bool) { s.sessionExists = exists } + func (s *Store) Path() string { return s.path } func DefaultPath() string { @@ -106,6 +223,25 @@ func DefaultConfig() Config { } } +// PutSession inserts or updates rec under key with a guard against the 8-char +// ShortID map key collapsing two distinct full IDs into one slot (F22). The +// short key carries only 32 bits of entropy, so two same-agent sessions can +// collide; without this guard the second write would silently clobber the +// first, orphaning a live session whose only handle is that record. It returns +// true on a successful write (no record, or the same full ID) and false (with a +// log) when an existing record under key carries a different non-empty full ID. +func (c *Config) PutSession(key string, rec SessionRecord) bool { + if c.Sessions == nil { + c.Sessions = map[string]SessionRecord{} + } + if existing, ok := c.Sessions[key]; ok && existing.ID != "" && rec.ID != "" && existing.ID != rec.ID { + log.Warn("refusing short-key collision overwrite", "key", key, "existing_id", existing.ID, "incoming_id", rec.ID) + return false + } + c.Sessions[key] = rec + return true +} + func Key(agent, id string) string { return strings.ToLower(agent) + ":" + ShortID(id) } @@ -128,6 +264,9 @@ func (s *Store) Load() (Config, error) { } func (s *Store) Save(cfg Config) error { + if cfg.ReadOnly { + return ErrReadOnly + } unlock, err := s.lock() if err != nil { return err @@ -146,6 +285,9 @@ func (s *Store) Update(fn func(*Config) error) error { if err != nil { return err } + if cfg.ReadOnly { + return ErrReadOnly + } if err := fn(&cfg); err != nil { return err } @@ -162,13 +304,32 @@ func (s *Store) loadNoLock() (Config, error) { } var cfg Config if err := json.Unmarshal(data, &cfg); err != nil { - _ = s.moveAside() - return DefaultConfig(), nil + // Quarantine the corrupt file before starting fresh. If the move-aside + // fails the original is still on disk, so returning DefaultConfig here + // would let the next Save overwrite it with defaults and lose the only + // copy. Propagate the error instead so the app refuses to clobber it + // (F43). normalize guarantees a non-nil Sessions map on the success path. + if mvErr := s.moveAside(); mvErr != nil { + return Config{}, fmt.Errorf("quarantine corrupt config %s: %w", s.path, mvErr) + } + return normalize(DefaultConfig()), nil + } + // A file written by a newer binary carries fields this version does not + // model. Surface it read-only (preserving the unknown overflow) instead of + // erroring or clobbering it on the next save (F33). + if cfg.SchemaVersion > CurrentSchemaVersion { + cfg.ReadOnly = true + return cfg, nil } + dropInvalidRecords(&cfg) if cfg.SchemaVersion < CurrentSchemaVersion { if err := s.copyBackup(); err != nil { return Config{}, err } + // Reclassify Statusless v1 records BEFORE normalize backfills them to + // Active: a dead-pane record was a user-stopped session and must not + // auto-resume on attach (F07). normalize stays unchanged. + reclassifyV1Closed(&cfg, s.sessionExists) cfg = migrate(normalize(cfg)) if err := s.saveNoLock(cfg); err != nil { return Config{}, err @@ -177,6 +338,95 @@ func (s *Store) loadNoLock() (Config, error) { return normalize(cfg), nil } +// reclassifyV1Closed runs on the RAW pre-normalize config during a v1->v2 +// migration. For each Statusless record it asks the injected probe whether the +// tmux pane is still alive: a live pane is a reboot survivor (left Statusless so +// normalize/migrate backfill it to Active), while a dead pane was a deliberately +// stopped session and is marked closed-by-user so it does not auto-resume. With +// no probe it is a no-op, preserving the legacy all-Active behavior. +func reclassifyV1Closed(cfg *Config, exists func(string) bool) { + if exists == nil || cfg.SchemaVersion >= 2 { + return + } + for k, rec := range cfg.Sessions { + if rec.Status == "" && !exists(rec.TmuxSession) { + rec.Status = StatusClosedByUser + cfg.Sessions[k] = rec + } + } +} + +// unsafeArgvChars are the shell metacharacters that must never appear in an ID +// or tmux session name. Although uam reaches tmux via argv (not a shell — see +// internal/tmux ShellJoin), these fields are an untrusted-on-disk injection +// surface for the F05/F06/F09 sinks, so we keep classic shell metacharacters +// out as defense in depth. Whitespace and control runes are rejected +// separately. Note `:` is intentionally allowed: it is tmux's target separator +// but never a shell hazard, and real Key-derived values can carry it. +const unsafeArgvChars = "'\"`;&|$<>(){}[]*?!#\\~/ \t\n\r" + +// prURLRE mirrors the GitHub PR URL shape recognized by the adapter's +// ExtractPR (internal/adapter/detect.go), anchored end-to-end so a record +// cannot smuggle in an unrelated or malformed URL. +var prURLRE = regexp.MustCompile(`^https://github\.com/[^/\s]+/[^/\s]+/pull/\d+$`) + +// dropInvalidRecords removes any session record whose untrusted on-disk fields +// fail validation, logging each drop. It runs on load ONLY (not on the write +// path via normalize) so a single corrupt or hostile record cannot brick the +// whole store — the bad record is discarded and the rest survive. +func dropInvalidRecords(cfg *Config) { + for key, rec := range cfg.Sessions { + if reason := validateRecord(rec); reason != "" { + log.Warn("dropping invalid session record", "key", key, "reason", reason) + delete(cfg.Sessions, key) + } + } +} + +// validateRecord returns a non-empty reason if the record must be dropped, or +// "" if it is safe to keep. It rejects only the values that carry real risk — +// shell metacharacters or control runes in the argv-bound ID/TmuxSession +// fields, a non-absolute or control-char Workdir, and a PR URL that does not +// match the GitHub PR shape. Empty optional fields are allowed. +func validateRecord(rec SessionRecord) string { + if isUnsafeArgv(rec.ID) { + return "unsafe id" + } + if isUnsafeArgv(rec.TmuxSession) { + return "unsafe tmux_session" + } + if rec.Workdir != "" { + if !filepath.IsAbs(rec.Workdir) { + return "non-absolute workdir" + } + if hasControlChar(rec.Workdir) { + return "control char in workdir" + } + } + if rec.PR != nil && !prURLRE.MatchString(rec.PR.URL) { + return "invalid pr url" + } + return "" +} + +// isUnsafeArgv reports whether s contains a shell metacharacter or a control +// rune that has no place in a persisted ID or tmux session name. +func isUnsafeArgv(s string) bool { + if strings.ContainsAny(s, unsafeArgvChars) { + return true + } + return hasControlChar(s) +} + +func hasControlChar(s string) bool { + for _, r := range s { + if unicode.IsControl(r) { + return true + } + } + return false +} + func normalize(cfg Config) Config { if cfg.SchemaVersion == 0 { cfg.SchemaVersion = CurrentSchemaVersion @@ -187,21 +437,51 @@ func normalize(cfg Config) Config { if cfg.Sessions == nil { cfg.Sessions = map[string]SessionRecord{} } - if cfg.UI.Sort == "" { - cfg.UI.Sort = "state" - } - if cfg.UI.PeekWidth == 0 { - cfg.UI.PeekWidth = 60 + if _, ok := knownSorts[cfg.UI.Sort]; !ok { + cfg.UI.Sort = defaultSort } + cfg.UI.PeekWidth = clampPeekWidth(cfg.UI.PeekWidth) for k, rec := range cfg.Sessions { - if rec.Status == "" { - rec.Status = StatusActive + if changed := coerceRecord(&rec); changed { cfg.Sessions[k] = rec } } return cfg } +// clampPeekWidth coerces an out-of-range peek width back into bounds. A +// non-positive value (unset or corrupt) falls back to the default; otherwise it +// is clamped into [minPeekWidth, maxPeekWidth]. +func clampPeekWidth(w int) int { + switch { + case w <= 0: + return defaultPeekWidth + case w < minPeekWidth: + return minPeekWidth + case w > maxPeekWidth: + return maxPeekWidth + default: + return w + } +} + +// coerceRecord normalizes a record's enum fields to valid values, reporting +// whether it changed anything. An empty or unknown Status becomes Active and an +// unknown Mode becomes yolo — unknown values are NEVER coerced to ClosedByUser +// or dropped, so a hostile/corrupt status can never silently retire a session. +func coerceRecord(rec *SessionRecord) bool { + changed := false + if rec.Status != StatusActive && rec.Status != StatusClosedByUser { + rec.Status = StatusActive + changed = true + } + if rec.Mode != ModeYolo && rec.Mode != ModeSafe { + rec.Mode = ModeYolo + changed = true + } + return changed +} + func migrate(cfg Config) Config { // v1 → v2 backfills Status. Pre-v2 records had no notion of user-closed, // so every existing row defaults to Active. Soft-closed records from @@ -223,11 +503,15 @@ func (s *Store) saveNoLock(cfg Config) error { if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { return err } - tmp := fmt.Sprintf("%s.tmp.%d", s.path, os.Getpid()) - f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) // #nosec G304 -- UAM intentionally writes its own config file path. + // CreateTemp picks a randomly-suffixed name with O_EXCL in the SAME + // directory as the target (filepath.Dir) — same dir is required because a + // cross-device rename fails with EXDEV. The random suffix means a stale + // orphan from a previously-killed run is never silently reused (F45). + f, err := os.CreateTemp(filepath.Dir(s.path), filepath.Base(s.path)+".tmp.*") if err != nil { return err } + tmp := f.Name() enc := json.NewEncoder(f) enc.SetIndent("", " ") if err := enc.Encode(cfg); err != nil { diff --git a/internal/store/store_test.go b/internal/store/store_test.go index df7b6d4..78ad94d 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -2,8 +2,10 @@ package store import ( "encoding/json" + "fmt" "os" "path/filepath" + "sync" "testing" "time" ) @@ -151,6 +153,99 @@ func TestMigrateV1BackfillsStatusActive(t *testing.T) { } } +func TestStoreUpdateIsAtomicUnderConcurrency(t *testing.T) { + // Characterizes the existing flock + read-modify-write + atomic-rename + // behavior of Store.Update: concurrent writers each inserting a distinct + // session key must not lose each other's writes. This is the safety net + // that protects the later Update/LoadSessions refactor (F01). + const writers = 20 + + dir := t.TempDir() + s, err := Open(filepath.Join(dir, "sessions.json")) + if err != nil { + t.Fatalf("Open: %v", err) + } + + var wg sync.WaitGroup + wg.Add(writers) + for i := 0; i < writers; i++ { + go func(i int) { + defer wg.Done() + key := Key("claude", fmt.Sprintf("sess%04d", i)) + err := s.Update(func(cfg *Config) error { + cfg.Sessions[key] = SessionRecord{ID: key, Agent: "claude"} + return nil + }) + if err != nil { + t.Errorf("Update(%s): %v", key, err) + } + }(i) + } + wg.Wait() + + cfg, err := s.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(cfg.Sessions) != writers { + t.Fatalf("sessions = %d, want %d (lost writes)", len(cfg.Sessions), writers) + } + for i := 0; i < writers; i++ { + key := Key("claude", fmt.Sprintf("sess%04d", i)) + if _, ok := cfg.Sessions[key]; !ok { + t.Errorf("missing key %q after concurrent updates", key) + } + } +} + +func TestUpdateSerializesConcurrentWriters_Race(t *testing.T) { + // Stronger than the distinct-key case: concurrent writers performing a + // read-modify-write on the SAME key must serialize, so every increment + // lands. This is the atomic-RMW guarantee LoadSessions (F01) relies on — + // it re-reads inside the flock instead of saving a stale whole-config + // snapshot. + const writers = 25 + + dir := t.TempDir() + s, err := Open(filepath.Join(dir, "sessions.json")) + if err != nil { + t.Fatalf("Open: %v", err) + } + key := Key("claude", "shared00") + if err := s.Update(func(cfg *Config) error { + cfg.Sessions[key] = SessionRecord{ID: "shared00", Agent: "claude"} + return nil + }); err != nil { + t.Fatalf("seed: %v", err) + } + + var wg sync.WaitGroup + wg.Add(writers) + for i := 0; i < writers; i++ { + go func() { + defer wg.Done() + err := s.Update(func(cfg *Config) error { + rec := cfg.Sessions[key] + rec.SortIndex++ + cfg.Sessions[key] = rec + return nil + }) + if err != nil { + t.Errorf("Update: %v", err) + } + }() + } + wg.Wait() + + cfg, err := s.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if got := cfg.Sessions[key].SortIndex; got != writers { + t.Fatalf("SortIndex = %d, want %d (lost read-modify-write updates)", got, writers) + } +} + func TestNormalizeBackfillsStatusForExistingSessions(t *testing.T) { // Even at the current schema version, a record with empty Status should // be normalized to Active on every load so downstream consumers never diff --git a/internal/store/temp_test.go b/internal/store/temp_test.go new file mode 100644 index 0000000..8137d35 --- /dev/null +++ b/internal/store/temp_test.go @@ -0,0 +1,61 @@ +package store + +import ( + "fmt" + "os" + "path/filepath" + "testing" +) + +// --------------------------------------------------------------------------- +// F45 — the atomic write must use a randomly-suffixed temp file in the SAME +// directory (os.CreateTemp(filepath.Dir(path), ...)). The old fixed +// ".tmp." name had no O_EXCL/random suffix, so a stale orphan from a +// previously-killed run with the same PID could linger or be silently reused. +// --------------------------------------------------------------------------- + +func TestSaveLeavesNoTempOrphan(t *testing.T) { + dir := t.TempDir() + s, err := Open(filepath.Join(dir, "sessions.json")) + if err != nil { + t.Fatalf("Open: %v", err) + } + if err := s.Save(DefaultConfig()); err != nil { + t.Fatalf("Save: %v", err) + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + name := e.Name() + if name == "sessions.json" || name == "sessions.json.lock" { + continue + } + t.Fatalf("unexpected leftover file after Save: %q", name) + } +} + +func TestSaveDoesNotUsePredictablePidTempName(t *testing.T) { + // Pre-occupy the OLD predictable ".tmp." name with a DIRECTORY. + // The legacy code opened that exact path with O_CREATE|O_TRUNC|O_WRONLY, + // which fails on a directory, so Save would error. The fixed code uses + // os.CreateTemp with a random suffix and never touches the predictable + // name, so Save succeeds — proving the temp name is no longer predictable. + dir := t.TempDir() + path := filepath.Join(dir, "sessions.json") + predictable := fmt.Sprintf("%s.tmp.%d", path, os.Getpid()) + if err := os.Mkdir(predictable, 0o700); err != nil { + t.Fatal(err) + } + s, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + if err := s.Save(DefaultConfig()); err != nil { + t.Fatalf("Save must not rely on the predictable PID temp name: %v", err) + } + if _, err := s.Load(); err != nil { + t.Fatalf("Load after Save: %v", err) + } +} diff --git a/internal/store/validate_test.go b/internal/store/validate_test.go new file mode 100644 index 0000000..7c24391 --- /dev/null +++ b/internal/store/validate_test.go @@ -0,0 +1,270 @@ +package store + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// writeConfig marshals an arbitrary config map to the store path. Using a +// generic map (not Config) lets these tests inject values that the typed +// constructors would never produce — exactly the untrusted-on-disk shape that +// load-time validation must defend against. +func writeConfig(t *testing.T, sessions map[string]any) *Store { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "sessions.json") + cfg := map[string]any{ + "schema_version": CurrentSchemaVersion, + "default_agent": "claude", + "sessions": sessions, + } + data, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatalf("write: %v", err) + } + s, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + return s +} + +// goodRecord is the canonical full-UUID-style record produced by Dispatch. +func goodRecord() map[string]any { + return map[string]any{ + "id": "12345678-1234-4234-9234-123456789abc", + "agent": "claude", + "name": "fix tests", + "mode": "yolo", + "workdir": "/tmp/repo", + "tmux_session": "uam-claude-12345678", + "status": "active", + } +} + +func TestLoadDropsRecordWithMetacharTmuxSession(t *testing.T) { + s := writeConfig(t, map[string]any{ + "claude:12345678": goodRecord(), + "claude:evil1234": map[string]any{ + "id": "evil1234", + "agent": "claude", + "tmux_session": "evil'; touch x #", + }, + }) + cfg, err := s.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if _, ok := cfg.Sessions["claude:evil1234"]; ok { + t.Fatal("record with shell-metachar tmux_session was NOT dropped") + } + if _, ok := cfg.Sessions["claude:12345678"]; !ok { + t.Fatal("valid sibling record was wrongly dropped") + } +} + +func TestLoadDropsRecordWithMetacharID(t *testing.T) { + s := writeConfig(t, map[string]any{ + "claude:12345678": goodRecord(), + "claude:bad": map[string]any{ + "id": "evil'; rm -rf /", + "agent": "claude", + "tmux_session": "uam-claude-evilrmrf", + }, + }) + cfg, err := s.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if _, ok := cfg.Sessions["claude:bad"]; ok { + t.Fatal("record with shell-metachar id was NOT dropped") + } + if _, ok := cfg.Sessions["claude:12345678"]; !ok { + t.Fatal("valid sibling record was wrongly dropped") + } +} + +func TestLoadDropsRecordWithBadPRURL(t *testing.T) { + s := writeConfig(t, map[string]any{ + "claude:12345678": goodRecord(), + "claude:badpr111": map[string]any{ + "id": "badpr111", + "agent": "claude", + "tmux_session": "uam-claude-badpr111", + "pr": map[string]any{ + "url": "https://evil.example.com/not/a/pull/req", + "number": 1, + }, + }, + }) + cfg, err := s.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if _, ok := cfg.Sessions["claude:badpr111"]; ok { + t.Fatal("record with non-github PR url was NOT dropped") + } + if _, ok := cfg.Sessions["claude:12345678"]; !ok { + t.Fatal("valid sibling record was wrongly dropped") + } +} + +func TestLoadDropsRecordWithRelativeWorkdir(t *testing.T) { + s := writeConfig(t, map[string]any{ + "claude:12345678": goodRecord(), + "claude:relwd111": map[string]any{ + "id": "relwd111", + "agent": "claude", + "tmux_session": "uam-claude-relwd111", + "workdir": "relative/path", + }, + }) + cfg, err := s.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if _, ok := cfg.Sessions["claude:relwd111"]; ok { + t.Fatal("record with non-absolute workdir was NOT dropped") + } + if _, ok := cfg.Sessions["claude:12345678"]; !ok { + t.Fatal("valid sibling record was wrongly dropped") + } +} + +func TestLoadDropsRecordWithControlCharWorkdir(t *testing.T) { + s := writeConfig(t, map[string]any{ + "claude:12345678": goodRecord(), + "claude:ctlwd111": map[string]any{ + "id": "ctlwd111", + "agent": "claude", + "tmux_session": "uam-claude-ctlwd111", + "workdir": "/tmp/re\npo", + }, + }) + cfg, err := s.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if _, ok := cfg.Sessions["claude:ctlwd111"]; ok { + t.Fatal("record with control-char workdir was NOT dropped") + } + if _, ok := cfg.Sessions["claude:12345678"]; !ok { + t.Fatal("valid sibling record was wrongly dropped") + } +} + +// --- GREEN accept-tests: legitimate records must survive load unchanged. --- + +func TestLoadKeepsCanonicalUUIDRecord(t *testing.T) { + s := writeConfig(t, map[string]any{"claude:12345678": goodRecord()}) + cfg, err := s.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + rec, ok := cfg.Sessions["claude:12345678"] + if !ok { + t.Fatal("canonical UUID-style record was wrongly dropped") + } + if rec.ID != "12345678-1234-4234-9234-123456789abc" || rec.TmuxSession != "uam-claude-12345678" { + t.Fatalf("record mutated: %+v", rec) + } +} + +func TestLoadKeepsListDiscoveredShape(t *testing.T) { + // The List path persists records whose ID is the 8-hex remainder after the + // "uam--" prefix is trimmed. These must survive validation. + s := writeConfig(t, map[string]any{ + "claude:abcd1234": map[string]any{ + "id": "abcd1234", + "agent": "claude", + "tmux_session": "uam-claude-abcd1234", + "status": "active", + }, + }) + cfg, err := s.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if _, ok := cfg.Sessions["claude:abcd1234"]; !ok { + t.Fatal("8-hex List-discovered record was wrongly dropped") + } +} + +func TestLoadKeepsNilPRRecord(t *testing.T) { + rec := goodRecord() + delete(rec, "pr") + s := writeConfig(t, map[string]any{"claude:12345678": rec}) + cfg, err := s.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + got, ok := cfg.Sessions["claude:12345678"] + if !ok { + t.Fatal("nil-PR record was wrongly dropped") + } + if got.PR != nil { + t.Fatalf("PR unexpectedly populated: %+v", got.PR) + } +} + +func TestLoadKeepsValidPRRecord(t *testing.T) { + rec := goodRecord() + rec["pr"] = map[string]any{ + "url": "https://github.com/owner/repo/pull/42", + "number": 42, + } + s := writeConfig(t, map[string]any{"claude:12345678": rec}) + cfg, err := s.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + got, ok := cfg.Sessions["claude:12345678"] + if !ok { + t.Fatal("record with valid github PR url was wrongly dropped") + } + if got.PR == nil || got.PR.Number != 42 { + t.Fatalf("PR not preserved: %+v", got.PR) + } +} + +func TestLoadKeepsMigratedRecord(t *testing.T) { + // A pre-v2 record (no status field) must survive both migration and + // validation. This mirrors TestMigrateV1BackfillsStatusActive but proves + // validation does not regress the migration path. + dir := t.TempDir() + path := filepath.Join(dir, "sessions.json") + old := map[string]any{ + "schema_version": 1, + "sessions": map[string]any{ + "claude:abcd1234": map[string]any{ + "id": "abcd1234", + "agent": "claude", + "tmux_session": "uam-claude-abcd1234", + }, + }, + } + data, _ := json.Marshal(old) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + s, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + cfg, err := s.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + rec, ok := cfg.Sessions["claude:abcd1234"] + if !ok { + t.Fatalf("migrated record dropped by validation: %+v", cfg.Sessions) + } + if rec.Status != StatusActive { + t.Fatalf("status = %q, want %q", rec.Status, StatusActive) + } +} diff --git a/internal/tmux/killserver_test.go b/internal/tmux/killserver_test.go new file mode 100644 index 0000000..5713b36 --- /dev/null +++ b/internal/tmux/killserver_test.go @@ -0,0 +1,68 @@ +package tmux + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// F24 — KillServer must tear down the private tmux server by emitting +// `tmux -L kill-server`. +func TestKillServerEmitsKillServer(t *testing.T) { + c, logPath := setupFakeTmuxClient(t) + if err := c.KillServer(context.Background()); err != nil { + t.Fatalf("KillServer: %v", err) + } + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "kill-server") { + t.Fatalf("KillServer did not emit kill-server: %s", data) + } +} + +// F24 — killing an already-dead server must be idempotent: tmux exits non-zero +// with a "no server" message, which KillServer treats as success. +func TestKillServerOnDeadServerIsIdempotent(t *testing.T) { + dir := t.TempDir() + script := filepath.Join(dir, "tmux") + if err := os.WriteFile(script, []byte(`#!/bin/sh +printf '%s\n' "$*" >> "$TMUX_LOG" +echo "no server running on /tmp/tmux-uam" >&2 +exit 1 +`), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("TMUX_LOG", filepath.Join(dir, "log")) + c := New("uam") + c.Executable = script + + if err := c.KillServer(context.Background()); err != nil { + t.Fatalf("KillServer on a dead server must be idempotent, got: %v", err) + } +} + +// F24 — a genuine failure (not a missing server) must still propagate. +func TestKillServerPropagatesGenuineError(t *testing.T) { + dir := t.TempDir() + script := filepath.Join(dir, "tmux") + if err := os.WriteFile(script, []byte(`#!/bin/sh +printf '%s\n' "$*" >> "$TMUX_LOG" +echo "permission denied" >&2 +exit 1 +`), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("TMUX_LOG", filepath.Join(dir, "log")) + c := New("uam") + c.Executable = script + + if err := c.KillServer(context.Background()); err == nil { + t.Fatal("KillServer must propagate a genuine (non-missing-server) error") + } +} diff --git a/internal/tmux/listcache_test.go b/internal/tmux/listcache_test.go new file mode 100644 index 0000000..da3f67e --- /dev/null +++ b/internal/tmux/listcache_test.go @@ -0,0 +1,101 @@ +package tmux + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func newCountingListClient(t *testing.T) (*Client, string) { + t.Helper() + dir := t.TempDir() + tmuxPath := filepath.Join(dir, "tmux") + if err := os.WriteFile(tmuxPath, []byte(`#!/bin/sh +printf '%s\n' "$*" >> "$TMUX_LOG" +case "$*" in + *"list-sessions"*) echo "uam-fake-abc12345|1710000000|0|1|/tmp/repo|fakeagent" ;; +esac +exit 0 +`), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + logPath := filepath.Join(dir, "tmux.log") + t.Setenv("TMUX_LOG", logPath) + c := New("uam") + c.Executable = tmuxPath + return c, logPath +} + +func countListSessions(t *testing.T, logPath string) int { + t.Helper() + b, _ := os.ReadFile(logPath) + n := 0 + for _, line := range strings.Split(string(b), "\n") { + if strings.Contains(line, "list-sessions") { + n++ + } + } + return n +} + +// F60 — multiple List calls within the cache TTL (one per adapter per refresh +// tick) must collapse to a single list-sessions shell-out. +func TestListIsTTLCachedWithinWindow(t *testing.T) { + c, logPath := newCountingListClient(t) + clock := time.Unix(1710000000, 0) + c.now = func() time.Time { return clock } + + for i := 0; i < 5; i++ { + if _, err := c.List(context.Background()); err != nil { + t.Fatalf("List %d: %v", i, err) + } + } + if got := countListSessions(t, logPath); got != 1 { + t.Fatalf("List calls within TTL must collapse to one shell-out, got %d", got) + } +} + +// F60 — once the TTL elapses, List must shell out again to pick up new sessions. +func TestListRefreshesAfterTTL(t *testing.T) { + c, logPath := newCountingListClient(t) + clock := time.Unix(1710000000, 0) + c.now = func() time.Time { return clock } + + if _, err := c.List(context.Background()); err != nil { + t.Fatalf("List 1: %v", err) + } + clock = clock.Add(listCacheTTL + time.Millisecond) + c.now = func() time.Time { return clock } + if _, err := c.List(context.Background()); err != nil { + t.Fatalf("List 2: %v", err) + } + if got := countListSessions(t, logPath); got != 2 { + t.Fatalf("List past TTL must re-shell-out, got %d", got) + } +} + +// F60 — a caller that mutates the returned slice must not corrupt the cache for +// the next caller within the TTL window. +func TestListReturnsCopyCallersCannotMutateCache(t *testing.T) { + c, _ := newCountingListClient(t) + clock := time.Unix(1710000000, 0) + c.now = func() time.Time { return clock } + + first, err := c.List(context.Background()) + if err != nil || len(first) != 1 { + t.Fatalf("List 1: len=%d err=%v", len(first), err) + } + first[0].Name = "mutated" + + second, err := c.List(context.Background()) + if err != nil || len(second) != 1 { + t.Fatalf("List 2: len=%d err=%v", len(second), err) + } + if second[0].Name != "uam-fake-abc12345" { + t.Fatalf("cached slice was mutated by a prior caller: %q", second[0].Name) + } +} diff --git a/internal/tmux/parse.go b/internal/tmux/parse.go index 23aaaa6..e3573e3 100644 --- a/internal/tmux/parse.go +++ b/internal/tmux/parse.go @@ -1,11 +1,19 @@ package tmux import ( + "errors" "fmt" "strconv" "strings" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/log" ) +// ErrMalformedSessionLines is the sentinel returned by ParseListSessions when +// one or more lines could not be parsed. The parsed subset is still returned so +// a single bad line (e.g. a cwd containing '|') never blanks the whole list. +var ErrMalformedSessionLines = errors.New("one or more tmux session lines were malformed") + type SessionInfo struct { Name string CreatedUnix int64 @@ -18,34 +26,43 @@ type SessionInfo struct { const ListFormat = "#{session_name}|#{session_created}|#{session_attached}|#{pane_pid}|#{pane_current_path}|#{pane_current_command}" func ParseSessionLine(line string) (SessionInfo, error) { - parts := strings.Split(line, "|") - if len(parts) != 6 { - return SessionInfo{}, fmt.Errorf("expected 6 fields, got %d", len(parts)) + // Right-anchored split: the first four fields and the trailing command are + // fixed-position, so pane_current_path (field 5) keeps any embedded '|'. + // SplitN caps the left split at five pieces, leaving path|command in head[4]. + head := strings.SplitN(line, "|", 5) + if len(head) != 5 { + return SessionInfo{}, fmt.Errorf("expected 6 fields, got %d", len(head)) + } + cut := strings.LastIndex(head[4], "|") + if cut < 0 { + return SessionInfo{}, fmt.Errorf("expected 6 fields, got %d", len(head)) } - created, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64) + path, command := head[4][:cut], head[4][cut+1:] + created, err := strconv.ParseInt(strings.TrimSpace(head[1]), 10, 64) if err != nil { return SessionInfo{}, fmt.Errorf("parse created: %w", err) } - attachedInt, err := strconv.Atoi(strings.TrimSpace(parts[2])) + attachedInt, err := strconv.Atoi(strings.TrimSpace(head[2])) if err != nil { return SessionInfo{}, fmt.Errorf("parse attached: %w", err) } - pid, err := strconv.Atoi(strings.TrimSpace(parts[3])) + pid, err := strconv.Atoi(strings.TrimSpace(head[3])) if err != nil { return SessionInfo{}, fmt.Errorf("parse pane pid: %w", err) } return SessionInfo{ - Name: strings.TrimSpace(parts[0]), + Name: strings.TrimSpace(head[0]), CreatedUnix: created, Attached: attachedInt != 0, PanePID: pid, - CurrentPath: strings.TrimSpace(parts[4]), - CurrentCommand: strings.TrimSpace(parts[5]), + CurrentPath: strings.TrimSpace(path), + CurrentCommand: strings.TrimSpace(command), }, nil } func ParseListSessions(output string) ([]SessionInfo, error) { var sessions []SessionInfo + malformed := 0 for _, line := range strings.Split(output, "\n") { line = strings.TrimSpace(line) if line == "" { @@ -53,9 +70,16 @@ func ParseListSessions(output string) ([]SessionInfo, error) { } info, err := ParseSessionLine(line) if err != nil { - return nil, err + // Skip-and-log: one unparseable line must not blank the whole list. + // The healthy subset is still returned with a sentinel error (F11). + log.Warn("skipping malformed tmux session line", "line", line, "error", err) + malformed++ + continue } sessions = append(sessions, info) } + if malformed > 0 { + return sessions, fmt.Errorf("%w: skipped %d line(s)", ErrMalformedSessionLines, malformed) + } return sessions, nil } diff --git a/internal/tmux/parse_test.go b/internal/tmux/parse_test.go index 988fe56..38d1fdb 100644 --- a/internal/tmux/parse_test.go +++ b/internal/tmux/parse_test.go @@ -1,6 +1,9 @@ package tmux -import "testing" +import ( + "strings" + "testing" +) func TestParseSessionLine(t *testing.T) { line := "uam-claude-abc12345|1710000000|0|4242|/tmp/repo|claude" @@ -29,3 +32,58 @@ func TestParseSessionLineRejectsMalformed(t *testing.T) { t.Fatal("expected error") } } + +// F11 — a cwd that legally contains '|' must not break parsing. The split is +// right-anchored: the first four fields and the trailing command are fixed, so +// the path keeps its embedded separators. +func TestParseSessionLine_PipeInPath(t *testing.T) { + line := "uam-claude-abc12345|1710000000|0|4242|/tmp/weird|dir|claude" + got, err := ParseSessionLine(line) + if err != nil { + t.Fatalf("ParseSessionLine: %v", err) + } + if got.CurrentPath != "/tmp/weird|dir" { + t.Fatalf("path lost embedded separator: %q", got.CurrentPath) + } + if got.CurrentCommand != "claude" || got.Name != "uam-claude-abc12345" || got.PanePID != 4242 { + t.Fatalf("round-trip mismatch: %+v", got) + } +} + +// F11 — one unparseable line (e.g. a non-numeric pid) must not discard the +// whole batch; healthy sessions survive and a sentinel error is returned. +func TestParseListSessions_PipeInPathKeepsHealthySessions(t *testing.T) { + input := strings.Join([]string{ + "uam-a|1|0|10|/home/a|bash", + "uam-b|2|0|notapid|/home/b|codex", // malformed: pid not numeric + "uam-c|3|0|30|/srv/proj|with|pipe|claude", + }, "\n") + got, err := ParseListSessions(input) + if err == nil { + t.Fatal("expected a sentinel error reporting the skipped malformed line") + } + if len(got) != 2 { + t.Fatalf("healthy sessions should survive, got %d: %+v", len(got), got) + } + if got[0].Name != "uam-a" || got[1].Name != "uam-c" { + t.Fatalf("unexpected survivors: %+v", got) + } + if got[1].CurrentPath != "/srv/proj|with|pipe" { + t.Fatalf("path with multiple pipes lost data: %q", got[1].CurrentPath) + } +} + +// F11 — CRLF line endings and unicode paths must round-trip cleanly. +func TestParseListSessions_CRLFAndUnicode(t *testing.T) { + input := "uam-a|1|0|10|/home/üser/проект|bash\r\nuam-b|2|0|20|/tmp|codex\r\n" + got, err := ParseListSessions(input) + if err != nil { + t.Fatalf("ParseListSessions: %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 sessions, got %d: %+v", len(got), got) + } + if got[0].CurrentPath != "/home/üser/проект" { + t.Fatalf("unicode path mangled: %q", got[0].CurrentPath) + } +} diff --git a/internal/tmux/timeout_test.go b/internal/tmux/timeout_test.go new file mode 100644 index 0000000..929b660 --- /dev/null +++ b/internal/tmux/timeout_test.go @@ -0,0 +1,63 @@ +package tmux + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +// F17 — an external tmux call must be bounded by an internal timeout so a hung +// tmux process cannot wedge a refresh indefinitely. The fake tmux sleeps far +// longer than the timeout; the call must return well before the sleep finishes. +func TestRunHonorsContextDeadline(t *testing.T) { + dir := t.TempDir() + script := filepath.Join(dir, "tmux") + if err := os.WriteFile(script, []byte("#!/bin/sh\nsleep 60\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + c := New("uam") + c.Executable = script + + done := make(chan struct{}, 1) + start := time.Now() + go func() { + // Capture is a representative external read; any run-backed call works. + _, _ = c.Capture(context.Background(), "uam-x", 10) + done <- struct{}{} + }() + // Generous upper bound: well under the 60s sleep but above tmuxCallTimeout. + select { + case <-done: + if elapsed := time.Since(start); elapsed > tmuxCallTimeout+5*time.Second { + t.Fatalf("run returned but took too long: %v", elapsed) + } + case <-time.After(tmuxCallTimeout + 5*time.Second): + t.Fatalf("run blocked past its internal timeout (%v)", tmuxCallTimeout) + } +} + +// F17 — a caller-supplied deadline tighter than the internal timeout must still +// be honored (the internal timeout is an upper bound, not a floor). +func TestRunHonorsTighterCallerDeadline(t *testing.T) { + dir := t.TempDir() + script := filepath.Join(dir, "tmux") + if err := os.WriteFile(script, []byte("#!/bin/sh\nsleep 60\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + c := New("uam") + c.Executable = script + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + start := time.Now() + if _, err := c.Capture(ctx, "uam-x", 10); err == nil { + t.Fatal("expected error from a tighter caller deadline") + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("tighter caller deadline not honored: %v", elapsed) + } +} diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index ff4f36b..62d6ff1 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -2,31 +2,74 @@ package tmux import ( "context" + "errors" "fmt" "os" "os/exec" + "path/filepath" + "regexp" "sort" - "strconv" "strings" "sync" "syscall" + "time" "github.com/RandomCodeSpace/unified-agent-manager/internal/execpath" + "github.com/RandomCodeSpace/unified-agent-manager/internal/log" ) +// ErrInvalidSessionName is returned when a session name fails the allow-list. +var ErrInvalidSessionName = fmt.Errorf("session name failed allow-list") + +// tmuxCallTimeout is the upper bound on a single tmux invocation. It is an +// upper bound, not a floor: a tighter caller deadline still wins. Without it a +// hung tmux (stuck server, lost pty) would block a refresh indefinitely (F17). +const tmuxCallTimeout = 10 * time.Second + +// tmuxWaitDelay is the short grace period after the context is cancelled before +// the child's I/O pipes are force-closed. Without it CombinedOutput keeps +// blocking on a pipe a grandchild inherited, so the deadline never unblocks the +// caller (F17). Kept short so a cancelled call returns promptly. +const tmuxWaitDelay = 2 * time.Second + +// listCacheTTL is how long a List result is reused before re-querying tmux. A +// single refresh tick calls List once per enabled adapter against the same +// shared Client; without a cache that is one whole-server list-sessions per +// adapter per tick. The TTL is short enough that a cached result is always from +// the current tick, so freshness is unaffected, while N adapter scans collapse +// to one shell-out (F60). +const listCacheTTL = 250 * time.Millisecond + +// sessionNameRE is the allow-list for tmux session names uam may create. It +// matches the canonical shape minted by adapter.startSession +// ("uam--"): a lowercase-alphanumeric provider segment and a +// hex id segment. The pattern admits no shell metacharacters, so a name that +// passes can be embedded in tmux argv without risk. +var sessionNameRE = regexp.MustCompile(`^uam-[a-z0-9]+-[0-9a-f]{1,16}$`) + type Client struct { Socket string Executable string - configOnce sync.Once - configErr error + configMu sync.Mutex + configDone bool + + // now is the clock used to expire the List cache; overridable in tests. + now func() time.Time + // listMu guards the cached List result and the time it was taken. The cache + // collapses the per-adapter list-sessions storm within a single refresh tick + // into one shell-out (F60). + listMu sync.Mutex + listCache []SessionInfo + listCachedAt time.Time + listCacheOK bool } func New(socket string) *Client { if socket == "" { socket = "uam" } - return &Client{Socket: socket} + return &Client{Socket: socket, now: time.Now} } func (c *Client) baseArgs(args ...string) []string { @@ -39,7 +82,22 @@ func (c *Client) run(ctx context.Context, args ...string) (string, error) { if err != nil { return "", err } - cmd := exec.CommandContext(ctx, exe, c.baseArgs(args...)...) // #nosec G204 -- tmux path is resolved from fixed system directories or injected as an absolute test path; argv args avoid shell expansion. + // Bound every tmux invocation so a hung process can't wedge a refresh. This + // is an upper bound only — a tighter caller deadline still applies (F17). + // Interactive attach does not flow through run (cli execs the argv directly), + // so this never caps a foreground session. + ctx, cancel := context.WithTimeout(ctx, tmuxCallTimeout) + defer cancel() + // The tmux path is resolved from fixed system directories or injected as an + // absolute test path. tmux's own args are passed via argv (no shell). Where + // an arg is itself a /bin/sh command string (the new-session command built + // by ShellJoin), every value is POSIX single-quote escaped by shellQuote, so + // $(), ``, $VAR, and word-splitting cannot fire inside it. + cmd := exec.CommandContext(ctx, exe, c.baseArgs(args...)...) // #nosec G204 + // Reap a hung tmux promptly once the deadline fires: WaitDelay caps how long + // CombinedOutput waits for the (possibly inherited) output pipe before force- + // closing it, so the timeout actually unblocks the caller (F17). + cmd.WaitDelay = tmuxWaitDelay out, err := cmd.CombinedOutput() if err != nil { return string(out), fmt.Errorf("tmux %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out))) @@ -64,6 +122,9 @@ func (c *Client) ExecutablePath() (string, error) { } func (c *Client) CreateSession(ctx context.Context, name, cwd string, env map[string]string, command []string) error { + if !sessionNameRE.MatchString(name) { + return fmt.Errorf("refusing to create session: invalid name %q: %w", name, ErrInvalidSessionName) + } args := []string{"new-session", "-d", "-s", name, "-x", "200", "-y", "50"} if cwd != "" { args = append(args, "-c", cwd) @@ -92,17 +153,73 @@ func commandWithEnv(env map[string]string, command []string) []string { } func (c *Client) List(ctx context.Context) ([]SessionInfo, error) { + if cached, ok := c.cachedList(); ok { + return cached, nil + } out, err := c.run(ctx, "list-sessions", "-F", ListFormat) if err != nil { - // tmux exits non-zero when the private server has no sessions. - if strings.Contains(err.Error(), "no server running") || strings.Contains(err.Error(), "failed to connect") { + // tmux exits non-zero when the private server has no sessions. Match the + // known no-server phrasings only; a genuine failure must still propagate. + if isNoServerErr(err) { + c.storeList(nil) return nil, nil } return nil, err } - // Server is up — apply uam-friendly settings (sync.Once, no-op after first). + // Server is up — apply uam-friendly settings (latches once it succeeds). _ = c.EnsureServerConfig(ctx) - return ParseListSessions(out) + // A malformed line (e.g. a cwd containing '|') yields the parsed subset plus + // ErrMalformedSessionLines; ParseListSessions already logged it. Returning + // the subset keeps the healthy sessions visible instead of blanking the + // whole list, so the sentinel is intentionally not propagated (F11). + sessions, err := ParseListSessions(out) + if errors.Is(err, ErrMalformedSessionLines) { + err = nil + } + if err != nil { + return sessions, err + } + c.storeList(sessions) + return cloneSessionInfos(sessions), nil +} + +// cachedList returns a defensive copy of the cached List result when it is +// still within listCacheTTL, collapsing the per-adapter list-sessions storm in +// one refresh tick into a single shell-out (F60). +func (c *Client) cachedList() ([]SessionInfo, bool) { + clock := c.now + if clock == nil { + clock = time.Now + } + c.listMu.Lock() + defer c.listMu.Unlock() + if !c.listCacheOK || clock().Sub(c.listCachedAt) >= listCacheTTL { + return nil, false + } + return cloneSessionInfos(c.listCache), true +} + +func (c *Client) storeList(sessions []SessionInfo) { + clock := c.now + if clock == nil { + clock = time.Now + } + c.listMu.Lock() + defer c.listMu.Unlock() + c.listCache = cloneSessionInfos(sessions) + c.listCachedAt = clock() + c.listCacheOK = true +} + +// cloneSessionInfos returns a shallow copy so a caller mutating the returned +// slice cannot corrupt the cache (SessionInfo holds only value fields). +func cloneSessionInfos(in []SessionInfo) []SessionInfo { + if in == nil { + return nil + } + out := make([]SessionInfo, len(in)) + copy(out, in) + return out } func (c *Client) Capture(ctx context.Context, target string, lines int) (string, error) { @@ -123,9 +240,28 @@ func (c *Client) SendEnter(ctx context.Context, target string) error { return err } +// SendLine types text into the target pane and submits it with a single Enter. +// +// tmux's `send-keys -l` interprets an embedded newline as Enter, so passing a +// multi-line prompt as one literal made the agent submit it line-by-line (F13). +// Instead we trim a trailing newline, then send each interior line as its own +// literal keystroke separated by a literal "\n" keystroke — no interior Enter +// events — and submit once at the end. A single-line prompt takes the original +// one-literal-plus-one-Enter path byte-for-byte. func (c *Client) SendLine(ctx context.Context, target, text string) error { - if err := c.SendKeysLiteral(ctx, target, text); err != nil { - return err + text = strings.TrimRight(text, "\n") + lines := strings.Split(text, "\n") + for i, line := range lines { + if i > 0 { + // Send the line separator as its own literal keystroke so it lands + // in the input buffer instead of submitting the partial prompt. + if err := c.SendKeysLiteral(ctx, target, "\n"); err != nil { + return err + } + } + if err := c.SendKeysLiteral(ctx, target, line); err != nil { + return err + } } return c.SendEnter(ctx, target) } @@ -135,15 +271,53 @@ func (c *Client) Kill(ctx context.Context, target string) error { return err } +// KillServer tears down the entire private tmux server (and every session it +// holds) via `tmux -L kill-server`. It is idempotent: a server that is +// already down makes tmux exit non-zero with a no-server message, which is +// treated as success so `uam kill-all` is safe to run repeatedly. A genuine +// failure still propagates (F24). +func (c *Client) KillServer(ctx context.Context) error { + if _, err := c.run(ctx, "kill-server"); err != nil { + if isNoServerErr(err) { + return nil + } + return err + } + return nil +} + +// isNoServerErr reports whether err is tmux's "the private server has no +// running instance" message. Different tmux versions phrase it differently; +// 3.4 reports the missing socket as "(No such file or directory)". +func isNoServerErr(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "no server running") || + strings.Contains(msg, "failed to connect") || + strings.Contains(msg, "No such file or directory") +} + // EnsureServerConfig applies session-friendly defaults to the private tmux // server: disable mouse mode so the host terminal owns text selection, and -// swallow Ctrl+Z so it can't suspend the agent in the foreground pane. The -// configuration is applied exactly once per Client. +// swallow Ctrl+Z so it can't suspend the agent in the foreground pane. +// +// The configuration is applied at most once SUCCESSFULLY. The first dispatch +// runs before the server exists, so set-option fails; latching that failure +// (the old sync.Once behaviour) meant the config never applied for the life of +// the process (F25). Instead we retry until a call succeeds, then latch. func (c *Client) EnsureServerConfig(ctx context.Context) error { - c.configOnce.Do(func() { - c.configErr = c.applyServerConfig(ctx) - }) - return c.configErr + c.configMu.Lock() + defer c.configMu.Unlock() + if c.configDone { + return nil + } + if err := c.applyServerConfig(ctx); err != nil { + return err + } + c.configDone = true + return nil } func (c *Client) applyServerConfig(ctx context.Context) error { @@ -160,9 +334,12 @@ func (c *Client) applyServerConfig(ctx context.Context) error { } // Hook install is best-effort. If we can't resolve a safe binary path, // the rest of uam still works — only the exit-in-session signal is lost, - // and the user can recover via Ctrl+X or `uam rm`. + // and the user can recover via Ctrl+X or `uam rm`. We log (not return) the + // failure so a missing hook is diagnosable without bricking dispatch (F56). if cmd := sessionClosedHookCommand(); cmd != "" { - _, _ = c.run(ctx, "set-hook", "-g", "session-closed", cmd) + if out, err := c.run(ctx, "set-hook", "-g", "session-closed", cmd); err != nil { + log.Warn("installing session-closed hook failed", "error", err, "output", strings.TrimSpace(out)) + } } return nil } @@ -183,15 +360,34 @@ func sessionClosedHookCommand() string { if err := execpath.ValidateAbsoluteExecutable(exe); err != nil { return "" } + return hookCommandForExe(exe) +} + +// hookCommandForExe builds the session-closed hook command for a given binary +// path, or returns empty string when the path isn't safe to embed. It is split +// out from sessionClosedHookCommand so the rejection branch can be table-tested +// directly without faking os.Executable (F51). The real-file check +// (ValidateAbsoluteExecutable) stays in the caller; this seam only enforces the +// quoting-safety rules that govern whether a path can be embedded. +func hookCommandForExe(exe string) string { + // An absolute path is required: the hook is a /bin/sh command string and a + // relative path would resolve against tmux's cwd, not uam's install dir. + if !filepath.IsAbs(exe) { + return "" + } // Reject paths with shell metacharacters we'd otherwise need to escape. // Real uam installs land in standard bin directories without these, // and bailing out is safer than risking a malformed hook. if strings.ContainsAny(exe, "\"'\\$`") { return "" } - // run-shell receives a /bin/sh command string. tmux substitutes - // #{hook_session_name} before sh sees it; single quotes around the - // substitution prevent sh from expanding anything inside. + // run-shell receives a /bin/sh command string. tmux expands + // #{hook_session_name} INTO that string before sh parses it, so the single + // quotes here do NOT neutralize a hostile name on their own — a name + // containing a quote would break out. Safety comes from CreateSession's + // allow-list (sessionNameRE), which guarantees every name we ever create is + // [a-z0-9-] only; the quoting then merely keeps a benign name as one argv + // token. return fmt.Sprintf(`run-shell "%s notify-closed '#{hook_session_name}'"`, exe) } @@ -234,7 +430,11 @@ func shellQuote(s string) string { }) == -1 { return s } - return strconv.Quote(s) + // POSIX single-quote escaping: wrap in single quotes and rewrite any + // embedded single quote as the close-reopen idiom '\''. Inside single + // quotes /bin/sh performs no expansion, so $(), ``, $VAR, and newlines + // all reach the command literally. + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } func isShellSafeRune(r rune) bool { diff --git a/internal/tmux/tmux_test.go b/internal/tmux/tmux_test.go index 9b3def0..15ebc68 100644 --- a/internal/tmux/tmux_test.go +++ b/internal/tmux/tmux_test.go @@ -1,11 +1,16 @@ package tmux import ( + "bytes" "context" + "log/slog" "os" + "os/exec" "path/filepath" "strings" "testing" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/log" ) func TestClientCommandsWithFakeTmux(t *testing.T) { @@ -44,7 +49,7 @@ exit 0 func assertCreateSessionCommand(t *testing.T, c *Client, logPath string) { t.Helper() - if err := c.CreateSession(context.Background(), "uam-a", "/tmp", map[string]string{"A": "B"}, []string{"cmd", "arg with space"}); err != nil { + if err := c.CreateSession(context.Background(), "uam-a-deadbeef", "/tmp", map[string]string{"A": "B"}, []string{"cmd", "arg with space"}); err != nil { t.Fatal(err) } logData, err := os.ReadFile(logPath) @@ -55,7 +60,7 @@ func assertCreateSessionCommand(t *testing.T, c *Client, logPath string) { if strings.Contains(logText, " -e ") { t.Fatalf("CreateSession should not rely on tmux new-session -e because older tmux rejects it: %s", logText) } - if !strings.Contains(logText, "env A=B cmd \"arg with space\"") { + if !strings.Contains(logText, "env A=B cmd 'arg with space'") { t.Fatalf("CreateSession should prefix the shell command with env assignments: %s", logText) } } @@ -133,21 +138,376 @@ func TestEnsureServerConfigInstallsSessionClosedHook(t *testing.T) { } } -func TestSessionClosedHookCommandRejectsUnsafePaths(t *testing.T) { - // We can't directly fake os.Executable without an injection seam, but - // we can at least sanity-check the format on the real test binary path. - cmd := sessionClosedHookCommand() - if cmd == "" { - t.Skip("test binary path was rejected as unsafe — skipping format check") +// F25 — EnsureServerConfig must not latch a failure. On first dispatch the +// server doesn't exist yet, so set-option fails; the next call (after the +// server is up) must retry and succeed rather than caching the earlier error. +func TestEnsureServerConfigRetriesAfterServerlessFailure(t *testing.T) { + dir := t.TempDir() + script := filepath.Join(dir, "tmux") + // set-option fails until a sentinel file exists (simulating "server up"). + if err := os.WriteFile(script, []byte(`#!/bin/sh +printf '%s\n' "$*" >> "$TMUX_LOG" +case "$*" in + *"set-option"*) + if [ ! -f "$TMUX_UP" ]; then + echo "no server running on /tmp/tmux" >&2 + exit 1 + fi + ;; +esac +exit 0 +`), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("TMUX_LOG", filepath.Join(dir, "log")) + upFile := filepath.Join(dir, "up") + t.Setenv("TMUX_UP", upFile) + + c := New("uam") + c.Executable = script + + if err := c.EnsureServerConfig(context.Background()); err == nil { + t.Fatal("expected serverless EnsureServerConfig to fail") + } + // Server comes up. + if err := os.WriteFile(upFile, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := c.EnsureServerConfig(context.Background()); err != nil { + t.Fatalf("EnsureServerConfig must retry and succeed once the server is up, got: %v", err) + } + // A third call after success must be a no-op (success-latched): the log + // should not contain a third set-option mouse invocation. + before, _ := os.ReadFile(filepath.Join(dir, "log")) + if err := c.EnsureServerConfig(context.Background()); err != nil { + t.Fatalf("post-success EnsureServerConfig: %v", err) + } + after, _ := os.ReadFile(filepath.Join(dir, "log")) + if len(after) != len(before) { + t.Fatalf("EnsureServerConfig must not re-apply after a successful latch:\nbefore=%s\nafter=%s", before, after) + } +} + +// F56 — a failing session-closed hook install must be logged (best-effort, +// non-fatal) rather than silently discarded. +func TestApplyServerConfigLogsHookInstallFailure(t *testing.T) { + if sessionClosedHookCommand() == "" { + t.Skip("test binary path rejected as unsafe — no hook command to install") + } + dir := t.TempDir() + script := filepath.Join(dir, "tmux") + // Everything succeeds except set-hook, which fails. + if err := os.WriteFile(script, []byte(`#!/bin/sh +printf '%s\n' "$*" >> "$TMUX_LOG" +case "$*" in + *"set-hook"*) echo "hook boom" >&2; exit 1 ;; +esac +exit 0 +`), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("TMUX_LOG", filepath.Join(dir, "log")) + c := New("uam") + c.Executable = script + + var buf bytes.Buffer + prev := log.SetLogger(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + defer log.SetLogger(prev) + + // Hook failure is non-fatal: EnsureServerConfig still returns nil. + if err := c.EnsureServerConfig(context.Background()); err != nil { + t.Fatalf("hook install failure must stay non-fatal, got: %v", err) + } + if !strings.Contains(buf.String(), "session-closed hook") { + t.Fatalf("hook install failure should be logged, got: %q", buf.String()) + } +} + +// F51 — hookCommandForExe is the testable seam: it takes the binary path +// explicitly so the rejection branch (shell metacharacters, relative paths) +// can be table-tested without faking os.Executable. The old test could only +// exercise the real test-binary path and had to t.Skip the rejection branch. +func TestHookCommandForExe(t *testing.T) { + cases := []struct { + name string + exe string + wantEmpty bool + }{ + {"clean absolute path", "/usr/local/bin/uam", false}, + {"relative path rejected", "uam", true}, + {"dot-relative path rejected", "./uam", true}, + {"double-quote rejected", `/usr/local/bin/u"am`, true}, + {"single-quote rejected", "/usr/local/bin/u'am", true}, + {"backslash rejected", `/usr/local/bin/u\am`, true}, + {"dollar rejected", "/usr/local/bin/u$am", true}, + {"backtick rejected", "/usr/local/bin/u`am`", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cmd := hookCommandForExe(tc.exe) + if tc.wantEmpty { + if cmd != "" { + t.Fatalf("hookCommandForExe(%q) = %q, want empty (rejected)", tc.exe, cmd) + } + return + } + if cmd == "" { + t.Fatalf("hookCommandForExe(%q) was rejected, want a command", tc.exe) + } + if !strings.Contains(cmd, "run-shell") { + t.Fatalf("hook command must use run-shell: %q", cmd) + } + if !strings.Contains(cmd, "notify-closed") { + t.Fatalf("hook command must reference notify-closed: %q", cmd) + } + if !strings.Contains(cmd, "'#{hook_session_name}'") { + t.Fatalf("session name must be single-quoted for the inner shell: %q", cmd) + } + if !strings.Contains(cmd, tc.exe) { + t.Fatalf("hook command must embed the binary path: %q", cmd) + } + }) + } +} + +// TestSessionClosedHookCommandUsesRealBinary verifies the wrapper resolves the +// running binary and delegates to hookCommandForExe. The deterministic +// rejection-branch coverage lives in TestHookCommandForExe above. +func TestSessionClosedHookCommandUsesRealBinary(t *testing.T) { + exe, err := os.Executable() + if err != nil { + t.Skipf("os.Executable unavailable: %v", err) + } + if got, want := sessionClosedHookCommand(), hookCommandForExe(exe); got != want { + t.Fatalf("sessionClosedHookCommand() = %q, want %q (from real binary path)", got, want) + } +} + +// TestShellQuoteIsInertUnderSh proves that values flowing through ShellJoin +// are passed literally to /bin/sh and cannot trigger command substitution, +// variable expansion, or word-splitting. /bin/sh is the faithful sink that +// tmux's `new-session ` ultimately feeds, so exercising sh directly +// keeps the test CI/air-gap portable (no real tmux required). +func TestShellQuoteIsInertUnderSh(t *testing.T) { + if _, err := os.Stat("/bin/sh"); err != nil { + t.Skipf("/bin/sh unavailable: %v", err) + } + dangerous := []struct { + name string + value string + }{ + {"command substitution", "$(touch SENTINEL)"}, + {"backtick substitution", "`touch SENTINEL`"}, + {"variable expansion", "$HOME"}, + {"embedded single quote", "a'b"}, + {"embedded newline", "line1\nline2"}, + } + for _, tc := range dangerous { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + sentinel := filepath.Join(dir, "SENTINEL") + // Mirror the real call site: env-prefixed command joined for sh. + joined := ShellJoin(commandWithEnv(map[string]string{"X": tc.value}, []string{"printf", "%s", tc.value})) + // Run with cwd=dir so a relative `touch SENTINEL` (if substitution + // fired) would land where we check for it. + cmd := exec.Command("/bin/sh", "-c", joined) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("sh -c %q failed: %v (out=%q)", joined, err, out) + } + if _, statErr := os.Stat(sentinel); statErr == nil { + t.Fatalf("sentinel created — value was NOT inert: joined=%q", joined) + } + // The literal value must survive into argv (the env=VALUE prefix + // also contains it). The first printf token echoes it back verbatim. + if !strings.Contains(string(out), tc.value) { + t.Fatalf("value not passed literally: want substring %q in out=%q (joined=%q)", tc.value, out, joined) + } + }) + } +} + +func TestShellQuoteFormByInput(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"", "''"}, + {"safe-token.v1", "safe-token.v1"}, + {"a'b", `'a'\''b'`}, + } + for _, tc := range cases { + if got := shellQuote(tc.in); got != tc.want { + t.Fatalf("shellQuote(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestCreateSessionRejectsUnsafeNames(t *testing.T) { + for _, name := range []string{ + "uam-claude-deadbeef'; touch x", + "uam-claude-$(touch x)", + "uam-claude-deadbeef; rm -rf /", + "`touch x`", + } { + c, logPath := setupFakeTmuxClient(t) + err := c.CreateSession(context.Background(), name, "/tmp", nil, []string{"cmd"}) + if err == nil { + t.Fatalf("CreateSession accepted unsafe name %q", name) + } + data, readErr := os.ReadFile(logPath) + if readErr != nil && !os.IsNotExist(readErr) { + t.Fatal(readErr) + } + if strings.Contains(string(data), name) { + t.Fatalf("fake tmux was invoked with unsafe name %q: %s", name, data) + } + } +} + +func TestCreateSessionAcceptsValidUamNames(t *testing.T) { + // Canonical shape produced by tmux_adapter.go: uam--. + c, logPath := setupFakeTmuxClient(t) + if err := c.CreateSession(context.Background(), "uam-claude-deadbeef", "/tmp", nil, []string{"cmd"}); err != nil { + t.Fatalf("CreateSession rejected a valid name: %v", err) } - if !strings.Contains(cmd, "run-shell") { - t.Fatalf("hook command must use run-shell: %q", cmd) + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "uam-claude-deadbeef") { + t.Fatalf("valid session not created: %s", data) + } +} + +// newSendKeysRecorder returns a Client whose fake tmux records, for each +// invocation, one marker line "INVOKE " where is the +// keystroke kind ("literal" for send-keys -l, "enter" for send-keys Enter, +// "other" otherwise). Using $@ positionally is newline-safe: an embedded +// newline in the literal text cannot inflate the invocation count. +func newSendKeysRecorder(t *testing.T) (*Client, string) { + t.Helper() + dir := t.TempDir() + script := filepath.Join(dir, "tmux") + if err := os.WriteFile(script, []byte(`#!/bin/sh +# Drop "-L " so $1 is the subcommand. +shift 2 +kind=other +case " $* " in + *" -l --"*) kind=literal ;; + *" Enter"*) kind=enter ;; +esac +# Record exactly one marker line per invocation (newline-safe count). +printf 'INVOKE %s\n' "$kind" >> "$TMUX_LOG" +exit 0 +`), 0o755); err != nil { + t.Fatal(err) + } + logPath := filepath.Join(dir, "log") + t.Setenv("TMUX_LOG", logPath) + c := New("uam") + c.Executable = script + return c, logPath +} + +// F13 characterization — a single-line prompt must keep the byte-identical +// sequence: one literal send-keys carrying the text, then exactly one Enter. +func TestSendLineSingleLineSequence(t *testing.T) { + c, logPath := setupFakeTmuxClient(t) + if err := c.SendLine(context.Background(), "uam-a", "hello world"); err != nil { + t.Fatalf("SendLine: %v", err) } - if !strings.Contains(cmd, "notify-closed") { - t.Fatalf("hook command must reference notify-closed: %q", cmd) + data, _ := os.ReadFile(logPath) + logText := string(data) + if !strings.Contains(logText, "send-keys -t uam-a -l -- hello world") { + t.Fatalf("single-line literal sequence changed: %s", logText) } - if !strings.Contains(cmd, "'#{hook_session_name}'") { - t.Fatalf("session name must be single-quoted for the inner shell: %q", cmd) + if strings.Count(logText, "Enter") != 1 { + t.Fatalf("single-line prompt must emit exactly one Enter: %s", logText) + } +} + +// F13 — a multi-line prompt must submit ONCE: exactly one Enter keystroke (the +// final submit) and no interior Enter events, regardless of how many literal +// segments are sent. +func TestSendLineDoesNotEmitLiteralEmbeddedNewline(t *testing.T) { + c, logPath := newSendKeysRecorder(t) + if err := c.SendLine(context.Background(), "uam-a", "line1\nline2\nline3"); err != nil { + t.Fatalf("SendLine: %v", err) + } + data, _ := os.ReadFile(logPath) + enters := strings.Count(string(data), "INVOKE enter") + if enters != 1 { + t.Fatalf("multi-line prompt must emit exactly one trailing Enter, got %d: %s", enters, data) + } + // More than one literal invocation proves the newlines were split into + // separate keystrokes rather than bundled into one submit-on-newline blob. + if literals := strings.Count(string(data), "INVOKE literal"); literals < 3 { + t.Fatalf("interior newlines must be split into separate literal keystrokes, got %d: %s", literals, data) + } +} + +// F13 — a trailing newline is trimmed so a normal "text\n" stays a single +// submit rather than emitting a spurious extra Enter or an empty trailing +// segment. +func TestSendLineTrimsTrailingNewline(t *testing.T) { + c, logPath := newSendKeysRecorder(t) + if err := c.SendLine(context.Background(), "uam-a", "solo\n"); err != nil { + t.Fatalf("SendLine: %v", err) + } + data, _ := os.ReadFile(logPath) + if enters := strings.Count(string(data), "INVOKE enter"); enters != 1 { + t.Fatalf("trailing-newline prompt must emit exactly one Enter, got %d: %s", enters, data) + } + if literals := strings.Count(string(data), "INVOKE literal"); literals != 1 { + t.Fatalf("trailing-newline prompt should send one literal segment, got %d: %s", literals, data) + } +} + +// newScriptedClient builds a Client whose fake tmux runs the given /bin/sh body. +func newScriptedClient(t *testing.T, body string) *Client { + t.Helper() + dir := t.TempDir() + script := filepath.Join(dir, "tmux") + if err := os.WriteFile(script, []byte("#!/bin/sh\n"+body), 0o755); err != nil { + t.Fatal(err) + } + c := New("uam") + c.Executable = script + return c +} + +// F10 — tmux 3.4 emits "(No such file or directory)" instead of the legacy +// "no server running" string when listing sessions against a fresh socket. +// List must treat that as an empty server, not an error. +func TestListReturnsEmptyOnFreshSocket(t *testing.T) { + c := newScriptedClient(t, `case "$*" in + *"list-sessions"*) echo "error connecting to /tmp/tmux-1000/uam (No such file or directory)" >&2; exit 1 ;; +esac +exit 0 +`) + list, err := c.List(context.Background()) + if err != nil { + t.Fatalf("fresh socket should yield no error, got %v", err) + } + if len(list) != 0 { + t.Fatalf("fresh socket should yield no sessions, got %v", list) + } +} + +// F10 trap — a genuine list-sessions failure (not a missing server) must still +// propagate as an error rather than being swallowed as an empty list. +func TestListPropagatesGenuineError(t *testing.T) { + c := newScriptedClient(t, `case "$*" in + *"list-sessions"*) echo "lost server: protocol version mismatch" >&2; exit 1 ;; +esac +exit 0 +`) + if _, err := c.List(context.Background()); err == nil { + t.Fatal("genuine list-sessions failure must propagate as an error") } } diff --git a/main_test.go b/main_test.go index 08cfc22..07d841a 100644 --- a/main_test.go +++ b/main_test.go @@ -109,7 +109,9 @@ func TestRunNew(t *testing.T) { } } -func TestRunNewAllowsEmptyPrompt(t *testing.T) { +// F54 — `uam new` must reject an empty final prompt instead of dispatching an +// empty-prompt session and exiting 0. +func TestRunNewRejectsEmptyPrompt(t *testing.T) { dir := setupFakeCLIEnv(t) t.Setenv("UAM_CONFIG_DIR", filepath.Join(dir, "cfg-empty-prompt")) old := os.Stdin @@ -122,12 +124,12 @@ func TestRunNewAllowsEmptyPrompt(t *testing.T) { os.Stdin = r defer func() { os.Stdin = old }() out := captureStdout(t, func() { - if err := run(context.Background(), []string{"new"}); err != nil { - t.Fatal(err) + if err := run(context.Background(), []string{"new"}); err == nil { + t.Fatal("expected run new to reject an empty prompt") } }) - if !strings.Contains(out, "dispatched") { - t.Fatalf("out=%q", out) + if strings.Contains(out, "dispatched") { + t.Fatalf("a rejected new must not dispatch, out=%q", out) } } diff --git a/sonar-project.properties b/sonar-project.properties index 0eddaa2..c2ab47d 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -5,7 +5,7 @@ sonar.host.url=https://sonarcloud.io sonar.qualitygate.wait=true sonar.sources=. -sonar.exclusions=bin/**,.git/**,.github/**,.claude/**,**/*_test.go +sonar.exclusions=bin/**,vendor/**,.git/**,.github/**,.claude/**,**/*_test.go sonar.tests=. sonar.test.inclusions=**/*_test.go sonar.go.coverage.reportPaths=coverage.out