/** * SSSF visualizer server — JSON API over one or more repos' sssf.db, plus the * built UI when ./dist exists. Reads are read-only; the single write is * POST /api/sessions/:adw_id/archive, which sets one review flag on a row. * * There is no ingest endpoint and no websocket. The data path is * agents → sqlite → web ui, and the UI gets there by polling. * * Single repo (legacy): * bun run server/index.ts * bun run server/index.ts --db /path/to/repo/adws/adw_data/sssf.db * SSSF_DB=/path/to/sssf.db PORT=4600 bun run server/index.ts * * Multi repo: * SSSF_REPOS=/path/to/repos.json PORT=4600 bun run server/index.ts * * In multi-repo mode every route is prefixed with the repo slug: * /api/repos * /api/:repo/sessions * /api/:repo/sessions/:adw_id * ... * The legacy unprefixed routes (/api/sessions, ...) are kept and resolve to * the first repo, so a single-repo deployment and the SPA's default view * keep working unchanged. */ import { existsSync, statSync } from "node:fs"; import { join, resolve, sep } from "node:path"; import { buildRepos, type RepoRuntime } from "./repos.ts"; import type { AgentPrompts, ApiError, HealthResponse } from "../shared/types.ts"; import type { Server } from "bun"; const PORT = Number(process.env.PORT ?? 4600); const DIST_DIR = resolve(import.meta.dir, "..", "dist"); function json(data: unknown, status = 200): Response { return new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", }, }); } function notFound(message: string): Response { return json({ error: message } satisfies ApiError, 404); } /** Guard every handler so a malformed query can't take the server down mid-run. */ function safely( handler: (req: Request) => Response | Promise, ): (req: Request) => Promise { return async (req) => { try { return await handler(req); } catch (error) { console.error(`[sssf] ${req.method} ${new URL(req.url).pathname}:`, error); return json({ error: (error as Error).message } satisfies ApiError, 500); } }; } /** * adw_ids and agent names are path segments on disk, so anything that isn't a * plain identifier is rejected outright rather than sanitized into something * that might still escape the sessions directory. */ const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/; function isSafeSegment(value: string): boolean { return SAFE_SEGMENT.test(value) && value !== "." && value !== ".."; } function param(req: Request, key: string): string { return decodeURIComponent( (req as Request & { params: Record }).params[key] ?? "", ); } function intQuery(req: Request, key: string, fallback: number): number { const raw = new URL(req.url).searchParams.get(key); if (raw === null || raw.trim() === "") return fallback; const parsed = Number.parseInt(raw, 10); return Number.isFinite(parsed) ? parsed : fallback; } /** Serve the built SPA if it has been built; otherwise point at the dev server. */ async function serveStatic(req: Request): Promise { const { pathname } = new URL(req.url); if (!existsSync(DIST_DIR)) { return new Response( `SSSF visualizer API is running on :${PORT}.\n\n` + `No ./dist build found. Run "bun run dev" for the Vite dev server ` + `(it proxies /api here), or "bun run build" to serve the UI from this process.\n`, { status: 200, headers: { "content-type": "text/plain; charset=utf-8" } }, ); } // Reject traversal before touching the filesystem. const candidate = resolve(join(DIST_DIR, pathname)); if (candidate === DIST_DIR || candidate.startsWith(DIST_DIR + "/")) { if (existsSync(candidate) && statSync(candidate).isFile()) { return new Response(Bun.file(candidate)); } } // SPA fallback: breadcrumb routes are client-side. const indexHtml = join(DIST_DIR, "index.html"); if (existsSync(indexHtml)) { return new Response(Bun.file(indexHtml), { headers: { "content-type": "text/html; charset=utf-8" }, }); } return notFound("not found"); } /** Build the route table for one repo, mounted at /api/:repo and /api (default). */ function repoRoutes(repo: RepoRuntime, prefix: string) { const db = repo.db; const p = (path: string) => `${prefix}${path}`; return { [p("/health")]: safely( () => json({ ok: true, repo: repo.slug, db: db.path, journal_mode: db.journalMode, sessions: db.sessionCount(), } satisfies HealthResponse), ), [p("/sessions")]: safely((req) => json(db.sessions(intQuery(req, "limit", 200)))), [p("/sessions/:adw_id")]: safely((req) => { const detail = db.sessionDetail(param(req, "adw_id")); return detail ? json(detail) : notFound(`no session ${param(req, "adw_id")}`); }), // The one write. Archiving is review triage — it belongs to the reader, not // to the run — so it never touches anything a tracer wrote. [p("/sessions/:adw_id/archive")]: { POST: safely(async (req) => { const adwId = param(req, "adw_id"); if (!isSafeSegment(adwId)) { return json({ error: "invalid adw_id" } satisfies ApiError, 400); } const body = (await req.json().catch(() => ({}))) as { archived?: unknown }; const archived = body.archived === undefined ? true : Boolean(body.archived); return db.setArchived(adwId, archived) ? json({ adw_id: adwId, archived }) : notFound(`no session ${adwId}`); }), }, [p("/sessions/:adw_id/events")]: safely((req) => json( db.events( param(req, "adw_id"), intQuery(req, "after", 0), intQuery(req, "limit", 500), ), ), ), [p("/sessions/:adw_id/envelopes")]: safely((req) => json(db.envelopes(param(req, "adw_id"))), ), [p("/sessions/:adw_id/gates")]: safely((req) => json(db.gates(param(req, "adw_id")))), // The exact prompts an agent was sent, read from the session dir. Files are // the raw record; the db has no copy of them. [p("/sessions/:adw_id/agents/:agent/prompts")]: safely(async (req) => { const adwId = param(req, "adw_id"); const agent = param(req, "agent"); if (!isSafeSegment(adwId) || !isSafeSegment(agent)) { return json({ error: "invalid adw_id or agent" } satisfies ApiError, 400); } if (!db.session(adwId)) return notFound(`no session ${adwId}`); const dir = resolve(db.sessionsDir, adwId, agent, "prompts"); // Defense in depth: the segment check already forbids traversal. if (dir !== db.sessionsDir && !dir.startsWith(db.sessionsDir + sep)) { return json({ error: "invalid path" } satisfies ApiError, 400); } // A prompt file is absent whenever the agent never ran in this session — // a normal state, so it reads as null rather than an error. const read = async (name: string): Promise => { const file = Bun.file(join(dir, `${name}.md`)); return (await file.exists()) ? await file.text() : null; }; return json({ system: await read("system"), user: await read("user"), } satisfies AgentPrompts); }), }; } // The registry is mutable: POST /api/reload re-reads repos.json and swaps the // route table in place (Bun's server.reload), so new repos appear without a // process restart. `server` is assigned below; the reload handler only runs on // a request, by which point it is set. let repos: RepoRuntime[] = (await buildRepos()).repos; let server: Server; /** Build the route table for the current registry. */ function buildRoutes(repos: RepoRuntime[]): Record { const routes: Record = { "/api/repos": safely(() => json(repos.map((r) => ({ slug: r.slug, name: r.name, db: r.db.path }))), ), // Re-read repos.json and swap the registry + routes in place. The UI calls // this after a repo is added/removed, so it shows up without a restart. "/api/reload": { POST: safely(async () => { const next = (await buildRepos()).repos; repos = next; server.reload({ routes: buildRoutes(next) as never }); return json({ ok: true, repos: next.map((r) => ({ slug: r.slug, name: r.name, db: r.db.path })), }); }), }, }; for (const repo of repos) { Object.assign(routes, repoRoutes(repo, `/api/${repo.slug}`)); } Object.assign(routes, repoRoutes(repos[0], "/api")); return routes; } const routes = buildRoutes(repos); server = Bun.serve({ port: PORT, routes: routes as never, fetch(req) { const { pathname } = new URL(req.url); if (pathname.startsWith("/api/")) return notFound(`no route ${pathname}`); return serveStatic(req); }, }); console.log(`[sssf] visualizer api http://localhost:${server.port}`); for (const repo of repos) { console.log(`[sssf] repo ${repo.slug} ${repo.db.path} [journal_mode=${repo.db.journalMode}]`); } console.log( existsSync(DIST_DIR) ? `[sssf] serving ui from ${DIST_DIR}` : `[sssf] no ./dist — use "bun run dev" for the Vite dev server on :4601`, ); process.on("SIGINT", () => { server.stop(); process.exit(0); });