/** * Fester REST API — mirrors the repo's route surface (backend/main.py + api/). * Bun-native Request/Response handlers. Only the routes the SysDeck Fester * sub-app needs; everything else 404s JSON. */ import type { EventBus, FesterEvent } from './events' import type { Store } from './store' import type { NodeRegistry } from './nodes' import type { FesterEngine } from './engine' import { projectCatalog } from './engine' import { failureAutopsy, propagateFailure } from './autopsy' import { buildCauseGraph } from './cause' export interface ApiCtx { req: Request url: URL bus: EventBus store: Store nodes: NodeRegistry engine: FesterEngine } interface PolicyEntry { key: string value: string ts: number } const POLICY = new Map() export async function handleApi(ctx: ApiCtx): Promise { const { req, url, store, nodes, engine } = ctx const path = url.pathname.replace(/\/+$/, '') const method = req.method.toUpperCase() try { // ── health ───────────────────────────────────────────── if (path === '/api/health') { return Response.json({ ok: true, service: 'fester', version: '0.2.1', clock: 'spawn-v2', nodes: nodes.names().length, uptime_s: process.uptime() }) } // ── builds ───────────────────────────────────────────── if (path === '/api/build' && method === 'POST') { const body = (await req.json().catch(() => ({}))) as { project?: string targets?: string[] failAction?: string noCache?: boolean retries?: number } if (!body.project || !Array.isArray(body.targets) || body.targets.length === 0) { return Response.json({ ok: false, error: 'project and targets[] are required' }, { status: 400 }) } const retries = body.retries ?? 0 if (typeof retries !== 'number' || !Number.isInteger(retries) || retries < 0 || retries > 3) { return Response.json({ ok: false, error: 'retries must be an integer between 0 and 3' }, { status: 400 }) } const buildId = engine.startBuild(body.project, body.targets, { failAction: body.failAction, noCache: body.noCache, retries, }) return Response.json({ ok: true, build_id: buildId, retries }) } if (path === '/api/builds') { return Response.json({ ok: true, builds: engine.listBuilds(), history: store.listBuilds(30) }) } const buildMatch = path.match(/^\/api\/builds\/([^/]+)$/) if (buildMatch) { const buildId = decodeURIComponent(buildMatch[1]) const live = engine.buildState(buildId) const record = store.getBuild(buildId) if (!live && !record) return Response.json({ ok: false, error: `build ${buildId} not found` }, { status: 404 }) return Response.json({ ok: true, build: live ?? record }) } const cancelMatch = path.match(/^\/api\/builds\/([^/]+)\/cancel$/) if (cancelMatch && method === 'POST') { const okCancel = engine.cancelBuild(decodeURIComponent(cancelMatch[1])) return Response.json({ ok: okCancel, error: okCancel ? undefined : 'build not running' }, { status: okCancel ? 200 : 409 }) } // ── projects / targets ───────────────────────────────── if (path === '/api/targets') { return Response.json({ ok: true, projects: projectCatalog() }) } if (path === '/api/targets/arches') { const arches = new Set() for (const p of projectCatalog()) for (const t of p.targets) arches.add(t.arch) return Response.json({ ok: true, arches: [...arches] }) } if (path === '/api/targets/runtimes') { return Response.json({ ok: true, runtimes: ['host', 'lxc', 'podman', 'firecracker', 'libvirt', 'tmux'] }) } // ── nodes ────────────────────────────────────────────── if (path === '/api/nodes') { return Response.json({ ok: true, nodes: nodes.all() }) } // ── policy ───────────────────────────────────────────── if (path === '/api/policy') { return Response.json({ ok: true, policy: [...POLICY.values()] }) } if (path === '/api/policy/set' && method === 'POST') { const body = (await req.json().catch(() => ({}))) as { key?: string; value?: string } if (!body.key) return Response.json({ ok: false, error: 'key required' }, { status: 400 }) POLICY.set(body.key, { key: body.key, value: body.value ?? '', ts: Date.now() / 1000 }) ctx.bus.emit({ type: 'debug', timestamp: Date.now() / 1000, state: 'policy-set', meta: { key: body.key, value: body.value } }) return Response.json({ ok: true }) } if (path === '/api/policy/clear' && method === 'POST') { const body = (await req.json().catch(() => ({}))) as { key?: string } if (body.key) POLICY.delete(body.key) else POLICY.clear() return Response.json({ ok: true }) } // ── timeline / replay ────────────────────────────────── const timelineMatch = path.match(/^\/api\/timeline(?:\/([^/]+))?$/) if (timelineMatch) { const buildId = timelineMatch[1] ? decodeURIComponent(timelineMatch[1]) : null const afterRaw = url.searchParams.get('after') if (afterRaw !== null) { const after = Number(afterRaw) if (!Number.isInteger(after) || after < 0) { return Response.json({ ok: false, error: 'after must be a non-negative integer event id' }, { status: 400 }) } const events = store.listEventsAfter(buildId, after) const last = events.length > 0 ? (events[events.length - 1].id as number) : null return Response.json({ ok: true, build_id: buildId, after, events, next_cursor: last }) } return Response.json({ ok: true, events: store.listEvents(buildId, 1000) }) } // ── replay sessions (port of backend/api/replay.py intent) ── if (path === '/api/sessions' && method === 'POST') { const body = (await req.json().catch(() => ({}))) as { buildId?: string; label?: string } if (!body.buildId) return Response.json({ ok: false, error: 'buildId is required' }, { status: 400 }) const buildId = body.buildId const exists = engine.buildState(buildId) ?? store.getBuild(buildId) if (!exists) return Response.json({ ok: false, error: `build ${buildId} not found` }, { status: 404 }) const sessionId = store.createSession(buildId, body.label) ctx.bus.emit({ type: 'replay', timestamp: Date.now() / 1000, build_id: buildId, state: 'session-created', meta: { session_id: sessionId, label: body.label ?? null }, }) const session = store.getSession(sessionId) return Response.json({ ok: true, session }) } if (path === '/api/sessions') { const sessions = store.listSessions().map((s) => ({ session_id: s.session_id, build_id: s.build_id, label: s.label, created_at: s.created_at, build: buildInfoOf(s), })) return Response.json({ ok: true, sessions }) } const sessionMatch = path.match(/^\/api\/sessions\/([^/]+)$/) if (sessionMatch && method === 'GET') { const sessionId = decodeURIComponent(sessionMatch[1]) const session = store.getSession(sessionId) if (!session) return Response.json({ ok: false, error: `session ${sessionId} not found` }, { status: 404 }) const events = store.listEvents((session.build_id as string) ?? null, 5000) return Response.json({ ok: true, session: { ...session, build: buildInfoOf(session) }, events }) } // ── metrics (JSON view, computed from the store + registry) ── if (path === '/api/metrics') { const counts = store.buildStateCounts() const sums = store.buildActionSums() const utilization = store.nodeUtilization() const nodesList = nodes.all().map((n) => ({ name: n.name, state: n.state, cpu_load: n.cpu_load, temp: n.temp, active_jobs: n.active_jobs, max_jobs: n.max_jobs, })) const utilizationList: { node: string; pct: number }[] = [ ...nodesList.map((n) => ({ node: n.name, pct: utilization.get(n.name) ?? 0 })), ...[...utilization.entries()] .filter(([name]) => !nodesList.some((n) => n.name === name)) .map(([node, pct]) => ({ node, pct })), ] const total = Object.values(counts).reduce((a, b) => a + b, 0) return Response.json({ ok: true, builds: { total, running: counts.running ?? 0, succeeded: counts.succeeded ?? 0, failed: counts.failed ?? 0, cancelled: counts.cancelled ?? 0, }, actions: { total: sums.total, done: sums.done, failed: sums.failed, cache_hits: sums.cache_hits, cache_hit_rate: sums.total > 0 ? Math.round((sums.cache_hits / sums.total) * 1000) / 10 : 0, }, nodes: nodesList, utilization: utilizationList, }) } // ── failure autopsy ──────────────────────────────────── const autopsyMatch = path.match(/^\/api\/autopsy\/([^/]+)\/(.+)$/) if (autopsyMatch) { const buildId = decodeURIComponent(autopsyMatch[1]) const action = decodeURIComponent(autopsyMatch[2]) const events = store.listEvents(buildId, 5000) as unknown as FesterEvent[] const build = engine.buildState(buildId) ?? store.getBuild(buildId) const autopsy = failureAutopsy(buildId, action, events, build as never) return Response.json({ ok: true, autopsy, propagation: propagateFailure(events, action) }) } // ── cause graph ──────────────────────────────────────── const causeMatch = path.match(/^\/api\/cause\/([^/]+)$/) if (causeMatch) { const buildId = decodeURIComponent(causeMatch[1]) const events = store.listEvents(buildId, 5000) as unknown as FesterEvent[] return Response.json({ ok: true, cause: buildCauseGraph(events) }) } // ── debugger state ───────────────────────────────────── const dbgMatch = path.match(/^\/api\/debugger\/([^/]+)$/) if (dbgMatch) { const buildId = decodeURIComponent(dbgMatch[1]) const state = engine.buildState(buildId) if (!state) return Response.json({ ok: false, error: 'build not found' }, { status: 404 }) return Response.json({ ok: true, debugger: { paused: state.paused, step_mode: state.step_mode, state: state.state, actions: state.actions.length } }) } // ── storage / CAS stats ──────────────────────────────── if (path === '/api/storage/status') { return Response.json({ ok: true, storage: { backend: 'sqlite-wal', cas: { entries: 'dynamic', shared_with: 'sorcery-go', reflink: 'unavailable' }, snapshots: 0, }, }) } return Response.json({ ok: false, error: `no route: ${method} ${path}` }, { status: 404 }) } catch (err) { // v0.3.0 security (audit follow-up): full detail to the service // journal, generic message to the caller (error messages can carry // absolute paths / internals). const detail = err instanceof Error ? `${err.name}: ${err.message}` : String(err) console.error(`[fester] ${method} ${path} failed: ${detail}`) return Response.json({ ok: false, error: 'request failed (see fester service log)' }, { status: 500 }) } } /** Compact build info embedded in session rows (LEFT JOIN against builds). */ function buildInfoOf(row: Record): Record | null { if (row.build_id === null || row.project === undefined) return null return { build_id: row.build_id, project: row.project, state: row.build_state, started_at: row.started_at, finished_at: row.finished_at, actions_total: row.actions_total, actions_done: row.actions_done, actions_failed: row.actions_failed, cache_hits: row.cache_hits, critical_path_ms: row.critical_path_ms, rc: row.rc, note: row.note, meta: row.meta, } }