diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-06-03 23:01:39 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-06-03 23:01:39 +0000 |
| commit | 68cf1e24dd2ca2612b72babc4b35280cd3512f92 (patch) | |
| tree | 14b73f21570a2f42ae729ca7e9676de6628d6819 | |
| parent | b28cfc6ff288d083f6c8e9c055b69bfcadbceccc (diff) | |
| parent | 7388337d3be5cc7f65ef547e30b2e39884dd165b (diff) | |
merge: integrate oss branch — budget gating, MCP back-channel, events, loopback-only bind
Key features from OSS branch:
- Budget gating: rolling per-provider spend caps with BUDGET_EXCEEDED task state
- Agent MCP back-channel: runners mint tokens; /mcp endpoint resolves them
- Chatbot MCP server at /chatbot/mcp (requires api_token in config)
- Event timeline: unified event stream replacing ad-hoc question/summary flows
- Loopback-only default bind (127.0.0.1:8484); external_bind_allowed=true to expose
- repository_url required on task creation (enforces traceability)
- Auto-checker: spawns verification task after each execution
Conflict resolutions:
- serve.go: keep cfg.Runners.XEnabled() conditional registration from main + agentRegistry from oss
- config.go: keep RunnersConfig from main + BudgetConfig/ExternalBindAllowed from oss
- server.go: handleStaticFiles (base-path rewrite) kept; deduplicated duplicate from both sides
- executor/claude.go: accepted oss deletion (ClaudeRunner replaced by ContainerRunner)
- container_test.go: added noopChannel + AgentChannel parameter to Run call
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
60 files changed, 4745 insertions, 4630 deletions
@@ -45,3 +45,4 @@ SESSION_STATE.md .claude/ credentials/ scripts/.claude/ +/spike @@ -62,9 +62,12 @@ Config defaults to `~/.claudomator/config.toml`. Data is stored in `~/.claudomat | Package | Role | |---|---| | `internal/task` | `Task` struct, YAML/JSON parsing, state machine constants, validation | -| `internal/executor` | `Pool` (bounded goroutine dispatcher) + `ClaudeRunner` (subprocess + sandbox) + `GeminiRunner` (stub) + `Classifier` + preamble + question/summary helpers | -| `internal/storage` | SQLite wrapper; additive migrations; tasks + executions tables | -| `internal/api` | HTTP/WebSocket server — REST endpoints, webhook handler, elaborate/validate, script runner | +| `internal/executor` | `Pool` (bounded goroutine dispatcher) + `ContainerRunner` (Docker; runs `claude`/`gemini`) + `LocalRunner` (OpenAI-compatible HTTP) + `Classifier` + `AgentChannel` + per-task agent MCP server (`Registry`) + planning preamble | +| `internal/storage` | SQLite wrapper; additive migrations; tasks + executions + events tables | +| `internal/event` | `Event` type, kind/actor constants — the per-task observability stream | +| `internal/budget` | `Accountant` — rolling per-provider spend windows; gates the dispatcher | +| `internal/llm` | OpenAI-compatible chat client (used by LocalRunner + Classifier), incl. tool-use | +| `internal/api` | HTTP/WebSocket server — REST endpoints, webhook handler, validate, script runner, agent MCP (`/mcp`) + chatbot MCP (`/chatbot/mcp`), budget + events endpoints | | `internal/notify` | `Notifier` interface; webhook, multi, log implementations | | `internal/reporter` | Console/JSON/HTML report generation | | `internal/deployment` | Deployment-status checking (polls URL for expected version) | @@ -98,25 +101,44 @@ PENDING ──→ QUEUED ──→ RUNNING ──→ READY ──→ COMPLETED └──→ BUDGET_EXCEEDED ``` -- **BLOCKED**: Parent task completed but has subtasks that are not yet COMPLETED, OR agent wrote a question file. Unblocked by `maybeUnblockParent()` or user answer via `/api/tasks/{id}/answer`. +- **BLOCKED**: Parent task completed but has subtasks that are not yet COMPLETED, OR the agent called `ask_user`. Unblocked by `maybeUnblockParent()` or a user/chatbot answer via `/api/tasks/{id}/answer` (or the chatbot MCP `answer_question` tool). - **READY**: Execution succeeded; awaits manual accept/reject via `/api/tasks/{id}/accept` or `/api/tasks/{id}/reject`. - **COMPLETED**: Terminal — entered only via user accept (top-level) or automatic subtask completion. - `FAILED/TIMED_OUT/CANCELLED/BUDGET_EXCEEDED` all re-enter at `QUEUED` for retry/resume. **WebSocket:** `Hub` fans out task completion events to all connected clients. `Server.StartHub()` must be called before `ListenAndServe`. -### Sandbox Lifecycle (ContainerRunner (Docker-based)) +### Execution model (ContainerRunner, Docker-based) -When `agent.project_dir` is set: -1. `setupSandbox()` clones the project into `/tmp/claudomator-sandbox-*` via the "local" remote (bare repo), then falls back to "origin", then the working copy path. -2. The claude subprocess runs inside the sandbox. -3. After successful execution, `teardownSandbox()` auto-commits any uncommitted changes (after running a build if `Makefile`/`go.mod`/`gradlew` is present), then pushes new commits to the bare repo (`origin` from the sandbox's perspective). The sandbox is then removed. -4. On failure the sandbox is preserved and its path is returned in the error. -5. On BLOCKED (question written), the sandbox path is stored in `executions.sandbox_dir` so the resume execution can reuse it. +The production runner for both `claude` and `gemini` agent types is +`ContainerRunner`. (The old subprocess `ClaudeRunner`/`GeminiRunner` and the +`/tmp` git-sandbox helpers were removed; the standalone Gemini stub is gone.) -> ~~**Known bug:** Variable shadowing in `claude.go` `Run()` — fixed in github/main merge; both `setupSandbox` calls correctly use `=` (assign to outer `sandboxDir`).~~ +1. The repo is cloned into a per-run workspace bind-mounted at `/workspace`; a + writable `$HOME` is staged at `/home/agent`. The agent runs as the host uid + (`--user`), with `IS_SANDBOX=1` set so `claude --permission-mode + bypassPermissions` is honored even under root. +2. Instructions are written to a file and passed to the CLI; when the MCP + back-channel is active and `skip_planning` is false, the planning preamble is + prepended. +3. After a successful run, new commits are pushed from the workspace. +4. On BLOCKED (the agent called `ask_user`), the workspace path is stored in + `executions.sandbox_dir` so the resume execution reuses it. -> ~~**Known bug:** `teardownSandbox` hardcodes `origin/master` — fixed; now uses `git pull --rebase` with no branch arg, which follows the configured upstream from `git clone`.~~ +### Agent back-channel (MCP) + +Runners expose a normalized `AgentChannel` (`AskUser`, `ReportSummary`, +`SpawnSubtask`, `RecordProgress`). Transport is per-runner: + +- **ContainerRunner** mints a per-task bearer token (`executor.Registry`) and + points the agent at the host MCP server: claude via `--mcp-config`, gemini via + `~/.gemini/settings.json`. The server (`/mcp`) resolves the task from the token + and never trusts an agent-supplied ID. +- **LocalRunner** declares the four tools as OpenAI function-calling defs and + runs a tool-use loop, re-feeding tool results as message history. + +`ask_user` records a clarification and returns `ErrAgentBlocked`; the runner +turns the buffered question into a `*BlockedError` and the task blocks. ### Task YAML Format @@ -162,9 +184,17 @@ tasks: id, name, description, config_json, priority, timeout_ns, retry_json executions: id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, - sandbox_dir, changestats_json, commits_json + sandbox_dir, changestats_json, commits_json, tokens_in, tokens_out, + agent + +events: id, task_id, seq, ts, kind, actor, payload_json ``` +The `events` table is the per-task observability stream (`internal/event`); see +the REST `GET /api/tasks/{id}/events` and the chatbot MCP `get_events` tool. +`executions.agent` records which provider ran each execution, for budget +accounting (`SpendByProviderSince`). + JSON blobs: `config_json` (AgentConfig), `retry_json`, `tags_json`, `depends_on_json`, `interactions_json`, `changestats_json`, `commits_json`. --- @@ -173,13 +203,13 @@ JSON blobs: `config_json` (AgentConfig), `retry_json`, `tags_json`, `depends_on_ ### Planning Preamble & Orchestration -When `agent.skip_planning` is false (the default), `withPlanningPreamble()` prepends a system-level prompt to the agent's instructions that: -- Instructs the agent to POST subtasks to `$CLAUDOMATOR_API_URL/api/tasks` and stop if the task will take more than ~3 minutes -- Instructs the agent to write a JSON question to `$CLAUDOMATOR_QUESTION_FILE` and exit if it needs user input -- Requires all changes to be committed before exit -- Requires a summary written to `$CLAUDOMATOR_SUMMARY_FILE` +When `agent.skip_planning` is false (the default), `withPlanningPreamble()` prepends a system-level prompt that points the agent at its MCP/tool-use back-channel rather than file/REST conventions: +- Break work taking more than ~3 minutes into pieces via the `spawn_subtask` tool, then stop +- Call `ask_user` (and end the turn) when it needs a decision +- Commit all changes before finishing +- Call `report_summary` before finishing -Env vars injected into every execution: `CLAUDOMATOR_API_URL`, `CLAUDOMATOR_TASK_ID`, `CLAUDOMATOR_PROJECT_DIR`, `CLAUDOMATOR_QUESTION_FILE`, `CLAUDOMATOR_SUMMARY_FILE`. +The four tools — `ask_user`, `report_summary`, `spawn_subtask`, `record_progress` — are served over MCP (claude/gemini) or OpenAI tool-use (local); the old `CLAUDOMATOR_QUESTION_FILE`/`CLAUDOMATOR_SUMMARY_FILE` conventions were removed. ### Changestats @@ -217,11 +247,28 @@ Tasks created for: Tagged `["ci", "auto"]`, capped at $3 USD, allowed tools: Read, Edit, Bash, Glob, Grep. -### Elaborate Endpoint +### Task creation (chatbot-driven) -`POST /api/tasks/elaborate` converts natural language → task JSON via a `claude --prompt` invocation. Optionally reads `CLAUDE.md` / `SESSION_STATE.md` from a configured working directory for context. Per-IP rate-limited. +The natural-language `POST /api/tasks/elaborate` endpoint and the web create +form were removed. Chatbots now create and drive tasks through the chatbot MCP +server (`/chatbot/mcp`, shared bearer token): `submit_task`, `list_tasks`, +`get_task`, `get_events`, `answer_question`, `accept_task`, `reject_task`, +`cancel_task`. The web UI is observability-only (task tree, live logs, event +timeline, accept/reject, budget headroom). -> **Implementation gap:** The elaborate endpoint is not tested against real Claude invocations. `sanitizeElaboratedTask()` uses keyword heuristics to infer missing tools (fragile). No caching. +### Budget gating + +`internal/budget.Accountant` tracks per-provider spend in a rolling window +(default 5h, `[budget]` config). Before running, the dispatcher reroutes paid +work to the free local runner when a cap would be breached, else blocks the task +(`BUDGET_EXCEEDED`). `GET /api/budget` surfaces headroom. Local runners are free. + +### Auth / binding + +The server binds `127.0.0.1` by default and refuses a non-loopback bind unless +`external_bind_allowed = true` (front external access with a reverse proxy). The +shared `api_token` guards the web UI + chatbot MCP; agent MCP uses per-task +tokens; GitHub webhooks use HMAC. ### Model Classifier @@ -236,13 +283,25 @@ Tagged `["ci", "auto"]`, capped at $3 USD, allowed tools: Read, Edit, Bash, Glob ## Design Debt -### GeminiRunner is a non-functional stub +### Stories / checker backend not yet removed (Phase 8 follow-up) + +The web stories dashboard was removed, but the backend remains: the `stories` +table + `internal/api/stories.go` handlers + `/api/stories/*` routes, +story-elaborate in `elaborate.go`, and the story/checker logic woven into +`executor.handleRunResult` (`checkStoryCompletion`, `spawnCheckerTask`, +`triggerStoryDeploy`, `ensureStoryBranch`) plus the `tasks` story/checker +columns. This is a large, lifecycle-touching teardown best done as its own pass. + +### Deprecated task columns not yet dropped (Phase 8 follow-up) -`internal/executor/gemini.go` `execOnce()` does not run the `gemini` binary. It starts a goroutine that writes hardcoded fake JSON to a pipe. `parseGeminiStream()` strips markdown fences but does no semantic parsing. There is no session/resume support. +`tasks.question_json`, `summary`, `interactions_json`, `elaboration_input` are +superseded by the `events` table but still read by the answer flow and task +panel. Dropping them requires migrating those reads to events first. -Any task with `agent.type: "gemini"` will silently return canned output. This is dangerous in production. +### Gemini tool-use unverified -**Decision needed:** Either implement GeminiRunner properly (subprocess + stream parsing + sandbox integration mirroring ClaudeRunner) or remove it and the `Classifier` from the codebase until it's ready. +The gemini MCP wiring (`~/.gemini/settings.json`) is in place but was never run +against a real `gemini` binary — see `cmd/spike` (`go run ./cmd/spike gemini`). ### Priority field is stored but never used @@ -300,8 +359,11 @@ In `executor.go`, `withFailureHistory` creates a copy of the task struct (`copy | GET | `/api/tasks/{id}/logs/stream` | Stream latest execution logs | | GET | `/api/executions` | List recent executions across all tasks | | GET | `/api/tasks/{id}/deployment-status` | Poll deployment readiness | -| POST | `/api/tasks/elaborate` | Convert natural language → task JSON | +| GET | `/api/tasks/{id}/events` | Task observability event stream (`?since_seq=N`) | +| GET | `/api/budget` | Per-provider rolling-window spend headroom | | POST | `/api/tasks/validate` | Validate task JSON | +| POST/GET/DELETE | `/mcp` | Per-task agent MCP back-channel (bearer = minted token) | +| POST/GET/DELETE | `/chatbot/mcp` | Chatbot MCP server (bearer = `api_token`) | | POST | `/api/scripts/{name}` | Run named script with task context | | GET | `/api/ws` | WebSocket upgrade (live task updates) | | GET | `/api/workspaces` | List directories under `workspace_root` | diff --git a/cmd/spike/main.go b/cmd/spike/main.go new file mode 100644 index 0000000..dd87021 --- /dev/null +++ b/cmd/spike/main.go @@ -0,0 +1,165 @@ +// Command spike is a MANUAL validation harness (never run by tests/CI; it +// spawns a real agent CLI and makes paid API calls). It validates the agent MCP +// back-channel against a real binary without Docker: it starts the agent MCP +// server on the host, points the chosen agent CLI at it, and reports whether the +// agent's tool calls reached the AgentChannel. +// +// Usage: +// +// go run ./cmd/spike # claude (default) +// go run ./cmd/spike gemini # gemini (requires a `gemini` binary) +// +// FINDINGS (2026-05, claude 2.1.150 / claude-sonnet-4-6): +// - PASS: claude discovered the server via --mcp-config (streamable HTTP + +// bearer) and called record_progress + report_summary; both reached the +// AgentChannel with zero permission denials (~$0.11/run). +// - PASS: claude called ask_user, received the ErrAgentBlocked "end your turn" +// response, and ended its turn cleanly (the human-in-the-loop path). +// - PRODUCTION NOTE: `claude --permission-mode bypassPermissions` (what +// ContainerRunner.buildInnerCmd emits) is REJECTED when the process is root. +// The in-container agent must run as a non-root user, or tools must be +// pre-allowed via --allowedTools. Worth verifying the container image's user. +// - GEMINI: not validated — no `gemini` binary was available. The gemini path +// below writes the same mcpServers/httpUrl settings the production +// ContainerRunner does; run `go run ./cmd/spike gemini` where a binary exists. +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/thepeterstone/claudomator/internal/executor" +) + +type recordingChannel struct { + mu sync.Mutex + asks []string + summary string + progress []string + subtasks []executor.SubtaskSpec +} + +func (c *recordingChannel) AskUser(_ context.Context, q string) (string, error) { + c.mu.Lock() + c.asks = append(c.asks, q) + c.mu.Unlock() + return "", executor.ErrAgentBlocked // mirror production storeChannel +} +func (c *recordingChannel) ReportSummary(_ context.Context, s string) error { + c.mu.Lock() + c.summary = s + c.mu.Unlock() + return nil +} +func (c *recordingChannel) SpawnSubtask(_ context.Context, spec executor.SubtaskSpec) (string, error) { + c.mu.Lock() + c.subtasks = append(c.subtasks, spec) + c.mu.Unlock() + return "spike-subtask-1", nil +} +func (c *recordingChannel) RecordProgress(_ context.Context, m string) error { + c.mu.Lock() + c.progress = append(c.progress, m) + c.mu.Unlock() + return nil +} + +func main() { + agent := "claude" + if len(os.Args) > 1 { + agent = os.Args[1] + } + + rec := &recordingChannel{} + reg := executor.NewRegistry() + token, err := reg.Mint(rec) + if err != nil { + fmt.Println("mint:", err) + os.Exit(1) + } + + mux := http.NewServeMux() + mux.Handle("/mcp", executor.NewAgentMCPHandler(reg)) + srv := httptest.NewServer(mux) + defer srv.Close() + mcpURL := srv.URL + "/mcp" + + prompt := "You have MCP tools from the 'claudomator' server. Do exactly this and nothing else: " + + "(1) call record_progress with message 'spike: starting'; " + + "(2) call report_summary with summary 'spike validation complete'; " + + "then stop. Do not edit files or run shell commands." + + ctx, cancel := context.WithTimeout(context.Background(), 150*time.Second) + defer cancel() + + var cmd *exec.Cmd + switch agent { + case "claude": + cfg := map[string]any{"mcpServers": map[string]any{"claudomator": map[string]any{ + "type": "http", "url": mcpURL, "headers": map[string]string{"Authorization": "Bearer " + token}, + }}} + data, _ := json.Marshal(cfg) + cfgPath := "/tmp/spike-claude-mcp.json" + _ = os.WriteFile(cfgPath, data, 0600) + allowed := "mcp__claudomator__record_progress,mcp__claudomator__report_summary,mcp__claudomator__ask_user,mcp__claudomator__spawn_subtask" + cmd = exec.CommandContext(ctx, "claude", "-p", prompt, + "--mcp-config", cfgPath, "--allowedTools", allowed, + "--output-format", "stream-json", "--verbose") + case "gemini": + home, _ := os.MkdirTemp("", "spike-gemini-home-*") + geminiDir := filepath.Join(home, ".gemini") + _ = os.MkdirAll(geminiDir, 0755) + cfg := map[string]any{"mcpServers": map[string]any{"claudomator": map[string]any{ + "httpUrl": mcpURL, "headers": map[string]string{"Authorization": "Bearer " + token}, + }}} + data, _ := json.Marshal(cfg) + _ = os.WriteFile(filepath.Join(geminiDir, "settings.json"), data, 0600) + cmd = exec.CommandContext(ctx, "gemini", "-p", prompt) + cmd.Env = append(os.Environ(), "HOME="+home) + default: + fmt.Println("unknown agent:", agent) + os.Exit(1) + } + + var out, errb strings.Builder + cmd.Stdout = &out + cmd.Stderr = &errb + start := time.Now() + runErr := cmd.Run() + + fmt.Printf("=== %s exit: err=%v elapsed=%s ===\n", agent, runErr, time.Since(start).Round(time.Millisecond)) + fmt.Println("--- stderr (tail) ---") + fmt.Println(tail(errb.String(), 1200)) + fmt.Println("--- stdout (tail) ---") + fmt.Println(tail(out.String(), 1500)) + + rec.mu.Lock() + defer rec.mu.Unlock() + fmt.Println("=== AgentChannel received ===") + fmt.Printf("record_progress: %d %v\n", len(rec.progress), rec.progress) + fmt.Printf("report_summary: %q\n", rec.summary) + fmt.Printf("ask_user: %d\n", len(rec.asks)) + fmt.Printf("spawn_subtask: %d\n", len(rec.subtasks)) + + if len(rec.progress) > 0 && rec.summary != "" { + fmt.Println("RESULT: PASS — the agent discovered and called the MCP tools; calls reached the AgentChannel.") + } else { + fmt.Println("RESULT: INCONCLUSIVE — see output above.") + } +} + +func tail(s string, n int) string { + if len(s) <= n { + return s + } + return "…" + s[len(s)-n:] +} diff --git a/docs/plans/claudomator-oss-replacement.md b/docs/plans/claudomator-oss-replacement.md new file mode 100644 index 0000000..f0fd3dc --- /dev/null +++ b/docs/plans/claudomator-oss-replacement.md @@ -0,0 +1,199 @@ +# Claudomator OSS Replacement — Architectural Redesign + +## Context + +Claudomator today is a useful but overgrown harness: ~10 task states, a stories layer, an elaborate-via-REST endpoint, file-based agent escape hatches, a manual task-creation web form, and a stub GeminiRunner that silently returns canned output. The primary mode of interaction we actually want is now: **a chatbot drives task creation and orchestration on the user's behalf; the human only intervenes when an agent asks a question; the web UI is for observability, not for primary input.** + +This plan locks the architectural shape of that redesign. Implementation phasing is sketched at the end but the focus here is decision capture so subsequent work can proceed without re-litigating. + +**Mission:** +- Reduce custom code we maintain. +- Support chatbot-as-primary-driver (us, or any chatbot with MCP). +- Push-notify the user when human input is needed. +- Web UI = observability layer (task tree, live logs, event stream, accept/reject). +- Multi-runner (Claude cloud, Gemini cloud, OSS local) with budget-aware routing. + +**Non-goals (deferred):** +- Cross-model resume — each runner is sovereign over its own continuity. +- Non-code-task UX optimization — shape must remain *possible* (project_dir is optional) but no code or docs accommodate it yet. +- Autonomous chatbot responding to agent questions without human — the subscription model must not foreclose it, but it's not built. + +--- + +## Locked Decisions + +### 1. SQLite is source of truth; external trackers are input feeds + +Tasks live in our `tasks` table. GitHub/GitLab/whatever-tracker integrations create and update tasks through the existing webhook surface — they are not the data model. Each task carries an `external_refs` slice (`[{tracker, url}]`) for opportunistic back-syncing of state, but nothing load-bearing depends on the external tracker being available. + +Rejected: GitHub Issues as source of truth (Option B). Too much lock-in, non-code tasks fit poorly, and we'd still need our own DB for execution data. + +### 2. Events table replaces ad-hoc task-state columns + +A single `events` table replaces `question_json`, `interactions_json`, `summary`, `elaboration_input` on `tasks`. Schema: + +``` +events: id, task_id, seq, ts, kind, actor, payload_json +``` + +- `seq` is monotonic per task. +- `actor ∈ {agent, user, chatbot, system, webhook}`. +- `kind ∈ {state_change, agent_message, clarification_request, clarification_answer, human_message, summary, commit_made, subtask_spawned, cost_report, execution_started, execution_ended}`. +- Granularity: **milestones, not raw stream chunks.** Raw Claude stream-json stays in `stdout.log` on disk, linked from the `execution_started` event. +- Replay: read events in `seq` order, fold state transitions, reconstruct UI. **For humans, not for re-prompting the agent.** Cross-model agent replay is explicitly out of scope. + +### 3. Per-runner structured agent channel (no more file conventions) + +Today the agent uses `CLAUDOMATOR_QUESTION_FILE`, `CLAUDOMATOR_SUMMARY_FILE`, and `POST /api/tasks` for subtasks — orchestrated by a ~1KB prompt preamble. We replace this with a `Runner` interface that exposes normalized callbacks: + +```go +type AgentChannel interface { + OnAskUser(question string, schema any) (answer string, err error) + OnReportSummary(text string) + OnSpawnSubtask(spec TaskSpec) (taskID string, err error) + OnRecordProgress(message string) +} +``` + +Each runner picks its own wire: +- **ClaudeRunner:** registers an in-process MCP server via `--mcp-config`. Tools (`ask_user`, `report_summary`, `spawn_subtask`, `record_progress`) bound to a per-task scoped token minted at subprocess spawn. Server derives `task_id` from token; never trusts agent-supplied IDs. +- **GeminiRunner:** uses Gemini CLI tool-use registration (or direct API if CLI tool-use proves unworkable — to determine during build). +- **LocalRunner:** uses Ollama-style tool-use API. Falls back to file conventions only if a future runner lacks tool-use plumbing. + +**Safety tradeoffs accepted:** +- Atomicity at run boundary: MCP calls land mid-run, file-based intent lands at exit. Mitigated by designing tools idempotent (`report_summary` upserts, `ask_user` creates a clarification event which is fine mid-run by design). +- Blast radius: per-task token scopes the agent's access to one task's data. +- Portability: per-runner transport means non-MCP runners stay supported via callback abstraction. + +### 4. Web UI = observability + +**In:** task tree view, live log tailing, accept/reject buttons, event-stream timeline per task, budget headroom display. + +**Out:** manual task creation form, elaborate textbox, stories dashboard. + +Chatbots create tasks via the MCP server. Humans visit the web UI to watch, intervene on `READY`, or answer a `BLOCKED` clarification when push notification pings them. + +### 5. Three runners + Classifier + +| Runner | Status | Transport | Resume mechanism | +|---|---|---|---| +| ClaudeRunner | exists, works | `claude -p` subprocess + MCP | provider-side via `--resume <session_id>` | +| GeminiRunner | scratch rewrite (current stub deleted) | `gemini` CLI subprocess + tool-use registration | message-history re-feed | +| LocalRunner | exists (Phase 1 of `local-oss-runner.md`), extend with agent-channel | OpenAI-compat HTTP + tool-use | message-history re-feed | + +Classifier stays. Its job becomes: given a task, choose Claude-cloud vs Gemini-cloud vs Local, and within Claude/Gemini, the specific model. **Budget state feeds this decision** (#7). + +### 6. Auth: loopback default + reverse proxy + scoped tokens + +- Server binds to `127.0.0.1` by default. Refuses to bind non-loopback unless `external_bind_allowed = true` is set in config — fail loud on misconfiguration. +- External network access: user fronts with Tailscale / Cloudflare Access / Caddy. Out of our code, battle-tested. +- Internal tokens, each scoped: + - **GitHub webhooks:** HMAC-SHA256 (already exists). + - **Chatbot MCP + web UI:** one shared bearer token from config; user pastes into MCP client config. One user, one credential. + - **Agent MCP back-channel:** per-task token minted at subprocess spawn; bound to single task ID; expires at task termination. Server resolves `task_id` from token — never accepts agent-supplied `task_id`. + - **Web push:** standard VAPID subscriptions. + +### 7. Budget — multi-dimensional + +- Per-task `max_budget_usd` (exists). +- **Rolling 5-hour budget per provider** (Claude, Gemini) — track spend in a window. Dispatcher refuses to start a paid task that would breach the window. Classifier prefers local when headroom is thin. +- Optional per-day / per-week hard ceiling in config. +- Local runners bypass entirely — free. +- UI surfaces headroom: e.g. "Claude: 67% remaining in current 5h window." + +This is genuinely new code: budget accountant, classifier hook, UI surface. Not throwaway. + +### 8. Resume = per-runner sovereign opaque state + +Each runner persists its own resume state as opaque JSON in `executions.runner_state_json`. The Runner interface: + +```go +Resume(ctx context.Context, opaqueState []byte) (*Result, error) +``` + +- **ClaudeRunner** stores `{session_id}`, calls `--resume`. **Prompt caching survives** — this is the killer feature that justifies the special case. Cost can differ 10x on long resumed tasks. +- **GeminiRunner** stores `{messages: [...]}`, re-feeds on resume. +- **LocalRunner** stores `{messages: [...]}`, re-feeds on resume. + +The "specialness" of Claude is entirely internal to ClaudeRunner — the dispatcher and event store don't know. Cross-model resume is **not** a goal; if we ever want it, revisit then. + +### 9. Subscription model: push, agent-return-friendly + +Server broadcasts events on WebSocket (live UI) + web push (high-signal subset: `clarification_request`, terminal state changes, `summary`). Subscription record: + +``` +subscriptions: id, endpoint, kind_filter, task_filter, actor_label +``` + +Today's only subscribers are browser WebSocket clients and web-push endpoints. The model is flexible enough that adding an autonomous-chatbot subscriber later is one row: `{endpoint: chatbot_url, kinds: ["clarification_request"], actor_label: "auto-responder"}`. The `POST /api/tasks/{id}/answer` endpoint already accepts any caller; the `actor` field on the resulting `clarification_answer` event records who answered. + +--- + +## What Gets Deleted + +| Surface | Reason | +|---|---| +| `stories` table + handlers + UI dashboard | Folded into events + task tree view | +| `tasks.question_json`, `interactions_json`, `summary`, `elaboration_input` | Replaced by events table | +| `POST /api/tasks/elaborate` endpoint | Chatbots elaborate in-conversation; no REST shell-out path | +| `sanitizeElaboratedTask` keyword heuristics | Same | +| Web UI: create-task form, elaborate textbox, stories dashboard | Out of scope for observability UI | +| `CLAUDOMATOR_QUESTION_FILE`, `CLAUDOMATOR_SUMMARY_FILE` env conventions | Replaced by per-runner agent channel | +| GeminiRunner stub (`internal/executor/gemini.go` simulated output) | Scratch rewrite as real runner | +| Planning preamble's file-protocol section | Shrunk to "use your tools" — runners register tools per-transport | + +## What Gets Built + +| Surface | Notes | +|---|---| +| `internal/event` package | `Event` struct, kind constants, `EventStore` interface | +| `events` table + scan/CRUD methods | Additive migration; indexed on `(task_id, seq)` | +| Per-task scoped MCP server | Hosted inside claudomator process; tokens minted per subprocess spawn | +| Chatbot-facing MCP server | Tools: `submit_task`, `list_tasks`, `get_task`, `get_events`, `answer_question`, `accept_task`, `reject_task`, `cancel_task` | +| `AgentChannel` interface + per-runner implementations | Normalized callbacks; transport hidden in runner | +| Real GeminiRunner | Subprocess + stream parser + sandbox integration; mirrors ClaudeRunner shape | +| Budget accountant | Rolling 5h window per provider; classifier hook; UI surface | +| Loopback-default binding + `external_bind_allowed` flag | Hard refusal on misconfig | +| Per-task token minting + scoping | Auth context for agent MCP calls | +| Web UI event-stream timeline component | Replaces ad-hoc question/summary panels | +| `external_refs` field on tasks | For opportunistic tracker back-sync | + +--- + +## Phasing + +These are sequencing notes, not commitments to PR boundaries. Each phase should be independently shippable. + +**Phase 1 — Events table + Runner interface refinement.** Add `events` table, `EventStore`, write paths for state changes and existing question/summary flows. Refactor `Runner` to expose `AgentChannel` callbacks; ClaudeRunner keeps file-based wire as transitional default. No user-visible behavior change yet. + +**Phase 2 — ClaudeRunner MCP agent channel.** Per-task MCP server, token minting, four agent tools. Delete file-based escape hatches. Shrink preamble. Migration: existing in-flight tasks finish on the old wire; new tasks use MCP. + +**Phase 3 — Chatbot MCP server + UI rebuild.** Chatbot-facing MCP server. Web UI gets event-stream timeline, drops create form / elaborate / stories. Elaborate REST endpoint removed. + +**Phase 4 — GeminiRunner scratch rewrite.** Real subprocess + stream parser + sandbox + tool-use registration. Plug into AgentChannel. + +**Phase 5 — LocalRunner agent channel.** Extend the existing LocalRunner (from `local-oss-runner.md`) to participate in AgentChannel via Ollama tool-use. Was previously fire-and-forget; now first-class for orchestration tasks. + +**Phase 6 — Budget accountant.** Rolling 5h window tracking, classifier integration, UI headroom display, dispatcher gating. + +**Phase 7 — Auth tightening.** Loopback default, `external_bind_allowed` flag, refuse-bind enforcement, chatbot bearer rotation flow. + +**Phase 8 — Cleanup pass.** Delete `stories`, deprecated columns, dead handlers, dead UI. Update CLAUDE.md and ADRs. + +--- + +## Open Design Questions (resolve at phase entry) + +- **MCP tool schemas.** Exact arg shapes for `ask_user` (free text? JSON schema for typed answers?), `spawn_subtask` (how much of `TaskSpec` is exposed?), `submit_task` (how to handle `project_dir` resolution from chatbot side?). Defer to Phase 1/3 detailed design. +- **Gemini transport for tool-use.** Whether `gemini` CLI's tool-use is reliable enough or we need to drop to the SDK. Decide in Phase 4 spike. +- **Web push subset.** Final list of event kinds that trigger push vs WebSocket-only. Default: `clarification_request`, `state_change → READY/FAILED/BLOCKED`, `summary`. Tune in Phase 3. +- **Single shared bearer token vs per-client.** If multi-device gets painful, revisit. Defer. +- **Budget hard-ceiling defaults.** What's the user's actual daily Claude spend tolerance? Surface in config with no default; require explicit value. + +## Deferred (revisit only if pressure appears) + +- Cross-model resume. +- Non-code-task UX (event types for "agent produced text", task-with-conversation-as-primary view). Shape must remain possible; no code or docs accommodate it. +- Autonomous chatbot answering clarifications. Subscription model supports it; no implementation. +- GitLab webhook handler (parallel to GitHub). Add when needed. +- Todoist or similar personal-task-tracker bridges. Same shape as GitHub webhook; add when needed. @@ -3,27 +3,33 @@ module github.com/thepeterstone/claudomator go 1.25.3 require ( + github.com/BurntSushi/toml v1.6.0 + github.com/SherClockHolmes/webpush-go v1.4.0 github.com/google/uuid v1.6.0 github.com/mattn/go-sqlite3 v1.14.33 + github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/spf13/cobra v1.10.2 golang.org/x/net v0.49.0 gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.47.0 ) require ( - github.com/BurntSushi/toml v1.6.0 // indirect - github.com/SherClockHolmes/webpush-go v1.4.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/golang-jwt/jwt/v5 v5.2.1 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/crypto v0.47.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sys v0.42.0 // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.47.0 // indirect ) @@ -5,26 +5,43 @@ github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxh github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= +github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -40,6 +57,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -50,6 +69,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -57,6 +78,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -95,16 +118,38 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= +modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk= modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/api/agentmcp_endpoint_test.go b/internal/api/agentmcp_endpoint_test.go new file mode 100644 index 0000000..b2f77d3 --- /dev/null +++ b/internal/api/agentmcp_endpoint_test.go @@ -0,0 +1,104 @@ +package api + +import ( + "context" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/thepeterstone/claudomator/internal/executor" + "github.com/thepeterstone/claudomator/internal/storage" +) + +type fakeAgentChannel struct{ progress []string } + +func (f *fakeAgentChannel) AskUser(context.Context, string) (string, error) { + return "", executor.ErrAgentBlocked +} +func (f *fakeAgentChannel) ReportSummary(context.Context, string) error { return nil } +func (f *fakeAgentChannel) SpawnSubtask(context.Context, executor.SubtaskSpec) (string, error) { + return "sub", nil +} +func (f *fakeAgentChannel) RecordProgress(_ context.Context, m string) error { + f.progress = append(f.progress, m) + return nil +} + +type tokenRT struct { + token string + base http.RoundTripper +} + +func (b tokenRT) RoundTrip(r *http.Request) (*http.Response, error) { + r = r.Clone(r.Context()) + r.Header.Set("Authorization", "Bearer "+b.token) + return b.base.RoundTrip(r) +} + +func TestServer_MountsAgentMCPEndpoint(t *testing.T) { + store, err := storage.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + pool := executor.NewPool(1, map[string]executor.Runner{}, store, logger) + + reg := executor.NewRegistry() + fc := &fakeAgentChannel{} + token, _ := reg.Mint(fc) + + srv := NewServer(store, pool, reg, logger, "claude", "gemini") + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1"}, nil) + cs, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{ + Endpoint: ts.URL + "/mcp", + HTTPClient: &http.Client{Transport: tokenRT{token: token, base: http.DefaultTransport}}, + }, nil) + if err != nil { + t.Fatalf("connect to mounted /mcp: %v", err) + } + defer cs.Close() + + if _, err := cs.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "record_progress", + Arguments: map[string]any{"message": "via api server"}, + }); err != nil { + t.Fatalf("CallTool through api server: %v", err) + } + if len(fc.progress) != 1 || fc.progress[0] != "via api server" { + t.Errorf("tool call did not reach channel: %+v", fc.progress) + } +} + +func TestServer_NoRegistry_NoMCPRoute(t *testing.T) { + store, err := storage.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + pool := executor.NewPool(1, map[string]executor.Runner{}, store, logger) + + // nil registry → /mcp must not be mounted. + srv := NewServer(store, pool, nil, logger, "claude", "gemini") + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + resp, err := http.Post(ts.URL+"/mcp", "application/json", http.NoBody) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + // Unmounted: the catch-all "GET /" route rejects POST with 405. A mounted + // MCP handler would instead respond (e.g. 400 for the missing token). + if resp.StatusCode == http.StatusBadRequest { + t.Errorf("expected /mcp to be absent without a registry, got 400 (handler ran)") + } +} diff --git a/internal/api/budget.go b/internal/api/budget.go new file mode 100644 index 0000000..eee5d09 --- /dev/null +++ b/internal/api/budget.go @@ -0,0 +1,36 @@ +package api + +import ( + "net/http" + + "github.com/thepeterstone/claudomator/internal/budget" +) + +// budgetReporter exposes per-provider spend headroom. Satisfied by +// *budget.Accountant; an interface keeps the handler testable. +type budgetReporter interface { + All() ([]budget.Headroom, error) +} + +// SetBudget wires the budget accountant so GET /api/budget can report headroom. +func (s *Server) SetBudget(b budgetReporter) { + s.budget = b +} + +// handleGetBudget returns per-provider rolling-window spend headroom. When no +// budget is configured it returns an empty list so the UI simply shows nothing. +func (s *Server) handleGetBudget(w http.ResponseWriter, _ *http.Request) { + if s.budget == nil { + writeJSON(w, http.StatusOK, []budget.Headroom{}) + return + } + hs, err := s.budget.All() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if hs == nil { + hs = []budget.Headroom{} + } + writeJSON(w, http.StatusOK, hs) +} diff --git a/internal/api/budget_test.go b/internal/api/budget_test.go new file mode 100644 index 0000000..d59836d --- /dev/null +++ b/internal/api/budget_test.go @@ -0,0 +1,68 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/thepeterstone/claudomator/internal/budget" +) + +type fakeBudget struct { + hs []budget.Headroom + err error +} + +func (f fakeBudget) All() ([]budget.Headroom, error) { return f.hs, f.err } + +func TestHandleGetBudget_NoBudgetConfigured_ReturnsEmpty(t *testing.T) { + srv, _ := testServer(t) + req := httptest.NewRequest("GET", "/api/budget", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d", w.Code) + } + var hs []budget.Headroom + if err := json.Unmarshal(w.Body.Bytes(), &hs); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(hs) != 0 { + t.Errorf("want empty list, got %+v", hs) + } +} + +func TestHandleGetBudget_ReportsHeadroom(t *testing.T) { + srv, _ := testServer(t) + srv.SetBudget(fakeBudget{hs: []budget.Headroom{ + {Provider: "claude", Limited: true, Limit: 10, Spent: 4, Remaining: 6, Fraction: 0.6}, + }}) + req := httptest.NewRequest("GET", "/api/budget", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d", w.Code) + } + var hs []budget.Headroom + if err := json.Unmarshal(w.Body.Bytes(), &hs); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(hs) != 1 || hs[0].Provider != "claude" || hs[0].Remaining != 6 { + t.Errorf("unexpected headroom: %+v", hs) + } +} + +func TestHandleGetBudget_ErrorReturns500(t *testing.T) { + srv, _ := testServer(t) + srv.SetBudget(fakeBudget{err: errors.New("db down")}) + req := httptest.NewRequest("GET", "/api/budget", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusInternalServerError { + t.Errorf("want 500, got %d", w.Code) + } +} diff --git a/internal/api/chatbotmcp.go b/internal/api/chatbotmcp.go new file mode 100644 index 0000000..e14b522 --- /dev/null +++ b/internal/api/chatbotmcp.go @@ -0,0 +1,218 @@ +package api + +import ( + "context" + "crypto/subtle" + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/thepeterstone/claudomator/internal/event" + "github.com/thepeterstone/claudomator/internal/storage" + "github.com/thepeterstone/claudomator/internal/task" +) + +// The chatbot-facing MCP server lets a chatbot drive task creation and +// orchestration. Unlike the per-task agent server, it spans all tasks and is +// guarded by the single shared API bearer token (config api_token / SetAPIToken). + +type submitTaskMCPInput struct { + Instructions string `json:"instructions" jsonschema:"complete instructions for the agent that will run this task"` + Name string `json:"name,omitempty" jsonschema:"short human-readable task name; defaults to the first line of instructions"` + Project string `json:"project,omitempty" jsonschema:"name of a configured project; used to resolve the repository when repository_url is omitted"` + RepositoryURL string `json:"repository_url,omitempty" jsonschema:"git repository URL to work in; required unless a project resolves to one"` + Model string `json:"model,omitempty" jsonschema:"optional model override, e.g. sonnet or opus"` + MaxBudgetUSD float64 `json:"max_budget_usd,omitempty" jsonschema:"optional spend cap in USD"` + Timeout string `json:"timeout,omitempty" jsonschema:"optional duration like 15m or 1h"` + Tags []string `json:"tags,omitempty" jsonschema:"optional tags"` +} + +type listTasksMCPInput struct { + State string `json:"state,omitempty" jsonschema:"optional state filter, e.g. RUNNING, READY, BLOCKED, COMPLETED, FAILED"` + Limit int `json:"limit,omitempty" jsonschema:"optional max number of tasks to return"` +} + +type taskIDInput struct { + TaskID string `json:"task_id" jsonschema:"the task ID"` +} + +type getEventsMCPInput struct { + TaskID string `json:"task_id" jsonschema:"the task ID"` + SinceSeq int64 `json:"since_seq,omitempty" jsonschema:"return only events with seq greater than this value"` +} + +type answerQuestionMCPInput struct { + TaskID string `json:"task_id" jsonschema:"the task ID of the BLOCKED task"` + Answer string `json:"answer" jsonschema:"the answer to the agent's question"` +} + +type rejectTaskMCPInput struct { + TaskID string `json:"task_id" jsonschema:"the task ID"` + Comment string `json:"comment,omitempty" jsonschema:"optional reason the work was rejected, fed back to the agent on retry"` +} + +type compactTask struct { + ID string `json:"id"` + Name string `json:"name"` + State task.State `json:"state"` + Project string `json:"project,omitempty"` +} + +func mcpText(text string) *mcp.CallToolResult { + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}} +} + +func mcpJSON(v any) (*mcp.CallToolResult, any, error) { + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + return nil, nil, err + } + return mcpText(string(b)), nil, nil +} + +// newChatbotServer builds the chatbot-facing MCP server bound to this Server's +// store and pool. +func (s *Server) newChatbotServer() *mcp.Server { + srv := mcp.NewServer(&mcp.Implementation{Name: "claudomator-chatbot", Version: "1"}, nil) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "submit_task", + Description: "Create a task and immediately queue it for execution. Returns the new task ID and state.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in submitTaskMCPInput) (*mcp.CallToolResult, any, error) { + spec := submitTaskSpec{ + Name: in.Name, + Instructions: in.Instructions, + Project: in.Project, + RepositoryURL: in.RepositoryURL, + Model: in.Model, + MaxBudgetUSD: in.MaxBudgetUSD, + Tags: in.Tags, + } + if strings.TrimSpace(in.Timeout) != "" { + d, err := time.ParseDuration(in.Timeout) + if err != nil { + return nil, nil, badRequestf("invalid timeout %q: %v", in.Timeout, err) + } + spec.Timeout = d + } + t, err := s.submitTask(ctx, spec) + if err != nil { + return nil, nil, err + } + return mcpJSON(compactTask{ID: t.ID, Name: t.Name, State: t.State, Project: t.Project}) + }) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "list_tasks", + Description: "List tasks, optionally filtered by state. Returns compact records; use get_task for full detail.", + }, func(_ context.Context, _ *mcp.CallToolRequest, in listTasksMCPInput) (*mcp.CallToolResult, any, error) { + filter := storage.TaskFilter{Limit: in.Limit} + if in.State != "" { + st := task.State(in.State) + if !validTaskStates[st] { + return nil, nil, badRequestf("invalid state %q", in.State) + } + filter.State = st + } + tasks, err := s.store.ListTasks(filter) + if err != nil { + return nil, nil, err + } + out := make([]compactTask, 0, len(tasks)) + for _, tk := range tasks { + out = append(out, compactTask{ID: tk.ID, Name: tk.Name, State: tk.State, Project: tk.Project}) + } + return mcpJSON(out) + }) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "get_task", + Description: "Get one task with enriched detail (changestats, deployment status, error message).", + }, func(_ context.Context, _ *mcp.CallToolRequest, in taskIDInput) (*mcp.CallToolResult, any, error) { + tk, err := s.store.GetTask(in.TaskID) + if err != nil { + return nil, nil, errTaskNotFound + } + return mcpJSON(s.enrichTask(tk)) + }) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "get_events", + Description: "Get the observability event stream for a task in seq order. Pass since_seq to page incrementally.", + }, func(_ context.Context, _ *mcp.CallToolRequest, in getEventsMCPInput) (*mcp.CallToolResult, any, error) { + if _, err := s.store.GetTask(in.TaskID); err != nil { + return nil, nil, errTaskNotFound + } + events, err := s.store.ListEvents(in.TaskID, in.SinceSeq) + if err != nil { + return nil, nil, err + } + if events == nil { + events = []*event.Event{} + } + return mcpJSON(events) + }) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "answer_question", + Description: "Answer a BLOCKED task's clarification question. The task resumes from where the agent left off.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in answerQuestionMCPInput) (*mcp.CallToolResult, any, error) { + if err := s.answerTaskQuestion(ctx, in.TaskID, in.Answer); err != nil { + return nil, nil, err + } + return mcpText("Answer recorded; task queued for resume."), nil, nil + }) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "accept_task", + Description: "Accept a READY task, marking it COMPLETED.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in taskIDInput) (*mcp.CallToolResult, any, error) { + if err := s.acceptTask(ctx, in.TaskID, event.ActorChatbot); err != nil { + return nil, nil, err + } + return mcpText("Task accepted."), nil, nil + }) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "reject_task", + Description: "Reject a READY task, sending it back to PENDING with an optional comment for the next attempt.", + }, func(_ context.Context, _ *mcp.CallToolRequest, in rejectTaskMCPInput) (*mcp.CallToolResult, any, error) { + if err := s.rejectTask(in.TaskID, in.Comment); err != nil { + return nil, nil, err + } + return mcpText("Task rejected."), nil, nil + }) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "cancel_task", + Description: "Cancel a RUNNING or QUEUED task.", + }, func(_ context.Context, _ *mcp.CallToolRequest, in taskIDInput) (*mcp.CallToolResult, any, error) { + msg, err := s.cancelTask(in.TaskID, event.ActorChatbot) + if err != nil { + return nil, nil, err + } + return mcpText(msg), nil, nil + }) + + return srv +} + +// chatbotMCPHandler returns the HTTP handler for the chatbot MCP server. It +// serves only when an API token is configured and the request presents it as a +// bearer credential; otherwise the underlying handler receives a nil server and +// rejects the request. +func (s *Server) chatbotMCPHandler() http.Handler { + srv := s.newChatbotServer() + return mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { + if s.apiToken == "" { + return nil + } + tok := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) + if subtle.ConstantTimeCompare([]byte(tok), []byte(s.apiToken)) != 1 { + return nil + } + return srv + }, nil) +} diff --git a/internal/api/chatbotmcp_test.go b/internal/api/chatbotmcp_test.go new file mode 100644 index 0000000..d13b0d7 --- /dev/null +++ b/internal/api/chatbotmcp_test.go @@ -0,0 +1,233 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/thepeterstone/claudomator/internal/storage" + "github.com/thepeterstone/claudomator/internal/task" +) + +// chatbotClient starts the api server with the given token and returns a +// connected MCP client session pointed at /chatbot/mcp. +func chatbotClient(t *testing.T, srv *Server, token string) *mcp.ClientSession { + t.Helper() + ts := httptest.NewServer(srv.Handler()) + t.Cleanup(ts.Close) + + client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1"}, nil) + cs, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{ + Endpoint: ts.URL + "/chatbot/mcp", + HTTPClient: &http.Client{Transport: tokenRT{token: token, base: http.DefaultTransport}}, + }, nil) + if err != nil { + t.Fatalf("connect to /chatbot/mcp: %v", err) + } + t.Cleanup(func() { cs.Close() }) + return cs +} + +func callText(t *testing.T, cs *mcp.ClientSession, name string, args map[string]any) string { + t.Helper() + res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args}) + if err != nil { + t.Fatalf("CallTool %s transport error: %v", name, err) + } + if res.IsError { + t.Fatalf("CallTool %s returned tool error: %s", name, toolText(res)) + } + return toolText(res) +} + +func toolText(res *mcp.CallToolResult) string { + if len(res.Content) == 0 { + return "" + } + if tc, ok := res.Content[0].(*mcp.TextContent); ok { + return tc.Text + } + return "" +} + +func TestChatbotMCP_RequiresToken(t *testing.T) { + srv, _ := testServer(t) + srv.SetAPIToken("s3cret") + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1"}, nil) + _, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{ + Endpoint: ts.URL + "/chatbot/mcp", + HTTPClient: &http.Client{Transport: tokenRT{token: "wrong", base: http.DefaultTransport}}, + }, nil) + if err == nil { + t.Fatal("expected connect with wrong token to be rejected") + } +} + +func TestChatbotMCP_SubmitTask_CreatesAndQueues(t *testing.T) { + srv, store := testServer(t) + srv.SetAPIToken("s3cret") + cs := chatbotClient(t, srv, "s3cret") + + out := callText(t, cs, "submit_task", map[string]any{ + "instructions": "fix the flaky test in package foo", + "repository_url": "https://github.com/user/repo", + }) + var ct compactTask + if err := json.Unmarshal([]byte(out), &ct); err != nil { + t.Fatalf("unmarshal submit_task result %q: %v", out, err) + } + if ct.ID == "" { + t.Fatal("submit_task returned empty task ID") + } + if ct.Name != "fix the flaky test in package foo" { + t.Errorf("name defaulted wrong: %q", ct.Name) + } + if _, err := store.GetTask(ct.ID); err != nil { + t.Fatalf("submitted task not persisted: %v", err) + } +} + +func TestChatbotMCP_SubmitTask_RequiresRepo(t *testing.T) { + srv, _ := testServer(t) + srv.SetAPIToken("s3cret") + cs := chatbotClient(t, srv, "s3cret") + + res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "submit_task", + Arguments: map[string]any{"instructions": "do a thing"}, + }) + if err != nil { + t.Fatalf("transport error: %v", err) + } + if !res.IsError { + t.Fatal("expected submit_task without repository_url to be a tool error") + } +} + +func TestChatbotMCP_GetAndListTasks(t *testing.T) { + srv, store := testServer(t) + srv.SetAPIToken("s3cret") + createTaskWithState(t, store, "ct-ready", task.StateReady) + cs := chatbotClient(t, srv, "s3cret") + + got := callText(t, cs, "get_task", map[string]any{"task_id": "ct-ready"}) + var view map[string]any + if err := json.Unmarshal([]byte(got), &view); err != nil { + t.Fatalf("unmarshal get_task: %v", err) + } + if view["id"] != "ct-ready" { + t.Errorf("get_task id: got %v", view["id"]) + } + + listed := callText(t, cs, "list_tasks", map[string]any{"state": "READY"}) + var tasks []compactTask + if err := json.Unmarshal([]byte(listed), &tasks); err != nil { + t.Fatalf("unmarshal list_tasks: %v", err) + } + if len(tasks) != 1 || tasks[0].ID != "ct-ready" { + t.Errorf("list_tasks returned %+v", tasks) + } +} + +func TestChatbotMCP_GetTask_NotFound(t *testing.T) { + srv, _ := testServer(t) + srv.SetAPIToken("s3cret") + cs := chatbotClient(t, srv, "s3cret") + + res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "get_task", + Arguments: map[string]any{"task_id": "nope"}, + }) + if err != nil { + t.Fatalf("transport error: %v", err) + } + if !res.IsError { + t.Fatal("expected get_task on missing task to be a tool error") + } +} + +func TestChatbotMCP_GetEvents(t *testing.T) { + srv, store := testServer(t) + srv.SetAPIToken("s3cret") + createTaskWithState(t, store, "ct-events", task.StateReady) + cs := chatbotClient(t, srv, "s3cret") + + out := callText(t, cs, "get_events", map[string]any{"task_id": "ct-events"}) + var events []map[string]any + if err := json.Unmarshal([]byte(out), &events); err != nil { + t.Fatalf("unmarshal get_events %q: %v", out, err) + } + // State walk to READY emits state_change events. + if len(events) == 0 { + t.Error("expected at least one event for a task that walked to READY") + } +} + +func TestChatbotMCP_AcceptTask(t *testing.T) { + srv, store := testServer(t) + srv.SetAPIToken("s3cret") + createTaskWithState(t, store, "ct-accept", task.StateReady) + cs := chatbotClient(t, srv, "s3cret") + + callText(t, cs, "accept_task", map[string]any{"task_id": "ct-accept"}) + got, _ := store.GetTask("ct-accept") + if got.State != task.StateCompleted { + t.Errorf("after accept_task: want COMPLETED, got %s", got.State) + } +} + +func TestChatbotMCP_RejectTask(t *testing.T) { + srv, store := testServer(t) + srv.SetAPIToken("s3cret") + createTaskWithState(t, store, "ct-reject", task.StateReady) + cs := chatbotClient(t, srv, "s3cret") + + callText(t, cs, "reject_task", map[string]any{"task_id": "ct-reject", "comment": "needs work"}) + got, _ := store.GetTask("ct-reject") + if got.State != task.StatePending { + t.Errorf("after reject_task: want PENDING, got %s", got.State) + } + if got.RejectionComment != "needs work" { + t.Errorf("rejection comment: got %q", got.RejectionComment) + } +} + +func TestChatbotMCP_CancelTask(t *testing.T) { + srv, store := testServer(t) + srv.SetAPIToken("s3cret") + createTaskWithState(t, store, "ct-cancel", task.StateQueued) + cs := chatbotClient(t, srv, "s3cret") + + callText(t, cs, "cancel_task", map[string]any{"task_id": "ct-cancel"}) + got, _ := store.GetTask("ct-cancel") + if got.State != task.StateCancelled { + t.Errorf("after cancel_task: want CANCELLED, got %s", got.State) + } +} + +func TestChatbotMCP_AnswerQuestion(t *testing.T) { + srv, store := testServer(t) + srv.SetAPIToken("s3cret") + createTaskWithState(t, store, "ct-answer", task.StateBlocked) + if err := store.CreateExecution(&storage.Execution{ + ID: "ct-answer-exec", + TaskID: "ct-answer", + SessionID: "550e8400-e29b-41d4-a716-446655440099", + Status: "BLOCKED", + }); err != nil { + t.Fatalf("create execution: %v", err) + } + cs := chatbotClient(t, srv, "s3cret") + + callText(t, cs, "answer_question", map[string]any{"task_id": "ct-answer", "answer": "use main"}) + got, _ := store.GetTask("ct-answer") + if got.State != task.StateQueued && got.State != task.StateRunning && got.State != task.StateReady { + t.Errorf("after answer_question: want QUEUED/RUNNING/READY, got %s", got.State) + } +} diff --git a/internal/api/elaborate.go b/internal/api/elaborate.go index 8676b36..e8930a6 100644 --- a/internal/api/elaborate.go +++ b/internal/api/elaborate.go @@ -6,128 +6,13 @@ import ( "encoding/json" "fmt" "net/http" - "os" "os/exec" - "path/filepath" - "sort" "strings" "time" - - "github.com/thepeterstone/claudomator/internal/llm" ) const elaborateTimeout = 30 * time.Second -func buildElaboratePrompt(workDir string) string { - workDirLine := ` "project_dir": string — leave empty unless you have a specific reason to set it,` - if workDir != "" { - workDirLine = fmt.Sprintf(` "project_dir": string — use %q for tasks that operate on this codebase, empty string otherwise,`, workDir) - } - return `You are a task configuration assistant for Claudomator, an AI task runner that executes tasks by running Claude or Gemini as a subprocess. - -Your ONLY job is to convert any user request into a Claudomator task JSON object. You MUST always output valid JSON. Never ask clarifying questions. Never explain. Never refuse. Make reasonable assumptions and produce the JSON. - -Output ONLY a valid JSON object matching this schema (no markdown fences, no prose, no explanation): - -{ - "name": string — short imperative title (≤60 chars), - "description": string — 1-2 sentence summary, - "agent": { - "type": "claude" | "gemini", - "model": string — "sonnet" for claude, "gemini-2.5-flash-lite" for gemini, - "instructions": string — detailed, step-by-step instructions for the agent. Must end with a "## Acceptance Criteria" section listing measurable conditions that define success. For coding tasks, include TDD requirements (write failing tests first, then implement), -` + workDirLine + ` - "max_budget_usd": number — conservative estimate (0.25–5.00), - "allowed_tools": array — every tool the task genuinely needs. Include "Write" if creating files, "Edit" if modifying files, "Read" if reading files, "Bash" for shell/git/test commands, "Grep"/"Glob" for searching. - }, - "timeout": string — e.g. "15m", - "priority": string — "normal" | "high" | "low", - "tags": array — relevant lowercase tags -}` -} - -// elaboratedTask mirrors the task creation schema for elaboration responses. -type elaboratedTask struct { - Name string `json:"name"` - Description string `json:"description"` - Agent elaboratedAgent `json:"agent"` - Timeout string `json:"timeout"` - Priority string `json:"priority"` - Tags []string `json:"tags"` -} - -type elaboratedAgent struct { - Type string `json:"type"` - Model string `json:"model"` - Instructions string `json:"instructions"` - ProjectDir string `json:"project_dir"` - MaxBudgetUSD float64 `json:"max_budget_usd"` - AllowedTools []string `json:"allowed_tools"` -} - -// sanitizeElaboratedTask enforces tool completeness and dev practice compliance. -// It modifies t in place, inferring missing tools from instruction keywords and -// appending required sections when they are absent. -func sanitizeElaboratedTask(t *elaboratedTask) { - lower := strings.ToLower(t.Agent.Instructions) - - // Build current tool set. - toolSet := make(map[string]bool, len(t.Agent.AllowedTools)) - for _, tool := range t.Agent.AllowedTools { - toolSet[tool] = true - } - - // Infer missing tools from instruction keywords. - type rule struct { - tool string - keywords []string - } - rules := []rule{ - {"Write", []string{"create file", "write file", "new file", "write to", "save to", "output to", "generate file", "creates a file", "create a new file"}}, - {"Edit", []string{"edit", "modify", "refactor", "replace", "patch"}}, - {"Read", []string{"read", "inspect", "examine", "look at the file"}}, - {"Bash", []string{"run", "execute", "bash", "shell", "command", "build", "compile", "git", "install", "make"}}, - {"Grep", []string{"search for", "grep", "find in", "locate in"}}, - {"Glob", []string{"find file", "list file", "search file"}}, - } - for _, r := range rules { - if toolSet[r.tool] { - continue - } - for _, kw := range r.keywords { - if strings.Contains(lower, kw) { - toolSet[r.tool] = true - break - } - } - } - // Edit without Read is almost always wrong. - if toolSet["Edit"] && !toolSet["Read"] { - toolSet["Read"] = true - } - // Rebuild the list only when tools were added. - if len(toolSet) > len(t.Agent.AllowedTools) { - tools := make([]string, 0, len(toolSet)) - for tool := range toolSet { - tools = append(tools, tool) - } - sort.Strings(tools) - t.Agent.AllowedTools = tools - } - - // Append an acceptance criteria section when none is present. - if !strings.Contains(lower, "acceptance") && - !strings.Contains(lower, "done when") && - !strings.Contains(lower, "success criteria") { - t.Agent.Instructions += "\n\n## Acceptance Criteria\nBefore finishing, verify all stated goals are met, tests pass (if applicable), and no unintended side effects were introduced." - } - - // Append a TDD reminder for coding tasks that do not already mention tests. - if (toolSet["Edit"] || toolSet["Write"]) && !strings.Contains(lower, "test") { - t.Agent.Instructions += "\n\n## Dev Practices\nFollow TDD: write a failing test first, then implement the minimum code to make it pass. Commit all changes before finishing." - } -} - // claudeJSONResult is the top-level object returned by `claude --output-format json`. type claudeJSONResult struct { Result string `json:"result"` @@ -167,142 +52,6 @@ func (s *Server) geminiBinaryPath() string { return "gemini" } -func readProjectContext(workDir string) string { - if workDir == "" { - return "" - } - var sb strings.Builder - for _, filename := range []string{"CLAUDE.md", ".agent/worklog.md"} { - path := filepath.Join(workDir, filename) - if data, err := os.ReadFile(path); err == nil { - if sb.Len() > 0 { - sb.WriteString("\n\n") - } - sb.WriteString(fmt.Sprintf("--- %s ---\n%s", filename, string(data))) - } - } - return sb.String() -} - -func (s *Server) appendRawNarrative(workDir, prompt string) { - if workDir == "" { - return - } - docsDir := filepath.Join(workDir, "docs") - if err := os.MkdirAll(docsDir, 0755); err != nil { - s.logger.Error("elaborate: failed to create docs directory", "error", err, "path", docsDir) - return - } - path := filepath.Join(docsDir, "RAW_NARRATIVE.md") - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - s.logger.Error("elaborate: failed to open RAW_NARRATIVE.md", "error", err, "path", path) - return - } - defer f.Close() - - entry := fmt.Sprintf("\n--- %s ---\n%s\n", time.Now().Format(time.RFC3339), prompt) - if _, err := f.WriteString(entry); err != nil { - s.logger.Error("elaborate: failed to write to RAW_NARRATIVE.md", "error", err, "path", path) - } -} - -func (s *Server) elaborateWithClaude(ctx context.Context, workDir, fullPrompt string) (*elaboratedTask, error) { - cmd := exec.CommandContext(ctx, s.claudeBinaryPath(), - "-p", fullPrompt, - "--system-prompt", buildElaboratePrompt(workDir), - "--output-format", "json", - "--model", "haiku", - ) - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - // Claude returns exit 1 if rate limited but still outputs JSON with is_error: true - err := cmd.Run() - - output := stdout.Bytes() - if len(output) == 0 { - if err != nil { - return nil, fmt.Errorf("claude failed: %w (stderr: %s)", err, stderr.String()) - } - return nil, fmt.Errorf("claude returned no output") - } - - var wrapper claudeJSONResult - if jerr := json.Unmarshal(output, &wrapper); jerr != nil { - return nil, fmt.Errorf("failed to parse claude JSON wrapper: %w (output: %s)", jerr, string(output)) - } - - if wrapper.IsError { - return nil, fmt.Errorf("claude error: %s", wrapper.Result) - } - - var result elaboratedTask - if jerr := json.Unmarshal([]byte(extractJSON(wrapper.Result)), &result); jerr != nil { - return nil, fmt.Errorf("failed to parse elaborated task JSON: %w (result: %s)", jerr, wrapper.Result) - } - - return &result, nil -} - -// elaborateWithLocal runs elaboration through an OpenAI-compatible local LLM. -// It uses the same prompt template as the Claude/Gemini paths and requests -// json_object response format so we can decode directly without the -// markdown-fence cleanup needed for the CLI paths. -func elaborateWithLocal(ctx context.Context, c *llm.Client, workDir, fullPrompt string) (*elaboratedTask, error) { - if c == nil { - return nil, fmt.Errorf("local llm: no client configured") - } - systemPrompt := buildElaboratePrompt(workDir) - resp, err := c.Chat(ctx, llm.ChatRequest{ - Messages: []llm.Message{ - {Role: "system", Content: systemPrompt}, - {Role: "user", Content: fullPrompt}, - }, - ResponseJSON: true, - }) - if err != nil { - return nil, fmt.Errorf("local llm: %w", err) - } - body := strings.TrimSpace(resp.Content) - var result elaboratedTask - if jerr := json.Unmarshal([]byte(extractJSON(body)), &result); jerr != nil { - return nil, fmt.Errorf("local llm: parse JSON: %w (response: %s)", jerr, body) - } - return &result, nil -} - -func (s *Server) elaborateWithGemini(ctx context.Context, workDir, fullPrompt string) (*elaboratedTask, error) { - combinedPrompt := fmt.Sprintf("%s\n\n%s", buildElaboratePrompt(workDir), fullPrompt) - cmd := exec.CommandContext(ctx, s.geminiBinaryPath(), - "-p", combinedPrompt, - "--output-format", "json", - "--model", "gemini-2.5-flash-lite", - ) - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - return nil, fmt.Errorf("gemini failed: %w (stderr: %s)", err, stderr.String()) - } - - var wrapper geminiJSONResult - if err := json.Unmarshal(stdout.Bytes(), &wrapper); err != nil { - return nil, fmt.Errorf("failed to parse gemini JSON wrapper: %w (output: %s)", err, stdout.String()) - } - - var result elaboratedTask - if err := json.Unmarshal([]byte(extractJSON(wrapper.Response)), &result); err != nil { - return nil, fmt.Errorf("failed to parse elaborated task JSON: %w (response: %s)", err, wrapper.Response) - } - - return &result, nil -} - // elaboratedStorySubtask is a leaf unit within a story task. type elaboratedStorySubtask struct { Name string `json:"name"` @@ -493,89 +242,3 @@ func (s *Server) handleElaborateStory(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, result) } - -func (s *Server) handleElaborateTask(w http.ResponseWriter, r *http.Request) { - if s.elaborateLimiter != nil && !s.elaborateLimiter.allow(realIP(r)) { - writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "rate limit exceeded"}) - return - } - - var input struct { - Prompt string `json:"prompt"` - ProjectID string `json:"project_id"` - // project_dir kept for backward compat; project_id takes precedence - ProjectDir string `json:"project_dir"` - } - if err := json.NewDecoder(r.Body).Decode(&input); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) - return - } - if input.Prompt == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "prompt is required"}) - return - } - - workDir := s.workDir - if input.ProjectID != "" { - if proj, err := s.store.GetProject(input.ProjectID); err == nil { - workDir = proj.LocalPath - } - } else if input.ProjectDir != "" { - workDir = input.ProjectDir - } - - if workDir != s.workDir { - go s.appendRawNarrative(workDir, input.Prompt) - } - - projectContext := readProjectContext(workDir) - fullPrompt := input.Prompt - if projectContext != "" { - fullPrompt = fmt.Sprintf("Project context from %s:\n%s\n\nUser request: %s", workDir, projectContext, input.Prompt) - } - - ctx, cancel := context.WithTimeout(r.Context(), elaborateTimeout) - defer cancel() - - var result *elaboratedTask - var err error - - // Try local LLM first when configured. Falls back to Claude → Gemini on - // hard failure of each prior attempt. - if s.llm != nil { - result, err = elaborateWithLocal(ctx, s.llm, workDir, fullPrompt) - if err != nil { - s.logger.Warn("elaborate: local llm failed, falling back to claude", "error", err) - result = nil - } - } - if result == nil { - result, err = s.elaborateWithClaude(ctx, workDir, fullPrompt) - if err != nil { - s.logger.Warn("elaborate: claude failed, falling back to gemini", "error", err) - result, err = s.elaborateWithGemini(ctx, workDir, fullPrompt) - if err != nil { - s.logger.Error("elaborate: gemini also failed", "error", err) - writeJSON(w, http.StatusBadGateway, map[string]string{ - "error": fmt.Sprintf("elaboration failed: %v", err), - }) - return - } - } - } - - if result.Name == "" || result.Agent.Instructions == "" { - writeJSON(w, http.StatusBadGateway, map[string]string{ - "error": "elaboration failed: missing required fields in response", - }) - return - } - - if result.Agent.Type == "" { - result.Agent.Type = "claude" - } - - sanitizeElaboratedTask(result) - - writeJSON(w, http.StatusOK, result) -} diff --git a/internal/api/elaborate_local_test.go b/internal/api/elaborate_local_test.go deleted file mode 100644 index 09a8f9e..0000000 --- a/internal/api/elaborate_local_test.go +++ /dev/null @@ -1,214 +0,0 @@ -package api - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" - - "github.com/thepeterstone/claudomator/internal/llm" -) - -// fakeChatCompletionsServer returns an httptest server that responds to a -// /chat/completions POST with the given assistant content (which should be a -// JSON-encoded elaboratedTask). Returns the server and a counter of calls -// received so tests can assert dispatch ordering. -func fakeChatCompletionsServer(t *testing.T, assistantContent string) (*httptest.Server, *int32) { - t.Helper() - var calls int32 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt32(&calls, 1) - w.Header().Set("Content-Type", "application/json") - // The assistant content has to be JSON-encoded inside the wire format. - escaped, _ := json.Marshal(assistantContent) - fmt.Fprintf(w, `{ - "model":"local", - "choices":[{"message":{"role":"assistant","content":%s},"finish_reason":"stop"}], - "usage":{"prompt_tokens":10,"completion_tokens":50} - }`, string(escaped)) - })) - t.Cleanup(srv.Close) - return srv, &calls -} - -func TestElaborateWithLocal_ParsesValidResponse(t *testing.T) { - taskBody, _ := json.Marshal(elaboratedTask{ - Name: "Test elaborated task", - Description: "From local llm", - Agent: elaboratedAgent{ - Type: "claude", - Model: "sonnet", - Instructions: "Run go build.", - MaxBudgetUSD: 0.25, - AllowedTools: []string{"Bash"}, - }, - Timeout: "10m", - Priority: "normal", - Tags: []string{"build"}, - }) - srv, calls := fakeChatCompletionsServer(t, string(taskBody)) - - c := &llm.Client{Endpoint: srv.URL + "/v1", Model: "fake"} - result, err := elaborateWithLocal(context.Background(), c, "/some/dir", "build the project") - if err != nil { - t.Fatalf("elaborateWithLocal: %v", err) - } - if result.Name != "Test elaborated task" { - t.Errorf("Name: %q", result.Name) - } - if result.Agent.Instructions != "Run go build." { - t.Errorf("Instructions: %q", result.Agent.Instructions) - } - if got := atomic.LoadInt32(calls); got != 1 { - t.Errorf("expected 1 call, got %d", got) - } -} - -func TestElaborateWithLocal_NilClient(t *testing.T) { - _, err := elaborateWithLocal(context.Background(), nil, "", "p") - if err == nil || !strings.Contains(err.Error(), "no client") { - t.Errorf("expected nil-client error, got %v", err) - } -} - -func TestElaborateWithLocal_BadJSON(t *testing.T) { - srv, _ := fakeChatCompletionsServer(t, "this is not JSON at all") - c := &llm.Client{Endpoint: srv.URL + "/v1", Model: "fake"} - _, err := elaborateWithLocal(context.Background(), c, "", "p") - if err == nil || !strings.Contains(err.Error(), "parse JSON") { - t.Errorf("expected parse error, got %v", err) - } -} - -// TestElaborateTask_LocalLLMPreferred verifies the dispatcher uses local LLM -// when SetLLM is configured, and does not invoke claude. -func TestElaborateTask_LocalLLMPreferred(t *testing.T) { - srv, _ := testServer(t) - - taskBody, _ := json.Marshal(elaboratedTask{ - Name: "Local-elaborated", - Description: "From local", - Agent: elaboratedAgent{ - Type: "claude", - Model: "sonnet", - Instructions: "Do work. Tests pass when complete.", - MaxBudgetUSD: 0.25, - AllowedTools: []string{"Bash"}, - }, - Timeout: "10m", - Priority: "normal", - }) - llmSrv, _ := fakeChatCompletionsServer(t, string(taskBody)) - srv.SetLLM(&llm.Client{Endpoint: llmSrv.URL + "/v1", Model: "fake"}) - // Point Claude binary at a path that would fail if called. - srv.elaborateCmdPath = "/nonexistent/claude-should-not-run" - - body := `{"prompt":"do work"}` - req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - srv.Handler().ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String()) - } - var got elaboratedTask - if err := json.NewDecoder(w.Body).Decode(&got); err != nil { - t.Fatalf("decode response: %v", err) - } - if got.Name != "Local-elaborated" { - t.Errorf("Name: want Local-elaborated got %q", got.Name) - } -} - -// TestElaborateTask_LocalFails_FallsBackToClaude verifies the dispatcher -// falls back to the Claude path when the local LLM returns an error. -func TestElaborateTask_LocalFails_FallsBackToClaude(t *testing.T) { - srv, _ := testServer(t) - - // Local LLM server that always 500s. - failSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "boom", http.StatusInternalServerError) - })) - t.Cleanup(failSrv.Close) - srv.SetLLM(&llm.Client{Endpoint: failSrv.URL + "/v1", Model: "fake"}) - - // Configure a working fake Claude binary. - taskBody, _ := json.Marshal(elaboratedTask{ - Name: "Claude-fallback", - Description: "From claude after local failed", - Agent: elaboratedAgent{ - Type: "claude", - Model: "sonnet", - Instructions: "Run tests.", - MaxBudgetUSD: 0.25, - AllowedTools: []string{"Bash"}, - }, - Timeout: "10m", - Priority: "normal", - }) - wrapper, _ := json.Marshal(map[string]string{"result": string(taskBody)}) - srv.elaborateCmdPath = createFakeClaude(t, string(wrapper), 0) - - body := `{"prompt":"run tests"}` - req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - srv.Handler().ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String()) - } - var got elaboratedTask - if err := json.NewDecoder(w.Body).Decode(&got); err != nil { - t.Fatalf("decode response: %v", err) - } - if got.Name != "Claude-fallback" { - t.Errorf("Name: want Claude-fallback (fallback path) got %q", got.Name) - } -} - -// TestElaborateTask_NoLocalLLM_UsesClaude verifies that when SetLLM is not -// called, behavior is unchanged (Claude path still primary). -func TestElaborateTask_NoLocalLLM_UsesClaude(t *testing.T) { - srv, _ := testServer(t) - - taskBody, _ := json.Marshal(elaboratedTask{ - Name: "Claude-only", - Description: "no local llm configured", - Agent: elaboratedAgent{ - Type: "claude", - Model: "sonnet", - Instructions: "Do work.", - MaxBudgetUSD: 0.25, - AllowedTools: []string{"Bash"}, - }, - Timeout: "10m", - Priority: "normal", - }) - wrapper, _ := json.Marshal(map[string]string{"result": string(taskBody)}) - srv.elaborateCmdPath = createFakeClaude(t, string(wrapper), 0) - - body := `{"prompt":"do work"}` - req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - srv.Handler().ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String()) - } - var got elaboratedTask - if err := json.NewDecoder(w.Body).Decode(&got); err != nil { - t.Fatalf("decode response: %v", err) - } - if got.Name != "Claude-only" { - t.Errorf("Name: %q", got.Name) - } -} - diff --git a/internal/api/elaborate_test.go b/internal/api/elaborate_test.go deleted file mode 100644 index 32cec3c..0000000 --- a/internal/api/elaborate_test.go +++ /dev/null @@ -1,527 +0,0 @@ -package api - -import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -// createFakeClaude writes a shell script to a temp dir that prints output and exits with the -// given code. Returns the script path. Used to mock the claude binary in elaborate tests. -func createFakeClaude(t *testing.T, output string, exitCode int) string { - t.Helper() - dir := t.TempDir() - outputFile := filepath.Join(dir, "output.json") - if err := os.WriteFile(outputFile, []byte(output), 0600); err != nil { - t.Fatal(err) - } - script := filepath.Join(dir, "claude") - content := fmt.Sprintf("#!/bin/sh\ncat %q\nexit %d\n", outputFile, exitCode) - if err := os.WriteFile(script, []byte(content), 0755); err != nil { - t.Fatal(err) - } - return script -} - -// hasTool is a test helper that reports whether name is in the tools slice. -func hasTool(tools []string, name string) bool { - for _, t := range tools { - if t == name { - return true - } - } - return false -} - -// --- sanitizeElaboratedTask unit tests --- - -func TestSanitize_AddsWriteWhenInstructionsMentionFileCreation(t *testing.T) { - task := &elaboratedTask{ - Agent: elaboratedAgent{ - Instructions: "Create a new file called output.txt with the results.", - AllowedTools: []string{"Bash"}, - }, - } - sanitizeElaboratedTask(task) - if !hasTool(task.Agent.AllowedTools, "Write") { - t.Errorf("expected Write in allowed_tools, got %v", task.Agent.AllowedTools) - } -} - -func TestSanitize_AddsReadWhenEditIsPresent(t *testing.T) { - task := &elaboratedTask{ - Agent: elaboratedAgent{ - Instructions: "Modify the configuration file.", - AllowedTools: []string{"Edit"}, - }, - } - sanitizeElaboratedTask(task) - if !hasTool(task.Agent.AllowedTools, "Read") { - t.Errorf("expected Read added alongside Edit, got %v", task.Agent.AllowedTools) - } -} - -func TestSanitize_NoDuplicateTools(t *testing.T) { - task := &elaboratedTask{ - Agent: elaboratedAgent{ - Instructions: "Run go test ./...", - AllowedTools: []string{"Bash"}, - }, - } - sanitizeElaboratedTask(task) - count := 0 - for _, tool := range task.Agent.AllowedTools { - if tool == "Bash" { - count++ - } - } - if count != 1 { - t.Errorf("Bash duplicated in allowed_tools: %v", task.Agent.AllowedTools) - } -} - -func TestSanitize_AddsAcceptanceCriteriaWhenMissing(t *testing.T) { - task := &elaboratedTask{ - Agent: elaboratedAgent{ - Instructions: "Do something useful with the codebase.", - AllowedTools: []string{"Bash"}, - }, - } - sanitizeElaboratedTask(task) - lower := strings.ToLower(task.Agent.Instructions) - if !strings.Contains(lower, "acceptance") && !strings.Contains(lower, "done when") { - t.Error("expected acceptance criteria section appended to instructions") - } -} - -func TestSanitize_NoopWhenAcceptanceCriteriaAlreadyPresent(t *testing.T) { - original := "Do something.\n\n## Acceptance Criteria\n- All tests pass." - task := &elaboratedTask{ - Agent: elaboratedAgent{ - Instructions: original, - AllowedTools: []string{"Bash"}, - }, - } - sanitizeElaboratedTask(task) - if task.Agent.Instructions != original { - t.Errorf("instructions were modified when acceptance criteria were already present") - } -} - -func TestSanitize_AddsTDDReminderForCodingTaskWithoutTestMention(t *testing.T) { - task := &elaboratedTask{ - Agent: elaboratedAgent{ - Instructions: "## Acceptance Criteria\nFix the bug.\n\nModify the handler to return 404 instead of 500.", - AllowedTools: []string{"Edit", "Read"}, - }, - } - sanitizeElaboratedTask(task) - lower := strings.ToLower(task.Agent.Instructions) - if !strings.Contains(lower, "tdd") && !strings.Contains(lower, "test") { - t.Error("expected TDD reminder for coding task without test mention") - } -} - -func TestSanitize_NoTDDReminderWhenTestsAlreadyMentioned(t *testing.T) { - original := "## Acceptance Criteria\nAll tests pass.\n\nEdit the file and run go test ./... to verify." - task := &elaboratedTask{ - Agent: elaboratedAgent{ - Instructions: original, - AllowedTools: []string{"Edit", "Read", "Bash"}, - }, - } - before := task.Agent.Instructions - sanitizeElaboratedTask(task) - // Should NOT add a second TDD block since tests are already mentioned. - // Count occurrences of "tdd" / "test" — just verify no double-append. - if strings.Count(strings.ToLower(task.Agent.Instructions), "tdd") > 1 { - t.Errorf("TDD block added twice; instructions:\n%s", task.Agent.Instructions) - } - _ = before -} - -func TestElaboratePrompt_RequiresAcceptanceCriteria(t *testing.T) { - prompt := buildElaboratePrompt("") - lower := strings.ToLower(prompt) - if !strings.Contains(lower, "acceptance criteria") { - t.Error("elaborate prompt should instruct the model to include acceptance criteria") - } -} - -func TestElaboratePrompt_RequiresAllRelevantTools(t *testing.T) { - prompt := buildElaboratePrompt("") - // Prompt must remind the model to include file-creating tools when needed. - if !strings.Contains(prompt, "Write") { - t.Error("elaborate prompt should mention the Write tool so models know to include it") - } -} - -func TestElaborateTask_SanitizationAppliedToResponse(t *testing.T) { - srv, _ := testServer(t) - - // Elaborator returns a task that needs Write (instructions say "create file") - // but does NOT include it in allowed_tools. - task := elaboratedTask{ - Name: "Generate report", - Description: "Creates a report file.", - Agent: elaboratedAgent{ - Type: "claude", - Model: "sonnet", - Instructions: "Create a new file called report.md with the analysis results.\n\n## Acceptance Criteria\n- report.md exists.", - MaxBudgetUSD: 0.5, - AllowedTools: []string{"Bash"}, // Write intentionally missing - }, - Timeout: "15m", - Priority: "normal", - Tags: []string{"report"}, - } - taskJSON, _ := json.Marshal(task) - wrapper := map[string]string{"result": string(taskJSON)} - wrapperJSON, _ := json.Marshal(wrapper) - - srv.elaborateCmdPath = createFakeClaude(t, string(wrapperJSON), 0) - - body := `{"prompt":"generate a report"}` - req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - srv.Handler().ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String()) - } - - var result elaboratedTask - if err := json.NewDecoder(w.Body).Decode(&result); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - if !hasTool(result.Agent.AllowedTools, "Write") { - t.Errorf("expected Write in sanitized allowed_tools, got %v", result.Agent.AllowedTools) - } -} - -func TestElaboratePrompt_ContainsWorkDir(t *testing.T) { - prompt := buildElaboratePrompt("/some/custom/path") - if !strings.Contains(prompt, "/some/custom/path") { - t.Error("prompt should contain the provided workDir") - } - if strings.Contains(prompt, "/root/workspace/claudomator") { - t.Error("prompt should not hardcode /root/workspace/claudomator") - } -} - -func TestElaboratePrompt_EmptyWorkDir(t *testing.T) { - prompt := buildElaboratePrompt("") - if strings.Contains(prompt, "/root") { - t.Error("prompt should not reference /root when workDir is empty") - } -} - -func TestElaborateTask_Success(t *testing.T) { - srv, _ := testServer(t) - - // Build fake Claude output: {"result": "<task-json>"} - task := elaboratedTask{ - Name: "Run Go tests with race detector", - Description: "Runs the Go test suite with -race flag and checks coverage.", - Agent: elaboratedAgent{ - Type: "claude", - Model: "sonnet", - Instructions: "Run go test -race ./... and report results.", - ProjectDir: "", - MaxBudgetUSD: 0.5, - AllowedTools: []string{"Bash"}, - }, - Timeout: "15m", - Priority: "normal", - Tags: []string{"testing", "ci"}, - } - taskJSON, err := json.Marshal(task) - if err != nil { - t.Fatal(err) - } - wrapper := map[string]string{"result": string(taskJSON)} - wrapperJSON, err := json.Marshal(wrapper) - if err != nil { - t.Fatal(err) - } - - srv.elaborateCmdPath = createFakeClaude(t, string(wrapperJSON), 0) - - body := `{"prompt":"run the Go test suite with race detector and fail if coverage < 80%"}` - req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - srv.Handler().ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String()) - } - - var result elaboratedTask - if err := json.NewDecoder(w.Body).Decode(&result); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - if result.Name == "" { - t.Error("expected non-empty name") - } - if result.Agent.Instructions == "" { - t.Error("expected non-empty instructions") - } -} - -func TestElaborateTask_EmptyPrompt(t *testing.T) { - srv, _ := testServer(t) - - body := `{"prompt":""}` - req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - srv.Handler().ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("status: want 400, got %d; body: %s", w.Code, w.Body.String()) - } - - var resp map[string]string - json.NewDecoder(w.Body).Decode(&resp) - if resp["error"] == "" { - t.Error("expected error message in response") - } -} - -func TestElaborateTask_MarkdownFencedJSON(t *testing.T) { - srv, _ := testServer(t) - - // Build a valid task JSON but wrap it in markdown fences as haiku sometimes does. - task := elaboratedTask{ - Name: "Test task", - Description: "Does something.", - Agent: elaboratedAgent{ - Type: "claude", - Model: "sonnet", - Instructions: "Do the thing.", - MaxBudgetUSD: 0.5, - AllowedTools: []string{"Bash"}, - }, - Timeout: "15m", - Priority: "normal", - Tags: []string{"test"}, - } - taskJSON, _ := json.Marshal(task) - fenced := "```json\n" + string(taskJSON) + "\n```" - wrapper := map[string]string{"result": fenced} - wrapperJSON, _ := json.Marshal(wrapper) - - srv.elaborateCmdPath = createFakeClaude(t, string(wrapperJSON), 0) - - body := `{"prompt":"do something"}` - req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - srv.Handler().ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String()) - } - - var result elaboratedTask - if err := json.NewDecoder(w.Body).Decode(&result); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - if result.Name != task.Name { - t.Errorf("name: want %q, got %q", task.Name, result.Name) - } -} - -func TestElaborateTask_InvalidJSONFromClaude(t *testing.T) { - srv, _ := testServer(t) - - // Fake Claude returns something that is not valid JSON. - srv.elaborateCmdPath = createFakeClaude(t, "not valid json at all", 0) - // Ensure Gemini fallback also fails so we get the expected 502. - srv.geminiBinPath = "/nonexistent/gemini" - - body := `{"prompt":"do something"}` - req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - srv.Handler().ServeHTTP(w, req) - - if w.Code != http.StatusBadGateway { - t.Fatalf("status: want 502, got %d; body: %s", w.Code, w.Body.String()) - } - - var resp map[string]string - json.NewDecoder(w.Body).Decode(&resp) - if resp["error"] == "" { - t.Error("expected error message in response") - } -} - -func createFakeClaudeCapturingArgs(t *testing.T, output string, exitCode int, argsFile string) string { - t.Helper() - dir := t.TempDir() - outputFile := filepath.Join(dir, "output.json") - if err := os.WriteFile(outputFile, []byte(output), 0600); err != nil { - t.Fatal(err) - } - script := filepath.Join(dir, "claude") - // Use printf to handle arguments safely - content := fmt.Sprintf("#!/bin/sh\nprintf \"%%s\\n\" \"$@\" > %q\ncat %q\nexit %d\n", argsFile, outputFile, exitCode) - if err := os.WriteFile(script, []byte(content), 0755); err != nil { - t.Fatal(err) - } - return script -} - -func TestElaborateTask_WithProjectContext(t *testing.T) { - srv, _ := testServer(t) - - // Create a temporary workspace with CLAUDE.md and .agent/worklog.md - workDir := t.TempDir() - claudeContent := "Claude context info" - sessionContent := "Session state info" - if err := os.WriteFile(filepath.Join(workDir, "CLAUDE.md"), []byte(claudeContent), 0600); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(workDir, ".agent"), 0700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(workDir, ".agent", "worklog.md"), []byte(sessionContent), 0600); err != nil { - t.Fatal(err) - } - - // Capture arguments passed to claude - argsFile := filepath.Join(t.TempDir(), "args.txt") - - task := elaboratedTask{ - Name: "Task with context", - Agent: elaboratedAgent{ - Instructions: "Instructions", - }, - } - taskJSON, _ := json.Marshal(task) - wrapper := map[string]string{"result": string(taskJSON)} - wrapperJSON, _ := json.Marshal(wrapper) - - // Modified createFakeClaude to capture arguments - srv.elaborateCmdPath = createFakeClaudeCapturingArgs(t, string(wrapperJSON), 0, argsFile) - - body := fmt.Sprintf(`{"prompt":"do something", "project_dir":"%s"}`, workDir) - req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - srv.Handler().ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String()) - } - - // Check if captured arguments contain the context - capturedArgs, err := os.ReadFile(argsFile) - if err != nil { - t.Fatal(err) - } - argsStr := string(capturedArgs) - if !strings.Contains(argsStr, claudeContent) { - t.Errorf("expected arguments to contain CLAUDE.md content, got %s", argsStr) - } - if !strings.Contains(argsStr, sessionContent) { - t.Errorf("expected arguments to contain .agent/worklog.md content, got %s", argsStr) - } -} - -func TestElaborateTask_NoRawNarrativeWithoutExplicitProjectDir(t *testing.T) { - srv, _ := testServer(t) - // Point workDir at a temp dir so any accidental write is detectable. - srv.workDir = t.TempDir() - - task := elaboratedTask{ - Name: "Task", - Agent: elaboratedAgent{Instructions: "Instructions"}, - } - taskJSON, _ := json.Marshal(task) - wrapper := map[string]string{"result": string(taskJSON)} - wrapperJSON, _ := json.Marshal(wrapper) - srv.elaborateCmdPath = createFakeClaude(t, string(wrapperJSON), 0) - - // No project_dir in request body. - body := `{"prompt":"do something"}` - req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - srv.Handler().ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status: want 200, got %d", w.Code) - } - - time.Sleep(30 * time.Millisecond) // let goroutine run if it was incorrectly triggered - narrativePath := filepath.Join(srv.workDir, "docs", "RAW_NARRATIVE.md") - if _, err := os.Stat(narrativePath); err == nil { - t.Errorf("RAW_NARRATIVE.md should NOT be written when project_dir is not provided by the user") - } -} - -func TestElaborateTask_AppendsRawNarrative(t *testing.T) { - srv, _ := testServer(t) - - workDir := t.TempDir() - prompt := "this is my raw request" - - task := elaboratedTask{ - Name: "Task", - Agent: elaboratedAgent{ - Instructions: "Instructions", - }, - } - taskJSON, _ := json.Marshal(task) - wrapper := map[string]string{"result": string(taskJSON)} - wrapperJSON, _ := json.Marshal(wrapper) - - srv.elaborateCmdPath = createFakeClaude(t, string(wrapperJSON), 0) - - body := fmt.Sprintf(`{"prompt":"%s", "project_dir":"%s"}`, prompt, workDir) - req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - srv.Handler().ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String()) - } - - // It runs in a goroutine, so wait a bit - path := filepath.Join(workDir, "docs", "RAW_NARRATIVE.md") - var data []byte - var err error - for i := 0; i < 10; i++ { - data, err = os.ReadFile(path) - if err == nil { - break - } - time.Sleep(10 * time.Millisecond) - } - - if err != nil { - t.Fatalf("failed to read RAW_NARRATIVE.md: %v", err) - } - if !strings.Contains(string(data), prompt) { - t.Errorf("expected RAW_NARRATIVE.md to contain prompt, got %s", string(data)) - } -} diff --git a/internal/api/events.go b/internal/api/events.go new file mode 100644 index 0000000..eefe064 --- /dev/null +++ b/internal/api/events.go @@ -0,0 +1,38 @@ +package api + +import ( + "net/http" + "strconv" + + "github.com/thepeterstone/claudomator/internal/event" +) + +// handleListTaskEvents returns a task's observability event stream in seq order. +// Pass ?since_seq=N to fetch only events newer than seq N (incremental polling). +func (s *Server) handleListTaskEvents(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if _, err := s.store.GetTask(id); err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"}) + return + } + + var sinceSeq int64 + if v := r.URL.Query().Get("since_seq"); v != "" { + n, err := strconv.ParseInt(v, 10, 64) + if err != nil || n < 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid since_seq"}) + return + } + sinceSeq = n + } + + events, err := s.store.ListEvents(id, sinceSeq) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if events == nil { + events = []*event.Event{} + } + writeJSON(w, http.StatusOK, events) +} diff --git a/internal/api/events_test.go b/internal/api/events_test.go new file mode 100644 index 0000000..456b6d8 --- /dev/null +++ b/internal/api/events_test.go @@ -0,0 +1,94 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/thepeterstone/claudomator/internal/event" + "github.com/thepeterstone/claudomator/internal/task" +) + +func TestListTaskEvents_ReturnsStreamInSeqOrder(t *testing.T) { + srv, store := testServer(t) + createTaskWithState(t, store, "ev-task", task.StateReady) + + req := httptest.NewRequest("GET", "/api/tasks/ev-task/events", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d; body %s", w.Code, w.Body.String()) + } + var events []*event.Event + if err := json.Unmarshal(w.Body.Bytes(), &events); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(events) == 0 { + t.Fatal("expected state-change events from the walk to READY") + } + for i := 1; i < len(events); i++ { + if events[i].Seq <= events[i-1].Seq { + t.Errorf("events not in ascending seq order: %d then %d", events[i-1].Seq, events[i].Seq) + } + } +} + +func TestListTaskEvents_SinceSeqFilters(t *testing.T) { + srv, store := testServer(t) + createTaskWithState(t, store, "ev-since", task.StateReady) + + all := getEvents(t, srv, "ev-since", "") + if len(all) < 2 { + t.Skipf("need at least 2 events to test since_seq, got %d", len(all)) + } + firstSeq := all[0].Seq + rest := getEvents(t, srv, "ev-since", "?since_seq="+itoa(firstSeq)) + for _, e := range rest { + if e.Seq <= firstSeq { + t.Errorf("since_seq=%d returned event with seq %d", firstSeq, e.Seq) + } + } +} + +func TestListTaskEvents_NotFound(t *testing.T) { + srv, _ := testServer(t) + req := httptest.NewRequest("GET", "/api/tasks/missing/events", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("want 404, got %d", w.Code) + } +} + +func TestListTaskEvents_InvalidSinceSeq(t *testing.T) { + srv, store := testServer(t) + createTaskWithState(t, store, "ev-bad", task.StateReady) + req := httptest.NewRequest("GET", "/api/tasks/ev-bad/events?since_seq=abc", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("want 400, got %d", w.Code) + } +} + +func getEvents(t *testing.T, srv *Server, taskID, query string) []*event.Event { + t.Helper() + req := httptest.NewRequest("GET", "/api/tasks/"+taskID+"/events"+query, nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("getEvents %s%s: status %d", taskID, query, w.Code) + } + var events []*event.Event + if err := json.Unmarshal(w.Body.Bytes(), &events); err != nil { + t.Fatalf("getEvents unmarshal: %v", err) + } + return events +} + +func itoa(n int64) string { + return strconv.FormatInt(n, 10) +} diff --git a/internal/api/server.go b/internal/api/server.go index afb80ad..4af7325 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -13,6 +13,7 @@ import ( "time" "github.com/thepeterstone/claudomator/internal/config" + "github.com/thepeterstone/claudomator/internal/event" "github.com/thepeterstone/claudomator/internal/executor" "github.com/thepeterstone/claudomator/internal/llm" "github.com/thepeterstone/claudomator/internal/notify" @@ -40,6 +41,7 @@ type Server struct { taskLogStore taskLogStore // injectable for tests; defaults to store questionStore questionStore // injectable for tests; defaults to store pool *executor.Pool + registry *executor.Registry // per-task agent MCP token registry; mounts /mcp when set hub *Hub logger *slog.Logger mux *http.ServeMux @@ -62,7 +64,8 @@ type Server struct { pushStore pushSubscriptionStore dropsDir string llm *llm.Client - basePath string // URL prefix the UI is mounted at, e.g. "/claudomator" + budget budgetReporter // optional; per-provider spend headroom for GET /api/budget + basePath string // URL prefix the UI is mounted at, e.g. "/claudomator" } // SetAPIToken configures a bearer token that must be supplied to access the API. @@ -98,6 +101,7 @@ func (s *Server) SetBasePath(p string) { s.basePath = p } + // Pool returns the executor pool, for graceful shutdown by the caller. func (s *Server) Pool() *executor.Pool { return s.pool } @@ -109,7 +113,7 @@ func (s *Server) SetLLM(c *llm.Client) { } -func NewServer(store *storage.DB, pool *executor.Pool, logger *slog.Logger, claudeBinPath, geminiBinPath string) *Server { +func NewServer(store *storage.DB, pool *executor.Pool, registry *executor.Registry, logger *slog.Logger, claudeBinPath, geminiBinPath string) *Server { wd, _ := os.Getwd() s := &Server{ ctx: context.Background(), @@ -118,6 +122,7 @@ func NewServer(store *storage.DB, pool *executor.Pool, logger *slog.Logger, clau taskLogStore: store, questionStore: store, pool: pool, + registry: registry, hub: NewHub(), logger: logger, mux: http.NewServeMux(), @@ -140,7 +145,6 @@ func (s *Server) StartHub() { } func (s *Server) routes() { - s.mux.HandleFunc("POST /api/tasks/elaborate", s.handleElaborateTask) s.mux.HandleFunc("POST /api/tasks/validate", s.handleValidateTask) s.mux.HandleFunc("POST /api/tasks", s.handleCreateTask) s.mux.HandleFunc("GET /api/tasks", s.handleListTasks) @@ -152,8 +156,10 @@ func (s *Server) routes() { s.mux.HandleFunc("DELETE /api/tasks/{id}", s.handleDeleteTask) s.mux.HandleFunc("GET /api/tasks/{id}/subtasks", s.handleListSubtasks) s.mux.HandleFunc("GET /api/tasks/{id}/executions", s.handleListExecutions) + s.mux.HandleFunc("GET /api/tasks/{id}/events", s.handleListTaskEvents) s.mux.HandleFunc("GET /api/executions", s.handleListRecentExecutions) s.mux.HandleFunc("GET /api/stats", s.handleGetDashboardStats) + s.mux.HandleFunc("GET /api/budget", s.handleGetBudget) s.mux.HandleFunc("GET /api/agents/status", s.handleGetAgentStatus) s.mux.HandleFunc("GET /api/executions/{id}", s.handleGetExecution) s.mux.HandleFunc("GET /api/executions/{id}/log", s.handleGetExecutionLog) @@ -189,6 +195,16 @@ func (s *Server) routes() { s.mux.HandleFunc("GET /api/drops", s.handleListDrops) s.mux.HandleFunc("GET /api/drops/{filename}", s.handleGetDrop) s.mux.HandleFunc("POST /api/drops", s.handlePostDrop) + if s.registry != nil { + mcpHandler := executor.NewAgentMCPHandler(s.registry) + s.mux.Handle("POST /mcp", mcpHandler) + s.mux.Handle("GET /mcp", mcpHandler) + s.mux.Handle("DELETE /mcp", mcpHandler) + } + chatbotHandler := s.chatbotMCPHandler() + s.mux.Handle("POST /chatbot/mcp", chatbotHandler) + s.mux.Handle("GET /chatbot/mcp", chatbotHandler) + s.mux.Handle("DELETE /chatbot/mcp", chatbotHandler) s.mux.HandleFunc("GET /", s.handleStaticFiles) } @@ -280,41 +296,16 @@ func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCancelTask(w http.ResponseWriter, r *http.Request) { taskID := r.PathValue("id") - tk, err := s.store.GetTask(taskID) + msg, err := s.cancelTask(taskID, event.ActorUser) if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"}) - return - } - // If the task is actively running in the pool, cancel it there. - if s.pool.Cancel(taskID) { - writeJSON(w, http.StatusOK, map[string]string{"message": "task cancellation requested", "task_id": taskID}) - return - } - // For non-running tasks (PENDING, QUEUED), transition directly to CANCELLED. - if !task.ValidTransition(tk.State, task.StateCancelled) { - writeJSON(w, http.StatusConflict, map[string]string{"error": "task cannot be cancelled from state " + string(tk.State)}) - return - } - if err := s.store.UpdateTaskState(taskID, task.StateCancelled); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to cancel task"}) + writeOpError(w, err) return } - writeJSON(w, http.StatusOK, map[string]string{"message": "task cancelled", "task_id": taskID}) + writeJSON(w, http.StatusOK, map[string]string{"message": msg, "task_id": taskID}) } func (s *Server) handleAnswerQuestion(w http.ResponseWriter, r *http.Request) { taskID := r.PathValue("id") - - tk, err := s.questionStore.GetTask(taskID) - if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"}) - return - } - if tk.State != task.StateBlocked { - writeJSON(w, http.StatusConflict, map[string]string{"error": "task is not blocked"}) - return - } - var input struct { Answer string `json:"answer"` } @@ -322,61 +313,10 @@ func (s *Server) handleAnswerQuestion(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) return } - if input.Answer == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "answer is required"}) - return - } - - // Look up the session ID from the most recent execution. - latest, err := s.questionStore.GetLatestExecution(taskID) - if err != nil || latest.SessionID == "" { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "no resumable session found"}) - return - } - - // Record the Q&A interaction before clearing the question. - if tk.QuestionJSON != "" { - var qData struct { - Text string `json:"text"` - Options []string `json:"options"` - } - if jsonErr := json.Unmarshal([]byte(tk.QuestionJSON), &qData); jsonErr == nil { - interaction := task.Interaction{ - QuestionText: qData.Text, - Options: qData.Options, - Answer: input.Answer, - AskedAt: tk.UpdatedAt, - } - if appendErr := s.questionStore.AppendTaskInteraction(taskID, interaction); appendErr != nil { - s.logger.Error("failed to append interaction", "taskID", taskID, "error", appendErr) - } - } - } - - // Clear the question and transition to QUEUED. - if err := s.questionStore.UpdateTaskQuestion(taskID, ""); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to clear question"}) + if err := s.answerTaskQuestion(s.ctx, taskID, input.Answer); err != nil { + writeOpError(w, err) return } - if err := s.questionStore.UpdateTaskState(taskID, task.StateQueued); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to queue task"}) - return - } - - // Submit a resume execution. Carry the sandbox path so the runner uses - // the same working directory where Claude stored its session files. - resumeExec := &storage.Execution{ - ID: uuid.New().String(), - TaskID: taskID, - ResumeSessionID: latest.SessionID, - ResumeAnswer: input.Answer, - SandboxDir: latest.SandboxDir, - } - if err := s.pool.SubmitResume(s.ctx, tk, resumeExec); err != nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]string{"message": "task queued for resume", "task_id": taskID}) } @@ -702,40 +642,15 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAcceptTask(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - t, err := s.store.GetTask(id) - if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"}) - return - } - if !task.ValidTransition(t.State, task.StateCompleted) { - writeJSON(w, http.StatusConflict, map[string]string{ - "error": fmt.Sprintf("task cannot be accepted from state %s", t.State), - }) - return - } - if err := s.store.UpdateTaskState(id, task.StateCompleted); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + if err := s.acceptTask(r.Context(), id, event.ActorUser); err != nil { + writeOpError(w, err) return } - if t.StoryID != "" { - go s.pool.CheckStoryCompletion(r.Context(), t.StoryID) - } writeJSON(w, http.StatusOK, map[string]string{"message": "task accepted", "task_id": id}) } func (s *Server) handleRejectTask(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - t, err := s.store.GetTask(id) - if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"}) - return - } - if !task.ValidTransition(t.State, task.StatePending) { - writeJSON(w, http.StatusConflict, map[string]string{ - "error": fmt.Sprintf("task cannot be rejected from state %s", t.State), - }) - return - } var input struct { Comment string `json:"comment"` } @@ -743,8 +658,8 @@ func (s *Server) handleRejectTask(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) return } - if err := s.store.RejectTask(id, input.Comment); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + if err := s.rejectTask(id, input.Comment); err != nil { + writeOpError(w, err) return } writeJSON(w, http.StatusOK, map[string]string{"message": "task rejected", "task_id": id}) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 2530d55..43d91b0 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -98,7 +98,7 @@ func testServerWithRunner(t *testing.T, runner executor.Runner) (*Server, *stora "gemini": runner, } pool := executor.NewPool(2, runners, store, logger) - srv := NewServer(store, pool, logger, "claude", "gemini") + srv := NewServer(store, pool, nil, logger, "claude", "gemini") return srv, store } @@ -116,7 +116,7 @@ func (m *mockRunner) ExecLogDir(execID string) string { return filepath.Join(m.logDir, execID) } -func (m *mockRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution) error { +func (m *mockRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution, _ executor.AgentChannel) error { if e.ID == "" { e.ID = uuid.New().String() } @@ -194,7 +194,7 @@ func testServerWithGeminiMockRunner(t *testing.T) (*Server, *storage.DB) { "gemini": mr, } pool := executor.NewPool(2, runners, store, logger) - srv := NewServer(store, pool, logger, "claude", "gemini") + srv := NewServer(store, pool, nil, logger, "claude", "gemini") return srv, store } @@ -1228,34 +1228,6 @@ func TestServer_AnswerQuestion_UpdateStateFails_Returns500(t *testing.T) { } } -func TestRateLimit_ElaborateRejectsExcess(t *testing.T) { - srv, _ := testServer(t) - // Use burst-1 and rate-0 so the second request from the same IP is rejected. - srv.elaborateLimiter = newIPRateLimiter(0, 1) - - makeReq := func(remoteAddr string) int { - req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(`{"description":"x"}`)) - req.Header.Set("Content-Type", "application/json") - req.RemoteAddr = remoteAddr - w := httptest.NewRecorder() - srv.Handler().ServeHTTP(w, req) - return w.Code - } - - // First request from IP A: limiter allows it (non-429). - if code := makeReq("192.0.2.1:1234"); code == http.StatusTooManyRequests { - t.Errorf("first request should not be rate limited, got 429") - } - // Second request from IP A: bucket exhausted, must be 429. - if code := makeReq("192.0.2.1:1234"); code != http.StatusTooManyRequests { - t.Errorf("second request from same IP should be 429, got %d", code) - } - // First request from IP B: separate bucket, not limited. - if code := makeReq("192.0.2.2:1234"); code == http.StatusTooManyRequests { - t.Errorf("first request from different IP should not be rate limited, got 429") - } -} - func TestListWorkspaces_RequiresAuth(t *testing.T) { srv, _ := testServer(t) srv.SetAPIToken("secret-token") diff --git a/internal/api/taskops.go b/internal/api/taskops.go new file mode 100644 index 0000000..881babe --- /dev/null +++ b/internal/api/taskops.go @@ -0,0 +1,253 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/google/uuid" + "github.com/thepeterstone/claudomator/internal/event" + "github.com/thepeterstone/claudomator/internal/storage" + "github.com/thepeterstone/claudomator/internal/task" +) + +// Shared task-operation service layer. Both the REST handlers and the +// chatbot-facing MCP tools call these methods so the two surfaces cannot drift. +// Errors are typed so callers can map them to the right transport status. + +// errTaskNotFound is returned when a task ID does not resolve. REST maps it to +// 404; MCP surfaces it as a tool error. +var errTaskNotFound = errors.New("task not found") + +// conflictError signals an operation that is invalid for the task's current +// state (REST 409). +type conflictError struct{ msg string } + +func (e *conflictError) Error() string { return e.msg } + +func conflictf(format string, a ...any) error { return &conflictError{msg: fmt.Sprintf(format, a...)} } + +// badRequestError signals malformed or missing input (REST 400). +type badRequestError struct{ msg string } + +func (e *badRequestError) Error() string { return e.msg } + +func badRequestf(format string, a ...any) error { return &badRequestError{msg: fmt.Sprintf(format, a...)} } + +// submitTaskSpec describes a task a chatbot wants created and immediately run. +type submitTaskSpec struct { + Name string + Instructions string + Project string + RepositoryURL string + Model string + MaxBudgetUSD float64 + Timeout time.Duration + Tags []string +} + +// submitTask creates a task and queues it for execution in one step. The +// repository URL is resolved from the named project when not given explicitly, +// because task.Validate requires it before the executor's lazy resolution runs. +func (s *Server) submitTask(ctx context.Context, spec submitTaskSpec) (*task.Task, error) { + if strings.TrimSpace(spec.Instructions) == "" { + return nil, badRequestf("instructions are required") + } + + repoURL := spec.RepositoryURL + if repoURL == "" && spec.Project != "" { + if proj, err := s.store.GetProject(spec.Project); err == nil { + repoURL = proj.RemoteURL + } + } + if repoURL == "" { + return nil, badRequestf("repository_url is required (pass it directly or a project that resolves to one)") + } + + name := strings.TrimSpace(spec.Name) + if name == "" { + name = firstLine(spec.Instructions, 72) + } + + now := time.Now().UTC() + t := &task.Task{ + ID: uuid.New().String(), + Name: name, + Project: spec.Project, + RepositoryURL: repoURL, + Agent: task.AgentConfig{ + Type: "claude", + Model: spec.Model, + Instructions: spec.Instructions, + MaxBudgetUSD: spec.MaxBudgetUSD, + }, + Priority: task.PriorityNormal, + Tags: spec.Tags, + DependsOn: []string{}, + Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "exponential"}, + State: task.StatePending, + CreatedAt: now, + UpdatedAt: now, + } + if spec.Timeout > 0 { + t.Timeout.Duration = spec.Timeout + } + if t.Tags == nil { + t.Tags = []string{} + } + + if err := task.Validate(t); err != nil { + return nil, badRequestf("%s", err.Error()) + } + if err := s.store.CreateTask(t); err != nil { + return nil, err + } + if err := s.store.UpdateTaskStateBy(t.ID, task.StateQueued, event.ActorChatbot); err != nil { + return nil, err + } + t.State = task.StateQueued + if err := s.pool.Submit(ctx, t); err != nil { + return nil, err + } + return t, nil +} + +// acceptTask transitions a READY task to COMPLETED. +func (s *Server) acceptTask(ctx context.Context, id string, actor event.Actor) error { + t, err := s.store.GetTask(id) + if err != nil { + return errTaskNotFound + } + if !task.ValidTransition(t.State, task.StateCompleted) { + return conflictf("task cannot be accepted from state %s", t.State) + } + if err := s.store.UpdateTaskStateBy(id, task.StateCompleted, actor); err != nil { + return err + } + if t.StoryID != "" { + go s.pool.CheckStoryCompletion(ctx, t.StoryID) + } + return nil +} + +// rejectTask transitions a READY task back to PENDING with an optional comment. +func (s *Server) rejectTask(id, comment string) error { + t, err := s.store.GetTask(id) + if err != nil { + return errTaskNotFound + } + if !task.ValidTransition(t.State, task.StatePending) { + return conflictf("task cannot be rejected from state %s", t.State) + } + return s.store.RejectTask(id, comment) +} + +// cancelTask cancels a running/queued task, returning a human-readable result. +func (s *Server) cancelTask(id string, actor event.Actor) (string, error) { + tk, err := s.store.GetTask(id) + if err != nil { + return "", errTaskNotFound + } + if s.pool.Cancel(id) { + return "task cancellation requested", nil + } + if !task.ValidTransition(tk.State, task.StateCancelled) { + return "", conflictf("task cannot be cancelled from state %s", tk.State) + } + if err := s.store.UpdateTaskStateBy(id, task.StateCancelled, actor); err != nil { + return "", err + } + return "task cancelled", nil +} + +// answerTaskQuestion answers a BLOCKED task's clarification and resumes it. +func (s *Server) answerTaskQuestion(ctx context.Context, id, answer string) error { + tk, err := s.questionStore.GetTask(id) + if err != nil { + return errTaskNotFound + } + if tk.State != task.StateBlocked { + return conflictf("task is not blocked") + } + if strings.TrimSpace(answer) == "" { + return badRequestf("answer is required") + } + + latest, err := s.questionStore.GetLatestExecution(id) + if err != nil || latest.SessionID == "" { + return fmt.Errorf("no resumable session found") + } + + if tk.QuestionJSON != "" { + var qData struct { + Text string `json:"text"` + Options []string `json:"options"` + } + if json.Unmarshal([]byte(tk.QuestionJSON), &qData) == nil { + if appendErr := s.questionStore.AppendTaskInteraction(id, task.Interaction{ + QuestionText: qData.Text, + Options: qData.Options, + Answer: answer, + AskedAt: tk.UpdatedAt, + }); appendErr != nil { + s.logger.Error("failed to append interaction", "taskID", id, "error", appendErr) + } + } + } + + if err := s.questionStore.UpdateTaskQuestion(id, ""); err != nil { + return err + } + if err := s.questionStore.UpdateTaskState(id, task.StateQueued); err != nil { + return err + } + + resumeExec := &storage.Execution{ + ID: uuid.New().String(), + TaskID: id, + ResumeSessionID: latest.SessionID, + ResumeAnswer: answer, + SandboxDir: latest.SandboxDir, + } + return s.pool.SubmitResume(ctx, tk, resumeExec) +} + +// writeOpError maps a service-layer error to the appropriate REST status. +func writeOpError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, errTaskNotFound): + writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"}) + return + } + var ce *conflictError + if errors.As(err, &ce) { + writeJSON(w, http.StatusConflict, map[string]string{"error": ce.Error()}) + return + } + var be *badRequestError + if errors.As(err, &be) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": be.Error()}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) +} + +// firstLine returns the first non-empty line of s, truncated to max runes. +func firstLine(s string, max int) string { + line := s + if i := strings.IndexByte(s, '\n'); i >= 0 { + line = s[:i] + } + line = strings.TrimSpace(line) + if r := []rune(line); len(r) > max { + return strings.TrimSpace(string(r[:max])) + } + if line == "" { + return "task" + } + return line +} diff --git a/internal/api/validate_test.go b/internal/api/validate_test.go index c3d7b1f..60fec14 100644 --- a/internal/api/validate_test.go +++ b/internal/api/validate_test.go @@ -3,11 +3,32 @@ package api import ( "bytes" "encoding/json" + "fmt" "net/http" "net/http/httptest" + "os" + "path/filepath" "testing" ) +// createFakeClaude writes a stub `claude` script that prints the given output +// and exits with the given code, returning its path for use as a binary +// override in tests. +func createFakeClaude(t *testing.T, output string, exitCode int) string { + t.Helper() + dir := t.TempDir() + outputFile := filepath.Join(dir, "output.json") + if err := os.WriteFile(outputFile, []byte(output), 0600); err != nil { + t.Fatal(err) + } + script := filepath.Join(dir, "claude") + content := fmt.Sprintf("#!/bin/sh\ncat %q\nexit %d\n", outputFile, exitCode) + if err := os.WriteFile(script, []byte(content), 0755); err != nil { + t.Fatal(err) + } + return script +} + func TestValidateTask_Success(t *testing.T) { srv, _ := testServer(t) diff --git a/internal/budget/budget.go b/internal/budget/budget.go new file mode 100644 index 0000000..60ce9d2 --- /dev/null +++ b/internal/budget/budget.go @@ -0,0 +1,122 @@ +// Package budget tracks agent spend against rolling per-provider windows so the +// dispatcher can refuse (or reroute) paid work that would breach a cap. Local +// runners are free and simply carry no configured limit. +package budget + +import ( + "sort" + "time" +) + +// DefaultWindow is the rolling spend window when none is configured. +const DefaultWindow = 5 * time.Hour + +// Limits configures per-provider spend caps within a rolling window. +type Limits struct { + // Window is the rolling lookback; DefaultWindow when zero. + Window time.Duration + // PerProvider maps a provider (claude/gemini/…) to its USD cap within the + // window. A missing or non-positive entry means "unlimited" (e.g. local). + PerProvider map[string]float64 +} + +// SpendSource supplies total spend per provider since a given time. +type SpendSource interface { + SpendByProviderSince(since time.Time) (map[string]float64, error) +} + +// Headroom describes remaining budget for one provider in the current window. +type Headroom struct { + Provider string `json:"provider"` + Limited bool `json:"limited"` + Limit float64 `json:"limit_usd"` + Spent float64 `json:"spent_usd"` + Remaining float64 `json:"remaining_usd"` + // Fraction is the share of the cap still available, 0..1 (0 when unlimited). + Fraction float64 `json:"fraction_remaining"` +} + +// Accountant answers spend questions against a SpendSource and Limits. +type Accountant struct { + src SpendSource + lim Limits + now func() time.Time +} + +// New builds an Accountant. A zero Window defaults to DefaultWindow. +func New(src SpendSource, lim Limits) *Accountant { + if lim.Window <= 0 { + lim.Window = DefaultWindow + } + return &Accountant{src: src, lim: lim, now: time.Now} +} + +func (a *Accountant) since() time.Time { return a.now().Add(-a.lim.Window) } + +func (a *Accountant) headroomFrom(provider string, spends map[string]float64) Headroom { + limit := a.lim.PerProvider[provider] + if limit <= 0 { + return Headroom{Provider: provider, Limited: false} + } + spent := spends[provider] + remaining := limit - spent + if remaining < 0 { + remaining = 0 + } + return Headroom{ + Provider: provider, + Limited: true, + Limit: limit, + Spent: spent, + Remaining: remaining, + Fraction: remaining / limit, + } +} + +// Headroom returns the remaining budget for one provider in the current window. +func (a *Accountant) Headroom(provider string) (Headroom, error) { + if a.lim.PerProvider[provider] <= 0 { + return Headroom{Provider: provider, Limited: false}, nil + } + spends, err := a.src.SpendByProviderSince(a.since()) + if err != nil { + return Headroom{}, err + } + return a.headroomFrom(provider, spends), nil +} + +// Allow reports whether starting a task on provider that may cost up to estCost +// is permitted without breaching the window cap. Unlimited providers always +// pass. estCost is typically the task's max_budget_usd (0 = unknown). +func (a *Accountant) Allow(provider string, estCost float64) (bool, error) { + h, err := a.Headroom(provider) + if err != nil { + return false, err + } + if !h.Limited { + return true, nil + } + return h.Spent+estCost <= h.Limit, nil +} + +// All returns headroom for every configured provider, sorted by name, in a +// single spend query. +func (a *Accountant) All() ([]Headroom, error) { + if len(a.lim.PerProvider) == 0 { + return []Headroom{}, nil + } + spends, err := a.src.SpendByProviderSince(a.since()) + if err != nil { + return nil, err + } + providers := make([]string, 0, len(a.lim.PerProvider)) + for p := range a.lim.PerProvider { + providers = append(providers, p) + } + sort.Strings(providers) + out := make([]Headroom, 0, len(providers)) + for _, p := range providers { + out = append(out, a.headroomFrom(p, spends)) + } + return out, nil +} diff --git a/internal/budget/budget_test.go b/internal/budget/budget_test.go new file mode 100644 index 0000000..c257c91 --- /dev/null +++ b/internal/budget/budget_test.go @@ -0,0 +1,112 @@ +package budget + +import ( + "testing" + "time" +) + +type fakeSpend struct { + spends map[string]float64 + since time.Time +} + +func (f *fakeSpend) SpendByProviderSince(since time.Time) (map[string]float64, error) { + f.since = since + return f.spends, nil +} + +func newAcct(spends map[string]float64, lim Limits) (*Accountant, *fakeSpend) { + src := &fakeSpend{spends: spends} + a := New(src, lim) + a.now = func() time.Time { return time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC) } + return a, src +} + +func TestNew_DefaultsWindow(t *testing.T) { + a, _ := newAcct(nil, Limits{}) + if a.lim.Window != DefaultWindow { + t.Errorf("want default window %v, got %v", DefaultWindow, a.lim.Window) + } +} + +func TestHeadroom_UnlimitedWhenNoLimit(t *testing.T) { + a, _ := newAcct(map[string]float64{"local": 999}, Limits{PerProvider: map[string]float64{"claude": 10}}) + h, err := a.Headroom("local") + if err != nil { + t.Fatal(err) + } + if h.Limited { + t.Errorf("local should be unlimited, got %+v", h) + } +} + +func TestHeadroom_ComputesRemainingAndFraction(t *testing.T) { + a, src := newAcct(map[string]float64{"claude": 4}, Limits{Window: 5 * time.Hour, PerProvider: map[string]float64{"claude": 10}}) + h, err := a.Headroom("claude") + if err != nil { + t.Fatal(err) + } + if !h.Limited || h.Limit != 10 || h.Spent != 4 || h.Remaining != 6 { + t.Errorf("unexpected headroom: %+v", h) + } + if h.Fraction != 0.6 { + t.Errorf("fraction: want 0.6, got %v", h.Fraction) + } + // Window lookback is now-5h. + want := time.Date(2026, 5, 26, 7, 0, 0, 0, time.UTC) + if !src.since.Equal(want) { + t.Errorf("since: want %v, got %v", want, src.since) + } +} + +func TestHeadroom_ClampsNegativeRemaining(t *testing.T) { + a, _ := newAcct(map[string]float64{"claude": 15}, Limits{PerProvider: map[string]float64{"claude": 10}}) + h, _ := a.Headroom("claude") + if h.Remaining != 0 { + t.Errorf("remaining should clamp to 0, got %v", h.Remaining) + } +} + +func TestAllow(t *testing.T) { + a, _ := newAcct(map[string]float64{"claude": 8}, Limits{PerProvider: map[string]float64{"claude": 10}}) + + if ok, _ := a.Allow("claude", 1.5); !ok { + t.Error("8 + 1.5 <= 10 should be allowed") + } + if ok, _ := a.Allow("claude", 3); ok { + t.Error("8 + 3 > 10 should be denied") + } + if ok, _ := a.Allow("local", 1000); !ok { + t.Error("unlimited provider should always allow") + } +} + +func TestAll_SortedAndSingleQuery(t *testing.T) { + a, src := newAcct(map[string]float64{"claude": 2, "gemini": 1}, + Limits{PerProvider: map[string]float64{"gemini": 5, "claude": 10}}) + calls := 0 + orig := src.spends + a2 := New(&countingSpend{spends: orig, calls: &calls}, a.lim) + a2.now = a.now + + hs, err := a2.All() + if err != nil { + t.Fatal(err) + } + if len(hs) != 2 || hs[0].Provider != "claude" || hs[1].Provider != "gemini" { + t.Errorf("want [claude gemini] sorted, got %+v", hs) + } + if calls != 1 { + t.Errorf("All should issue exactly one spend query, got %d", calls) + } +} + +type countingSpend struct { + spends map[string]float64 + calls *int +} + +func (c *countingSpend) SpendByProviderSince(time.Time) (map[string]float64, error) { + *c.calls++ + return c.spends, nil +} diff --git a/internal/cli/serve.go b/internal/cli/serve.go index ae42780..f687e14 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -11,6 +11,8 @@ import ( "time" "github.com/thepeterstone/claudomator/internal/api" + "github.com/thepeterstone/claudomator/internal/budget" + "github.com/thepeterstone/claudomator/internal/config" "github.com/thepeterstone/claudomator/internal/executor" "github.com/thepeterstone/claudomator/internal/notify" "github.com/thepeterstone/claudomator/internal/storage" @@ -34,7 +36,7 @@ func newServeCmd() *cobra.Command { }, } - cmd.Flags().StringVar(&addr, "addr", ":8484", "listen address") + cmd.Flags().StringVar(&addr, "addr", "127.0.0.1:8484", "listen address (loopback by default; set external_bind_allowed=true in config to bind externally)") cmd.Flags().StringVar(&basePath, "base-path", "/claudomator", "URL prefix the UI is served under (e.g. /claudomator-oss)") cmd.Flags().StringVar(&workspaceRoot, "workspace-root", "/workspace", "root directory for listing workspaces") cmd.Flags().StringVar(&cfg.ClaudeImage, "claude-image", cfg.ClaudeImage, "docker image for claude agents") @@ -44,6 +46,9 @@ func newServeCmd() *cobra.Command { } func serve(addr, basePath string) error { + if err := config.ValidateBindAddr(addr, cfg.ExternalBindAllowed); err != nil { + return err + } if err := cfg.EnsureDirs(); err != nil { return fmt.Errorf("creating dirs: %w", err) } @@ -81,6 +86,10 @@ func serve(addr, basePath string) error { claudeConfigDir := cfg.ClaudeConfigDir repoDir, _ := os.Getwd() + + // Shared per-task agent MCP token registry: runners mint tokens; the API + // server mounts /mcp and resolves them. + agentRegistry := executor.NewRegistry() runners := map[string]executor.Runner{} // ContainerRunner: binaries are resolved via PATH inside the container image, @@ -96,6 +105,7 @@ func serve(addr, basePath string) error { ClaudeConfigDir: claudeConfigDir, CredentialSyncCmd: filepath.Join(repoDir, "scripts", "sync-credentials"), Store: store, + Registry: agentRegistry, } } if cfg.Runners.GeminiEnabled() { @@ -109,6 +119,7 @@ func serve(addr, basePath string) error { ClaudeConfigDir: claudeConfigDir, CredentialSyncCmd: filepath.Join(repoDir, "scripts", "sync-credentials"), Store: store, + Registry: agentRegistry, } } if cfg.Runners.ContainerEnabled() { @@ -122,6 +133,7 @@ func serve(addr, basePath string) error { ClaudeConfigDir: claudeConfigDir, CredentialSyncCmd: filepath.Join(repoDir, "scripts", "sync-credentials"), Store: store, + Registry: agentRegistry, } } @@ -136,7 +148,6 @@ func serve(addr, basePath string) error { logger.Info("local runner registered", "endpoint", cfg.LocalModel.Endpoint, "model", cfg.LocalModel.Model) } - pool := executor.NewPool(cfg.MaxConcurrent, runners, store, logger) pool.Classifier = &executor.Classifier{ LLM: localClient, @@ -146,6 +157,23 @@ func serve(addr, basePath string) error { pool.LLM = localClient } + // Budget accountant: gate paid providers against rolling per-provider caps. + // With no limits configured this is a no-op. + var accountant *budget.Accountant + if len(cfg.Budget.Provider5hUSD) > 0 { + window := budget.DefaultWindow + if cfg.Budget.Window != "" { + if d, derr := time.ParseDuration(cfg.Budget.Window); derr == nil { + window = d + } else { + logger.Warn("invalid budget.window; using default", "value", cfg.Budget.Window, "default", window) + } + } + accountant = budget.New(store, budget.Limits{Window: window, PerProvider: cfg.Budget.Provider5hUSD}) + pool.Budget = accountant + logger.Info("budget gating enabled", "window", window, "limits", cfg.Budget.Provider5hUSD) + } + if err := store.SeedProjects(); err != nil { logger.Error("failed to seed projects", "error", err) } @@ -154,8 +182,11 @@ func serve(addr, basePath string) error { pool.RecoverStaleQueued(context.Background()) pool.RecoverStaleBlocked() - srv := api.NewServer(store, pool, logger, cfg.ClaudeBinaryPath, cfg.GeminiBinaryPath) + srv := api.NewServer(store, pool, agentRegistry, logger, cfg.ClaudeBinaryPath, cfg.GeminiBinaryPath) srv.SetBasePath(basePath) + if accountant != nil { + srv.SetBudget(accountant) + } // Configure notifiers: combine webhook (if set) with web push. notifiers := []notify.Notifier{} @@ -185,6 +216,11 @@ func serve(addr, basePath string) error { } srv.SetGitHubWebhookConfig(cfg.WebhookSecret, cfg.Projects) + if cfg.APIToken != "" { + srv.SetAPIToken(cfg.APIToken) + logger.Info("API token configured; chatbot MCP endpoint enabled at /chatbot/mcp") + } + // Register scripts. wd, _ := os.Getwd() srv.SetScripts(api.ScriptRegistry{ @@ -238,4 +274,3 @@ func serve(addr, basePath string) error { } return nil } - diff --git a/internal/config/bind.go b/internal/config/bind.go new file mode 100644 index 0000000..f117385 --- /dev/null +++ b/internal/config/bind.go @@ -0,0 +1,44 @@ +package config + +import ( + "fmt" + "net" +) + +// ValidateBindAddr enforces loopback-by-default binding. It returns an error +// when addr resolves to a non-loopback interface and externalAllowed is false, +// so the server fails loud rather than silently exposing itself. Loopback +// addresses (127.0.0.1, ::1, localhost) always pass. +func ValidateBindAddr(addr string, externalAllowed bool) error { + host, _, err := net.SplitHostPort(addr) + if err != nil { + // No port (or malformed): treat the whole string as the host. + host = addr + } + if isLoopbackHost(host) { + return nil + } + if externalAllowed { + return nil + } + return fmt.Errorf( + "refusing to bind non-loopback address %q: set external_bind_allowed = true to override, "+ + "but prefer fronting external access with a reverse proxy (Tailscale / Cloudflare Access / Caddy)", + addr, + ) +} + +// isLoopbackHost reports whether host is a loopback target. An empty host +// (e.g. ":8484") or 0.0.0.0/:: binds all interfaces and is NOT loopback. +func isLoopbackHost(host string) bool { + switch host { + case "localhost": + return true + case "": + return false + } + if ip := net.ParseIP(host); ip != nil { + return ip.IsLoopback() + } + return false +} diff --git a/internal/config/bind_test.go b/internal/config/bind_test.go new file mode 100644 index 0000000..8e0ab4b --- /dev/null +++ b/internal/config/bind_test.go @@ -0,0 +1,27 @@ +package config + +import "testing" + +func TestValidateBindAddr(t *testing.T) { + cases := []struct { + addr string + external bool + wantErr bool + }{ + {"127.0.0.1:8484", false, false}, // loopback ok + {"localhost:8484", false, false}, // loopback ok + {"[::1]:8484", false, false}, // ipv6 loopback ok + {":8484", false, true}, // all interfaces, not allowed + {"0.0.0.0:8484", false, true}, // all interfaces, not allowed + {"192.168.1.10:8484", false, true}, // LAN ip, not allowed + {":8484", true, false}, // all interfaces, explicitly allowed + {"0.0.0.0:8484", true, false}, // explicitly allowed + {"192.168.1.10:8484", true, false}, // explicitly allowed + } + for _, c := range cases { + err := ValidateBindAddr(c.addr, c.external) + if (err != nil) != c.wantErr { + t.Errorf("ValidateBindAddr(%q, external=%v): err=%v, wantErr=%v", c.addr, c.external, err, c.wantErr) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 892d3f7..80777ff 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -80,6 +80,7 @@ type Config struct { WebhookURL string `toml:"webhook_url"` WorkspaceRoot string `toml:"workspace_root"` WebhookSecret string `toml:"webhook_secret"` + APIToken string `toml:"api_token"` // shared bearer for web UI + chatbot MCP; empty disables both auth and the chatbot MCP endpoint Projects []Project `toml:"projects"` VAPIDPublicKey string `toml:"vapid_public_key"` VAPIDPrivateKey string `toml:"vapid_private_key"` @@ -87,6 +88,16 @@ type Config struct { ClaudeConfigDir string `toml:"claude_config_dir"` LocalModel LocalModel `toml:"local_model"` Runners RunnersConfig `toml:"runners"` + Budget BudgetConfig `toml:"budget"` + // ExternalBindAllowed must be explicitly true to bind a non-loopback address. + ExternalBindAllowed bool `toml:"external_bind_allowed"` +} + +// BudgetConfig configures rolling per-provider spend caps. With no providers +// set, budget gating is disabled. +type BudgetConfig struct { + Window string `toml:"window"` + Provider5hUSD map[string]float64 `toml:"provider_5h_usd"` } func Default() (*Config, error) { @@ -110,7 +121,7 @@ func Default() (*Config, error) { GeminiImage: "claudomator-agent:latest", MaxConcurrent: 3, DefaultTimeout: "15m", - ServerAddr: ":8484", + ServerAddr: "127.0.0.1:8484", WorkspaceRoot: "/workspace", ClaudeConfigDir: "/workspace/claudomator/credentials/claude", }, nil diff --git a/internal/event/event.go b/internal/event/event.go new file mode 100644 index 0000000..2573fde --- /dev/null +++ b/internal/event/event.go @@ -0,0 +1,50 @@ +// Package event defines the Event type and canonical kinds/actors for the +// task observability stream. Events are immutable, per-task, monotonically +// sequenced records persisted by internal/storage. +package event + +import ( + "encoding/json" + "time" +) + +// Kind enumerates the canonical event types. +type Kind string + +const ( + KindStateChange Kind = "state_change" + KindAgentMessage Kind = "agent_message" + KindClarificationRequest Kind = "clarification_request" + KindClarificationAnswer Kind = "clarification_answer" + KindHumanMessage Kind = "human_message" + KindSummary Kind = "summary" + KindCommitMade Kind = "commit_made" + KindSubtaskSpawned Kind = "subtask_spawned" + KindCostReport Kind = "cost_report" + KindExecutionStarted Kind = "execution_started" + KindExecutionEnded Kind = "execution_ended" +) + +// Actor identifies the originator of an event. +type Actor string + +const ( + ActorAgent Actor = "agent" + ActorUser Actor = "user" + ActorChatbot Actor = "chatbot" + ActorSystem Actor = "system" + ActorWebhook Actor = "webhook" +) + +// Event is a single timestamped record on a task's audit/observability stream. +// Once written, events are append-only — the seq column is per-task monotonic +// and assigned by the storage layer on insert. +type Event struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + Seq int64 `json:"seq"` + Timestamp time.Time `json:"timestamp"` + Kind Kind `json:"kind"` + Actor Actor `json:"actor"` + Payload json.RawMessage `json:"payload"` +} diff --git a/internal/event/event_test.go b/internal/event/event_test.go new file mode 100644 index 0000000..a2cd143 --- /dev/null +++ b/internal/event/event_test.go @@ -0,0 +1,42 @@ +package event + +import ( + "encoding/json" + "testing" + "time" +) + +func TestEvent_JSONRoundTrip(t *testing.T) { + original := Event{ + ID: "evt-1", + TaskID: "task-1", + Seq: 42, + Timestamp: time.Date(2026, 5, 22, 12, 0, 0, 0, time.UTC), + Kind: KindClarificationRequest, + Actor: ActorAgent, + Payload: json.RawMessage(`{"question":"which env?"}`), + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got Event + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got.ID != original.ID || got.TaskID != original.TaskID || got.Seq != original.Seq { + t.Errorf("id/task/seq mismatch: got %+v want %+v", got, original) + } + if !got.Timestamp.Equal(original.Timestamp) { + t.Errorf("timestamp mismatch: got %v want %v", got.Timestamp, original.Timestamp) + } + if got.Kind != original.Kind || got.Actor != original.Actor { + t.Errorf("kind/actor mismatch: got %v/%v want %v/%v", got.Kind, got.Actor, original.Kind, original.Actor) + } + if string(got.Payload) != string(original.Payload) { + t.Errorf("payload mismatch: got %s want %s", got.Payload, original.Payload) + } +} diff --git a/internal/executor/agentmcp.go b/internal/executor/agentmcp.go new file mode 100644 index 0000000..4368031 --- /dev/null +++ b/internal/executor/agentmcp.go @@ -0,0 +1,160 @@ +package executor + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "strings" + "sync" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// Registry maps per-task MCP bearer tokens to a built agent MCP server. A token +// is minted when a runner spawns an agent subprocess and revoked when it exits, +// so the server resolves task context from the token alone and never trusts an +// agent-supplied task ID. +type Registry struct { + mu sync.RWMutex + servers map[string]*mcp.Server +} + +func NewRegistry() *Registry { + return &Registry{servers: make(map[string]*mcp.Server)} +} + +// Mint creates a token bound to a freshly built MCP server for ch. +func (r *Registry) Mint(ch AgentChannel) (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + token := hex.EncodeToString(buf) + r.mu.Lock() + r.servers[token] = newAgentServer(ch) + r.mu.Unlock() + return token, nil +} + +func (r *Registry) server(token string) (*mcp.Server, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + s, ok := r.servers[token] + return s, ok +} + +func (r *Registry) Revoke(token string) { + r.mu.Lock() + delete(r.servers, token) + r.mu.Unlock() +} + +type askUserInput struct { + Question string `json:"question" jsonschema:"the question to ask the user; phrase it as a real question ending with a question mark"` + Options []string `json:"options,omitempty" jsonschema:"optional list of suggested answer choices"` +} + +type reportSummaryInput struct { + Summary string `json:"summary" jsonschema:"a 2-5 sentence summary of what you did and the outcome"` +} + +type spawnSubtaskInput struct { + Name string `json:"name" jsonschema:"short descriptive name for the subtask"` + Instructions string `json:"instructions" jsonschema:"complete instructions for the subtask agent"` + Model string `json:"model,omitempty" jsonschema:"optional model override, e.g. sonnet or opus"` + MaxBudgetUSD float64 `json:"max_budget_usd,omitempty" jsonschema:"optional budget cap in USD"` +} + +type recordProgressInput struct { + Message string `json:"message" jsonschema:"a short progress note describing what you are doing"` +} + +func textResult(text string) *mcp.CallToolResult { + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}} +} + +// newAgentServer builds an MCP server exposing the four agent tools bound to ch. +func newAgentServer(ch AgentChannel) *mcp.Server { + s := mcp.NewServer(&mcp.Implementation{Name: "claudomator", Version: "1"}, nil) + + mcp.AddTool(s, &mcp.Tool{ + Name: "ask_user", + Description: "Ask the user a question when you genuinely need a decision to proceed. Your turn ends after calling this; the task is resumed with the user's answer. Prefer making a reasonable decision and noting it in report_summary over asking.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in askUserInput) (*mcp.CallToolResult, any, error) { + q := map[string]any{"text": in.Question} + if len(in.Options) > 0 { + q["options"] = in.Options + } + payload, _ := json.Marshal(q) + ans, err := ch.AskUser(ctx, string(payload)) + if errors.Is(err, ErrAgentBlocked) { + return textResult("Question recorded. End your turn now without calling any more tools; the task will be resumed once the user answers."), nil, nil + } + if err != nil { + return nil, nil, err + } + return textResult(ans), nil, nil + }) + + mcp.AddTool(s, &mcp.Tool{ + Name: "report_summary", + Description: "Record a concise summary of what you accomplished. Call this before finishing.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in reportSummaryInput) (*mcp.CallToolResult, any, error) { + if err := ch.ReportSummary(ctx, in.Summary); err != nil { + return nil, nil, err + } + return textResult("Summary recorded."), nil, nil + }) + + mcp.AddTool(s, &mcp.Tool{ + Name: "spawn_subtask", + Description: "Create a child task to be executed separately. Use this to break large work into focused pieces, then finish your turn.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in spawnSubtaskInput) (*mcp.CallToolResult, any, error) { + id, err := ch.SpawnSubtask(ctx, SubtaskSpec{ + Name: in.Name, + Instructions: in.Instructions, + Model: in.Model, + MaxBudgetUSD: in.MaxBudgetUSD, + }) + if err != nil { + return nil, nil, err + } + return textResult("Created subtask " + id), nil, nil + }) + + mcp.AddTool(s, &mcp.Tool{ + Name: "record_progress", + Description: "Record a short progress note that appears in the task timeline.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in recordProgressInput) (*mcp.CallToolResult, any, error) { + if err := ch.RecordProgress(ctx, in.Message); err != nil { + return nil, nil, err + } + return textResult("Noted."), nil, nil + }) + + return s +} + +func bearerToken(r *http.Request) string { + h := r.Header.Get("Authorization") + if h == "" { + return "" + } + return strings.TrimSpace(strings.TrimPrefix(h, "Bearer ")) +} + +// NewAgentMCPHandler returns the HTTP handler for the per-task agent MCP server. +// It resolves the request's bearer token to the server for that task's run; +// unknown tokens yield a 400 from the underlying handler. +func NewAgentMCPHandler(reg *Registry) http.Handler { + return mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { + s, ok := reg.server(bearerToken(r)) + if !ok { + return nil + } + return s + }, nil) +} diff --git a/internal/executor/agentmcp_test.go b/internal/executor/agentmcp_test.go new file mode 100644 index 0000000..3973af0 --- /dev/null +++ b/internal/executor/agentmcp_test.go @@ -0,0 +1,218 @@ +package executor + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// recordingChannel is a fake AgentChannel that records tool invocations. +type recordingChannel struct { + asked string + summary string + spawned []SubtaskSpec + progress []string + spawnID string +} + +func (c *recordingChannel) AskUser(_ context.Context, q string) (string, error) { + c.asked = q + return "", ErrAgentBlocked +} +func (c *recordingChannel) ReportSummary(_ context.Context, s string) error { + c.summary = s + return nil +} +func (c *recordingChannel) SpawnSubtask(_ context.Context, spec SubtaskSpec) (string, error) { + c.spawned = append(c.spawned, spec) + return c.spawnID, nil +} +func (c *recordingChannel) RecordProgress(_ context.Context, m string) error { + c.progress = append(c.progress, m) + return nil +} + +func resultText(t *testing.T, res *mcp.CallToolResult) string { + t.Helper() + if len(res.Content) == 0 { + t.Fatal("expected content in tool result") + } + tc, ok := res.Content[0].(*mcp.TextContent) + if !ok { + t.Fatalf("expected TextContent, got %T", res.Content[0]) + } + return tc.Text +} + +func TestRegistry_MintLookupRevoke(t *testing.T) { + reg := NewRegistry() + tok, err := reg.Mint(&recordingChannel{}) + if err != nil { + t.Fatalf("Mint: %v", err) + } + if tok == "" { + t.Fatal("expected non-empty token") + } + if _, ok := reg.server(tok); !ok { + t.Error("expected server for minted token") + } + if _, ok := reg.server("nonsense"); ok { + t.Error("expected no server for unknown token") + } + reg.Revoke(tok) + if _, ok := reg.server(tok); ok { + t.Error("expected no server after revoke") + } +} + +func TestRegistry_MintUniqueTokens(t *testing.T) { + reg := NewRegistry() + a, _ := reg.Mint(&recordingChannel{}) + b, _ := reg.Mint(&recordingChannel{}) + if a == b { + t.Error("expected unique tokens per mint") + } +} + +// connectInMemory wires a client to a server over the SDK's in-memory transport. +func connectInMemory(t *testing.T, srv *mcp.Server) *mcp.ClientSession { + t.Helper() + ctx := context.Background() + clientT, serverT := mcp.NewInMemoryTransports() + if _, err := srv.Connect(ctx, serverT, nil); err != nil { + t.Fatalf("server connect: %v", err) + } + client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1"}, nil) + cs, err := client.Connect(ctx, clientT, nil) + if err != nil { + t.Fatalf("client connect: %v", err) + } + t.Cleanup(func() { _ = cs.Close() }) + return cs +} + +func TestAgentServer_AskUser_RecordsAndInstructsStop(t *testing.T) { + ch := &recordingChannel{} + cs := connectInMemory(t, newAgentServer(ch)) + res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "ask_user", + Arguments: map[string]any{"question": "Proceed?", "options": []string{"yes", "no"}}, + }) + if err != nil { + t.Fatalf("CallTool: %v", err) + } + if !strings.Contains(ch.asked, `"text":"Proceed?"`) || !strings.Contains(ch.asked, "yes") { + t.Errorf("channel did not receive question+options: %q", ch.asked) + } + if txt := resultText(t, res); !strings.Contains(strings.ToLower(txt), "recorded") { + t.Errorf("expected stop instruction, got %q", txt) + } +} + +func TestAgentServer_ReportSummary(t *testing.T) { + ch := &recordingChannel{} + cs := connectInMemory(t, newAgentServer(ch)) + if _, err := cs.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "report_summary", + Arguments: map[string]any{"summary": "did the work"}, + }); err != nil { + t.Fatalf("CallTool: %v", err) + } + if ch.summary != "did the work" { + t.Errorf("summary not recorded, got %q", ch.summary) + } +} + +func TestAgentServer_SpawnSubtask(t *testing.T) { + ch := &recordingChannel{spawnID: "sub-1"} + cs := connectInMemory(t, newAgentServer(ch)) + res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "spawn_subtask", + Arguments: map[string]any{"name": "child", "instructions": "do it", "model": "sonnet"}, + }) + if err != nil { + t.Fatalf("CallTool: %v", err) + } + if len(ch.spawned) != 1 || ch.spawned[0].Name != "child" || ch.spawned[0].Instructions != "do it" || ch.spawned[0].Model != "sonnet" { + t.Errorf("subtask spec not propagated: %+v", ch.spawned) + } + if txt := resultText(t, res); !strings.Contains(txt, "sub-1") { + t.Errorf("expected returned subtask ID in result, got %q", txt) + } +} + +func TestAgentServer_RecordProgress(t *testing.T) { + ch := &recordingChannel{} + cs := connectInMemory(t, newAgentServer(ch)) + if _, err := cs.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "record_progress", + Arguments: map[string]any{"message": "halfway"}, + }); err != nil { + t.Fatalf("CallTool: %v", err) + } + if len(ch.progress) != 1 || ch.progress[0] != "halfway" { + t.Errorf("progress not recorded: %+v", ch.progress) + } +} + +type bearerRT struct { + token string + base http.RoundTripper +} + +func (b bearerRT) RoundTrip(r *http.Request) (*http.Response, error) { + r = r.Clone(r.Context()) + if b.token != "" { + r.Header.Set("Authorization", "Bearer "+b.token) + } + return b.base.RoundTrip(r) +} + +func TestAgentMCPHandler_HTTP_AuthAndDispatch(t *testing.T) { + ch := &recordingChannel{} + reg := NewRegistry() + tok, _ := reg.Mint(ch) + + httpSrv := httptest.NewServer(NewAgentMCPHandler(reg)) + defer httpSrv.Close() + + ctx := context.Background() + client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1"}, nil) + cs, err := client.Connect(ctx, &mcp.StreamableClientTransport{ + Endpoint: httpSrv.URL, + HTTPClient: &http.Client{Transport: bearerRT{token: tok, base: http.DefaultTransport}}, + }, nil) + if err != nil { + t.Fatalf("client connect with valid token: %v", err) + } + defer cs.Close() + + if _, err := cs.CallTool(ctx, &mcp.CallToolParams{ + Name: "record_progress", + Arguments: map[string]any{"message": "over http"}, + }); err != nil { + t.Fatalf("CallTool over HTTP: %v", err) + } + if len(ch.progress) != 1 || ch.progress[0] != "over http" { + t.Errorf("HTTP dispatch did not reach channel: %+v", ch.progress) + } +} + +func TestAgentMCPHandler_HTTP_BadTokenRejected(t *testing.T) { + reg := NewRegistry() + httpSrv := httptest.NewServer(NewAgentMCPHandler(reg)) + defer httpSrv.Close() + + client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1"}, nil) + _, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{ + Endpoint: httpSrv.URL, + HTTPClient: &http.Client{Transport: bearerRT{token: "invalid", base: http.DefaultTransport}}, + }, nil) + if err == nil { + t.Fatal("expected connect to fail with invalid token") + } +} diff --git a/internal/executor/channel.go b/internal/executor/channel.go new file mode 100644 index 0000000..8605ffa --- /dev/null +++ b/internal/executor/channel.go @@ -0,0 +1,159 @@ +package executor + +import ( + "context" + "encoding/json" + "errors" + "sync" + "time" + + "github.com/thepeterstone/claudomator/internal/event" + "github.com/thepeterstone/claudomator/internal/task" + "github.com/google/uuid" +) + +// ErrAgentBlocked is returned by AgentChannel.AskUser when the transport cannot +// deliver an answer within the current run — e.g. the file transport, which can +// only write-and-exit. Runners translate it into a *BlockedError so the task +// blocks and resumes later. Transports that can suspend the agent in-session +// (MCP, Phase 2) instead block and return the answer. +var ErrAgentBlocked = errors.New("agent blocked awaiting user input") + +// SubtaskSpec describes a child task an agent wants to spawn. +type SubtaskSpec struct { + Name string + Instructions string + Model string + MaxBudgetUSD float64 +} + +// AgentChannel is how a Runner reports agent-originated signals to the rest of +// the system. Implementations translate these into stored artifacts and events. +// The transport by which a Runner detects these signals — post-exit files +// today, MCP/tool-use in Phase 2 — is the Runner's private concern. +type AgentChannel interface { + // AskUser records that the agent needs human input. Transports that can + // suspend the agent in-session return the answer; those that cannot return + // ErrAgentBlocked. + AskUser(ctx context.Context, questionJSON string) (answer string, err error) + // ReportSummary records the agent's final summary text. + ReportSummary(ctx context.Context, summary string) error + // SpawnSubtask creates a child task and returns its ID. + SpawnSubtask(ctx context.Context, spec SubtaskSpec) (taskID string, err error) + // RecordProgress records a free-form progress note from the agent. + RecordProgress(ctx context.Context, message string) error +} + +// channelStore is the subset of storage the default channel needs. +type channelStore interface { + CreateTask(t *task.Task) error + CreateEvent(e *event.Event) error +} + +// pendingAsker is implemented by channels that buffer an ask_user call so the +// runner can convert it into a BlockedError after the agent subprocess exits. +type pendingAsker interface { + PendingQuestion() (questionJSON string, blocked bool) +} + +// channelPendingQuestion reports whether the agent asked a question via the +// channel during the run (the MCP transport's ask_user path). +func channelPendingQuestion(ch AgentChannel) (string, bool) { + if pa, ok := ch.(pendingAsker); ok { + return pa.PendingQuestion() + } + return "", false +} + +// storeChannel is the default AgentChannel backed by storage. Summary and +// question signals are buffered here under a mutex (they may arrive on an MCP +// handler goroutine mid-run); the pool flushes the summary to the execution and +// the runner reads the pending question to build a BlockedError after the +// subprocess exits. SpawnSubtask/RecordProgress write immediately. +type storeChannel struct { + store channelStore + taskID string + + mu sync.Mutex + summary string + summarySet bool + question string + blocked bool +} + +var _ AgentChannel = (*storeChannel)(nil) + +func newStoreChannel(store channelStore, taskID string) *storeChannel { + return &storeChannel{store: store, taskID: taskID} +} + +// AskUser buffers the question and flags the run as blocked. The default +// transport cannot deliver an answer in-session, so it returns ErrAgentBlocked; +// the runner converts the buffered question into a BlockedError after exit, and +// question persistence is owned by the pool's BlockedError handling. +func (c *storeChannel) AskUser(_ context.Context, questionJSON string) (string, error) { + c.mu.Lock() + c.question = questionJSON + c.blocked = true + c.mu.Unlock() + return "", ErrAgentBlocked +} + +// ReportSummary buffers the summary; the pool flushes it onto the execution +// after the run (preferring it over its extract/synthesize fallbacks). +func (c *storeChannel) ReportSummary(_ context.Context, summary string) error { + c.mu.Lock() + c.summary = summary + c.summarySet = true + c.mu.Unlock() + return nil +} + +// ReportedSummary returns the buffered summary if report_summary was called. +func (c *storeChannel) ReportedSummary() (string, bool) { + c.mu.Lock() + defer c.mu.Unlock() + return c.summary, c.summarySet +} + +// PendingQuestion returns the buffered ask_user question if the run is blocked. +func (c *storeChannel) PendingQuestion() (string, bool) { + c.mu.Lock() + defer c.mu.Unlock() + return c.question, c.blocked +} + +func (c *storeChannel) SpawnSubtask(_ context.Context, spec SubtaskSpec) (string, error) { + now := time.Now().UTC() + child := &task.Task{ + ID: uuid.NewString(), + Name: spec.Name, + ParentTaskID: c.taskID, + Agent: task.AgentConfig{ + Type: "claude", + Model: spec.Model, + Instructions: spec.Instructions, + MaxBudgetUSD: spec.MaxBudgetUSD, + }, + Priority: task.PriorityNormal, + State: task.StatePending, + CreatedAt: now, + UpdatedAt: now, + } + if err := c.store.CreateTask(child); err != nil { + return "", err + } + return child.ID, nil +} + +func (c *storeChannel) RecordProgress(_ context.Context, message string) error { + payload, _ := json.Marshal(struct { + Message string `json:"message"` + }{Message: message}) + return c.store.CreateEvent(&event.Event{ + TaskID: c.taskID, + Kind: event.KindAgentMessage, + Actor: event.ActorAgent, + Payload: payload, + }) +}
\ No newline at end of file diff --git a/internal/executor/channel_test.go b/internal/executor/channel_test.go new file mode 100644 index 0000000..250e8eb --- /dev/null +++ b/internal/executor/channel_test.go @@ -0,0 +1,124 @@ +package executor + +import ( + "context" + "errors" + "testing" + + "github.com/thepeterstone/claudomator/internal/event" + "github.com/thepeterstone/claudomator/internal/task" +) + +type fakeChannelStore struct { + createdTasks []*task.Task + createdEvents []*event.Event + createTaskErr error +} + +func (f *fakeChannelStore) CreateTask(t *task.Task) error { + if f.createTaskErr != nil { + return f.createTaskErr + } + f.createdTasks = append(f.createdTasks, t) + return nil +} + +func (f *fakeChannelStore) CreateEvent(e *event.Event) error { + f.createdEvents = append(f.createdEvents, e) + return nil +} + +func TestStoreChannel_AskUser_BuffersAndBlocks(t *testing.T) { + ch := newStoreChannel(&fakeChannelStore{}, "task-1") + answer, err := ch.AskUser(context.Background(), `{"text":"q"}`) + if !errors.Is(err, ErrAgentBlocked) { + t.Errorf("expected ErrAgentBlocked, got %v", err) + } + if answer != "" { + t.Errorf("expected empty answer, got %q", answer) + } + q, blocked := ch.PendingQuestion() + if !blocked || q != `{"text":"q"}` { + t.Errorf("expected buffered question, got q=%q blocked=%v", q, blocked) + } +} + +func TestStoreChannel_PendingQuestion_DefaultNotBlocked(t *testing.T) { + ch := newStoreChannel(&fakeChannelStore{}, "task-1") + if q, blocked := ch.PendingQuestion(); blocked || q != "" { + t.Errorf("expected no pending question, got q=%q blocked=%v", q, blocked) + } +} + +func TestStoreChannel_ReportSummary_Buffers(t *testing.T) { + ch := newStoreChannel(&fakeChannelStore{}, "task-1") + if _, ok := ch.ReportedSummary(); ok { + t.Error("expected no summary before report") + } + if err := ch.ReportSummary(context.Background(), "did the thing"); err != nil { + t.Fatalf("ReportSummary: %v", err) + } + sum, ok := ch.ReportedSummary() + if !ok || sum != "did the thing" { + t.Errorf("expected buffered summary, got %q ok=%v", sum, ok) + } +} + +func TestStoreChannel_SpawnSubtask_CreatesChildWithParent(t *testing.T) { + store := &fakeChannelStore{} + ch := newStoreChannel(store, "parent-1") + id, err := ch.SpawnSubtask(context.Background(), SubtaskSpec{ + Name: "child", + Instructions: "do it", + Model: "sonnet", + MaxBudgetUSD: 1.5, + }) + if err != nil { + t.Fatalf("SpawnSubtask: %v", err) + } + if id == "" { + t.Error("expected non-empty subtask ID") + } + if len(store.createdTasks) != 1 { + t.Fatalf("expected 1 created task, got %d", len(store.createdTasks)) + } + ct := store.createdTasks[0] + if ct.ParentTaskID != "parent-1" { + t.Errorf("ParentTaskID: got %q want parent-1", ct.ParentTaskID) + } + if ct.ID != id { + t.Errorf("returned id %q != created task id %q", id, ct.ID) + } + if ct.Agent.Instructions != "do it" || ct.Agent.Model != "sonnet" || ct.Agent.MaxBudgetUSD != 1.5 { + t.Errorf("agent config not propagated: %+v", ct.Agent) + } + if ct.State != task.StatePending { + t.Errorf("expected PENDING child, got %s", ct.State) + } +} + +func TestStoreChannel_SpawnSubtask_PropagatesError(t *testing.T) { + store := &fakeChannelStore{createTaskErr: errors.New("boom")} + ch := newStoreChannel(store, "parent-1") + if _, err := ch.SpawnSubtask(context.Background(), SubtaskSpec{Name: "x"}); err == nil { + t.Error("expected error from CreateTask") + } +} + +func TestStoreChannel_RecordProgress_EmitsAgentMessage(t *testing.T) { + store := &fakeChannelStore{} + ch := newStoreChannel(store, "task-1") + if err := ch.RecordProgress(context.Background(), "halfway done"); err != nil { + t.Fatalf("RecordProgress: %v", err) + } + if len(store.createdEvents) != 1 { + t.Fatalf("expected 1 event, got %d", len(store.createdEvents)) + } + e := store.createdEvents[0] + if e.Kind != event.KindAgentMessage || e.Actor != event.ActorAgent { + t.Errorf("got kind=%v actor=%v want agent_message/agent", e.Kind, e.Actor) + } + if e.TaskID != "task-1" { + t.Errorf("TaskID: got %q want task-1", e.TaskID) + } +} diff --git a/internal/executor/claude.go b/internal/executor/claude.go deleted file mode 100644 index 7b70486..0000000 --- a/internal/executor/claude.go +++ /dev/null @@ -1,554 +0,0 @@ -package executor - -import ( - "context" - "fmt" - "log/slog" - "os" - "os/exec" - "path/filepath" - "strings" - "sync" - "syscall" - "time" - - "github.com/thepeterstone/claudomator/internal/retry" - "github.com/thepeterstone/claudomator/internal/storage" - "github.com/thepeterstone/claudomator/internal/task" -) - -// ClaudeRunner spawns the `claude` CLI in non-interactive mode. -type ClaudeRunner struct { - BinaryPath string // defaults to "claude" - Logger *slog.Logger - LogDir string // base directory for execution logs - APIURL string // base URL of the Claudomator API, passed to subprocesses -} - -// BlockedError is returned by Run when the agent wrote a question file and exited. -// The pool transitions the task to BLOCKED and stores the question for the user. -// ExecLogDir returns the log directory for the given execution ID. -// Implements LogPather so the pool can persist paths before execution starts. -func (r *ClaudeRunner) ExecLogDir(execID string) string { - if r.LogDir == "" { - return "" - } - return filepath.Join(r.LogDir, execID) -} - -func (r *ClaudeRunner) binaryPath() string { - if r.BinaryPath != "" { - return r.BinaryPath - } - return "claude" -} - -// Run executes a claude -p invocation, streaming output to log files. -// It retries up to 3 times on rate-limit errors using exponential backoff. -// If the agent writes a question file and exits, Run returns *BlockedError. -// -// When project_dir is set and this is not a resume execution, Run clones the -// project into a temp sandbox, runs the agent there, then merges committed -// changes back to project_dir. On failure the sandbox is preserved and its -// path is included in the error. -func (r *ClaudeRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution) error { - projectDir := t.Agent.ProjectDir - - // Validate project_dir exists when set. - if projectDir != "" { - if _, err := os.Stat(projectDir); err != nil { - return fmt.Errorf("project_dir %q: %w", projectDir, err) - } - } - - // Setup log directory once; retries overwrite the log files. - logDir := r.ExecLogDir(e.ID) - if logDir == "" { - logDir = e.ID // fallback for tests without LogDir set - } - if err := os.MkdirAll(logDir, 0700); err != nil { - return fmt.Errorf("creating log dir: %w", err) - } - if e.StdoutPath == "" { - e.StdoutPath = filepath.Join(logDir, "stdout.log") - e.StderrPath = filepath.Join(logDir, "stderr.log") - e.ArtifactDir = logDir - } - - // Pre-assign session ID so we can resume after a BLOCKED state. - // For resume executions, the claude session continues under the original - // session ID (the one passed to --resume). Using the new exec's own UUID - // would cause a second block-and-resume cycle to pass the wrong --resume - // argument. - if e.SessionID == "" { - if e.ResumeSessionID != "" { - e.SessionID = e.ResumeSessionID - } else { - e.SessionID = e.ID // reuse execution UUID as session UUID (both are UUIDs) - } - } - - // For new (non-resume) executions with a project_dir, clone into a sandbox. - // Resume executions run in the preserved sandbox (e.SandboxDir) so Claude - // finds its session files under the same project slug. If no sandbox was - // preserved (e.g. task had no project_dir), fall back to project_dir. - var sandboxDir string - var startHEAD string - effectiveWorkingDir := projectDir - if e.ResumeSessionID != "" { - if e.SandboxDir != "" { - if _, statErr := os.Stat(e.SandboxDir); statErr == nil { - effectiveWorkingDir = e.SandboxDir - } else { - // Preserved sandbox was cleaned up (e.g. /tmp purge after reboot). - // Clone a fresh sandbox so the task can run rather than fail immediately. - r.Logger.Warn("preserved sandbox missing, cloning fresh", "sandbox", e.SandboxDir, "project_dir", projectDir) - e.SandboxDir = "" - if projectDir != "" { - var err error - sandboxDir, err = setupSandbox(t.Agent.ProjectDir, r.Logger) - if err != nil { - return fmt.Errorf("setting up sandbox: %w", err) - } - - effectiveWorkingDir = sandboxDir - r.Logger.Info("fresh sandbox created for resume", "sandbox", sandboxDir, "project_dir", projectDir) - } - } - } - } else if projectDir != "" { - var err error - sandboxDir, err = setupSandbox(t.Agent.ProjectDir, r.Logger) - if err != nil { - return fmt.Errorf("setting up sandbox: %w", err) - } - - effectiveWorkingDir = sandboxDir - r.Logger.Info("sandbox created", "sandbox", sandboxDir, "project_dir", projectDir) - } - - if effectiveWorkingDir != "" { - // Capture the initial HEAD so we can identify new commits later. - headOut, _ := exec.Command("git", gitSafe("-C", effectiveWorkingDir, "rev-parse", "HEAD")...).Output() - startHEAD = strings.TrimSpace(string(headOut)) - } - - questionFile := filepath.Join(logDir, "question.json") - args := r.buildArgs(t, e, questionFile) - - attempt := 0 - err := retry.RunWithBackoff(ctx, 3, 5*time.Second, func() error { - if attempt > 0 { - delay := 5 * time.Second * (1 << (attempt - 1)) - r.Logger.Warn("rate-limited by Claude API, retrying", - "attempt", attempt, - "delay", delay, - ) - } - attempt++ - return r.execOnce(ctx, args, effectiveWorkingDir, projectDir, e) - }) - if err != nil { - if sandboxDir != "" { - return fmt.Errorf("%w (sandbox preserved at %s)", err, sandboxDir) - } - return err - } - - // Check whether the agent left a question before exiting. - data, readErr := os.ReadFile(questionFile) - if readErr == nil { - os.Remove(questionFile) // consumed - questionJSON := strings.TrimSpace(string(data)) - // If the agent wrote a completion report instead of a real question, - // extract the text as the summary and fall through to normal completion. - if isCompletionReport(questionJSON) { - r.Logger.Info("treating question file as completion report", "taskID", e.TaskID) - e.Summary = extractQuestionText(questionJSON) - } else { - // Preserve sandbox on BLOCKED — agent may have partial work and its - // Claude session files are stored under the sandbox's project slug. - // The resume execution must run in the same directory. - return &BlockedError{QuestionJSON: questionJSON, SessionID: e.SessionID, SandboxDir: sandboxDir} - } - } - - // Read agent summary if written. - summaryFile := filepath.Join(logDir, "summary.txt") - if summaryData, readErr := os.ReadFile(summaryFile); readErr == nil { - os.Remove(summaryFile) // consumed - e.Summary = strings.TrimSpace(string(summaryData)) - } - - // Merge sandbox back to project_dir and clean up. - if sandboxDir != "" { - if mergeErr := teardownSandbox(projectDir, sandboxDir, startHEAD, r.Logger, e); mergeErr != nil { - return fmt.Errorf("sandbox teardown: %w (sandbox preserved at %s)", mergeErr, sandboxDir) - } - } - return nil -} - -// sandboxCloneSource returns the URL to clone the sandbox from. It prefers a -// remote named "local" (a local bare repo that accepts pushes cleanly), then -// falls back to "origin", then to the working copy path itself. -func sandboxCloneSource(projectDir string) string { - for _, remote := range []string{"local", "origin"} { - out, err := exec.Command("git", gitSafe("-C", projectDir, "remote", "get-url", remote)...).Output() - if err == nil { - u := strings.TrimSpace(string(out)) - if u != "" && (strings.HasPrefix(u, "/") || strings.HasPrefix(u, "file://")) { - return u - } - } - } - return projectDir -} - -// setupSandbox prepares a temporary git clone of projectDir. -// If projectDir is not a git repo it is initialised with an initial commit first. -func setupSandbox(projectDir string, logger *slog.Logger) (string, error) { - // Ensure projectDir is a git repo; initialise if not. - if err := exec.Command("git", gitSafe("-C", projectDir, "rev-parse", "--git-dir")...).Run(); err != nil { - cmds := [][]string{ - gitSafe("-C", projectDir, "init"), - gitSafe("-C", projectDir, "add", "-A"), - gitSafe("-C", projectDir, "commit", "--allow-empty", "-m", "chore: initial commit"), - } - for _, args := range cmds { - if out, err := exec.Command("git", args...).CombinedOutput(); err != nil { //nolint:gosec - return "", fmt.Errorf("git init %s: %w\n%s", projectDir, err, out) - } - } - } - - src := sandboxCloneSource(projectDir) - - tempDir, err := os.MkdirTemp("", "claudomator-sandbox-*") - if err != nil { - return "", fmt.Errorf("creating sandbox dir: %w", err) - } - // git clone requires the target to not exist; remove the placeholder first. - if err := os.Remove(tempDir); err != nil { - return "", fmt.Errorf("removing temp dir placeholder: %w", err) - } - out, err := exec.Command("git", gitSafe("clone", "--no-hardlinks", src, tempDir)...).CombinedOutput() - if err != nil { - return "", fmt.Errorf("git clone: %w\n%s", err, out) - } - return tempDir, nil -} - -// teardownSandbox verifies the sandbox is clean and pushes new commits to the -// canonical bare repo. If the push is rejected because another task pushed -// concurrently, it fetches and rebases then retries once. -// -// The working copy (projectDir) is NOT updated automatically — it is the -// developer's workspace and is pulled manually. This avoids permission errors -// from mixed-owner .git/objects directories. -func teardownSandbox(projectDir, sandboxDir, startHEAD string, logger *slog.Logger, execRecord *storage.Execution) error { - // Automatically commit uncommitted changes. - out, err := exec.Command("git", gitSafe("-C", sandboxDir, "status", "--porcelain")...).Output() - if err != nil { - return fmt.Errorf("git status: %w", err) - } - if len(strings.TrimSpace(string(out))) > 0 { - logger.Info("autocommitting uncommitted changes", "sandbox", sandboxDir) - - // Run build before autocommitting. - if _, err := os.Stat(filepath.Join(sandboxDir, "Makefile")); err == nil { - logger.Info("running 'make build' before autocommit", "sandbox", sandboxDir) - if buildOut, buildErr := exec.Command("make", "-C", sandboxDir, "build").CombinedOutput(); buildErr != nil { - return fmt.Errorf("build failed before autocommit: %w\n%s", buildErr, buildOut) - } - } else if _, err := os.Stat(filepath.Join(sandboxDir, "gradlew")); err == nil { - logger.Info("running './gradlew build' before autocommit", "sandbox", sandboxDir) - cmd := exec.Command("./gradlew", "build") - cmd.Dir = sandboxDir - if buildOut, buildErr := cmd.CombinedOutput(); buildErr != nil { - return fmt.Errorf("build failed before autocommit: %w\n%s", buildErr, buildOut) - } - } else if _, err := os.Stat(filepath.Join(sandboxDir, "go.mod")); err == nil { - logger.Info("running 'go build ./...' before autocommit", "sandbox", sandboxDir) - cmd := exec.Command("go", "build", "./...") - cmd.Dir = sandboxDir - if buildOut, buildErr := cmd.CombinedOutput(); buildErr != nil { - return fmt.Errorf("build failed before autocommit: %w\n%s", buildErr, buildOut) - } - } - - cmds := [][]string{ - gitSafe("-C", sandboxDir, "add", "-A"), - gitSafe("-C", sandboxDir, "commit", "-m", "chore: autocommit uncommitted changes"), - } - for _, args := range cmds { - if out, err := exec.Command("git", args...).CombinedOutput(); err != nil { - return fmt.Errorf("autocommit failed (%v): %w\n%s", args, err, out) - } - } - } - - // Capture commits before pushing/deleting. - // Use startHEAD..HEAD to find all commits made during this execution. - logRange := "origin/HEAD..HEAD" - if startHEAD != "" && startHEAD != "HEAD" { - logRange = startHEAD + "..HEAD" - } - - logCmd := exec.Command("git", gitSafe("-C", sandboxDir, "log", logRange, "--pretty=format:%H|%s")...) - logOut, logErr := logCmd.CombinedOutput() - if logErr == nil { - lines := strings.Split(strings.TrimSpace(string(logOut)), "\n") - logger.Debug("captured commits", "count", len(lines), "range", logRange) - for _, line := range lines { - if line == "" { - continue - } - parts := strings.SplitN(line, "|", 2) - if len(parts) == 2 { - execRecord.Commits = append(execRecord.Commits, task.GitCommit{ - Hash: parts[0], - Message: parts[1], - }) - } - } - } else { - logger.Warn("failed to capture commits", "err", logErr, "range", logRange, "output", string(logOut)) - } - - // Check whether there are any new commits to push. - ahead, err := exec.Command("git", gitSafe("-C", sandboxDir, "rev-list", "--count", logRange)...).Output() - if err != nil { - logger.Warn("could not determine commits ahead of origin; proceeding", "err", err, "range", logRange) - } - if strings.TrimSpace(string(ahead)) == "0" { - os.RemoveAll(sandboxDir) - return nil - } - - // Push from sandbox → bare repo (sandbox's origin is the bare repo). - if out, err := exec.Command("git", gitSafe("-C", sandboxDir, "push", "origin", "HEAD")...).CombinedOutput(); err != nil { - // If rejected due to concurrent push, fetch+rebase and retry once. - // Use `git pull --rebase` without explicit remote/branch so git uses the - // configured upstream (set by clone), handling both main- and master-based repos. - if strings.Contains(string(out), "fetch first") || strings.Contains(string(out), "non-fast-forward") { - logger.Info("push rejected (concurrent task); rebasing and retrying", "sandbox", sandboxDir) - if out2, err2 := exec.Command("git", gitSafe("-C", sandboxDir, "pull", "--rebase")...).CombinedOutput(); err2 != nil { - return fmt.Errorf("git rebase before retry push: %w\n%s", err2, out2) - } - // Re-capture commits after rebase (hashes might have changed) - execRecord.Commits = nil - logOut, logErr = exec.Command("git", gitSafe("-C", sandboxDir, "log", logRange, "--pretty=format:%H|%s")...).Output() - if logErr == nil { - lines := strings.Split(strings.TrimSpace(string(logOut)), "\n") - for _, line := range lines { - parts := strings.SplitN(line, "|", 2) - if len(parts) == 2 { - execRecord.Commits = append(execRecord.Commits, task.GitCommit{ - Hash: parts[0], - Message: parts[1], - }) - } - } - } - - if out3, err3 := exec.Command("git", gitSafe("-C", sandboxDir, "push", "origin", "HEAD")...).CombinedOutput(); err3 != nil { - return fmt.Errorf("git push to origin (after rebase): %w\n%s", err3, out3) - } - } else { - return fmt.Errorf("git push to origin: %w\n%s", err, out) - } - } - - logger.Info("sandbox pushed to bare repo", "sandbox", sandboxDir) - os.RemoveAll(sandboxDir) - return nil -} - -// execOnce runs the claude subprocess once, streaming output to e's log paths. -func (r *ClaudeRunner) execOnce(ctx context.Context, args []string, workingDir, projectDir string, e *storage.Execution) error { - cmd := exec.CommandContext(ctx, r.binaryPath(), args...) - cmd.Env = append(os.Environ(), - "CLAUDOMATOR_API_URL="+r.APIURL, - "CLAUDOMATOR_TASK_ID="+e.TaskID, - "CLAUDOMATOR_PROJECT_DIR="+projectDir, - "CLAUDOMATOR_QUESTION_FILE="+filepath.Join(e.ArtifactDir, "question.json"), - "CLAUDOMATOR_SUMMARY_FILE="+filepath.Join(e.ArtifactDir, "summary.txt"), - ) - // Put the subprocess in its own process group so we can SIGKILL the entire - // group (MCP servers, bash children, etc.) on cancellation. - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - if workingDir != "" { - cmd.Dir = workingDir - } - - stdoutFile, err := os.Create(e.StdoutPath) - if err != nil { - return fmt.Errorf("creating stdout log: %w", err) - } - defer stdoutFile.Close() - - stderrFile, err := os.Create(e.StderrPath) - if err != nil { - return fmt.Errorf("creating stderr log: %w", err) - } - defer stderrFile.Close() - - // Use os.Pipe for stdout so we own the read-end lifetime. - // cmd.StdoutPipe() would add the read-end to closeAfterWait, causing - // cmd.Wait() to close it before our goroutine finishes reading. - stdoutR, stdoutW, err := os.Pipe() - if err != nil { - return fmt.Errorf("creating stdout pipe: %w", err) - } - cmd.Stdout = stdoutW // *os.File — not added to closeAfterStart/Wait - cmd.Stderr = stderrFile - - if err := cmd.Start(); err != nil { - stdoutW.Close() - stdoutR.Close() - return fmt.Errorf("starting claude: %w", err) - } - // Close our write-end immediately; the subprocess holds its own copy. - // The goroutine below gets EOF when the subprocess exits. - stdoutW.Close() - - // killDone is closed when cmd.Wait() returns, stopping the pgid-kill goroutine. - // - // Safety: this goroutine cannot block indefinitely. The select has two arms: - // • ctx.Done() — fires if the caller cancels (e.g. timeout, user cancel). - // The goroutine sends SIGKILL and exits immediately. - // • killDone — closed by close(killDone) below, immediately after cmd.Wait() - // returns. This fires when the process exits for any reason (natural exit, - // SIGKILL from the ctx arm, or any other signal). The goroutine exits without - // doing anything. - // - // Therefore: for a task that completes normally with a long-lived (non-cancelled) - // context, the killDone arm fires and the goroutine exits. There is no path where - // this goroutine outlives execOnce(). - killDone := make(chan struct{}) - go func() { - select { - case <-ctx.Done(): - // SIGKILL the entire process group to reap orphan children. - syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) - case <-killDone: - } - }() - - // Stream stdout to the log file and parse cost/errors. - // wg ensures costUSD and streamErr are fully written before we read them after cmd.Wait(). - var costUSD float64 - var streamErr error - var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() - costUSD, _, streamErr = parseStream(stdoutR, stdoutFile, r.Logger) - stdoutR.Close() - }() - - waitErr := cmd.Wait() - close(killDone) // stop the pgid-kill goroutine - wg.Wait() // drain remaining stdout before reading costUSD/streamErr - - e.CostUSD = costUSD - - if waitErr != nil { - if exitErr, ok := waitErr.(*exec.ExitError); ok { - e.ExitCode = exitErr.ExitCode() - } - // If the stream captured a rate-limit or quota message, return it - // so callers can distinguish it from a generic exit-status failure. - if retry.IsRateLimitError(streamErr) || isQuotaExhausted(streamErr) { - return streamErr - } - if tail := tailFile(e.StderrPath, 20); tail != "" { - return fmt.Errorf("claude exited with error: %w\nstderr:\n%s", waitErr, tail) - } - return fmt.Errorf("claude exited with error: %w", waitErr) - } - - e.ExitCode = 0 - if streamErr != nil { - return streamErr - } - return nil -} - -func (r *ClaudeRunner) buildArgs(t *task.Task, e *storage.Execution, questionFile string) []string { - // Resume execution: the agent already has context; just deliver the answer. - if e.ResumeSessionID != "" { - args := []string{ - "-p", e.ResumeAnswer, - "--resume", e.ResumeSessionID, - "--output-format", "stream-json", - "--verbose", - } - permMode := t.Agent.PermissionMode - if permMode == "" { - permMode = "bypassPermissions" - } - args = append(args, "--permission-mode", permMode) - if t.Agent.Model != "" { - args = append(args, "--model", t.Agent.Model) - } - return args - } - - instructions := t.Agent.Instructions - allowedTools := t.Agent.AllowedTools - - if !t.Agent.SkipPlanning { - instructions = withPlanningPreamble(instructions) - // Ensure Bash is available so the agent can POST subtasks and ask questions. - hasBash := false - for _, tool := range allowedTools { - if tool == "Bash" { - hasBash = true - break - } - } - if !hasBash { - allowedTools = append(allowedTools, "Bash") - } - } - - args := []string{ - "-p", instructions, - "--session-id", e.SessionID, - "--output-format", "stream-json", - "--verbose", - } - - if t.Agent.Model != "" { - args = append(args, "--model", t.Agent.Model) - } - if t.Agent.MaxBudgetUSD > 0 { - args = append(args, "--max-budget-usd", fmt.Sprintf("%.2f", t.Agent.MaxBudgetUSD)) - } - // Default to bypassPermissions — claudomator runs tasks unattended, so - // prompting for write access would always stall execution. Tasks that need - // a more restrictive mode can set permission_mode explicitly. - permMode := t.Agent.PermissionMode - if permMode == "" { - permMode = "bypassPermissions" - } - args = append(args, "--permission-mode", permMode) - if t.Agent.SystemPromptAppend != "" { - args = append(args, "--append-system-prompt", t.Agent.SystemPromptAppend) - } - for _, tool := range allowedTools { - args = append(args, "--allowedTools", tool) - } - for _, tool := range t.Agent.DisallowedTools { - args = append(args, "--disallowedTools", tool) - } - for _, f := range t.Agent.ContextFiles { - args = append(args, "--add-dir", f) - } - args = append(args, t.Agent.AdditionalArgs...) - - return args -} - diff --git a/internal/executor/claude_test.go b/internal/executor/claude_test.go deleted file mode 100644 index c01e160..0000000 --- a/internal/executor/claude_test.go +++ /dev/null @@ -1,810 +0,0 @@ -package executor - -import ( - "context" - "fmt" - "io" - "log/slog" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "testing" - "time" - - "github.com/thepeterstone/claudomator/internal/storage" - "github.com/thepeterstone/claudomator/internal/task" -) - -func TestClaudeRunner_BuildArgs_BasicTask(t *testing.T) { - r := &ClaudeRunner{} - tk := &task.Task{ - Agent: task.AgentConfig{ - Type: "claude", - Instructions: "fix the bug", - Model: "sonnet", - SkipPlanning: true, - }, - } - - args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json") - - argMap := make(map[string]bool) - for _, a := range args { - argMap[a] = true - } - for _, want := range []string{"-p", "fix the bug", "--output-format", "stream-json", "--verbose", "--model", "sonnet"} { - if !argMap[want] { - t.Errorf("missing arg %q in %v", want, args) - } - } -} - -func TestClaudeRunner_BuildArgs_FullConfig(t *testing.T) { - r := &ClaudeRunner{} - tk := &task.Task{ - Agent: task.AgentConfig{ - Type: "claude", - Instructions: "implement feature", - Model: "opus", - MaxBudgetUSD: 5.0, - PermissionMode: "bypassPermissions", - SystemPromptAppend: "Follow TDD", - AllowedTools: []string{"Bash", "Edit"}, - DisallowedTools: []string{"Write"}, - ContextFiles: []string{"/src"}, - AdditionalArgs: []string{"--verbose"}, - SkipPlanning: true, - }, - } - - args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json") - - // Check key args are present. - argMap := make(map[string]bool) - for _, a := range args { - argMap[a] = true - } - - requiredArgs := []string{ - "-p", "implement feature", "--output-format", "stream-json", - "--model", "opus", "--max-budget-usd", "5.00", - "--permission-mode", "bypassPermissions", - "--append-system-prompt", "Follow TDD", - "--allowedTools", "Bash", "Edit", - "--disallowedTools", "Write", - "--add-dir", "/src", - "--verbose", - } - for _, req := range requiredArgs { - if !argMap[req] { - t.Errorf("missing arg %q in %v", req, args) - } - } -} - -func TestClaudeRunner_BuildArgs_DefaultsToBypassPermissions(t *testing.T) { - r := &ClaudeRunner{} - tk := &task.Task{ - Agent: task.AgentConfig{ - Type: "claude", - Instructions: "do work", - SkipPlanning: true, - // PermissionMode intentionally not set - }, - } - - args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json") - - found := false - for i, a := range args { - if a == "--permission-mode" && i+1 < len(args) && args[i+1] == "bypassPermissions" { - found = true - } - } - if !found { - t.Errorf("expected --permission-mode bypassPermissions when PermissionMode is empty, args: %v", args) - } -} - -func TestClaudeRunner_BuildArgs_RespectsExplicitPermissionMode(t *testing.T) { - r := &ClaudeRunner{} - tk := &task.Task{ - Agent: task.AgentConfig{ - Type: "claude", - Instructions: "do work", - PermissionMode: "default", - SkipPlanning: true, - }, - } - - args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json") - - for i, a := range args { - if a == "--permission-mode" && i+1 < len(args) { - if args[i+1] != "default" { - t.Errorf("expected --permission-mode default, got %q", args[i+1]) - } - return - } - } - t.Errorf("--permission-mode flag not found in args: %v", args) -} - -func TestClaudeRunner_BuildArgs_AlwaysIncludesVerbose(t *testing.T) { - r := &ClaudeRunner{} - tk := &task.Task{ - Agent: task.AgentConfig{ - Type: "claude", - Instructions: "do something", - SkipPlanning: true, - }, - } - - args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json") - - found := false - for _, a := range args { - if a == "--verbose" { - found = true - break - } - } - if !found { - t.Errorf("--verbose missing from args: %v", args) - } -} - -func TestClaudeRunner_BuildArgs_PreamblePrepended(t *testing.T) { - r := &ClaudeRunner{} - tk := &task.Task{ - Agent: task.AgentConfig{ - Type: "claude", - Instructions: "fix the bug", - SkipPlanning: false, - }, - } - - args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json") - - // The -p value should start with the preamble and end with the original instructions. - if len(args) < 2 || args[0] != "-p" { - t.Fatalf("expected -p as first arg, got: %v", args) - } - if !strings.HasPrefix(args[1], "## Runtime Environment") { - t.Errorf("instructions should start with planning preamble, got prefix: %q", args[1][:min(len(args[1]), 20)]) - } - if !strings.Contains(args[1], "$CLAUDOMATOR_PROJECT_DIR") { - t.Errorf("preamble should mention $CLAUDOMATOR_PROJECT_DIR") - } - if !strings.HasSuffix(args[1], "fix the bug") { - t.Errorf("instructions should end with original instructions") - } -} - -func TestClaudeRunner_BuildArgs_PreambleAddsBash(t *testing.T) { - r := &ClaudeRunner{} - tk := &task.Task{ - Agent: task.AgentConfig{ - Type: "claude", - Instructions: "do work", - AllowedTools: []string{"Read"}, - SkipPlanning: false, - }, - } - - args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json") - - // Bash should be appended to allowed tools. - foundBash := false - for i, a := range args { - if a == "--allowedTools" && i+1 < len(args) && args[i+1] == "Bash" { - foundBash = true - } - } - if !foundBash { - t.Errorf("Bash should be added to --allowedTools when preamble is active: %v", args) - } -} - -func TestClaudeRunner_BuildArgs_PreambleBashNotDuplicated(t *testing.T) { - r := &ClaudeRunner{} - tk := &task.Task{ - Agent: task.AgentConfig{ - Type: "claude", - Instructions: "do work", - AllowedTools: []string{"Bash", "Read"}, - SkipPlanning: false, - }, - } - - args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json") - - // Count Bash occurrences in --allowedTools values. - bashCount := 0 - for i, a := range args { - if a == "--allowedTools" && i+1 < len(args) && args[i+1] == "Bash" { - bashCount++ - } - } - if bashCount != 1 { - t.Errorf("Bash should appear exactly once in --allowedTools, got %d: %v", bashCount, args) - } -} - -// TestClaudeRunner_Run_ResumeSetsSessionIDFromResumeSession verifies that when a -// resume execution is itself blocked again, the stored SessionID is the original -// resumed session, not the new execution's own UUID. Without this, a second -// block-and-resume cycle passes the wrong --resume session ID and fails. -func TestClaudeRunner_Run_ResumeSetsSessionIDFromResumeSession(t *testing.T) { - logDir := t.TempDir() - r := &ClaudeRunner{ - BinaryPath: "true", // exits 0, no output - Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), - LogDir: logDir, - } - tk := &task.Task{ - Agent: task.AgentConfig{ - Type: "claude", - Instructions: "continue", - SkipPlanning: true, - }, - } - exec := &storage.Execution{ - ID: "resume-exec-uuid", - TaskID: "task-1", - ResumeSessionID: "original-session-uuid", - ResumeAnswer: "yes", - } - - // Run completes successfully (binary is "true"). - _ = r.Run(context.Background(), tk, exec) - - // SessionID must be the original session (ResumeSessionID), not the new - // exec's own ID. If it were exec.ID, a second blocked-then-resumed cycle - // would use the wrong --resume argument and fail. - if exec.SessionID != "original-session-uuid" { - t.Errorf("SessionID after resume Run: want %q, got %q", "original-session-uuid", exec.SessionID) - } -} - -func TestClaudeRunner_Run_InaccessibleWorkingDir_ReturnsError(t *testing.T) { - r := &ClaudeRunner{ - BinaryPath: "true", // would succeed if it ran - Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), - LogDir: t.TempDir(), - } - tk := &task.Task{ - Agent: task.AgentConfig{ - Type: "claude", - ProjectDir: "/nonexistent/path/does/not/exist", - SkipPlanning: true, - }, - } - exec := &storage.Execution{ID: "test-exec"} - - err := r.Run(context.Background(), tk, exec) - - if err == nil { - t.Fatal("expected error for inaccessible working_dir, got nil") - } - if !strings.Contains(err.Error(), "project_dir") { - t.Errorf("expected 'project_dir' in error, got: %v", err) - } -} - -func TestClaudeRunner_BinaryPath_Default(t *testing.T) { - r := &ClaudeRunner{} - if r.binaryPath() != "claude" { - t.Errorf("want 'claude', got %q", r.binaryPath()) - } -} - -func TestClaudeRunner_BinaryPath_Custom(t *testing.T) { - r := &ClaudeRunner{BinaryPath: "/usr/local/bin/claude"} - if r.binaryPath() != "/usr/local/bin/claude" { - t.Errorf("want custom path, got %q", r.binaryPath()) - } -} - -// TestExecOnce_NoGoroutineLeak_OnNaturalExit verifies that execOnce does not -// leave behind any goroutines when the subprocess exits normally (no context -// cancellation). Both the pgid-kill goroutine and the parseStream goroutine -// must have exited before execOnce returns. -func TestExecOnce_NoGoroutineLeak_OnNaturalExit(t *testing.T) { - logDir := t.TempDir() - r := &ClaudeRunner{ - BinaryPath: "true", // exits immediately with status 0, produces no output - Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), - LogDir: logDir, - } - e := &storage.Execution{ - ID: "goroutine-leak-test", - TaskID: "task-id", - StdoutPath: filepath.Join(logDir, "stdout.log"), - StderrPath: filepath.Join(logDir, "stderr.log"), - ArtifactDir: logDir, - } - - // Let any goroutines from test infrastructure settle before sampling. - runtime.Gosched() - baseline := runtime.NumGoroutine() - - if err := r.execOnce(context.Background(), []string{}, "", "", e); err != nil { - t.Fatalf("execOnce failed: %v", err) - } - - // Give the scheduler a moment to let any leaked goroutines actually exit. - // In correct code the goroutines exit before execOnce returns, so this is - // just a safety buffer for the scheduler. - time.Sleep(10 * time.Millisecond) - runtime.Gosched() - - after := runtime.NumGoroutine() - if after > baseline { - t.Errorf("goroutine leak: %d goroutines before execOnce, %d after (leaked %d)", - baseline, after, after-baseline) - } -} - -// initGitRepo creates a git repo in dir with one commit so it is clonable. -func initGitRepo(t *testing.T, dir string) { - t.Helper() - cmds := [][]string{ - {"git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", dir, "init", "-b", "main"}, - {"git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", dir, "config", "user.email", "test@test"}, - {"git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", dir, "config", "user.name", "test"}, - } - for _, args := range cmds { - if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil { - t.Fatalf("%v: %v\n%s", args, err, out) - } - } - if err := os.WriteFile(filepath.Join(dir, "init.txt"), []byte("init"), 0644); err != nil { - t.Fatal(err) - } - if out, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", dir, "add", ".").CombinedOutput(); err != nil { - t.Fatalf("git add: %v\n%s", err, out) - } - if out, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", dir, "commit", "-m", "init").CombinedOutput(); err != nil { - t.Fatalf("git commit: %v\n%s", err, out) - } -} - -func TestSandboxCloneSource_PrefersLocalRemote(t *testing.T) { - dir := t.TempDir() - initGitRepo(t, dir) - // Add a "local" remote pointing to a bare repo. - bare := t.TempDir() - exec.Command("git", "init", "--bare", bare).Run() - exec.Command("git", "-C", dir, "remote", "add", "local", bare).Run() - exec.Command("git", "-C", dir, "remote", "add", "origin", "https://example.com/repo").Run() - - got := sandboxCloneSource(dir) - if got != bare { - t.Errorf("expected bare repo path %q, got %q", bare, got) - } -} - -func TestSandboxCloneSource_FallsBackToOrigin(t *testing.T) { - dir := t.TempDir() - initGitRepo(t, dir) - // sandboxCloneSource intentionally filters to local-FS remotes (so - // `git clone <src>` doesn't go over the network). Use a local path - // for origin to verify the fallback semantics. - originURL := t.TempDir() - exec.Command("git", "-C", dir, "remote", "add", "origin", originURL).Run() - - got := sandboxCloneSource(dir) - if got != originURL { - t.Errorf("expected origin URL %q, got %q", originURL, got) - } -} - -func TestSandboxCloneSource_FallsBackToProjectDir(t *testing.T) { - dir := t.TempDir() - initGitRepo(t, dir) - // No remotes configured. - got := sandboxCloneSource(dir) - if got != dir { - t.Errorf("expected projectDir %q (no remotes), got %q", dir, got) - } -} - -func TestSetupSandbox_ClonesGitRepo(t *testing.T) { - src := t.TempDir() - initGitRepo(t, src) - - sandbox, err := setupSandbox(src, slog.Default()) - if err != nil { - t.Fatalf("setupSandbox: %v", err) - } - t.Cleanup(func() { os.RemoveAll(sandbox) }) - - // Force sandbox to master if it cloned as main - exec.Command("git", gitSafe("-C", sandbox, "checkout", "master")...).Run() - - // Debug sandbox - logOut, _ := exec.Command("git", "-C", sandbox, "log", "-1").CombinedOutput() - fmt.Printf("DEBUG: sandbox log: %s\n", string(logOut)) - - // Verify sandbox is a git repo with at least one commit. - out, err := exec.Command("git", "-C", sandbox, "log", "--oneline").Output() - if err != nil { - t.Fatalf("git log in sandbox: %v", err) - } - if len(strings.TrimSpace(string(out))) == 0 { - t.Error("expected at least one commit in sandbox, got empty log") - } -} - -func TestSetupSandbox_InitialisesNonGitDir(t *testing.T) { - // A plain directory (not a git repo) should be initialised then cloned. - src := t.TempDir() - - sandbox, err := setupSandbox(src, slog.Default()) - if err != nil { - t.Fatalf("setupSandbox on plain dir: %v", err) - } - t.Cleanup(func() { os.RemoveAll(sandbox) }) - - if _, err := os.Stat(filepath.Join(sandbox, ".git")); err != nil { - t.Errorf("sandbox should be a git repo: %v", err) - } -} - -func TestTeardownSandbox_AutocommitsChanges(t *testing.T) { - // Create a bare repo as origin so push succeeds. - bare := t.TempDir() - if out, err := exec.Command("git", "init", "--bare", "-b", "main", bare).CombinedOutput(); err != nil { - t.Fatalf("git init bare: %v\n%s", err, out) - } - - // Create a sandbox directly. - sandbox := t.TempDir() - initGitRepo(t, sandbox) - if out, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "remote", "add", "origin", bare).CombinedOutput(); err != nil { - t.Fatalf("git remote add: %v\n%s", err, out) - } - // Initial push to establish origin/main - if out, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "push", "origin", "main").CombinedOutput(); err != nil { - t.Fatalf("git push initial: %v\n%s", err, out) - } - - // Capture startHEAD - headOut, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "rev-parse", "HEAD").Output() - if err != nil { - t.Fatalf("rev-parse HEAD: %v", err) - } - startHEAD := strings.TrimSpace(string(headOut)) - - // Leave an uncommitted file in the sandbox. - if err := os.WriteFile(filepath.Join(sandbox, "dirty.txt"), []byte("autocommit me"), 0644); err != nil { - t.Fatal(err) - } - - logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})) - execRecord := &storage.Execution{} - - err = teardownSandbox("", sandbox, startHEAD, logger, execRecord) - if err != nil { - t.Fatalf("expected autocommit to succeed, got error: %v", err) - } - - // Sandbox should be removed after successful autocommit and push. - if _, statErr := os.Stat(sandbox); !os.IsNotExist(statErr) { - t.Error("sandbox should have been removed after successful autocommit and push") - } - - // Verify the commit exists in the bare repo. - out, err := exec.Command("git", "-C", bare, "log", "-1", "--pretty=%B").Output() - if err != nil { - t.Fatalf("git log in bare repo: %v", err) - } - if !strings.Contains(string(out), "chore: autocommit uncommitted changes") { - t.Errorf("expected autocommit message in log, got: %q", string(out)) - } - - // Verify the commit was captured in execRecord. - if len(execRecord.Commits) == 0 { - t.Error("expected at least one commit in execRecord") - } else if !strings.Contains(execRecord.Commits[0].Message, "chore: autocommit uncommitted changes") { - t.Errorf("unexpected commit message: %q", execRecord.Commits[0].Message) - } -} - -func TestTeardownSandbox_BuildFailure_BlocksAutocommit(t *testing.T) { - bare := t.TempDir() - if out, err := exec.Command("git", "init", "--bare", "-b", "main", bare).CombinedOutput(); err != nil { - t.Fatalf("git init bare: %v\n%s", err, out) - } - - sandbox := t.TempDir() - initGitRepo(t, sandbox) - if out, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "remote", "add", "origin", bare).CombinedOutput(); err != nil { - t.Fatalf("git remote add: %v\n%s", err, out) - } - - // Capture startHEAD - headOut, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "rev-parse", "HEAD").Output() - if err != nil { - t.Fatalf("rev-parse HEAD: %v", err) - } - startHEAD := strings.TrimSpace(string(headOut)) - - // Leave an uncommitted file. - if err := os.WriteFile(filepath.Join(sandbox, "dirty.txt"), []byte("dirty"), 0644); err != nil { - t.Fatal(err) - } - - // Add a failing Makefile. - makefile := "build:\n\t@echo 'build failed'\n\texit 1\n" - if err := os.WriteFile(filepath.Join(sandbox, "Makefile"), []byte(makefile), 0644); err != nil { - t.Fatal(err) - } - - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - execRecord := &storage.Execution{} - - err = teardownSandbox("", sandbox, startHEAD, logger, execRecord) - if err == nil { - t.Error("expected teardown to fail due to build failure, but it succeeded") - } else if !strings.Contains(err.Error(), "build failed before autocommit") { - t.Errorf("expected build failure error message, got: %v", err) - } - - // Sandbox should NOT be removed if teardown failed. - if _, statErr := os.Stat(sandbox); os.IsNotExist(statErr) { - t.Error("sandbox should have been preserved after build failure") - } - - // Verify no new commit in bare repo. - out, err := exec.Command("git", "-C", bare, "log", "HEAD").CombinedOutput() - if strings.Contains(string(out), "chore: autocommit uncommitted changes") { - t.Error("autocommit should not have been pushed after build failure") - } -} - -func TestTeardownSandbox_BuildSuccess_ProceedsToAutocommit(t *testing.T) { - bare := t.TempDir() - if out, err := exec.Command("git", "init", "--bare", "-b", "main", bare).CombinedOutput(); err != nil { - t.Fatalf("git init bare: %v\n%s", err, out) - } - - sandbox := t.TempDir() - initGitRepo(t, sandbox) - if out, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "remote", "add", "origin", bare).CombinedOutput(); err != nil { - t.Fatalf("git remote add: %v\n%s", err, out) - } - - // Capture startHEAD - headOut, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "rev-parse", "HEAD").Output() - if err != nil { - t.Fatalf("rev-parse HEAD: %v", err) - } - startHEAD := strings.TrimSpace(string(headOut)) - - // Leave an uncommitted file. - if err := os.WriteFile(filepath.Join(sandbox, "dirty.txt"), []byte("dirty"), 0644); err != nil { - t.Fatal(err) - } - - // Add a successful Makefile. - makefile := "build:\n\t@echo 'build succeeded'\n" - if err := os.WriteFile(filepath.Join(sandbox, "Makefile"), []byte(makefile), 0644); err != nil { - t.Fatal(err) - } - - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - execRecord := &storage.Execution{} - - err = teardownSandbox("", sandbox, startHEAD, logger, execRecord) - if err != nil { - t.Fatalf("expected teardown to succeed after build success, got error: %v", err) - } - - // Sandbox should be removed after success. - if _, statErr := os.Stat(sandbox); !os.IsNotExist(statErr) { - t.Error("sandbox should have been removed after successful build and autocommit") - } - - // Verify new commit in bare repo. - out, err := exec.Command("git", "-C", bare, "log", "-1", "--pretty=%B").Output() - if err != nil { - t.Fatalf("git log in bare repo: %v", err) - } - if !strings.Contains(string(out), "chore: autocommit uncommitted changes") { - t.Errorf("expected autocommit message in log, got: %q", string(out)) - } -} - - -func TestTeardownSandbox_CapturesExplicitCommits(t *testing.T) { - bare := t.TempDir() - if out, err := exec.Command("git", "init", "--bare", "-b", "main", bare).CombinedOutput(); err != nil { - t.Fatalf("git init bare: %v\n%s", err, out) - } - - sandbox := t.TempDir() - initGitRepo(t, sandbox) - if out, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "remote", "add", "origin", bare).CombinedOutput(); err != nil { - t.Fatalf("git remote add: %v\n%s", err, out) - } - if out, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "push", "origin", "main").CombinedOutput(); err != nil { - t.Fatalf("git push initial: %v\n%s", err, out) - } - - headOut, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "rev-parse", "HEAD").Output() - if err != nil { - t.Fatalf("rev-parse HEAD: %v", err) - } - startHEAD := strings.TrimSpace(string(headOut)) - - // Simulate Claude explicitly committing changes. - if err := os.WriteFile(filepath.Join(sandbox, "work.txt"), []byte("done"), 0644); err != nil { - t.Fatal(err) - } - for _, args := range [][]string{ - {"-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "add", "-A"}, - {"-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "commit", "-m", "feat: implement the feature"}, - } { - if out, err := exec.Command("git", args...).CombinedOutput(); err != nil { - t.Fatalf("git %v: %v\n%s", args, err, out) - } - } - - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - execRecord := &storage.Execution{} - - if err := teardownSandbox("", sandbox, startHEAD, logger, execRecord); err != nil { - t.Fatalf("teardownSandbox: %v", err) - } - - if len(execRecord.Commits) == 0 { - t.Fatal("expected commits to be captured in execRecord") - } - if !strings.Contains(execRecord.Commits[0].Message, "feat: implement the feature") { - t.Errorf("unexpected commit message: %q", execRecord.Commits[0].Message) - } - if execRecord.Commits[0].Hash == "" { - t.Error("commit hash should not be empty") - } -} - -func TestTeardownSandbox_CleanSandboxWithNoNewCommits_RemovesSandbox(t *testing.T) { - src := t.TempDir() - initGitRepo(t, src) - sandbox, err := setupSandbox(src, slog.Default()) - if err != nil { - t.Fatalf("setupSandbox: %v", err) - } - - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - execRecord := &storage.Execution{} - - headOut, _ := exec.Command("git", "-C", sandbox, "rev-parse", "HEAD").Output() - startHEAD := strings.TrimSpace(string(headOut)) - - // Sandbox has no new commits beyond origin; teardown should succeed and remove it. - if err := teardownSandbox(src, sandbox, startHEAD, logger, execRecord); err != nil { - t.Fatalf("teardownSandbox: %v", err) - } - if _, statErr := os.Stat(sandbox); !os.IsNotExist(statErr) { - t.Error("sandbox should have been removed after clean teardown") - os.RemoveAll(sandbox) - } -} - - -// TestClaudeRunner_Run_ResumeUsesStoredSandboxDir verifies that when a resume -// execution has SandboxDir set, the runner uses that directory (not project_dir) -// as the working directory, so Claude finds its session files there. -func TestClaudeRunner_Run_ResumeUsesStoredSandboxDir(t *testing.T) { - logDir := t.TempDir() - sandboxDir := t.TempDir() - cwdFile := filepath.Join(logDir, "cwd.txt") - - // Use a script that writes its working directory to a file in logDir (stable path). - scriptPath := filepath.Join(t.TempDir(), "fake-claude.sh") - script := "#!/bin/sh\nprintf '%s' \"$PWD\" > " + cwdFile + "\n" - if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { - t.Fatalf("write script: %v", err) - } - - r := &ClaudeRunner{ - BinaryPath: scriptPath, - Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), - LogDir: logDir, - } - tk := &task.Task{ - Agent: task.AgentConfig{ - Type: "claude", - ProjectDir: sandboxDir, // must exist; resume overrides it with SandboxDir anyway - SkipPlanning: true, - }, - } - exec := &storage.Execution{ - ID: "resume-exec-uuid", - TaskID: "task-1", - ResumeSessionID: "original-session", - ResumeAnswer: "yes", - SandboxDir: sandboxDir, - } - - _ = r.Run(context.Background(), tk, exec) - - got, err := os.ReadFile(cwdFile) - if err != nil { - t.Fatalf("cwd file not written: %v", err) - } - // The runner should have executed claude in sandboxDir, not in project_dir. - if string(got) != sandboxDir { - t.Errorf("resume working dir: want %q, got %q", sandboxDir, string(got)) - } -} - -func TestClaudeRunner_Run_StaleSandboxDir_ClonesAfresh(t *testing.T) { - logDir := t.TempDir() - projectDir := t.TempDir() - initGitRepo(t, projectDir) - - cwdFile := filepath.Join(logDir, "cwd.txt") - scriptPath := filepath.Join(t.TempDir(), "fake-claude.sh") - script := "#!/bin/sh\nprintf '%s' \"$PWD\" > " + cwdFile + "\n" - if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { - t.Fatalf("write script: %v", err) - } - - r := &ClaudeRunner{ - BinaryPath: scriptPath, - Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), - LogDir: logDir, - } - tk := &task.Task{ - Agent: task.AgentConfig{ - Type: "claude", - ProjectDir: projectDir, - SkipPlanning: true, - }, - } - // Point to a sandbox that no longer exists (e.g. /tmp was purged). - staleSandbox := filepath.Join(t.TempDir(), "gone") - e := &storage.Execution{ - ID: "resume-exec-2", - TaskID: "task-2", - ResumeSessionID: "session-abc", - ResumeAnswer: "ok", - SandboxDir: staleSandbox, - } - - if err := r.Run(context.Background(), tk, e); err != nil { - t.Fatalf("Run with stale sandbox: %v", err) - } - - got, err := os.ReadFile(cwdFile) - if err != nil { - t.Fatalf("cwd file not written: %v", err) - } - // Should have run in a fresh sandbox (not the stale path, not the raw projectDir). - // The sandbox is removed after teardown, so we only check what it wasn't. - cwd := string(got) - if cwd == staleSandbox { - t.Error("ran in stale sandbox dir that doesn't exist") - } - if cwd == projectDir { - t.Error("ran directly in project_dir; expected a fresh sandbox clone") - } - // cwd should look like a claudomator sandbox path. - if !strings.Contains(cwd, "claudomator-sandbox-") { - t.Errorf("expected sandbox path, got %q", cwd) - } -} - -func TestTailFile_MissingFile_ReturnsEmpty(t *testing.T) { - got := tailFile("/nonexistent/path/file.log", 10) - if got != "" { - t.Errorf("want empty string for missing file, got %q", got) - } -} - diff --git a/internal/executor/container.go b/internal/executor/container.go index 23e35b3..39f737a 100644 --- a/internal/executor/container.go +++ b/internal/executor/container.go @@ -2,6 +2,7 @@ package executor import ( "context" + "encoding/json" "errors" "fmt" "log/slog" @@ -29,6 +30,9 @@ type ContainerRunner struct { ClaudeConfigDir string // host path to ~/.claude; mounted into container for auth credentials CredentialSyncCmd string // optional path to sync-credentials script for auth-error auto-recovery Store Store // optional; used to look up stories and projects for story-aware cloning + // Registry mints the per-task MCP token; when set, the agent gets an + // mcp-config pointing at the host agent MCP server. + Registry *Registry // Command allows mocking exec.CommandContext for tests. Command func(ctx context.Context, name string, arg ...string) *exec.Cmd } @@ -100,7 +104,7 @@ func (r *ContainerRunner) ensureStoryBranch(ctx context.Context, remoteURL, bran return nil } -func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution) error { +func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution, ch AgentChannel) error { var err error repoURL := t.RepositoryURL if repoURL == "" { @@ -237,7 +241,7 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec } // Run container (with auth retry on failure). - runErr := r.runContainer(ctx, t, e, workspace, agentHome, isResume, storyBranch) + runErr := r.runContainer(ctx, t, e, workspace, agentHome, isResume, storyBranch, ch) if runErr != nil && isAuthError(runErr) && r.CredentialSyncCmd != "" { r.Logger.Warn("auth failure detected, syncing credentials and retrying once", "taskID", t.ID) syncOut, syncErr := r.command(ctx, r.CredentialSyncCmd).CombinedOutput() @@ -251,7 +255,7 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec if srcData, readErr := os.ReadFile(filepath.Join(r.ClaudeConfigDir, ".claude.json")); readErr == nil { _ = os.WriteFile(filepath.Join(agentHome, ".claude.json"), srcData, 0644) } - runErr = r.runContainer(ctx, t, e, workspace, agentHome, isResume, storyBranch) + runErr = r.runContainer(ctx, t, e, workspace, agentHome, isResume, storyBranch, ch) } if runErr == nil { @@ -267,7 +271,7 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec // runContainer runs the docker container for the given task and handles log setup, // environment files, instructions, and post-execution git operations. -func (r *ContainerRunner) runContainer(ctx context.Context, t *task.Task, e *storage.Execution, workspace, agentHome string, isResume bool, storyBranch string) error { +func (r *ContainerRunner) runContainer(ctx context.Context, t *task.Task, e *storage.Execution, workspace, agentHome string, isResume bool, storyBranch string, ch AgentChannel) error { repoURL := t.RepositoryURL image := t.Agent.ContainerImage @@ -311,14 +315,39 @@ func (r *ContainerRunner) runContainer(ctx context.Context, t *task.Task, e *sto return fmt.Errorf("writing env file: %w", err) } - // Inject custom instructions via file to avoid CLI length limits + // Per-task MCP back-channel: mint a scoped token bound to this run's channel + // and point the agent at the host agent MCP server. Claude reads an + // --mcp-config file; the gemini CLI auto-discovers servers from its + // ~/.gemini/settings.json (agentHome is the container's $HOME). + mcpEnabled := false + if r.Registry != nil { + token, mintErr := r.Registry.Mint(ch) + if mintErr != nil { + return fmt.Errorf("minting agent mcp token: %w", mintErr) + } + defer r.Registry.Revoke(token) + mcpURL := strings.TrimRight(strings.ReplaceAll(r.APIURL, "localhost", "host.docker.internal"), "/") + "/mcp" + if t.Agent.Type == "gemini" { + if err := writeGeminiMCPSettings(agentHome, mcpURL, token); err != nil { + return fmt.Errorf("writing gemini mcp settings: %w", err) + } + } else if err := writeMCPConfig(workspace, mcpURL, token); err != nil { + return fmt.Errorf("writing mcp config: %w", err) + } + mcpEnabled = true + } + + // Inject custom instructions via file to avoid CLI length limits. When the + // MCP back-channel is active, prepend the planning preamble that points the + // agent at the ask_user/report_summary/spawn_subtask/record_progress tools. + instructions := buildAgentInstructions(t, mcpEnabled) instructionsFile := filepath.Join(workspace, ".claudomator-instructions.txt") - if err := os.WriteFile(instructionsFile, []byte(t.Agent.Instructions), 0644); err != nil { + if err := os.WriteFile(instructionsFile, []byte(instructions), 0644); err != nil { return fmt.Errorf("writing instructions: %w", err) } args := r.buildDockerArgs(workspace, agentHome, e.TaskID) - innerCmd := r.buildInnerCmd(t, e, isResume) + innerCmd := r.buildInnerCmd(t, e, isResume, mcpEnabled) fullArgs := append(args, image) fullArgs = append(fullArgs, innerCmd...) @@ -375,31 +404,13 @@ func (r *ContainerRunner) runContainer(ctx context.Context, t *task.Task, e *sto e.SessionID = sessionID } - // Check whether the agent left a question before exiting. - questionFile := filepath.Join(logDir, "question.json") - if data, readErr := os.ReadFile(questionFile); readErr == nil { - os.Remove(questionFile) // consumed - questionJSON := strings.TrimSpace(string(data)) - if isCompletionReport(questionJSON) { - r.Logger.Info("treating question file as completion report", "taskID", e.TaskID) - e.Summary = extractQuestionText(questionJSON) - } else { - if e.SessionID == "" { - r.Logger.Warn("missing session ID; resume will start fresh", "taskID", e.TaskID) - } - return &BlockedError{ - QuestionJSON: questionJSON, - SessionID: e.SessionID, - SandboxDir: workspace, - } + // MCP transport: if the agent called ask_user, the question is buffered on + // the channel. Block so the task resumes with the user's answer. + if q, blocked := channelPendingQuestion(ch); blocked { + if e.SessionID == "" { + r.Logger.Warn("missing session ID; resume will start fresh", "taskID", e.TaskID) } - } - - // Read agent summary if written. - summaryFile := filepath.Join(logDir, "summary.txt") - if summaryData, readErr := os.ReadFile(summaryFile); readErr == nil { - os.Remove(summaryFile) // consumed - e.Summary = strings.TrimSpace(string(summaryData)) + return &BlockedError{QuestionJSON: q, SessionID: e.SessionID, SandboxDir: workspace} } // 5. Post-execution: push changes if successful @@ -467,6 +478,10 @@ func (r *ContainerRunner) buildDockerArgs(workspace, claudeHome, taskID string) "-w", "/workspace", "--env-file", hostEnvFile, "-e", "HOME=/home/agent", + // The container is an isolated sandbox; IS_SANDBOX=1 lets the claude CLI + // honor --permission-mode bypassPermissions even when the agent runs as + // root (buildDockerArgs maps --user to the host uid, which may be 0). + "-e", "IS_SANDBOX=1", "-e", "CLAUDOMATOR_API_URL=" + apiURL, "-e", "CLAUDOMATOR_TASK_ID=" + taskID, "-e", "CLAUDOMATOR_DROP_DIR=" + r.DropsDir, @@ -477,7 +492,71 @@ func (r *ContainerRunner) buildDockerArgs(workspace, claudeHome, taskID string) return args } -func (r *ContainerRunner) buildInnerCmd(t *task.Task, e *storage.Execution, isResume bool) []string { +// mcpConfigContainerPath is where the agent mcp-config is mounted in-container +// (the workspace is bind-mounted at /workspace). +const mcpConfigContainerPath = "/workspace/.claudomator-mcp.json" + +// buildAgentInstructions returns the agent's instructions, prepending the +// MCP-tool planning preamble when the back-channel is active and planning is +// not skipped. +func buildAgentInstructions(t *task.Task, mcpEnabled bool) string { + instructions := t.Agent.Instructions + if mcpEnabled && !t.Agent.SkipPlanning { + instructions = withPlanningPreamble(instructions) + } + return instructions +} + +// writeMCPConfig writes a claude CLI mcp-config that registers the per-task +// agent MCP server over HTTP with a bearer token. +func writeMCPConfig(workspace, mcpURL, token string) error { + cfg := map[string]any{ + "mcpServers": map[string]any{ + "claudomator": map[string]any{ + "type": "http", + "url": mcpURL, + "headers": map[string]string{"Authorization": "Bearer " + token}, + }, + }, + } + data, err := json.Marshal(cfg) + if err != nil { + return err + } + return os.WriteFile(filepath.Join(workspace, ".claudomator-mcp.json"), data, 0600) +} + +// writeGeminiMCPSettings registers the per-task agent MCP server in the gemini +// CLI's user settings (agentHome/.gemini/settings.json, which is $HOME/.gemini +// in-container). The gemini CLI discovers MCP servers from this file rather than +// a command-line flag; httpUrl selects the streamable-HTTP transport and headers +// carry the bearer token. +// +// NOTE: the on-disk schema follows the gemini-cli mcpServers format, but whether +// the CLI actually invokes these tools in non-interactive (-p) mode has not been +// verified against a live gemini binary — confirm with a spike before relying on +// gemini tool-use in production. +func writeGeminiMCPSettings(agentHome, mcpURL, token string) error { + cfg := map[string]any{ + "mcpServers": map[string]any{ + "claudomator": map[string]any{ + "httpUrl": mcpURL, + "headers": map[string]string{"Authorization": "Bearer " + token}, + }, + }, + } + data, err := json.Marshal(cfg) + if err != nil { + return err + } + dir := filepath.Join(agentHome, ".gemini") + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, "settings.json"), data, 0600) +} + +func (r *ContainerRunner) buildInnerCmd(t *task.Task, e *storage.Execution, isResume, mcpEnabled bool) []string { // Claude CLI uses -p for prompt text. To pass a file, we use a shell to cat it. // We use a shell variable to capture the expansion to avoid quoting issues with instructions contents. // The outer single quotes around the sh -c argument prevent host-side expansion. @@ -501,6 +580,9 @@ func (r *ContainerRunner) buildInnerCmd(t *task.Task, e *storage.Execution, isRe if isResume && e.ResumeSessionID != "" { claudeCmd.WriteString(fmt.Sprintf(" --resume %s", e.ResumeSessionID)) } + if mcpEnabled { + claudeCmd.WriteString(" --mcp-config " + mcpConfigContainerPath) + } claudeCmd.WriteString(" --output-format stream-json --verbose --permission-mode bypassPermissions") return []string{"sh", "-c", claudeCmd.String()} @@ -511,6 +593,7 @@ func (r *ContainerRunner) buildInnerCmd(t *task.Task, e *storage.Execution, isRe var scaffoldPrefixes = []string{ ".claudomator-env", ".claudomator-instructions.txt", + ".claudomator-mcp.json", ".agent-home", } diff --git a/internal/executor/container_test.go b/internal/executor/container_test.go index 54b4689..dec666e 100644 --- a/internal/executor/container_test.go +++ b/internal/executor/container_test.go @@ -2,6 +2,7 @@ package executor import ( "context" + "encoding/json" "fmt" "io" "log/slog" @@ -36,6 +37,7 @@ func TestContainerRunner_BuildDockerArgs(t *testing.T) { "-w", "/workspace", "--env-file", "/tmp/ws/.claudomator-env", "-e", "HOME=/home/agent", + "-e", "IS_SANDBOX=1", "-e", "CLAUDOMATOR_API_URL=http://host.docker.internal:8484", "-e", "CLAUDOMATOR_TASK_ID=task-123", "-e", "CLAUDOMATOR_DROP_DIR=/data/drops", @@ -53,13 +55,96 @@ func TestContainerRunner_BuildDockerArgs(t *testing.T) { } } +func TestBuildAgentInstructions(t *testing.T) { + t.Run("mcp enabled prepends preamble", func(t *testing.T) { + tk := &task.Task{Agent: task.AgentConfig{Instructions: "do the thing"}} + got := buildAgentInstructions(tk, true) + if !strings.HasPrefix(got, planningPreamble) || !strings.HasSuffix(got, "do the thing") { + t.Errorf("expected preamble + instructions, got %q", got) + } + }) + t.Run("mcp disabled keeps raw instructions", func(t *testing.T) { + tk := &task.Task{Agent: task.AgentConfig{Instructions: "do the thing"}} + if got := buildAgentInstructions(tk, false); got != "do the thing" { + t.Errorf("expected raw instructions, got %q", got) + } + }) + t.Run("skip planning keeps raw instructions even with mcp", func(t *testing.T) { + tk := &task.Task{Agent: task.AgentConfig{Instructions: "do the thing", SkipPlanning: true}} + if got := buildAgentInstructions(tk, true); got != "do the thing" { + t.Errorf("expected raw instructions when skip_planning, got %q", got) + } + }) +} + +func TestWriteMCPConfig(t *testing.T) { + dir := t.TempDir() + if err := writeMCPConfig(dir, "http://host.docker.internal:8484/mcp", "tok-abc"); err != nil { + t.Fatalf("writeMCPConfig: %v", err) + } + data, err := os.ReadFile(filepath.Join(dir, ".claudomator-mcp.json")) + if err != nil { + t.Fatalf("read config: %v", err) + } + var parsed struct { + MCPServers map[string]struct { + Type string `json:"type"` + URL string `json:"url"` + Headers map[string]string `json:"headers"` + } `json:"mcpServers"` + } + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("config is not valid JSON: %v", err) + } + srv, ok := parsed.MCPServers["claudomator"] + if !ok { + t.Fatal("expected claudomator server entry") + } + if srv.Type != "http" || srv.URL != "http://host.docker.internal:8484/mcp" { + t.Errorf("unexpected server config: %+v", srv) + } + if srv.Headers["Authorization"] != "Bearer tok-abc" { + t.Errorf("expected bearer header, got %q", srv.Headers["Authorization"]) + } +} + +func TestWriteGeminiMCPSettings(t *testing.T) { + agentHome := t.TempDir() + if err := writeGeminiMCPSettings(agentHome, "http://host.docker.internal:8484/mcp", "tok-xyz"); err != nil { + t.Fatalf("writeGeminiMCPSettings: %v", err) + } + data, err := os.ReadFile(filepath.Join(agentHome, ".gemini", "settings.json")) + if err != nil { + t.Fatalf("read settings: %v", err) + } + var parsed struct { + MCPServers map[string]struct { + HTTPURL string `json:"httpUrl"` + Headers map[string]string `json:"headers"` + } `json:"mcpServers"` + } + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("settings is not valid JSON: %v", err) + } + srv, ok := parsed.MCPServers["claudomator"] + if !ok { + t.Fatal("expected claudomator server entry") + } + if srv.HTTPURL != "http://host.docker.internal:8484/mcp" { + t.Errorf("unexpected httpUrl: %q", srv.HTTPURL) + } + if srv.Headers["Authorization"] != "Bearer tok-xyz" { + t.Errorf("expected bearer header, got %q", srv.Headers["Authorization"]) + } +} + func TestContainerRunner_BuildInnerCmd(t *testing.T) { runner := &ContainerRunner{} t.Run("claude-fresh", func(t *testing.T) { tk := &task.Task{Agent: task.AgentConfig{Type: "claude"}} exec := &storage.Execution{} - cmd := runner.buildInnerCmd(tk, exec, false) + cmd := runner.buildInnerCmd(tk, exec, false, false) cmdStr := strings.Join(cmd, " ") if strings.Contains(cmdStr, "--resume") { @@ -73,7 +158,7 @@ func TestContainerRunner_BuildInnerCmd(t *testing.T) { t.Run("claude-resume", func(t *testing.T) { tk := &task.Task{Agent: task.AgentConfig{Type: "claude"}} exec := &storage.Execution{ResumeSessionID: "orig-session-123"} - cmd := runner.buildInnerCmd(tk, exec, true) + cmd := runner.buildInnerCmd(tk, exec, true, false) cmdStr := strings.Join(cmd, " ") if !strings.Contains(cmdStr, "--resume orig-session-123") { @@ -81,10 +166,26 @@ func TestContainerRunner_BuildInnerCmd(t *testing.T) { } }) + t.Run("claude-mcp-enabled", func(t *testing.T) { + tk := &task.Task{Agent: task.AgentConfig{Type: "claude"}} + cmdStr := strings.Join(runner.buildInnerCmd(tk, &storage.Execution{}, false, true), " ") + if !strings.Contains(cmdStr, "--mcp-config "+mcpConfigContainerPath) { + t.Errorf("expected --mcp-config flag when MCP enabled, got %q", cmdStr) + } + }) + + t.Run("claude-mcp-disabled", func(t *testing.T) { + tk := &task.Task{Agent: task.AgentConfig{Type: "claude"}} + cmdStr := strings.Join(runner.buildInnerCmd(tk, &storage.Execution{}, false, false), " ") + if strings.Contains(cmdStr, "--mcp-config") { + t.Errorf("did not expect --mcp-config flag when MCP disabled, got %q", cmdStr) + } + }) + t.Run("gemini", func(t *testing.T) { tk := &task.Task{Agent: task.AgentConfig{Type: "gemini"}} exec := &storage.Execution{} - cmd := runner.buildInnerCmd(tk, exec, false) + cmd := runner.buildInnerCmd(tk, exec, false, false) cmdStr := strings.Join(cmd, " ") if !strings.Contains(cmdStr, "gemini -p \"$INST\"") { @@ -99,13 +200,13 @@ func TestContainerRunner_BuildInnerCmd(t *testing.T) { } tkClaude := &task.Task{Agent: task.AgentConfig{Type: "claude"}} - cmdClaude := runnerCustom.buildInnerCmd(tkClaude, &storage.Execution{}, false) + cmdClaude := runnerCustom.buildInnerCmd(tkClaude, &storage.Execution{}, false, false) if !strings.Contains(strings.Join(cmdClaude, " "), "/usr/bin/claude-v2 -p") { t.Errorf("expected custom claude binary, got %q", cmdClaude) } tkGemini := &task.Task{Agent: task.AgentConfig{Type: "gemini"}} - cmdGemini := runnerCustom.buildInnerCmd(tkGemini, &storage.Execution{}, false) + cmdGemini := runnerCustom.buildInnerCmd(tkGemini, &storage.Execution{}, false, false) if !strings.Contains(strings.Join(cmdGemini, " "), "/usr/local/bin/gemini-pro -p") { t.Errorf("expected custom gemini binary, got %q", cmdGemini) } @@ -139,7 +240,7 @@ func TestContainerRunner_Run_PreservesWorkspaceOnFailure(t *testing.T) { } exec := &storage.Execution{ID: "test-exec", TaskID: "test-task"} - err := runner.Run(context.Background(), tk, exec) + err := runner.Run(context.Background(), tk, exec, newStoreChannel(nil, tk.ID)) if err == nil { t.Fatal("expected error due to mocked docker failure") } @@ -168,48 +269,6 @@ func TestBlockedError_IncludesSandboxDir(t *testing.T) { } } -func TestIsCompletionReport(t *testing.T) { - tests := []struct { - name string - json string - expected bool - }{ - { - name: "real question with options", - json: `{"text": "Should I proceed with implementation?", "options": ["Yes", "No"]}`, - expected: false, - }, - { - name: "real question no options", - json: `{"text": "Which approach do you prefer?"}`, - expected: false, - }, - { - name: "completion report no options no question mark", - json: `{"text": "All tests pass. Implementation complete. Summary written to CLAUDOMATOR_SUMMARY_FILE."}`, - expected: true, - }, - { - name: "completion report with empty options", - json: `{"text": "Feature implemented and committed.", "options": []}`, - expected: true, - }, - { - name: "invalid json treated as not a report", - json: `not json`, - expected: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := isCompletionReport(tt.json) - if got != tt.expected { - t.Errorf("isCompletionReport(%q) = %v, want %v", tt.json, got, tt.expected) - } - }) - } -} - func TestTailFile_ReturnsLastNLines(t *testing.T) { f, err := os.CreateTemp("", "tailfile-*") if err != nil { @@ -378,7 +437,7 @@ func TestContainerRunner_MissingCredentials_FailsFast(t *testing.T) { } e := &storage.Execution{ID: "test-exec", TaskID: "test-missing-creds"} - err := runner.Run(context.Background(), tk, e) + err := runner.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID)) if err == nil { t.Fatal("expected error due to missing credentials, got nil") } @@ -418,7 +477,7 @@ func TestContainerRunner_MissingSettings_FailsFast(t *testing.T) { } e := &storage.Execution{ID: "test-exec-2", TaskID: "test-missing-settings"} - err := runner.Run(context.Background(), tk, e) + err := runner.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID)) if err == nil { t.Fatal("expected error due to missing settings, got nil") } @@ -504,7 +563,7 @@ func TestContainerRunner_AuthError_SyncsAndRetries(t *testing.T) { e := &storage.Execution{ID: "auth-retry-exec", TaskID: "auth-retry-test"} // Run — first attempt will fail with auth error, triggering sync+retry - runner.Run(context.Background(), tk, e) + runner.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID)) // We don't check error strictly since second run may also fail (git push etc.) // What we care about is that docker was called twice and sync was called if callCount < 2 { @@ -550,7 +609,7 @@ func TestContainerRunner_ClonesStoryBranch(t *testing.T) { } e := &storage.Execution{ID: "exec-1", TaskID: "story-branch-test"} - runner.Run(context.Background(), tk, e) + runner.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID)) os.RemoveAll(e.SandboxDir) // Assert git checkout was called with the story branch name. @@ -597,7 +656,7 @@ func TestContainerRunner_ClonesDefaultBranchWhenNoBranchName(t *testing.T) { } e := &storage.Execution{ID: "exec-2", TaskID: "no-branch-test"} - runner.Run(context.Background(), tk, e) + runner.Run(context.Background(), tk, e, newStoreChannel(nil, tk.ID)) os.RemoveAll(e.SandboxDir) for _, a := range cloneArgs { @@ -663,7 +722,7 @@ func TestContainerRunner_ClonesFromLocalPathForCITask(t *testing.T) { Agent: task.AgentConfig{Type: "claude"}, } e := &storage.Execution{ID: "exec-ci-1", TaskID: "ci-task-1"} - runner.Run(context.Background(), tk, e) + runner.Run(context.Background(), tk, e, noopChannel{}) os.RemoveAll(e.SandboxDir) if cloneSrc == "https://github.com/thepeterstone/nav.git" { @@ -752,3 +811,13 @@ func TestEnsureStoryBranch_IdempotentIfExists(t *testing.T) { t.Fatalf("ensureStoryBranch on existing branch: %v", err) } } + +// noopChannel satisfies AgentChannel with no-op implementations for tests. +type noopChannel struct{} + +func (noopChannel) AskUser(_ context.Context, _ string) (string, error) { return "", nil } +func (noopChannel) ReportSummary(_ context.Context, _ string) error { return nil } +func (noopChannel) SpawnSubtask(_ context.Context, _ SubtaskSpec) (string, error) { + return "", nil +} +func (noopChannel) RecordProgress(_ context.Context, _ string) error { return nil } diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 09169bd..8614481 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -12,6 +12,7 @@ import ( "sync" "time" + "github.com/thepeterstone/claudomator/internal/event" "github.com/thepeterstone/claudomator/internal/llm" "github.com/thepeterstone/claudomator/internal/retry" "github.com/thepeterstone/claudomator/internal/storage" @@ -41,6 +42,7 @@ type Store interface { ListTasksByStory(storyID string) ([]*task.Task, error) UpdateStoryStatus(id string, status task.StoryState) error CreateTask(t *task.Task) error + CreateEvent(e *event.Event) error UpdateTaskCheckerReport(id, report string) error GetCheckerTask(checkedTaskID string) (*task.Task, error) } @@ -52,9 +54,11 @@ type LogPather interface { ExecLogDir(execID string) string } -// Runner executes a single task and returns the result. +// Runner executes a single task and returns the result. The AgentChannel is how +// the runner reports agent-originated signals (summary, questions, subtasks, +// progress) back to the system. type Runner interface { - Run(ctx context.Context, t *task.Task, exec *storage.Execution) error + Run(ctx context.Context, t *task.Task, exec *storage.Execution, ch AgentChannel) error } // workItem is an entry in the pool's internal work queue. @@ -89,6 +93,14 @@ type Pool struct { dispatchDone chan struct{} // closed when the dispatch goroutine exits Classifier *Classifier LLM *llm.Client + // Budget gates paid providers against a rolling window; nil disables gating. + Budget BudgetGate +} + +// BudgetGate reports whether a task estimated at estCost may run on a provider +// without breaching its rolling spend window. Satisfied by *budget.Accountant. +type BudgetGate interface { + Allow(provider string, estCost float64) (bool, error) } // Result is emitted when a task execution completes. @@ -352,8 +364,12 @@ func (p *Pool) executeResume(ctx context.Context, t *task.Task, exec *storage.Ex } } - err = runner.Run(ctx, t, exec) + sc := newStoreChannel(p.store, t.ID) + err = runner.Run(ctx, t, exec, sc) exec.EndTime = time.Now().UTC() + if sum, ok := sc.ReportedSummary(); ok { + exec.Summary = sum + } p.decActiveAgent(agentType, &cleaned) p.handleRunResult(ctx, t, exec, err, agentType) @@ -929,6 +945,44 @@ func (p *Pool) execute(ctx context.Context, t *task.Task) { agentType = "claude" } + // Budget gating: if running on this provider could breach its rolling window + // (estimated by the task's max_budget_usd), reroute to the free local runner + // when one is registered, otherwise stop and mark the task BUDGET_EXCEEDED. + if p.Budget != nil { + if ok, berr := p.Budget.Allow(agentType, t.Agent.MaxBudgetUSD); berr != nil { + p.logger.Warn("budget check failed; proceeding", "error", berr, "taskID", t.ID) + } else if !ok { + if _, hasLocal := p.runners["local"]; hasLocal && agentType != "local" { + p.logger.Info("budget window would be breached; rerouting to local", "taskID", t.ID, "from", agentType) + t.Agent.Type = "local" + t.Agent.Model = "" + agentType = "local" + if uerr := p.store.UpdateTaskAgent(t.ID, t.Agent); uerr != nil { + p.logger.Error("failed to persist rerouted agent", "error", uerr, "taskID", t.ID) + } + } else { + now := time.Now().UTC() + exec := &storage.Execution{ + ID: uuid.New().String(), + TaskID: t.ID, + StartTime: now, + EndTime: now, + Status: "BUDGET_EXCEEDED", + Agent: agentType, + ErrorMsg: fmt.Sprintf("rolling budget window cap reached for provider %q", agentType), + } + if createErr := p.store.CreateExecution(exec); createErr != nil { + p.logger.Error("failed to create execution record", "error", createErr) + } + if err := p.store.UpdateTaskState(t.ID, task.StateBudgetExceeded); err != nil { + p.logger.Error("failed to update task state", "taskID", t.ID, "state", task.StateBudgetExceeded, "error", err) + } + p.resultCh <- &Result{TaskID: t.ID, Execution: exec, Err: fmt.Errorf("budget cap reached for %s", agentType)} + return + } + } + } + // Check dependencies before taking the per-agent slot to avoid deadlock: // if a dependent task holds the slot while waiting for its dependency to run, // the dependency can never start (maxPerAgent=1). @@ -1017,6 +1071,7 @@ func (p *Pool) execute(ctx context.Context, t *task.Task) { TaskID: t.ID, StartTime: time.Now().UTC(), Status: "RUNNING", + Agent: agentType, } // Pre-populate log paths so they're available in the DB immediately — @@ -1073,8 +1128,12 @@ func (p *Pool) execute(ctx context.Context, t *task.Task) { } // Run the task. - err = runner.Run(ctx, t, exec) + sc := newStoreChannel(p.store, t.ID) + err = runner.Run(ctx, t, exec, sc) exec.EndTime = time.Now().UTC() + if sum, ok := sc.ReportedSummary(); ok { + exec.Summary = sum + } p.decActiveAgent(agentType, &cleaned) p.handleRunResult(ctx, t, exec, err, agentType) diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go index 9214872..b737e22 100644 --- a/internal/executor/executor_test.go +++ b/internal/executor/executor_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "github.com/thepeterstone/claudomator/internal/event" "github.com/thepeterstone/claudomator/internal/storage" "github.com/thepeterstone/claudomator/internal/task" ) @@ -63,7 +64,7 @@ type mockRunner struct { onRun func(*task.Task, *storage.Execution) error } -func (m *mockRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution) error { +func (m *mockRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution, _ AgentChannel) error { m.mu.Lock() m.calls++ cb := m.onRun @@ -216,6 +217,63 @@ func TestPool_Submit_Subtask_GoesToCompleted(t *testing.T) { } } +type fakeGate struct{ deny map[string]bool } + +func (g fakeGate) Allow(provider string, _ float64) (bool, error) { return !g.deny[provider], nil } + +func TestPool_BudgetGate_BlocksWhenNoLocalFallback(t *testing.T) { + store := testStore(t) + runner := &mockRunner{} + pool := NewPool(2, map[string]Runner{"claude": runner}, store, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))) + pool.Budget = fakeGate{deny: map[string]bool{"claude": true}} + + tk := makeTask("bg-1") + store.CreateTask(tk) + if err := pool.Submit(context.Background(), tk); err != nil { + t.Fatalf("submit: %v", err) + } + + result := <-pool.Results() + if result.Execution.Status != "BUDGET_EXCEEDED" { + t.Errorf("status: want BUDGET_EXCEEDED, got %q", result.Execution.Status) + } + if runner.callCount() != 0 { + t.Errorf("claude runner should not have run, got %d calls", runner.callCount()) + } + got, _ := store.GetTask("bg-1") + if got.State != task.StateBudgetExceeded { + t.Errorf("task state: want BUDGET_EXCEEDED, got %v", got.State) + } +} + +func TestPool_BudgetGate_ReroutesToLocal(t *testing.T) { + store := testStore(t) + claudeRunner := &mockRunner{} + localRunner := &mockRunner{} + pool := NewPool(2, map[string]Runner{"claude": claudeRunner, "local": localRunner}, store, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))) + pool.Budget = fakeGate{deny: map[string]bool{"claude": true}} // local is allowed + + tk := makeTask("bg-2") + store.CreateTask(tk) + if err := pool.Submit(context.Background(), tk); err != nil { + t.Fatalf("submit: %v", err) + } + + result := <-pool.Results() + if result.Err != nil { + t.Errorf("expected success after reroute, got %v", result.Err) + } + if localRunner.callCount() != 1 { + t.Errorf("local runner should have run once, got %d", localRunner.callCount()) + } + if claudeRunner.callCount() != 0 { + t.Errorf("claude runner should not have run, got %d", claudeRunner.callCount()) + } + if result.Execution.Agent != "local" { + t.Errorf("execution agent: want local, got %q", result.Execution.Agent) + } +} + func TestPool_Submit_Failure(t *testing.T) { store := testStore(t) runner := &mockRunner{err: fmt.Errorf("boom"), exitCode: 1} @@ -391,11 +449,11 @@ func (m *logPatherMockRunner) ExecLogDir(execID string) string { return filepath.Join(m.logDir, execID) } -func (m *logPatherMockRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution) error { +func (m *logPatherMockRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution, ch AgentChannel) error { m.mu.Lock() m.capturedPath = e.StdoutPath m.mu.Unlock() - return m.mockRunner.Run(ctx, t, e) + return m.mockRunner.Run(ctx, t, e, ch) } // TestPool_Execute_LogPathsPreSetBeforeRun verifies that when the runner @@ -1194,6 +1252,7 @@ func (m *minimalMockStore) GetStory(_ string) (*task.Story, error) func (m *minimalMockStore) ListTasksByStory(_ string) ([]*task.Task, error) { return nil, nil } func (m *minimalMockStore) UpdateStoryStatus(_ string, _ task.StoryState) error { return nil } func (m *minimalMockStore) CreateTask(_ *task.Task) error { return nil } +func (m *minimalMockStore) CreateEvent(_ *event.Event) error { return nil } func (m *minimalMockStore) UpdateTaskCheckerReport(_ string, _ string) error { return nil } func (m *minimalMockStore) GetCheckerTask(_ string) (*task.Task, error) { return nil, nil } diff --git a/internal/executor/gemini.go b/internal/executor/gemini.go deleted file mode 100644 index 3abec05..0000000 --- a/internal/executor/gemini.go +++ /dev/null @@ -1,346 +0,0 @@ -package executor - -import ( - "context" - "encoding/json" - "fmt" - "io" - "log/slog" - "os" - "os/exec" - "path/filepath" - "strings" - "sync" - "syscall" - - "github.com/thepeterstone/claudomator/internal/storage" - "github.com/thepeterstone/claudomator/internal/task" -) - -// GeminiRunner spawns the `gemini` CLI in non-interactive mode. -type GeminiRunner struct { - BinaryPath string // defaults to "gemini" - Logger *slog.Logger - LogDir string // base directory for execution logs - APIURL string // base URL of the Claudomator API, passed to subprocesses -} - -// ExecLogDir returns the log directory for the given execution ID. -func (r *GeminiRunner) ExecLogDir(execID string) string { - if r.LogDir == "" { - return "" - } - return filepath.Join(r.LogDir, execID) -} - -func (r *GeminiRunner) binaryPath() string { - if r.BinaryPath != "" { - return r.BinaryPath - } - return "gemini" -} - -// Run executes the gemini CLI inside a sandboxed clone of project_dir. -// When project_dir is set, claudomator first clones it into a temp sandbox -// (preferring a `local` bare remote, then `origin`, then the working tree) -// and runs the agent there. On success the sandbox is autocommitted and -// pushed back to origin/master, then removed. On failure the sandbox is -// preserved and its path is included in the returned error so the user can -// inspect partial work. If the agent writes a question file before exiting, -// Run returns *BlockedError with SandboxDir populated so a resume execution -// can pick up in the same directory. -func (r *GeminiRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution) error { - projectDir := t.Agent.ProjectDir - - if projectDir != "" { - if _, err := os.Stat(projectDir); err != nil { - return fmt.Errorf("project_dir %q: %w", projectDir, err) - } - } - - logDir := r.ExecLogDir(e.ID) - if logDir == "" { - logDir = e.ID - } - if err := os.MkdirAll(logDir, 0700); err != nil { - return fmt.Errorf("creating log dir: %w", err) - } - - if e.StdoutPath == "" { - e.StdoutPath = filepath.Join(logDir, "stdout.log") - e.StderrPath = filepath.Join(logDir, "stderr.log") - e.ArtifactDir = logDir - } - - if e.SessionID == "" { - if e.ResumeSessionID != "" { - e.SessionID = e.ResumeSessionID - } else { - e.SessionID = e.ID - } - } - - // Sandbox setup: for new executions with a project_dir, clone into a sandbox. - // Resume executions reuse the preserved sandbox so any partial work survives. - // If the preserved sandbox is missing (e.g. /tmp was purged), clone fresh. - var sandboxDir string - var startHEAD string - effectiveWorkingDir := projectDir - if e.ResumeSessionID != "" { - if e.SandboxDir != "" { - if _, statErr := os.Stat(e.SandboxDir); statErr == nil { - effectiveWorkingDir = e.SandboxDir - } else { - r.Logger.Warn("preserved sandbox missing, cloning fresh", "sandbox", e.SandboxDir, "project_dir", projectDir) - e.SandboxDir = "" - if projectDir != "" { - var err error - sandboxDir, err = setupSandbox(projectDir, r.Logger) - if err != nil { - return fmt.Errorf("setting up sandbox: %w", err) - } - effectiveWorkingDir = sandboxDir - r.Logger.Info("fresh sandbox created for resume", "sandbox", sandboxDir, "project_dir", projectDir) - } - } - } - } else if projectDir != "" { - var err error - sandboxDir, err = setupSandbox(projectDir, r.Logger) - if err != nil { - return fmt.Errorf("setting up sandbox: %w", err) - } - effectiveWorkingDir = sandboxDir - r.Logger.Info("sandbox created", "sandbox", sandboxDir, "project_dir", projectDir) - } - - if effectiveWorkingDir != "" { - headOut, _ := exec.Command("git", gitSafe("-C", effectiveWorkingDir, "rev-parse", "HEAD")...).Output() - startHEAD = strings.TrimSpace(string(headOut)) - } - - questionFile := filepath.Join(logDir, "question.json") - args := r.buildArgs(t, e, questionFile) - - if err := r.execOnce(ctx, args, effectiveWorkingDir, projectDir, e); err != nil { - if sandboxDir != "" { - return fmt.Errorf("%w (sandbox preserved at %s)", err, sandboxDir) - } - return err - } - - // Check whether the agent left a question before exiting. - data, readErr := os.ReadFile(questionFile) - if readErr == nil { - os.Remove(questionFile) - questionJSON := strings.TrimSpace(string(data)) - if isCompletionReport(questionJSON) { - r.Logger.Info("treating question file as completion report", "taskID", e.TaskID) - e.Summary = extractQuestionText(questionJSON) - } else { - // Preserve sandbox on BLOCKED so a resume can pick up in the same dir. - return &BlockedError{QuestionJSON: questionJSON, SessionID: e.SessionID, SandboxDir: sandboxDir} - } - } - - // Read agent summary if written. - summaryFile := filepath.Join(logDir, "summary.txt") - if summaryData, readErr := os.ReadFile(summaryFile); readErr == nil { - os.Remove(summaryFile) - e.Summary = strings.TrimSpace(string(summaryData)) - } - - // Merge sandbox back to project_dir and clean up. - if sandboxDir != "" { - if mergeErr := teardownSandbox(projectDir, sandboxDir, startHEAD, r.Logger, e); mergeErr != nil { - return fmt.Errorf("sandbox teardown: %w (sandbox preserved at %s)", mergeErr, sandboxDir) - } - } - return nil -} - -func (r *GeminiRunner) execOnce(ctx context.Context, args []string, workingDir, projectDir string, e *storage.Execution) error { - cmd := exec.CommandContext(ctx, r.binaryPath(), args...) - cmd.Env = append(os.Environ(), - "CLAUDOMATOR_API_URL="+r.APIURL, - "CLAUDOMATOR_TASK_ID="+e.TaskID, - "CLAUDOMATOR_PROJECT_DIR="+projectDir, - "CLAUDOMATOR_QUESTION_FILE="+filepath.Join(e.ArtifactDir, "question.json"), - "CLAUDOMATOR_SUMMARY_FILE="+filepath.Join(e.ArtifactDir, "summary.txt"), - ) - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - if workingDir != "" { - cmd.Dir = workingDir - } - - stdoutFile, err := os.Create(e.StdoutPath) - if err != nil { - return fmt.Errorf("creating stdout log: %w", err) - } - defer stdoutFile.Close() - - stderrFile, err := os.Create(e.StderrPath) - if err != nil { - return fmt.Errorf("creating stderr log: %w", err) - } - defer stderrFile.Close() - - stdoutR, stdoutW, err := os.Pipe() - if err != nil { - return fmt.Errorf("creating stdout pipe: %w", err) - } - cmd.Stdout = stdoutW - cmd.Stderr = stderrFile - - if err := cmd.Start(); err != nil { - stdoutW.Close() - stdoutR.Close() - return fmt.Errorf("starting gemini: %w", err) - } - stdoutW.Close() - - killDone := make(chan struct{}) - go func() { - select { - case <-ctx.Done(): - syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) - case <-killDone: - } - }() - - var streamCost float64 - var streamErr error - var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() - streamCost, streamErr = parseGeminiStream(stdoutR, stdoutFile, r.Logger) - stdoutR.Close() - }() - - waitErr := cmd.Wait() - close(killDone) - wg.Wait() - - if streamCost > 0 { - e.CostUSD = streamCost - } - - if waitErr != nil { - if exitErr, ok := waitErr.(*exec.ExitError); ok { - e.ExitCode = exitErr.ExitCode() - } - if streamErr != nil { - return streamErr - } - if tail := tailFile(e.StderrPath, 20); tail != "" { - return fmt.Errorf("gemini exited with error: %w\nstderr:\n%s", waitErr, tail) - } - return fmt.Errorf("gemini exited with error: %w", waitErr) - } - - if streamErr != nil { - return streamErr - } - return nil -} - -// parseGeminiStream reads streaming JSON from the gemini CLI, strips markdown -// code fences if the output is wrapped in them, writes the inner stream-json -// to w, and returns (costUSD, error). If a `result` event has `is_error: true`, -// an error wrapping the result message is returned. -func parseGeminiStream(r io.Reader, w io.Writer, logger *slog.Logger) (float64, error) { - fullOutput, err := io.ReadAll(r) - if err != nil { - return 0, fmt.Errorf("reading full gemini output: %w", err) - } - logger.Debug("parseGeminiStream: raw output received", "output", string(fullOutput)) - - inner := stripGeminiFences(string(fullOutput), logger) - if _, writeErr := w.Write([]byte(inner)); writeErr != nil { - return 0, fmt.Errorf("writing gemini output: %w", writeErr) - } - - // Walk lines looking for a result event so we can surface errors and cost. - var ( - cost float64 - errMsg string - isError bool - ) - for _, raw := range strings.Split(inner, "\n") { - line := strings.TrimSpace(raw) - if line == "" { - continue - } - var evt struct { - Type string `json:"type"` - IsError bool `json:"is_error"` - Result string `json:"result"` - Cost float64 `json:"total_cost_usd"` - } - if err := json.Unmarshal([]byte(line), &evt); err != nil { - continue - } - if evt.Type == "result" { - if evt.Cost > 0 { - cost = evt.Cost - } - if evt.IsError { - isError = true - errMsg = evt.Result - } - } - } - if isError { - return cost, fmt.Errorf("gemini reported error: %s", errMsg) - } - return cost, nil -} - -// stripGeminiFences removes a surrounding ```json ... ``` markdown block if -// present, returning the trimmed inner content. If no markdown fence is -// found, the input is returned verbatim (no whitespace trimming) so callers -// that expect byte-exact pass-through behavior get it. -func stripGeminiFences(raw string, logger *slog.Logger) string { - trimmed := strings.TrimSpace(raw) - if start := strings.Index(trimmed, "```json"); start != -1 { - if end := strings.LastIndex(trimmed, "```"); end > start { - return strings.TrimSpace(trimmed[start+len("```json") : end]) - } - logger.Warn("malformed gemini markdown block (missing closing fence); using raw output", "len", len(trimmed)) - return trimmed - } - return raw -} - -func (r *GeminiRunner) buildArgs(t *task.Task, e *storage.Execution, questionFile string) []string { - // Gemini CLI uses a different command structure: gemini "instructions" [flags] - - instructions := t.Agent.Instructions - if !t.Agent.SkipPlanning { - instructions = withPlanningPreamble(instructions) - } - - args := []string{ - "-p", instructions, - "--output-format", "stream-json", - "--yolo", // auto-approve all tools (equivalent to Claude's bypassPermissions) - } - - // Note: Gemini CLI flags might differ from Claude CLI. - // Assuming common flags for now, but these may need adjustment. - if t.Agent.Model != "" { - args = append(args, "--model", t.Agent.Model) - } - - // Gemini CLI doesn't use --session-id for the first run in the same way, - // or it might use it differently. For now we assume compatibility. - if e.SessionID != "" { - // If it's a resume, it might use different flags. - if e.ResumeSessionID != "" { - // This is a placeholder for Gemini's resume logic - } - } - - return args -} diff --git a/internal/executor/gemini_test.go b/internal/executor/gemini_test.go deleted file mode 100644 index cd11ebc..0000000 --- a/internal/executor/gemini_test.go +++ /dev/null @@ -1,447 +0,0 @@ -package executor - -import ( - "bytes" - "context" - "errors" - "io" - "log/slog" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/thepeterstone/claudomator/internal/storage" - "github.com/thepeterstone/claudomator/internal/task" -) - -func TestGeminiRunner_BuildArgs_BasicTask(t *testing.T) { - r := &GeminiRunner{} - tk := &task.Task{ - Agent: task.AgentConfig{ - Type: "gemini", - Instructions: "fix the bug", - Model: "gemini-2.5-flash-lite", - SkipPlanning: true, - }, - } - - args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json") - - // Gemini CLI: instructions passed via -p for non-interactive mode - if len(args) < 2 || args[0] != "-p" || args[1] != "fix the bug" { - t.Errorf("expected -p <instructions> as first args, got: %v", args) - } - - argMap := make(map[string]bool) - for _, a := range args { - argMap[a] = true - } - for _, want := range []string{"--output-format", "stream-json", "--model", "gemini-2.5-flash-lite"} { - if !argMap[want] { - t.Errorf("missing arg %q in %v", want, args) - } - } -} - -func TestGeminiRunner_BuildArgs_PreamblePrepended(t *testing.T) { - r := &GeminiRunner{} - tk := &task.Task{ - Agent: task.AgentConfig{ - Type: "gemini", - Instructions: "fix the bug", - SkipPlanning: false, - }, - } - - args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json") - - if len(args) < 2 || args[0] != "-p" { - t.Fatalf("expected -p <instructions> as first args, got: %v", args) - } - if !strings.HasPrefix(args[1], planningPreamble) { - t.Errorf("instructions should start with planning preamble") - } - if !strings.HasSuffix(args[1], "fix the bug") { - t.Errorf("instructions should end with original instructions") - } -} - -func TestGeminiRunner_BuildArgs_IncludesYolo(t *testing.T) { - r := &GeminiRunner{} - tk := &task.Task{ - Agent: task.AgentConfig{ - Type: "gemini", - Instructions: "write a doc", - SkipPlanning: true, - }, - } - args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json") - argMap := make(map[string]bool) - for _, a := range args { - argMap[a] = true - } - if !argMap["--yolo"] { - t.Errorf("expected --yolo in gemini args (enables all tools); got: %v", args) - } -} - -func TestGeminiRunner_BuildArgs_IncludesPromptFlag(t *testing.T) { - r := &GeminiRunner{} - tk := &task.Task{ - Agent: task.AgentConfig{ - Type: "gemini", - Instructions: "do the thing", - SkipPlanning: true, - }, - } - args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json") - // Instructions must be passed via -p/--prompt for non-interactive headless mode, - // not as a bare positional (which starts interactive mode). - found := false - for i, a := range args { - if (a == "-p" || a == "--prompt") && i+1 < len(args) && args[i+1] == "do the thing" { - found = true - break - } - } - if !found { - t.Errorf("expected instructions passed via -p/--prompt flag; got: %v", args) - } -} - -func TestGeminiRunner_Run_InaccessibleProjectDir_ReturnsError(t *testing.T) { - r := &GeminiRunner{ - BinaryPath: "true", // would succeed if it ran - Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), - LogDir: t.TempDir(), - } - tk := &task.Task{ - Agent: task.AgentConfig{ - Type: "gemini", - ProjectDir: "/nonexistent/path/does/not/exist", - SkipPlanning: true, - }, - } - exec := &storage.Execution{ID: "test-exec"} - - err := r.Run(context.Background(), tk, exec) - - if err == nil { - t.Fatal("expected error for inaccessible project_dir, got nil") - } - if !strings.Contains(err.Error(), "project_dir") { - t.Errorf("expected 'project_dir' in error, got: %v", err) - } -} - -func TestGeminiRunner_BinaryPath_Default(t *testing.T) { - r := &GeminiRunner{} - if r.binaryPath() != "gemini" { - t.Errorf("want 'gemini', got %q", r.binaryPath()) - } -} - -func TestGeminiRunner_BinaryPath_Custom(t *testing.T) { - r := &GeminiRunner{BinaryPath: "/usr/local/bin/gemini"} - if r.binaryPath() != "/usr/local/bin/gemini" { - t.Errorf("want custom path, got %q", r.binaryPath()) - } -} - - -func TestParseGeminiStream_ParsesStructuredOutput(t *testing.T) { - // Simulate a stream-json input with various message types, including a result with error and cost. - input := streamLine(`{"type":"content_block_start","content_block":{"text":"Hello,"}}`) + - streamLine(`{"type":"content_block_delta","content_block":{"text":" World!"}}`) + - streamLine(`{"type":"content_block_end"}`) + - streamLine(`{"type":"result","subtype":"error_during_execution","is_error":true,"result":"something went wrong","total_cost_usd":0.123}`) - - reader := strings.NewReader(input) - var writer bytes.Buffer // To capture what's written to the output log - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - - cost, err := parseGeminiStream(reader, &writer, logger) - - if err == nil { - t.Errorf("expected an error, got nil") - } - if !strings.Contains(err.Error(), "something went wrong") { - t.Errorf("expected error message to contain 'something went wrong', got: %v", err) - } - - if cost != 0.123 { - t.Errorf("expected cost 0.123, got %f", cost) - } - - // Verify that the writer received the content (even if parseGeminiStream isn't fully parsing it yet) - expectedWriterContent := input - if writer.String() != expectedWriterContent { - t.Errorf("writer content mismatch:\nwant:\n%s\ngot:\n%s", expectedWriterContent, writer.String()) - } -} - -// TestGeminiRunner_Run_ProjectDir_RunsInSandbox verifies that when project_dir -// is set, the gemini subprocess runs inside a sandbox clone — not in -// project_dir itself. -func TestGeminiRunner_Run_ProjectDir_RunsInSandbox(t *testing.T) { - projectDir := t.TempDir() - initGitRepo(t, projectDir) - - logDir := t.TempDir() - cwdFile := filepath.Join(logDir, "gemini-cwd.txt") - - // Fake gemini binary that records its $PWD then exits 0. - scriptPath := filepath.Join(t.TempDir(), "fake-gemini.sh") - script := "#!/bin/sh\nprintf '%s' \"$PWD\" > " + cwdFile + "\n" - if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { - t.Fatalf("write script: %v", err) - } - - r := &GeminiRunner{ - BinaryPath: scriptPath, - Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), - LogDir: logDir, - } - tk := &task.Task{ - Agent: task.AgentConfig{ - Type: "gemini", - Instructions: "do work", - ProjectDir: projectDir, - SkipPlanning: true, - }, - } - e := &storage.Execution{ID: "sandbox-exec", TaskID: "task-1"} - - if err := r.Run(context.Background(), tk, e); err != nil { - t.Fatalf("Run: %v", err) - } - - got, err := os.ReadFile(cwdFile) - if err != nil { - t.Fatalf("cwd file not written: %v", err) - } - cwd := string(got) - if cwd == projectDir { - t.Errorf("ran directly in project_dir; expected sandbox clone (cwd=%q)", cwd) - } - // Sandbox should be removed after successful teardown (no edits → nothing to push). - // We can't assert the exact dir, but it should not be projectDir. -} - -// TestGeminiRunner_Run_BlockedError_IncludesSandboxDir verifies that when the -// agent writes a question file before exiting, the BlockedError carries the -// sandbox path so resume runs in the same dir. -func TestGeminiRunner_Run_BlockedError_IncludesSandboxDir(t *testing.T) { - src := t.TempDir() - initGitRepo(t, src) - logDir := t.TempDir() - - scriptPath := filepath.Join(t.TempDir(), "fake-gemini.sh") - if err := os.WriteFile(scriptPath, []byte(`#!/bin/sh -if [ -n "$CLAUDOMATOR_QUESTION_FILE" ]; then - printf '{"text":"Should I continue?"}' > "$CLAUDOMATOR_QUESTION_FILE" -fi -`), 0755); err != nil { - t.Fatalf("write script: %v", err) - } - - r := &GeminiRunner{ - BinaryPath: scriptPath, - Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), - LogDir: logDir, - } - tk := &task.Task{ - Agent: task.AgentConfig{ - Type: "gemini", - Instructions: "do something", - ProjectDir: src, - SkipPlanning: true, - }, - } - e := &storage.Execution{ID: "blocked-gemini-exec", TaskID: "task-1"} - - err := r.Run(context.Background(), tk, e) - - var blocked *BlockedError - if !errors.As(err, &blocked) { - t.Fatalf("expected BlockedError, got: %v", err) - } - if blocked.SandboxDir == "" { - t.Error("BlockedError.SandboxDir should be set when gemini task runs in a sandbox") - } - if _, statErr := os.Stat(blocked.SandboxDir); os.IsNotExist(statErr) { - t.Error("sandbox directory should be preserved when blocked") - } else { - os.RemoveAll(blocked.SandboxDir) - } -} - -// TestGeminiRunner_Run_ExecError_PreservesSandbox verifies that when gemini -// exits non-zero, the sandbox path is included in the wrapped error so the -// user can inspect partial work. -func TestGeminiRunner_Run_ExecError_PreservesSandbox(t *testing.T) { - src := t.TempDir() - initGitRepo(t, src) - logDir := t.TempDir() - - // "false" exits 1, no output. - r := &GeminiRunner{ - BinaryPath: "false", - Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), - LogDir: logDir, - } - tk := &task.Task{ - Agent: task.AgentConfig{ - Type: "gemini", - Instructions: "do something", - ProjectDir: src, - SkipPlanning: true, - }, - } - e := &storage.Execution{ID: "err-gemini-exec", TaskID: "task-1"} - - err := r.Run(context.Background(), tk, e) - if err == nil { - t.Fatal("expected error from failing gemini exit") - } - if !strings.Contains(err.Error(), "sandbox preserved at ") { - t.Errorf("expected error to include sandbox path; got: %v", err) - } - // Extract path and verify it exists. - idx := strings.Index(err.Error(), "sandbox preserved at ") - rest := err.Error()[idx+len("sandbox preserved at "):] - rest = strings.TrimSuffix(rest, ")") - rest = strings.TrimSpace(rest) - if _, statErr := os.Stat(rest); os.IsNotExist(statErr) { - t.Errorf("sandbox path from error should exist on disk: %q", rest) - } else { - os.RemoveAll(rest) - } -} - -// TestGeminiRunner_Run_ResumeUsesStoredSandboxDir verifies that a resume -// execution runs in the preserved SandboxDir rather than cloning fresh. -func TestGeminiRunner_Run_ResumeUsesStoredSandboxDir(t *testing.T) { - logDir := t.TempDir() - sandboxDir := t.TempDir() - initGitRepo(t, sandboxDir) - cwdFile := filepath.Join(logDir, "cwd.txt") - - scriptPath := filepath.Join(t.TempDir(), "fake-gemini.sh") - script := "#!/bin/sh\nprintf '%s' \"$PWD\" > " + cwdFile + "\n" - if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { - t.Fatalf("write script: %v", err) - } - - r := &GeminiRunner{ - BinaryPath: scriptPath, - Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), - LogDir: logDir, - } - tk := &task.Task{ - Agent: task.AgentConfig{ - Type: "gemini", - SkipPlanning: true, - }, - } - e := &storage.Execution{ - ID: "resume-gemini-1", - TaskID: "task-resume", - ResumeSessionID: "session-abc", - SandboxDir: sandboxDir, - } - - if err := r.Run(context.Background(), tk, e); err != nil { - t.Fatalf("Run with preserved sandbox: %v", err) - } - - got, err := os.ReadFile(cwdFile) - if err != nil { - t.Fatalf("cwd file not written: %v", err) - } - if string(got) != sandboxDir { - t.Errorf("resume should run in preserved sandbox; got cwd=%q want %q", got, sandboxDir) - } -} - -// TestGeminiRunner_Run_StaleSandboxDir_ClonesAfresh verifies that a resume -// pointing at a missing sandbox falls back to cloning a fresh sandbox from -// project_dir rather than failing outright. -func TestGeminiRunner_Run_StaleSandboxDir_ClonesAfresh(t *testing.T) { - logDir := t.TempDir() - projectDir := t.TempDir() - initGitRepo(t, projectDir) - - cwdFile := filepath.Join(logDir, "cwd.txt") - scriptPath := filepath.Join(t.TempDir(), "fake-gemini.sh") - script := "#!/bin/sh\nprintf '%s' \"$PWD\" > " + cwdFile + "\n" - if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { - t.Fatalf("write script: %v", err) - } - - r := &GeminiRunner{ - BinaryPath: scriptPath, - Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), - LogDir: logDir, - } - tk := &task.Task{ - Agent: task.AgentConfig{ - Type: "gemini", - ProjectDir: projectDir, - SkipPlanning: true, - }, - } - staleSandbox := filepath.Join(t.TempDir(), "gone") - e := &storage.Execution{ - ID: "resume-gemini-2", - TaskID: "task-stale", - ResumeSessionID: "session-xyz", - SandboxDir: staleSandbox, - } - - if err := r.Run(context.Background(), tk, e); err != nil { - t.Fatalf("Run with stale sandbox: %v", err) - } - - got, err := os.ReadFile(cwdFile) - if err != nil { - t.Fatalf("cwd file not written: %v", err) - } - cwd := string(got) - if cwd == staleSandbox { - t.Error("ran in stale (nonexistent) sandbox dir") - } - if cwd == projectDir { - t.Error("ran directly in project_dir; expected a fresh sandbox clone") - } -} - -// TestGeminiRunner_Run_NoProjectDir_SkipsSandbox verifies that a task with no -// project_dir doesn't trigger sandbox setup (matches LocalRunner/non-coding -// task semantics). -func TestGeminiRunner_Run_NoProjectDir_SkipsSandbox(t *testing.T) { - logDir := t.TempDir() - - r := &GeminiRunner{ - BinaryPath: "true", // exits 0, no output - Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), - LogDir: logDir, - } - tk := &task.Task{ - Agent: task.AgentConfig{ - Type: "gemini", - Instructions: "summarize: 2+2", - SkipPlanning: true, - // No ProjectDir - }, - } - e := &storage.Execution{ID: "no-pd-gemini", TaskID: "task-nopd"} - - if err := r.Run(context.Background(), tk, e); err != nil { - t.Fatalf("Run without project_dir: %v", err) - } - if e.SandboxDir != "" { - t.Errorf("SandboxDir should be empty for tasks without project_dir, got %q", e.SandboxDir) - } -} diff --git a/internal/executor/helpers.go b/internal/executor/helpers.go index 76bf8b1..9517492 100644 --- a/internal/executor/helpers.go +++ b/internal/executor/helpers.go @@ -177,29 +177,3 @@ func gitSafe(args ...string) []string { "-c", "tag.gpgsign=false", }, args...) } - -// isCompletionReport returns true when a question-file JSON looks like a -// completion report rather than a real user question. Heuristic: no options -// (or empty options) and no "?" anywhere in the text. -func isCompletionReport(questionJSON string) bool { - var q struct { - Text string `json:"text"` - Options []string `json:"options"` - } - if err := json.Unmarshal([]byte(questionJSON), &q); err != nil { - return false - } - return len(q.Options) == 0 && !strings.Contains(q.Text, "?") -} - -// extractQuestionText returns the "text" field from a question-file JSON, or -// the raw string if parsing fails. -func extractQuestionText(questionJSON string) string { - var q struct { - Text string `json:"text"` - } - if err := json.Unmarshal([]byte(questionJSON), &q); err != nil { - return questionJSON - } - return strings.TrimSpace(q.Text) -} diff --git a/internal/executor/local.go b/internal/executor/local.go index 5d874c6..3f20fe7 100644 --- a/internal/executor/local.go +++ b/internal/executor/local.go @@ -37,10 +37,17 @@ func (r *LocalRunner) ExecLogDir(execID string) string { return filepath.Join(r.LogDir, execID) } -// Run streams a chat completion to stdout.log. The response is wrapped in -// stream-json envelopes line-by-line so downstream parsers (summary, -// changestats) read it the same way they read Claude output. -func (r *LocalRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution) error { +// maxLocalToolTurns bounds the tool-use loop so a misbehaving model cannot spin +// forever calling tools without finishing. +const maxLocalToolTurns = 12 + +// Run drives a chat completion against the local endpoint with the agent +// back-channel tools (ask_user/report_summary/spawn_subtask/record_progress) +// declared. It loops, feeding tool results back as message history, until the +// model stops calling tools. Assistant text is written to stdout.log in the +// same stream-json envelope Claude uses so downstream parsers keep working. +// If the model calls ask_user, the run stops and returns a *BlockedError. +func (r *LocalRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution, ch AgentChannel) error { if r.Client == nil { return fmt.Errorf("local runner: no LLM client configured") } @@ -70,56 +77,95 @@ func (r *LocalRunner) Run(ctx context.Context, t *task.Task, e *storage.Executio if sys := strings.TrimSpace(t.Agent.SystemPromptAppend); sys != "" { messages = append(messages, llm.Message{Role: "system", Content: sys}) } - messages = append(messages, llm.Message{Role: "user", Content: t.Agent.Instructions}) + // The agent tools are real here, so guide the model toward them with the + // same planning preamble the container runners use (unless skip_planning). + messages = append(messages, llm.Message{Role: "user", Content: buildAgentInstructions(t, true)}) temperature := t.Agent.Temperature if temperature == nil && r.DefaultTemperature > 0 { v := r.DefaultTemperature temperature = &v } - - req := llm.ChatRequest{ - Model: t.Agent.Model, - Messages: messages, - Temperature: temperature, - MaxTokens: t.Agent.MaxTokens, - } + tools := agentToolDefs() start := time.Now() - resp, err := r.Client.ChatStream(ctx, req, func(delta string) { - if delta == "" { - return + var totalIn, totalOut int + var lastModel, lastFinish string + blocked := false + + for turn := 0; turn < maxLocalToolTurns; turn++ { + resp, chatErr := r.Client.Chat(ctx, llm.ChatRequest{ + Model: t.Agent.Model, + Messages: messages, + Temperature: temperature, + MaxTokens: t.Agent.MaxTokens, + Tools: tools, + }) + if chatErr != nil { + writeResultLine(stdout, "error", chatErr.Error(), totalIn, totalOut) + return fmt.Errorf("local runner: chat: %w", chatErr) + } + totalIn += resp.PromptTokens + totalOut += resp.OutputTokens + lastModel, lastFinish = resp.Model, resp.FinishReason + + if resp.Content != "" { + writeAssistantTextLine(stdout, resp.Content) } - writeAssistantTextLine(stdout, delta) - }) - if err != nil { - writeResultLine(stdout, "error", err.Error(), 0, 0) - return fmt.Errorf("local runner: chat: %w", err) - } - elapsed := time.Since(start) - // Write one consolidated assistant envelope containing the full response. - // extractSummary and ParseChangestatFromOutput operate per-line, so a - // single envelope with the full text is what they expect to find. - if resp.Content != "" { - writeAssistantTextLine(stdout, resp.Content) + if len(resp.ToolCalls) == 0 { + break + } + + // Re-feed: record the assistant's tool-call turn, then each tool result. + messages = append(messages, llm.Message{Role: "assistant", Content: resp.Content, ToolCalls: resp.ToolCalls}) + for _, tc := range resp.ToolCalls { + result, didBlock, toolErr := dispatchAgentTool(ctx, ch, tc.Function.Name, tc.Function.Arguments) + if toolErr != nil { + writeResultLine(stdout, "error", toolErr.Error(), totalIn, totalOut) + return fmt.Errorf("local runner: tool %s: %w", tc.Function.Name, toolErr) + } + if didBlock { + blocked = true + break + } + messages = append(messages, llm.Message{ + Role: "tool", + ToolCallID: tc.ID, + Name: tc.Function.Name, + Content: result, + }) + } + if blocked { + break + } } - writeResultLine(stdout, "success", "", resp.PromptTokens, resp.OutputTokens) + elapsed := time.Since(start) e.CostUSD = 0 - e.TokensIn = int64(resp.PromptTokens) - e.TokensOut = int64(resp.OutputTokens) + e.TokensIn = int64(totalIn) + e.TokensOut = int64(totalOut) if r.Logger != nil { r.Logger.Info("local runner completed", "taskID", t.ID, - "model", resp.Model, - "tokens_in", resp.PromptTokens, - "tokens_out", resp.OutputTokens, - "finish_reason", resp.FinishReason, + "model", lastModel, + "tokens_in", totalIn, + "tokens_out", totalOut, + "finish_reason", lastFinish, + "blocked", blocked, "elapsed_ms", elapsed.Milliseconds(), ) } + + // If the model asked the user, stop and block. Local resume re-feeds the + // conversation (per-runner sovereign state) — not yet wired, so the session + // ID is empty and resume starts fresh for now. + if q, isBlocked := channelPendingQuestion(ch); isBlocked { + return &BlockedError{QuestionJSON: q} + } + + writeResultLine(stdout, "success", "", totalIn, totalOut) return nil } diff --git a/internal/executor/local_test.go b/internal/executor/local_test.go index d8ab678..2ae8380 100644 --- a/internal/executor/local_test.go +++ b/internal/executor/local_test.go @@ -3,7 +3,7 @@ package executor import ( "context" "encoding/json" - "fmt" + "errors" "io" "log/slog" "net/http" @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" "github.com/google/uuid" @@ -19,60 +20,77 @@ import ( "github.com/thepeterstone/claudomator/internal/task" ) -// fakeOpenAIServer returns an httptest.Server that replies with a streaming -// chat completion containing the supplied content (split into chunks) plus a -// usage record. -func fakeOpenAIServer(t *testing.T, chunks []string, promptTok, outTok int) *httptest.Server { +// fakeTurn is one canned chat-completion response the fake server returns. +type fakeTurn struct { + content string + toolCalls []llm.ToolCall + promptTok int + outTok int +} + +// fakeChatServer replies to /chat/completions with the supplied turns in order, +// one per request (non-streaming JSON), so a tool-use loop can be driven +// deterministically. Requests beyond the list repeat the final turn. +func fakeChatServer(t *testing.T, turns []fakeTurn) *httptest.Server { t.Helper() - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/event-stream") - flusher, _ := w.(http.Flusher) - for _, c := range chunks { - payload := map[string]any{ - "model": "fake", - "choices": []map[string]any{{"delta": map[string]string{"content": c}}}, - } - b, _ := json.Marshal(payload) - fmt.Fprintf(w, "data: %s\n\n", b) - if flusher != nil { - flusher.Flush() - } + var mu sync.Mutex + idx := 0 + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + turn := turns[idx] + if idx < len(turns)-1 { + idx++ + } + mu.Unlock() + + msg := map[string]any{"role": "assistant", "content": turn.content} + if len(turn.toolCalls) > 0 { + msg["tool_calls"] = turn.toolCalls } - final := map[string]any{ + resp := map[string]any{ "model": "fake", - "choices": []map[string]any{{"delta": map[string]string{}, "finish_reason": "stop"}}, - "usage": map[string]int{"prompt_tokens": promptTok, "completion_tokens": outTok}, + "choices": []map[string]any{{"message": msg, "finish_reason": "stop"}}, + "usage": map[string]int{"prompt_tokens": turn.promptTok, "completion_tokens": turn.outTok}, } - fb, _ := json.Marshal(final) - fmt.Fprintf(w, "data: %s\n\ndata: [DONE]\n\n", fb) + _ = json.NewEncoder(w).Encode(resp) })) } -func TestLocalRunner_Run_WritesStreamJSON(t *testing.T) { - srv := fakeOpenAIServer(t, - []string{"## Summary\n", "All ", "good."}, - 11, 22, - ) - defer srv.Close() +func toolCall(id, name, args string) llm.ToolCall { + return llm.ToolCall{ID: id, Type: "function", Function: llm.ToolCallFunction{Name: name, Arguments: args}} +} - logRoot := t.TempDir() - r := &LocalRunner{ +func newLocalRunner(t *testing.T, srv *httptest.Server) *LocalRunner { + t.Helper() + return &LocalRunner{ Client: &llm.Client{Endpoint: srv.URL + "/v1", Model: "fake"}, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), - LogDir: logRoot, + LogDir: t.TempDir(), } - tt := &task.Task{ +} + +func localTask() *task.Task { + return &task.Task{ ID: "task-1", Name: "test", Agent: task.AgentConfig{ Type: "local", Model: "fake", Instructions: "Do a thing.", + SkipPlanning: true, // keep the preamble out of these assertions }, } +} + +func TestLocalRunner_Run_WritesStreamJSON(t *testing.T) { + srv := fakeChatServer(t, []fakeTurn{{content: "## Summary\nAll good.", promptTok: 11, outTok: 22}}) + defer srv.Close() + + r := newLocalRunner(t, srv) + tt := localTask() exec := &storage.Execution{ID: uuid.New().String(), TaskID: tt.ID} - if err := r.Run(context.Background(), tt, exec); err != nil { + if err := r.Run(context.Background(), tt, exec, newStoreChannel(&fakeChannelStore{}, tt.ID)); err != nil { t.Fatalf("Run: %v", err) } @@ -83,45 +101,108 @@ func TestLocalRunner_Run_WritesStreamJSON(t *testing.T) { t.Errorf("tokens: want 11/22 got %d/%d", exec.TokensIn, exec.TokensOut) } - // Verify stdout.log contains stream-json envelopes that extractSummary can parse. stdoutPath := filepath.Join(r.ExecLogDir(exec.ID), "stdout.log") data, err := os.ReadFile(stdoutPath) if err != nil { t.Fatalf("read stdout: %v", err) } lines := strings.Split(strings.TrimSpace(string(data)), "\n") - if len(lines) < 4 { - t.Fatalf("expected at least 4 lines (3 deltas + 1 result), got %d:\n%s", len(lines), data) - } - for i, line := range lines[:3] { - var env struct { - Type string `json:"type"` - Message struct { - Content []struct { - Type string `json:"type"` - Text string `json:"text"` - } - } - } - if err := json.Unmarshal([]byte(line), &env); err != nil { - t.Fatalf("line %d not JSON: %v: %s", i, err, line) - } - if env.Type != "assistant" { - t.Errorf("line %d: want type=assistant, got %q", i, env.Type) - } + // One assistant envelope + one result line. + if len(lines) != 2 { + t.Fatalf("expected 2 lines (assistant + result), got %d:\n%s", len(lines), data) + } + var env struct { + Type string `json:"type"` + } + if err := json.Unmarshal([]byte(lines[0]), &env); err != nil || env.Type != "assistant" { + t.Fatalf("line 0 should be an assistant envelope: %v: %s", err, lines[0]) } - summary := extractSummary(stdoutPath) - if !strings.Contains(summary, "All good.") { + if summary := extractSummary(stdoutPath); !strings.Contains(summary, "All good.") { t.Errorf("extractSummary should find 'All good.', got %q", summary) } } +func TestLocalRunner_Run_ToolLoop_ReportSummaryAndSpawn(t *testing.T) { + // Turn 1: model spawns a subtask. Turn 2: reports summary. Turn 3: finishes. + srv := fakeChatServer(t, []fakeTurn{ + {toolCalls: []llm.ToolCall{toolCall("c1", "spawn_subtask", `{"name":"sub A","instructions":"do sub"}`)}, promptTok: 5, outTok: 3}, + {toolCalls: []llm.ToolCall{toolCall("c2", "report_summary", `{"summary":"all done"}`)}, promptTok: 4, outTok: 2}, + {content: "finished", promptTok: 2, outTok: 1}, + }) + defer srv.Close() + + r := newLocalRunner(t, srv) + tt := localTask() + store := &fakeChannelStore{} + ch := newStoreChannel(store, tt.ID) + exec := &storage.Execution{ID: uuid.New().String(), TaskID: tt.ID} + + if err := r.Run(context.Background(), tt, exec, ch); err != nil { + t.Fatalf("Run: %v", err) + } + + if len(store.createdTasks) != 1 || store.createdTasks[0].Name != "sub A" { + t.Errorf("expected one spawned subtask 'sub A', got %+v", store.createdTasks) + } + if store.createdTasks[0].ParentTaskID != tt.ID { + t.Errorf("subtask parent: want %q, got %q", tt.ID, store.createdTasks[0].ParentTaskID) + } + if sum, ok := ch.ReportedSummary(); !ok || sum != "all done" { + t.Errorf("expected reported summary 'all done', got %q (set=%v)", sum, ok) + } + // Tokens accumulate across all three turns. + if exec.TokensIn != 11 || exec.TokensOut != 6 { + t.Errorf("tokens should accumulate: want 11/6, got %d/%d", exec.TokensIn, exec.TokensOut) + } +} + +func TestLocalRunner_Run_RecordProgress(t *testing.T) { + srv := fakeChatServer(t, []fakeTurn{ + {toolCalls: []llm.ToolCall{toolCall("c1", "record_progress", `{"message":"halfway there"}`)}}, + {content: "done"}, + }) + defer srv.Close() + + store := &fakeChannelStore{} + tt := localTask() + exec := &storage.Execution{ID: uuid.New().String(), TaskID: tt.ID} + if err := newLocalRunner(t, srv).Run(context.Background(), tt, exec, newStoreChannel(store, tt.ID)); err != nil { + t.Fatalf("Run: %v", err) + } + if len(store.createdEvents) != 1 { + t.Fatalf("expected 1 progress event, got %d", len(store.createdEvents)) + } + if !strings.Contains(string(store.createdEvents[0].Payload), "halfway there") { + t.Errorf("progress event payload: %s", store.createdEvents[0].Payload) + } +} + +func TestLocalRunner_Run_AskUser_Blocks(t *testing.T) { + srv := fakeChatServer(t, []fakeTurn{ + {toolCalls: []llm.ToolCall{toolCall("c1", "ask_user", `{"question":"which branch?"}`)}}, + {content: "should not be reached"}, + }) + defer srv.Close() + + tt := localTask() + exec := &storage.Execution{ID: uuid.New().String(), TaskID: tt.ID} + err := newLocalRunner(t, srv).Run(context.Background(), tt, exec, newStoreChannel(&fakeChannelStore{}, tt.ID)) + + var be *BlockedError + if !errors.As(err, &be) { + t.Fatalf("expected *BlockedError, got %v", err) + } + if !strings.Contains(be.QuestionJSON, "which branch?") { + t.Errorf("BlockedError question: %q", be.QuestionJSON) + } +} + func TestLocalRunner_Run_NoClient_Errors(t *testing.T) { r := &LocalRunner{LogDir: t.TempDir()} tt := &task.Task{ID: "x", Agent: task.AgentConfig{Instructions: "hi"}} exec := &storage.Execution{ID: "exec-x"} - err := r.Run(context.Background(), tt, exec) + err := r.Run(context.Background(), tt, exec, newStoreChannel(nil, tt.ID)) if err == nil || !strings.Contains(err.Error(), "no LLM client") { t.Errorf("expected 'no LLM client' error, got %v", err) } @@ -134,7 +215,7 @@ func TestLocalRunner_Run_EmptyInstructions_Errors(t *testing.T) { } tt := &task.Task{ID: "x", Agent: task.AgentConfig{}} exec := &storage.Execution{ID: "exec-x"} - err := r.Run(context.Background(), tt, exec) + err := r.Run(context.Background(), tt, exec, newStoreChannel(nil, tt.ID)) if err == nil || !strings.Contains(err.Error(), "empty instructions") { t.Errorf("expected empty-instructions error, got %v", err) } diff --git a/internal/executor/localtools.go b/internal/executor/localtools.go new file mode 100644 index 0000000..48b862f --- /dev/null +++ b/internal/executor/localtools.go @@ -0,0 +1,135 @@ +package executor + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/thepeterstone/claudomator/internal/llm" +) + +// agentToolDefs returns the four agent back-channel tools as OpenAI +// function-calling definitions, for runners that talk to an OpenAI-compatible +// endpoint (LocalRunner). They mirror the MCP tools the container runners expose. +func agentToolDefs() []llm.Tool { + strProp := func(desc string) map[string]any { + return map[string]any{"type": "string", "description": desc} + } + return []llm.Tool{ + {Type: "function", Function: llm.ToolFunction{ + Name: "ask_user", + Description: "Ask the user a question when you genuinely need a decision to proceed. The task pauses until the user answers; do not call other tools after this.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "question": strProp("the question to ask, phrased as a real question"), + "options": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "optional suggested answer choices"}, + }, + "required": []string{"question"}, + }, + }}, + {Type: "function", Function: llm.ToolFunction{ + Name: "report_summary", + Description: "Record a concise 2-5 sentence summary of what you accomplished. Call this before finishing.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{"summary": strProp("the summary text")}, + "required": []string{"summary"}, + }, + }}, + {Type: "function", Function: llm.ToolFunction{ + Name: "spawn_subtask", + Description: "Create a child task to be executed separately. Use this to break large work into focused pieces.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": strProp("short descriptive name for the subtask"), + "instructions": strProp("complete instructions for the subtask agent"), + "model": strProp("optional model override"), + "max_budget_usd": map[string]any{"type": "number", "description": "optional budget cap in USD"}, + }, + "required": []string{"name", "instructions"}, + }, + }}, + {Type: "function", Function: llm.ToolFunction{ + Name: "record_progress", + Description: "Record a short progress note that appears in the task timeline.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{"message": strProp("a short progress note")}, + "required": []string{"message"}, + }, + }}, + } +} + +// dispatchAgentTool invokes one model-requested tool against the AgentChannel. +// It returns the text to feed back to the model as the tool result, and a +// blocked flag set when ask_user could not be answered in-session (the run must +// stop and the task block). +func dispatchAgentTool(ctx context.Context, ch AgentChannel, name, argsJSON string) (result string, blocked bool, err error) { + switch name { + case "ask_user": + var a struct { + Question string `json:"question"` + Options []string `json:"options"` + } + _ = json.Unmarshal([]byte(argsJSON), &a) + q := map[string]any{"text": a.Question} + if len(a.Options) > 0 { + q["options"] = a.Options + } + payload, _ := json.Marshal(q) + ans, askErr := ch.AskUser(ctx, string(payload)) + if errors.Is(askErr, ErrAgentBlocked) { + return "", true, nil + } + if askErr != nil { + return "", false, askErr + } + return ans, false, nil + + case "report_summary": + var a struct { + Summary string `json:"summary"` + } + _ = json.Unmarshal([]byte(argsJSON), &a) + if rsErr := ch.ReportSummary(ctx, a.Summary); rsErr != nil { + return "", false, rsErr + } + return "Summary recorded.", false, nil + + case "spawn_subtask": + var a struct { + Name string `json:"name"` + Instructions string `json:"instructions"` + Model string `json:"model"` + MaxBudgetUSD float64 `json:"max_budget_usd"` + } + _ = json.Unmarshal([]byte(argsJSON), &a) + id, ssErr := ch.SpawnSubtask(ctx, SubtaskSpec{ + Name: a.Name, + Instructions: a.Instructions, + Model: a.Model, + MaxBudgetUSD: a.MaxBudgetUSD, + }) + if ssErr != nil { + return "", false, ssErr + } + return "Created subtask " + id, false, nil + + case "record_progress": + var a struct { + Message string `json:"message"` + } + _ = json.Unmarshal([]byte(argsJSON), &a) + if rpErr := ch.RecordProgress(ctx, a.Message); rpErr != nil { + return "", false, rpErr + } + return "Noted.", false, nil + + default: + return "", false, fmt.Errorf("unknown tool %q", name) + } +} diff --git a/internal/executor/preamble.go b/internal/executor/preamble.go index b949986..77fae3c 100644 --- a/internal/executor/preamble.go +++ b/internal/executor/preamble.go @@ -2,61 +2,48 @@ package executor const planningPreamble = `## Runtime Environment -You are running as a background agent inside Claudomator. You cannot interact -with the user directly. However, if you need a decision or clarification: - -**To ask the user a question and pause:** -1. Write a JSON object to the path in $CLAUDOMATOR_QUESTION_FILE: - {"text": "Your question here?", "options": ["option A", "option B"]} - (options is optional — omit it for free-text answers) -2. Exit immediately. Do not wait. The task will be resumed with the user's answer - as the next message in this conversation. - -Only use this when you genuinely need user input to proceed. The text MUST be a -real question ending with "?". Do NOT write completion reports or status updates -here — use $CLAUDOMATOR_SUMMARY_FILE for those. Prefer making a reasonable -decision and noting it in your summary rather than asking. +You are running as a background agent inside Claudomator. You cannot chat with +the user directly, but you have these tools to coordinate: + +- **ask_user** — ask the user a question when you genuinely need a decision to + proceed. After calling it, end your turn immediately; the task pauses and is + resumed with the user's answer. Prefer making a reasonable decision and noting + it via report_summary over asking. +- **report_summary** — record a 2-5 sentence summary of what you did. Call this + before you finish so the user knows the outcome. +- **spawn_subtask** — create a child task to be run separately. +- **record_progress** — leave a short progress note in the task timeline. --- ## Planning Step (do this first) -Before doing any implementation work: - -1. Estimate: will this task take more than 3 minutes of implementation effort? +Before doing any implementation work, estimate whether the task will take more +than ~3 minutes of effort. -2. If YES — break it down: - - Create 3–7 discrete subtasks by POSTing to $CLAUDOMATOR_API_URL/api/tasks - - Each subtask POST body should be JSON with: name, agent.instructions, agent.project_dir (copy from $CLAUDOMATOR_PROJECT_DIR), agent.model, agent.allowed_tools, and agent.skip_planning set to true - - Set parent_task_id to $CLAUDOMATOR_TASK_ID in each POST body - - After creating all subtasks, output a brief summary and STOP. Do not implement anything. - - You can also specify agent.type (either "claude" or "gemini") to choose the agent for subtasks. - -3. If NO — proceed with the task instructions below. +- If YES — break it into 3-7 focused pieces with spawn_subtask, then call + report_summary describing the breakdown and STOP. Do not implement anything. +- If NO — proceed with the task instructions below. --- -## Git Discipline (mandatory when project_dir is set) +## Git Discipline (mandatory when working in a repository) -Every change you make to the working directory **must be committed before you finish**. -The sandbox is rejected if there are any uncommitted modifications. +Every change you make **must be committed before you finish** — the workspace is +rejected if there are uncommitted modifications. -- After completing work: run "git add -A && git commit -m 'concise description'" -- One commit is fine. Multiple focused commits are also fine. -- If you realise the task was already done and you made no changes, that is also fine — just exit cleanly without committing. -- Do not exit with uncommitted edits. -- **CRITICAL:** Run ALL git commands from your current directory — do NOT use absolute paths or "cd <project_path> && git ...". Your working directory IS the project. Using absolute paths bypasses the sandbox and breaks commit tracking. +- After completing work: run "git add -A && git commit -m 'concise description'". +- One commit is fine; multiple focused commits are also fine. +- If the task was already done and you made no changes, just exit cleanly. +- Run ALL git commands from your current directory — do NOT use absolute paths + or "cd <path> && git ...". Your working directory IS the project. --- -## Final Summary (mandatory) - -Before exiting, write a brief summary paragraph (2–5 sentences) describing what you did -and the outcome. Write it to the path in $CLAUDOMATOR_SUMMARY_FILE: - - echo "Your summary here." > "$CLAUDOMATOR_SUMMARY_FILE" +## Before Finishing -This summary is displayed in the task UI so the user knows what happened. +Call report_summary with a brief paragraph describing what you did and the +outcome. This is shown in the task UI. --- ` diff --git a/internal/executor/preamble_test.go b/internal/executor/preamble_test.go index 5c31b4f..77a8fca 100644 --- a/internal/executor/preamble_test.go +++ b/internal/executor/preamble_test.go @@ -5,27 +5,37 @@ import ( "testing" ) -func TestPlanningPreamble_ContainsFinalSummarySection(t *testing.T) { - if !strings.Contains(planningPreamble, "## Final Summary (mandatory)") { - t.Error("planningPreamble missing '## Final Summary (mandatory)' heading") +func TestPlanningPreamble_ReferencesMCPTools(t *testing.T) { + for _, tool := range []string{"ask_user", "report_summary", "spawn_subtask", "record_progress"} { + if !strings.Contains(planningPreamble, tool) { + t.Errorf("planningPreamble should mention the %q tool", tool) + } } } -func TestPlanningPreamble_SummaryUsesFileEnvVar(t *testing.T) { - if !strings.Contains(planningPreamble, "CLAUDOMATOR_SUMMARY_FILE") { - t.Error("planningPreamble should instruct agent to write summary to $CLAUDOMATOR_SUMMARY_FILE") +func TestPlanningPreamble_NoFileEscapeHatches(t *testing.T) { + for _, marker := range []string{"CLAUDOMATOR_SUMMARY_FILE", "CLAUDOMATOR_QUESTION_FILE"} { + if strings.Contains(planningPreamble, marker) { + t.Errorf("planningPreamble should no longer reference %s", marker) + } } } -func TestPlanningPreamble_SummaryInstructsEchoToFile(t *testing.T) { - if !strings.Contains(planningPreamble, `"$CLAUDOMATOR_SUMMARY_FILE"`) { - t.Error("planningPreamble should show example of writing to $CLAUDOMATOR_SUMMARY_FILE via echo") +func TestPlanningPreamble_AskUserEndsTurn(t *testing.T) { + if !strings.Contains(planningPreamble, "end your turn") { + t.Error("planningPreamble should instruct the agent to end its turn after ask_user") } } func TestPlanningPreamble_GitDiscipline_ForbidsAbsolutePaths(t *testing.T) { - // Agents must not bypass the sandbox by using absolute project paths in git commands. if !strings.Contains(planningPreamble, "do NOT use absolute paths") { t.Error("planningPreamble should warn agents not to use absolute paths in git commands") } } + +func TestWithPlanningPreamble_PrependsToInstructions(t *testing.T) { + got := withPlanningPreamble("DO THE THING") + if !strings.HasPrefix(got, planningPreamble) || !strings.HasSuffix(got, "DO THE THING") { + t.Error("withPlanningPreamble should prepend the preamble to the instructions") + } +} diff --git a/internal/llm/client.go b/internal/llm/client.go index 613ebe5..e3a6102 100644 --- a/internal/llm/client.go +++ b/internal/llm/client.go @@ -34,6 +34,38 @@ type Client struct { type Message struct { Role string `json:"role"` Content string `json:"content"` + // ToolCalls is set on assistant messages that request tool invocations. + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + // ToolCallID links a role:"tool" result message to the call it answers. + ToolCallID string `json:"tool_call_id,omitempty"` + // Name carries the tool name on role:"tool" result messages. + Name string `json:"name,omitempty"` +} + +// Tool declares a function the model may call (OpenAI function-calling format). +type Tool struct { + Type string `json:"type"` // always "function" + Function ToolFunction `json:"function"` +} + +// ToolFunction describes a callable function and its JSON-schema parameters. +type ToolFunction struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters map[string]any `json:"parameters,omitempty"` +} + +// ToolCall is a function invocation the model emitted in its response. +type ToolCall struct { + ID string `json:"id"` + Type string `json:"type"` // "function" + Function ToolCallFunction `json:"function"` +} + +// ToolCallFunction holds the called function's name and raw JSON arguments. +type ToolCallFunction struct { + Name string `json:"name"` + Arguments string `json:"arguments"` } // ChatRequest captures the parameters of a single Chat or ChatStream call. @@ -45,6 +77,7 @@ type ChatRequest struct { Temperature *float64 MaxTokens int ResponseJSON bool + Tools []Tool } // ChatResponse is the aggregated result of a chat completion. @@ -54,6 +87,7 @@ type ChatResponse struct { OutputTokens int Model string FinishReason string + ToolCalls []ToolCall } // Chat performs a non-streaming chat completion. Rate-limit errors (HTTP 429, @@ -87,6 +121,7 @@ func (c *Client) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, erro OutputTokens: oai.Usage.CompletionTokens, Model: oai.Model, FinishReason: oai.Choices[0].FinishReason, + ToolCalls: oai.Choices[0].Message.ToolCalls, } return nil }) @@ -133,6 +168,7 @@ func (c *Client) buildRequestBody(req ChatRequest, stream bool) ([]byte, error) Model: model, Messages: req.Messages, Stream: stream, + Tools: req.Tools, } if req.Temperature != nil { payload.Temperature = req.Temperature @@ -301,6 +337,7 @@ type openAIRequest struct { Stream bool `json:"stream,omitempty"` StreamOptions *streamOptions `json:"stream_options,omitempty"` ResponseFormat *responseFormat `json:"response_format,omitempty"` + Tools []Tool `json:"tools,omitempty"` } type streamOptions struct { diff --git a/internal/llm/client_test.go b/internal/llm/client_test.go index 8257836..7533a8a 100644 --- a/internal/llm/client_test.go +++ b/internal/llm/client_test.go @@ -157,3 +157,43 @@ func TestErrFromStatus_RateLimitMarker(t *testing.T) { t.Errorf("error should embed retry-after, got: %v", err) } } + +func TestChat_ToolsRoundTrip(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body openAIRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if len(body.Tools) != 1 || body.Tools[0].Function.Name != "do_thing" { + t.Errorf("tools not forwarded in request: %+v", body.Tools) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintln(w, `{ + "model": "m", + "choices": [{"message": {"role": "assistant", "content": "", + "tool_calls": [{"id": "call_1", "type": "function", + "function": {"name": "do_thing", "arguments": "{\"x\":1}"}}]}, + "finish_reason": "tool_calls"}], + "usage": {"prompt_tokens": 3, "completion_tokens": 4} + }`) + })) + defer srv.Close() + + c := &Client{Endpoint: srv.URL + "/v1", Model: "m"} + resp, err := c.Chat(context.Background(), ChatRequest{ + Messages: []Message{{Role: "user", Content: "go"}}, + Tools: []Tool{{Type: "function", Function: ToolFunction{ + Name: "do_thing", Description: "d", Parameters: map[string]any{"type": "object"}, + }}}, + }) + if err != nil { + t.Fatalf("Chat: %v", err) + } + if len(resp.ToolCalls) != 1 { + t.Fatalf("expected 1 tool call, got %d", len(resp.ToolCalls)) + } + tc := resp.ToolCalls[0] + if tc.ID != "call_1" || tc.Function.Name != "do_thing" || tc.Function.Arguments != `{"x":1}` { + t.Errorf("unexpected tool call parsed: %+v", tc) + } +} diff --git a/internal/storage/db.go b/internal/storage/db.go index 4adc1ba..e03a902 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/thepeterstone/claudomator/internal/event" "github.com/thepeterstone/claudomator/internal/task" ) @@ -139,6 +140,21 @@ func (s *DB) migrate() error { `ALTER TABLE tasks ADD COLUMN checker_report TEXT NOT NULL DEFAULT ''`, `ALTER TABLE executions ADD COLUMN tokens_in INTEGER`, `ALTER TABLE executions ADD COLUMN tokens_out INTEGER`, + `ALTER TABLE executions ADD COLUMN agent TEXT`, + `CREATE TABLE IF NOT EXISTS events ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + seq INTEGER NOT NULL, + ts DATETIME NOT NULL, + kind TEXT NOT NULL, + actor TEXT NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}', + UNIQUE(task_id, seq), + FOREIGN KEY (task_id) REFERENCES tasks(id) + )`, + `CREATE INDEX IF NOT EXISTS idx_events_task_id_seq ON events(task_id, seq)`, + `CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts)`, + `CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind)`, } for _, m := range migrations { if _, err := s.db.Exec(m); err != nil { @@ -175,15 +191,41 @@ func (s *DB) CreateTask(t *task.Task) error { return fmt.Errorf("marshaling depends_on: %w", err) } - _, err = s.db.Exec(` + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() //nolint:errcheck + + if _, err = tx.Exec(` INSERT INTO tasks (id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, story_id, acceptance_criteria, checker_for_task_id, checker_report) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, t.ID, t.Name, t.Description, t.ElaborationInput, t.Project, t.RepositoryURL, string(configJSON), string(t.Priority), t.Timeout.Duration.Nanoseconds(), string(retryJSON), string(tagsJSON), string(depsJSON), t.ParentTaskID, string(t.State), t.CreatedAt.UTC(), t.UpdatedAt.UTC(), t.StoryID, t.AcceptanceCriteria, t.CheckerForTaskID, t.CheckerReport, - ) - return err + ); err != nil { + return err + } + + // A subtask records a subtask_spawned event on its parent's stream so the + // parent's history shows the children it dispatched. + if t.ParentTaskID != "" { + payload, _ := json.Marshal(struct { + SubtaskID string `json:"subtask_id"` + Name string `json:"name"` + }{SubtaskID: t.ID, Name: t.Name}) + if err = createEventTx(tx, &event.Event{ + TaskID: t.ParentTaskID, + Kind: event.KindSubtaskSpawned, + Actor: event.ActorAgent, + Payload: payload, + }); err != nil { + return err + } + } + + return tx.Commit() } // GetTask retrieves a task by ID. @@ -247,8 +289,17 @@ func (s *DB) ListSubtasks(parentID string) ([]*task.Task, error) { return tasks, rows.Err() } -// UpdateTaskState atomically updates a task's state, enforcing valid transitions. +// UpdateTaskState atomically updates a task's state, enforcing valid +// transitions. The transition is attributed to the system actor; callers +// acting on behalf of a user should use UpdateTaskStateBy. func (s *DB) UpdateTaskState(id string, newState task.State) error { + return s.UpdateTaskStateBy(id, newState, event.ActorSystem) +} + +// UpdateTaskStateBy is UpdateTaskState with explicit actor attribution. It +// writes a state_change event in the same transaction as the row update so the +// event stream and task state never diverge. +func (s *DB) UpdateTaskStateBy(id string, newState task.State, actor event.Actor) error { tx, err := s.db.Begin() if err != nil { return err @@ -271,6 +322,14 @@ func (s *DB) UpdateTaskState(id string, newState task.State) error { if _, err := tx.Exec(`UPDATE tasks SET state = ?, updated_at = ? WHERE id = ?`, string(newState), now, id); err != nil { return err } + if err := createEventTx(tx, &event.Event{ + TaskID: id, + Kind: event.KindStateChange, + Actor: actor, + Payload: stateChangePayload(task.State(currentState), newState), + }); err != nil { + return err + } return tx.Commit() } @@ -305,6 +364,15 @@ func (s *DB) ResetTaskForRetry(id string) (*task.Task, error) { return nil, err } + if err := createEventTx(tx, &event.Event{ + TaskID: id, + Kind: event.KindStateChange, + Actor: event.ActorUser, + Payload: stateChangePayload(t.State, task.StateQueued), + }); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { return nil, err } @@ -326,20 +394,35 @@ func (s *DB) UpdateTaskAgent(id string, agent task.AgentConfig) error { // RejectTask sets a task's state to PENDING and stores the rejection comment. func (s *DB) RejectTask(id, comment string) error { - now := time.Now().UTC() - result, err := s.db.Exec(`UPDATE tasks SET state = ?, rejection_comment = ?, updated_at = ? WHERE id = ?`, - string(task.StatePending), comment, now, id) + tx, err := s.db.Begin() if err != nil { return err } - n, err := result.RowsAffected() - if err != nil { + defer tx.Rollback() //nolint:errcheck + + var currentState string + if err := tx.QueryRow(`SELECT state FROM tasks WHERE id = ?`, id).Scan(¤tState); err != nil { + if err == sql.ErrNoRows { + return fmt.Errorf("task %q not found", id) + } return err } - if n == 0 { - return fmt.Errorf("task %q not found", id) + + now := time.Now().UTC() + if _, err := tx.Exec(`UPDATE tasks SET state = ?, rejection_comment = ?, updated_at = ? WHERE id = ?`, + string(task.StatePending), comment, now, id); err != nil { + return err } - return nil + + if err := createEventTx(tx, &event.Event{ + TaskID: id, + Kind: event.KindStateChange, + Actor: event.ActorUser, + Payload: stateChangePayload(task.State(currentState), task.StatePending), + }); err != nil { + return err + } + return tx.Commit() } // TaskUpdate holds the fields that UpdateTask may change. @@ -454,6 +537,7 @@ type Execution struct { ErrorMsg string SessionID string // claude --session-id; persisted for resume SandboxDir string // preserved sandbox path when task is BLOCKED; resume must run here + Agent string // provider that ran this execution (claude/gemini/local); for budget accounting Changestats *task.Changestats // stored as JSON; nil if not yet recorded Commits []task.GitCommit // stored as JSON; empty if no commits @@ -502,10 +586,10 @@ func (s *DB) CreateExecutionAndSetRunning(e *Execution) error { commitsJSON = string(b) } if _, err := tx.Exec(` - INSERT INTO executions (id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)`, + INSERT INTO executions (id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json, agent) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`, e.ID, e.TaskID, e.StartTime.UTC(), e.EndTime.UTC(), e.ExitCode, e.Status, - e.StdoutPath, e.StderrPath, e.ArtifactDir, e.CostUSD, e.ErrorMsg, e.SessionID, e.SandboxDir, commitsJSON, + e.StdoutPath, e.StderrPath, e.ArtifactDir, e.CostUSD, e.ErrorMsg, e.SessionID, e.SandboxDir, commitsJSON, e.Agent, ); err != nil { return err } @@ -519,6 +603,32 @@ func (s *DB) CreateExecutionAndSetRunning(e *Execution) error { return tx.Commit() } +// SpendByProviderSince returns total cost_usd per provider (executions.agent) +// for executions started at or after `since`. Executions with no recorded agent +// are grouped under the empty string and can be ignored by callers. +func (s *DB) SpendByProviderSince(since time.Time) (map[string]float64, error) { + rows, err := s.db.Query(` + SELECT COALESCE(agent, ''), COALESCE(SUM(cost_usd), 0) + FROM executions + WHERE start_time >= ? + GROUP BY agent`, since.UTC()) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make(map[string]float64) + for rows.Next() { + var agent string + var cost float64 + if err := rows.Scan(&agent, &cost); err != nil { + return nil, err + } + out[agent] = cost + } + return out, rows.Err() +} + // CreateExecution inserts an execution record. func (s *DB) CreateExecution(e *Execution) error { var changestatsJSON *string @@ -539,23 +649,23 @@ func (s *DB) CreateExecution(e *Execution) error { commitsJSON = string(b) } _, err := s.db.Exec(` - INSERT INTO executions (id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json, tokens_in, tokens_out) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + INSERT INTO executions (id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json, tokens_in, tokens_out, agent) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, e.ID, e.TaskID, e.StartTime.UTC(), e.EndTime.UTC(), e.ExitCode, e.Status, - e.StdoutPath, e.StderrPath, e.ArtifactDir, e.CostUSD, e.ErrorMsg, e.SessionID, e.SandboxDir, changestatsJSON, commitsJSON, e.TokensIn, e.TokensOut, + e.StdoutPath, e.StderrPath, e.ArtifactDir, e.CostUSD, e.ErrorMsg, e.SessionID, e.SandboxDir, changestatsJSON, commitsJSON, e.TokensIn, e.TokensOut, e.Agent, ) return err } // GetExecution retrieves an execution by ID. func (s *DB) GetExecution(id string) (*Execution, error) { - row := s.db.QueryRow(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json, tokens_in, tokens_out FROM executions WHERE id = ?`, id) + row := s.db.QueryRow(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json, tokens_in, tokens_out, agent FROM executions WHERE id = ?`, id) return scanExecution(row) } // ListExecutions returns executions for a task. func (s *DB) ListExecutions(taskID string) ([]*Execution, error) { - rows, err := s.db.Query(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json, tokens_in, tokens_out FROM executions WHERE task_id = ? ORDER BY start_time DESC`, taskID) + rows, err := s.db.Query(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json, tokens_in, tokens_out, agent FROM executions WHERE task_id = ? ORDER BY start_time DESC`, taskID) if err != nil { return nil, err } @@ -574,7 +684,7 @@ func (s *DB) ListExecutions(taskID string) ([]*Execution, error) { // GetLatestExecution returns the most recent execution for a task. func (s *DB) GetLatestExecution(taskID string) (*Execution, error) { - row := s.db.QueryRow(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json, tokens_in, tokens_out FROM executions WHERE task_id = ? ORDER BY start_time DESC LIMIT 1`, taskID) + row := s.db.QueryRow(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json, tokens_in, tokens_out, agent FROM executions WHERE task_id = ? ORDER BY start_time DESC LIMIT 1`, taskID) return scanExecution(row) } @@ -824,17 +934,57 @@ func (s *DB) ListRecentExecutions(since time.Time, limit int, taskID string) ([] // UpdateTaskQuestion stores the pending question JSON on a task. // Pass empty string to clear the question after it has been answered. +// A non-empty question additionally emits a clarification_request event +// (the agent asking for input); clearing emits nothing. func (s *DB) UpdateTaskQuestion(taskID, questionJSON string) error { - _, err := s.db.Exec(`UPDATE tasks SET question_json = ?, updated_at = ? WHERE id = ?`, - questionJSON, time.Now().UTC(), taskID) - return err + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() //nolint:errcheck + + if _, err := tx.Exec(`UPDATE tasks SET question_json = ?, updated_at = ? WHERE id = ?`, + questionJSON, time.Now().UTC(), taskID); err != nil { + return err + } + if questionJSON != "" { + if err := createEventTx(tx, &event.Event{ + TaskID: taskID, + Kind: event.KindClarificationRequest, + Actor: event.ActorAgent, + Payload: json.RawMessage(questionJSON), + }); err != nil { + return err + } + } + return tx.Commit() } -// UpdateTaskSummary stores the agent's final summary paragraph on a task. +// UpdateTaskSummary stores the agent's final summary paragraph on a task and +// emits a summary event. func (s *DB) UpdateTaskSummary(taskID, summary string) error { - _, err := s.db.Exec(`UPDATE tasks SET summary = ?, updated_at = ? WHERE id = ?`, - summary, time.Now().UTC(), taskID) - return err + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() //nolint:errcheck + + if _, err := tx.Exec(`UPDATE tasks SET summary = ?, updated_at = ? WHERE id = ?`, + summary, time.Now().UTC(), taskID); err != nil { + return err + } + payload, _ := json.Marshal(struct { + Text string `json:"text"` + }{Text: summary}) + if err := createEventTx(tx, &event.Event{ + TaskID: taskID, + Kind: event.KindSummary, + Actor: event.ActorAgent, + Payload: payload, + }); err != nil { + return err + } + return tx.Commit() } // UpdateTaskCheckerReport sets the checker_report field on a task. @@ -887,6 +1037,15 @@ func (s *DB) AppendTaskInteraction(taskID string, interaction task.Interaction) string(updated), time.Now().UTC(), taskID); err != nil { return err } + payload, _ := json.Marshal(interaction) + if err := createEventTx(tx, &event.Event{ + TaskID: taskID, + Kind: event.KindClarificationAnswer, + Actor: event.ActorUser, + Payload: payload, + }); err != nil { + return err + } return tx.Commit() } @@ -1006,8 +1165,9 @@ func scanExecution(row scanner) (*Execution, error) { var commitsJSON sql.NullString var tokensIn sql.NullInt64 var tokensOut sql.NullInt64 + var agent sql.NullString err := row.Scan(&e.ID, &e.TaskID, &e.StartTime, &e.EndTime, &e.ExitCode, &e.Status, - &e.StdoutPath, &e.StderrPath, &e.ArtifactDir, &e.CostUSD, &e.ErrorMsg, &sessionID, &sandboxDir, &changestatsJSON, &commitsJSON, &tokensIn, &tokensOut) + &e.StdoutPath, &e.StderrPath, &e.ArtifactDir, &e.CostUSD, &e.ErrorMsg, &sessionID, &sandboxDir, &changestatsJSON, &commitsJSON, &tokensIn, &tokensOut, &agent) if err != nil { return nil, err } @@ -1015,6 +1175,7 @@ func scanExecution(row scanner) (*Execution, error) { e.SandboxDir = sandboxDir.String e.TokensIn = tokensIn.Int64 e.TokensOut = tokensOut.Int64 + e.Agent = agent.String if changestatsJSON.Valid && changestatsJSON.String != "" { var cs task.Changestats if err := json.Unmarshal([]byte(changestatsJSON.String), &cs); err != nil { diff --git a/internal/storage/db_test.go b/internal/storage/db_test.go index 0e67e02..4d744a4 100644 --- a/internal/storage/db_test.go +++ b/internal/storage/db_test.go @@ -309,6 +309,44 @@ func TestListTasks_WithLimit(t *testing.T) { } } +func TestSpendByProviderSince(t *testing.T) { + db := testDB(t) + now := time.Now().UTC() + + tk := &task.Task{ + ID: "spend-task", Name: "S", Agent: task.AgentConfig{Type: "claude", Instructions: "x"}, + Priority: task.PriorityNormal, Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"}, + Tags: []string{}, DependsOn: []string{}, State: task.StatePending, CreatedAt: now, UpdatedAt: now, + } + if err := db.CreateTask(tk); err != nil { + t.Fatal(err) + } + + // Two recent claude execs, one recent gemini, one old claude (outside window). + execs := []*Execution{ + {ID: "s1", TaskID: "spend-task", StartTime: now.Add(-1 * time.Hour), Status: "COMPLETED", CostUSD: 1.5, Agent: "claude"}, + {ID: "s2", TaskID: "spend-task", StartTime: now.Add(-2 * time.Hour), Status: "COMPLETED", CostUSD: 2.0, Agent: "claude"}, + {ID: "s3", TaskID: "spend-task", StartTime: now.Add(-30 * time.Minute), Status: "COMPLETED", CostUSD: 0.75, Agent: "gemini"}, + {ID: "s4", TaskID: "spend-task", StartTime: now.Add(-10 * time.Hour), Status: "COMPLETED", CostUSD: 99, Agent: "claude"}, + } + for _, e := range execs { + if err := db.CreateExecution(e); err != nil { + t.Fatalf("create exec %s: %v", e.ID, err) + } + } + + spends, err := db.SpendByProviderSince(now.Add(-5 * time.Hour)) + if err != nil { + t.Fatalf("SpendByProviderSince: %v", err) + } + if got := spends["claude"]; got != 3.5 { + t.Errorf("claude spend: want 3.5 (old $99 excluded), got %v", got) + } + if got := spends["gemini"]; got != 0.75 { + t.Errorf("gemini spend: want 0.75, got %v", got) + } +} + func TestCreateExecution_AndGet(t *testing.T) { db := testDB(t) now := time.Now().UTC().Truncate(time.Second) diff --git a/internal/storage/events.go b/internal/storage/events.go new file mode 100644 index 0000000..2051127 --- /dev/null +++ b/internal/storage/events.go @@ -0,0 +1,120 @@ +package storage + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/thepeterstone/claudomator/internal/event" + "github.com/thepeterstone/claudomator/internal/task" +) + +// CreateEvent appends an event to a task's stream. Seq is assigned by the +// database as MAX(seq)+1 within a transaction. On return, e.ID, e.Seq, and +// e.Timestamp are populated (timestamps default to time.Now().UTC()). +func (s *DB) CreateEvent(e *event.Event) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if err := createEventTx(tx, e); err != nil { + return err + } + return tx.Commit() +} + +// createEventTx validates, defaults, assigns a per-task monotonic seq, and +// inserts an event using the supplied transaction. The caller commits. This +// lets state-changing methods write their state_change event atomically with +// the row update. +func createEventTx(tx *sql.Tx, e *event.Event) error { + if e == nil { + return errors.New("event is nil") + } + if e.TaskID == "" { + return errors.New("event.TaskID required") + } + if e.Kind == "" { + return errors.New("event.Kind required") + } + if e.Actor == "" { + return errors.New("event.Actor required") + } + if len(e.Payload) == 0 { + e.Payload = json.RawMessage("{}") + } + if e.ID == "" { + e.ID = uuid.NewString() + } + if e.Timestamp.IsZero() { + e.Timestamp = time.Now().UTC() + } else { + e.Timestamp = e.Timestamp.UTC() + } + + var nextSeq int64 + if err := tx.QueryRow(`SELECT COALESCE(MAX(seq), 0) + 1 FROM events WHERE task_id = ?`, e.TaskID).Scan(&nextSeq); err != nil { + return fmt.Errorf("computing next seq: %w", err) + } + e.Seq = nextSeq + + if _, err := tx.Exec( + `INSERT INTO events (id, task_id, seq, ts, kind, actor, payload_json) VALUES (?, ?, ?, ?, ?, ?, ?)`, + e.ID, e.TaskID, e.Seq, e.Timestamp, string(e.Kind), string(e.Actor), string(e.Payload), + ); err != nil { + return fmt.Errorf("inserting event: %w", err) + } + return nil +} + +// stateChangePayload builds the JSON payload for a state_change event. +func stateChangePayload(from, to task.State) json.RawMessage { + b, _ := json.Marshal(struct { + From task.State `json:"from"` + To task.State `json:"to"` + }{From: from, To: to}) + return b +} + +// ListEvents returns events for a task in seq order. Pass sinceSeq=0 for the +// full history, or a higher value to fetch only events strictly after that seq +// (for incremental polling / SSE catch-up). +func (s *DB) ListEvents(taskID string, sinceSeq int64) ([]*event.Event, error) { + rows, err := s.db.Query( + `SELECT id, task_id, seq, ts, kind, actor, payload_json FROM events WHERE task_id = ? AND seq > ? ORDER BY seq ASC`, + taskID, sinceSeq, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*event.Event + for rows.Next() { + e, err := scanEvent(rows) + if err != nil { + return nil, err + } + out = append(out, e) + } + return out, rows.Err() +} + +func scanEvent(row scanner) (*event.Event, error) { + var ( + e event.Event + kind string + actor string + payload string + ) + if err := row.Scan(&e.ID, &e.TaskID, &e.Seq, &e.Timestamp, &kind, &actor, &payload); err != nil { + return nil, err + } + e.Kind = event.Kind(kind) + e.Actor = event.Actor(actor) + e.Payload = json.RawMessage(payload) + return &e, nil +} diff --git a/internal/storage/events_test.go b/internal/storage/events_test.go new file mode 100644 index 0000000..c5d3c37 --- /dev/null +++ b/internal/storage/events_test.go @@ -0,0 +1,456 @@ +package storage + +import ( + "encoding/json" + "testing" + "time" + + "github.com/thepeterstone/claudomator/internal/event" + "github.com/thepeterstone/claudomator/internal/task" +) + +// makeTaskForEvent creates a minimal task row so events can satisfy the FK. +func makeTaskForEvent(t *testing.T, db *DB, id string) { + t.Helper() + now := time.Now().UTC().Truncate(time.Second) + tk := &task.Task{ + ID: id, + Name: id, + Agent: task.AgentConfig{Type: "claude", Instructions: "noop"}, + Priority: task.PriorityNormal, + State: task.StatePending, + CreatedAt: now, + UpdatedAt: now, + } + if err := db.CreateTask(tk); err != nil { + t.Fatalf("creating task: %v", err) + } +} + +func TestCreateEvent_AssignsSeqAndDefaults(t *testing.T) { + db := testDB(t) + makeTaskForEvent(t, db, "task-1") + + e := &event.Event{ + TaskID: "task-1", + Kind: event.KindStateChange, + Actor: event.ActorSystem, + } + if err := db.CreateEvent(e); err != nil { + t.Fatalf("CreateEvent: %v", err) + } + if e.ID == "" { + t.Error("expected ID populated") + } + if e.Seq != 1 { + t.Errorf("expected seq=1, got %d", e.Seq) + } + if e.Timestamp.IsZero() { + t.Error("expected Timestamp populated") + } + if string(e.Payload) != "{}" { + t.Errorf("expected default payload {}, got %s", e.Payload) + } +} + +func TestCreateEvent_SeqIsMonotonicPerTask(t *testing.T) { + db := testDB(t) + makeTaskForEvent(t, db, "task-A") + makeTaskForEvent(t, db, "task-B") + + // 3 events on task-A, 2 on task-B, interleaved. + for _, taskID := range []string{"task-A", "task-A", "task-B", "task-A", "task-B"} { + e := &event.Event{ + TaskID: taskID, + Kind: event.KindAgentMessage, + Actor: event.ActorAgent, + } + if err := db.CreateEvent(e); err != nil { + t.Fatalf("CreateEvent: %v", err) + } + } + + a, err := db.ListEvents("task-A", 0) + if err != nil { + t.Fatalf("ListEvents A: %v", err) + } + if len(a) != 3 { + t.Fatalf("expected 3 events on task-A, got %d", len(a)) + } + for i, e := range a { + want := int64(i + 1) + if e.Seq != want { + t.Errorf("task-A event %d: seq=%d want %d", i, e.Seq, want) + } + } + + b, err := db.ListEvents("task-B", 0) + if err != nil { + t.Fatalf("ListEvents B: %v", err) + } + if len(b) != 2 { + t.Fatalf("expected 2 events on task-B, got %d", len(b)) + } + for i, e := range b { + want := int64(i + 1) + if e.Seq != want { + t.Errorf("task-B event %d: seq=%d want %d", i, e.Seq, want) + } + } +} + +func TestListEvents_SinceSeq(t *testing.T) { + db := testDB(t) + makeTaskForEvent(t, db, "task-1") + + for i := 0; i < 5; i++ { + e := &event.Event{ + TaskID: "task-1", + Kind: event.KindAgentMessage, + Actor: event.ActorAgent, + } + if err := db.CreateEvent(e); err != nil { + t.Fatalf("CreateEvent: %v", err) + } + } + + got, err := db.ListEvents("task-1", 3) + if err != nil { + t.Fatalf("ListEvents: %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 events with seq>3, got %d", len(got)) + } + if got[0].Seq != 4 || got[1].Seq != 5 { + t.Errorf("expected seqs 4,5; got %d,%d", got[0].Seq, got[1].Seq) + } +} + +func TestListEvents_PreservesPayloadAndKind(t *testing.T) { + db := testDB(t) + makeTaskForEvent(t, db, "task-1") + + payload := json.RawMessage(`{"question":"which env?","options":["dev","prod"]}`) + written := &event.Event{ + TaskID: "task-1", + Kind: event.KindClarificationRequest, + Actor: event.ActorAgent, + Payload: payload, + } + if err := db.CreateEvent(written); err != nil { + t.Fatalf("CreateEvent: %v", err) + } + + got, err := db.ListEvents("task-1", 0) + if err != nil { + t.Fatalf("ListEvents: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 event, got %d", len(got)) + } + e := got[0] + if e.Kind != event.KindClarificationRequest { + t.Errorf("kind: got %v want %v", e.Kind, event.KindClarificationRequest) + } + if e.Actor != event.ActorAgent { + t.Errorf("actor: got %v want %v", e.Actor, event.ActorAgent) + } + if string(e.Payload) != string(payload) { + t.Errorf("payload: got %s want %s", e.Payload, payload) + } +} + +func TestUpdateTaskState_WritesStateChangeEvent(t *testing.T) { + db := testDB(t) + makeTaskForEvent(t, db, "task-1") // starts PENDING + + if err := db.UpdateTaskState("task-1", task.StateQueued); err != nil { + t.Fatalf("UpdateTaskState: %v", err) + } + + events, err := db.ListEvents("task-1", 0) + if err != nil { + t.Fatalf("ListEvents: %v", err) + } + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + e := events[0] + if e.Kind != event.KindStateChange { + t.Errorf("kind: got %v want state_change", e.Kind) + } + if e.Actor != event.ActorSystem { + t.Errorf("actor: got %v want system", e.Actor) + } + var payload struct { + From task.State `json:"from"` + To task.State `json:"to"` + } + if err := json.Unmarshal(e.Payload, &payload); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + if payload.From != task.StatePending || payload.To != task.StateQueued { + t.Errorf("payload: got %s→%s want PENDING→QUEUED", payload.From, payload.To) + } +} + +func TestUpdateTaskStateBy_AttributesActor(t *testing.T) { + db := testDB(t) + makeTaskForEvent(t, db, "task-1") + + if err := db.UpdateTaskStateBy("task-1", task.StateCancelled, event.ActorUser); err != nil { + t.Fatalf("UpdateTaskStateBy: %v", err) + } + + events, err := db.ListEvents("task-1", 0) + if err != nil { + t.Fatalf("ListEvents: %v", err) + } + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + if events[0].Actor != event.ActorUser { + t.Errorf("actor: got %v want user", events[0].Actor) + } +} + +func TestUpdateTaskState_InvalidTransition_WritesNoEvent(t *testing.T) { + db := testDB(t) + makeTaskForEvent(t, db, "task-1") // PENDING + + // PENDING → COMPLETED is not a valid transition. + if err := db.UpdateTaskState("task-1", task.StateCompleted); err == nil { + t.Fatal("expected invalid transition error, got nil") + } + + events, err := db.ListEvents("task-1", 0) + if err != nil { + t.Fatalf("ListEvents: %v", err) + } + if len(events) != 0 { + t.Fatalf("expected no events after rejected transition, got %d", len(events)) + } +} + +func TestRejectTask_WritesUserStateChangeEvent(t *testing.T) { + db := testDB(t) + makeTaskForEvent(t, db, "task-1") + // Drive PENDING → QUEUED → RUNNING → READY so reject is valid. + for _, st := range []task.State{task.StateQueued, task.StateRunning, task.StateReady} { + if err := db.UpdateTaskState("task-1", st); err != nil { + t.Fatalf("transition to %s: %v", st, err) + } + } + + if err := db.RejectTask("task-1", "needs work"); err != nil { + t.Fatalf("RejectTask: %v", err) + } + + events, err := db.ListEvents("task-1", 0) + if err != nil { + t.Fatalf("ListEvents: %v", err) + } + last := events[len(events)-1] + if last.Kind != event.KindStateChange || last.Actor != event.ActorUser { + t.Errorf("reject event: got kind=%v actor=%v want state_change/user", last.Kind, last.Actor) + } + var payload struct { + From task.State `json:"from"` + To task.State `json:"to"` + } + if err := json.Unmarshal(last.Payload, &payload); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if payload.From != task.StateReady || payload.To != task.StatePending { + t.Errorf("payload: got %s→%s want READY→PENDING", payload.From, payload.To) + } +} + +func TestUpdateTaskQuestion_EmitsClarificationRequest(t *testing.T) { + db := testDB(t) + makeTaskForEvent(t, db, "task-1") + + q := `{"text":"which env?","options":["dev","prod"]}` + if err := db.UpdateTaskQuestion("task-1", q); err != nil { + t.Fatalf("UpdateTaskQuestion: %v", err) + } + + events, err := db.ListEvents("task-1", 0) + if err != nil { + t.Fatalf("ListEvents: %v", err) + } + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + e := events[0] + if e.Kind != event.KindClarificationRequest || e.Actor != event.ActorAgent { + t.Errorf("got kind=%v actor=%v want clarification_request/agent", e.Kind, e.Actor) + } + if string(e.Payload) != q { + t.Errorf("payload: got %s want %s", e.Payload, q) + } +} + +func TestUpdateTaskQuestion_ClearEmitsNothing(t *testing.T) { + db := testDB(t) + makeTaskForEvent(t, db, "task-1") + + if err := db.UpdateTaskQuestion("task-1", ""); err != nil { + t.Fatalf("UpdateTaskQuestion clear: %v", err) + } + events, err := db.ListEvents("task-1", 0) + if err != nil { + t.Fatalf("ListEvents: %v", err) + } + if len(events) != 0 { + t.Fatalf("clearing question should emit no event, got %d", len(events)) + } +} + +func TestUpdateTaskSummary_EmitsSummaryEvent(t *testing.T) { + db := testDB(t) + makeTaskForEvent(t, db, "task-1") + + if err := db.UpdateTaskSummary("task-1", "Did the thing."); err != nil { + t.Fatalf("UpdateTaskSummary: %v", err) + } + events, err := db.ListEvents("task-1", 0) + if err != nil { + t.Fatalf("ListEvents: %v", err) + } + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + e := events[0] + if e.Kind != event.KindSummary || e.Actor != event.ActorAgent { + t.Errorf("got kind=%v actor=%v want summary/agent", e.Kind, e.Actor) + } + var payload struct { + Text string `json:"text"` + } + if err := json.Unmarshal(e.Payload, &payload); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if payload.Text != "Did the thing." { + t.Errorf("payload text: got %q want %q", payload.Text, "Did the thing.") + } +} + +func TestAppendTaskInteraction_EmitsClarificationAnswer(t *testing.T) { + db := testDB(t) + makeTaskForEvent(t, db, "task-1") + + interaction := task.Interaction{ + QuestionText: "which env?", + Options: []string{"dev", "prod"}, + Answer: "prod", + } + if err := db.AppendTaskInteraction("task-1", interaction); err != nil { + t.Fatalf("AppendTaskInteraction: %v", err) + } + + events, err := db.ListEvents("task-1", 0) + if err != nil { + t.Fatalf("ListEvents: %v", err) + } + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + e := events[0] + if e.Kind != event.KindClarificationAnswer || e.Actor != event.ActorUser { + t.Errorf("got kind=%v actor=%v want clarification_answer/user", e.Kind, e.Actor) + } + var payload task.Interaction + if err := json.Unmarshal(e.Payload, &payload); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if payload.Answer != "prod" || payload.QuestionText != "which env?" { + t.Errorf("payload: got %+v", payload) + } +} + +func TestCreateTask_Subtask_EmitsSubtaskSpawnedOnParent(t *testing.T) { + db := testDB(t) + makeTaskForEvent(t, db, "parent-1") + + now := time.Now().UTC().Truncate(time.Second) + child := &task.Task{ + ID: "child-1", + Name: "Do the subthing", + ParentTaskID: "parent-1", + Agent: task.AgentConfig{Type: "claude", Instructions: "noop"}, + Priority: task.PriorityNormal, + State: task.StatePending, + CreatedAt: now, + UpdatedAt: now, + } + if err := db.CreateTask(child); err != nil { + t.Fatalf("CreateTask child: %v", err) + } + + // Parent should have the subtask_spawned event. + parentEvents, err := db.ListEvents("parent-1", 0) + if err != nil { + t.Fatalf("ListEvents parent: %v", err) + } + if len(parentEvents) != 1 { + t.Fatalf("expected 1 event on parent, got %d", len(parentEvents)) + } + e := parentEvents[0] + if e.Kind != event.KindSubtaskSpawned || e.Actor != event.ActorAgent { + t.Errorf("got kind=%v actor=%v want subtask_spawned/agent", e.Kind, e.Actor) + } + var payload struct { + SubtaskID string `json:"subtask_id"` + Name string `json:"name"` + } + if err := json.Unmarshal(e.Payload, &payload); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if payload.SubtaskID != "child-1" || payload.Name != "Do the subthing" { + t.Errorf("payload: got %+v", payload) + } + + // Child should have no events of its own from creation. + childEvents, err := db.ListEvents("child-1", 0) + if err != nil { + t.Fatalf("ListEvents child: %v", err) + } + if len(childEvents) != 0 { + t.Errorf("expected no events on child, got %d", len(childEvents)) + } +} + +func TestCreateTask_TopLevel_EmitsNoEvent(t *testing.T) { + db := testDB(t) + makeTaskForEvent(t, db, "top-1") // no parent + + events, err := db.ListEvents("top-1", 0) + if err != nil { + t.Fatalf("ListEvents: %v", err) + } + if len(events) != 0 { + t.Errorf("top-level task creation should emit no event, got %d", len(events)) + } +} + +func TestCreateEvent_RequiresFields(t *testing.T) { + db := testDB(t) + + cases := []struct { + name string + e *event.Event + }{ + {"nil", nil}, + {"empty taskID", &event.Event{Kind: event.KindAgentMessage, Actor: event.ActorAgent}}, + {"empty kind", &event.Event{TaskID: "t", Actor: event.ActorAgent}}, + {"empty actor", &event.Event{TaskID: "t", Kind: event.KindAgentMessage}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := db.CreateEvent(tc.e); err == nil { + t.Errorf("expected error, got nil") + } + }) + } +} diff --git a/internal/task/task.go b/internal/task/task.go index 935a238..eeac49e 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -138,7 +138,7 @@ type BatchFile struct { // BLOCKED may advance to READY when all subtasks complete, or back to QUEUED on user answer. var validTransitions = map[State]map[State]bool{ StatePending: {StateQueued: true, StateCancelled: true}, - StateQueued: {StateRunning: true, StateCancelled: true, StateReady: true}, // READY: parent task completed via subtask delegation + StateQueued: {StateRunning: true, StateCancelled: true, StateReady: true, StateBudgetExceeded: true}, // READY: subtask delegation; BUDGET_EXCEEDED: dispatcher budget gate blocks before running StateRunning: {StateReady: true, StateCompleted: true, StateFailed: true, StateTimedOut: true, StateCancelled: true, StateBudgetExceeded: true, StateBlocked: true}, StateReady: {StateCompleted: true, StatePending: true}, StateFailed: {StateQueued: true}, // retry @@ -115,6 +115,136 @@ export function renderDeploymentBadge(status, doc = (typeof document !== 'undefi return span; } +// ── Event timeline ────────────────────────────────────────────────────────── +// The observability event stream (GET /api/tasks/{id}/events) replaces the +// ad-hoc question/summary panels. formatEventText turns one event into a human +// line; renderEventTimeline builds the list element. + +// formatEventText returns a one-line human description of a task event. +export function formatEventText(event) { + if (!event) return ''; + const p = event.payload || {}; + switch (event.kind) { + case 'state_change': + return `State: ${p.from || '?'} → ${p.to || '?'}`; + case 'summary': + return `Summary: ${p.text || p.summary || ''}`; + case 'clarification_request': + return `Question: ${p.text || ''}`; + case 'clarification_answer': + return `Answer: ${p.answer || ''}`; + case 'subtask_spawned': + return `Spawned subtask: ${p.name || p.subtask_id || ''}`; + case 'commit_made': + return `Commit: ${p.message || p.hash || ''}`; + case 'cost_report': + return `Cost: $${p.cost_usd != null ? p.cost_usd : 0}`; + case 'agent_message': + case 'human_message': + return p.text || p.message || ''; + case 'execution_started': + return 'Execution started'; + case 'execution_ended': + return p.status ? `Execution ended (${p.status})` : 'Execution ended'; + default: + return event.kind || ''; + } +} + +// renderEventTimeline builds a <ul class="event-timeline"> from an events array. +// Accepts an optional doc parameter for testability (defaults to document). +export function renderEventTimeline(events, doc = (typeof document !== 'undefined' ? document : null)) { + if (doc == null) return null; + const ul = doc.createElement('ul'); + ul.className = 'event-timeline'; + if (!events || events.length === 0) { + const empty = doc.createElement('li'); + empty.className = 'event-timeline__empty'; + empty.textContent = 'No events yet.'; + ul.appendChild(empty); + return ul; + } + for (const event of events) { + const li = doc.createElement('li'); + li.className = `event-timeline__item event-timeline__item--${event.kind || 'unknown'}`; + + const actor = doc.createElement('span'); + actor.className = 'event-timeline__actor'; + actor.textContent = event.actor || ''; + li.appendChild(actor); + + const text = doc.createElement('span'); + text.className = 'event-timeline__text'; + text.textContent = formatEventText(event); + li.appendChild(text); + + ul.appendChild(li); + } + return ul; +} + +// fetchTaskEvents loads a task's event stream. fetchImpl defaults to the global +// fetch; pass a stub in tests. Returns [] on any error so the UI degrades +// gracefully. +export async function fetchTaskEvents(taskId, fetchImpl = (typeof fetch !== 'undefined' ? fetch : null)) { + if (!fetchImpl) return []; + try { + const resp = await fetchImpl(`/api/tasks/${taskId}/events`); + if (!resp.ok) return []; + const data = await resp.json(); + return Array.isArray(data) ? data : []; + } catch { + return []; + } +} + +// ── Budget headroom ───────────────────────────────────────────────────────── +// Renders the per-provider rolling-window spend headroom from GET /api/budget. + +// formatBudgetHeadroom turns one provider's headroom into a short label. +// Returns '' for unlimited providers (nothing to show). +export function formatBudgetHeadroom(h) { + if (!h || !h.limited) return ''; + const pct = Math.round((h.fraction_remaining || 0) * 100); + const remaining = (h.remaining_usd || 0).toFixed(2); + const limit = (h.limit_usd || 0).toFixed(2); + const name = h.provider ? h.provider.charAt(0).toUpperCase() + h.provider.slice(1) : '?'; + return `${name}: ${pct}% left ($${remaining} of $${limit})`; +} + +// renderBudgetHeadroom builds a <span> chip per limited provider, flagging +// providers under 20% remaining with a --low modifier. Returns the container. +export function renderBudgetHeadroom(headrooms, doc = (typeof document !== 'undefined' ? document : null)) { + if (doc == null) return null; + const wrap = doc.createElement('div'); + wrap.className = 'budget-bar'; + for (const h of headrooms || []) { + if (!h || !h.limited) continue; + const chip = doc.createElement('span'); + chip.className = 'budget-chip' + ((h.fraction_remaining || 0) < 0.2 ? ' budget-chip--low' : ''); + chip.textContent = formatBudgetHeadroom(h); + wrap.appendChild(chip); + } + return wrap; +} + +// loadBudget fetches headroom and injects chips into #budget-bar. Best-effort. +async function loadBudget(fetchImpl = (typeof fetch !== 'undefined' ? fetch : null)) { + if (!fetchImpl || typeof document === 'undefined') return; + const slot = document.getElementById('budget-bar'); + if (!slot) return; + try { + const resp = await fetchImpl(`${BASE_PATH}/api/budget`); + if (!resp.ok) return; + const headrooms = await resp.json(); + const rendered = renderBudgetHeadroom(headrooms); + slot.innerHTML = ''; + if (rendered) for (const c of rendered.children) slot.appendChild(c); + } catch { + // budget display is non-critical; ignore. + } +} + function truncateToWordBoundary(text, maxLen = 120) { if (!text || text.length <= maxLen) return text; const cut = text.lastIndexOf(' ', maxLen); @@ -382,9 +512,6 @@ export function filterActiveTasks(tasks) { return tasks.filter(t => _PANEL_ACTIVE_STATES.has(t.state)); } -// The New Task button is always visible regardless of active tab. -export function newTaskButtonShouldShowOnTab(_tab) { return true; } - export function filterTasksByTab(tasks, tab) { if (tab === 'active') return tasks.filter(t => ACTIVE_STATES.has(t.state)); if (tab === 'interrupted') return tasks.filter(t => INTERRUPTED_STATES.has(t.state)); @@ -531,90 +658,6 @@ export function computeExecutionStats(executions) { }; } -// ── Stories ─────────────────────────────────────────────────────────────────── - -const STORY_STATUS_LABELS = { - PENDING: 'Pending', - IN_PROGRESS: 'In Progress', - SHIPPABLE: 'Shippable', - DEPLOYED: 'Deployed', - VALIDATING: 'Validating', - REVIEW_READY: 'Review Ready', - NEEDS_FIX: 'Needs Fix', -}; - -export function storyStatusLabel(status) { - return STORY_STATUS_LABELS[status] || status; -} - -export function renderStoryCard(story, doc = document) { - const card = doc.createElement('div'); - card.className = 'story-card'; - card.dataset.storyId = story.id; - - const header = doc.createElement('div'); - header.className = 'story-card-header'; - - const name = doc.createElement('span'); - name.className = 'story-name'; - name.textContent = story.name; - header.appendChild(name); - - const badge = doc.createElement('span'); - badge.className = 'story-status-badge'; - badge.dataset.status = story.status; - badge.textContent = storyStatusLabel(story.status); - header.appendChild(badge); - - card.appendChild(header); - - const meta = doc.createElement('div'); - meta.className = 'story-meta'; - - const project = doc.createElement('span'); - project.className = 'story-project'; - project.textContent = story.project_id || '—'; - meta.appendChild(project); - - if (story.branch_name) { - const branch = doc.createElement('span'); - branch.className = 'story-branch'; - branch.textContent = story.branch_name; - meta.appendChild(branch); - } - - card.appendChild(meta); - - // Ship button for SHIPPABLE stories. - if (story.status === 'SHIPPABLE') { - const shipBtn = doc.createElement('button'); - shipBtn.className = 'btn-primary story-ship-btn'; - shipBtn.textContent = 'Ship'; - shipBtn.addEventListener('click', async (e) => { - e.stopPropagation(); - shipBtn.disabled = true; - shipBtn.textContent = 'Shipping…'; - try { - const res = await fetch(`${API_BASE}/api/stories/${story.id}/ship`, { method: 'POST' }); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - alert(body.error || `Ship failed (${res.status})`); - shipBtn.disabled = false; - shipBtn.textContent = 'Ship'; - } else { - renderStoriesPanel(); - } - } catch { - shipBtn.disabled = false; - shipBtn.textContent = 'Ship'; - } - }); - card.appendChild(shipBtn); - } - - return card; -} - export function updateFilterTabs() { const current = getTaskFilterTab(); document.querySelectorAll('.filter-tab[data-filter]').forEach(el => { @@ -1290,9 +1333,6 @@ function renderActiveTab(allTasks) { .then(([execs, agentData, dashStats]) => renderStatsPanel(allTasks, execs, agentData, dashStats)) .catch(() => {}); break; - case 'stories': - renderStoriesPanel(); - break; case 'drops': renderDropsPanel(); break; @@ -1304,6 +1344,7 @@ function renderActiveTab(allTasks) { async function poll() { try { + loadBudget(); // fire-and-forget; budget can change independently of tasks const health = await fetchHealth(); const serverUpdate = health.last_updated; @@ -1559,152 +1600,6 @@ async function submitAnswer(taskId, questionId, answer, banner) { // ── Elaborate (Draft with AI) ───────────────────────────────────────────────── -async function elaborateTask(prompt, workingDir) { - const res = await fetch(`${API_BASE}/api/tasks/elaborate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ prompt, project_dir: workingDir }), - }); - if (!res.ok) { - let msg = `HTTP ${res.status}`; - try { const body = await res.json(); msg = body.error || msg; } catch {} - throw new Error(msg); - } - return res.json(); -} - -// ── Validate ────────────────────────────────────────────────────────────────── - -async function validateTask(payload) { - const res = await fetch(`${API_BASE}/api/tasks/validate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - if (!res.ok) { - let msg = res.statusText; - try { const body = await res.json(); msg = body.error || body.message || msg; } catch {} - throw new Error(msg); - } - return res.json(); -} - -function buildValidatePayload() { - const f = document.getElementById('task-form'); - const name = f.querySelector('[name="name"]').value; - const instructions = f.querySelector('[name="instructions"]').value; - const repository_url = document.getElementById('repository-url').value; - const container_image = document.getElementById('container-image').value; - const allowedToolsEl = f.querySelector('[name="allowed_tools"]'); - const allowed_tools = allowedToolsEl - ? allowedToolsEl.value.split(',').map(s => s.trim()).filter(Boolean) - : []; - return { name, repository_url, agent: { instructions, container_image, allowed_tools } }; -} - -function renderValidationResult(result) { - const container = document.getElementById('validate-result'); - container.removeAttribute('hidden'); - container.dataset.clarity = result.clarity; - - let icon; - if (result.ready === true) { - icon = '✓'; - } else if (result.clarity === 'ambiguous') { - icon = '⚠'; - } else { - icon = '✗'; - } - - container.innerHTML = ''; - - const header = document.createElement('div'); - header.className = 'validate-header'; - const iconSpan = document.createElement('span'); - iconSpan.className = 'validate-icon'; - iconSpan.textContent = icon; - const summarySpan = document.createElement('span'); - summarySpan.textContent = ' ' + (result.summary || ''); - header.append(iconSpan, summarySpan); - container.appendChild(header); - - if (result.questions && result.questions.length > 0) { - const ul = document.createElement('ul'); - ul.className = 'validate-questions'; - for (const q of result.questions) { - const li = document.createElement('li'); - li.className = q.severity === 'blocking' ? 'validate-blocking' : 'validate-minor'; - li.textContent = q.text; - ul.appendChild(li); - } - container.appendChild(ul); - } - - if (result.suggestions && result.suggestions.length > 0) { - const ul = document.createElement('ul'); - ul.className = 'validate-suggestions'; - for (const s of result.suggestions) { - const li = document.createElement('li'); - li.className = 'validate-suggestion'; - li.textContent = s; - ul.appendChild(li); - } - container.appendChild(ul); - } -} - -// ── Task modal ──────────────────────────────────────────────────────────────── - -async function openTaskModal() { - document.getElementById('task-modal').showModal(); -} - -function closeTaskModal() { - document.getElementById('task-modal').close(); - document.getElementById('task-form').reset(); - document.getElementById('elaborate-prompt').value = ''; - const validateResult = document.getElementById('validate-result'); - validateResult.setAttribute('hidden', ''); - validateResult.innerHTML = ''; - validateResult.removeAttribute('data-clarity'); -} - -async function createTask(formData) { - const repository_url = formData.get('repository_url'); - const container_image = formData.get('container_image'); - const elaboratePromptEl = document.getElementById('elaborate-prompt'); - const elaborationInput = elaboratePromptEl ? elaboratePromptEl.value.trim() : ''; - const body = { - name: formData.get('name'), - description: '', - elaboration_input: elaborationInput || undefined, - repository_url: repository_url, - agent: { - instructions: formData.get('instructions'), - container_image: container_image, - max_budget_usd: parseFloat(formData.get('max_budget_usd')), - type: 'container', - }, - timeout: formData.get('timeout'), - priority: formData.get('priority'), - tags: [], - }; - - const res = await fetch(`${API_BASE}/api/tasks`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - - if (!res.ok) { - const text = await res.text(); - throw new Error(text || `HTTP ${res.status}`); - } - - closeTaskModal(); - await poll(); -} - // ── Task side panel ─────────────────────────────────────────────────────────── // Format Go's task.Duration JSON value {"Duration": <nanoseconds>} to human string. @@ -2055,6 +1950,21 @@ export function renderTaskPanel(task, executions) { execSection.appendChild(list); } content.appendChild(execSection); + + // ── Timeline ── + // Observability event stream, loaded asynchronously. Guarded so the sync + // panel render (and its unit tests, which have no fetch) is unaffected. + const timelineSection = makeSection('Timeline'); + const timelineContainer = document.createElement('div'); + timelineContainer.className = 'event-timeline-container'; + timelineSection.appendChild(timelineContainer); + content.appendChild(timelineSection); + if (typeof fetch !== 'undefined') { + fetchTaskEvents(task.id).then(events => { + timelineContainer.innerHTML = ''; + timelineContainer.appendChild(renderEventTimeline(events)); + }); + } } async function handleViewLogs(execId) { @@ -3124,217 +3034,6 @@ async function fetchDrops() { // ── Stories panel ───────────────────────────────────────────────────────────── -async function renderStoriesPanel() { - const panel = document.querySelector('[data-panel="stories"]'); - if (!panel) return; - - let stories; - try { - const res = await fetch(`${BASE_PATH}/api/stories`); - stories = res.ok ? await res.json() : []; - } catch { - panel.innerHTML = '<p class="task-meta" style="padding:1rem">Failed to load stories.</p>'; - return; - } - - panel.innerHTML = ''; - - const toolbar = document.createElement('div'); - toolbar.className = 'stories-toolbar'; - const btnNew = document.createElement('button'); - btnNew.className = 'btn-primary'; - btnNew.textContent = 'New Story'; - btnNew.addEventListener('click', openStoryModal); - toolbar.appendChild(btnNew); - panel.appendChild(toolbar); - - if (!stories || stories.length === 0) { - const empty = document.createElement('p'); - empty.className = 'task-empty'; - empty.textContent = 'No stories yet. Create one to get started.'; - panel.appendChild(empty); - return; - } - - const list = document.createElement('div'); - list.className = 'stories-list'; - for (const story of stories) { - const card = renderStoryCard(story); - card.addEventListener('click', () => openStoryDetail(story)); - list.appendChild(card); - } - panel.appendChild(list); -} - -function openStoryDetail(story) { - const modal = document.getElementById('story-detail-modal'); - if (!modal) return; - - document.getElementById('story-detail-name').textContent = story.name; - - const body = document.getElementById('story-detail-body'); - body.innerHTML = ''; - - function addRow(label, value) { - const row = document.createElement('div'); - row.className = 'meta-item'; - const lbl = document.createElement('div'); - lbl.className = 'meta-label'; - lbl.textContent = label; - const val = document.createElement('div'); - val.className = 'meta-value'; - val.textContent = value || '—'; - row.appendChild(lbl); - row.appendChild(val); - body.appendChild(row); - } - - const badge = document.createElement('span'); - badge.className = 'story-status-badge'; - badge.dataset.status = story.status; - badge.textContent = storyStatusLabel(story.status); - - const statusRow = document.createElement('div'); - statusRow.className = 'meta-item'; - const statusLbl = document.createElement('div'); - statusLbl.className = 'meta-label'; - statusLbl.textContent = 'Status'; - statusRow.appendChild(statusLbl); - statusRow.appendChild(badge); - body.appendChild(statusRow); - - addRow('Project', story.project_id); - addRow('Branch', story.branch_name); - addRow('Created', story.created_at ? new Date(story.created_at).toLocaleString() : '—'); - - // Load tasks for this story. - const tasksSection = document.createElement('div'); - tasksSection.className = 'story-detail-tasks'; - tasksSection.innerHTML = '<p class="task-meta">Loading tasks…</p>'; - body.appendChild(tasksSection); - - fetch(`${API_BASE}/api/stories/${story.id}/tasks`) - .then(r => r.ok ? r.json() : []) - .then(async tasks => { - tasksSection.innerHTML = ''; - const topLevel = tasks.filter(t => !t.parent_task_id); - if (topLevel.length === 0) { - tasksSection.innerHTML = '<p class="task-meta">No tasks yet.</p>'; - return; - } - const ol = document.createElement('ol'); - ol.className = 'story-detail-task-list'; - for (const t of topLevel) { - const li = document.createElement('li'); - li.className = `story-detail-task story-detail-task-${t.state.toLowerCase()}`; - li.textContent = `${STATE_EMOJI[t.state] || '•'} ${t.name}`; - const subs = tasks.filter(s => s.parent_task_id === t.id); - if (subs.length > 0) { - const ul = document.createElement('ul'); - ul.className = 'story-detail-subtask-list'; - for (const s of subs) { - const sli = document.createElement('li'); - sli.className = `subtask-item subtask-${s.state.toLowerCase()}`; - sli.textContent = `${STATE_EMOJI[s.state] || '•'} ${s.name}`; - ul.appendChild(sli); - } - li.appendChild(ul); - } - ol.appendChild(li); - } - tasksSection.appendChild(ol); - }) - .catch(() => { tasksSection.innerHTML = '<p class="task-meta">Could not load tasks.</p>'; }); - - modal.showModal(); -} - -function openStoryModal() { - const modal = document.getElementById('story-modal'); - if (!modal) return; - - // Reset form state - document.getElementById('story-goal').value = ''; - const planArea = document.getElementById('story-plan-area'); - planArea.innerHTML = ''; - planArea.setAttribute('hidden', ''); - - const btnElaborate = document.getElementById('btn-story-elaborate'); - btnElaborate.disabled = false; - btnElaborate.textContent = 'Elaborate with AI ✦'; - - const btnApprove = document.getElementById('btn-story-approve'); - btnApprove.setAttribute('hidden', ''); - btnApprove._elaboratedPlan = null; - - // Populate project dropdown - fetch(`${BASE_PATH}/api/projects`) - .then(r => r.ok ? r.json() : []) - .then(projects => { - const sel = document.getElementById('story-project'); - sel.innerHTML = ''; - for (const p of projects) { - const opt = document.createElement('option'); - opt.value = p.id; - opt.textContent = p.name; - sel.appendChild(opt); - } - }) - .catch(() => {}); - - modal.showModal(); -} - -function renderElaboratedPlan(plan) { - const planArea = document.getElementById('story-plan-area'); - planArea.innerHTML = ''; - planArea.removeAttribute('hidden'); - - const nameEl = document.createElement('p'); - nameEl.className = 'story-plan-name'; - nameEl.textContent = `Story: ${plan.name}`; - planArea.appendChild(nameEl); - - if (plan.branch_name) { - const branchEl = document.createElement('p'); - branchEl.className = 'story-plan-branch'; - branchEl.textContent = `Branch: ${plan.branch_name}`; - planArea.appendChild(branchEl); - } - - if (plan.tasks && plan.tasks.length > 0) { - const tasksHeader = document.createElement('p'); - tasksHeader.className = 'story-plan-section'; - tasksHeader.textContent = `Tasks (${plan.tasks.length}):`; - planArea.appendChild(tasksHeader); - - const taskList = document.createElement('ol'); - taskList.className = 'story-plan-tasks'; - for (const t of plan.tasks) { - const li = document.createElement('li'); - li.textContent = t.name; - if (t.subtasks && t.subtasks.length > 0) { - const subList = document.createElement('ul'); - for (const s of t.subtasks) { - const subLi = document.createElement('li'); - subLi.textContent = s.name; - subList.appendChild(subLi); - } - li.appendChild(subList); - } - taskList.appendChild(li); - } - planArea.appendChild(taskList); - } - - if (plan.validation && plan.validation.type) { - const valHeader = document.createElement('p'); - valHeader.className = 'story-plan-section'; - valHeader.textContent = `Validation: ${plan.validation.type}`; - planArea.appendChild(valHeader); - } -} - async function renderDropsPanel() { const panel = document.querySelector('[data-panel="drops"] .drops-panel'); if (!panel) return; @@ -3447,9 +3146,6 @@ if (typeof document !== 'undefined') { btn.addEventListener('click', () => switchTab(btn.dataset.tab)); }); - // Task modal - document.getElementById('btn-new-task').addEventListener('click', openTaskModal); - document.getElementById('btn-cancel-task').addEventListener('click', closeTaskModal); // Push notifications button const btnNotify = document.getElementById('btn-notifications'); @@ -3457,213 +3153,6 @@ if (typeof document !== 'undefined') { btnNotify.addEventListener('click', () => enableNotifications(btnNotify)); } - // Validate button - document.getElementById('btn-validate').addEventListener('click', async () => { - const btn = document.getElementById('btn-validate'); - const resultDiv = document.getElementById('validate-result'); - btn.disabled = true; - btn.textContent = 'Checking…'; - try { - const payload = buildValidatePayload(); - const result = await validateTask(payload); - renderValidationResult(result); - } catch (err) { - resultDiv.removeAttribute('hidden'); - resultDiv.textContent = 'Validation failed: ' + err.message; - } finally { - btn.disabled = false; - btn.textContent = 'Validate Instructions'; - } - }); - - // Draft with AI button - const btnElaborate = document.getElementById('btn-elaborate'); - btnElaborate.addEventListener('click', async () => { - const prompt = document.getElementById('elaborate-prompt').value.trim(); - if (!prompt) { - const form = document.getElementById('task-form'); - // Remove previous error - const prev = form.querySelector('.form-error'); - if (prev) prev.remove(); - const errEl = document.createElement('p'); - errEl.className = 'form-error'; - errEl.textContent = 'Please enter a description before drafting.'; - form.querySelector('.elaborate-section').appendChild(errEl); - return; - } - - btnElaborate.disabled = true; - btnElaborate.textContent = 'Drafting…'; - - // Remove any previous errors or banners - const form = document.getElementById('task-form'); - form.querySelectorAll('.form-error, .elaborate-banner').forEach(el => el.remove()); - - try { - const repoUrl = document.getElementById('repository-url').value.trim(); - const result = await elaborateTask(prompt, repoUrl); - - // Populate form fields - const f = document.getElementById('task-form'); - if (result.name) - f.querySelector('[name="name"]').value = result.name; - if (result.agent && result.agent.instructions) - f.querySelector('[name="instructions"]').value = result.agent.instructions; - if (result.repository_url || result.agent?.repository_url) { - document.getElementById('repository-url').value = result.repository_url || result.agent.repository_url; - } - if (result.agent && result.agent.container_image) { - document.getElementById('container-image').value = result.agent.container_image; - } - if (result.agent && result.agent.max_budget_usd != null) - f.querySelector('[name="max_budget_usd"]').value = result.agent.max_budget_usd; - if (result.timeout) - f.querySelector('[name="timeout"]').value = result.timeout; - if (result.priority) { - const sel = f.querySelector('[name="priority"]'); - if ([...sel.options].some(o => o.value === result.priority)) { - sel.value = result.priority; - } - } - - // Show success banner - const banner = document.createElement('p'); - banner.className = 'elaborate-banner'; - banner.textContent = 'AI draft ready — review and submit.'; - document.getElementById('task-form').querySelector('.elaborate-section').appendChild(banner); - - // Auto-validate after elaboration - try { - const result = await validateTask(buildValidatePayload()); - renderValidationResult(result); - } catch (_) { - // silent - elaboration already succeeded, validation is bonus - } - } catch (err) { - const errEl = document.createElement('p'); - errEl.className = 'form-error'; - errEl.textContent = `Elaboration failed: ${err.message}`; - document.getElementById('task-form').querySelector('.elaborate-section').appendChild(errEl); - } finally { - btnElaborate.disabled = false; - btnElaborate.textContent = 'Draft with AI ✦'; - } - }); - - document.getElementById('task-form').addEventListener('submit', async e => { - e.preventDefault(); - // Remove any previous error - const prev = e.target.querySelector('.form-error'); - if (prev) prev.remove(); - - const btn = e.submitter; - btn.disabled = true; - btn.textContent = 'Creating…'; - - try { - const validateResult = document.getElementById('validate-result'); - if (!validateResult.hasAttribute('hidden') && validateResult.dataset.clarity && validateResult.dataset.clarity !== 'clear') { - if (!window.confirm('The validator flagged issues. Create task anyway?')) { - return; - } - } - await createTask(new FormData(e.target)); - } catch (err) { - const errEl = document.createElement('p'); - errEl.className = 'form-error'; - errEl.textContent = err.message; - e.target.appendChild(errEl); - } finally { - btn.disabled = false; - btn.textContent = 'Create & Queue'; - } - }); - - // Story modal - const storyModal = document.getElementById('story-modal'); - if (storyModal) { - document.getElementById('btn-close-story-modal').addEventListener('click', () => storyModal.close()); - - document.getElementById('btn-story-elaborate').addEventListener('click', async () => { - const btn = document.getElementById('btn-story-elaborate'); - const goal = document.getElementById('story-goal').value.trim(); - const projectId = document.getElementById('story-project').value; - - if (!goal) { - const errEl = document.createElement('p'); - errEl.className = 'form-error'; - errEl.textContent = 'Please enter a goal before elaborating.'; - storyModal.querySelector('.story-modal-body').appendChild(errEl); - return; - } - - storyModal.querySelectorAll('.form-error').forEach(el => el.remove()); - btn.disabled = true; - btn.textContent = 'Elaborating…'; - - try { - const res = await fetch(`${BASE_PATH}/api/stories/elaborate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ goal, project_id: projectId }), - }); - if (!res.ok) { - const err = await res.json().catch(() => ({ error: res.statusText })); - throw new Error(err.error || res.statusText); - } - const plan = await res.json(); - renderElaboratedPlan(plan); - - const btnApprove = document.getElementById('btn-story-approve'); - btnApprove._elaboratedPlan = { ...plan, project_id: projectId }; - btnApprove.removeAttribute('hidden'); - } catch (err) { - const errEl = document.createElement('p'); - errEl.className = 'form-error'; - errEl.textContent = `Elaboration failed: ${err.message}`; - storyModal.querySelector('.story-modal-body').appendChild(errEl); - } finally { - btn.disabled = false; - btn.textContent = 'Elaborate with AI ✦'; - } - }); - - document.getElementById('btn-story-approve').addEventListener('click', async () => { - const btn = document.getElementById('btn-story-approve'); - const plan = btn._elaboratedPlan; - if (!plan) return; - - btn.disabled = true; - btn.textContent = 'Approving…'; - - try { - const res = await fetch(`${BASE_PATH}/api/stories/approve`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(plan), - }); - if (!res.ok) { - const err = await res.json().catch(() => ({ error: res.statusText })); - throw new Error(err.error || res.statusText); - } - storyModal.close(); - renderStoriesPanel(); - } catch (err) { - const errEl = document.createElement('p'); - errEl.className = 'form-error'; - errEl.textContent = `Approve failed: ${err.message}`; - storyModal.querySelector('.story-modal-body').appendChild(errEl); - btn.disabled = false; - btn.textContent = 'Approve & Queue'; - } - }); - } - - // Story detail modal - const storyDetailModal = document.getElementById('story-detail-modal'); - if (storyDetailModal) { - document.getElementById('btn-close-story-detail').addEventListener('click', () => storyDetailModal.close()); - } }); } diff --git a/web/index.html b/web/index.html index 0632cd7..5aa7b44 100644 --- a/web/index.html +++ b/web/index.html @@ -36,11 +36,10 @@ </select> <button id="btn-notifications" class="btn-secondary" title="Enable push notifications">🔔</button> <button id="btn-start-next" class="btn-secondary">Start Next</button> - <button id="btn-new-task" class="btn-primary">New Task</button> </div> + <div id="budget-bar" class="budget-bar"></div> </header> <nav class="tab-bar"> - <button class="tab" data-tab="stories" title="Stories">📖</button> <button class="tab active" data-tab="queue" title="Queue">⏳</button> <button class="tab" data-tab="interrupted" title="Interrupted">⚠️<span class="tab-count-badge" hidden></span></button> <button class="tab" data-tab="ready" title="Ready">✅<span class="tab-count-badge" hidden></span></button> @@ -69,57 +68,12 @@ <div data-panel="drops" hidden> <div class="drops-panel"></div> </div> - <div data-panel="stories" hidden></div> <div data-panel="stats" hidden></div> <div data-panel="settings" hidden> <p class="task-meta" style="padding:1rem">Settings coming soon.</p> </div> </main> - <dialog id="task-modal"> - <form id="task-form" method="dialog"> - <h2>New Task</h2> - <div class="elaborate-section"> - <label>Describe what you want the agent to do - <textarea id="elaborate-prompt" rows="3" - placeholder="e.g. run tests with race detector and check coverage"></textarea> - </label> - <button type="button" id="btn-elaborate" class="btn-secondary"> - Draft with AI ✦ - </button> - <p class="elaborate-hint">AI will fill in the form fields below. You can edit before submitting.</p> - </div> - <hr class="form-divider"> - <label>Repository URL - <input name="repository_url" id="repository-url" placeholder="https://github.com/user/repo.git" required> - </label> - <label>Container Image - <input name="container_image" id="container-image" placeholder="claudomator-agent:latest" value="claudomator-agent:latest"> - </label> - <label>Name <input name="name" required></label> - <label>Instructions <textarea name="instructions" rows="6" required></textarea></label> - <div class="validate-section"> - <button type="button" id="btn-validate" class="btn-secondary"> - Validate Instructions - </button> - <div id="validate-result" hidden></div> - </div> - <label>Max Budget (USD) <input name="max_budget_usd" type="number" step="0.01" value="1.00"></label> - <label>Timeout <input name="timeout" value="15m"></label> - <label>Priority - <select name="priority"> - <option value="normal" selected>Normal</option> - <option value="high">High</option> - <option value="low">Low</option> - </select> - </label> - <div class="form-actions"> - <button type="button" id="btn-cancel-task">Cancel</button> - <button type="submit" class="btn-primary">Create & Queue</button> - </div> - </form> - </dialog> - <!-- Side panel backdrop --> <div id="task-panel-backdrop" class="panel-backdrop" hidden></div> @@ -142,36 +96,6 @@ </div> </dialog> - <!-- New Story modal --> - <dialog id="story-modal"> - <div class="story-modal-header"> - <h2>New Story</h2> - <button id="btn-close-story-modal" class="btn-close-panel" aria-label="Close">✕</button> - </div> - <div class="story-modal-body"> - <label>Project - <select id="story-project"></select> - </label> - <label>Goal - <textarea id="story-goal" rows="4" placeholder="Describe the feature or change you want to build…"></textarea> - </label> - <button type="button" id="btn-story-elaborate" class="btn-secondary">Elaborate with AI ✦</button> - <div id="story-plan-area" hidden></div> - <div class="form-actions"> - <button type="button" id="btn-story-approve" class="btn-primary" hidden>Approve & Queue</button> - </div> - </div> - </dialog> - - <!-- Story detail modal --> - <dialog id="story-detail-modal"> - <div class="story-modal-header"> - <h2 id="story-detail-name">Story</h2> - <button id="btn-close-story-detail" class="btn-close-panel" aria-label="Close">✕</button> - </div> - <div id="story-detail-body" class="story-detail-body meta-grid"></div> - </dialog> - <script type="module" src="app.js"></script> </body> </html> diff --git a/web/style.css b/web/style.css index d3b01d0..b6f0484 100644 --- a/web/style.css +++ b/web/style.css @@ -1989,3 +1989,69 @@ dialog label select:focus { opacity: 0.85; padding: 0.1rem 0; } + +/* Event timeline (task observability stream) */ +.event-timeline { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} +.event-timeline__item { + display: flex; + align-items: baseline; + gap: 10px; + padding: 6px 10px; + background: var(--bg); + border: 1px solid var(--border); + border-left: 3px solid var(--border); + border-radius: 6px; + font-size: 0.85rem; +} +.event-timeline__item--state_change { border-left-color: var(--state-queued); } +.event-timeline__item--clarification_request { border-left-color: var(--state-blocked); } +.event-timeline__item--clarification_answer { border-left-color: var(--state-blocked); } +.event-timeline__item--summary { border-left-color: var(--state-completed); } +.event-timeline__item--subtask_spawned { border-left-color: var(--accent); } +.event-timeline__item--execution_started, +.event-timeline__item--execution_ended { border-left-color: var(--state-running); } +.event-timeline__actor { + flex: 0 0 auto; + text-transform: uppercase; + font-size: 0.65rem; + letter-spacing: 0.04em; + color: var(--text-muted); + min-width: 56px; +} +.event-timeline__text { + color: var(--text); + word-break: break-word; +} +.event-timeline__empty { + color: var(--text-muted); + font-size: 0.85rem; + padding: 6px 10px; +} + +/* Budget headroom chips */ +.budget-bar { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} +.budget-chip { + font-size: 0.72rem; + padding: 2px 8px; + border-radius: 999px; + background: var(--bg); + border: 1px solid var(--border); + color: var(--text-muted); + white-space: nowrap; +} +.budget-chip--low { + border-color: var(--state-budget-exceeded); + color: var(--state-budget-exceeded); +} diff --git a/web/test/budget.test.mjs b/web/test/budget.test.mjs new file mode 100644 index 0000000..35d5256 --- /dev/null +++ b/web/test/budget.test.mjs @@ -0,0 +1,58 @@ +// budget.test.mjs — Unit tests for budget headroom display. +// +// Run with: node --test web/test/budget.test.mjs + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { formatBudgetHeadroom, renderBudgetHeadroom } from '../app.js'; + +function makeDoc() { + return { + createElement(tag) { + return { + tag, + className: '', + textContent: '', + children: [], + appendChild(child) { this.children.push(child); return child; }, + }; + }, + }; +} + +describe('formatBudgetHeadroom', () => { + it('formats a limited provider with percent and dollars', () => { + assert.equal( + formatBudgetHeadroom({ provider: 'claude', limited: true, limit_usd: 10, remaining_usd: 6, fraction_remaining: 0.6 }), + 'Claude: 60% left ($6.00 of $10.00)', + ); + }); + + it('returns empty string for unlimited providers', () => { + assert.equal(formatBudgetHeadroom({ provider: 'local', limited: false }), ''); + assert.equal(formatBudgetHeadroom(null), ''); + }); +}); + +describe('renderBudgetHeadroom', () => { + it('renders one chip per limited provider, skipping unlimited', () => { + const wrap = renderBudgetHeadroom([ + { provider: 'claude', limited: true, limit_usd: 10, remaining_usd: 6, fraction_remaining: 0.6 }, + { provider: 'local', limited: false }, + ], makeDoc()); + assert.equal(wrap.children.length, 1); + assert.equal(wrap.children[0].className, 'budget-chip'); + assert.match(wrap.children[0].textContent, /Claude: 60% left/); + }); + + it('flags providers under 20% remaining with --low', () => { + const wrap = renderBudgetHeadroom([ + { provider: 'gemini', limited: true, limit_usd: 5, remaining_usd: 0.5, fraction_remaining: 0.1 }, + ], makeDoc()); + assert.equal(wrap.children[0].className, 'budget-chip budget-chip--low'); + }); + + it('returns null when doc is null', () => { + assert.equal(renderBudgetHeadroom([], null), null); + }); +}); diff --git a/web/test/event-timeline.test.mjs b/web/test/event-timeline.test.mjs new file mode 100644 index 0000000..20ef61a --- /dev/null +++ b/web/test/event-timeline.test.mjs @@ -0,0 +1,112 @@ +// event-timeline.test.mjs — Unit tests for the event timeline component. +// +// Run with: node --test web/test/event-timeline.test.mjs + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { formatEventText, renderEventTimeline, fetchTaskEvents } from '../app.js'; + +function makeDoc() { + return { + createElement(tag) { + return { + tag, + className: '', + textContent: '', + children: [], + appendChild(child) { this.children.push(child); return child; }, + }; + }, + }; +} + +describe('formatEventText', () => { + it('formats state_change with from/to', () => { + assert.equal( + formatEventText({ kind: 'state_change', payload: { from: 'QUEUED', to: 'RUNNING' } }), + 'State: QUEUED → RUNNING', + ); + }); + + it('formats summary text', () => { + assert.equal( + formatEventText({ kind: 'summary', payload: { text: 'did the thing' } }), + 'Summary: did the thing', + ); + }); + + it('formats clarification_request and answer', () => { + assert.equal( + formatEventText({ kind: 'clarification_request', payload: { text: 'which branch?' } }), + 'Question: which branch?', + ); + assert.equal( + formatEventText({ kind: 'clarification_answer', payload: { answer: 'main' } }), + 'Answer: main', + ); + }); + + it('formats subtask_spawned by name', () => { + assert.equal( + formatEventText({ kind: 'subtask_spawned', payload: { name: 'write tests', subtask_id: 'x' } }), + 'Spawned subtask: write tests', + ); + }); + + it('falls back to kind for unknown events', () => { + assert.equal(formatEventText({ kind: 'mystery', payload: {} }), 'mystery'); + }); + + it('handles null/empty defensively', () => { + assert.equal(formatEventText(null), ''); + assert.equal(formatEventText({ kind: 'agent_message', payload: { message: 'hi' } }), 'hi'); + }); +}); + +describe('renderEventTimeline', () => { + it('renders one item per event with actor and text', () => { + const events = [ + { kind: 'state_change', actor: 'system', payload: { from: 'PENDING', to: 'QUEUED' } }, + { kind: 'summary', actor: 'agent', payload: { text: 'done' } }, + ]; + const ul = renderEventTimeline(events, makeDoc()); + assert.equal(ul.className, 'event-timeline'); + assert.equal(ul.children.length, 2); + // each item has [actor, text] + assert.equal(ul.children[0].children[0].textContent, 'system'); + assert.equal(ul.children[0].children[1].textContent, 'State: PENDING → QUEUED'); + assert.equal(ul.children[1].children[1].textContent, 'Summary: done'); + }); + + it('renders an empty-state item for no events', () => { + const ul = renderEventTimeline([], makeDoc()); + assert.equal(ul.children.length, 1); + assert.equal(ul.children[0].className, 'event-timeline__empty'); + }); + + it('returns null when doc is null', () => { + assert.equal(renderEventTimeline([], null), null); + }); +}); + +describe('fetchTaskEvents', () => { + it('returns parsed array on success', async () => { + const stub = async () => ({ ok: true, json: async () => [{ kind: 'summary', payload: {} }] }); + const events = await fetchTaskEvents('t1', stub); + assert.equal(events.length, 1); + }); + + it('returns [] on non-ok response', async () => { + const stub = async () => ({ ok: false, json: async () => ({}) }); + assert.deepEqual(await fetchTaskEvents('t1', stub), []); + }); + + it('returns [] when fetch throws', async () => { + const stub = async () => { throw new Error('network'); }; + assert.deepEqual(await fetchTaskEvents('t1', stub), []); + }); + + it('returns [] when no fetch implementation is available', async () => { + assert.deepEqual(await fetchTaskEvents('t1', null), []); + }); +}); diff --git a/web/test/new-task-button.test.mjs b/web/test/new-task-button.test.mjs deleted file mode 100644 index 45a3548..0000000 --- a/web/test/new-task-button.test.mjs +++ /dev/null @@ -1,24 +0,0 @@ -// new-task-button.test.mjs — visibility contract for the New Task button -// -// The New Task button lives in the global header and must be visible on all tabs. -// Run with: node --test web/test/new-task-button.test.mjs - -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; -import { newTaskButtonShouldShowOnTab } from '../app.js'; - -const ALL_TABS = ['tasks', 'active', 'running', 'stats']; - -describe('new task button visibility', () => { - for (const tab of ALL_TABS) { - it(`is visible on "${tab}" tab`, () => { - assert.equal(newTaskButtonShouldShowOnTab(tab), true, `expected button to be visible on tab "${tab}"`); - }); - } - - it('is visible on any unknown future tab', () => { - assert.equal(newTaskButtonShouldShowOnTab('help'), true); - assert.equal(newTaskButtonShouldShowOnTab('templates'), true); - assert.equal(newTaskButtonShouldShowOnTab(''), true); - }); -}); diff --git a/web/test/stories.test.mjs b/web/test/stories.test.mjs deleted file mode 100644 index 263f202..0000000 --- a/web/test/stories.test.mjs +++ /dev/null @@ -1,164 +0,0 @@ -// stories.test.mjs — TDD tests for stories UI functions. -// -// Run with: node --test web/test/stories.test.mjs - -import { describe, it, beforeEach } from 'node:test'; -import assert from 'node:assert/strict'; -import { renderStoryCard, storyStatusLabel } from '../app.js'; - -// ── Minimal DOM mock ────────────────────────────────────────────────────────── - -function makeMockDoc() { - function createElement(tag) { - return { - tag, - className: '', - textContent: '', - innerHTML: '', - hidden: false, - dataset: {}, - children: [], - _listeners: {}, - style: {}, - appendChild(child) { this.children.push(child); return child; }, - prepend(...nodes) { this.children.unshift(...nodes); }, - append(...nodes) { nodes.forEach(n => this.children.push(n)); }, - addEventListener(ev, fn) { this._listeners[ev] = fn; }, - querySelector(sel) { - const cls = sel.startsWith('.') ? sel.slice(1) : null; - function search(el) { - if (cls && el.className && el.className.split(' ').includes(cls)) return el; - for (const c of el.children || []) { - const found = search(c); - if (found) return found; - } - return null; - } - return search(this); - }, - querySelectorAll(sel) { - const cls = sel.startsWith('.') ? sel.slice(1) : null; - const results = []; - function search(el) { - if (cls && el.className && el.className.split(' ').includes(cls)) results.push(el); - for (const c of el.children || []) search(c); - } - search(this); - return results; - }, - }; - } - return { createElement }; -} - -function makeStory(overrides = {}) { - return { - id: 'story-1', - name: 'Add login page', - project_id: 'claudomator', - branch_name: 'story/add-login-page', - status: 'PENDING', - created_at: '2026-03-25T10:00:00Z', - updated_at: '2026-03-25T10:00:00Z', - ...overrides, - }; -} - -// ── storyStatusLabel ────────────────────────────────────────────────────────── - -describe('storyStatusLabel', () => { - it('returns human-readable label for PENDING', () => { - assert.equal(storyStatusLabel('PENDING'), 'Pending'); - }); - - it('returns human-readable label for IN_PROGRESS', () => { - assert.equal(storyStatusLabel('IN_PROGRESS'), 'In Progress'); - }); - - it('returns human-readable label for SHIPPABLE', () => { - assert.equal(storyStatusLabel('SHIPPABLE'), 'Shippable'); - }); - - it('returns human-readable label for DEPLOYED', () => { - assert.equal(storyStatusLabel('DEPLOYED'), 'Deployed'); - }); - - it('returns human-readable label for VALIDATING', () => { - assert.equal(storyStatusLabel('VALIDATING'), 'Validating'); - }); - - it('returns human-readable label for REVIEW_READY', () => { - assert.equal(storyStatusLabel('REVIEW_READY'), 'Review Ready'); - }); - - it('returns human-readable label for NEEDS_FIX', () => { - assert.equal(storyStatusLabel('NEEDS_FIX'), 'Needs Fix'); - }); - - it('falls back to the raw status for unknown values', () => { - assert.equal(storyStatusLabel('UNKNOWN_STATE'), 'UNKNOWN_STATE'); - }); -}); - -// ── renderStoryCard ─────────────────────────────────────────────────────────── - -describe('renderStoryCard', () => { - let doc; - beforeEach(() => { doc = makeMockDoc(); }); - - it('renders the story name', () => { - const card = renderStoryCard(makeStory(), doc); - function findText(el, text) { - if (el.textContent === text) return true; - return (el.children || []).some(c => findText(c, text)); - } - assert.ok(findText(card, 'Add login page'), 'card should contain story name'); - }); - - it('has story-card class', () => { - const card = renderStoryCard(makeStory(), doc); - assert.ok(card.className.split(' ').includes('story-card'), 'root element should have story-card class'); - }); - - it('status badge has data-status matching story status', () => { - const card = renderStoryCard(makeStory({ status: 'IN_PROGRESS' }), doc); - const badge = card.querySelector('.story-status-badge'); - assert.ok(badge, 'badge element should exist'); - assert.equal(badge.dataset.status, 'IN_PROGRESS'); - }); - - it('status badge shows human-readable label', () => { - const card = renderStoryCard(makeStory({ status: 'REVIEW_READY' }), doc); - const badge = card.querySelector('.story-status-badge'); - assert.equal(badge.textContent, 'Review Ready'); - }); - - it('shows project_id', () => { - const card = renderStoryCard(makeStory({ project_id: 'nav' }), doc); - function findText(el, text) { - if (el.textContent === text) return true; - return (el.children || []).some(c => findText(c, text)); - } - assert.ok(findText(card, 'nav'), 'card should show project_id'); - }); - - it('shows branch_name when present', () => { - const card = renderStoryCard(makeStory({ branch_name: 'story/my-feature' }), doc); - function findText(el, text) { - if (el.textContent && el.textContent.includes(text)) return true; - return (el.children || []).some(c => findText(c, text)); - } - assert.ok(findText(card, 'story/my-feature'), 'card should show branch_name'); - }); - - it('does not show branch section when branch_name is empty', () => { - const card = renderStoryCard(makeStory({ branch_name: '' }), doc); - const branchEl = card.querySelector('.story-branch'); - assert.ok(!branchEl, 'no .story-branch element when branch is empty'); - }); - - it('card dataset.storyId is set to story id', () => { - const card = renderStoryCard(makeStory({ id: 'abc-123' }), doc); - assert.equal(card.dataset.storyId, 'abc-123'); - }); -}); |
