fable-agent/src/fable5/rsi-reconcile.ts

80 lines
3.7 KiB
TypeScript

import * as fs from "node:fs";
import * as path from "node:path";
import { spawnSync } from "node:child_process";
export interface RsiReconcileOptions {
host: string;
port?: number;
out?: string;
now?: Date;
runner?: (command: string, args: string[]) => { status: number | null; stdout: string; stderr: string };
}
export interface RsiReconcileReceipt {
created_at: string;
host: string;
port: number;
checks: Array<{ name: string; ok: boolean; status: number | null; stdout: string; stderr: string }>;
facts: {
cron_present: boolean;
skill_health_present: boolean;
latest_score?: string;
factory_watcher_active: boolean;
deploy_route_present: boolean;
auto_patch_proven: boolean;
};
decision: "healthy" | "healthy_but_autopatch_unproven" | "degraded";
}
export function reconcileRsi(opts: RsiReconcileOptions): RsiReconcileReceipt {
const port = opts.port ?? 2222;
const runner = opts.runner ?? runCommand;
const checks = [
check("cron", port, opts.host, "((crontab -l 2>/dev/null || true); (crontab -u goku -l 2>/dev/null || true)) | grep -E 'skill_health.py|rsi-diagnosis'", runner),
check("skill_health", port, opts.host, "test -f /tmp/skill_health.py && echo present", runner),
check("latest_rsi", port, opts.host, "tail -160 /tmp/rsi-diagnosis.log 2>/dev/null | grep -E 'RSI:|All healthy|auto-patching|Rolled back|All patched'", runner),
check("factory_watcher", port, opts.host, "systemctl is-active factory-watcher.service 2>/dev/null", runner),
check("deploy_route", port, opts.host, "code=\"$(curl -sS -m 5 -o /dev/null -w '%{http_code}' -X POST http://127.0.0.1:8099/deploy)\" && (test \"$code\" = \"401\" || test \"$code\" = \"403\") && echo present", runner),
];
const latest = checks.find((c) => c.name === "latest_rsi")?.stdout ?? "";
const facts = {
cron_present: isOk("cron", checks),
skill_health_present: isOk("skill_health", checks),
latest_score: latest.match(/RSI:\s*([^\n]+)/)?.[1]?.trim(),
factory_watcher_active: /active/.test(checks.find((c) => c.name === "factory_watcher")?.stdout ?? ""),
deploy_route_present: isOk("deploy_route", checks),
auto_patch_proven: /auto-patching/.test(latest) && /All patched/.test(latest),
};
// ponytail: cron name drift is a warning; live health + watcher + deploy route are the gate.
const coreHealthy = facts.skill_health_present && /100%|65\/65/.test(facts.latest_score ?? "") && facts.factory_watcher_active && facts.deploy_route_present;
const receipt: RsiReconcileReceipt = {
created_at: (opts.now ?? new Date()).toISOString(),
host: opts.host,
port,
checks,
facts,
decision: coreHealthy ? (facts.auto_patch_proven ? "healthy" : "healthy_but_autopatch_unproven") : "degraded",
};
if (opts.out) writeRsiReconcileReceipt(opts.out, receipt);
return receipt;
}
export function writeRsiReconcileReceipt(file: string, receipt: RsiReconcileReceipt): void {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`);
}
function check(name: string, port: number, host: string, script: string, runner: NonNullable<RsiReconcileOptions["runner"]>) {
const r = runner("ssh", ["-p", String(port), `root@${host}`, script]);
return { name, ok: r.status === 0, status: r.status, stdout: r.stdout.slice(0, 4000), stderr: r.stderr.slice(0, 1000) };
}
function isOk(name: string, checks: RsiReconcileReceipt["checks"]): boolean {
return checks.find((c) => c.name === name)?.ok ?? false;
}
function runCommand(command: string, args: string[]) {
const result = spawnSync(command, args, { encoding: "utf-8" });
return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
}