/** * The content-addressed compile-phase IR cache (`cli.md` §4.2; CLI plan Phase 1). * * This module is KEYLESS and offline-safe (N2): it persists + loads the * serializable compile IR and re-lowers each node's canonicalizer via the keyless * `compileNode(spec)` from the root `@openprose/reactor` barrel — NO * `@openai/agents`, NO `zod`, NO model call on a cache hit. The intelligent * compile sessions live behind a dynamic import in `compile/run-compile.ts`; this * module never reaches them. * * Determinism boundary (N1/N4): the contract-set fingerprint is computed with the * SDK's `contentAddressOf` over a deterministic byte image of the loaded contract * set — it is NEVER a model call. The re-lower is the SDK's pure `compileNode`. * * What is persisted under `/compile/`: * - `topology.json` — the serializable ReconcilerTopology (Forme). * - `nodes//canonicalizer-spec.json` — the per-node CanonicalizationSpec * (re-lowered at load via compileNode — NOT the * in-memory CompiledNode, which carries closures). * - `nodes//postconditions.json` — the postcondition IR ref (mode + * artifactId); runProject does not consult * postconditions today (documented v1 coarsening). * - `contract-fingerprints.json` — { node → contract_fingerprint }. * - `manifest.json` — the cache KEY = (contract-set-fp, SDK version, * model id). Cost is metadata, NOT part of the key * (correction #9 / cli.md §4.2). */ import * as fs from 'fs'; import * as path from 'path'; import { contentAddressOf } from '@openprose/reactor/adapters'; import { compileNode, type CanonicalizationSpec, type CompiledNode, type ReconcilerTopology, } from '@openprose/reactor/internals'; /** The serializable per-node postcondition reference persisted in the cache. */ export interface PersistedPostcondition { readonly node: string; readonly mode: 'deterministic' | 'render-attested'; readonly artifactId: string; } /** A token Cost as persisted in the manifest (metadata only, NOT a cache key). */ export interface PersistedCost { readonly provider: string; readonly model: string; readonly tokens: { readonly fresh: number; readonly reused: number }; readonly surprise_cause: string; } /** The cache manifest — the content-addressed compile key + standing metadata. */ export interface CompileManifest { /** The cache KEY: the contract-set fingerprint (contentAddressOf). */ readonly contract_set_fingerprint: string; /** The cache KEY: the SDK version the IR was produced with. */ readonly sdk_version: string; /** The cache KEY: the compile model id. */ readonly model: string; /** Standing metadata (NOT part of the key): the compile token cost. */ readonly cost: PersistedCost; /** Standing metadata: node/edge counts for a fast report without re-load. */ readonly nodes: number; readonly edges: number; /** Standing metadata: when the cache was written (ISO). */ readonly compiled_at: string; } /** The full IR a `serve`/`run` mounts — re-lowered, ready to hand to runProject. */ export interface LoadedCompileIR { readonly topology: ReconcilerTopology; /** Per-node re-lowered canonicalizer (via compileNode) keyed by node id. */ readonly perNode: Readonly< Record >; readonly postconditions: Readonly>; readonly contractFingerprints: Readonly>; readonly manifest: CompileManifest; } /** The serializable IR a compile produces, ready to persist (closures excluded). */ export interface SerializableCompileIR { readonly topology: ReconcilerTopology; readonly perNodeSpec: Readonly>; readonly postconditions: Readonly>; readonly contractFingerprints: Readonly>; readonly manifest: CompileManifest; } // --------------------------------------------------------------------------- // Layout // --------------------------------------------------------------------------- /** The compile-cache directory under a state dir. */ export function compileDir(stateDir: string): string { return path.join(stateDir, 'compile'); } export function manifestPath(stateDir: string): string { return path.join(compileDir(stateDir), 'manifest.json'); } function topologyPath(stateDir: string): string { return path.join(compileDir(stateDir), 'topology.json'); } function contractFingerprintsPath(stateDir: string): string { return path.join(compileDir(stateDir), 'contract-fingerprints.json'); } function nodeDir(stateDir: string, node: string): string { return path.join(compileDir(stateDir), 'nodes', encodeNode(node)); } function nodeSpecPath(stateDir: string, node: string): string { return path.join(nodeDir(stateDir, node), 'canonicalizer-spec.json'); } function nodePostconditionPath(stateDir: string, node: string): string { return path.join(nodeDir(stateDir, node), 'postconditions.json'); } /** A filesystem-safe node directory name (node ids may carry path-hostile chars). */ function encodeNode(node: string): string { return node.replace(/[^A-Za-z0-9._-]/g, (c) => `_${c.charCodeAt(0).toString(16)}`); } // --------------------------------------------------------------------------- // The contract-set fingerprint (deterministic — NOT a model call, N1/N4) // --------------------------------------------------------------------------- /** A contract's identity-bearing source image, the unit the set-fp is built over. */ export interface ContractImage { readonly id: string; readonly name: string; readonly kind: string; readonly requires?: string; readonly maintains?: string; readonly continuity?: string; readonly execution?: string; readonly criteria?: string; } /** * Compute the contract-SET fingerprint: `contentAddressOf` over the byte-encoded * SORTED UNION of per-contract source images. Deterministic + keyless. The * per-contract image mirrors the SDK's own `deriveContractFingerprints` image * (run-project.ts) so the set-fp moves on exactly the changes that move a node's * contract fingerprint (id/name/kind/requires/maintains/continuity/execution/ * criteria). Adding/removing a contract changes the union, so the set-fp moves. */ export function contractSetFingerprint(images: readonly ContractImage[]): string { const perContract = images .map((c) => contractImageString(c)) .sort(); const setImage = perContract.join('\n\n'); return contentAddressOf(new TextEncoder().encode(setImage)); } /** The per-contract deterministic image (matches the SDK's content-address form). */ function contractImageString(c: ContractImage): string { return [ `id:${c.id}`, `name:${c.name}`, `kind:${c.kind}`, `requires:${c.requires ?? ''}`, `maintains:${c.maintains ?? ''}`, `continuity:${c.continuity ?? ''}`, `execution:${c.execution ?? ''}`, `criteria:${c.criteria ?? ''}`, ].join('\n'); } // --------------------------------------------------------------------------- // Persist // --------------------------------------------------------------------------- /** Write the serializable IR to `/compile/` (atomic-ish per file). */ export function persistIR(stateDir: string, ir: SerializableCompileIR): void { const dir = compileDir(stateDir); fs.mkdirSync(dir, { recursive: true }); writeJson(topologyPath(stateDir), ir.topology); writeJson(contractFingerprintsPath(stateDir), ir.contractFingerprints); for (const [node, spec] of Object.entries(ir.perNodeSpec)) { fs.mkdirSync(nodeDir(stateDir, node), { recursive: true }); writeJson(nodeSpecPath(stateDir, node), spec); const pc = ir.postconditions[node]; if (pc !== undefined) { writeJson(nodePostconditionPath(stateDir, node), pc); } } // The manifest is written LAST — its presence + matching fingerprint is the // commit point of a successful compile (a half-written cache has no manifest). writeJson(manifestPath(stateDir), ir.manifest); } // --------------------------------------------------------------------------- // Load + re-lower (keyless — compileNode, no model) // --------------------------------------------------------------------------- /** Read the manifest if present (a cache miss / no compile yet ⇒ undefined). */ export function readManifest(stateDir: string): CompileManifest | undefined { return readJson(manifestPath(stateDir)); } /** * Read just the topology SHAPE (entry points + acyclicity) from the persisted * `topology.json` — keyless, no node re-lower. A warm `compile` / `compile --json` * builds its report from the cache without re-running Forme, and both fields ARE * persisted; reading them here is what lets a cache HIT report the real * `entry_points`/`acyclic` instead of a placeholder (a cyclic graph must not * report `acyclic: true` just because it was served from cache). Returns * undefined when no topology is cached. (Forme `diagnostics` are NOT yet persisted * in the IR cache — a tracked follow-on — so they stay empty on a cache hit.) */ export function readTopologyShape( stateDir: string, ): { readonly entry_points: readonly string[]; readonly acyclic: boolean } | undefined { const topology = readJson(topologyPath(stateDir)); if (topology === undefined) { return undefined; } return { entry_points: [...topology.topology.entry_points], acyclic: topology.topology.acyclic, }; } /** * Is the cache FRESH for `contractSetFp` + `sdkVersion` + `model`? All three are * the cache key; a mismatch (or a missing manifest) is stale. Cost is excluded. */ export function isCacheFresh( stateDir: string, contractSetFp: string, sdkVersion: string, model: string, ): boolean { const m = readManifest(stateDir); return ( m !== undefined && m.contract_set_fingerprint === contractSetFp && m.sdk_version === sdkVersion && m.model === model ); } /** * Load the cached IR + re-lower each node's canonicalizer via `compileNode(spec)` * — keyless, no model, no `@openai/agents`. Throws if the cache is incomplete * (a node spec referenced by the topology is missing). */ export function loadIR(stateDir: string): LoadedCompileIR { const manifest = readManifest(stateDir); if (manifest === undefined) { throw new Error( `reactor: no compiled IR at ${manifestPath(stateDir)} — run \`reactor compile\` first`, ); } const topology = readJson(topologyPath(stateDir)); if (topology === undefined) { throw new Error(`reactor: compile cache is missing topology.json`); } const contractFingerprints = readJson>(contractFingerprintsPath(stateDir)) ?? {}; const perNode: Record = {}; const postconditions: Record = {}; for (const tNode of topology.topology.nodes) { const node = tNode.node; const spec = readJson(nodeSpecPath(stateDir, node)); if (spec === undefined) { throw new Error( `reactor: compile cache is missing the canonicalizer spec for node '${node}'`, ); } // KEYLESS RE-LOWER — the determinism boundary's run-side: pure compileNode. perNode[node] = { compiled: compileNode(spec), spec }; const pc = readJson(nodePostconditionPath(stateDir, node)); if (pc !== undefined) { postconditions[node] = pc; } } return { topology, perNode, postconditions, contractFingerprints, manifest, }; } // --------------------------------------------------------------------------- // JSON I/O (stable, deterministic key order) // --------------------------------------------------------------------------- function writeJson(file: string, value: unknown): void { fs.writeFileSync(file, JSON.stringify(value, stableReplacer(), 2) + '\n', 'utf8'); } function readJson(file: string): T | undefined { try { return JSON.parse(fs.readFileSync(file, 'utf8')) as T; } catch { return undefined; } } /** * A JSON replacer that emits object keys in sorted order, so the persisted IR is * byte-stable across runs (a cache that round-trips identically is the whole * point of content-addressing). */ function stableReplacer(): (key: string, value: unknown) => unknown { return (_key, value) => { if (value && typeof value === 'object' && !Array.isArray(value)) { const sorted: Record = {}; for (const k of Object.keys(value as Record).sort()) { sorted[k] = (value as Record)[k]; } return sorted; } return value; }; }