beadboard/tests/api/agent-status-route.test.ts

85 lines
2.8 KiB
TypeScript
Raw Normal View History

[beadboard] Add agent-dispatch and agent-status API routes ## Context The Dispatch-to-Agent button in the right-panel (previous commit) calls two endpoints that did not yet exist server-side: - `POST /api/agent-dispatch` — resolve the bead via the same Dolt pool the existing `/api/beads/read` route uses, build a prompt from it, and forward to `claude-agent-service`'s `/execute` endpoint with a bearer token. This is the server-side trust boundary: the bearer token lives in env (never in the browser), and the bead is re-read from Dolt (never trusted from the client payload) so a malicious client cannot inject a prompt. - `GET /api/agent-status` — proxy `claude-agent-service`'s `/health` endpoint (which already returns `{status, busy}`), with a 2 s in- memory cache so 5 s UI polls across multiple open tabs don't hammer the service. The claude-agent-service serialises jobs behind an `asyncio.Lock` — a second `/execute` while one is running returns HTTP 409 "Agent is busy". We surface that 409 through to the browser unchanged so the UI can show the right toast/status line without re-mapping status codes. ``` client ──► /api/agent-dispatch ──► claude-agent-service /execute │ (asyncio.Lock guards) └─► reads bead from Dolt pool (same path as /api/beads/read) └─► buildDispatchPrompt(bead) └─► POST {prompt, agent, budget, timeout} + Bearer token client ──► /api/agent-status (2s cache) ──► /health ──► {busy: bool} ``` ## This change ### `src/app/api/agent-dispatch/route.ts` - `POST {taskId: string}` handler. - Validates JSON body and non-empty taskId (→ 400). - Early 500 if `CLAUDE_AGENT_SERVICE_URL` or `CLAUDE_AGENT_BEARER_TOKEN` is missing — fail fast, fail loud. - Resolves the bead via `readIssuesFromDisk({preferBd: true})`, which uses the existing Dolt client (and falls back to `issues.jsonl`). Filtering by id after is acceptable at BeadBoard's scale (~hundreds of beads) and avoids introducing a new single-bead query path. - 400 when the bead is missing, or when `acceptance_criteria?.trim()` is empty — defense in depth alongside the UI disable. The button should already hide in these cases, but a curl'd POST must still be rejected. - Forwards to `${CLAUDE_AGENT_SERVICE_URL}/execute` with agent `beads-task-runner`, max_budget_usd 5, timeout_seconds 900 (matches the values in the task spec). - Passes through 409 verbatim. Other upstream errors collapse to 502. ### `src/app/api/agent-status/route.ts` - `GET` handler, module-level snapshot cache with 2 s TTL to avoid hammering `/health`. - In-flight de-dup: a single pending `fetchRemoteStatus()` is shared across concurrent requests so we only hit the upstream once per window even under bursty load. - When `CLAUDE_AGENT_SERVICE_URL` is unset, returns `{busy: false}` and skips the fetch entirely — this is how the dev server boots before the service env is configured. - HTTP 503 from upstream is interpreted as `busy: true` (future-proofing in case the service swaps 409 for 503 on overload). - Any network error degrades gracefully to `{busy: false}` — the 409 path on `/api/agent-dispatch` is the authoritative gate. ### Test coverage - `tests/api/agent-dispatch-route.test.ts` (3 cases): invalid JSON body, missing taskId, missing env returns 500. - `tests/api/agent-status-route.test.ts` (3 cases): unset service URL returns `{busy: false}` without a fetch, `/health busy:true` proxies through, HTTP 503 maps to busy=true. Uses a `globalThis.fetch` stub and cache-busting query params on dynamic import so each case starts from a fresh module snapshot. ## What is NOT in this change - End-to-end happy-path coverage (bead loads, fetch returns 200, route yields job_id) would require mocking `readIssuesFromDisk` — that's a bigger refactor than this commit warrants. Live integration happens once the pipeline is wired. - No retry/backoff on upstream failures. 502 passes through; the client decides whether to retry. - No auth on the routes themselves — they inherit the Next.js app's session model (same as all other `/api/*` routes today). ## Test Plan ### Automated ``` $ node --import tsx --test tests/api/agent-dispatch-route.test.ts \ tests/api/agent-status-route.test.ts # tests 6 pass 6 fail 0 duration_ms 1560 ``` All six route cases pass. Typecheck output shows only the pre-existing `OrchestratorChatMessage` gap in `left-panel.tsx`. Lint output is unchanged from `main` (1 pre-existing `no-require-imports` error in `src/lib/bb-pi-bootstrap.ts`). ### Manual Verification 1. Export env: ``` export CLAUDE_AGENT_SERVICE_URL=http://claude-agent-service.claude-agent.svc.cluster.local:8080 export CLAUDE_AGENT_BEARER_TOKEN=$(vault kv get -field=api_bearer_token secret/claude-agent-service) ``` 2. `npm run dev` 3. `curl -s http://localhost:3000/api/agent-status` → `{"busy":false}` (if the service is reachable; `{"busy":true}` if a job is running). 4. `curl -X POST http://localhost:3000/api/agent-dispatch \ -H 'content-type: application/json' \ -d '{"taskId":"beadboard-xyz"}'` - 400 if bead does not exist or lacks acceptance criteria. - 200 `{"job_id":"..."}` on success. - 409 `{"error":"Agent is busy"}` when an agent is already running. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:07:21 +00:00
import test from 'node:test';
import assert from 'node:assert/strict';
import { GET } from '../../src/app/api/agent-status/route';
type FetchFn = typeof fetch;
function withFetchStub<T>(stub: FetchFn, work: () => Promise<T>): Promise<T> {
const original = globalThis.fetch;
globalThis.fetch = stub;
return work().finally(() => {
globalThis.fetch = original;
});
}
function captureEnv(keys: string[]): () => void {
const saved = new Map<string, string | undefined>();
for (const key of keys) saved.set(key, process.env[key]);
return () => {
for (const key of keys) {
const prior = saved.get(key);
if (prior === undefined) delete process.env[key];
else process.env[key] = prior;
}
};
}
test('GET returns {busy:false} when CLAUDE_AGENT_SERVICE_URL is unset', async () => {
const restore = captureEnv(['CLAUDE_AGENT_SERVICE_URL']);
delete process.env.CLAUDE_AGENT_SERVICE_URL;
try {
await withFetchStub(async () => {
throw new Error('fetch should not be called when URL is unset');
}, async () => {
// Bust the module cache so cached snapshots from prior tests do not leak.
await import(`../../src/app/api/agent-status/route?url-unset=${Date.now()}`);
const response = await GET();
const body = await response.json();
assert.equal(response.status, 200);
assert.equal(body.busy, false);
});
} finally {
restore();
}
});
test('GET proxies busy=true when /health returns busy=true', async () => {
const restore = captureEnv(['CLAUDE_AGENT_SERVICE_URL']);
process.env.CLAUDE_AGENT_SERVICE_URL = 'http://fake-agent.local';
try {
const stub: FetchFn = async () => new Response(JSON.stringify({ status: 'ok', busy: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
await withFetchStub(stub, async () => {
const mod = await import(`../../src/app/api/agent-status/route?busy-true=${Date.now()}`) as { GET: typeof GET };
const response = await mod.GET();
const body = await response.json();
assert.equal(response.status, 200);
assert.equal(body.busy, true);
});
} finally {
restore();
}
});
test('GET returns busy=true when /health returns HTTP 503', async () => {
const restore = captureEnv(['CLAUDE_AGENT_SERVICE_URL']);
process.env.CLAUDE_AGENT_SERVICE_URL = 'http://fake-agent.local';
try {
const stub: FetchFn = async () => new Response('busy', { status: 503 });
await withFetchStub(stub, async () => {
const mod = await import(`../../src/app/api/agent-status/route?busy-503=${Date.now()}`) as { GET: typeof GET };
const response = await mod.GET();
const body = await response.json();
assert.equal(response.status, 200);
assert.equal(body.busy, true);
});
} finally {
restore();
}
});