Add Colibri watchdog host-status seam

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 <noreply@anthropic.com>

---
Build: pass | Tests: FAIL — 2 failed
This commit is contained in:
Operator & Claude Code 2026-05-24 21:28:03 +02:00
parent b26e4da118
commit 2b611161a4
2 changed files with 314 additions and 0 deletions

View file

@ -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('<colibri-host-status>');
expect(summary).toContain('mode=auto');
expect(summary).toContain('throttled=YES');
expect(summary).toContain('controlplane=degraded');
expect(summary).toContain('</colibri-host-status>');
});
});
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<void>((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<void>((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);
});
});

164
src/colibri-host-status.ts Normal file
View file

@ -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<string, unknown>;
}
export type ColibriHostStatusResult =
| { ok: true; status: ColibriHostStatus }
| { ok: false; error: string };
function isRecord(value: unknown): value is Record<string, unknown> {
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<string, unknown>,
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 [
'<colibri-host-status>',
`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'}`,
'</colibri-host-status>',
].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<ColibriHostStatusResult> {
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<typeof setTimeout>;
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}` });
});
});
}