Repository Public
1/** Health, system status (real checks; unknown ⇒ null) and the audit trail. */2import type { FastifyInstance } from 'fastify';3import { z } from 'zod';4import { AuditEvent } from '../../dto-auth.js';5import { Health, SystemStatus } from '../../dto.js';6import { authOf, parse, type Ctx } from '../../http.js';78const AuditQuery = z.object({9 limit: z.coerce.number().int().min(1).max(500).default(100),10 before: z.string().regex(/^\d{1,18}$/).optional(),11 action: z.string().max(80).optional(),12});13/** A worker is alive when it was seen less than this long ago (CONTRACT §8). */14const WORKER_FRESH_SEC = 60;15/** Lease length of the kernel (CONTRACT §2): `lease_expires_at - 90 s` = last heartbeat. */16const LEASE_SEC = 90;1718export default async function systemPlugin(app: FastifyInstance, ctx: Ctx): Promise<void> {19 const { db, config } = ctx;2021 app.get('/health', { config: { auth: 'public', rate: 'health', doc: { summary: 'Liveness (public; nothing else is disclosed)', response: Health, responseName: 'Health' } } }, () => ({ ok: true, version: config.version, agent: 'aos' as const }));2223 app.get('/system/status', { config: { doc: { summary: 'Database latency, worker liveness, provider key presence (booleans), counts', response: SystemStatus, responseName: 'SystemStatus' } } }, async (req): Promise<SystemStatus> => {24 authOf(req);25 const t0 = performance.now();26 let dbOk = false;27 let latencyMs: number | null = null;28 try {29 await db.q('SELECT 1');30 dbOk = true;31 latencyMs = Math.round((performance.now() - t0) * 10) / 10;32 } catch {33 /* reported as db.ok = false */34 }3536 const worker: SystemStatus['worker'] = { alive: null, lastSeenAt: null, source: 'none', workers: [] };37 let counts = { queued: 0, running: 0, waiting: 0, waits: 0 };38 if (dbOk) {39 // `workers(id, last_seen, version)` (migration 011) is written by the worker loop; on a database40 // without it, or without a fresh row, liveness is derived from the lease heartbeats of running runs.41 const hasWorkers = (await db.one<{ t: string | null }>("SELECT to_regclass('public.workers')::text AS t").catch(() => null))?.t != null;42 if (hasWorkers) {43 const rows = await db44 .q<{ id: string; last_seen: Date; version: string | null }>(45 "SELECT id::text AS id, last_seen, version::text AS version FROM workers WHERE last_seen > now() - interval '1 day' ORDER BY last_seen DESC LIMIT 50",46 )47 .catch(() => []);48 worker.source = 'workers';49 worker.workers = rows.map((r) => ({ id: r.id, lastSeenAt: r.last_seen.toISOString(), version: r.version || null }));50 const last = rows[0]?.last_seen ?? null;51 worker.lastSeenAt = last?.toISOString() ?? null;52 worker.alive = last !== null && Date.now() - last.getTime() < WORKER_FRESH_SEC * 1000;53 }54 if (!worker.alive) {55 const lease = await db.one<{ beat: Date | null; fresh: boolean | null }>(56 `SELECT max(lease_expires_at) - make_interval(secs => $1) AS beat,57 max(lease_expires_at) - make_interval(secs => $1) > now() - make_interval(secs => $2) AS fresh58 FROM runs WHERE status = 'running' AND lease_expires_at IS NOT NULL`,59 [LEASE_SEC, WORKER_FRESH_SEC],60 );61 if (lease?.beat) {62 if (lease.fresh || !hasWorkers) {63 worker.source = 'leases';64 worker.lastSeenAt = lease.beat.toISOString();65 worker.alive = lease.fresh === true;66 }67 }68 // No worker row and no leased run: nothing proves a worker is up or down ⇒ alive stays null69 // (with a `workers` table and no fresh row, false).70 }71 const c = await db.one<{ queued: number; running: number; waiting: number; waits: number }>(72 `SELECT (SELECT count(*)::int FROM missions WHERE status = 'queued') AS queued,73 (SELECT count(*)::int FROM missions WHERE status = 'running') AS running,74 (SELECT count(*)::int FROM missions WHERE status = 'waiting') AS waiting,75 (SELECT count(*)::int FROM waits WHERE status = 'pending' AND kind IN ('approval','question')) AS waits`,76 );77 if (c) counts = c;78 }7980 return {81 collectedAt: new Date().toISOString(),82 version: config.version,83 db: { ok: dbOk, latencyMs },84 worker,85 providers: { ...config.providerKeys },86 counts: { missionsQueued: counts.queued, missionsRunning: counts.running, missionsWaiting: counts.waiting, pendingWaits: counts.waits, wsClients: ctx.live.count() },87 };88 });8990 app.get('/system/audit', { config: { doc: { summary: 'Audit trail, newest first (paginate with `before` = last id)', query: AuditQuery, response: z.array(AuditEvent), responseName: 'AuditEventList' } } }, async (req): Promise<AuditEvent[]> => {91 authOf(req);92 const q = parse(AuditQuery, req.query);93 const rows = await db.q<{ id: number; at: Date; actor: string; action: string; target: string | null; ip: string | null; ok: boolean; detail: Record<string, unknown> }>(94 `SELECT id, at, actor, action, target, host(ip) AS ip, ok, detail FROM audit_events95 WHERE ($1::bigint IS NULL OR id < $1::bigint) AND ($2::text IS NULL OR action = $2) ORDER BY id DESC LIMIT $3`,96 [q.before ?? null, q.action ?? null, q.limit],97 );98 return rows.map((r) => ({ id: String(r.id), at: r.at.toISOString(), actor: r.actor, action: r.action, target: r.target, ip: r.ip, ok: r.ok, detail: r.detail }));99 });100}101