diff --git a/COMMANDS.md b/COMMANDS.md index 68585a9..cbbd5b4 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -154,6 +154,8 @@ fable-agent plinius godmode "improve explanation quality" ### `factory` - `factory check` +- `factory capabilities` + - `--file ` - `factory gate ` - `--repo ` - `--diagnostic-only` diff --git a/factory-capabilities.yaml b/factory-capabilities.yaml new file mode 100644 index 0000000..0c60601 --- /dev/null +++ b/factory-capabilities.yaml @@ -0,0 +1,9 @@ +services: + deploy-webhook: + url: http://77.42.112.29:8098/health + provides: [deploy] + required: true + git-proxy: + url: http://77.42.112.29:8099/factory-status + provides: [factory-status, discovery] + required: true diff --git a/src/fable5/factory-capabilities.test.ts b/src/fable5/factory-capabilities.test.ts new file mode 100644 index 0000000..691893c --- /dev/null +++ b/src/fable5/factory-capabilities.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { formatCapabilityReport, parseFactoryCapabilities, probeFactoryCapabilities } from "./factory-capabilities.js"; + +const yaml = `services: + deploy-webhook: + url: http://77.42.112.29:8098/health + provides: [deploy] + required: true + git-proxy: + url: http://77.42.112.29:8099/factory-status + provides: [factory-status, discovery] + required: true +`; + +describe("factory capabilities", () => { + it("parses the capability registry", () => { + const services = parseFactoryCapabilities(yaml); + expect(services).toHaveLength(2); + expect(services[0]).toMatchObject({ name: "deploy-webhook", required: true, provides: ["deploy"] }); + }); + + it("fails required capabilities that return 404", async () => { + const services = parseFactoryCapabilities(yaml); + const report = await probeFactoryCapabilities(services, async (url) => ({ ok: !url.includes("8099"), status: url.includes("8099") ? 404 : 200 })); + + expect(report.requiredOk).toBe(false); + expect(report.available).toContain("deploy"); + expect(report.missing).toContain("factory-status"); + expect(formatCapabilityReport(report)).toContain("FAIL git-proxy provides factory-status, discovery: HTTP 404"); + }); +}); diff --git a/src/fable5/factory-capabilities.ts b/src/fable5/factory-capabilities.ts new file mode 100644 index 0000000..eee1e9a --- /dev/null +++ b/src/fable5/factory-capabilities.ts @@ -0,0 +1,88 @@ +import * as fs from "node:fs"; + +export interface FactoryCapabilityService { + name: string; + url: string; + provides: string[]; + required: boolean; +} + +export interface CapabilityProbeResult extends FactoryCapabilityService { + ok: boolean; + status?: number; + error?: string; +} + +export interface CapabilityReport { + results: CapabilityProbeResult[]; + available: string[]; + missing: string[]; + requiredOk: boolean; +} + +type FetchLike = (url: string) => Promise<{ ok: boolean; status: number }>; + +export function parseFactoryCapabilities(text: string): FactoryCapabilityService[] { + const services: FactoryCapabilityService[] = []; + let current: Partial | null = null; + + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line === "services:" || line.startsWith("#")) continue; + const service = raw.match(/^ ([A-Za-z0-9_-]+):\s*$/); + if (service) { + if (current?.name) services.push(normalizeService(current)); + current = { name: service[1] }; + continue; + } + if (!current) continue; + const prop = line.match(/^(url|provides|required):\s*(.*)$/); + if (!prop) continue; + const [, key, value] = prop; + if (key === "url") current.url = value; + if (key === "required") current.required = value === "true"; + if (key === "provides") current.provides = value.replace(/^\[/, "").replace(/\]$/, "").split(",").map((s) => s.trim()).filter(Boolean); + } + if (current?.name) services.push(normalizeService(current)); + return services; +} + +export function loadFactoryCapabilities(file: string): FactoryCapabilityService[] { + return parseFactoryCapabilities(fs.readFileSync(file, "utf-8")); +} + +export async function probeFactoryCapabilities(services: FactoryCapabilityService[], fetcher: FetchLike = fetch): Promise { + const results = await Promise.all(services.map(async (svc): Promise => { + try { + const res = await fetcher(svc.url); + return { ...svc, ok: res.ok, status: res.status, error: res.ok ? undefined : `HTTP ${res.status}` }; + } catch (error) { + return { ...svc, ok: false, error: error instanceof Error ? error.message : String(error) }; + } + })); + const available = [...new Set(results.filter((r) => r.ok).flatMap((r) => r.provides))]; + const missing = [...new Set(results.filter((r) => !r.ok).flatMap((r) => r.provides))]; + return { results, available, missing, requiredOk: results.every((r) => !r.required || r.ok) }; +} + +export function formatCapabilityReport(report: CapabilityReport): string { + return [ + "", + " Factory Capabilities", + " ─────────────────────────────", + ...report.results.map((r) => ` ${r.ok ? "OK " : "FAIL"} ${r.name} provides ${r.provides.join(", ")}${r.ok ? "" : `: ${r.error ?? `HTTP ${r.status ?? "unknown"}`}`}`), + "", + ` available: ${report.available.join(", ") || "none"}`, + ` missing: ${report.missing.join(", ") || "none"}`, + "", + ].join("\n"); +} + +function normalizeService(svc: Partial): FactoryCapabilityService { + return { + name: svc.name ?? "unknown", + url: svc.url ?? "", + provides: svc.provides ?? [], + required: svc.required ?? false, + }; +} diff --git a/src/fable5/index.ts b/src/fable5/index.ts index 1ae9077..0a3828b 100644 --- a/src/fable5/index.ts +++ b/src/fable5/index.ts @@ -43,3 +43,6 @@ export type { ToolPolicy, EffectiveToolPolicy } from "./orchestrator-policy.js"; export { appendZteReceipt, createZteReceipt, createZteSpec } from "./zte-protocol.js"; export type { ZteReceipt, ZteSpec, ZteSpecOptions } from "./zte-protocol.js"; + +export { formatCapabilityReport, loadFactoryCapabilities, parseFactoryCapabilities, probeFactoryCapabilities } from "./factory-capabilities.js"; +export type { CapabilityProbeResult, CapabilityReport, FactoryCapabilityService } from "./factory-capabilities.js"; diff --git a/src/index.ts b/src/index.ts index ae02f7b..ad53c40 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1440,6 +1440,18 @@ factory await printFactoryCheck(); }); +factory + .command("capabilities") + .description("Probe the factory capability registry") + .option("--file ", "Capability registry path", "factory-capabilities.yaml") + .action(async (opts: { file?: string }) => { + const { formatCapabilityReport, loadFactoryCapabilities, probeFactoryCapabilities } = await import("./fable5/factory-capabilities.js"); + const services = loadFactoryCapabilities(path.resolve(opts.file ?? "factory-capabilities.yaml")); + const report = await probeFactoryCapabilities(services); + console.log(formatCapabilityReport(report)); + if (!report.requiredOk) process.exit(1); + }); + factory .command("gate ") .description("Run factory check plus local ZTE/repo verification gate")