/** * PBL v2 A/B viewer — tiny dev server. * * Loads eval-result JSON on demand (NOT a baked-in HTML): lists every run * under `results/`, and serves each run's `projects/*.json` + `results.json` * through a small JSON API the page fetches. New runs show up on refresh — * no rebuild. * * Usage: * pnpm tsx eval/pbl-v2-planner/serve.ts # http://localhost:5179 * EVAL_PBL_PORT=8080 pnpm tsx eval/pbl-v2-planner/serve.ts * * Routes: * GET / → viewer page (run selector + side-by-side compare) * GET /api/runs → [{ dir, label, caseCount, mtime }] * GET /api/run?dir= → { runDir, cases } for one run (dir must be under results/) */ import { createServer } from 'node:http'; import { readdirSync, statSync, existsSync } from 'node:fs'; import { join, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { collectRunData, VIEWER_CSS, CLIENT_RENDER_JS } from './compare-html'; function here(): string { return typeof __dirname !== 'undefined' ? __dirname : dirname(fileURLToPath(import.meta.url)); } const RESULTS_ROOT = resolve(join(here(), 'results')); const PORT = parseInt(process.env.EVAL_PBL_PORT || '5179', 10); interface RunEntry { dir: string; label: string; caseCount: number; mtime: number; } /** All run dirs (model/ts leaves containing a projects/ subdir), newest first. */ function listRuns(): RunEntry[] { if (!existsSync(RESULTS_ROOT)) return []; const runs: RunEntry[] = []; for (const model of readdirSync(RESULTS_ROOT)) { const modelDir = join(RESULTS_ROOT, model); if (!statSync(modelDir).isDirectory()) continue; for (const ts of readdirSync(modelDir)) { const dir = join(modelDir, ts); if (!statSync(dir).isDirectory()) continue; const projectsDir = join(dir, 'projects'); if (!existsSync(projectsDir)) continue; const caseCount = new Set( readdirSync(projectsDir) .filter((f) => f.endsWith('.json')) .map((f) => f.slice(0, f.lastIndexOf('__'))), ).size; runs.push({ dir, label: `${model} / ${ts} (${caseCount} cases)`, caseCount, mtime: statSync(dir).mtimeMs, }); } } return runs.sort((a, b) => b.mtime - a.mtime); } function pageHtml(): string { return ` PBL v2 — A/B viewer

PBL v2 — loop vs single-call

`; } function send( res: import('node:http').ServerResponse, code: number, type: string, body: string, ): void { res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store' }); res.end(body); } const server = createServer((req, res) => { try { const url = new URL(req.url ?? '/', `http://localhost:${PORT}`); if (url.pathname === '/') { return send(res, 200, 'text/html; charset=utf-8', pageHtml()); } if (url.pathname === '/api/runs') { return send(res, 200, 'application/json', JSON.stringify(listRuns())); } if (url.pathname === '/api/run') { const dir = resolve(url.searchParams.get('dir') ?? ''); // Path-traversal guard: only serve dirs under results/. if (!dir.startsWith(RESULTS_ROOT + '/') && dir !== RESULTS_ROOT) { return send(res, 403, 'application/json', JSON.stringify({ error: 'forbidden' })); } if (!existsSync(join(dir, 'projects'))) { return send(res, 404, 'application/json', JSON.stringify({ error: 'not a run dir' })); } return send(res, 200, 'application/json', JSON.stringify(collectRunData(dir))); } send(res, 404, 'text/plain', 'not found'); } catch (err) { send(res, 500, 'application/json', JSON.stringify({ error: String(err) })); } }); server.listen(PORT, () => { console.log(`PBL v2 A/B viewer → http://localhost:${PORT}`); console.log(`Serving runs from ${RESULTS_ROOT}`); });