/* JE Validation console — configure / live / results screens. * Vanilla JS, no build step, no external assets. Layout and visual language * follow docs/je_validation_task_spec_3.html's "Sample run" and "Environment * console" mockups (frontend-design skill pass, 2026-08-06); every payload * field consumed below is verified against console/api.py and console/runs.py, * not assumed from the mockup's illustrative JSON. */ // The two baselines always run alongside real models -- they have no // OpenRouter price and are always launchable (console/api.py BASELINE_ENTRIES). const BASELINE_IDS = new Set(['baseline:flag_everything', 'baseline:no_evidence']); // Curated shortlist for the model picker (user decision, 2026-08-06 fixlist): // these five real OpenRouter models spanning price tiers (opus-class, // gpt-class, deepseek pro/flash, qwen) are what the dropdown shows before the // user types; typing searches the full cached catalog instead. Prices shown // in the UI always come from /api/models -- this list is only ids. The two // baselines live in their own separate control, not in the dropdown. const CURATED_MODELS = [ 'anthropic/claude-opus-5', 'openai/gpt-5.6-sol', 'deepseek/deepseek-v4-pro', 'deepseek/deepseek-v4-flash', 'qwen/qwen3-30b-a3b-instruct-2507', ]; // Mirrors run_config.py's MIN_SEEDS_FOR_VARIANCE (api.py uses it server-side // for the 202 response's seed_warning; the UI mirrors it for the inline note // so the warning appears the moment the slider moves, not after launch). const MIN_SEEDS_FOR_VARIANCE = 5; // Mirrors budgets.py's EST_TOKENS_PER_STEP -- calibrated 2026-08-06 from a // real run's measured token/step p90 (scripts/measure_tokens.py). Used both // for the token-ceiling prefill and the sweep cost estimate below. const EST_TOKENS_PER_STEP = 85000; // Display order for the tool-surface union. Mirrors envir/toolspecs.py's // ALL_TOOL_NAMES order; purely cosmetic (the rendered set is always the real // union of whatever /api/tasks returns, not this list) so a tool this array // doesn't know about still renders, just sorted after the known ones. const TOOL_ORDER = [ 'query_ledger', 'get_entry', 'list_documents', 'open_document', 'get_policy', 'get_master_data', 'recompute', 'aggregate', 'compare_period', 'request_info', 'disposition', 'submit', ]; const Console = { state: { task: null, knobs: {}, tools: [], // default sweep: the cheapest proven LLM preselected so the estimate is // populated on first paint (launch stays explicit). No baseline by // default (user, 2026-08-06) -- they're opt-in via the checkboxes. models: ['deepseek/deepseek-v4-flash'], seedCount: 5, stepBudget: 0, tokenCeiling: 0, estimate: null, // /api/estimate result: totals + key headroom // runs with different prompt versions are separate scoring conditions; // recorded in the contract server-side (run_config.py PROMPT_VERSIONS) promptVersion: 'standard', }, tasks: [], modelCatalog: [], toolUnion: [], async init() { checkHealth(); const [tasks, models] = await Promise.all([ fetch('/api/tasks').then((r) => r.json()), fetch('/api/models').then((r) => r.json()), ]); this.tasks = tasks; this.modelCatalog = models; this.toolUnion = unionTools(tasks); renderTaskList(tasks); initModelPicker(); initPromptPreview(); // Mockup parity: a task is always selected, so every section renders // populated on first paint instead of a page of empty headers. const firstRow = document.querySelector('#task-list .crow'); if (firstRow) firstRow.click(); const seedCountEl = document.getElementById('seed-count'); seedCountEl.value = this.state.seedCount; document.getElementById('seed-count-val').textContent = this.state.seedCount; seedCountEl.addEventListener('input', onSeedCountChange); document.getElementById('prompt-version').addEventListener('change', (e) => { this.state.promptVersion = e.target.value; this.refreshPreview(); refreshPromptPreview(); }); document.getElementById('launch').addEventListener('click', () => this.launch()); for (const btn of document.querySelectorAll('.raw-toggle')) { btn.addEventListener('click', () => onRawToggle(btn)); } document.getElementById('history-refresh') .addEventListener('click', loadRunHistory); loadRunHistory(); this.refreshPreview(); }, selectTask(task) { this.state.task = task; this.state.knobs = { ...task.defaults.knobs }; this.state.tools = [...task.tools]; // all prechecked this.state.stepBudget = task.defaults.step_budget; this.state.tokenCeiling = task.defaults.token_ceiling; clearLaunchError(); renderTaskDetail(task, this.state); this.refreshPreview(); refreshPromptPreview(); }, buildRunRequest() { const s = this.state; return { run_id: `run-${crypto.randomUUID().slice(0, 8)}`, task_id: s.task.id, models: s.models, seed_count: s.seedCount, knobs: s.knobs, tools_enabled: s.tools, step_budget: s.stepBudget, token_ceiling: s.tokenCeiling, prompt_version: s.promptVersion, }; }, refreshPreview() { const pre = document.getElementById('config-preview'); const launchBtn = document.getElementById('launch'); if (!this.state.task) { pre.textContent = '// select a task to build a run config'; launchBtn.disabled = true; } else { pre.textContent = JSON.stringify(this.buildRunRequest(), null, 2); launchBtn.disabled = this.state.models.length === 0; } renderSweepEstimate(); }, async launch() { clearLaunchError(); const body = this.buildRunRequest(); let resp; try { resp = await fetch('/api/runs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); } catch (err) { showLaunchError({ field: 'request', error: 'network error. Is the backend running?' }); return; } if (!resp.ok) { let err; try { err = await resp.json(); } catch { err = {}; } showLaunchError(normalizeError(resp, err)); return; } const { contract } = await resp.json(); loadRunHistory(); openLiveView(body.run_id, contract); }, }; // ---- shared helpers --------------------------------------------------------- function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }[c])); } function teaser(brief, n) { return brief.length > n ? `${brief.slice(0, n)}…` : brief; } // mirrors runs.py's _slug: model ids ("anthropic/claude-sonnet-4.5", // "baseline:flag_everything") become the path-safe slugs used in episode // keys and in the trajectory endpoint's URL segment. function slugModel(model) { return model.replace(/\//g, '_').replace(/:/g, '_'); } // "failed: " -> "failed"; anything without a colon passes through. function statusBase(status) { const i = status.indexOf(':'); return i === -1 ? status : status.slice(0, i); } function failureReason(status) { const i = status.indexOf(':'); return i === -1 ? '' : status.slice(i + 1).trim(); } const STATUS_GLYPH = { queued: '○', running: '◐', finished: '✓', failed: '✕', aborted: '■', interrupted: '‼', }; function statusGlyph(base) { return STATUS_GLYPH[base] || '?'; } function fmtPrice(pricePerToken) { return `$${(pricePerToken * 1e6).toFixed(2)}`; } function fmtUsd(x) { return x < 1 ? x.toFixed(4) : x.toFixed(2); } // ---- task list ------------------------------------------------------------ function unionTools(tasks) { const seen = new Set(); for (const t of tasks) for (const tool of t.tools) seen.add(tool); const known = TOOL_ORDER.filter((t) => seen.has(t)); const unknown = [...seen].filter((t) => !TOOL_ORDER.includes(t)).sort(); return [...known, ...unknown]; } function renderTaskList(tasks) { const root = document.getElementById('task-list'); root.innerHTML = ''; for (const task of tasks) { const row = document.createElement('button'); row.type = 'button'; row.className = 'crow'; row.dataset.taskId = task.id; row.title = task.brief; row.innerHTML = ` ${task.tier} ${task.id} ${escapeHtml(teaser(task.brief, 70))} `; row.addEventListener('click', () => { for (const el of root.querySelectorAll('.crow')) el.classList.remove('on'); row.classList.add('on'); Console.selectTask(task); }); root.appendChild(row); } } function renderTaskDetail(task, state) { document.getElementById('knob-box').hidden = false; document.getElementById('tool-box').hidden = false; renderKnobs(task, state); renderTools(task, state); syncBudgetInputs(state); } // ---- knobs / budgets -------------------------------------------------------- function rangeNumberRow(label, id, min, max, value, disabled, onChange) { const row = document.createElement('div'); row.className = 'field-row'; row.innerHTML = ` ${label} `; const range = row.querySelector(`#${id}-range`); const num = row.querySelector(`#${id}-num`); range.addEventListener('input', () => onChange(Number(range.value), range, num)); num.addEventListener('input', () => onChange(Number(num.value), range, num)); return row; } function clampKnob(value, meta) { if (Number.isNaN(value)) return meta.min; return Math.min(meta.max, Math.max(meta.min, value)); } function renderKnobs(task, state) { const wrap = document.getElementById('knobs'); wrap.innerHTML = ''; for (const [name, meta] of Object.entries(task.knob_meta)) { const disabled = name === 'population' && !task.population_editable; const label = name.replace(/_/g, ' '); const row = rangeNumberRow(label.charAt(0).toUpperCase() + label.slice(1), `knob-${name}`, meta.min, meta.max, state.knobs[name], disabled, (v, range, num) => { const clamped = clampKnob(v, meta); range.value = clamped; num.value = clamped; state.knobs[name] = clamped; if (name === 'population') recalcBudgets(clamped); Console.refreshPreview(); }); wrap.appendChild(row); } } // Mirrors budgets.py's default_step_budget / default_token_ceiling; the // server remains authoritative — this is a UI prefill convenience only, // recomputed whenever the population knob moves. function recalcBudgets(population) { const stepBudget = Math.max(60, Math.ceil(4.5 * population)); const tokenCeiling = 10 * stepBudget * EST_TOKENS_PER_STEP; Console.state.stepBudget = stepBudget; Console.state.tokenCeiling = tokenCeiling; syncBudgetInputs(Console.state); schedulePromptPreviewRefresh(); } function syncBudgetInputs(state) { document.getElementById('step-budget-range').value = state.stepBudget; document.getElementById('step-budget-num').value = state.stepBudget; } // The token ceiling has no input of its own (user, 2026-08-06): it is a // runaway-episode fuse, not a per-run tuning knob, so it silently tracks the // step budget (same 10x-headroom formula as budgets.py) and only surfaces in // the run-config JSON. function onStepBudgetChange(v) { Console.state.stepBudget = v; Console.state.tokenCeiling = 10 * v * EST_TOKENS_PER_STEP; document.getElementById('step-budget-range').value = v; document.getElementById('step-budget-num').value = v; Console.refreshPreview(); schedulePromptPreviewRefresh(); } function onSeedCountChange(e) { Console.state.seedCount = Number(e.target.value); document.getElementById('seed-count-val').textContent = Console.state.seedCount; Console.refreshPreview(); } // ---- tool surface ----------------------------------------------------------- function renderTools(task, state) { const wrap = document.getElementById('tools'); wrap.innerHTML = ''; for (const tool of Console.toolUnion) { const inTask = task.tools.includes(tool); const on = state.tools.includes(tool); const label = document.createElement('label'); label.className = `ctool${inTask ? '' : ' off'}`; label.innerHTML = ` ${tool}`; label.querySelector('input').addEventListener('change', () => { state.tools = [...wrap.querySelectorAll('input:checked')].map((cb) => cb.value); Console.refreshPreview(); }); wrap.appendChild(label); } } // ---- model picker: dropdown + full-catalog search + separate baselines ------ // // One "Models" dropdown button (user decision, 2026-08-06, deliberate spec // deviation): closed it shows the selection summary; open it lists the five // curated models, and typing in its search box searches the FULL cached // catalog (Console.modelCatalog, fetched once from /api/models) -- results // are instant because the catalog is already in memory. The two baselines // are plain checkboxes outside the dropdown; they never appear in search. const SEARCH_RESULT_CAP = 50; function realModels(ids) { return ids.filter((id) => !BASELINE_IDS.has(id)); } function catalogById() { return new Map(Console.modelCatalog.map((m) => [m.id, m])); } function curatedModelList() { const byId = catalogById(); return CURATED_MODELS.map((id) => byId.get(id)).filter(Boolean); } // If OpenRouter is unreachable, /api/models falls back to PINNED_MODELS, // which contains none of the CURATED_MODELS ids -- curatedModelList() // quietly filters those out, so without this the dropdown would open empty // with no explanation. Surface the gap instead. function renderCuratedNote(searching) { const note = document.getElementById('curated-note'); const missing = searching ? 0 : CURATED_MODELS.filter((id) => !catalogById().has(id)).length; note.hidden = missing === 0; note.textContent = missing > 0 ? `${missing} curated model${missing === 1 ? '' : 's'} unavailable ` + '(model catalog offline); showing what is available.' : ''; } function initModelPicker() { const btn = document.getElementById('model-dd-btn'); const panel = document.getElementById('model-dd'); const search = document.getElementById('model-search'); btn.addEventListener('click', () => { const open = panel.hidden; panel.hidden = !open; btn.setAttribute('aria-expanded', String(open)); if (open) { search.value = ''; renderModelDropdown(''); search.focus(); } }); search.addEventListener('input', () => renderModelDropdown(search.value)); document.addEventListener('click', (e) => { if (panel.hidden) return; if (!panel.contains(e.target) && e.target !== btn && !btn.contains(e.target)) { closeModelDropdown(); } }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !panel.hidden) { closeModelDropdown(); btn.focus(); } }); for (const boxId of ['baseline-flag', 'baseline-noev']) { const cb = document.getElementById(boxId); cb.checked = Console.state.models.includes(cb.value); cb.addEventListener('change', () => { const others = Console.state.models.filter((id) => id !== cb.value); Console.state.models = cb.checked ? [...others, cb.value] : others; Console.refreshPreview(); }); } renderModelSelection(); } function closeModelDropdown() { document.getElementById('model-dd').hidden = true; document.getElementById('model-dd-btn').setAttribute('aria-expanded', 'false'); } function searchCatalog(q) { const hits = Console.modelCatalog.filter((m) => !BASELINE_IDS.has(m.id) && `${m.id} ${m.name}`.toLowerCase().includes(q)); return { shown: hits.slice(0, SEARCH_RESULT_CAP), total: hits.length }; } function renderModelDropdown(query) { const q = query.trim().toLowerCase(); const list = document.getElementById('model-list'); const hint = document.getElementById('model-dd-hint'); renderCuratedNote(q.length > 0); let models; if (q.length === 0) { models = curatedModelList(); hint.textContent = 'Curated shortlist. Type to search the full catalog.'; } else { const { shown, total } = searchCatalog(q); models = shown; hint.textContent = total === 0 ? 'No models match.' : total > shown.length ? `Showing ${shown.length} of ${total} matches. Keep typing to narrow.` : `${total} match${total === 1 ? '' : 'es'}.`; } list.innerHTML = ''; for (const model of models) { const launchable = model.launchable !== false; const row = document.createElement('label'); row.className = 'mrow' + (launchable ? '' : ' mrow-disabled'); row.innerHTML = ` ${escapeHtml(model.name)} ${escapeHtml(model.id)} ${fmtPrice(model.prompt_price)} / ${fmtPrice(model.completion_price)} /Mtok `; row.querySelector('input').addEventListener('change', (e) => { toggleModel(model.id, e.target.checked); }); list.appendChild(row); } } function toggleModel(id, on) { const without = Console.state.models.filter((m) => m !== id); Console.state.models = on ? [...without, id] : without; renderModelSelection(); Console.refreshPreview(); } function renderModelSelection() { const selected = realModels(Console.state.models); const label = document.getElementById('model-dd-label'); const byId = catalogById(); if (selected.length === 0) { label.textContent = 'Select models'; } else if (selected.length === 1) { const m = byId.get(selected[0]); label.textContent = m ? m.name : selected[0]; } else { label.textContent = `${selected.length} models selected`; } const tags = document.getElementById('model-tags'); tags.innerHTML = ''; for (const id of selected) { const m = byId.get(id); const tag = document.createElement('span'); tag.className = 'mtag'; tag.innerHTML = ` ${escapeHtml(m ? m.name : id)} `; tag.querySelector('.mtag-x').addEventListener('click', () => { toggleModel(id, false); const panel = document.getElementById('model-dd'); if (!panel.hidden) { renderModelDropdown(document.getElementById('model-search').value); } }); tags.appendChild(tag); } } // ---- prompt preview --------------------------------------------------------- // // Shows the literal text the agent receives (GET /api/tasks/{id}/prompt -- // same brief_for() the episode path uses, so the preview cannot drift). // Re-fetched on task/version change while open; step-budget edits re-fetch // debounced since the budget is interpolated into the standard/detailed text. let promptPreviewTimer = null; function initPromptPreview() { document.getElementById('prompt-view-btn').addEventListener('click', () => { const box = document.getElementById('prompt-preview'); const btn = document.getElementById('prompt-view-btn'); box.hidden = !box.hidden; btn.textContent = box.hidden ? 'view' : 'hide'; btn.setAttribute('aria-pressed', String(!box.hidden)); if (!box.hidden) refreshPromptPreview(); }); } function schedulePromptPreviewRefresh() { clearTimeout(promptPreviewTimer); promptPreviewTimer = setTimeout(refreshPromptPreview, 300); } async function refreshPromptPreview() { const box = document.getElementById('prompt-preview'); if (box.hidden || !Console.state.task) return; const sys = document.getElementById('prompt-preview-system'); const brief = document.getElementById('prompt-preview-brief'); const params = new URLSearchParams({ version: Console.state.promptVersion, step_budget: String(Console.state.stepBudget), }); try { const resp = await fetch( `/api/tasks/${Console.state.task.id}/prompt?${params}`); if (!resp.ok) { const err = await resp.json().catch(() => ({})); sys.textContent = ''; brief.textContent = `unavailable: ${err.error || `HTTP ${resp.status}`}`; return; } const data = await resp.json(); sys.textContent = data.system_prompt; brief.textContent = data.brief; } catch { sys.textContent = ''; brief.textContent = 'unavailable: network error'; } } // ---- sweep estimate ----------------------------------------------------------- // // Cost is "rough, input-token dominated, before caching discounts": tokens // estimated from EST_TOKENS_PER_STEP regardless of model, then bounded by the // cheapest and priciest SELECTED model's prompt_price -- a range, not a // blended single number, so the model choice's cost impact stays visible. // Baseline conditions have $0 pricing and are excluded from the price bound. // ---- key headroom (cost guard) ------------------------------------------- // Server-computed estimate + remaining key credit (/api/estimate). Warns when // the run would outspend the key; never blocks the launch. Debounced: knob // drags fire refresh() per tick and the endpoint calls OpenRouter's /key. let estimateTimer = null; function scheduleEstimateRefresh() { clearTimeout(estimateTimer); estimateTimer = setTimeout(refreshEstimate, 400); } async function refreshEstimate() { const s = Console.state; if (!s.models.length || !s.stepBudget) { Console.state.estimate = null; renderHeadroom(); return; } const params = new URLSearchParams({ models: s.models.join(','), step_budget: String(s.stepBudget), seeds: String(s.seedCount), }); try { const r = await fetch(`/api/estimate?${params}`); Console.state.estimate = r.ok ? await r.json() : null; } catch { Console.state.estimate = null; // headroom unknown -> no warning } renderHeadroom(); } function renderHeadroom() { const est = Console.state.estimate; const line = document.getElementById('headroom-line'); if (line) { line.hidden = !(est && est.headroom_usd != null); if (!line.hidden) { line.textContent = `key headroom $${fmtUsd(est.headroom_usd)} remaining`; } } const warning = document.getElementById('headroom-warning'); warning.hidden = !(est && est.exceeds); if (!warning.hidden) { warning.textContent = `Estimated cost ≈ $${fmtUsd(est.total_usd)} exceeds the key's remaining ` + `credit $${fmtUsd(est.headroom_usd)} — the run would die partway. ` + 'Launching stays possible; lower seeds/budget or raise the key limit.'; } } function costRangeUsd(state, modelCatalog) { const byId = new Map(modelCatalog.map((m) => [m.id, m])); const prices = state.models .filter((id) => !BASELINE_IDS.has(id)) .map((id) => byId.get(id)) .filter(Boolean) .map((m) => m.prompt_price); if (prices.length === 0) return null; const totalTokens = prices.length * state.seedCount * state.stepBudget * EST_TOKENS_PER_STEP; return { low: totalTokens * Math.min(...prices), high: totalTokens * Math.max(...prices) }; } function renderSweepEstimate() { const s = Console.state; const box = document.getElementById('sweep-estimate'); const episodes = s.models.length * s.seedCount; const stepCeiling = episodes * s.stepBudget; const range = costRangeUsd(s, Console.modelCatalog); let costLine; if (!s.models.length) costLine = '-'; else if (!range) costLine = '$0.00 (baselines only)'; else if (range.low === range.high) costLine = `$${fmtUsd(range.low)}`; else costLine = `$${fmtUsd(range.low)} – $${fmtUsd(range.high)}`; box.innerHTML = `
${episodes} episode${episodes === 1 ? '' : 's'} · ${s.models.length} model${s.models.length === 1 ? '' : 's'} × ${s.seedCount} seed${s.seedCount === 1 ? '' : 's'}
${stepCeiling} agent steps total
${costLine}
rough, input-token dominated, before caching discounts
`; scheduleEstimateRefresh(); const seedWarning = document.getElementById('seed-warning'); seedWarning.hidden = s.seedCount >= MIN_SEEDS_FOR_VARIANCE; seedWarning.textContent = 'Fewer than 5 seeds: variance not measurable.'; const withheldCount = s.task ? s.task.tools.length - s.tools.length : 0; const withheldNote = document.getElementById('withheld-note'); withheldNote.hidden = withheldCount <= 0; withheldNote.textContent = `${withheldCount} tool(s) withheld: harder variant, scored as a separate condition.`; } // ---- errors -------------------------------------------------------------- const FIELD_TO_BOX = { knobs: 'knob-box', tools_enabled: 'tool-box', models: 'model-box', }; function normalizeError(resp, body) { if (resp.status === 409) { return { field: 'run_id', error: body.detail || 'duplicate run_id' }; } if (typeof body.field === 'string' && typeof body.error === 'string') return body; return { field: 'request', error: body.detail || body.error || `launch failed (${resp.status})` }; } function showLaunchError(err) { clearLaunchError(); const p = document.getElementById('launch-error'); p.textContent = `${err.field}: ${err.error}`; p.hidden = false; const boxId = FIELD_TO_BOX[err.field]; if (boxId) document.getElementById(boxId).classList.add('invalid'); } function clearLaunchError() { const p = document.getElementById('launch-error'); p.hidden = true; p.textContent = ''; for (const id of Object.values(FIELD_TO_BOX)) { document.getElementById(id).classList.remove('invalid'); } } // ---- health dot ------------------------------------------------------------ async function checkHealth() { const dot = document.getElementById('health-dot'); try { const r = await fetch('/api/health'); dot.classList.toggle('ok', r.ok); dot.classList.toggle('down', !r.ok); dot.title = r.ok ? 'backend online' : 'backend unhealthy'; } catch { dot.classList.add('down'); dot.title = 'backend unreachable'; } } // ---- run history ----------------------------------------------------------- // // Persisted run.json summaries from GET /api/runs. Clicking a row re-enters // the normal live-view path (openLiveView + SSE): a finished run's snapshot // goes terminal immediately, which loads its results; a still-running run // resumes streaming. This is what makes recorded runs reachable after a page // reload, a backend restart, or from a browser that never launched them. async function loadRunHistory() { let runs; try { runs = await fetch('/api/runs').then((r) => r.json()); } catch { return; // backend unreachable: keep whatever is shown } renderRunHistory(runs); } function historyModelsLabel(models) { const real = models.filter((id) => !BASELINE_IDS.has(id)); const parts = real.map((id) => id.split('/').pop()); const baselines = models.length - real.length; if (baselines) parts.push(baselines === 1 ? '1 baseline' : `${baselines} baselines`); return parts.join(', ') || '-'; } function renderRunHistory(runs) { const list = document.getElementById('history-list'); document.getElementById('history-empty').hidden = runs.length > 0; list.hidden = runs.length === 0; list.innerHTML = ''; for (const run of runs) { const base = statusBase(run.status); const row = document.createElement('button'); row.type = 'button'; row.className = 'crow history-row'; row.innerHTML = ` ${escapeHtml(run.run_id)} ${escapeHtml(run.task_id)} ${escapeHtml(run.tier || '')} ${escapeHtml(historyModelsLabel(run.models))} ${run.seed_count} seed${run.seed_count === 1 ? '' : 's'} ${run.cost_usd != null ? `$${fmtUsd(run.cost_usd)}` : '-'} ${escapeHtml(base)} `; row.addEventListener('click', () => openPastRun(run.run_id)); list.appendChild(row); } } async function openPastRun(runId) { let snap; try { snap = await fetch(`/api/runs/${runId}`).then((r) => r.json()); } catch { return; } if (!snap || !snap.contract) return; openLiveView(runId, snap.contract); } // ---- deep links from the Tasks tab ----------------------------------------- // // tabs.js translates #console/run/ into a "je:open-run" event (it is // the only hash reader). Opening the run scrolls to the live view; the pulse // on the panel and on the run's history row says "this is the one you // clicked". Unknown run ids degrade to the plain console (openPastRun // already swallows the failed fetch). function pulseOnce(el) { if (!el) return; el.classList.remove('deep-flash'); void el.offsetWidth; // restart the animation on repeat clicks el.classList.add('deep-flash'); setTimeout(() => el.classList.remove('deep-flash'), 1600); } async function openDeepLinkRun(runId) { await loadRunHistory(); // ensure the row exists before pulsing it await openPastRun(runId); if (!Console.live || Console.live.runId !== runId) return; pulseOnce(document.querySelector('#live .panel')); const idEl = [...document.querySelectorAll('#history-list .history-id')] .find((el) => el.textContent === runId); if (idEl) pulseOnce(idEl.closest('.history-row')); } window.addEventListener('je:open-run', (e) => openDeepLinkRun(e.detail)); // ---- live view: SSE client ------------------------------------------------- // // A fresh EventSource cannot send Last-Event-ID (the browser only adds it on // its own auto-reconnects), so the resume point after a page reload travels // as ?after= -- the server honors either. lastSeq lives in sessionStorage per // run so a reload in the same tab resumes instead of replaying from zero. function connectEvents(runId) { let lastSeq = Number(sessionStorage.getItem(`seq:${runId}`) || 0); const es = new EventSource(`/api/runs/${runId}/events?after=${lastSeq}`); es.onopen = async () => { setHealth('live'); try { const snap = await fetch(`/api/runs/${runId}`).then((r) => r.json()); await applySnapshot(snap); } catch { // transient fetch failure -- the next event or error will recover } }; es.onmessage = (ev) => { const seq = Number(ev.lastEventId); if (seq <= lastSeq) return; // dedupe: never double-apply lastSeq = seq; sessionStorage.setItem(`seq:${runId}`, String(seq)); applyEvent(JSON.parse(ev.data)); }; es.onerror = () => setHealth('reconnecting'); // EventSource retries itself return es; } function setHealth(state) { const el = document.getElementById('live-conn'); if (!el) return; el.textContent = state; el.className = `conn-badge conn-${state}`; } // ---- live view: state + rendering ------------------------------------------ function openLiveView(runId, contract) { if (Console.live && Console.live.es) Console.live.es.close(); document.getElementById('results').hidden = true; Console.results = null; const models = contract.sweep.models; const seedCount = contract.sweep.seeds; const tiles = {}; for (const model of models) { for (let seed = 1; seed <= seedCount; seed++) { const key = `${slugModel(model)}/seed${seed}`; tiles[key] = { key, model, seed, status: 'queued', stepsByIdx: {}, detail: {}, cost: 0, // Incremental counters kept in lockstep with stepsByIdx (see setTileStep) // so renderScoreboard/renderTile never have to re-walk a tile's full // step map -- that walk is O(steps-per-tile), and doing it on every // single incoming step event across a whole sweep is O(total-steps^2). steps: 0, tokensIn: 0, tokensOut: 0, }; } } Console.live = { runId, contract, tiles, tileEls: {}, focusedKey: null, rowEls: {}, runStatus: 'queued', runCostFinal: null, terminalHandled: false, es: null, trajCache: {}, detailTimer: null, }; renderLiveShell(runId, tiles); const live = document.getElementById('live'); live.hidden = false; live.scrollIntoView({ behavior: 'smooth', block: 'start' }); Console.live.es = connectEvents(runId); const firstKey = Object.keys(tiles)[0]; if (firstKey) selectEpisode(firstKey); } function renderLiveShell(runId, tiles) { document.getElementById('live-run-id').textContent = runId; setRunStatusChip('queued'); setHealth('connecting'); document.getElementById('live-banner').hidden = true; document.getElementById('live-terminal-note').hidden = true; document.getElementById('player-grid').hidden = true; document.getElementById('step-feed').innerHTML = ''; const grid = document.getElementById('episode-grid'); grid.innerHTML = ''; for (const key of Object.keys(tiles)) { const tile = tiles[key]; const card = document.createElement('button'); card.type = 'button'; card.className = 'ep-tile'; card.innerHTML = ` ${statusGlyph('queued')}queued ${escapeHtml(tile.model)} seed ${tile.seed} 0 steps0 tok `; card.addEventListener('click', () => selectEpisode(key)); grid.appendChild(card); Console.live.tileEls[key] = card; } renderScoreboard(); } function setRunStatusChip(status) { const el = document.getElementById('live-status'); const base = statusBase(status); el.textContent = status; el.className = `stat status-${base}`; } function showLiveBanner(msg) { const b = document.getElementById('live-banner'); b.textContent = msg; b.hidden = false; } function showLiveTerminalNote(status) { const note = document.getElementById('live-terminal-note'); note.textContent = `Run ended without results (${status}). ` + `failed/interrupted runs never produce a results.json.`; note.hidden = false; setHealth('closed'); } function tileStepCount(tile) { return tile.steps; } function tileTokenTotal(tile) { return tile.tokensIn + tile.tokensOut; } // Sets tile.stepsByIdx[idx] and keeps tile.steps/tokensIn/tokensOut (the O(1) // counters tileStepCount/tileTokenTotal read) in sync with the write -- // diffing against whatever was previously at that idx so a re-write of an // already-seen step (trajectory backfill overwriting a step-event stub with // the full record) adjusts the totals by the delta instead of double-adding. function setTileStep(tile, idx, rec) { const prev = tile.stepsByIdx[idx]; const prevIn = prev ? (prev.tokens_in || 0) : 0; const prevOut = prev ? (prev.tokens_out || 0) : 0; tile.stepsByIdx[idx] = rec; if (!prev) tile.steps += 1; tile.tokensIn += (rec.tokens_in || 0) - prevIn; tile.tokensOut += (rec.tokens_out || 0) - prevOut; } function renderTile(key) { const tile = Console.live.tiles[key]; const card = Console.live.tileEls[key]; if (!tile || !card) return; const base = statusBase(tile.status); const chip = card.querySelector('.ep-tile-chip'); chip.className = `ep-tile-chip status-${base}`; chip.innerHTML = `${statusGlyph(base)}${escapeHtml(base)}`; card.querySelector('.ep-tile-steps').textContent = `${tileStepCount(tile)} steps`; card.querySelector('.ep-tile-tokens').textContent = `${tileTokenTotal(tile)} tok`; card.querySelector('.ep-tile-reason').textContent = base === 'failed' ? failureReason(tile.status) : ''; card.classList.toggle('ep-tile-focused', Console.live.focusedKey === key); } function renderAllTiles() { for (const key of Object.keys(Console.live.tiles)) renderTile(key); } // ---- live view: step log (sample-run-player pattern) ----------------------- // // SSE 'step' events only carry {tool, ok, tokens_in, tokens_out, step_idx} // (runs.py's _tail) -- no args/observation. Those live in the trajectory // JSONL only, so the focused episode's full records are backfilled via GET // .../trajectory: once on selection, then debounced on every further step // event for that same episode so the log doesn't refetch the whole file on // every single SSE message. function selectEpisode(key) { Console.live.focusedKey = key; renderAllTiles(); document.getElementById('player-grid').hidden = false; document.getElementById('step-feed-title').textContent = key; renderStepLog(key, { forceBottom: true }); refreshFocusedDetail(key); } async function refreshFocusedDetail(key) { const live = Console.live; const tile = live.tiles[key]; if (!tile) return; try { const [modelslug, seedTag] = key.split('/'); const seed = seedTag.replace('seed', ''); const resp = await fetch(`/api/runs/${live.runId}/episodes/${modelslug}/${seed}/trajectory`); if (!resp.ok) return; // not written yet -- fine const traj = await resp.json(); for (const rec of traj) { setTileStep(tile, rec.step_idx, rec); tile.detail[rec.step_idx] = rec; } live.trajCache[key] = traj; renderTile(key); if (live.focusedKey === key) renderStepLog(key); } catch { // trajectory unreadable/unavailable right now -- leave rows as-is } } function scheduleFocusedDetailRefresh(key) { const live = Console.live; if (!live || live.focusedKey !== key) return; clearTimeout(live.detailTimer); live.detailTimer = setTimeout(() => refreshFocusedDetail(key), 400); } function compactJson(v) { const s = JSON.stringify(v); return s.length > 90 ? `${s.slice(0, 90)}…` : s; } function stepBorderColor(rec) { if (!rec.ok) return 'var(--l4)'; if (rec.tool === 'submit') return 'var(--accent)'; return 'var(--line-2)'; } function stepLogRow(idx, rec) { const hasDetail = 'args' in rec; const row = document.createElement('div'); row.className = 'rstep'; row.style.borderLeftColor = stepBorderColor(rec); row.innerHTML = `
${idx} ${escapeHtml(rec.tool)} ${hasDetail ? escapeHtml(compactJson(rec.args)) : '…'} ${rec.tokens_in || 0}→${rec.tokens_out || 0}
${hasDetail ? `
${escapeHtml(compactJson(rec.observation))}
` : ''} `; return row; } // Autoscroll only if the feed was already at (or very near) the bottom -- // otherwise a user who scrolled up to read an earlier step gets yanked back // down on every new row. `forceBottom` is for a deliberate view change // (selecting a different episode), where jumping to the newest step is the // expected behavior rather than "fighting" anything. function isNearBottom(el, threshold = 4) { return el.scrollHeight - el.scrollTop - el.clientHeight <= threshold; } // Full rebuild of the step feed from tile.stepsByIdx. Used when focus // switches episode and after a trajectory backfill (both infrequent -- // backfill is debounced to at most once per 400ms per episode) -- NOT on // every SSE step event, which instead goes through the incremental // upsertStepRow below. function renderStepLog(key, { forceBottom = false } = {}) { const tile = Console.live.tiles[key]; if (!tile) return; const feed = document.getElementById('step-feed'); const stayAtBottom = forceBottom || isNearBottom(feed); feed.innerHTML = ''; const idxs = Object.keys(tile.stepsByIdx).map(Number).sort((a, b) => a - b); const rowEls = {}; let lastRow = null; for (const idx of idxs) { const row = stepLogRow(idx, tile.stepsByIdx[idx]); feed.appendChild(row); rowEls[idx] = row; lastRow = row; } Console.live.rowEls = rowEls; if (stayAtBottom && lastRow) lastRow.scrollIntoView({ block: 'nearest' }); } // Incremental per-event update for the focused episode's step feed: append // the new row, or replace the existing row in place if this step_idx was // already rendered (trajectory backfill re-delivering a step with fuller // detail). Avoids rebuilding #step-feed's whole innerHTML on every single // SSE 'step' message. function upsertStepRow(key, idx, rec) { const live = Console.live; if (!live || live.focusedKey !== key) return; const feed = document.getElementById('step-feed'); const stayAtBottom = isNearBottom(feed); const row = stepLogRow(idx, rec); const existing = live.rowEls[idx]; if (existing) existing.replaceWith(row); else feed.appendChild(row); live.rowEls[idx] = row; if (stayAtBottom) row.scrollIntoView({ block: 'nearest' }); } // ---- live view: run-wide scoreboard ----------------------------------------- // // "Live scoreboard" mirrors the spec player's sticky panel, scoped to the // whole run (not just the focused episode) so it stays meaningful across a // multi-model sweep. Per-step correct/false-positive/missed tags from the // spec mockup are NOT reproduced here: the answer key stays server-side, // so nothing in this panel is inferred -- only fields the API actually sends. function renderScoreboard() { const live = Console.live; if (!live) return; const tiles = Object.values(live.tiles); const totalSteps = tiles.reduce((s, t) => s + tileStepCount(t), 0); const totalBudget = tiles.length * (live.contract.step_budget || 0); const totalTokens = tiles.reduce((s, t) => s + tileTokenTotal(t), 0); const costSoFar = live.runCostFinal != null ? live.runCostFinal : tiles.reduce((s, t) => s + (t.cost || 0), 0); const counts = {}; for (const t of tiles) { const b = statusBase(t.status); counts[b] = (counts[b] || 0) + 1; } const board = document.getElementById('scoreboard-body'); if (!board) return; board.innerHTML = `
Steps used${totalSteps} / ${totalBudget}
Cumulative tokens${totalTokens}
Run cost so far$${fmtUsd(costSoFar)}
Episodes
${Object.entries(counts) .map(([b, n]) => `${statusGlyph(b)} ${n} ${escapeHtml(b)}`).join(' ')}
`; } // ---- live view: event + snapshot application ------------------------------- function applyEvent(evt) { const live = Console.live; if (!live) return; if (evt.type === 'step') { const tile = live.tiles[evt.episode]; if (!tile) return; const idx = evt.data.step_idx; const rec = { ...tile.detail[idx], ...evt.data }; setTileStep(tile, idx, rec); renderTile(evt.episode); if (live.focusedKey === evt.episode) { upsertStepRow(evt.episode, idx, rec); scheduleFocusedDetailRefresh(evt.episode); } renderScoreboard(); } else if (evt.type === 'episode') { const tile = live.tiles[evt.episode]; if (!tile) return; tile.status = evt.data.status; if (evt.data.cost_usd != null) tile.cost = evt.data.cost_usd; renderTile(evt.episode); renderScoreboard(); } else if (evt.type === 'run') { if (evt.data.status) { live.runStatus = evt.data.status; setRunStatusChip(evt.data.status); maybeHandleTerminal(evt.data.status); } else if (evt.data.abort_reason) { showLiveBanner(`Run aborted: ${evt.data.abort_reason}`); } else if (evt.data.warning) { const who = evt.episode ? `${evt.episode}: ` : ''; showLiveBanner(`${who}${evt.data.warning}` + (evt.data.error ? `: ${evt.data.error}` : '')); } } } async function applySnapshot(status) { const live = Console.live; if (!live) return; live.runStatus = status.status; setRunStatusChip(status.status); if (status.cost_usd != null) live.runCostFinal = status.cost_usd; if (status.abort_reason) showLiveBanner(`Run aborted: ${status.abort_reason}`); const episodes = status.episodes || {}; for (const [key, epStatus] of Object.entries(episodes)) { if (live.tiles[key]) live.tiles[key].status = epStatus; } renderAllTiles(); // Ground-truth repair: for any episode that has started, the trajectory // file on disk is authoritative for step count / token total regardless // of whether the event stream survived (buffer rollover, backend // restart). Keyed merge by step_idx keeps this idempotent against any // live 'step' events arriving around the same time. await Promise.all(Object.keys(episodes) .filter((key) => episodes[key] !== 'queued' && live.tiles[key]) .map((key) => refreshFocusedDetail(key))); renderScoreboard(); maybeHandleTerminal(status.status); } function maybeHandleTerminal(status) { const live = Console.live; if (!live || live.terminalHandled) return; const base = statusBase(status); if (base === 'queued' || base === 'running') return; live.terminalHandled = true; if (live.es) { live.es.close(); live.es = null; } // the stream is deliberately closed now; without this the badge can stick // at "reconnecting" when a past run's empty SSE stream ends before the // snapshot goes terminal (EventSource fires onerror on stream end) setHealth('closed'); finalizeLiveCost(live.runId); loadRunHistory(); // flip this run's history row to its final state if (base === 'finished' || base === 'aborted') { loadResults(live.runId); } else { // failed:/interrupted runs never get a results.json -- show the // terminal state instead of polling results forever showLiveTerminalNote(status); } } // Episode 'finished' events carry cost_usd, but an episode that instead // fails mid-run (token ceiling, credit limit) never emits one -- its real // spend only ever lands in run.json's total (self._spent covers every // on_usage call regardless of how the episode ended). So the running // tile-summed total in renderScoreboard can undercount until the run goes // terminal, at which point this reconciles against the authoritative total. async function finalizeLiveCost(runId) { try { const snap = await fetch(`/api/runs/${runId}`).then((r) => r.json()); const live = Console.live; if (!live || live.runId !== runId) return; if (snap.cost_usd != null) live.runCostFinal = snap.cost_usd; renderScoreboard(); } catch { // best effort -- the tile-summed running total stays as the estimate } } // ---- results view ----------------------------------------------------------- // // The model->seed->step drill-down rows are interactive (click to expand) but // aren't real links or buttons, so they need explicit keyboard support: // tabindex="0" + role="button" in their markup, and this Enter/Space handler // wired alongside every click listener. :focus-visible in styles.css already // renders a visible outline on any focusable element, tr included. function onActivateKey(handler) { return (e) => { if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') { e.preventDefault(); handler(); } }; } async function loadResults(runId) { let resp; try { resp = await fetch(`/api/runs/${runId}/results`); } catch { showLiveTerminalNote('results unreachable: network error'); return; } if (!resp.ok) { // per the invariant this shouldn't happen for finished/aborted runs // (results.json lands before the status flips terminal) -- surface it // rather than retry forever showLiveTerminalNote(`results unavailable (HTTP ${resp.status})`); return; } const data = await resp.json(); const statusSnap = await fetch(`/api/runs/${runId}`).then((r) => r.json()) .catch(() => null); Console.results = { runId, data, statusSnap, model: null, seed: null, trajCache: {} }; renderResultsModelTable(); document.getElementById('results').hidden = false; document.getElementById('results-level-2').hidden = true; document.getElementById('results-level-3').hidden = true; document.getElementById('results').scrollIntoView({ behavior: 'smooth', block: 'start' }); } function fmtStat(stat) { if (!stat || stat.mean == null) return '-'; return `${stat.mean.toFixed(3)} ± ${(stat.std ?? 0).toFixed(3)}`; } function fmtMean(stat) { if (!stat || stat.mean == null) return '-'; return stat.mean.toFixed(3); } function fmtPassed(passed) { if (passed === true) return 'pass'; if (passed === false) return 'fail'; return '-'; } function renderResultsModelTable() { const { data } = Console.results; const wrap = document.getElementById('results-model-table-wrap'); const models = Object.keys(data); let rows = ''; for (const model of models) { const agg = data[model].aggregate; rows += ` ${escapeHtml(model)} ${fmtStat(agg.final)} ${fmtMean(agg.outcome)} ${fmtMean(agg.process)} ${(agg.fail_rate * 100).toFixed(0)}% ${agg.n} ${agg.variance_measurable ? 'yes' : 'no'} ${escapeHtml(agg.rationale)} `; } wrap.innerHTML = ` ${rows}
ModelFinal (mean ± std)OutcomeProcess Fail ratenVariance measurableRationale
`; for (const row of wrap.querySelectorAll('.results-model-row')) { row.addEventListener('click', () => selectModel(row.dataset.model)); row.addEventListener('keydown', onActivateKey(() => selectModel(row.dataset.model))); } document.getElementById('results-raw-1').textContent = JSON.stringify(data, null, 2); } function selectModel(model) { Console.results.model = model; Console.results.seed = null; renderSeedRows(model); document.getElementById('results-level-2').hidden = false; document.getElementById('results-level-3').hidden = true; } // The reports array in results.json carries no seed number of its own -- // episodes that never finished (failed/aborted) simply have no entry, so a // naive positional zip against 1..seed_count would misalign after any // failure. Instead: walk seeds in order and only consume the next report // when the run snapshot says that seed's episode actually finished. Report // order is guaranteed to match ascending finished-seed order because // runs.py submits futures model-major/seed-minor and awaits fut.result() // in that same submission order. function zipSeedsToReports(model, seedCount, episodes, reports) { const slug = slugModel(model); const queue = [...reports]; const rows = []; for (let seed = 1; seed <= seedCount; seed++) { const key = `${slug}/seed${seed}`; const status = episodes[key] || 'unknown'; const base = statusBase(status); const report = base === 'finished' ? queue.shift() : null; rows.push({ seed, key, status, base, report }); } return rows; } function renderSeedRows(model) { const { data, statusSnap, runId } = Console.results; const modelData = data[model]; const seedCount = (statusSnap && statusSnap.request && statusSnap.request.seed_count) || (Console.live && Console.live.contract.sweep.seeds) || 0; const episodes = (statusSnap && statusSnap.episodes) || {}; const rows = zipSeedsToReports(model, seedCount, episodes, modelData.reports); Console.results.seedRows = rows; document.getElementById('results-level-2-title').textContent = model; const wrap = document.getElementById('results-seed-table-wrap'); let body = ''; for (const r of rows) { if (r.report) { body += ` ${r.seed} ${r.report.final.toFixed(3)} ${r.report.outcome.toFixed(3)} ${r.report.process.toFixed(3)} ${r.report.fp_rate.toFixed(3)} ${r.report.auto_fail ? 'auto-fail' : ''} ${fmtPassed(r.report.passed)} … `; } else { body += ` ${r.seed} ${escapeHtml(r.status)} … `; } } wrap.innerHTML = ` ${body}
SeedFinalOutcomeProcessFP rate Auto-failPassedForced submit
`; for (const row of wrap.querySelectorAll('.results-seed-row:not(.results-seed-row-noreport)')) { row.addEventListener('click', () => selectSeed(model, Number(row.dataset.seed))); row.addEventListener('keydown', onActivateKey(() => selectSeed(model, Number(row.dataset.seed)))); } document.getElementById('results-raw-2').textContent = JSON.stringify(modelData, null, 2); document.getElementById('results-breakdown').innerHTML = ''; document.getElementById('results-breakdown').hidden = true; for (const r of rows) { if (r.base === 'queued') continue; fillForcedSubmitMarker(runId, model, r.seed); } } function isForcedSubmit(traj) { if (!traj.length) return false; const last = traj[traj.length - 1]; return last.tool === 'submit' && typeof last.reasoning_trace === 'string' && last.reasoning_trace.startsWith('FORCED'); } async function fetchTrajectory(runId, model, seed) { const slug = slugModel(model); const key = `${slug}/seed${seed}`; const cached = (Console.live && Console.live.trajCache[key]) || Console.results.trajCache[key]; if (cached) return cached; const resp = await fetch(`/api/runs/${runId}/episodes/${slug}/${seed}/trajectory`); if (!resp.ok) throw new Error(`trajectory unavailable (HTTP ${resp.status})`); const traj = await resp.json(); Console.results.trajCache[key] = traj; return traj; } async function fillForcedSubmitMarker(runId, model, seed) { const cell = document.querySelector(`[data-seed-forced="${seed}"]`); try { const traj = await fetchTrajectory(runId, model, seed); if (cell) cell.textContent = isForcedSubmit(traj) ? 'forced' : 'no'; } catch { if (cell) cell.textContent = '-'; } } // End-of-run score breakdown, adapted from the spec player's final panel: // component rows weighted per the task's real weights (from the run's own // contract, not guessed), fp_rate, auto_fail, and the fabrications list. // The rationale value is always the literal redaction string the backend // writes into results.json ("pending judge calibration"). function renderScoreBreakdown(report) { const wrap = document.getElementById('results-breakdown'); if (!report) { wrap.hidden = true; wrap.innerHTML = ''; return; } const weights = (Console.results.statusSnap && Console.results.statusSnap.contract.scoring.weights) || { outcome: 0, process: 0, rationale: 0 }; const pct = (x) => (x * 100).toFixed(0); const fabrications = report.fabrications || []; wrap.hidden = false; wrap.innerHTML = `
ComponentWeightScore
Outcome${pct(weights.outcome)}%${report.outcome.toFixed(3)}
Process${pct(weights.process)}%${report.process.toFixed(3)}
Rationale${pct(weights.rationale)}%${escapeHtml(report.rationale)}
Final${report.final.toFixed(3)}
fp_rate ${report.fp_rate.toFixed(3)} ${report.auto_fail ? 'auto-fail' : 'no auto-fail'}
Fabrications
${fabrications.length ? fabrications.map((f) => `${escapeHtml(f)}`).join('') : 'none'}
`; } async function selectSeed(model, seed) { Console.results.seed = seed; const runId = Console.results.runId; const row = (Console.results.seedRows || []).find((r) => r.seed === seed); renderScoreBreakdown(row && row.report); let traj; try { traj = await fetchTrajectory(runId, model, seed); } catch (e) { const wrap = document.getElementById('results-step-table-wrap'); wrap.innerHTML = `

${escapeHtml(e.message)}

`; document.getElementById('results-level-3-title').textContent = `${model} / seed ${seed}`; document.getElementById('results-level-3').hidden = false; return; } renderStepTimeline(model, seed, traj); document.getElementById('results-level-3').hidden = false; } function renderStepTimeline(model, seed, traj) { document.getElementById('results-level-3-title').textContent = `${model} / seed ${seed}`; const wrap = document.getElementById('results-step-table-wrap'); wrap.innerHTML = ''; const table = document.createElement('table'); table.className = 'results-table'; table.innerHTML = 'StepToolArgsStatus'; const tbody = document.createElement('tbody'); for (const rec of traj) { const tr = document.createElement('tr'); tr.className = 'step-timeline-row' + (rec.ok ? '' : ' step-timeline-row-error'); tr.tabIndex = 0; tr.setAttribute('role', 'button'); const argsStr = JSON.stringify(rec.args); const summary = argsStr.length > 80 ? `${argsStr.slice(0, 80)}…` : argsStr; const tdStep = document.createElement('td'); tdStep.className = 'mono'; tdStep.textContent = `#${rec.step_idx}`; const tdTool = document.createElement('td'); tdTool.textContent = rec.tool; const tdArgs = document.createElement('td'); tdArgs.className = 'mono'; tdArgs.textContent = summary; const tdOk = document.createElement('td'); const badge = document.createElement('span'); badge.className = `badge ${rec.ok ? 'badge-ok' : 'badge-error'}`; badge.textContent = rec.ok ? 'ok' : 'error'; tdOk.appendChild(badge); tr.append(tdStep, tdTool, tdArgs, tdOk); tr.addEventListener('click', () => toggleStepDetail(tr, rec)); tr.addEventListener('keydown', onActivateKey(() => toggleStepDetail(tr, rec))); tbody.appendChild(tr); } table.appendChild(tbody); wrap.appendChild(table); document.getElementById('results-raw-3').textContent = JSON.stringify(traj, null, 2); } function toggleStepDetail(tr, rec) { const next = tr.nextElementSibling; if (next && next.classList.contains('step-detail-row')) { next.remove(); return; } const table = tr.closest('table'); const openDetail = table.querySelector('.step-detail-row'); if (openDetail) openDetail.remove(); const detailRow = document.createElement('tr'); detailRow.className = 'step-detail-row'; const td = document.createElement('td'); td.colSpan = 4; const pre = document.createElement('pre'); pre.textContent = JSON.stringify({ args: rec.args, observation: rec.observation }, null, 2); td.appendChild(pre); detailRow.appendChild(td); tr.after(detailRow); } // ---- raw JSON toggles (all three result levels) ---------------------------- const RAW_TOGGLE_WRAP_ID = { 1: 'results-model-table-wrap', 2: 'results-seed-table-wrap', 3: 'results-step-table-wrap', }; function onRawToggle(btn) { const level = btn.dataset.level; const pre = document.getElementById(`results-raw-${level}`); const wrap = document.getElementById(RAW_TOGGLE_WRAP_ID[level]); const showingRaw = !pre.hidden; pre.hidden = showingRaw; wrap.hidden = !showingRaw; btn.textContent = showingRaw ? 'Raw JSON' : 'Rendered'; } // step-budget range/number pair is wired here (not via renderKnobs) since it // isn't a task knob -- it's a top-level RunRequest field with no server-side // min/max, just budgets.py's prefill. document.getElementById('step-budget-range').addEventListener('input', (e) => onStepBudgetChange(Number(e.target.value))); document.getElementById('step-budget-num').addEventListener('input', (e) => onStepBudgetChange(Number(e.target.value))); Console.init();