feat: add RSI reconcile receipts
This commit is contained in:
parent
933f3c8974
commit
72578ae7f7
|
|
@ -170,6 +170,10 @@ fable-agent plinius godmode "improve explanation quality"
|
|||
- `factory check`
|
||||
- `factory capabilities`
|
||||
- `--file <path>`
|
||||
- `factory rsi`
|
||||
- `--host <host>`
|
||||
- `--port <n>`
|
||||
- `--out <path>`
|
||||
- `factory deploy <command>`
|
||||
- `--token <token>`
|
||||
- `--gate-receipt <path>`
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ export type { CapabilityProbeResult, CapabilityReport, FactoryCapabilityService
|
|||
export { deployToFactory, isAllowedFactoryCommand, writeFactoryDeployReceipt } from "./factory-deploy.js";
|
||||
export type { FactoryDeployOptions, FactoryDeployReceipt } from "./factory-deploy.js";
|
||||
|
||||
export { reconcileRsi, writeRsiReconcileReceipt } from "./rsi-reconcile.js";
|
||||
export type { RsiReconcileOptions, RsiReconcileReceipt } from "./rsi-reconcile.js";
|
||||
|
||||
export { FLUE_INTEROP_ROWS, formatFlueInteropReport, summarizeFlueInterop } from "./flue-interop.js";
|
||||
export type { FlueInteropRow, FlueInteropStatus } from "./flue-interop.js";
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { reconcileRsi } from "./rsi-reconcile.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function tmpFile(): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "fable-rsi-reconcile-"));
|
||||
roots.push(root);
|
||||
return path.join(root, "receipt.json");
|
||||
}
|
||||
|
||||
function runner(overrides: Record<string, { status: number; stdout: string; stderr?: string }> = {}) {
|
||||
return (_command: string, args: string[]) => {
|
||||
const script = args.at(-1) ?? "";
|
||||
const hit = Object.entries(overrides).find(([needle]) => script.includes(needle))?.[1];
|
||||
if (hit) return { status: hit.status, stdout: hit.stdout, stderr: hit.stderr ?? "" };
|
||||
if (script.includes("skill_health.py|rsi-diagnosis")) return { status: 0, stdout: "0 */6 * * * docker cp /tmp/skill_health.py hermes:/tmp/skill_health.py\n", stderr: "" };
|
||||
if (script.includes("test -f /tmp/skill_health.py")) return { status: 0, stdout: "present\n", stderr: "" };
|
||||
if (script.includes("tail -160")) return { status: 0, stdout: "RSI: 65/65 (100%)\nAll healthy — ZTE idle\n", stderr: "" };
|
||||
if (script.includes("systemctl is-active")) return { status: 0, stdout: "active\n", stderr: "" };
|
||||
if (script.includes("/deploy")) return { status: 0, stdout: "present\n", stderr: "" };
|
||||
return { status: 1, stdout: "", stderr: "unexpected" };
|
||||
};
|
||||
}
|
||||
|
||||
describe("rsi reconcile", () => {
|
||||
it("marks healthy but autopatch unproven when all health checks pass", () => {
|
||||
const receipt = reconcileRsi({ host: "example", runner: runner(), now: new Date("2026-06-18T00:00:00Z") });
|
||||
|
||||
expect(receipt.facts.cron_present).toBe(true);
|
||||
expect(receipt.facts.latest_score).toBe("65/65 (100%)");
|
||||
expect(receipt.decision).toBe("healthy_but_autopatch_unproven");
|
||||
});
|
||||
|
||||
it("marks healthy when auto-patch evidence exists", () => {
|
||||
const receipt = reconcileRsi({ host: "example", runner: runner({ "tail -160": { status: 0, stdout: "RSI: 65/65 (100%)\nauto-patching 1 degraded skills\nAll patched successfully\n" } }) });
|
||||
|
||||
expect(receipt.facts.auto_patch_proven).toBe(true);
|
||||
expect(receipt.decision).toBe("healthy");
|
||||
});
|
||||
|
||||
it("marks degraded when cron is missing", () => {
|
||||
const receipt = reconcileRsi({ host: "example", runner: runner({ "skill_health.py|rsi-diagnosis": { status: 1, stdout: "" } }) });
|
||||
|
||||
expect(receipt.facts.cron_present).toBe(false);
|
||||
expect(receipt.decision).toBe("degraded");
|
||||
});
|
||||
|
||||
it("writes a receipt", () => {
|
||||
const out = tmpFile();
|
||||
reconcileRsi({ host: "example", out, runner: runner() });
|
||||
|
||||
expect(fs.readFileSync(out, "utf-8")).toContain("healthy_but_autopatch_unproven");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
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, "test \"$(curl -sS -m 5 -o /dev/null -w '%{http_code}' -X POST http://127.0.0.1:8099/deploy)\" = \"401\" && 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: /All patched|auto-patching/.test(latest),
|
||||
};
|
||||
const coreHealthy = facts.cron_present && 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 ?? "" };
|
||||
}
|
||||
15
src/index.ts
15
src/index.ts
|
|
@ -1452,6 +1452,21 @@ factory
|
|||
if (!report.requiredOk) process.exit(1);
|
||||
});
|
||||
|
||||
factory
|
||||
.command("rsi")
|
||||
.description("Reconcile live RSI/self-improvement evidence into a receipt")
|
||||
.option("--host <host>", "Factory SSH host", "77.42.112.29")
|
||||
.option("--port <n>", "Factory SSH port", (v) => Number(v), 2222)
|
||||
.option("--out <path>", "Receipt output path")
|
||||
.action(async (opts: { host?: string; port?: number; out?: string }) => {
|
||||
const { reconcileRsi } = await import("./fable5/rsi-reconcile.js");
|
||||
const out = opts.out ?? path.join(".fable", "rsi", `${new Date().toISOString().replace(/[:.]/g, "-")}.json`);
|
||||
const receipt = reconcileRsi({ host: opts.host ?? "77.42.112.29", port: opts.port, out });
|
||||
console.log(JSON.stringify(receipt, null, 2));
|
||||
console.log(`\n Receipt: ${out}\n`);
|
||||
if (receipt.decision === "degraded") process.exit(1);
|
||||
});
|
||||
|
||||
factory
|
||||
.command("deploy <command>")
|
||||
.description("Post an approved command to the factory deploy webhook")
|
||||
|
|
|
|||
Loading…
Reference in New Issue