SPB Git

Repository Public

index.ts 5.4 KB 101 lines typescript

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
Files main
Menu

Theme