/** * Deploy intent cache — `.mandu/deploy.intent.json`. * * Issue #250 — Phase 1. * * The cache is the persistence layer between `mandu deploy:plan` * (writer) and `mandu deploy --target=...` (reader). It MUST be * checked into the repo so deploys are deterministic, brain-free, and * reproducible across CI runs. * * Per-entry shape: * * - `intent` — fully validated `DeployIntent`. * - `source` — `"explicit"` (user wrote `.deploy()` on the * route) or `"inferred"` (heuristic / brain). * Explicit entries are never overwritten by * `deploy:plan` — the inferer treats them as * pinned ground truth. * - `rationale` — short, human-readable reason. The plan command * surfaces this in the diff so reviewers can audit * why a route landed on a given runtime. * - `sourceHash` — content hash of the route source the intent was * inferred from. The next plan call skips * re-inference when the hash matches — that's the * cost cap on brain calls. * - `inferredAt` — ISO timestamp; left unset on `explicit` entries. */ import { z } from "zod"; import { promises as fs } from "fs"; import path from "path"; import { DeployIntent } from "./intent"; export const DeployIntentSource = z.enum(["explicit", "inferred"]); export type DeployIntentSource = z.infer; export const DeployIntentCacheEntry = z.object({ intent: DeployIntent, source: DeployIntentSource, rationale: z.string().min(1), sourceHash: z.string().min(1), inferredAt: z.string().datetime().optional(), }); export type DeployIntentCacheEntry = z.infer; export const DeployIntentCache = z.object({ /** Format version — bump on any breaking schema change. */ version: z.literal(1), /** ISO timestamp of the most recent `deploy:plan` write. */ generatedAt: z.string().datetime(), /** * Identifier of the inferer used for the most recent write. Examples: * - `"heuristic"` — rule-tree only (no brain). * - `"openai:gpt-4.1-mini"` — brain-validated. * - `"manual"` — user hand-edited entries. */ brainModel: z.string().min(1).default("heuristic"), /** Map of route id → cache entry. */ intents: z.record(z.string(), DeployIntentCacheEntry), }); export type DeployIntentCache = z.infer; /** Filename relative to the project root. */ export const DEPLOY_INTENT_CACHE_FILE = ".mandu/deploy.intent.json"; /** Resolve the absolute path of the cache file for a given project root. */ export function resolveDeployIntentCachePath(rootDir: string): string { return path.join(rootDir, DEPLOY_INTENT_CACHE_FILE); } /** * An empty (but valid) cache. Returned by `loadDeployIntentCache` * when the file is missing — the caller treats that as "every route * needs inference". */ export function emptyDeployIntentCache(): DeployIntentCache { return { version: 1, generatedAt: new Date().toISOString(), brainModel: "heuristic", intents: {}, }; } /** * Read + validate the cache file. A missing file resolves to an empty * cache so first-time runs work without ceremony. A malformed file * rejects — silently swallowing JSON errors would let a corrupted * cache silently produce wrong deploy configs. */ export async function loadDeployIntentCache( rootDir: string, ): Promise { const file = resolveDeployIntentCachePath(rootDir); let raw: string; try { raw = await fs.readFile(file, "utf8"); } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") { return emptyDeployIntentCache(); } throw err; } let parsed: unknown; try { parsed = JSON.parse(raw); } catch (err) { throw new Error( `Deploy intent cache is not valid JSON (${file}): ${(err as Error).message}`, { cause: err }, ); } return DeployIntentCache.parse(parsed); } /** * Atomically write the cache. The intermediate `.tmp` file + rename * dance prevents a half-written cache from being read by a concurrent * deploy. */ export async function saveDeployIntentCache( rootDir: string, cache: DeployIntentCache, ): Promise { const validated = DeployIntentCache.parse(cache); const file = resolveDeployIntentCachePath(rootDir); await fs.mkdir(path.dirname(file), { recursive: true }); const tmp = `${file}.tmp`; // Stable key order so the committed file stays diff-friendly. const ordered: DeployIntentCache = { ...validated, intents: Object.fromEntries( Object.keys(validated.intents) .sort() .map((id) => [id, validated.intents[id]!]), ), }; await fs.writeFile(tmp, JSON.stringify(ordered, null, 2) + "\n", "utf8"); await fs.rename(tmp, file); }