134 lines
5.3 KiB
TypeScript
134 lines
5.3 KiB
TypeScript
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import { spawnSync } from "node:child_process";
|
|
import { checkFactory, factoryGateReady, type FactoryCheckResult } from "./factory-status.js";
|
|
import { reconcileRsi, type ReceiptRubric, type RsiReconcileOptions, type RsiReconcileReceipt } from "./rsi-reconcile.js";
|
|
|
|
export interface VerifyCycleOptions {
|
|
host: string;
|
|
port?: number;
|
|
skill?: string;
|
|
out?: string;
|
|
allowDirty?: boolean;
|
|
canaryProof?: string;
|
|
allowReconciledStaleFactory?: boolean;
|
|
now?: Date;
|
|
factoryCheck?: () => Promise<FactoryCheckResult>;
|
|
rsiRunner?: RsiReconcileOptions["runner"];
|
|
runner?: (command: string, args: string[]) => { status: number | null; stdout: string; stderr: string };
|
|
}
|
|
|
|
export interface VerifyCycleEvidence {
|
|
container_count?: number;
|
|
factory_ok: boolean;
|
|
cron_present: boolean;
|
|
rsi_healthy: boolean;
|
|
deploy_target: "8099/deploy";
|
|
verify_cycle_passed: boolean;
|
|
receipt_id: string;
|
|
}
|
|
|
|
export interface VerifyCycleReceipt {
|
|
created_at: string;
|
|
commit_sha: string;
|
|
skill: string;
|
|
deploy: "8099/deploy gated";
|
|
evidence: VerifyCycleEvidence;
|
|
rubric: ReceiptRubric;
|
|
factory_ready: boolean;
|
|
factory: FactoryCheckResult;
|
|
rsi: RsiReconcileReceipt;
|
|
commands: Array<{ name: string; command: string; status: number | null; stdout: string; stderr: string }>;
|
|
changed_files: string[];
|
|
result: "passed" | "failed";
|
|
}
|
|
|
|
export async function verifyCycle(opts: VerifyCycleOptions): Promise<VerifyCycleReceipt> {
|
|
const skill = opts.skill ?? "rsi_canary";
|
|
if (!/^[a-zA-Z0-9_-]+$/.test(skill)) throw new Error("skill must be a simple name");
|
|
|
|
const runner = opts.runner ?? runCommand;
|
|
const port = opts.port ?? 2222;
|
|
const created = (opts.now ?? new Date()).toISOString();
|
|
const factory = await (opts.factoryCheck ?? checkFactory)();
|
|
const rsi = reconcileRsi({ host: opts.host, port, runner: opts.rsiRunner, canaryProof: opts.canaryProof, now: opts.now });
|
|
const commands = [
|
|
local("commit", "git", ["rev-parse", "HEAD"], runner),
|
|
local("changed_files", "git", ["status", "--short"], runner),
|
|
remote("auto_patch", port, opts.host, "docker exec hermes python3 /tmp/skill_health.py --auto-patch", runner),
|
|
remote("verify_skill", port, opts.host, `docker exec hermes bash -lc 'test -x /opt/data/skills/${skill}/test.sh && /opt/data/skills/${skill}/test.sh'`, runner),
|
|
];
|
|
const commit = commands.find((c) => c.name === "commit")?.stdout.trim() || "unknown";
|
|
const changed = (commands.find((c) => c.name === "changed_files")?.stdout ?? "").split(/\r?\n/).filter(Boolean);
|
|
const cleanEnough = opts.allowDirty || changed.length === 0;
|
|
const factoryReady = factoryGateReady(factory) || (opts.allowReconciledStaleFactory === true && factoryStaleButReconciled(factory));
|
|
const passed = cleanEnough && factoryReady && rsi.decision === "healthy" && commands.every((c) => c.status === 0);
|
|
const receipt: VerifyCycleReceipt = {
|
|
created_at: created,
|
|
commit_sha: commit,
|
|
skill,
|
|
deploy: "8099/deploy gated",
|
|
evidence: {
|
|
container_count: factory.factory.containers,
|
|
factory_ok: factoryReady,
|
|
cron_present: rsi.facts.cron_present,
|
|
rsi_healthy: rsi.decision === "healthy",
|
|
deploy_target: "8099/deploy",
|
|
verify_cycle_passed: passed,
|
|
receipt_id: `verify-cycle:${commit}:${created}`,
|
|
},
|
|
rubric: receiptRubric(passed),
|
|
factory_ready: factoryReady,
|
|
factory,
|
|
rsi,
|
|
commands,
|
|
changed_files: changed,
|
|
result: passed ? "passed" : "failed",
|
|
};
|
|
if (opts.out) writeVerifyCycleReceipt(opts.out, receipt);
|
|
return receipt;
|
|
}
|
|
|
|
export function writeVerifyCycleReceipt(file: string, receipt: VerifyCycleReceipt): void {
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
}
|
|
|
|
function factoryStaleButReconciled(result: FactoryCheckResult): boolean {
|
|
const factory = result.factory as Record<string, unknown>;
|
|
return result.factoryAvailable
|
|
&& result.deployAvailable
|
|
&& result.deploy.status === "ok"
|
|
&& factory.status === "stale"
|
|
&& factory.state === "STALE_FACTORY_STATE"
|
|
&& typeof factory.live_containers === "number"
|
|
&& typeof factory.catalog_containers === "number"
|
|
&& factory.truth === "live docker ps wins over catalog/memory when counts disagree";
|
|
}
|
|
|
|
function local(name: string, command: string, args: string[], runner: NonNullable<VerifyCycleOptions["runner"]>) {
|
|
const r = runner(command, args);
|
|
return { name, command: [command, ...args].join(" "), status: r.status, stdout: r.stdout.slice(0, 4000), stderr: r.stderr.slice(0, 1000) };
|
|
}
|
|
|
|
function remote(name: string, port: number, host: string, script: string, runner: NonNullable<VerifyCycleOptions["runner"]>) {
|
|
const args = ["-p", String(port), `root@${host}`, script];
|
|
const r = runner("ssh", args);
|
|
return { name, command: ["ssh", ...args].join(" "), status: r.status, stdout: r.stdout.slice(0, 4000), stderr: r.stderr.slice(0, 1000) };
|
|
}
|
|
|
|
function runCommand(command: string, args: string[]) {
|
|
const result = spawnSync(command, args, { encoding: "utf-8" });
|
|
return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
|
|
}
|
|
|
|
function receiptRubric(ok: boolean): ReceiptRubric {
|
|
return {
|
|
format: 5,
|
|
factuality: ok ? 5 : 3,
|
|
consistency: ok ? 5 : 3,
|
|
realism: 4,
|
|
quality: ok ? 5 : 3,
|
|
};
|
|
}
|