feat(api): add backend with kanban, docs store, and orchestrator
Introduce the @void-nav/api Hono + SQLite backend that powers the docs site: a persisted implementation board (kanban), a custom documentation-pages store with AI beautify, and an agentic orchestrator that runs `pi` agents per card. The orchestrator spawns `pi --mode rpc` inside an isolated git worktree per run, streams slim events over SSE, and lets the agent drive the board/docs via token-gated internal endpoints (all SQLite writes stay in-process). Interrupted runs are reconciled to "stopped" on boot. Workspace wiring: root `dev:api`/`dev:web` scripts with `concurrently`, the docs Vite `/api` proxy, and `.worktrees/` gitignore.
This commit is contained in:
148
apps/api/src/routes/orchestrator.ts
Normal file
148
apps/api/src/routes/orchestrator.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
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<typeof hydrateCard>[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<string, unknown>);
|
||||
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;
|
||||
|
||||
try {
|
||||
const run = runManager.start(card, { cardId, prompt, useWorktree, cleanupOnFinish });
|
||||
return c.json({ run }, 201);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'failed to start run';
|
||||
return c.json({ error: message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/** 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<string, unknown>);
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
/** 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<void>((resolve) => {
|
||||
stream.onAbort(() => resolve());
|
||||
});
|
||||
|
||||
clearInterval(timer);
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user