import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { isDeepStrictEqual } from "node:util"; import { invalidateConfigCache } from "../config/loader.js"; import { completeCustomProfile, mergePreservingUnknownKeys, } from "../config/profile-materialization.js"; import { LLMConfigBase, ProfileEntry } from "../config/schemas/llm.js"; import { getLogger } from "../util/logger.js"; const log = getLogger("custom-profile-ensure"); // Materializes partial custom profiles into complete overrides on every boot. // // This is a boot ensure pass rather than a workspace migration because // completion needs the live model catalog: a model-only profile takes the // provider `completeCustomProfile` implies from the catalog, and migrations // are frozen self-contained snapshots that may not import it (see // workspace/migrations/AGENTS.md). Using the live helper keeps this pass and // the write-path normalization in `commitConfigWrite` identical by // construction. Running unconditionally each boot (the // `ensureDefaultProvider` pattern) also covers configs restored from backups // and entries injected after the migration registry checkpoints — and it // completes before the first `loadConfig()`, so no resolution ever sees a // partial custom profile. // // Idempotent and write-avoidant: entries that are already complete (and mix // profiles, managed-source entries, and entries that do not parse as a // `ProfileEntry`) are left byte-identical; the file is rewritten only when // at least one entry changed. Unknown keys on an entry survive completion. // Each materialized profile logs the exact fields it baked so a later // resolution report can be attributed to baked data vs. resolver semantics. export function ensureCompleteCustomProfiles(workspaceDir: string): void { const configPath = join(workspaceDir, "config.json"); if (!existsSync(configPath)) { return; } let config: Record; try { const raw = JSON.parse(readFileSync(configPath, "utf-8")); if (!raw || typeof raw !== "object" || Array.isArray(raw)) { return; } config = raw as Record; } catch { return; } const llm = readObject(config.llm); if (llm === null) { return; } const profiles = readObject(llm.profiles); if (profiles === null) { return; } const parsedDefault = LLMConfigBase.safeParse(llm.default ?? {}); if (!parsedDefault.success) { return; } let changed = false; for (const [name, rawEntry] of Object.entries(profiles)) { const entry = readObject(rawEntry); if (entry === null) { continue; } const merged = completedProfileBody(entry, parsedDefault.data); if (merged === null || isDeepStrictEqual(merged, entry)) { continue; } const bakedFields = Object.keys(merged).filter( (key) => !(key in entry) || !isDeepStrictEqual(merged[key], entry[key]), ); profiles[name] = merged; changed = true; log.info( { profile: name, bakedFields }, "Materialized partial custom profile into a complete override", ); } if (!changed) { return; } writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n"); // The lifecycle call site runs before the first loadConfig() of this boot; // this guards callers that read config earlier (and future reordering). invalidateConfigCache(); } /** * The completion this pass bakes onto a single raw profile body: schema-parse, * complete against the workspace's default base, then merge over the original * raw entry at every depth so keys the schema doesn't know survive * (`safeParse` strips them, top-level and inside nested objects like * `contextWindow`). Returns null for a body that does not parse as a * `ProfileEntry` (completion never touches it). The BYOK conversion pass * normalizes bodies through this same function so its unedited-copy * comparisons match what this pass wrote to disk on prior boots. */ export function completedProfileBody( entry: Record, dflt: LLMConfigBase, ): Record | null { const parsed = ProfileEntry.safeParse(entry); if (!parsed.success) { return null; } return mergePreservingUnknownKeys( entry, completeCustomProfile(dflt, parsed.data) as Record, ); } function readObject(value: unknown): Record | null { if (value === null || typeof value !== "object" || Array.isArray(value)) { return null; } return value as Record; }