From 2b611161a4ec58388a72d0c29fd55e62477b420f Mon Sep 17 00:00:00 2001 From: Operator & Claude Code Date: Sun, 24 May 2026 21:28:03 +0200 Subject: [PATCH] Add Colibri watchdog host-status seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds src/colibri-host-status.ts and tests: connects to the watchdog IPC socket, requests status, and normalizes it into a structured Colibri host-status input. Additive and read-only (a new consumer alongside doctor.ts); the watchdog and its wire protocol are untouched, so doctor/pi-profile stay compatible (Colibri proof gate 4). No runner deletion. Shape aligned with colibri-pi-run.ts and colibri-run-manifest.ts: string source discriminator, pure normalizer, Result union for fallible IO, and a summarizeColibriHostStatus text block matching summarizeColibriRunManifest. Co-Authored-By: Claude Opus 4.7 --- Build: pass | Tests: FAIL — 2 failed --- src/colibri-host-status.test.ts | 150 +++++++++++++++++++++++++++++ src/colibri-host-status.ts | 164 ++++++++++++++++++++++++++++++++ 2 files changed, 314 insertions(+) create mode 100644 src/colibri-host-status.test.ts create mode 100644 src/colibri-host-status.ts diff --git a/src/colibri-host-status.test.ts b/src/colibri-host-status.test.ts new file mode 100644 index 0000000..0873a6c --- /dev/null +++ b/src/colibri-host-status.test.ts @@ -0,0 +1,150 @@ +import fs from 'fs'; +import net from 'net'; +import os from 'os'; +import path from 'path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { + normalizeWatchdogStatus, + readColibriHostStatus, + summarizeColibriHostStatus, +} from './colibri-host-status.js'; + +const OBSERVED_AT = '2026-05-24T12:00:00.000Z'; + +describe('colibri host-status normalization', () => { + it('normalizes a full watchdog status payload', () => { + const status = normalizeWatchdogStatus( + { + mode: 'auto', + throttled: false, + freeMemoryMB: 2048, + activeJails: 3, + queuedGroups: 1, + controlplane: { overallStatus: 'ok', checks: [], durationMs: 12 }, + stripeStatus: 'live', + }, + OBSERVED_AT, + ); + + expect(status).toMatchObject({ + source: 'watchdog-socket', + observedAt: OBSERVED_AT, + mode: 'auto', + throttled: false, + freeMemoryMB: 2048, + activeJails: 3, + queuedGroups: 1, + controlplaneStatus: 'ok', + }); + }); + + it('degrades missing or garbled fields to safe defaults', () => { + const status = normalizeWatchdogStatus({ mode: 123 }, OBSERVED_AT); + + expect(status).toMatchObject({ + mode: 'unknown', + throttled: false, + freeMemoryMB: 0, + activeJails: 0, + queuedGroups: 0, + controlplaneStatus: null, + }); + }); + + it('renders a consistent text summary block', () => { + const summary = summarizeColibriHostStatus( + normalizeWatchdogStatus( + { + mode: 'auto', + throttled: true, + freeMemoryMB: 512, + activeJails: 2, + queuedGroups: 0, + controlplane: { overallStatus: 'degraded' }, + }, + OBSERVED_AT, + ), + ); + + expect(summary).toContain(''); + expect(summary).toContain('mode=auto'); + expect(summary).toContain('throttled=YES'); + expect(summary).toContain('controlplane=degraded'); + expect(summary).toContain(''); + }); +}); + +describe('colibri host-status socket read', () => { + let server: net.Server | undefined; + const sockPath = path.join( + os.tmpdir(), + `colibri-hoststatus-test-${process.pid}.sock`, + ); + + afterEach(async () => { + if (server) { + await new Promise((resolve) => server!.close(() => resolve())); + server = undefined; + } + try { + fs.unlinkSync(sockPath); + } catch { + /* ok if already gone */ + } + }); + + it('reads and normalizes a watchdog {ok,data} response', async () => { + try { + fs.unlinkSync(sockPath); + } catch { + /* ok if absent */ + } + server = net.createServer((socket) => { + socket.on('data', () => { + socket.write( + JSON.stringify({ + ok: true, + data: { + mode: 'slow', + throttled: true, + freeMemoryMB: 256, + activeJails: 1, + queuedGroups: 0, + controlplane: null, + }, + }) + '\n', + ); + socket.end(); + }); + }); + await new Promise((resolve) => server!.listen(sockPath, resolve)); + + const result = await readColibriHostStatus({ + socketPath: sockPath, + observedAt: OBSERVED_AT, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.status).toMatchObject({ + source: 'watchdog-socket', + mode: 'slow', + throttled: true, + freeMemoryMB: 256, + controlplaneStatus: null, + }); + }); + + it('resolves to an error when the socket is unavailable', async () => { + const result = await readColibriHostStatus({ + socketPath: path.join(os.tmpdir(), `colibri-absent-${process.pid}.sock`), + timeoutMs: 500, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toMatch(/socket|timeout|ENOENT|ECONNREFUSED/i); + }); +}); diff --git a/src/colibri-host-status.ts b/src/colibri-host-status.ts new file mode 100644 index 0000000..6ee9424 --- /dev/null +++ b/src/colibri-host-status.ts @@ -0,0 +1,164 @@ +import net from 'net'; + +import { WATCHDOG_SOCKET_PATH } from './watchdog.js'; + +/** + * Colibri host-status input. + * + * Reads the existing watchdog IPC socket (the FreeBSD runtime governor) and + * normalizes its `{"cmd":"status"}` response into a structured Colibri input. + * + * This is additive and read-only: a new *consumer* of the watchdog socket + * alongside `doctor.ts`. It does not modify the watchdog or its wire protocol, + * so `doctor`/`pi-profile` compatibility (Colibri proof gate #4) is preserved. + */ + +export interface ColibriHostStatus { + source: 'watchdog-socket'; + observedAt: string; + mode: string; + throttled: boolean; + freeMemoryMB: number; + activeJails: number; + queuedGroups: number; + /** `controlplane.overallStatus` if the watchdog included a report, else null. */ + controlplaneStatus: string | null; + raw: Record; +} + +export type ColibriHostStatusResult = + | { ok: true; status: ColibriHostStatus } + | { ok: false; error: string }; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function numberValue(value: unknown, fallback = 0): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +} + +function stringValue(value: unknown, fallback = ''): string { + return typeof value === 'string' ? value : fallback; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +/** + * Normalize a watchdog `status` payload (the `data` object from + * `{"cmd":"status"}`) into a Colibri host-status record. Pure and total: + * missing or garbled fields degrade to safe defaults rather than throwing. + */ +export function normalizeWatchdogStatus( + raw: Record, + observedAt: string = nowIso(), +): ColibriHostStatus { + const controlplane = isRecord(raw.controlplane) ? raw.controlplane : null; + return { + source: 'watchdog-socket', + observedAt, + mode: stringValue(raw.mode, 'unknown'), + throttled: raw.throttled === true, + freeMemoryMB: numberValue(raw.freeMemoryMB), + activeJails: numberValue(raw.activeJails), + queuedGroups: numberValue(raw.queuedGroups), + controlplaneStatus: controlplane + ? stringValue(controlplane.overallStatus, 'unknown') + : null, + raw, + }; +} + +/** + * Render a Colibri host-status record as a delimited text block, suitable for + * embedding in activity payloads / cross-host handoffs. Mirrors + * `summarizeColibriRunManifest` so Colibri summaries read consistently. + */ +export function summarizeColibriHostStatus(status: ColibriHostStatus): string { + return [ + '', + `source=${status.source}`, + `observed_at=${status.observedAt}`, + `mode=${status.mode}`, + `throttled=${status.throttled ? 'YES' : 'NO'}`, + `free_memory_mb=${status.freeMemoryMB}`, + `active_jails=${status.activeJails}`, + `queued_groups=${status.queuedGroups}`, + `controlplane=${status.controlplaneStatus ?? 'none'}`, + '', + ].join('\n'); +} + +/** + * Connect to the watchdog IPC socket, request status, and return a normalized + * Colibri host-status record. Mirrors the newline-framed `{ok,data}` protocol + * used by `doctor.ts` so both consumers stay consistent. Never throws — all + * failures resolve to `{ ok: false, error }`. + */ +export function readColibriHostStatus(options?: { + socketPath?: string; + timeoutMs?: number; + observedAt?: string; +}): Promise { + const socketPath = options?.socketPath ?? WATCHDOG_SOCKET_PATH; + const timeoutMs = options?.timeoutMs ?? 2_000; + + return new Promise((resolve) => { + const socket = net.createConnection(socketPath); + let settled = false; + let timer: ReturnType; + + const done = (result: ColibriHostStatusResult): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.destroy(); + resolve(result); + }; + + timer = setTimeout( + () => + done({ + ok: false, + error: `watchdog socket timeout after ${timeoutMs}ms`, + }), + timeoutMs, + ); + + socket.on('connect', () => { + socket.write(JSON.stringify({ cmd: 'status' }) + '\n'); + }); + + let buf = ''; + socket.on('data', (data) => { + buf += data.toString(); + const nl = buf.indexOf('\n'); + if (nl === -1) return; + + let res: { ok?: boolean; data?: unknown }; + try { + res = JSON.parse(buf.slice(0, nl)) as { ok?: boolean; data?: unknown }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + done({ ok: false, error: `invalid watchdog response: ${message}` }); + return; + } + + if (res.ok !== true || !isRecord(res.data)) { + done({ ok: false, error: 'watchdog returned non-ok or missing data' }); + return; + } + + done({ + ok: true, + status: normalizeWatchdogStatus(res.data, options?.observedAt), + }); + }); + + socket.on('error', (err) => { + done({ ok: false, error: `watchdog socket error: ${err.message}` }); + }); + }); +}