SysDeck/web/mini-services/fester/api.ts

289 lines
13 KiB
TypeScript

/**
* 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<string, PolicyEntry>()
export async function handleApi(ctx: ApiCtx): Promise<Response> {
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<string>()
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<string, unknown>): Record<string, unknown> | 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,
}
}