|
| 1 | +/** |
| 2 | + * Auto-persistence layer for compile-from-seed scenarios. |
| 3 | + * |
| 4 | + * Quickstart's compile-from-seed flow used to register every successful |
| 5 | + * compile in `customScenarioCatalog` with `source: 'compiled'` — but |
| 6 | + * memory-only. A server restart wiped the catalog and users who had |
| 7 | + * authored a custom scenario lost it. This module saves the |
| 8 | + * post-compile draft (the scenario shape minus the function-typed |
| 9 | + * `hooks` field) to `${scenarioDir}/compiled/{id}.json` after every |
| 10 | + * successful compile, and exposes loaders so the server can lift those |
| 11 | + * drafts back into the catalog at boot. Hook source strings live in |
| 12 | + * the compiler's separate disk cache (`.paracosm/cache/`), so a boot- |
| 13 | + * time `compileScenario(draft, { cache: true })` re-hydrates the full |
| 14 | + * runnable scenario for free when the cache is warm — and at LLM cost |
| 15 | + * (~$0.10/draft) when it's cold (e.g. fresh deploy with no cache |
| 16 | + * volume mounted). |
| 17 | + * |
| 18 | + * @module paracosm/cli/persisted-compiled-scenarios |
| 19 | + */ |
| 20 | +import { |
| 21 | + readdirSync, |
| 22 | + readFileSync, |
| 23 | + writeFileSync, |
| 24 | + mkdirSync, |
| 25 | + existsSync, |
| 26 | + unlinkSync, |
| 27 | + statSync, |
| 28 | +} from 'node:fs'; |
| 29 | +import { resolve } from 'node:path'; |
| 30 | +import type { ScenarioPackage } from '../engine/types.js'; |
| 31 | + |
| 32 | +/** |
| 33 | + * Subdirectory under `scenarios/` where compile-from-seed scenarios |
| 34 | + * get auto-persisted. Kept separate from `scenarios/` proper so |
| 35 | + * admin-curated drafts (corporate-quarterly.json, frontier-ai-lab.json, |
| 36 | + * etc.) and runtime-compiled drafts stay distinguishable on disk and |
| 37 | + * the catalog UI can label them differently if it wants. |
| 38 | + */ |
| 39 | +export const COMPILED_SUBDIR = 'compiled'; |
| 40 | + |
| 41 | +/** |
| 42 | + * Cap on the number of persisted compiled scenarios at any time. Oldest |
| 43 | + * (by mtime) evict FIFO when a 51st scenario tries to save. 50 is sized |
| 44 | + * for the public-demo use case: enough room for the entire interesting- |
| 45 | + * scenario tail, small enough that disk + boot-time recompile cost |
| 46 | + * stay bounded. |
| 47 | + */ |
| 48 | +export const COMPILED_SCENARIOS_CAP = 50; |
| 49 | + |
| 50 | +/** |
| 51 | + * Side-channel metadata attached to each persisted draft. Captures when |
| 52 | + * the scenario was compiled and (truncated) what seed text produced it, |
| 53 | + * so a public catalog can render "compiled 3 days ago from a brief |
| 54 | + * about ... " without re-deriving from the scenario body. |
| 55 | + */ |
| 56 | +export interface PersistedCompiledMeta { |
| 57 | + /** ISO-8601 wall-clock when compile-from-seed succeeded. */ |
| 58 | + compiledAt: string; |
| 59 | + /** First 1KB of the original seed prompt. Null when the compile path |
| 60 | + * didn't carry seed text (e.g. /scenario/store calls). */ |
| 61 | + seedText: string | null; |
| 62 | +} |
| 63 | + |
| 64 | +/** One entry returned by {@link loadPersistedCompiledDrafts}. */ |
| 65 | +export interface PersistedCompiledDraft { |
| 66 | + id: string; |
| 67 | + /** Scenario JSON minus the function-typed `hooks` field — runnable |
| 68 | + * through `compileScenario(draft, { cache: true })`. */ |
| 69 | + draft: Record<string, unknown>; |
| 70 | + meta: PersistedCompiledMeta; |
| 71 | +} |
| 72 | + |
| 73 | +/** Resolve the `compiled/` subdir path under a scenario root. */ |
| 74 | +function compiledDir(scenarioDir: string): string { |
| 75 | + return resolve(scenarioDir, COMPILED_SUBDIR); |
| 76 | +} |
| 77 | + |
| 78 | +/** |
| 79 | + * Strip the function-typed `hooks` field so the rest of the scenario |
| 80 | + * round-trips through JSON cleanly. `compileScenario` regenerates hooks |
| 81 | + * from cached source strings (free) or via LLM ($0.10) so the |
| 82 | + * persisted JSON only needs to carry the scenario shape — the hook |
| 83 | + * functions themselves are not directly serializable. |
| 84 | + */ |
| 85 | +function stripHooks(scenario: ScenarioPackage): Record<string, unknown> { |
| 86 | + const obj = scenario as unknown as Record<string, unknown>; |
| 87 | + const { hooks: _hooks, ...rest } = obj; |
| 88 | + return rest; |
| 89 | +} |
| 90 | + |
| 91 | +/** |
| 92 | + * Save a compile-from-seed scenario to disk. Caller passes the fully- |
| 93 | + * compiled scenario; we persist a hook-stripped copy plus metadata. |
| 94 | + * Idempotent on the same id (overwrites the previous file). |
| 95 | + * |
| 96 | + * @returns Absolute path written, or `null` on filesystem failure (we |
| 97 | + * swallow the error, log via console.warn, and let the in-memory |
| 98 | + * catalog continue to serve the live run; persistence is a best- |
| 99 | + * effort enhancement, not a critical path). |
| 100 | + */ |
| 101 | +export function persistCompiledScenario( |
| 102 | + scenarioDir: string, |
| 103 | + scenario: ScenarioPackage, |
| 104 | + seedText: string | null, |
| 105 | +): string | null { |
| 106 | + try { |
| 107 | + const dir = compiledDir(scenarioDir); |
| 108 | + mkdirSync(dir, { recursive: true }); |
| 109 | + const meta: PersistedCompiledMeta = { |
| 110 | + compiledAt: new Date().toISOString(), |
| 111 | + seedText: seedText && seedText.length > 0 ? seedText.slice(0, 1000) : null, |
| 112 | + }; |
| 113 | + const payload = { |
| 114 | + ...stripHooks(scenario), |
| 115 | + _persistMeta: meta, |
| 116 | + }; |
| 117 | + const filePath = resolve(dir, `${scenario.id}.json`); |
| 118 | + writeFileSync(filePath, JSON.stringify(payload, null, 2)); |
| 119 | + enforceCompiledCap(dir, COMPILED_SCENARIOS_CAP); |
| 120 | + return filePath; |
| 121 | + } catch (err) { |
| 122 | + console.warn(`[scenarios] persistCompiledScenario failed for ${scenario.id}:`, err); |
| 123 | + return null; |
| 124 | + } |
| 125 | +} |
| 126 | + |
| 127 | +/** |
| 128 | + * Read every compiled-draft JSON from the persistence dir. Malformed |
| 129 | + * files are skipped silently — one corrupted entry never blocks the |
| 130 | + * rest of the catalog from loading. |
| 131 | + */ |
| 132 | +export function loadPersistedCompiledDrafts(scenarioDir: string): PersistedCompiledDraft[] { |
| 133 | + const dir = compiledDir(scenarioDir); |
| 134 | + if (!existsSync(dir)) return []; |
| 135 | + const out: PersistedCompiledDraft[] = []; |
| 136 | + for (const entry of readdirSync(dir, { withFileTypes: true })) { |
| 137 | + if (!entry.isFile() || !entry.name.endsWith('.json')) continue; |
| 138 | + const filePath = resolve(dir, entry.name); |
| 139 | + try { |
| 140 | + const raw = JSON.parse(readFileSync(filePath, 'utf-8')) as Record<string, unknown>; |
| 141 | + const id = typeof raw.id === 'string' ? raw.id : null; |
| 142 | + if (!id) continue; |
| 143 | + const persistMeta = (raw._persistMeta ?? {}) as Partial<PersistedCompiledMeta>; |
| 144 | + // Fall back to file mtime when the on-disk metadata predates the |
| 145 | + // _persistMeta field (e.g. user manually dropped a JSON in this |
| 146 | + // dir without going through persistCompiledScenario). |
| 147 | + const fallbackCompiledAt = (() => { |
| 148 | + try { return new Date(statSync(filePath).mtimeMs).toISOString(); } |
| 149 | + catch { return new Date(0).toISOString(); } |
| 150 | + })(); |
| 151 | + const meta: PersistedCompiledMeta = { |
| 152 | + compiledAt: typeof persistMeta.compiledAt === 'string' ? persistMeta.compiledAt : fallbackCompiledAt, |
| 153 | + seedText: typeof persistMeta.seedText === 'string' ? persistMeta.seedText : null, |
| 154 | + }; |
| 155 | + // Strip the side-channel field so the draft passed to |
| 156 | + // compileScenario matches the schema it expects. |
| 157 | + const { _persistMeta: _meta, ...draft } = raw; |
| 158 | + out.push({ id, draft, meta }); |
| 159 | + } catch (err) { |
| 160 | + console.warn(`[scenarios] skipping unreadable draft ${entry.name}:`, err); |
| 161 | + } |
| 162 | + } |
| 163 | + return out; |
| 164 | +} |
| 165 | + |
| 166 | +/** |
| 167 | + * FIFO eviction once the cap is exceeded. Oldest by mtime drops first. |
| 168 | + * Idempotent and safe to call after every persist — at-cap state is the |
| 169 | + * common case so the function returns quickly when nothing needs |
| 170 | + * eviction. |
| 171 | + */ |
| 172 | +function enforceCompiledCap(dir: string, cap: number): void { |
| 173 | + if (!existsSync(dir)) return; |
| 174 | + const files = readdirSync(dir, { withFileTypes: true }) |
| 175 | + .filter((e) => e.isFile() && e.name.endsWith('.json')) |
| 176 | + .map((e) => { |
| 177 | + const filePath = resolve(dir, e.name); |
| 178 | + return { filePath, mtime: statSync(filePath).mtimeMs }; |
| 179 | + }) |
| 180 | + .sort((a, b) => a.mtime - b.mtime); |
| 181 | + while (files.length > cap) { |
| 182 | + const drop = files.shift(); |
| 183 | + if (!drop) break; |
| 184 | + try { |
| 185 | + unlinkSync(drop.filePath); |
| 186 | + } catch (err) { |
| 187 | + console.warn(`[scenarios] eviction failed for ${drop.filePath}:`, err); |
| 188 | + } |
| 189 | + } |
| 190 | +} |
| 191 | + |
| 192 | +/** |
| 193 | + * Remove a persisted draft by id. Returns true when a file was actually |
| 194 | + * deleted, false when no matching file existed (idempotent for callers |
| 195 | + * that don't pre-check). Used by the future `/scenario/delete` admin |
| 196 | + * surface and by tests; production server-app does not call this on |
| 197 | + * the active path. |
| 198 | + */ |
| 199 | +export function deletePersistedCompiledScenario(scenarioDir: string, id: string): boolean { |
| 200 | + const filePath = resolve(compiledDir(scenarioDir), `${id}.json`); |
| 201 | + if (!existsSync(filePath)) return false; |
| 202 | + try { |
| 203 | + unlinkSync(filePath); |
| 204 | + return true; |
| 205 | + } catch (err) { |
| 206 | + console.warn(`[scenarios] deletePersistedCompiledScenario failed for ${id}:`, err); |
| 207 | + return false; |
| 208 | + } |
| 209 | +} |
0 commit comments