Skip to content

Commit 2b3fdee

Browse files
committed
feat(scenarios): auto-persist compile-from-seed scenarios + run-count + age in picker
1 parent 0dd8f7a commit 2b3fdee

4 files changed

Lines changed: 526 additions & 11 deletions

File tree

src/cli/dashboard/src/components/quickstart/LoadedScenarioCTA.tsx

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,34 @@ interface CatalogScenario {
2020
description?: string;
2121
departments?: number;
2222
source?: string;
23+
compiledAt?: string;
24+
seedText?: string | null;
25+
runCount?: number;
26+
}
27+
28+
/**
29+
* Render an ISO-8601 compiledAt timestamp as a coarse "X days ago"
30+
* affix for the picker option label. Tight (3-char minutes / hours
31+
* / days / weeks suffix) so the dropdown stays scannable. Older than
32+
* 365 days falls back to the year ("compiled 2024").
33+
*/
34+
function formatCompiledAge(iso: string): string {
35+
const then = Date.parse(iso);
36+
if (!Number.isFinite(then)) return '';
37+
const diffMs = Date.now() - then;
38+
if (diffMs < 0) return 'just now';
39+
const minutes = Math.floor(diffMs / 60_000);
40+
if (minutes < 1) return 'just now';
41+
if (minutes < 60) return `${minutes}m ago`;
42+
const hours = Math.floor(minutes / 60);
43+
if (hours < 24) return `${hours}h ago`;
44+
const days = Math.floor(hours / 24);
45+
if (days < 7) return `${days}d ago`;
46+
const weeks = Math.floor(days / 7);
47+
if (weeks < 5) return `${weeks}w ago`;
48+
const months = Math.floor(days / 30);
49+
if (months < 12) return `${months}mo ago`;
50+
return new Date(iso).getFullYear().toString();
2351
}
2452

2553
interface LoadedScenarioCTAProps {
@@ -194,14 +222,28 @@ export function LoadedScenarioCTA({
194222
aria-label="Switch active scenario"
195223
>
196224
{renderedScenarios.map(s => {
197-
const tag = s.source === 'builtin' ? ' [builtin]'
198-
: s.source === 'disk' ? ' [disk]'
199-
: s.source === 'compiled' ? ' [compiled]'
200-
: s.source === 'active' ? ' [active]'
225+
const tag = s.source === 'builtin' ? '[builtin]'
226+
: s.source === 'disk' ? '[disk]'
227+
: s.source === 'compiled' ? '[compiled]'
228+
: s.source === 'active' ? '[active]'
201229
: '';
230+
// Suffix shape: "[source] · 12 runs · 3d ago" — only
231+
// the parts with real values render so a fresh-install
232+
// builtin reads as "Mars Genesis [builtin]" without
233+
// empty " · " gaps.
234+
const parts: string[] = [];
235+
if (tag) parts.push(tag);
236+
if (typeof s.runCount === 'number' && s.runCount > 0) {
237+
parts.push(`${s.runCount} run${s.runCount === 1 ? '' : 's'}`);
238+
}
239+
if (s.compiledAt) {
240+
const age = formatCompiledAge(s.compiledAt);
241+
if (age) parts.push(age);
242+
}
243+
const suffix = parts.length > 0 ? ` · ${parts.join(' · ')}` : '';
202244
return (
203245
<option key={s.id} value={s.id}>
204-
{s.name}{tag}
246+
{s.name}{suffix}
205247
</option>
206248
);
207249
})}
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
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

Comments
 (0)