Add factory reconciliation receipt

This commit is contained in:
artale 2026-06-27 17:01:10 +02:00
parent 284c1bc95c
commit 265c42edbb
5 changed files with 128 additions and 0 deletions

View File

@ -207,6 +207,9 @@ fable-agent plinius godmode "improve explanation quality"
- `factory capabilities`
- `--file <path>`
- `--run <id>`
- `factory reconcile`
- `--output <path>`
- `--run <id>`
- `factory rsi`
- `--host <host>`
- `--port <n>`

View File

@ -0,0 +1,60 @@
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 { CANONICAL_DEPLOY_ROUTE } from "./factory-routes.js";
import { createFactoryReconciliationReceipt, writeFactoryReconciliationReceipt } from "./factory-reconciliation.js";
const roots: string[] = [];
afterEach(() => {
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
});
function factoryResult(overrides = {}) {
return {
factory: { status: "ok", containers: 25 },
deploy: { status: "ok", service: "git-proxy-deploy" },
factoryAvailable: true,
deployAvailable: true,
errors: [],
...overrides,
};
}
describe("factory reconciliation receipt", () => {
it("captures compact live evidence for the canonical 8099 deploy route", () => {
const receipt = createFactoryReconciliationReceipt(factoryResult(), { now: new Date("2026-06-27T00:00:00.000Z"), sourceCommand: "test command" });
expect(receipt).toMatchObject({
schema: "fable.factory.reconciliation.v1",
createdAt: "2026-06-27T00:00:00.000Z",
sourceCommand: "test command",
containerCount: 25,
deployRoute: { url: CANONICAL_DEPLOY_ROUTE, status: "ok", name: "git-proxy-deploy" },
decision: "ready",
reasons: [],
});
expect(receipt.deployRoute.url).toContain(":8099/deploy");
expect(receipt.deployRoute.url).not.toContain("8098");
expect(receipt.deployRoute.url).not.toContain("deploy-webhook");
});
it("fails closed on stale or missing live factory evidence", () => {
const receipt = createFactoryReconciliationReceipt(factoryResult({ factory: { status: "stale" } }));
expect(receipt.decision).toBe("blocked");
expect(receipt.containerCount).toBe("unknown");
expect(receipt.reasons).toContain("factory status stale");
});
it("writes the receipt to the requested path", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "fable-factory-reconcile-"));
roots.push(root);
const out = path.join(root, "receipt.json");
await writeFactoryReconciliationReceipt(out, { check: async () => factoryResult(), now: new Date("2026-06-27T00:00:00.000Z") });
expect(JSON.parse(fs.readFileSync(out, "utf-8"))).toMatchObject({ schema: "fable.factory.reconciliation.v1", decision: "ready" });
});
});

View File

@ -0,0 +1,47 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { CANONICAL_DEPLOY_ROUTE } from "./factory-routes.js";
import { checkFactory, factoryGateReady, type FactoryCheckResult } from "./factory-status.js";
export interface FactoryReconciliationReceipt {
schema: "fable.factory.reconciliation.v1";
createdAt: string;
sourceCommand: string;
containerCount: number | "unknown";
deployRoute: { url: string; status: string; name: string };
decision: "ready" | "blocked";
reasons: string[];
}
export function createFactoryReconciliationReceipt(result: FactoryCheckResult, opts: { now?: Date; sourceCommand?: string } = {}): FactoryReconciliationReceipt {
const reasons = reconciliationReasons(result);
return {
schema: "fable.factory.reconciliation.v1",
createdAt: (opts.now ?? new Date()).toISOString(),
sourceCommand: opts.sourceCommand ?? "fable-agent factory reconcile",
containerCount: result.factory.containers ?? "unknown",
deployRoute: {
url: CANONICAL_DEPLOY_ROUTE,
status: result.deploy.status ?? "unknown",
name: result.deploy.service ?? "unknown",
},
decision: reasons.length === 0 ? "ready" : "blocked",
reasons,
};
}
export async function writeFactoryReconciliationReceipt(file: string, opts: { now?: Date; sourceCommand?: string; check?: () => Promise<FactoryCheckResult> } = {}): Promise<FactoryReconciliationReceipt> {
const receipt = createFactoryReconciliationReceipt(await (opts.check ?? checkFactory)(), opts);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`);
return receipt;
}
function reconciliationReasons(result: FactoryCheckResult): string[] {
const reasons = [...result.errors];
if (!result.factoryAvailable) reasons.push("factory-status unavailable");
if (!result.deployAvailable) reasons.push("canonical 8099/deploy route unavailable");
if (result.factory.status !== "ok") reasons.push(`factory status ${result.factory.status ?? "unknown"}`);
if (result.deploy.status !== "ok") reasons.push(`deploy route status ${result.deploy.status ?? "unknown"}`);
return factoryGateReady(result) ? [] : [...new Set(reasons)];
}

View File

@ -51,6 +51,9 @@ export type { CapabilityProbeResult, CapabilityReport, FactoryCapabilityService
export { deployToFactory, isAllowedFactoryCommand, writeFactoryDeployReceipt } from "./factory-deploy.js";
export type { FactoryDeployOptions, FactoryDeployReceipt } from "./factory-deploy.js";
export { createFactoryReconciliationReceipt, writeFactoryReconciliationReceipt } from "./factory-reconciliation.js";
export type { FactoryReconciliationReceipt } from "./factory-reconciliation.js";
export { reconcileRsi, writeRsiReconcileReceipt } from "./rsi-reconcile.js";
export type { RsiReconcileOptions, RsiReconcileReceipt } from "./rsi-reconcile.js";

View File

@ -1564,6 +1564,21 @@ factory
if (!report.requiredOk) process.exit(1);
});
factory
.command("reconcile")
.description("Write a live factory reconciliation receipt")
.option("--output <path>", "Receipt output path", path.join(".fable", "factory", "reconciliation-live.json"))
.option("--run <id>", "Emit reconciliation receipt event to .runs/<id>/channel.jsonl")
.action(async (opts: { output?: string; run?: string }) => {
const { writeFactoryReconciliationReceipt } = await import("./fable5/factory-reconciliation.js");
const out = opts.output ?? path.join(".fable", "factory", "reconciliation-live.json");
const receipt = await writeFactoryReconciliationReceipt(out, { sourceCommand: `node ${process.argv.slice(1).join(" ")}` });
console.log(JSON.stringify(receipt, null, 2));
console.log(`\n Receipt: ${out}\n`);
await emitRunEvent(opts.run, { source: "gate", type: receipt.decision === "ready" ? "receipt" : "error", data: { kind: "factory-reconciliation", receipt, path: out } });
if (receipt.decision !== "ready") process.exit(1);
});
factory
.command("rsi")
.description("Reconcile live RSI/self-improvement evidence into a receipt")