From 265c42edbb9e1b6cce428c5e648c2f3fbd6a45f6 Mon Sep 17 00:00:00 2001 From: artale Date: Sat, 27 Jun 2026 17:01:10 +0200 Subject: [PATCH] Add factory reconciliation receipt --- COMMANDS.md | 3 ++ src/fable5/factory-reconciliation.test.ts | 60 +++++++++++++++++++++++ src/fable5/factory-reconciliation.ts | 47 ++++++++++++++++++ src/fable5/index.ts | 3 ++ src/index.ts | 15 ++++++ 5 files changed, 128 insertions(+) create mode 100644 src/fable5/factory-reconciliation.test.ts create mode 100644 src/fable5/factory-reconciliation.ts diff --git a/COMMANDS.md b/COMMANDS.md index 4918dcd..e077f1e 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -207,6 +207,9 @@ fable-agent plinius godmode "improve explanation quality" - `factory capabilities` - `--file ` - `--run ` +- `factory reconcile` + - `--output ` + - `--run ` - `factory rsi` - `--host ` - `--port ` diff --git a/src/fable5/factory-reconciliation.test.ts b/src/fable5/factory-reconciliation.test.ts new file mode 100644 index 0000000..cfe181f --- /dev/null +++ b/src/fable5/factory-reconciliation.test.ts @@ -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" }); + }); +}); diff --git a/src/fable5/factory-reconciliation.ts b/src/fable5/factory-reconciliation.ts new file mode 100644 index 0000000..577c18e --- /dev/null +++ b/src/fable5/factory-reconciliation.ts @@ -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 } = {}): Promise { + 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)]; +} diff --git a/src/fable5/index.ts b/src/fable5/index.ts index 2597cf0..bb1b980 100644 --- a/src/fable5/index.ts +++ b/src/fable5/index.ts @@ -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"; diff --git a/src/index.ts b/src/index.ts index 9f53604..e26917c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1564,6 +1564,21 @@ factory if (!report.requiredOk) process.exit(1); }); +factory + .command("reconcile") + .description("Write a live factory reconciliation receipt") + .option("--output ", "Receipt output path", path.join(".fable", "factory", "reconciliation-live.json")) + .option("--run ", "Emit reconciliation receipt event to .runs//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")