/** * Orchestrator HTTP routes. * * Exposes run lifecycle (start/list/get/delete), interactive control * (steer/stop), and a Server-Sent-Events stream of a run's slim event log. The * agent itself runs in a `pi` subprocess and drives the board via the separate * `internal` router; these routes are for the human-facing UI. */ import { Hono } from 'hono'; import { streamSSE } from 'hono/streaming'; import { db } from '../db.js'; import type { Card } from '../types.js'; import { hydrateCard } from './kanban.js'; import { RunNotFoundError, runManager } from '../orchestrator/runs.js'; import { commitsAheadOfHead, diffPatch, diffStat, mainDirtySummary, isWorktreePresent, mergeBranch, } from '../orchestrator/worktrees.js'; export const orchestrator = new Hono(); /** Hydrate a full card (with references/tags/comments) for prompt building. */ function loadCard(cardId: string): Card | undefined { const row = db.prepare('SELECT * FROM cards WHERE id = ?').get(cardId); if (!row) return undefined; return hydrateCard(row as Parameters[0]); } /** List recent runs (optionally filtered to a card). */ orchestrator.get('/runs', (c) => { const cardId = c.req.query('cardId'); const runs = cardId ? runManager.listForCard(cardId) : runManager.list(); return c.json({ runs }); }); /** Get a single run, including its persisted event history. */ orchestrator.get('/runs/:id', (c) => { const run = runManager.get(c.req.param('id')); if (!run) return c.json({ error: 'run not found' }, 404); const since = Number(c.req.query('since') ?? '0'); return c.json({ run, events: runManager.events(run.id, Number.isFinite(since) ? since : 0) }); }); /** Start a new run for a card. */ orchestrator.post('/runs', async (c) => { const body = await c.req.json().catch(() => ({}) as Record); const cardId = typeof body.cardId === 'string' ? body.cardId : ''; if (!cardId) return c.json({ error: "'cardId' is required" }, 400); const card = loadCard(cardId); if (!card) return c.json({ error: 'card not found' }, 404); const prompt = typeof body.prompt === 'string' ? body.prompt : undefined; const useWorktree = body.useWorktree !== false; // default true const cleanupOnFinish = body.cleanupOnFinish === true; const refineRunId = typeof body.refineRunId === 'string' ? body.refineRunId : undefined; try { const run = runManager.start(card, { cardId, prompt, useWorktree, cleanupOnFinish, refineRunId }); return c.json({ run }, 201); } catch (err) { const message = err instanceof Error ? err.message : 'failed to start run'; // A bad refinement target (missing run / cleaned-up worktree) is a client error. const status = message.startsWith('refinement') ? 409 : 500; return c.json({ error: message }, status); } }); /** Steer or follow-up an active run. */ orchestrator.post('/runs/:id/message', async (c) => { const id = c.req.param('id'); const body = await c.req.json().catch(() => ({}) as Record); const text = typeof body.text === 'string' ? body.text.trim() : ''; const mode = body.mode === 'followUp' ? 'followUp' : 'steer'; if (!text) return c.json({ error: "'text' is required" }, 400); try { runManager.message(id, text, mode); return c.json({ ok: true }); } catch (err) { if (err instanceof RunNotFoundError) return c.json({ error: 'run is not active' }, 409); throw err; } }); /** Stop an active run. */ orchestrator.post('/runs/:id/stop', (c) => { const id = c.req.param('id'); try { runManager.stop(id); return c.json({ ok: true }); } catch (err) { if (err instanceof RunNotFoundError) return c.json({ error: 'run is not active' }, 409); throw err; } }); /** Delete a (settled) run and reclaim its worktree. */ orchestrator.delete('/runs/:id', (c) => { const id = c.req.param('id'); try { runManager.remove(id); return c.body(null, 204); } catch (err) { const message = err instanceof Error ? err.message : 'failed to delete run'; const status = message.includes('active') ? 409 : 404; return c.json({ error: message }, status); } }); // --- worktree review & playtesting ---------------------------------------- /** Diff of a run's branch vs main (commits, --stat, and the capped patch). */ orchestrator.get('/runs/:id/diff', (c) => { const run = runManager.get(c.req.param('id')); if (!run) return c.json({ error: 'run not found' }, 404); if (!run.branch || !run.worktreePath || !isWorktreePresent(run.worktreePath)) { return c.json({ error: 'run has no worktree to diff' }, 409); } const { patch, truncated } = diffPatch(run.branch); return c.json({ branch: run.branch, commits: commitsAheadOfHead(run.branch), stat: diffStat(run.branch), patch, truncated, }); }); /** Merge a run's branch into the main worktree's checked-out branch. */ orchestrator.post('/runs/:id/merge', (c) => { const run = runManager.get(c.req.param('id')); if (!run) return c.json({ error: 'run not found' }, 404); if (!run.branch) return c.json({ error: 'run has no branch to merge' }, 409); // Refuse if the main worktree is dirty — merging would be unsafe. const dirty = mainDirtySummary(); if (dirty) return c.json({ error: `Cannot merge: ${dirty}` }, 409); // The merge outcome (success or conflict-aborted) is in the body; only hard // preconditions (above) throw HTTP errors so the UI can read `ok` directly. return c.json(mergeBranch(run.branch)); }); /** Whether a Bevy playtest is running for a run. */ orchestrator.get('/runs/:id/bevy', (c) => { const run = runManager.get(c.req.param('id')); if (!run) return c.json({ error: 'run not found' }, 404); return c.json({ running: runManager.bevyRunning(run.id) }); }); /** Launch a Bevy playtest (`cargo run`) in a run's worktree. */ orchestrator.post('/runs/:id/bevy', (c) => { const id = c.req.param('id'); try { runManager.startBevy(id); return c.json({ ok: true }); } catch (err) { if (err instanceof RunNotFoundError) return c.json({ error: 'run not found' }, 404); const message = err instanceof Error ? err.message : 'failed to start Bevy'; const status = message.includes('no worktree') || message.includes('already running') ? 409 : 500; return c.json({ error: message }, status); } }); /** Stop a run's Bevy playtest. */ orchestrator.post('/runs/:id/bevy/stop', (c) => { runManager.stopBevy(c.req.param('id')); return c.json({ ok: true }); }); /** Live event stream for a run (Server-Sent Events). */ orchestrator.get('/runs/:id/stream', (c) => { const id = c.req.param('id'); const run = runManager.get(id); if (!run) return c.json({ error: 'run not found' }, 404); return streamSSE(c, async (stream) => { // Replay any persisted history the client hasn't seen yet. let lastSeq = Number(c.req.query('since') ?? '0'); if (!Number.isFinite(lastSeq)) lastSeq = 0; const flushHistory = () => { for (const ev of runManager.events(id, lastSeq)) { lastSeq = ev.seq; void stream.writeSSE({ event: 'event', data: JSON.stringify({ seq: ev.seq, type: ev.type, data: ev.data, createdAt: ev.createdAt }) }); } }; flushHistory(); // If the run is still active, subscribe to live events. Each carries its // persisted `seq`, so advancing `lastSeq` here keeps the periodic history // flush below from ever re-emitting the same event. const unsubscribe = runManager.subscribe(id, (ev) => { if (ev.seq <= lastSeq) return; lastSeq = ev.seq; const { seq, createdAt, type, ...data } = ev; void stream.writeSSE({ event: 'event', data: JSON.stringify({ seq, type, data, createdAt }) }); }); // Periodic flush of persisted history catches events written straight to the // DB (agent `curl` mutations, settle-time warnings) that bypass the live // subscriber. Since `lastSeq` tracks everything delivered, this is a no-op // except for those DB-direct events. const timer = setInterval(flushHistory, 1500); // Keep the stream open until the client disconnects. `onAbort` fires on // disconnect; resolving the promise lets `streamSSE` finish cleanly. await new Promise((resolve) => { stream.onAbort(() => resolve()); }); clearInterval(timer); unsubscribe(); }); });