From 72578ae7f7be6d8a82edbd7a92034edbd88e7ebe Mon Sep 17 00:00:00 2001 From: artale Date: Thu, 18 Jun 2026 03:10:23 +0200 Subject: [PATCH] feat: add RSI reconcile receipts --- COMMANDS.md | 4 ++ src/fable5/index.ts | 3 ++ src/fable5/rsi-reconcile.test.ts | 62 +++++++++++++++++++++++++ src/fable5/rsi-reconcile.ts | 78 ++++++++++++++++++++++++++++++++ src/index.ts | 15 ++++++ 5 files changed, 162 insertions(+) create mode 100644 src/fable5/rsi-reconcile.test.ts create mode 100644 src/fable5/rsi-reconcile.ts diff --git a/COMMANDS.md b/COMMANDS.md index 87e818b..dad4888 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -170,6 +170,10 @@ fable-agent plinius godmode "improve explanation quality" - `factory check` - `factory capabilities` - `--file ` +- `factory rsi` + - `--host ` + - `--port ` + - `--out ` - `factory deploy ` - `--token ` - `--gate-receipt ` diff --git a/src/fable5/index.ts b/src/fable5/index.ts index dbc0b1c..fdf0d06 100644 --- a/src/fable5/index.ts +++ b/src/fable5/index.ts @@ -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"; diff --git a/src/fable5/rsi-reconcile.test.ts b/src/fable5/rsi-reconcile.test.ts new file mode 100644 index 0000000..587a04f --- /dev/null +++ b/src/fable5/rsi-reconcile.test.ts @@ -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 = {}) { + 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"); + }); +}); diff --git a/src/fable5/rsi-reconcile.ts b/src/fable5/rsi-reconcile.ts new file mode 100644 index 0000000..bfcdee4 --- /dev/null +++ b/src/fable5/rsi-reconcile.ts @@ -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) { + 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 ?? "" }; +} diff --git a/src/index.ts b/src/index.ts index 8f4ee86..916d5a9 100644 --- a/src/index.ts +++ b/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 ", "Factory SSH host", "77.42.112.29") + .option("--port ", "Factory SSH port", (v) => Number(v), 2222) + .option("--out ", "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 ") .description("Post an approved command to the factory deploy webhook")