Repository Public
1/** Health, system status (real checks, each with its own `checkedAt`) and the audit trail. */2import type { FastifyInstance } from 'fastify';3import { z } from 'zod';4import { AuditEvent, LIVE_SESSION_STATES, SystemStatus, type Health } from '@cr/shared';5import { authOf, parse, type Ctx } from '../../http.js';6import { RuntimeError } from '../../runtime-client.js';7import type { HostMetrics } from '../cluster/service.js';89const AuditQuery = z.object({10 limit: z.coerce.number().int().min(1).max(500).default(100),11 before: z.string().regex(/^\d{1,18}$/).optional(),12 action: z.string().max(80).optional(),13});14const HealthResponse = z.object({ ok: z.boolean(), version: z.string(), agent: z.enum(['claude', 'codex']).optional() });15/**16 * Profile fields are ADDED only for a non-default profile: a Claude instance keeps answering the17 * exact payloads it always did (`{ok, version}`, `claude: {version, authenticated}`). Absent ⇒ claude.18 */19const profileFields = (agent: { id: 'claude' | 'codex'; displayName: string }): { agent?: 'claude' | 'codex'; agentName?: string } =>20 agent.id === 'claude' ? {} : { agent: agent.id, agentName: agent.displayName };21type Component = z.infer<typeof SystemStatus>['components'][number];22const numOrNull = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null);2324export default async function systemPlugin(app: FastifyInstance, ctx: Ctx): Promise<void> {25 const { db, rt, config } = ctx;2627 app.get('/health', { config: { auth: 'public', rate: 'health', doc: { summary: 'Liveness (public; nothing else is disclosed)', response: HealthResponse, responseName: 'Health' } } }, () => ({ ok: true, version: config.version, ...(config.agent.id === 'claude' ? {} : { agent: config.agent.id }) }));2829 app.get('/system/status', { config: { doc: { summary: 'Component health, host metrics, counts', response: SystemStatus, responseName: 'SystemStatus' } } }, async (req): Promise<SystemStatus> => {30 authOf(req);31 const at = (): string => new Date().toISOString();32 const components: Component[] = [];3334 const t0 = performance.now();35 try {36 await db.q('SELECT 1');37 const ms = Math.round(performance.now() - t0);38 components.push({ component: 'database', health: ms > 500 ? 'DEGRADED' : 'ONLINE', detail: `${ms} ms`, checkedAt: at() });39 } catch {40 components.push({ component: 'database', health: 'OFFLINE', detail: 'query failed', checkedAt: at() });41 }4243 let runtimeUp = false;44 let claudeVersion: string | null = null;45 try {46 const h = await rt.json<{ ok?: boolean; tmux?: boolean; claude?: { version?: unknown }; agentHooks?: { status?: unknown; detail?: unknown } }>('GET', '/health', undefined, { timeoutMs: 4000 });47 runtimeUp = true;48 ctx.sessions.markRuntime(true);49 claudeVersion = typeof h?.claude?.version === 'string' ? h.claude.version : null;50 components.push({ component: 'runtime', health: h?.ok === false ? 'DEGRADED' : 'ONLINE', detail: null, checkedAt: at() });51 components.push({ component: 'tmux', health: h?.tmux === true ? 'ONLINE' : h?.tmux === false ? 'OFFLINE' : 'UNKNOWN', detail: null, checkedAt: at() });52 // Only reported when the hook / notify installation was requested and did not succeed53 // (e.g. another `notify` program already configured for Codex): no WAITING_FOR_INPUT signal.54 const hooks = h?.agentHooks;55 if (hooks && (hooks.status === 'conflict' || hooks.status === 'error')) {56 components.push({ component: 'agent-hooks', health: 'DEGRADED', detail: typeof hooks.detail === 'string' ? hooks.detail.slice(0, 300) : String(hooks.status), checkedAt: at() });57 }58 } catch (err) {59 const unreachable = err instanceof RuntimeError && err.unreachable;60 if (unreachable) ctx.sessions.markRuntime(false);61 components.push({ component: 'runtime', health: unreachable ? 'OFFLINE' : 'DEGRADED', detail: unreachable ? 'unreachable' : 'error response', checkedAt: at() });62 components.push({ component: 'tmux', health: 'UNKNOWN', detail: 'runtime not reachable', checkedAt: at() });63 }6465 let authenticated: boolean | null = null;66 let metrics: HostMetrics | null = null;67 if (runtimeUp) {68 const [info, m] = await Promise.all([69 rt.json<{ version?: unknown; authenticated?: unknown }>('GET', '/claude/info', undefined, { timeoutMs: 8000 }).catch(() => null),70 rt.json<HostMetrics>('GET', '/host/metrics', undefined, { timeoutMs: 5000 }).catch(() => null),71 ]);72 metrics = m;73 if (info) {74 authenticated = typeof info.authenticated === 'boolean' ? info.authenticated : null;75 if (typeof info.version === 'string') claudeVersion = info.version;76 }77 const health: Health = !info ? 'UNKNOWN' : claudeVersion === null ? 'OFFLINE' : authenticated === false ? 'DEGRADED' : 'ONLINE';78 components.push({ component: 'claude', health, detail: !info ? 'not checked' : claudeVersion === null ? 'CLI not found' : authenticated === false ? 'not authenticated' : null, checkedAt: at() });79 } else {80 components.push({ component: 'claude', health: 'UNKNOWN', detail: 'runtime not reachable', checkedAt: at() });81 }8283 const c = ctx.cluster.status();84 components.push(85 !c.configured86 ? { component: 'agentd', health: 'UNKNOWN', detail: 'not configured', checkedAt: at() }87 : { component: 'agentd', health: c.fresh ? 'ONLINE' : c.lastGoodAt ? 'UNKNOWN' : 'OFFLINE', detail: c.fresh ? null : (c.error ?? 'no recent data'), checkedAt: (c.lastGoodAt ?? new Date()).toISOString() },88 );8990 const counts = await db.one<{ live: number; running: number; queued: number }>(91 `SELECT (SELECT count(*)::int FROM terminal_sessions WHERE state = ANY($1::text[])) AS live,92 (SELECT count(*)::int FROM agent_tasks WHERE state IN ('RUNNING','CANCELLING')) AS running,93 (SELECT count(*)::int FROM agent_tasks WHERE state = 'QUEUED') AS queued`,94 [[...LIVE_SESSION_STATES]],95 ).catch(() => null);9697 return {98 collectedAt: at(),99 version: config.version,100 components,101 host: {102 name: typeof metrics?.hostname === 'string' ? (metrics.hostname.split('.')[0] ?? '') : '',103 cpuPct: numOrNull(metrics?.cpuPct),104 load: Array.isArray(metrics?.load) ? metrics.load.filter((n) => typeof n === 'number') : [],105 memUsedMb: numOrNull(metrics?.memUsedMb),106 memTotalMb: numOrNull(metrics?.memTotalMb),107 diskFreeGb: numOrNull(metrics?.diskFreeGb),108 diskTotalGb: numOrNull(metrics?.diskTotalGb),109 uptimeSec: numOrNull(metrics?.uptimeSec),110 },111 counts: { liveSessions: counts?.live ?? 0, wsClients: ctx.live.count(), runningTasks: counts?.running ?? 0, queuedTasks: counts?.queued ?? 0 },112 claude: { version: claudeVersion, authenticated, ...profileFields(config.agent) },113 };114 });115116 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[]> => {117 authOf(req);118 const q = parse(AuditQuery, req.query);119 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> }>(120 `SELECT id, at, actor, action, target, host(ip) AS ip, ok, detail FROM audit_events121 WHERE ($1::bigint IS NULL OR id < $1::bigint) AND ($2::text IS NULL OR action = $2) ORDER BY id DESC LIMIT $3`,122 [q.before ?? null, q.action ?? null, q.limit],123 );124 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 }));125 });126}127