/** * The fleet dashboard server (epic #292, slice 1/5): a separate `Bun.serve` * process that renders the fleet in a browser. * * Deliberately NOT a route on the daemon port: that surface is unauthenticated * and loopback-only by design, and `verbs/server.test.ts` pins every non-daemon * path (and every mutating method) to 404. The dashboard is its own process * with its own bearer token, and it only ever *reads* fleet state — it never * starts, stops or mutates a daemon. * * The token is minted once at `/dashboard-token` — the same * 0700-dir / 0600-file atomic discipline `writeConfigRaw` uses — and reused on * every later start. Every `/api/*` request must present it as * `Authorization: Bearer `; anything else is a bare 401 with no body * detail. Static assets ship on disk next to this module and are served * unauthenticated; the UI asks for the token once and keeps it in * localStorage. * * Project liveness is the one membership convention `status` and `board` * already share (#379): `livingDaemon()` + `healthCheck(port)`, classified by * `classifyDaemonProjectHealth`. A project whose record is absent, whose * daemon will not answer `/healthz`, or whose record is pinned to another * project renders as down — never as an empty healthy fleet. */ import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { loadConfig, stateDir } from "../config.ts"; import { healthCheck, livingDaemon, type DaemonRecord } from "../lifecycle.ts"; import { classifyDaemonProjectHealth, fleetLayers } from "../fleet.ts"; import type { FleetLayers } from "../status-render.ts"; import { statusSnapshot, type StatusSnapshot } from "../daemon.ts"; import { boardJson, boardSnapshotOnce } from "../board.ts"; import { computeStats, parseStatsWindow, type StatsReport } from "../stats.ts"; import { dbPath, openStore } from "../store.ts"; import type { ProjectConfig } from "../types.ts"; import { dashboardAnswerDecision, dashboardDisarm, dashboardExtend, dashboardHold, dashboardPause, dashboardResume, dashboardUnblock, dashboardWorkerControl, type ControlDeps, type ControlOutcome, } from "./controls.ts"; import { transcriptStreamResponse } from "./transcript.ts"; import type { BaseHealth, DecisionRecord, DigestBacklog, HeldNotice, ReportRecord, RunRecord, TurnOverride, VerbLedgerEntry, } from "../types.ts"; /** The dashboard's default bind address — loopback, like the daemon port. */ export const DASHBOARD_HOST = "127.0.0.1"; /** The dashboard's default port. Away from the daemon's 8787 and the verbs. */ export const DASHBOARD_PORT = 8788; const TOKEN_FILE = "dashboard-token"; /** Where the bearer token lives, next to config.json under `$OMP_CONDUCTOR_HOME`. */ export function tokenPath(): string { return join(stateDir(), TOKEN_FILE); } /** The stored token, or undefined when the dashboard has never been started. */ export function readDashboardToken(): string | undefined { try { const raw = readFileSync(tokenPath(), "utf8").trim(); return raw.length > 0 ? raw : undefined; } catch { return undefined; } } function hashToken(value: string): Buffer { return createHash("sha256").update(value).digest(); } /** * Constant-time bearer check. Both sides are hashed to fixed length first so a * wrong-length candidate cannot leak the stored token's length through the * compare — the arm-challenge spirit: verify by hash, never by a plaintext * comparison that leaks shape in timing. */ export function verifyDashboardToken(presented: string | undefined, stored: string): boolean { if (presented === undefined || presented.length === 0) return false; return timingSafeEqual(hashToken(presented), hashToken(stored)); } /** * Mints `/dashboard-token` on first start; later starts reuse the * existing token. The write discipline is `writeConfigRaw`'s: the state dir is * created 0700 (chmod only what this call created, so an operator-chosen mode * survives), the file is written 0600 through a same-directory temp file and * an atomic rename so a crash mid-write leaves the previous token intact * rather than a truncated one. */ export function ensureDashboardToken(): string { const existing = readDashboardToken(); if (existing !== undefined) return existing; const token = randomBytes(32).toString("base64url"); const dir = stateDir(); const created = mkdirSync(dir, { recursive: true, mode: 0o700 }); // mkdir's mode is masked by umask; chmod only what we just created so an // existing directory keeps whatever the user chose for it. if (created !== undefined) chmodSync(dir, 0o700); const target = tokenPath(); const tmp = join(dir, `.dashboard-token.${process.pid.toString(36)}.${Date.now().toString(36)}.tmp`); try { writeFileSync(tmp, `${token}\n`, { mode: 0o600 }); renameSync(tmp, target); } catch (err) { rmSync(tmp, { force: true }); throw err; } // The rename preserves the temp's 0600; state it anyway so the promise never // leans on umask moods. chmodSync(target, 0o600); return token; } /** One configured project as `/api/projects` reports it. */ export interface DashboardProjectView { name: string; /** `tracker.repo` — the issue source the project dispatches. */ repo: string; daemon: { /** The shared verdict every viewer uses (#379). */ state: "ok" | "stopped" | "unreachable" | "unresponsive" | "other-project"; /** The living daemon's port when a record exists; null when there is none. */ port: number | null; /** The raw `/healthz` body, parsed, when this project's daemon answered it. */ healthz?: unknown; /** Which project a foreign living record is pinned to, when state is other-project. */ serves?: string; }; } /** * One project against a record already read for the request. The record is * shared across projects so one pidfile read serves one whole request. */ async function probeProject(project: ProjectConfig, record: DaemonRecord | undefined): Promise { // The status/board gate (#379): never health-check a record pinned to // another project — its `/healthz` would describe a daemon that does not // serve this one, and mixing the two is how an empty fleet reads as healthy. const pinnedElsewhere = record !== undefined && record.project !== undefined && record.project !== project.name; const health = record !== undefined && !pinnedElsewhere ? await healthCheck(record.port) : undefined; const classified = classifyDaemonProjectHealth(record, health, project.name); const view: DashboardProjectView = { name: project.name, repo: project.tracker.repo, daemon: { state: classified.kind, port: record?.port ?? null }, }; if (classified.kind === "ok") { let healthz: unknown; try { healthz = JSON.parse(health?.body ?? "null") as unknown; } catch { healthz = undefined; } view.daemon.healthz = healthz; } else if (classified.kind === "other-project") { view.daemon.serves = classified.serves; } return view; } /** * Every configured project, annotated for the dashboard. Read-only: never * starts a daemon, never touches a pidfile beyond `livingDaemon()`'s read. */ export async function dashboardProjects(): Promise { const cfg = loadConfig(); const record = livingDaemon(); return Promise.all(cfg.projects.map((project) => probeProject(project, record))); } /** * One project on the fleet-overview row (epic #292, slice 2/5). Every field is * the existing producer's own number — `statusSnapshot` for workers, spend and * base health, `fleetLayers` for the dispatch verdict — so a row can never * disagree with `omp-conductor status` read at the same moment. */ export interface FleetOverviewRow { name: string; repo: string; /** The same shared daemon-health verdict `/api/projects` reports (#379). */ daemon: DashboardProjectView["daemon"]; /** The dispatch layer: `stopped` | `paused` | `running`. */ dispatch: FleetLayers["dispatch"]; liveWorkers: number; spendTodayUsd: number; baseHealth: BaseHealth[]; /** * True when the daemon this project is pinned to is unreachable or serves * another project. The UI renders DEGRADED instead of a fabricated healthy * empty row — never zeros that read as a working fleet. */ degraded: boolean; } /** * Every configured project as a fleet-overview row. Reuses `dashboardProjects` * for the membership verdict (one pidfile + one `/healthz` read serves the * whole call), then `statusSnapshot` and `fleetLayers` per project for the * numbers the `status` CLI renders from the very same producers. */ export async function fleetOverview(): Promise { const views = await dashboardProjects(); return views.map((view) => { const snap = statusSnapshot(view.name); const layers = fleetLayers(view.name); return { name: view.name, repo: view.repo, daemon: view.daemon, dispatch: layers.dispatch, liveWorkers: snap.liveWorkers, spendTodayUsd: snap.spendTodayUsd, baseHealth: snap.baseHealth, degraded: view.daemon.state === "unreachable" || view.daemon.state === "unresponsive" || view.daemon.state === "other-project", }; }); } // --------------------------------------------------------------------------- // The read-only monitoring surface (epic #292, slice 2/5). Each handler hands // the existing CLI producer its arguments and returns what it already computes // verbatim — nothing here re-derives a lane, a spend figure or a hold. // --------------------------------------------------------------------------- /** `/api/projects/:name/board` — the exact lanes `omp-conductor board --json` * prints for the same project at the same moment, parsed so the UI consumes * them as an object. */ async function boardProducer(name: string): Promise { const snapshot = await boardSnapshotOnce(name); return JSON.parse(boardJson(snapshot)) as unknown; } /** `/api/projects/:name/status` — the `StatusSnapshot` plus the `fleetLayers` * verdict, structured (not the rendered text). */ async function statusProducer(name: string): Promise<{ status: StatusSnapshot; fleetLayers: FleetLayers }> { return { status: statusSnapshot(name), fleetLayers: fleetLayers(name) }; } /** `/api/projects/:name/ledger` — the verb ledger + turn-override ledger, with * the CLI's own defaults (limit 50) so the two cannot disagree. */ async function ledgerProducer( name: string, opts: { issue?: number; limit: number }, ): Promise<{ verb: VerbLedgerEntry[]; turnOverrides: TurnOverride[] }> { const store = openStore(dbPath()); try { return { verb: store.verbLedger(name, opts), turnOverrides: store.turnOverrideLedger(name, opts), }; } finally { store.close(); } } /** `/api/projects/:name/reports` — the store readers `status` already uses: * open reports, the digest backlog, and the held notices awaiting a digest. */ async function reportsProducer( name: string, ): Promise<{ openReports: ReportRecord[]; digestBacklog: DigestBacklog; heldNotices: HeldNotice[]; openDecisions: DecisionRecord[]; }> { const store = openStore(dbPath()); try { return { openReports: store.openReports(name), digestBacklog: store.digestBacklog(name), heldNotices: store.undigestedNotices(name), // The questions waiting on an operator. Carried here so the answer verb // #295 added has something to answer: an endpoint with no surface that // lists what it can act on is an endpoint nobody uses. openDecisions: store.openDecisions(name), }; } finally { store.close(); } } /** `/api/projects/:name/runs/:issue` — every run row for the issue: attempts, * states, spend, failure classes, PR URLs. */ /** * The analytics payload (#297): byte-for-byte what `stats --json` prints. * * One aggregation, two consumers — `computeStats` and `parseStatsWindow` are the * same functions the CLI runs, fed by the same read-only store queries. That is * the whole of the slice's first acceptance criterion ("numbers on the page equal * `stats --json` for the same window"): it holds because there is nothing else * here that could compute a different number. * * Store-only by construction, which is the second criterion: `statsRuns` and * `ghCallsBetween` are local reads, and no GitHub client exists in this module. */ async function statsProducer(name: string, since: string): Promise { const window = parseStatsWindow(since, Date.now()); // An unparseable window is the caller's error, and the caller turns this into // a 400. Defaulting it would measure a different week and say nothing — the // exact failure the shared parser exists to prevent. if (window === undefined) return undefined; const store = openStore(dbPath()); try { return computeStats({ project: name, window, ghCalls: store.ghCallsBetween(window.sinceDay, window.untilDay), runs: store.statsRuns(name, window.sinceEpochMs), // Phase 4 attribution: daemon-owned session spend lives in its own table, // deliberately outside `runs`, so it is a second read rather than more // rows in the first one. sessions: store.sessionSpendSince(name, window.sinceEpochMs), }); } finally { store.close(); } } async function runsProducer(name: string, issue: number): Promise<{ runs: RunRecord[] }> { const store = openStore(dbPath()); try { return { runs: store.runsForIssue(name, issue) }; } finally { store.close(); } } /** * The deps a live dashboard is wired with. Every endpoint defaults to the real * producer above; a test overrides only the slice it exercises. */ export interface DashboardHttpDeps { /** The bearer token every `/api/*` request must carry. */ token: string; /** The `/api/projects` payload. */ projects(): Promise; /** The `/api/overview` fleet rows. */ overview(): Promise; board(name: string): Promise; status(name: string): Promise<{ status: StatusSnapshot; fleetLayers: FleetLayers }>; ledger( name: string, opts: { issue?: number; limit: number }, ): Promise<{ verb: VerbLedgerEntry[]; turnOverrides: TurnOverride[] }>; reports( name: string, ): Promise<{ openReports: ReportRecord[]; digestBacklog: DigestBacklog; heldNotices: HeldNotice[]; openDecisions: DecisionRecord[]; }>; runs(name: string, issue: number): Promise<{ runs: RunRecord[] }>; /** The analytics payload (#297); `undefined` for an unparseable window. */ stats(name: string, since: string): Promise; /** * The live transcript stream (#296). Returns the SSE response itself rather * than data, because the whole point is a connection that stays open — a * producer returning a value would have to buffer the run to completion first. */ transcript?: (name: string, issue: number, req: Request) => Response; /** * The mutating half (#295). Held as one injected object rather than eight * function fields so a test overrides exactly the action it drives, and so the * read surface above stays visibly read-only. */ controls?: Partial; } /** The deps bound to the real producers — what `startDashboard` serves with. */ export function defaultDashboardDeps(token: string): DashboardHttpDeps { return { token, projects: dashboardProjects, overview: fleetOverview, board: boardProducer, status: statusProducer, ledger: ledgerProducer, reports: reportsProducer, runs: runsProducer, stats: statsProducer, }; } const STATIC_FILES: Record = { "/": "index.html", "/index.html": "index.html", "/app.js": "app.js", "/style.css": "style.css", }; const STATIC_MIME: Record = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", }; /** The value of `Authorization: Bearer `, or undefined for any other form. */ function bearerToken(req: Request): string | undefined { const header = req.headers.get("authorization"); if (header === null) return undefined; const prefix = "Bearer "; return header.startsWith(prefix) ? header.slice(prefix.length).trim() : undefined; } /** * The token from `?token=`, accepted on the SSE stream path and nowhere else * (#296). * * `EventSource` is the browser API for Server-Sent Events and it cannot set an * `Authorization` header — that is a documented limitation of the API, not * something the UI is choosing. The two honest alternatives are a cookie or a * query parameter, and a cookie is strictly worse here: it would ride on every * request including the unauthenticated static ones, and it would need SameSite * and expiry policy this product has no other use for. * * So the exposure is accepted, narrowed, and stated: a URL can reach a server * log or a browser history entry, and this endpoint is the only place that can * happen. It is scoped to the exact stream path so no other route can ever be * reached with a URL-borne credential, and the dashboard binds loopback by * default — the `--host` opt-in already carries its own warning about the token * being the only thing between the network and the fleet. */ function streamQueryToken(url: URL): string | undefined { if (!/^\/api\/projects\/[^/]+\/runs\/\d+\/transcript\/stream$/.test(url.pathname)) { return undefined; } const token = url.searchParams.get("token"); return token === null || token === "" ? undefined : token; } /** * The ledger's query params. `limit` defaults to 50 — the CLI's own default — * so the endpoint and `omp-conductor ledger` cannot disagree. An explicit * non-positive or non-integral limit or issue is a client error (400), never a * silent fallback that hides a typo'd URL. */ function parseLedgerParams(url: URL): { issue?: number; limit: number } | null { const issueParam = url.searchParams.get("issue"); const limitParam = url.searchParams.get("limit"); // `Number`, not `parseInt`: `1.5` and `-1` must be rejected, never truncated // down to a valid-seeming value. const issueNumber = issueParam === null ? undefined : Number(issueParam); const issue = issueNumber !== undefined && Number.isInteger(issueNumber) ? issueNumber : undefined; const limitParamValue = limitParam === null ? 50 : Number(limitParam); const limit = Number.isInteger(limitParamValue) ? limitParamValue : Number.NaN; if (issueParam !== null && (issue === undefined || issue < 1)) return null; if (!Number.isSafeInteger(limit) || limit < 1) return null; return { ...(issue === undefined ? {} : { issue }), limit }; } /** * The configured project a `/api/projects/:name/...` path names, or undefined. * * One resolver for reads and mutations both: an unknown name is a 404 rather * than a producer's throw, and — the part that matters for #295 — an action can * never be applied to a project this host does not configure. */ function projectFromPath(path: string): ProjectConfig | undefined { const match = /^\/api\/projects\/([^/]+)(?:\/|$)/.exec(path); if (match === null) return undefined; let name: string; try { name = decodeURIComponent(match[1]!); } catch { return undefined; } return loadConfig().projects.find((p) => p.name === name); } /** * The JSON body of a mutating request, or the 400 that says why not. * * An absent body is `{}` rather than an error: `resume` and `disarm` take no * arguments, and requiring an empty object from them would be ceremony. A body * that is present and unparseable is always an error — that is a client bug, and * treating it as "no arguments" is how a `stop` arrives with no reason. */ async function mutationBody(req: Request): Promise { const raw = await req.text(); if (raw.trim() === "") return {}; try { return JSON.parse(raw) as unknown; } catch { return { __malformed: true }; } } /** * The mutating surface (#295). Every branch delegates to `controls.ts`, which * delegates to the CLI's own code or the owning daemon's own endpoint — there is * no fleet logic in this function, only routing. */ async function mutatingResponse( req: Request, path: string, project: ProjectConfig, d: DashboardHttpDeps, ): Promise { const body = await mutationBody(req); if (body !== null && typeof body === "object" && Reflect.get(body, "__malformed") === true) { return Response.json({ error: "request body must be valid JSON" }, { status: 400 }); } const deps: ControlDeps = { project, ...d.controls }; const answer = async (): Promise => { const fleet = /^\/api\/projects\/[^/]+\/(pause|resume|hold|disarm)$/.exec(path); if (fleet !== null) { switch (fleet[1]) { case "pause": return dashboardPause(body, deps); case "resume": return dashboardResume(deps); case "hold": return dashboardHold(body, deps); case "disarm": return dashboardDisarm(deps); } } const run = /^\/api\/projects\/[^/]+\/runs\/(\d+)\/(extend|unblock)$/.exec(path); if (run !== null) { const issue = Number.parseInt(run[1]!, 10); if (!Number.isSafeInteger(issue)) return undefined; return run[2] === "extend" ? await dashboardExtend(issue, body, deps) : await dashboardUnblock(issue, body, deps); } const worker = /^\/api\/projects\/[^/]+\/runs\/(\d+)\/worker\/(pause|resume|stop)$/.exec(path); if (worker !== null) { const issue = Number.parseInt(worker[1]!, 10); if (!Number.isSafeInteger(issue)) return undefined; return await dashboardWorkerControl( issue, worker[2] as "pause" | "resume" | "stop", body, deps, ); } const decision = /^\/api\/projects\/[^/]+\/decisions\/([^/]+)\/answer$/.exec(path); if (decision !== null) { let id: string; try { id = decodeURIComponent(decision[1]!); } catch { return undefined; } return dashboardAnswerDecision(id, body, deps); } return undefined; }; const outcome = await answer(); if (outcome === undefined) return new Response("", { status: 404 }); return Response.json(outcome.body, { status: outcome.status }); } /** * The dashboard's whole HTTP surface, separated from the socket exactly like * `daemonHttpResponse` so a test can drive it without binding a port. The UI * ships on disk and is served without auth; `/api/*` is bearer-authenticated. */ export async function dashboardResponse(req: Request, d: DashboardHttpDeps): Promise { const url = new URL(req.url); const path = url.pathname; if (path.startsWith("/api/")) { const presented = bearerToken(req) ?? streamQueryToken(url); if (presented === undefined || !verifyDashboardToken(presented, d.token)) { // Bare 401: no body, no scheme hint — nothing about the token leaks. return new Response("", { status: 401 }); } // Two methods now (#295): GET reads, POST mutates. Anything else stays 404 // — after the auth check, so the surface cannot be probed unauthenticated. if (req.method !== "GET" && req.method !== "POST") { return new Response("", { status: 404 }); } // The mutating routes first (#295), so a read route can never answer a POST. // Reads are then explicitly GET-gated below: without that, `POST .../board` // would have fallen through and returned the board, which is a mutating // method quietly served by a read handler. const project = projectFromPath(path); if (req.method === "POST") { if (project === undefined) return new Response("", { status: 404 }); return mutatingResponse(req, path, project, d); } if (path === "/api/projects") return Response.json(await d.projects()); if (path === "/api/overview") return Response.json(await d.overview()); const proj = /^\/api\/projects\/([^/]+)\/(board|status|ledger|reports|stats)$/.exec(path); if (proj !== null) { if (project === undefined) return new Response("", { status: 404 }); switch (proj[2]) { case "board": return Response.json(await d.board(project.name)); case "status": return Response.json(await d.status(project.name)); case "ledger": { const opts = parseLedgerParams(url); if (opts === null) return new Response("", { status: 400 }); return Response.json(await d.ledger(project.name, opts)); } case "reports": return Response.json(await d.reports(project.name)); case "stats": { // `7d` is the CLI's own default, so an unqualified page and an // unqualified `stats` describe the same window. const report = await d.stats(project.name, url.searchParams.get("since") ?? "7d"); if (report === undefined) { return Response.json( { error: "since must be 7d, 30d, any Nd, or YYYY-MM-DD" }, { status: 400 }, ); } return Response.json(report); } } } // The transcript stream (#296) is matched before the run-detail route so // `/runs/42/transcript/stream` cannot be read as an issue named // "42/transcript". const stream = /^\/api\/projects\/([^/]+)\/runs\/(\d+)\/transcript\/stream$/.exec(path); if (stream !== null) { const issue = Number.parseInt(stream[2]!, 10); if (project === undefined || !Number.isSafeInteger(issue)) { return new Response("", { status: 404 }); } return (d.transcript ?? transcriptStreamResponse)(project.name, issue, req); } const runs = /^\/api\/projects\/([^/]+)\/runs\/(\d+)$/.exec(path); if (runs !== null) { const issue = Number.parseInt(runs[2]!, 10); if (project === undefined || !Number.isSafeInteger(issue)) { return new Response("", { status: 404 }); } return Response.json(await d.runs(project.name, issue)); } return new Response("", { status: 404 }); } // Everything outside /api is static UI, unauthenticated by contract. if (req.method !== "GET" && req.method !== "HEAD") { return new Response("", { status: 405 }); } const name = STATIC_FILES[path]; if (name === undefined) return new Response("not found", { status: 404 }); // Small fixed set of files, read on demand like the config: no cache to go // stale when an operator edits the UI beside a running dashboard. const body = readFileSync(join(import.meta.dir, name)); const mime = STATIC_MIME[name.slice(name.lastIndexOf("."))] ?? "application/octet-stream"; return new Response(body, { headers: { "content-type": mime } }); } export interface DashboardStartOpts { port?: number; /** Bind address; defaults to loopback. A non-loopback host still starts, * with a one-line warning naming the token file. */ host?: string; } /** Loopback is the safe posture; anything else gets the token warning. */ function isLoopbackHost(host: string): boolean { return host === "localhost" || host === "::1" || host.startsWith("127."); } /** * Runs the dashboard until SIGINT/SIGTERM, like the foreground daemon. Mints * the token on first start and prints where the server is listening. */ export async function startDashboard(opts: DashboardStartOpts = {}): Promise { const token = ensureDashboardToken(); const host = opts.host ?? DASHBOARD_HOST; const server = Bun.serve({ hostname: host, port: opts.port ?? DASHBOARD_PORT, // Long-lived by necessity (#296). `Bun.serve` defaults to a 10s idle // timeout, which closes a transcript stream mid-run — a quiet worker is // exactly the case where watching matters most, and no keepalive interval // short enough to beat that default would be sane. Disabled here rather // than tuned, because the streams are the only long connections this server // has and each one already ends on its own: the follow returns when the run // goes terminal, and the client vanishing aborts it. idleTimeout: 0, fetch: (req) => dashboardResponse(req, defaultDashboardDeps(token)), }); // An IPv6 literal needs brackets to be a clickable URL. const displayHost = host.includes(":") ? `[${host}]` : host; process.stdout.write(`dashboard on http://${displayHost}:${server.port}\n`); if (!isLoopbackHost(host)) { // The one-line warning the contract promises: the token file is the only // door onto the project list, so say where it is before a request arrives. process.stderr.write( `warning: dashboard bound to ${host} — anyone who can reach it can use the token at ${tokenPath()}; keep it secret\n`, ); } await new Promise((resolve) => { const stop = (): void => { process.off("SIGINT", stop); process.off("SIGTERM", stop); void server.stop(true).finally(() => resolve()); }; process.on("SIGINT", stop); process.on("SIGTERM", stop); }); }