[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>
This commit is contained in:
Viktor Barzin 2026-04-18 14:07:21 +00:00
parent 394a771b67
commit a7b4f7ba32
5 changed files with 311 additions and 1 deletions

View file

@ -0,0 +1,98 @@
import { NextResponse } from 'next/server';
import { buildDispatchPrompt } from '../../../lib/dispatch-prompt';
import { readIssuesFromDisk } from '../../../lib/read-issues';
import type { BeadIssue } from '../../../lib/types';
export const dynamic = 'force-dynamic';
const DISPATCH_AGENT = 'beads-task-runner';
const DEFAULT_MAX_BUDGET_USD = 5;
const DEFAULT_TIMEOUT_SECONDS = 900;
interface DispatchRequestBody {
taskId?: unknown;
}
async function findBead(taskId: string): Promise<BeadIssue | null> {
const issues = await readIssuesFromDisk({ projectRoot: process.cwd(), preferBd: true });
return issues.find((issue) => issue.id === taskId) ?? null;
}
function missingEnv(): Response | null {
const serviceUrl = process.env.CLAUDE_AGENT_SERVICE_URL;
const token = process.env.CLAUDE_AGENT_BEARER_TOKEN;
if (!serviceUrl || !token) {
return NextResponse.json(
{ error: 'Claude agent service is not configured (CLAUDE_AGENT_SERVICE_URL / CLAUDE_AGENT_BEARER_TOKEN missing).' },
{ status: 500 },
);
}
return null;
}
export async function POST(request: Request): Promise<Response> {
let body: DispatchRequestBody;
try {
body = (await request.json()) as DispatchRequestBody;
} catch {
return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 });
}
const taskId = typeof body.taskId === 'string' ? body.taskId.trim() : '';
if (!taskId) {
return NextResponse.json({ error: 'taskId is required.' }, { status: 400 });
}
const envError = missingEnv();
if (envError) return envError;
const bead = await findBead(taskId);
if (!bead) {
return NextResponse.json({ error: `Bead ${taskId} not found.` }, { status: 400 });
}
if (!bead.acceptance_criteria?.trim()) {
return NextResponse.json({ error: `Bead ${taskId} is missing acceptance criteria.` }, { status: 400 });
}
const prompt = buildDispatchPrompt(bead);
const serviceUrl = process.env.CLAUDE_AGENT_SERVICE_URL!;
const token = process.env.CLAUDE_AGENT_BEARER_TOKEN!;
try {
const response = await fetch(`${serviceUrl.replace(/\/$/, '')}/execute`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
prompt,
agent: DISPATCH_AGENT,
max_budget_usd: DEFAULT_MAX_BUDGET_USD,
timeout_seconds: DEFAULT_TIMEOUT_SECONDS,
}),
});
if (response.status === 409) {
return NextResponse.json({ error: 'Agent is busy' }, { status: 409 });
}
const payload = await response.json().catch(() => null) as { job_id?: string; detail?: string } | null;
if (!response.ok) {
const message = payload?.detail ?? `Agent service returned HTTP ${response.status}.`;
return NextResponse.json({ error: message }, { status: 502 });
}
if (!payload?.job_id) {
return NextResponse.json({ error: 'Agent service response missing job_id.' }, { status: 502 });
}
return NextResponse.json({ job_id: payload.job_id }, { status: 200 });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to reach claude-agent-service.';
return NextResponse.json({ error: message }, { status: 502 });
}
}

View file

@ -0,0 +1,66 @@
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const CACHE_TTL_MS = 2000;
interface StatusSnapshot {
busy: boolean;
fetchedAt: number;
}
let cache: StatusSnapshot | null = null;
let inflight: Promise<StatusSnapshot> | null = null;
async function fetchRemoteStatus(): Promise<StatusSnapshot> {
const serviceUrl = process.env.CLAUDE_AGENT_SERVICE_URL;
if (!serviceUrl) {
return { busy: false, fetchedAt: Date.now() };
}
const now = Date.now();
try {
const response = await fetch(`${serviceUrl.replace(/\/$/, '')}/health`, {
cache: 'no-store',
headers: { 'Accept': 'application/json' },
});
if (response.status === 503) {
return { busy: true, fetchedAt: now };
}
if (!response.ok) {
return { busy: false, fetchedAt: now };
}
const payload = await response.json().catch(() => null) as { busy?: boolean } | null;
return { busy: Boolean(payload?.busy), fetchedAt: now };
} catch {
return { busy: false, fetchedAt: now };
}
}
async function getStatus(): Promise<StatusSnapshot> {
const now = Date.now();
if (cache && now - cache.fetchedAt < CACHE_TTL_MS) {
return cache;
}
if (inflight) {
return inflight;
}
inflight = fetchRemoteStatus().then((snapshot) => {
cache = snapshot;
return snapshot;
}).finally(() => {
inflight = null;
});
return inflight;
}
export async function GET(): Promise<Response> {
const snapshot = await getStatus();
return NextResponse.json({ busy: snapshot.busy });
}