2658deee94
Adds a long-lived sidecar (pr_state_warmer.py) that polls Forgejo's /pulls endpoint every 30s and writes the full PR snapshot to a shared SQLite store, eliminating the dispatcher's per-cycle cold-cache stalls (24-30s rebuilds on flaky cycles) and the silent 50-PR pagination cap on the legacy single-page fetch. Substrate - tools/_pr_state_cache.py — SQLite store with (owner, repo) PK, WAL mode, additive v2→v3 migration (comments_refreshed_updated_at), bounded fcntl.flock migration lock, threading.Lock for per-process init, @_with_reheal decorator (catches OperationalError no-such- table + DatabaseError corruption with file quarantine), atomic TEMP-table chunking for >32k seen-set, _normalize_updated_at to canonicalize Forgejo tz-marker drift - tools/pr_state_warmer.py — poll/upsert/vanish/comments-refresh loop with fcntl.flock singleton (rejects second warmer), bounded comments-refresh cap, persistent deferral via SQL pending query, PermissionError-tolerant lock setup, cold-start log suppression - tools/_pr_classification_cache.py — three-layer fall-through (warmer cache → list cache → live fetch) with staleness gate (PR_STATE_WARMER_STALE_AFTER_S floored at 30s in prod) Comments cache hardening - Bot-filter at write time drops bot status/claim/release/sentinel while preserving **Implementation Attempt** markers (94.6% reduction on bot-heavy PRs like #30's 19k-comment thread) - _normalize_since_cursor strips microsecond precision before building ?since= query (fixes the live-observed Forgejo HTTP 422 bug on PRs #25 + #28); handles uppercase Z, lowercase z, ±HH:MM offsets (including non-zero like +05:30), naive ISO - Lazy migration of legacy null-key by_author entries on _read_cache - _newest_cursor walks tail-back skipping malformed entries Supporting infrastructure (cumulative dmpipeline-v2 work) - Telemetry server: SSE live tail, run-sessions enumeration, cost/token tracking, app.js UI rewrite with collapsible sections - MCP servers (mcp_ci_server, mcp_forgejo_server, mcp_git_server, mcp_handoff_server, mcp_graphify_server) for opencode worker context access - Live log writer (tools/live_log_writer.py) — SSE-streaming dispatcher event log - Tier-dispatcher escalation flow with prompts trimmed for budget - Shared bot-logins resolver (tools/_bot_logins.py) replacing two drift-prone copies - token_usage_audit.py for opencode cost analysis Tests - 2259 passing across 65 changed/new files - New suites: test_pr_state_cache, test_pr_state_warmer, test_pr_state_warmer_integration, test_pr_classification_cache, test_pr_list_cache_backoff, test_mcp_* (5 servers), test_live_log_writer_sse, test_telemetry_run_sessions, test_review_post_ready_label - Test_pr_comments_cache expanded with bot-filter coverage, cursor-normalization regression pins, format-drift, atomicity, failed-comments-not-stamped (silent-data-loss class) - Parametrized @_with_reheal coverage across 7 wrapped APIs - Real fault-inject atomicity test for chunked mark_vanished path via Connection wrapper class - Subprocess-based singleton flock test (cross-process contract) - Event-driven SIGTERM-mid-poll test (no fixed-sleep flake) Architecture notes - Schema v3 migration is additive (ALTER ADD COLUMN); v0/v1 still need destructive rebuild because pre-v2 column shape lacks owner/repo. Cross-process drop-table-ping-pong prevented by the fcntl migration lock + per-process _initialized flag. - Comments-refresh deferral is persistent via comments_refreshed_updated_at column — survives warmer restart, picks up next cycle even if PR didn't change again. Replaces in-memory changed_numbers list. - Rollback path: PR_STATE_WARMER_PREFER=0 bypasses the warmer cache and reverts to live-fetch behavior. PR_STATE_CACHE_DISABLE=1 short-circuits the warmer process at startup. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1886 lines
73 KiB
JavaScript
1886 lines
73 KiB
JavaScript
// Pipeline Telemetry Console — front-end controller.
|
||
//
|
||
// Per-tab polling: only the active tab's endpoints are fetched. Switching
|
||
// tabs cancels the previous tab's interval (stops needless server load on
|
||
// long-lived browser sessions). The connection-status dot in the topbar
|
||
// turns red when any fetch fails so operators see API outages instantly
|
||
// without leaving the active tab.
|
||
|
||
'use strict';
|
||
|
||
// ── tab plumbing ──────────────────────────────────────────────────────
|
||
|
||
const PANES = {};
|
||
let activeTab = 'overview';
|
||
let activeTimer = null;
|
||
|
||
function showTab(name) {
|
||
if (activeTab === name && activeTimer) return; // already active
|
||
if (activeTimer) { clearInterval(activeTimer); activeTimer = null; }
|
||
activeTab = name;
|
||
for (const tab of document.querySelectorAll('.tab')) {
|
||
const isActive = tab.dataset.tab === name;
|
||
tab.classList.toggle('active', isActive);
|
||
tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
|
||
}
|
||
for (const pane of document.querySelectorAll('.pane')) {
|
||
pane.classList.toggle('active', pane.dataset.pane === name);
|
||
}
|
||
const cfg = PANES[name];
|
||
if (!cfg) return;
|
||
cfg.refresh();
|
||
if (cfg.intervalMs) {
|
||
activeTimer = setInterval(cfg.refresh, cfg.intervalMs);
|
||
}
|
||
}
|
||
|
||
document.querySelectorAll('.tab').forEach(t => {
|
||
t.addEventListener('click', () => showTab(t.dataset.tab));
|
||
});
|
||
|
||
// ── fetch wrapper with connection-status feedback ─────────────────────
|
||
|
||
const connDot = document.getElementById('conn-dot');
|
||
|
||
async function api(path) {
|
||
try {
|
||
const r = await fetch(path, { cache: 'no-store' });
|
||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||
const data = await r.json();
|
||
connDot.classList.remove('err'); connDot.classList.add('ok');
|
||
return data;
|
||
} catch (e) {
|
||
connDot.classList.remove('ok'); connDot.classList.add('err');
|
||
console.warn('api error', path, e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ── small helpers ─────────────────────────────────────────────────────
|
||
|
||
function el(tag, attrs = {}, ...children) {
|
||
const e = document.createElement(tag);
|
||
for (const [k, v] of Object.entries(attrs || {})) {
|
||
if (v === false || v === null || v === undefined) continue;
|
||
if (k === 'class') e.className = v;
|
||
else if (k === 'html') e.innerHTML = v;
|
||
else e.setAttribute(k, v);
|
||
}
|
||
for (const c of children) {
|
||
if (c === false || c === null || c === undefined) continue;
|
||
e.appendChild(c instanceof Node ? c : document.createTextNode(String(c)));
|
||
}
|
||
return e;
|
||
}
|
||
|
||
function pill(text, tone) {
|
||
return el('span', { class: `pill ${tone || ''}` }, text);
|
||
}
|
||
|
||
function fmtAge(s) {
|
||
if (s == null) return '—';
|
||
if (s < 60) return `${s.toFixed(0)}s`;
|
||
if (s < 3600) return `${(s / 60).toFixed(1)}m`;
|
||
if (s < 86400) return `${(s / 3600).toFixed(1)}h`;
|
||
return `${(s / 86400).toFixed(1)}d`;
|
||
}
|
||
|
||
function fmtDate(iso) {
|
||
if (!iso) return '—';
|
||
try { return new Date(iso).toLocaleString(); }
|
||
catch { return iso; }
|
||
}
|
||
|
||
function setText(id, txt) {
|
||
const e = document.getElementById(id);
|
||
if (e) e.textContent = txt;
|
||
}
|
||
|
||
const OUTCOME_TONE = {
|
||
// success
|
||
'merged': 'ok', 'resolved': 'ok',
|
||
'resolved-no-conflict': 'ok', 'no-op': 'ok',
|
||
// transient
|
||
'lease-violation': 'warn', 'transport-error': 'warn',
|
||
'timeout': 'warn', 'ci-timeout': 'warn',
|
||
// definite
|
||
'unresolvable': 'err', 'verification-fail': 'err',
|
||
'rebase-failed': 'err', 'ci-fail-on-rebased-sha': 'err',
|
||
'restart-budget-exhausted': 'err', 'bisect-budget-exhausted': 'err',
|
||
};
|
||
|
||
function tonedPill(outcome) {
|
||
return pill(outcome || '?', OUTCOME_TONE[outcome] || 'info');
|
||
}
|
||
|
||
// ── meta refresh (drives the topbar) ──────────────────────────────────
|
||
|
||
let metaCache = null;
|
||
|
||
async function refreshMeta() {
|
||
const m = await api('/api/meta');
|
||
if (!m) return;
|
||
metaCache = m;
|
||
const target = `${m.repo_owner}/${m.repo_name}`;
|
||
setText('repo-target', target);
|
||
if (m.warn_canonical_target) {
|
||
document.getElementById('repo-target').classList.add('pill', 'warn');
|
||
}
|
||
setText('cache-info', m.cache_present ? `cache OK` : `cache MISSING`);
|
||
document.getElementById('cache-info').classList.toggle(
|
||
'pill', !m.cache_present
|
||
);
|
||
document.getElementById('cache-info').classList.toggle(
|
||
'err', !m.cache_present
|
||
);
|
||
setText('server-time', new Date(m.server_time_utc).toLocaleString());
|
||
setText('oc-url', m.opencode_url);
|
||
setText('oc-url-2', m.opencode_url);
|
||
setText('cache-path-drivers', m.cache_path);
|
||
// about-tab
|
||
const dl = document.getElementById('about-dl');
|
||
dl.innerHTML = '';
|
||
for (const [k, v] of Object.entries(m)) {
|
||
dl.appendChild(el('dt', {}, k));
|
||
dl.appendChild(el('dd', {}, String(v)));
|
||
}
|
||
}
|
||
|
||
// Refresh meta on load + every 30s independently of tab.
|
||
refreshMeta();
|
||
setInterval(refreshMeta, 30000);
|
||
|
||
// ── Overview tab ──────────────────────────────────────────────────────
|
||
|
||
PANES['overview'] = {
|
||
intervalMs: 5000,
|
||
async refresh() {
|
||
const [health, mergeRows, conflictRows, reviewRows, implRows] = await Promise.all([
|
||
api('/api/health'),
|
||
api('/api/cycles?driver=merge&limit=5'),
|
||
api('/api/cycles?driver=conflict&limit=5'),
|
||
api('/api/cycles?driver=dispatch_review&limit=5'),
|
||
api('/api/cycles?driver=dispatch_implementer&limit=5'),
|
||
]);
|
||
renderDaemonGrid(health);
|
||
renderMergeMini('overview-merge', mergeRows);
|
||
renderConflictMini('overview-conflict', conflictRows);
|
||
renderDispatcherMini('overview-dispatch_review', reviewRows);
|
||
renderDispatcherMini('overview-dispatch_implementer', implRows);
|
||
},
|
||
};
|
||
|
||
function renderDaemonGrid(health) {
|
||
const grid = document.getElementById('daemon-grid');
|
||
grid.innerHTML = '';
|
||
if (!health) {
|
||
grid.appendChild(el('div', { class: 'card placeholder' },
|
||
'health endpoint unreachable'));
|
||
return;
|
||
}
|
||
for (const d of health.daemons) {
|
||
// Tone precedence: down > running_long_worker (heartbeat stale
|
||
// while pid is alive — points at a callback regression or
|
||
// filesystem failure) > stale (heartbeat old, fallback) > up.
|
||
// ``in_flight_cycle`` does not affect tone — an in-flight cycle
|
||
// is the healthy state, not an alarm.
|
||
const tone =
|
||
!d.running ? 'down'
|
||
: d.running_long_worker ? 'long-worker'
|
||
: d.heartbeat && d.heartbeat.mtime_s_ago > 180 ? 'stale'
|
||
: 'up';
|
||
const pid = d.processes && d.processes[0] ? d.processes[0].pid : null;
|
||
const elapsed =
|
||
d.processes && d.processes[0] && d.processes[0].elapsed_s != null
|
||
? fmtAge(d.processes[0].elapsed_s)
|
||
: null;
|
||
const hbAge =
|
||
d.heartbeat ? fmtAge(d.heartbeat.mtime_s_ago) : null;
|
||
const dotClass =
|
||
tone === 'up' ? 'ok'
|
||
: tone === 'stale' ? 'warn'
|
||
: tone === 'long-worker' ? 'warn'
|
||
: 'err';
|
||
const dot = el('span', { class: `dot ${dotClass}` });
|
||
const inFlight = d.in_flight_cycle;
|
||
const inFlightRow = inFlight
|
||
? el('div', { class: 'in-flight', title: 'cycle in flight' },
|
||
'cycle ',
|
||
el('span', { class: 'mono' }, (inFlight.cycle_id || '').slice(0, 8)),
|
||
inFlight.elapsed_s != null ? ` · ${fmtAge(inFlight.elapsed_s)} elapsed` : '',
|
||
inFlight.session_id ? ` · session ${inFlight.session_id.slice(0, 16)}` : '')
|
||
: null;
|
||
const hbRow = el('div', { class: 'hb' },
|
||
hbAge ? `heartbeat ${hbAge} ago` :
|
||
(d.heartbeat_candidates && d.heartbeat_candidates.length
|
||
? 'no heartbeat file'
|
||
: '— no heartbeat probe'),
|
||
d.running_long_worker ? ' · ⚠ heartbeat stale, pid alive' : '');
|
||
const tile = el('div', { class: `daemon ${tone}` },
|
||
el('div', { class: 'name' }, dot, d.name),
|
||
el('div', { class: 'pid' },
|
||
d.running ? `pid ${pid || '?'}` : 'not running',
|
||
elapsed ? ` · uptime ${elapsed}` : ''),
|
||
hbRow);
|
||
if (inFlightRow) tile.appendChild(inFlightRow);
|
||
grid.appendChild(tile);
|
||
}
|
||
// OpenCode tile
|
||
const oc = health.opencode || {};
|
||
const ocTone = oc.reachable ? 'up' : 'down';
|
||
grid.appendChild(
|
||
el('div', { class: `daemon ${ocTone}` },
|
||
el('div', { class: 'name' },
|
||
el('span', { class: `dot ${oc.reachable ? 'ok' : 'err'}` }),
|
||
'opencode_server'),
|
||
el('div', { class: 'pid' }, oc.url),
|
||
el('div', { class: 'hb' },
|
||
oc.reachable
|
||
? `version ${(oc.details || {}).version || '?'}`
|
||
: 'unreachable'))
|
||
);
|
||
}
|
||
|
||
function renderMergeMini(targetId, data) {
|
||
const host = document.getElementById(targetId);
|
||
host.innerHTML = '';
|
||
const rows = (data && data.rows) || [];
|
||
if (!rows.length) {
|
||
host.appendChild(el('span', { class: 'muted' }, 'no recent cycles'));
|
||
return;
|
||
}
|
||
const tbl = el('table', { class: 'tt' });
|
||
tbl.appendChild(el('thead', {}, el('tr', {},
|
||
el('th', {}, 'started'), el('th', {}, 'PRs'),
|
||
el('th', {}, 'state'), el('th', {}, 'action'))));
|
||
const tbody = el('tbody');
|
||
for (const r of rows) {
|
||
tbody.appendChild(el('tr', {},
|
||
el('td', {}, fmtDate(r.started_at)),
|
||
el('td', {}, r.pr_numbers || ''),
|
||
el('td', {}, tonedPill(r.terminal_state)),
|
||
el('td', {}, r.action_taken || '—')));
|
||
}
|
||
tbl.appendChild(tbody);
|
||
host.appendChild(tbl);
|
||
}
|
||
|
||
function renderDispatcherMini(targetId, data) {
|
||
// Compact 5-row view of the most recent dispatcher cycles for the
|
||
// Overview tab. Pairs the OpenCode session lifecycle (terminal_state)
|
||
// with the worker-emitted JSON outcome so the operator sees both
|
||
// dimensions side by side, matching the Drivers-tab full view.
|
||
const host = document.getElementById(targetId);
|
||
host.innerHTML = '';
|
||
const rows = (data && data.rows) || [];
|
||
if (!rows.length) {
|
||
host.appendChild(el('span', { class: 'muted' }, 'no recent cycles'));
|
||
return;
|
||
}
|
||
const tbl = el('table', { class: 'tt' });
|
||
tbl.appendChild(el('thead', {}, el('tr', {},
|
||
el('th', {}, 'started'),
|
||
el('th', { class: 'num' }, 'cands'),
|
||
el('th', { class: 'num' }, 'claims'),
|
||
el('th', {}, 'terminal_state'),
|
||
el('th', {}, 'worker_outcome'))));
|
||
const tbody = el('tbody');
|
||
for (const r of rows) {
|
||
const inFlight = r.ended_at == null;
|
||
tbody.appendChild(el('tr', { class: inFlight ? 'in-flight-row' : '' },
|
||
el('td', {}, fmtDate(r.started_at)),
|
||
el('td', { class: 'num' }, r.candidates_count ?? 0),
|
||
el('td', { class: 'num' }, r.claims_acquired ?? 0),
|
||
el('td', {},
|
||
inFlight
|
||
? el('span', { class: 'pill warn', title: 'cycle is still running' }, 'in flight')
|
||
: tonedPill(r.terminal_state || '—')),
|
||
el('td', {}, r.worker_outcome ? tonedPill(r.worker_outcome) : el('span', { class: 'muted' }, '—'))));
|
||
}
|
||
tbl.appendChild(tbody);
|
||
host.appendChild(tbl);
|
||
}
|
||
|
||
function renderConflictMini(targetId, data) {
|
||
const host = document.getElementById(targetId);
|
||
host.innerHTML = '';
|
||
const rows = (data && data.rows) || [];
|
||
if (!rows.length) {
|
||
host.appendChild(el('span', { class: 'muted' }, 'no recent cycles'));
|
||
return;
|
||
}
|
||
const tbl = el('table', { class: 'tt' });
|
||
tbl.appendChild(el('thead', {}, el('tr', {},
|
||
el('th', {}, 'started'), el('th', {}, 'PR'),
|
||
el('th', {}, 'outcome'), el('th', {}, 'kind'))));
|
||
const tbody = el('tbody');
|
||
for (const r of rows) {
|
||
tbody.appendChild(el('tr', {},
|
||
el('td', {}, fmtDate(r.started_at)),
|
||
el('td', { class: 'num' }, `#${r.pr_number}`),
|
||
el('td', {}, tonedPill(r.outcome)),
|
||
el('td', {}, r.failure_kind || '—')));
|
||
}
|
||
tbl.appendChild(tbody);
|
||
host.appendChild(tbl);
|
||
}
|
||
|
||
// ── Drivers tab ───────────────────────────────────────────────────────
|
||
|
||
PANES['drivers'] = {
|
||
intervalMs: 15000,
|
||
async refresh() {
|
||
const [m, c, dr, di] = await Promise.all([
|
||
api('/api/cycles?driver=merge&limit=50'),
|
||
api('/api/cycles?driver=conflict&limit=50'),
|
||
api('/api/cycles?driver=dispatch_review&limit=50'),
|
||
api('/api/cycles?driver=dispatch_implementer&limit=50'),
|
||
]);
|
||
renderBreakdown('merge-breakdown', m && m.outcome_breakdown_24h);
|
||
renderBreakdown('conflict-breakdown', c && c.outcome_breakdown_24h);
|
||
renderBreakdown('dispatch_review-breakdown', dr && dr.outcome_breakdown_24h);
|
||
renderBreakdown('dispatch_implementer-breakdown', di && di.outcome_breakdown_24h);
|
||
renderMergeFull('merge-table', m);
|
||
renderConflictFull('conflict-table', c);
|
||
renderDispatcherFull('dispatch_review-table', dr, 'dispatch_review_cycles');
|
||
renderDispatcherFull('dispatch_implementer-table', di, 'dispatch_implementer_cycles');
|
||
},
|
||
};
|
||
|
||
function renderBreakdown(targetId, rows) {
|
||
const host = document.getElementById(targetId);
|
||
host.innerHTML = '';
|
||
if (!rows || !rows.length) {
|
||
host.appendChild(document.createTextNode('—'));
|
||
return;
|
||
}
|
||
for (const r of rows) {
|
||
host.appendChild(tonedPill(`${r.k} × ${r.n}`));
|
||
host.appendChild(document.createTextNode(' '));
|
||
}
|
||
}
|
||
|
||
function renderMergeFull(targetId, data) {
|
||
const host = document.getElementById(targetId);
|
||
host.innerHTML = '';
|
||
const rows = (data && data.rows) || [];
|
||
if (!rows.length) {
|
||
if (data && data.note) {
|
||
host.appendChild(el('span', { class: 'muted' }, data.note));
|
||
} else {
|
||
host.appendChild(el('span', { class: 'muted' },
|
||
'no merge_cycle rows yet — driver has not recorded any cycles'));
|
||
}
|
||
return;
|
||
}
|
||
const tbl = el('table', { class: 'tt' });
|
||
tbl.appendChild(el('thead', {}, el('tr', {},
|
||
el('th', {}, 'started'), el('th', {}, 'ended'),
|
||
el('th', {}, 'PRs'), el('th', {}, 'train'),
|
||
el('th', { class: 'num' }, 'bisect'),
|
||
el('th', { class: 'num' }, 'total s'),
|
||
el('th', {}, 'state'), el('th', {}, 'action'),
|
||
el('th', {}, 'detail'))));
|
||
const tbody = el('tbody');
|
||
for (const r of rows) {
|
||
tbody.appendChild(el('tr', {},
|
||
el('td', {}, fmtDate(r.started_at)),
|
||
el('td', {}, r.ended_at ? fmtDate(r.ended_at) : '—'),
|
||
el('td', {}, r.pr_numbers || ''),
|
||
el('td', {}, r.train_id || '—'),
|
||
el('td', { class: 'num' }, r.bisect_depth || 0),
|
||
el('td', { class: 'num' },
|
||
r.total_seconds != null ? r.total_seconds.toFixed(1) : '—'),
|
||
el('td', {}, tonedPill(r.terminal_state)),
|
||
el('td', {}, r.action_taken || '—'),
|
||
el('td', { class: 'muted small' }, r.action_detail || '')));
|
||
}
|
||
tbl.appendChild(tbody);
|
||
host.appendChild(tbl);
|
||
}
|
||
|
||
function renderConflictFull(targetId, data) {
|
||
const host = document.getElementById(targetId);
|
||
host.innerHTML = '';
|
||
const rows = (data && data.rows) || [];
|
||
if (!rows.length) {
|
||
host.appendChild(el('span', { class: 'muted' },
|
||
'no conflict_drive_cycles rows yet'));
|
||
return;
|
||
}
|
||
const tbl = el('table', { class: 'tt' });
|
||
tbl.appendChild(el('thead', {}, el('tr', {},
|
||
el('th', {}, 'started'),
|
||
el('th', { class: 'num' }, 'PR'),
|
||
el('th', {}, 'outcome'),
|
||
el('th', {}, 'kind'),
|
||
el('th', { class: 'num' }, 'cands'),
|
||
el('th', { class: 'num' }, 'resolved'),
|
||
el('th', { class: 'num' }, 'escalated'),
|
||
el('th', { class: 'num' }, 'timeout'),
|
||
el('th', { class: 'num' }, 'pushrej'))));
|
||
const tbody = el('tbody');
|
||
for (const r of rows) {
|
||
tbody.appendChild(el('tr', {},
|
||
el('td', {}, fmtDate(r.started_at)),
|
||
el('td', { class: 'num' }, `#${r.pr_number}`),
|
||
el('td', {}, tonedPill(r.outcome)),
|
||
el('td', {}, r.failure_kind || '—'),
|
||
el('td', { class: 'num' }, r.candidates_count),
|
||
el('td', { class: 'num' }, r.resolved_count),
|
||
el('td', { class: 'num' }, r.escalated_count),
|
||
el('td', { class: 'num' }, r.timeout_count),
|
||
el('td', { class: 'num' }, r.push_rejected_count)));
|
||
}
|
||
tbl.appendChild(tbody);
|
||
host.appendChild(tbl);
|
||
}
|
||
|
||
function renderDispatcherFull(targetId, data, tableLabel) {
|
||
// Tier 2 dispatcher row renderer (dispatch_review_cycles /
|
||
// dispatch_implementer_cycles). Surfaces the candidate set,
|
||
// claim acquisition stats, and the (terminal_state /
|
||
// worker_outcome) pair documented in
|
||
// ``tools/_dispatch_runtime.py:dispatch_one``.
|
||
const host = document.getElementById(targetId);
|
||
host.innerHTML = '';
|
||
const rows = (data && data.rows) || [];
|
||
if (!rows.length) {
|
||
if (data && data.note) {
|
||
host.appendChild(el('span', { class: 'muted' }, data.note));
|
||
} else {
|
||
host.appendChild(el('span', { class: 'muted' },
|
||
`no ${tableLabel} rows yet — dispatcher has not recorded any cycles`));
|
||
}
|
||
return;
|
||
}
|
||
const tbl = el('table', { class: 'tt' });
|
||
tbl.appendChild(el('thead', {}, el('tr', {},
|
||
el('th', {}, 'started'),
|
||
el('th', {}, 'ended'),
|
||
el('th', { class: 'num' }, 'cands'),
|
||
el('th', { class: 'num' }, 'claims'),
|
||
el('th', { class: 'num' }, 'swept'),
|
||
el('th', { class: 'num' }, 'processed'),
|
||
el('th', {}, 'terminal_state'),
|
||
el('th', {}, 'worker_outcome'),
|
||
el('th', { class: 'num' }, 'wallclock s'),
|
||
el('th', {}, 'session_id'))));
|
||
const tbody = el('tbody');
|
||
for (const r of rows) {
|
||
// ended_at IS NULL means the cycle is in flight (the F2 begin/finish
|
||
// split inserts the row at start and updates it on completion). Mark
|
||
// these rows visually so the operator can spot a long-running
|
||
// worker session immediately rather than scanning the daemon tile.
|
||
const inFlight = r.ended_at == null;
|
||
const tr = el('tr', { class: inFlight ? 'in-flight-row' : '' },
|
||
el('td', {}, fmtDate(r.started_at)),
|
||
el('td', {},
|
||
inFlight
|
||
? el('span', { class: 'pill warn', title: 'cycle is still running' }, 'in flight')
|
||
: fmtDate(r.ended_at)),
|
||
el('td', { class: 'num' }, r.candidates_count ?? 0),
|
||
el('td', { class: 'num' }, r.claims_acquired ?? 0),
|
||
el('td', { class: 'num' }, r.swept_count ?? 0),
|
||
el('td', { class: 'num' }, r.processed_count ?? 0),
|
||
el('td', {},
|
||
r.terminal_state
|
||
? tonedPill(r.terminal_state)
|
||
: (inFlight ? el('span', { class: 'muted' }, '…') : '—')),
|
||
el('td', {},
|
||
r.worker_outcome
|
||
? tonedPill(r.worker_outcome)
|
||
: el('span', { class: 'muted' }, '—')),
|
||
el('td', { class: 'num' },
|
||
r.worker_wallclock_seconds != null ? r.worker_wallclock_seconds.toFixed(1) : '—'),
|
||
el('td', { class: 'muted small' }, r.session_id || '—'));
|
||
tbody.appendChild(tr);
|
||
}
|
||
tbl.appendChild(tbody);
|
||
host.appendChild(tbl);
|
||
}
|
||
|
||
// ── PRs tab ───────────────────────────────────────────────────────────
|
||
|
||
PANES['prs'] = {
|
||
intervalMs: 30000,
|
||
async refresh() {
|
||
const sel = document.getElementById('pr-label-select');
|
||
const lbl = sel.value || '';
|
||
const data = await api(
|
||
lbl
|
||
? `/api/prs?label=${encodeURIComponent(lbl)}`
|
||
: '/api/prs'
|
||
);
|
||
renderPRs('pr-table', data, lbl);
|
||
},
|
||
};
|
||
|
||
document.getElementById('pr-label-select').addEventListener('change', () => {
|
||
PANES['prs'].refresh();
|
||
});
|
||
|
||
function renderPRs(targetId, data, lbl) {
|
||
const host = document.getElementById(targetId);
|
||
host.innerHTML = '';
|
||
const rows = (data && data.rows) || [];
|
||
// When no specific label is requested, only show PRs that have at
|
||
// least one auto/* label so the dashboard isn't dominated by noise.
|
||
const filtered = lbl ? rows : rows.filter(r => r.auto_labels && r.auto_labels.length);
|
||
setText('pr-count', `${filtered.length} PR${filtered.length === 1 ? '' : 's'}`);
|
||
if (!filtered.length) {
|
||
if (data && data.note) {
|
||
host.appendChild(el('span', { class: 'muted' }, data.note));
|
||
} else {
|
||
host.appendChild(el('span', { class: 'muted' }, lbl
|
||
? 'no open PRs with that label'
|
||
: 'no open PRs carrying any auto/* label'));
|
||
}
|
||
return;
|
||
}
|
||
const tbl = el('table', { class: 'tt' });
|
||
tbl.appendChild(el('thead', {}, el('tr', {},
|
||
el('th', { class: 'num' }, '#'),
|
||
el('th', {}, 'title'),
|
||
el('th', {}, 'author'),
|
||
el('th', {}, 'auto labels'),
|
||
el('th', {}, 'updated'))));
|
||
const tbody = el('tbody');
|
||
for (const r of filtered) {
|
||
const labels = el('td');
|
||
for (const al of (r.auto_labels || [])) {
|
||
labels.appendChild(pill(al, OUTCOME_TONE[al] || 'info'));
|
||
labels.appendChild(document.createTextNode(' '));
|
||
}
|
||
const titleCell = el('td');
|
||
if (r.html_url) {
|
||
titleCell.appendChild(el('a',
|
||
{ href: r.html_url, target: '_blank', rel: 'noreferrer' },
|
||
r.title || '(untitled)'));
|
||
} else {
|
||
titleCell.textContent = r.title || '(untitled)';
|
||
}
|
||
tbody.appendChild(el('tr', {},
|
||
el('td', { class: 'num' }, r.number),
|
||
titleCell,
|
||
el('td', {}, r.user || '—'),
|
||
labels,
|
||
el('td', { class: 'small muted' }, fmtDate(r.updated_at))));
|
||
}
|
||
tbl.appendChild(tbody);
|
||
host.appendChild(tbl);
|
||
}
|
||
|
||
// ── Velocity tab ──────────────────────────────────────────────────────
|
||
|
||
PANES['velocity'] = {
|
||
intervalMs: 5 * 60 * 1000,
|
||
async refresh() {
|
||
const windows = ['24h', '48h', '7d', '30d'];
|
||
const data = await Promise.all(
|
||
windows.map(w => api(`/api/velocity?window=${w}`))
|
||
);
|
||
renderVelocity('velocity-grid', windows, data);
|
||
},
|
||
};
|
||
|
||
function renderVelocity(targetId, windows, results) {
|
||
const grid = document.getElementById(targetId);
|
||
grid.innerHTML = '';
|
||
for (let i = 0; i < windows.length; i++) {
|
||
const w = windows[i];
|
||
const r = results[i] || {};
|
||
const s = r.stats || {};
|
||
grid.appendChild(
|
||
el('div', { class: 'card' },
|
||
el('div', { class: 'card-head' }, `last ${w}`),
|
||
el('div', { class: 'card-body' },
|
||
el('div', { class: 'stat' },
|
||
el('span', { class: 'v ok' }, String(s.merged ?? 0)),
|
||
el('span', { class: 'l' }, 'PRs merged')),
|
||
el('div', { class: 'stat', style: 'margin-top:8px' },
|
||
el('span', { class: 'v info' }, String(s.opened ?? 0)),
|
||
el('span', { class: 'l' }, 'PRs opened')),
|
||
el('div', { class: 'stat', style: 'margin-top:8px' },
|
||
el('span', { class: 'v' }, String(s.commits ?? 0)),
|
||
el('span', { class: 'l' }, 'commits → master'))))
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── LLM tab ───────────────────────────────────────────────────────────
|
||
|
||
PANES['llm'] = {
|
||
intervalMs: 10000,
|
||
async refresh() {
|
||
const data = await api('/api/sessions');
|
||
renderLLM('llm-table', data);
|
||
},
|
||
};
|
||
|
||
function renderLLM(targetId, data) {
|
||
const host = document.getElementById(targetId);
|
||
host.innerHTML = '';
|
||
const rows = (data && data.rows) || [];
|
||
if (!rows.length) {
|
||
host.appendChild(el('span', { class: 'muted' }, (data && data.note)
|
||
? data.note
|
||
: 'no active sessions'));
|
||
return;
|
||
}
|
||
const tbl = el('table', { class: 'tt' });
|
||
tbl.appendChild(el('thead', {}, el('tr', {},
|
||
el('th', {}, 'session id'),
|
||
el('th', {}, 'title'),
|
||
el('th', {}, 'directory'),
|
||
el('th', { class: 'num' }, 'msgs'),
|
||
el('th', { class: 'num' }, 'created'),
|
||
el('th', { class: 'num' }, 'updated'))));
|
||
const tbody = el('tbody');
|
||
for (const r of rows) {
|
||
tbody.appendChild(el('tr', {},
|
||
el('td', {}, el('code', {}, (r.id || '').slice(-12))),
|
||
el('td', {}, r.title || '—'),
|
||
el('td', { class: 'small muted' }, r.directory || ''),
|
||
el('td', { class: 'num' }, r.message_count),
|
||
el('td', { class: 'num' }, fmtAge(r.created_s_ago)),
|
||
el('td', { class: 'num' }, fmtAge(r.updated_s_ago))));
|
||
}
|
||
tbl.appendChild(tbody);
|
||
host.appendChild(tbl);
|
||
}
|
||
|
||
// ── Cost tab ──────────────────────────────────────────────────────────
|
||
|
||
PANES['cost'] = {
|
||
intervalMs: 60000,
|
||
async refresh() {
|
||
const days = document.getElementById('cost-days-select').value || '7';
|
||
const data = await api(`/api/cost?days=${encodeURIComponent(days)}`);
|
||
renderCost('cost-table', data);
|
||
},
|
||
};
|
||
|
||
document.getElementById('cost-days-select').addEventListener('change', () => {
|
||
PANES['cost'].refresh();
|
||
});
|
||
|
||
function renderCost(targetId, data) {
|
||
const host = document.getElementById(targetId);
|
||
host.innerHTML = '';
|
||
const note = document.getElementById('cost-instrumentation-note');
|
||
if (!data) { host.textContent = '—'; return; }
|
||
const totals = data.totals || {};
|
||
setText('cost-totals',
|
||
`total $${(totals.usd || 0).toFixed(2)} · ${(totals.tokens_in || 0).toLocaleString()} in / ${(totals.tokens_out || 0).toLocaleString()} out`);
|
||
const rows = data.rows || [];
|
||
note.classList.toggle('hidden', !data.instrumentation_pending);
|
||
if (!rows.length) {
|
||
host.appendChild(el('span', { class: 'muted' },
|
||
data.note || 'no LLM activity recorded in this window'));
|
||
return;
|
||
}
|
||
const tbl = el('table', { class: 'tt' });
|
||
tbl.appendChild(el('thead', {}, el('tr', {},
|
||
el('th', {}, 'model'),
|
||
el('th', { class: 'num' }, 'dispatches'),
|
||
el('th', { class: 'num' }, 'tokens in'),
|
||
el('th', { class: 'num' }, 'tokens out'),
|
||
el('th', { class: 'num' }, 'cached'),
|
||
el('th', { class: 'num' }, 'cost USD'))));
|
||
const tbody = el('tbody');
|
||
for (const r of rows) {
|
||
tbody.appendChild(el('tr', {},
|
||
el('td', {}, el('code', {}, r.model || '_unknown'),
|
||
' ',
|
||
r.priced ? null : pill('unpriced', 'warn')),
|
||
el('td', { class: 'num' }, r.n),
|
||
el('td', { class: 'num' }, (r.tokens_in || 0).toLocaleString()),
|
||
el('td', { class: 'num' }, (r.tokens_out || 0).toLocaleString()),
|
||
el('td', { class: 'num' }, (r.cached || 0).toLocaleString()),
|
||
el('td', { class: 'num' },
|
||
'$' + (r.usd || 0).toFixed(4))));
|
||
}
|
||
tbl.appendChild(tbody);
|
||
host.appendChild(tbl);
|
||
}
|
||
|
||
// ── Sessions tab (archived worker sessions) ──────────────────────────
|
||
//
|
||
// The dispatcher writes a JSON snapshot of every worker session to
|
||
// .dispatcher-logs/sessions/<filename>.json before the OpenCode
|
||
// session is deleted. This tab lets the operator browse those
|
||
// archives — the LLM tab only shows live sessions, but those vanish
|
||
// the moment the dispatcher cycle finishes.
|
||
|
||
PANES['sessions'] = {
|
||
intervalMs: 30000,
|
||
async refresh() {
|
||
const data = await api('/api/sessions/archived');
|
||
renderSessions(data);
|
||
},
|
||
};
|
||
|
||
// Map archive entry status -> pill tone, matching OUTCOME_TONE.
|
||
const ARCHIVE_STATUS_TONE = {
|
||
completed: 'ok',
|
||
timeout: 'warn',
|
||
'transport-error': 'err',
|
||
};
|
||
|
||
function renderSessions(data) {
|
||
const host = document.getElementById('archive-table');
|
||
host.innerHTML = '';
|
||
const dirInfoEl = document.getElementById('archive-dir-info');
|
||
const countEl = document.getElementById('archive-count');
|
||
if (!data) {
|
||
host.appendChild(el('span', { class: 'muted' }, 'failed to fetch archive index'));
|
||
return;
|
||
}
|
||
if (dirInfoEl) dirInfoEl.textContent = data.archive_dir || '(unknown)';
|
||
const rows = (data && data.rows) || [];
|
||
if (countEl) {
|
||
const total = data.total ?? rows.length;
|
||
countEl.textContent = total === rows.length
|
||
? `${total} archived`
|
||
: `showing ${rows.length} of ${total} (capped at ${data.limit})`;
|
||
}
|
||
if (data.note) {
|
||
host.appendChild(el('div', { class: 'muted small' }, data.note));
|
||
return;
|
||
}
|
||
if (!rows.length) {
|
||
host.appendChild(el('span', { class: 'muted' },
|
||
'no archived sessions yet — they appear here automatically when the dispatcher runs.'));
|
||
return;
|
||
}
|
||
const tbl = el('table', { class: 'tt' });
|
||
tbl.appendChild(el('thead', {}, el('tr', {},
|
||
el('th', {}, 'archived'),
|
||
el('th', {}, 'tag'),
|
||
el('th', {}, 'agent'),
|
||
el('th', {}, 'status'),
|
||
el('th', { class: 'num' }, 'turns'),
|
||
el('th', { class: 'num' }, 'msgs'),
|
||
el('th', { class: 'num' }, 'wallclock'),
|
||
el('th', {}, 'session id'))));
|
||
const tbody = el('tbody');
|
||
for (const r of rows) {
|
||
if (r.error) {
|
||
tbody.appendChild(el('tr', {},
|
||
el('td', { colspan: '8' },
|
||
el('span', { class: 'muted small' },
|
||
`${r.filename}: ${r.error}`))));
|
||
continue;
|
||
}
|
||
const wc = r.wallclock_seconds;
|
||
const wcText = wc == null ? '—' : `${wc.toFixed(1)}s`;
|
||
const tone = ARCHIVE_STATUS_TONE[r.status] || '';
|
||
const tr = el('tr', { class: 'clickable', 'data-filename': r.filename },
|
||
el('td', { class: 'small' }, fmtDate(r.modified_at)),
|
||
el('td', {}, el('code', {}, r.tag || '—')),
|
||
el('td', { class: 'small muted' }, r.agent || '—'),
|
||
el('td', {}, pill(r.status || '—', tone)),
|
||
el('td', { class: 'num' }, String(r.turn_count ?? 0)),
|
||
el('td', { class: 'num' }, String(r.message_count ?? 0)),
|
||
el('td', { class: 'num' }, wcText),
|
||
el('td', { class: 'small muted' },
|
||
el('code', {}, (r.session_id || '').slice(-12))));
|
||
tr.addEventListener('click', () => loadArchiveDetail(r.filename));
|
||
tbody.appendChild(tr);
|
||
}
|
||
tbl.appendChild(tbody);
|
||
host.appendChild(tbl);
|
||
}
|
||
|
||
async function loadArchiveDetail(filename) {
|
||
const card = document.getElementById('archive-detail-card');
|
||
const body = document.getElementById('archive-detail-body');
|
||
const title = document.getElementById('archive-detail-title');
|
||
card.classList.remove('hidden');
|
||
title.textContent = filename;
|
||
body.innerHTML = '';
|
||
body.appendChild(el('span', { class: 'muted' }, 'loading…'));
|
||
const url = `/api/sessions/archived/detail?filename=${encodeURIComponent(filename)}`;
|
||
const data = await api(url);
|
||
body.innerHTML = '';
|
||
if (!data) {
|
||
body.appendChild(el('span', { class: 'muted' }, 'failed to fetch detail'));
|
||
return;
|
||
}
|
||
if (data.error) {
|
||
body.appendChild(el('span', { class: 'muted' }, `error: ${data.error}`));
|
||
return;
|
||
}
|
||
// Header summary
|
||
body.appendChild(el('dl', { class: 'kv' },
|
||
el('dt', {}, 'session_id'), el('dd', {}, el('code', {}, data.session_id || '—')),
|
||
el('dt', {}, 'tag'), el('dd', {}, el('code', {}, data.tag || '—')),
|
||
el('dt', {}, 'agent'), el('dd', {}, data.agent || '—'),
|
||
el('dt', {}, 'status'), el('dd', {}, pill(data.status, ARCHIVE_STATUS_TONE[data.status] || '')),
|
||
el('dt', {}, 'started_at'), el('dd', {}, data.started_at || '—'),
|
||
el('dt', {}, 'archived_at'), el('dd', {}, data.archived_at || '—'),
|
||
el('dt', {}, 'wallclock'), el('dd', {},
|
||
data.wallclock_seconds == null ? '—' : `${data.wallclock_seconds.toFixed(1)}s`)));
|
||
|
||
// Per-turn metrics table
|
||
if (Array.isArray(data.per_turn) && data.per_turn.length) {
|
||
body.appendChild(el('h4', {}, 'Per-turn metrics'));
|
||
const tbl = el('table', { class: 'tt' });
|
||
tbl.appendChild(el('thead', {}, el('tr', {},
|
||
el('th', { class: 'num' }, '#'),
|
||
el('th', {}, 'tools'),
|
||
el('th', { class: 'num' }, 'in'),
|
||
el('th', { class: 'num' }, 'out'),
|
||
el('th', { class: 'num' }, 'reasoning'),
|
||
el('th', { class: 'num' }, 'wallclock'),
|
||
el('th', {}, 'completed'))));
|
||
const tb = el('tbody');
|
||
data.per_turn.forEach((t, i) => {
|
||
tb.appendChild(el('tr', {},
|
||
el('td', { class: 'num' }, String(i + 1)),
|
||
el('td', {}, (t.tools || []).join(', ') || '—'),
|
||
el('td', { class: 'num' }, String(t.input_tokens ?? '—')),
|
||
el('td', { class: 'num' }, String(t.output_tokens ?? '—')),
|
||
el('td', { class: 'num' }, String(t.reasoning_tokens ?? '—')),
|
||
el('td', { class: 'num' },
|
||
t.wallclock_seconds == null ? '—' : `${t.wallclock_seconds.toFixed(1)}s`),
|
||
el('td', {}, t.completed ? 'yes' : 'no')));
|
||
});
|
||
tbl.appendChild(tb);
|
||
body.appendChild(tbl);
|
||
}
|
||
|
||
// State history
|
||
if (Array.isArray(data.state_history) && data.state_history.length) {
|
||
const transitions = data.state_history.filter((e, i, arr) =>
|
||
i === 0 || e.state !== arr[i - 1].state);
|
||
body.appendChild(el('h4', {}, 'State transitions'));
|
||
const histList = el('ul', { class: 'tight' });
|
||
for (const t of transitions) {
|
||
histList.appendChild(el('li', {},
|
||
`+${(t.elapsed_s || 0).toFixed(1)}s -> ${t.state}` +
|
||
(t.seen_busy ? ' (seen_busy=true)' : '')));
|
||
}
|
||
body.appendChild(histList);
|
||
}
|
||
|
||
// Messages — collapsible per-message blocks
|
||
if (Array.isArray(data.messages) && data.messages.length) {
|
||
body.appendChild(el('h4', {}, `Messages (${data.messages.length})`));
|
||
for (let i = 0; i < data.messages.length; i++) {
|
||
const m = data.messages[i];
|
||
const info = (m && m.info) || m || {};
|
||
const role = info.role || '?';
|
||
const summary = `[${i}] ${role}` +
|
||
(info.id ? ` · ${info.id}` : '');
|
||
const det = el('details');
|
||
det.appendChild(el('summary', {}, summary));
|
||
const pre = el('pre', { class: 'archive-msg' },
|
||
JSON.stringify(m, null, 2));
|
||
det.appendChild(pre);
|
||
body.appendChild(det);
|
||
}
|
||
}
|
||
}
|
||
|
||
document.getElementById('archive-detail-close').addEventListener('click', () => {
|
||
document.getElementById('archive-detail-card').classList.add('hidden');
|
||
});
|
||
|
||
// ── About tab is fully meta-driven; no per-tab refresh needed ─────────
|
||
|
||
PANES['about'] = { intervalMs: null, refresh() { refreshMeta(); } };
|
||
|
||
// ── Live tab (per-run status grid driven by /runs + /snapshot + /events)
|
||
|
||
const LIVE = {
|
||
selectedRunId: null,
|
||
lastEventTsMs: 0,
|
||
feedRows: [], // deque-like; we cap at FEED_MAX
|
||
feedMax: 200,
|
||
severityFilter: 'info', // hide debug by default
|
||
textFilter: '',
|
||
// Session tree + transcript state. ``sessionRows`` is the flat list
|
||
// from /api/run/sessions; ``selectedSessionId`` drives which session's
|
||
// transcript is shown in the right-hand detail panel; ``sessionDetail``
|
||
// caches the last-loaded transcript so re-renders don't flash.
|
||
sessionRows: [],
|
||
sessionsRunId: null, // run_id that sessionRows was loaded for
|
||
selectedSessionId: null,
|
||
sessionDetail: null,
|
||
sessionDetailLoading: false,
|
||
feedFriendly: true, // show friendly text by default; raw toggle in UI
|
||
// Mount-once tracking: when the panel structure has been built for a
|
||
// run, subsequent ticks only swap dynamic body contents instead of
|
||
// wiping the whole panel — that's what kept eating the user's scroll
|
||
// position every 3 s.
|
||
panelMountedRunId: null,
|
||
};
|
||
|
||
// Save the scrollTop of a container and a marker for the distance from the
|
||
// bottom, then return a restorer. Use the marker (not raw scrollTop) when
|
||
// content has grown above the user's view — keeps the same DOM items
|
||
// stationary on screen even though offsets shifted.
|
||
function preserveScroll(container) {
|
||
if (!container) return () => {};
|
||
const before = {
|
||
top: container.scrollTop,
|
||
height: container.scrollHeight,
|
||
fromBottom: container.scrollHeight - container.scrollTop - container.clientHeight,
|
||
atTop: container.scrollTop < 4,
|
||
atBottom: (
|
||
container.scrollHeight - container.scrollTop - container.clientHeight
|
||
) < 4,
|
||
};
|
||
return function restore() {
|
||
if (!container.isConnected) return;
|
||
if (before.atTop) {
|
||
container.scrollTop = 0;
|
||
} else if (before.atBottom) {
|
||
container.scrollTop = container.scrollHeight;
|
||
} else {
|
||
// Maintain the same distance from the bottom — keeps the items
|
||
// the user is reading stationary regardless of how much content
|
||
// was added/removed above or below them.
|
||
const newTop = container.scrollHeight - container.clientHeight - before.fromBottom;
|
||
container.scrollTop = Math.max(0, newTop);
|
||
}
|
||
};
|
||
}
|
||
|
||
// ── Friendly event renderer ──────────────────────────────────────────
|
||
//
|
||
// Maps the closed set of EVENT_TYPES emitted by tools/live_log_writer.py
|
||
// to short human sentences. Keep the keys here in sync with
|
||
// ``EVENT_TYPES`` in that file — a missing key falls through to the raw
|
||
// type + summary, which is also what ``raw mode`` shows.
|
||
|
||
const MODEL_FRIENDLY = {
|
||
'claude-opus-4-6': 'Opus 4.6',
|
||
'claude-opus-4-7': 'Opus 4.7',
|
||
'claude-sonnet-4-6': 'Sonnet 4.6',
|
||
'claude-sonnet-4-5': 'Sonnet 4.5',
|
||
'claude-haiku-4-5': 'Haiku 4.5',
|
||
'gpt-5-mini': 'GPT-5 mini',
|
||
'gpt-5-nano': 'GPT-5 nano',
|
||
};
|
||
function friendlyModel(model) {
|
||
if (!model) return '';
|
||
// Strip provider prefix (e.g. "openai/gpt-5-mini" -> "gpt-5-mini")
|
||
const bare = model.includes('/') ? model.split('/').pop() : model;
|
||
return MODEL_FRIENDLY[bare] || bare;
|
||
}
|
||
function friendlyAgent(agent) {
|
||
if (!agent) return '?';
|
||
// The agent names already read well; just shorten a couple of long ones
|
||
// for sidebar density.
|
||
return ({
|
||
'implementation-worker': 'implementer',
|
||
'pr-review-worker': 'reviewer',
|
||
'task-implementor': 'task',
|
||
'estimator-implementation': 'estimator',
|
||
})[agent] || agent;
|
||
}
|
||
function friendlyEvent(evt) {
|
||
const d = evt.data || {};
|
||
const pr = evt.pr_number;
|
||
const prTag = pr ? ` for PR #${pr}` : '';
|
||
switch (evt.type) {
|
||
case 'dispatcher.boot':
|
||
return `${d.process || 'process'} alive (pid ${d.pid || '?'})`;
|
||
case 'dispatcher.identity_verified':
|
||
return `Verified ${d.dispatcher || 'dispatcher'} login as ${d.login || '?'}`;
|
||
case 'dispatcher.shutdown':
|
||
return `${d.dispatcher || 'Dispatcher'} shut down`;
|
||
case 'dispatcher.cycle_end':
|
||
return `Finished PR #${pr} (${d.terminal_state || 'done'}, ${(d.duration_s || 0).toFixed(0)}s)`;
|
||
case 'dispatcher.cycle_failed':
|
||
return `Cycle failed (${d.consecutive || '?'}/${d.max_consecutive || '?'} consecutive)`;
|
||
case 'claim.released':
|
||
return `Released claim on PR #${pr} (${d.reason || '?'})`;
|
||
case 'infra.bare_mirror_refresh_start':
|
||
return `Refreshing git mirror (age ${(d.age_s || 0).toFixed(0)}s)`;
|
||
case 'infra.bare_mirror_refresh_end':
|
||
return `Git mirror refreshed`;
|
||
case 'infra.worker_infra_seed':
|
||
return `Seeded worker infra (${d.files || 0} files, ${((d.bytes || 0) / 1024).toFixed(0)} KB)`;
|
||
case 'infra.worktree_created':
|
||
return `Created worktree${prTag}`;
|
||
case 'infra.worktree_cleaned':
|
||
return `Cleaned up worktree${prTag}`;
|
||
case 'infra.preflight_started':
|
||
return `Pre-flight checks started${prTag}`;
|
||
case 'infra.preflight_finished':
|
||
return `Pre-flight checks finished${prTag}`;
|
||
case 'infra.compliance_scanned':
|
||
return `Compliance scan completed${prTag}`;
|
||
case 'infra.prefetch_complete':
|
||
return `PR context prepared${prTag} (data_complete=${d.data_complete})`;
|
||
case 'infra.prefetch_cache_hit':
|
||
return `Reused cached context${prTag}`;
|
||
case 'infra.short_circuit':
|
||
return `Short-circuited${prTag} — no LLM call needed`;
|
||
case 'worker.session_created':
|
||
if (evt.session_id) {
|
||
return `Started worker session ${evt.session_id.slice(0, 14)}…${prTag} (${evt.tag || '?'})`;
|
||
}
|
||
// The pre-id "model override" emit comes first; surface the model.
|
||
return `Selected model ${friendlyModel(d.model)} for ${friendlyAgent(d.agent)}${prTag}`;
|
||
case 'worker.state_change':
|
||
return `Session ${(evt.session_id || '').slice(0, 14)}… ${d.from || '?'} → ${d.to || '?'}`;
|
||
case 'worker.turn_finished': {
|
||
const tools = (d.tools || []).join(', ') || 'no tools';
|
||
return `Turn ${d.turn_index ?? '?'}: ran ${tools} (${d.input_tok || 0}→${d.output_tok || 0} tok, ${(d.wallclock_s || 0).toFixed(1)}s)`;
|
||
}
|
||
case 'worker.subagent_spawned':
|
||
return `Spawned subagent ${friendlyAgent(d.agent)} at depth ${d.depth}`;
|
||
case 'worker.terminated':
|
||
return `Session ended cleanly (${(d.total_wallclock_s || 0).toFixed(0)}s)`;
|
||
case 'worker.timeout':
|
||
return `Worker timed out after ${(d.budget_s || 0).toFixed(0)}s`;
|
||
case 'worker.transport_error':
|
||
return `Transport error: session never went busy (${(d.elapsed_s || 0).toFixed(1)}s)`;
|
||
case 'worker.archive_written':
|
||
return `Saved session archive (${((d.size_bytes || 0) / 1024).toFixed(0)} KB)`;
|
||
case 'escalation.loop_start':
|
||
return `Starting escalation loop${prTag} (max tier ${d.max_tier || '?'})`;
|
||
case 'escalation.attempt':
|
||
return `Attempting tier ${d.tier || '?'}${prTag}`;
|
||
case 'escalation.exhausted':
|
||
return `Escalation exhausted${prTag}`;
|
||
case 'escalation.cross_cycle_seed':
|
||
return `Carried forward seed across cycles${prTag}`;
|
||
case 'telemetry.row_written':
|
||
return `Wrote telemetry row${prTag}`;
|
||
case 'reviewer.review_submitted':
|
||
return `Submitted review${prTag} (event=${d.event || '?'}, status=${d.status || '?'})`;
|
||
case 'reviewer.list_failure':
|
||
return `Reviewer list step failed: ${d.script || '?'}`;
|
||
case 'service.opencode_health':
|
||
return d.up
|
||
? `OpenCode healthy (v${d.version || '?'}, ${d.latency_ms || '?'}ms)`
|
||
: `OpenCode unreachable`;
|
||
case 'service.local_claude_proxy_health':
|
||
return d.up ? `Local Claude proxy up` : `Local Claude proxy down`;
|
||
case 'service.telemetry_server_health':
|
||
return d.up ? `Telemetry server up` : `Telemetry server down`;
|
||
// ── live-subagent events (sourced from OpenCode SSE) ─────────
|
||
case 'subagent.session_created':
|
||
return `${friendlyAgent(d.agent)} subagent started (depth ${d.depth ?? '?'})`;
|
||
case 'subagent.state_change':
|
||
return `Subagent ${(evt.session_id || '').slice(0, 14)}… ${d.from || '?'} → ${d.to || '?'}`;
|
||
case 'subagent.text':
|
||
return `${(evt.session_id || '').slice(0, 14)}… +${d.chars || 0}c text (seq ${d.delta_seq || '?'})`;
|
||
case 'subagent.tool_call_start': {
|
||
const keys = (d.input_keys || []).join(', ');
|
||
return `${d.tool || 'tool'} start${keys ? ` (${keys})` : ''} @ depth ${d.depth ?? '?'}`;
|
||
}
|
||
case 'subagent.tool_call_end':
|
||
return `${d.tool || 'tool'} ${d.status || '?'} in ${d.duration_ms || 0}ms`;
|
||
case 'subagent.terminated':
|
||
return `Subagent ${(evt.session_id || '').slice(0, 14)}… ended`;
|
||
case 'subagent.tier_recommendation':
|
||
return `Estimator tier pick: tier=${d.tier}` +
|
||
(d.is_confident != null ? ` (confidence=${d.is_confident})` : '');
|
||
default:
|
||
return evt.summary || evt.type;
|
||
}
|
||
}
|
||
|
||
// Phases that should render as the cheerful "running" tone; others get the
|
||
// default accent. ``failed`` / ``transport-error`` get the error tone.
|
||
function livePhaseClass(phase) {
|
||
if (!phase) return '';
|
||
if (phase === 'idle') return 'idle';
|
||
if (phase === 'failed') return 'failed';
|
||
if (phase === 'short_circuit') return 'short_circuit';
|
||
return '';
|
||
}
|
||
|
||
function liveFmtCountdown(seconds) {
|
||
if (seconds == null) return '';
|
||
if (seconds <= 0) return 'expired';
|
||
const h = Math.floor(seconds / 3600);
|
||
const m = Math.floor((seconds % 3600) / 60);
|
||
const s = Math.floor(seconds % 60);
|
||
return h > 0 ? `${h}h ${m}m ${s}s` : `${m}m ${s}s`;
|
||
}
|
||
|
||
function renderLiveRunsList(runs) {
|
||
const list = document.getElementById('live-runs-list');
|
||
list.innerHTML = '';
|
||
if (!runs || runs.length === 0) {
|
||
list.appendChild(el('div', { class: 'muted small' },
|
||
'no runs found — start tools/live_log_writer.py to populate this list'));
|
||
return;
|
||
}
|
||
for (const r of runs) {
|
||
const row = el('div', {
|
||
class: `live-run-row ${r.run_id === LIVE.selectedRunId ? 'selected' : ''}`,
|
||
});
|
||
const dot = el('span', {
|
||
class: `dot ${r.is_live ? 'ok' : ''}`,
|
||
title: r.is_live ? 'snapshot fresh' : 'archived',
|
||
});
|
||
const idBlock = el('div', { class: 'live-run-id' },
|
||
r.run_id,
|
||
el('div', { class: 'live-run-meta' },
|
||
`${r.implementer_phase || '—'} · ` +
|
||
`pr=${r.implementer_active_pr ?? '—'} · ` +
|
||
`evt=${r.events_count_approx ?? '?'} · ` +
|
||
`snap ${fmtAge(r.snapshot_mtime_s_ago)}`
|
||
),
|
||
);
|
||
row.appendChild(dot);
|
||
row.appendChild(idBlock);
|
||
row.addEventListener('click', () => selectRun(r.run_id));
|
||
list.appendChild(row);
|
||
}
|
||
}
|
||
|
||
function selectRun(runId) {
|
||
if (LIVE.selectedRunId === runId) return;
|
||
LIVE.selectedRunId = runId;
|
||
LIVE.lastEventTsMs = 0;
|
||
LIVE.feedRows = [];
|
||
LIVE.sessionRows = [];
|
||
LIVE.sessionsRunId = null;
|
||
LIVE.selectedSessionId = null;
|
||
LIVE.sessionDetail = null;
|
||
// Force a structural remount on the next tick — the per-run header
|
||
// text (runId) lives inside live-runhead and we want it updated when
|
||
// updateLivePanelContents runs against the fresh snapshot.
|
||
LIVE.panelMountedRunId = null;
|
||
// Update selection highlight without refetching the full /runs list.
|
||
document.querySelectorAll('.live-run-row').forEach(r => {
|
||
r.classList.toggle('selected',
|
||
r.querySelector('.live-run-id')?.firstChild?.textContent === runId);
|
||
});
|
||
// Trigger an immediate snapshot+events fetch.
|
||
if (activeTab === 'live') PANES['live'].refresh();
|
||
}
|
||
|
||
function selectSession(sessionId) {
|
||
if (LIVE.selectedSessionId === sessionId) return;
|
||
LIVE.selectedSessionId = sessionId;
|
||
LIVE.sessionDetail = null;
|
||
LIVE.sessionDetailLoading = true;
|
||
// Re-render the tree to update selection highlight + the (empty) detail
|
||
// pane to show a loading state. The next refresh tick will fetch the
|
||
// detail and re-render — but kick off the fetch immediately so the
|
||
// operator doesn't wait for the 3s tick.
|
||
renderLiveSessionsPanel();
|
||
fetchSessionDetail();
|
||
}
|
||
|
||
async function fetchSessionDetail() {
|
||
if (!LIVE.selectedRunId || !LIVE.selectedSessionId) return;
|
||
const runId = LIVE.selectedRunId;
|
||
const sid = LIVE.selectedSessionId;
|
||
const detail = await api(
|
||
`/api/run/session?run=${encodeURIComponent(runId)}` +
|
||
`&session=${encodeURIComponent(sid)}`);
|
||
// Bail if the user moved on between request and response.
|
||
if (LIVE.selectedRunId !== runId || LIVE.selectedSessionId !== sid) return;
|
||
LIVE.sessionDetail = detail;
|
||
LIVE.sessionDetailLoading = false;
|
||
renderLiveSessionsPanel();
|
||
}
|
||
|
||
function renderLiveProcGrid(processes) {
|
||
const order = ['opencode_serve', 'launcher', 'dispatch_review',
|
||
'dispatch_implementer'];
|
||
const body = el('div', { class: 'live-card-body' });
|
||
for (const name of order) {
|
||
const p = processes?.[name] || { alive: false };
|
||
const row = el('div', { class: `live-proc-row ${p.alive ? '' : 'dead'}` });
|
||
row.appendChild(el('span', { class: 'live-proc-name' }, name));
|
||
const detail = p.alive
|
||
? `pid ${p.pid} · ${fmtAge(p.elapsed_s)}` +
|
||
(p.cpu_percent != null ? ` · ${p.cpu_percent.toFixed(1)}% cpu` : '') +
|
||
(p.wchan ? ` · ${p.wchan}` : '')
|
||
: 'down';
|
||
row.appendChild(el('span', { class: 'live-proc-detail' }, detail));
|
||
const dot = el('span', { class: `dot ${p.alive ? 'ok' : 'err'}` });
|
||
row.appendChild(dot);
|
||
body.appendChild(row);
|
||
if (p.subprocess) {
|
||
const sub = el('div', { class: 'live-proc-row' });
|
||
sub.appendChild(el('span', { class: 'live-proc-name', style: 'margin-left:1em;color:var(--fg-muted)' },
|
||
'↳ child'));
|
||
sub.appendChild(el('span', { class: 'live-proc-detail' },
|
||
`${p.subprocess.cmd.slice(0, 80)} · ${fmtAge(p.subprocess.elapsed_s)}`));
|
||
sub.appendChild(el('span'));
|
||
body.appendChild(sub);
|
||
}
|
||
}
|
||
return body;
|
||
}
|
||
|
||
function renderLiveServiceGrid(services) {
|
||
const order = ['opencode_4096', 'local_claude_3456', 'telemetry_8765'];
|
||
const body = el('div', { class: 'live-card-body' });
|
||
for (const name of order) {
|
||
const s = services?.[name] || { up: null };
|
||
const row = el('div', { class: `live-proc-row ${s.up ? '' : 'dead'}` });
|
||
row.appendChild(el('span', { class: 'live-proc-name' }, name));
|
||
const detail =
|
||
s.up == null ? 'probing…' :
|
||
s.up ? `${s.status || 200} · ${s.latency_ms ?? '—'}ms${s.version ? ` · v${s.version}` : ''}` :
|
||
(s.error || 'down').slice(0, 60);
|
||
row.appendChild(el('span', { class: 'live-proc-detail' }, detail));
|
||
const dotCls = s.up == null ? '' : s.up ? 'ok' : 'err';
|
||
row.appendChild(el('span', { class: `dot ${dotCls}` }));
|
||
body.appendChild(row);
|
||
}
|
||
return body;
|
||
}
|
||
|
||
function renderLiveImplementerCard(impl, telemetryMeta) {
|
||
const t = impl.totals || {};
|
||
const body = el('div', { class: 'live-card-body' });
|
||
body.appendChild(el('div', {},
|
||
el('span', { class: `live-phase-badge ${livePhaseClass(impl.current_phase)}` },
|
||
impl.current_phase || 'idle'),
|
||
));
|
||
const kv = el('dl', { class: 'live-kv' });
|
||
function addKv(k, v) {
|
||
kv.appendChild(el('dt', {}, k));
|
||
kv.appendChild(el('dd', {}, v == null ? '—' : String(v)));
|
||
}
|
||
addKv('active PR', impl.active_pr_number);
|
||
addKv('active tier', impl.active_tier);
|
||
addKv('active session',
|
||
impl.active_session_id
|
||
? `${impl.active_session_id.slice(0, 18)}… (${fmtAge(impl.active_session_elapsed_s)})`
|
||
: null,
|
||
);
|
||
// NB: no "active claims" row — the snapshot has no active_claim_pr_numbers
|
||
// field. The live writer never tracked an active-claims set because the
|
||
// dispatchers log claim *releases* but not claim *acquisitions*, so the
|
||
// set would have been provably always empty. See live_log_writer.py.
|
||
addKv('cycles compl/fail', `${t.cycles_completed ?? 0} / ${t.cycles_failed ?? 0}`);
|
||
addKv('telemetry rows', t.telemetry_rows_total ?? 0);
|
||
addKv('head_sha_advanced', t.head_sha_advanced_count ?? 0);
|
||
addKv('outcome_disputed',
|
||
t.outcome_disputed_count ?? 0);
|
||
if ((t.outcome_disputed_count ?? 0) > 0) {
|
||
kv.lastElementChild.style.color = 'var(--err)';
|
||
kv.lastElementChild.style.fontWeight = '700';
|
||
}
|
||
addKv('outcome_synthesised', t.outcome_synthesised_count ?? 0);
|
||
addKv('short-circuit P0/A',
|
||
`${t.short_circuit_count_p0 ?? 0} / ${t.short_circuit_count_a ?? 0}`);
|
||
if (t.outcomes_by_status) {
|
||
const parts = Object.entries(t.outcomes_by_status)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.map(([k, v]) => `${k}=${v}`);
|
||
addKv('outcomes', parts.join(', ') || '—');
|
||
}
|
||
if (telemetryMeta?.first_outcome_disputed_ts) {
|
||
addKv('first disputed', fmtDate(telemetryMeta.first_outcome_disputed_ts));
|
||
}
|
||
if (telemetryMeta?.first_head_sha_advanced_ts) {
|
||
addKv('first push', fmtDate(telemetryMeta.first_head_sha_advanced_ts));
|
||
}
|
||
body.appendChild(kv);
|
||
return body;
|
||
}
|
||
|
||
function renderLiveReviewerCard(rev) {
|
||
const t = rev.totals || {};
|
||
const body = el('div', { class: 'live-card-body' });
|
||
body.appendChild(el('div', {},
|
||
el('span', { class: `live-phase-badge ${livePhaseClass(rev.current_phase)}` },
|
||
rev.current_phase || 'idle'),
|
||
));
|
||
const kv = el('dl', { class: 'live-kv' });
|
||
function addKv(k, v) {
|
||
kv.appendChild(el('dt', {}, k));
|
||
kv.appendChild(el('dd', {}, v == null ? '—' : String(v)));
|
||
}
|
||
addKv('active PR', rev.active_pr_number);
|
||
addKv('reviews submitted', t.reviews_submitted ?? 0);
|
||
addKv('cycles compl/fail', `${t.cycles_completed ?? 0} / ${t.cycles_failed ?? 0}`);
|
||
addKv('consecutive fails', t.consecutive_failures ?? 0);
|
||
if ((t.consecutive_failures ?? 0) >= 3) {
|
||
kv.lastElementChild.style.color = 'var(--err)';
|
||
kv.lastElementChild.style.fontWeight = '700';
|
||
}
|
||
addKv('list failures', t.list_failures_total ?? 0);
|
||
if (t.list_failures_by_kind) {
|
||
const parts = Object.entries(t.list_failures_by_kind)
|
||
.map(([k, v]) => `${k}=${v}`);
|
||
addKv('list failure kinds', parts.join(', ') || '—');
|
||
}
|
||
body.appendChild(kv);
|
||
return body;
|
||
}
|
||
|
||
function renderLiveErrors(errors) {
|
||
const body = el('div', { class: 'live-card-body' });
|
||
if (!errors || errors.length === 0) {
|
||
body.appendChild(el('div', { class: 'muted small' }, 'no recent errors'));
|
||
return body;
|
||
}
|
||
for (const e of errors.slice().reverse()) {
|
||
const row = el('div', { class: 'live-errors-row' });
|
||
row.appendChild(el('div', { class: 'ts' },
|
||
`${fmtDate(e.ts)} · ${e.type || ''}`));
|
||
row.appendChild(el('div', {},
|
||
(e.pr_number ? `PR #${e.pr_number}: ` : '') + (e.summary || '')));
|
||
body.appendChild(row);
|
||
}
|
||
return body;
|
||
}
|
||
|
||
function liveFeedRowMatches(row) {
|
||
// Severity gate (info+ by default; debug shown only when filter == 'debug').
|
||
const SEV_ORDER = { debug: 0, info: 1, warn: 2, error: 3, critical: 4 };
|
||
const minSev = SEV_ORDER[LIVE.severityFilter] ?? 1;
|
||
if ((SEV_ORDER[row.severity] ?? 1) < minSev) return false;
|
||
// Text filter (substring match on type + summary).
|
||
if (LIVE.textFilter) {
|
||
const needle = LIVE.textFilter.toLowerCase();
|
||
const hay = `${row.type} ${row.summary || ''}`.toLowerCase();
|
||
if (!hay.includes(needle)) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function renderLiveFeed() {
|
||
const body = el('div', { class: 'live-card-body' });
|
||
const filterBar = el('div', { class: 'live-feed-filter' });
|
||
const sevSel = el('select');
|
||
for (const v of ['debug', 'info', 'warn', 'error']) {
|
||
const opt = el('option', { value: v }, `≥ ${v}`);
|
||
if (v === LIVE.severityFilter) opt.setAttribute('selected', 'true');
|
||
sevSel.appendChild(opt);
|
||
}
|
||
sevSel.addEventListener('change', () => {
|
||
LIVE.severityFilter = sevSel.value;
|
||
drawLiveFeed();
|
||
});
|
||
filterBar.appendChild(el('span', {}, 'severity:'));
|
||
filterBar.appendChild(sevSel);
|
||
const txt = el('input', {
|
||
type: 'text',
|
||
placeholder: 'filter (type/summary substring)',
|
||
value: LIVE.textFilter,
|
||
});
|
||
txt.addEventListener('input', () => {
|
||
LIVE.textFilter = txt.value;
|
||
drawLiveFeed();
|
||
});
|
||
filterBar.appendChild(txt);
|
||
const friendlyBtn = el('button', {
|
||
class: `live-feed-toggle ${LIVE.feedFriendly ? 'on' : ''}`,
|
||
type: 'button',
|
||
title: 'Toggle friendly text vs. raw event types',
|
||
}, LIVE.feedFriendly ? 'friendly' : 'raw');
|
||
friendlyBtn.addEventListener('click', () => {
|
||
LIVE.feedFriendly = !LIVE.feedFriendly;
|
||
drawLiveFeed();
|
||
friendlyBtn.classList.toggle('on', LIVE.feedFriendly);
|
||
friendlyBtn.textContent = LIVE.feedFriendly ? 'friendly' : 'raw';
|
||
});
|
||
filterBar.appendChild(friendlyBtn);
|
||
filterBar.appendChild(el('span', { class: 'muted small' },
|
||
`${LIVE.feedRows.length} buffered`));
|
||
body.appendChild(filterBar);
|
||
const feed = el('div', { class: 'live-feed', id: 'live-feed-inner' });
|
||
body.appendChild(feed);
|
||
return body;
|
||
}
|
||
|
||
function drawLiveFeed() {
|
||
const feed = document.getElementById('live-feed-inner');
|
||
if (!feed) return;
|
||
const restoreScroll = preserveScroll(feed);
|
||
feed.innerHTML = '';
|
||
// Most recent first.
|
||
const visible = LIVE.feedRows.slice().reverse().filter(liveFeedRowMatches);
|
||
for (const e of visible) {
|
||
const row = el('div', { class: 'live-feed-row' });
|
||
const tsShort = (e.ts || '').slice(11, 23); // HH:MM:SS.mmm
|
||
row.appendChild(el('span', { class: 'ts' }, tsShort));
|
||
row.appendChild(el('span', { class: `sev-${e.severity}` },
|
||
e.severity));
|
||
// Friendly text by default; raw type + summary when the toggle is off.
|
||
if (LIVE.feedFriendly) {
|
||
row.appendChild(el('span', { class: 'live-feed-msg' },
|
||
friendlyEvent(e)));
|
||
// Faded type chip so power users can still see the underlying type
|
||
// without losing readability.
|
||
row.appendChild(el('span', { class: 'ty muted small' }, e.type));
|
||
} else {
|
||
row.appendChild(el('span', { class: 'ty' }, e.type));
|
||
row.appendChild(el('span', {},
|
||
(e.pr_number ? `PR#${e.pr_number} ` : '') + (e.summary || '')));
|
||
}
|
||
feed.appendChild(row);
|
||
}
|
||
restoreScroll();
|
||
}
|
||
|
||
// ── Session tree + transcript (data from /api/run/sessions + /api/run/session)
|
||
|
||
function liveSessionStatusClass(status) {
|
||
if (!status) return '';
|
||
if (status === 'running') return 'running';
|
||
if (status === 'idle') return 'idle';
|
||
if (status === 'starting') return 'starting';
|
||
if (status === 'archived' || status === 'terminated') return 'done';
|
||
if (status === 'timeout' || status === 'transport_error') return 'failed';
|
||
return '';
|
||
}
|
||
|
||
function liveSessionStatusLabel(status) {
|
||
return ({
|
||
running: 'running',
|
||
idle: 'idle',
|
||
starting: 'starting',
|
||
terminated: 'done',
|
||
archived: 'done',
|
||
timeout: 'TIMED OUT',
|
||
transport_error: 'TRANSPORT ERR',
|
||
})[status] || (status || '—');
|
||
}
|
||
|
||
function buildSessionTree(rows) {
|
||
// Group rows by parent_session_id. Nulls are roots. Orphaned children
|
||
// (parent not in this run) are surfaced under a synthetic "(unparented)"
|
||
// root so the operator can still see them rather than them silently
|
||
// disappearing from the tree.
|
||
const byParent = new Map();
|
||
const sids = new Set(rows.map(r => r.session_id));
|
||
for (const r of rows) {
|
||
const p = r.parent_session_id;
|
||
const key = (p && sids.has(p)) ? p : '__ROOT__';
|
||
if (!byParent.has(key)) byParent.set(key, []);
|
||
byParent.get(key).push(r);
|
||
}
|
||
// Stable order within each parent: by started_at, then session_id.
|
||
for (const arr of byParent.values()) {
|
||
arr.sort((a, b) =>
|
||
(a.started_at || '').localeCompare(b.started_at || '') ||
|
||
a.session_id.localeCompare(b.session_id));
|
||
}
|
||
return byParent;
|
||
}
|
||
|
||
function renderSessionNode(row, byParent, depth) {
|
||
const cls = `live-session-node depth-${depth} ` +
|
||
`${liveSessionStatusClass(row.status)} ` +
|
||
`${row.session_id === LIVE.selectedSessionId ? 'selected' : ''}`;
|
||
const node = el('div', { class: cls });
|
||
node.style.paddingLeft = `${depth * 18 + 8}px`;
|
||
|
||
const dot = el('span', {
|
||
class: `dot ${row.status === 'running' ? 'ok' : ''}`,
|
||
title: liveSessionStatusLabel(row.status),
|
||
});
|
||
node.appendChild(dot);
|
||
|
||
const head = el('span', { class: 'live-session-head' },
|
||
el('span', { class: 'live-session-agent' }, friendlyAgent(row.agent)),
|
||
row.tag ? el('span', { class: 'live-session-tag' }, row.tag) : '',
|
||
row.model ? el('span', { class: 'live-session-model' },
|
||
friendlyModel(row.model)) : '',
|
||
);
|
||
// Tier badge: the estimator's single most useful real-time signal.
|
||
// Sourced from the subagent.tier_recommendation event aggregated into
|
||
// row.tier_recommendation server-side. Surface it inline on the head
|
||
// so the operator sees the pick without drilling into the transcript.
|
||
if (row.tier_recommendation && row.tier_recommendation.tier != null) {
|
||
const t = row.tier_recommendation;
|
||
head.appendChild(el('span', {
|
||
class: 'live-session-tier-badge',
|
||
title: t.is_confident != null
|
||
? `tier=${t.tier} · confidence=${t.is_confident}`
|
||
: `tier=${t.tier}`,
|
||
}, `tier=${t.tier}`));
|
||
}
|
||
node.appendChild(head);
|
||
|
||
const statusBadge = el('span',
|
||
{ class: `live-session-status ${liveSessionStatusClass(row.status)}` },
|
||
liveSessionStatusLabel(row.status));
|
||
node.appendChild(statusBadge);
|
||
|
||
const meta = el('span', { class: 'live-session-meta muted small' },
|
||
`${row.turn_count || 0} turn${row.turn_count === 1 ? '' : 's'}` +
|
||
(row.input_tokens
|
||
? ` · ${(row.input_tokens / 1000).toFixed(1)}k→${(row.output_tokens / 1000).toFixed(1)}k tok`
|
||
: ''));
|
||
node.appendChild(meta);
|
||
|
||
node.addEventListener('click', (ev) => {
|
||
ev.stopPropagation();
|
||
selectSession(row.session_id);
|
||
});
|
||
|
||
const wrapper = el('div', { class: 'live-session-branch' }, node);
|
||
const children = byParent.get(row.session_id) || [];
|
||
for (const c of children) {
|
||
wrapper.appendChild(renderSessionNode(c, byParent, depth + 1));
|
||
}
|
||
return wrapper;
|
||
}
|
||
|
||
function renderLiveSessionTree() {
|
||
const rows = LIVE.sessionRows || [];
|
||
const body = el('div', { class: 'live-card-body' });
|
||
if (!rows.length) {
|
||
body.appendChild(el('div', { class: 'muted small' },
|
||
'no sessions yet — the run has not started any worker sessions, ' +
|
||
'or this run pre-dates the per-run session endpoint'));
|
||
return body;
|
||
}
|
||
const byParent = buildSessionTree(rows);
|
||
const roots = byParent.get('__ROOT__') || [];
|
||
// Highest-depth-first: render the trees in started_at order, oldest at
|
||
// top. Each tree is a parent + its descendants — already produced by
|
||
// renderSessionNode's recursion.
|
||
for (const r of roots) {
|
||
body.appendChild(renderSessionNode(r, byParent, 0));
|
||
}
|
||
return body;
|
||
}
|
||
|
||
function renderTurnPart(part) {
|
||
if (part.kind === 'text') {
|
||
return el('div', { class: 'live-part live-part-text' }, part.text);
|
||
}
|
||
if (part.kind === 'reasoning') {
|
||
return el('div', { class: 'live-part live-part-reasoning' },
|
||
el('span', { class: 'live-part-label' }, 'reasoning'),
|
||
el('div', {}, part.text));
|
||
}
|
||
if (part.kind === 'tool') {
|
||
const wrap = el('div', { class: 'live-part live-part-tool' });
|
||
const head = el('div', { class: 'live-tool-head' },
|
||
el('span', { class: 'live-tool-name' }, part.tool || 'tool'),
|
||
el('span', { class: `live-tool-status ${part.status || ''}` },
|
||
part.status || 'pending'));
|
||
wrap.appendChild(head);
|
||
if (part.input !== undefined && part.input !== null) {
|
||
const inputStr = typeof part.input === 'string'
|
||
? part.input : JSON.stringify(part.input, null, 2);
|
||
wrap.appendChild(el('details', { class: 'live-tool-block' },
|
||
el('summary', {}, 'input'),
|
||
el('pre', {}, inputStr)));
|
||
}
|
||
if (part.output !== undefined && part.output !== null) {
|
||
const outputStr = typeof part.output === 'string'
|
||
? part.output : JSON.stringify(part.output, null, 2);
|
||
const summary = part.output_truncated
|
||
? `output (truncated at 8 KB)` : 'output';
|
||
wrap.appendChild(el('details', { class: 'live-tool-block' },
|
||
el('summary', {}, summary),
|
||
el('pre', {}, outputStr)));
|
||
}
|
||
return wrap;
|
||
}
|
||
return el('div', {});
|
||
}
|
||
|
||
function renderLiveSessionDetail() {
|
||
const body = el('div', { class: 'live-card-body live-session-detail' });
|
||
if (!LIVE.selectedSessionId) {
|
||
body.appendChild(el('div', { class: 'muted small' },
|
||
'Click a session on the left to inspect its prompts, reasoning, ' +
|
||
'tool calls, and outputs.'));
|
||
return body;
|
||
}
|
||
if (LIVE.sessionDetailLoading && !LIVE.sessionDetail) {
|
||
body.appendChild(el('div', { class: 'muted small' }, 'loading…'));
|
||
return body;
|
||
}
|
||
const d = LIVE.sessionDetail;
|
||
if (!d) {
|
||
body.appendChild(el('div', { class: 'muted small' },
|
||
'no transcript loaded yet'));
|
||
return body;
|
||
}
|
||
if (d.error) {
|
||
body.appendChild(el('div', { class: 'muted small' }, d.error));
|
||
return body;
|
||
}
|
||
const head = el('div', { class: 'live-session-detail-head' },
|
||
el('strong', {}, friendlyAgent(d.agent || '?')),
|
||
el('span', { class: 'live-session-tag' }, d.tag || ''),
|
||
el('span', { class: 'muted small' },
|
||
`${d.source || '?'} · ${(d.turns || []).length} turn${(d.turns || []).length === 1 ? '' : 's'}`),
|
||
);
|
||
body.appendChild(head);
|
||
const turns = d.turns || [];
|
||
if (!turns.length) {
|
||
body.appendChild(el('div', { class: 'muted small' },
|
||
'no messages yet'));
|
||
return body;
|
||
}
|
||
for (const t of turns) {
|
||
const turnCard = el('div', { class: `live-turn role-${t.role || 'unknown'}` });
|
||
turnCard.appendChild(el('div', { class: 'live-turn-head' },
|
||
el('span', { class: `live-turn-role role-${t.role || 'unknown'}` },
|
||
t.role || '?'),
|
||
t.model
|
||
? el('span', { class: 'muted small' }, friendlyModel(t.model))
|
||
: '',
|
||
t.ts ? el('span', { class: 'muted small' }, fmtDate(t.ts)) : '',
|
||
));
|
||
for (const p of (t.parts || [])) {
|
||
turnCard.appendChild(renderTurnPart(p));
|
||
}
|
||
body.appendChild(turnCard);
|
||
}
|
||
return body;
|
||
}
|
||
|
||
function renderLiveSessionsPanel() {
|
||
// The sessions row is mounted with stable element ids so it can be
|
||
// re-rendered in-place without rebuilding the whole live panel. Both
|
||
// the tree card and the detail card are independently scrollable;
|
||
// preserve each one's scroll position across the rebuild so the
|
||
// operator's reading position doesn't jump on every tick.
|
||
const treeHost = document.getElementById('live-sessions-tree');
|
||
const detailHost = document.getElementById('live-sessions-detail');
|
||
if (treeHost) {
|
||
const treeCard = treeHost.closest('.live-sessions-tree-card');
|
||
const restoreTree = preserveScroll(treeCard);
|
||
treeHost.innerHTML = '';
|
||
treeHost.appendChild(renderLiveSessionTree());
|
||
restoreTree();
|
||
}
|
||
if (detailHost) {
|
||
const detailCard = detailHost.closest('.live-sessions-detail-card');
|
||
const restoreDetail = preserveScroll(detailCard);
|
||
detailHost.innerHTML = '';
|
||
detailHost.appendChild(renderLiveSessionDetail());
|
||
restoreDetail();
|
||
}
|
||
}
|
||
|
||
function renderLivePanel(runId, snap) {
|
||
// Mount-once / update-in-place. Replacing the whole panel on every
|
||
// 3 s tick destroyed every scrollable container's position — the user
|
||
// could not read a long event feed or transcript without it jumping
|
||
// back to the top. Now: build the structure when the run changes (or
|
||
// the panel hasn't been mounted yet); on every other tick, swap only
|
||
// the dynamic body contents and let the browser keep its scroll.
|
||
const panel = document.getElementById('live-panel');
|
||
if (!snap || snap.error) {
|
||
// Error state: tear down the mount so the next successful snapshot
|
||
// re-mounts cleanly. The error message is small enough that any
|
||
// scroll loss is fine here.
|
||
LIVE.panelMountedRunId = null;
|
||
panel.innerHTML = '';
|
||
panel.appendChild(el('div', { class: 'live-empty muted' },
|
||
snap?.error || 'snapshot not yet available'));
|
||
return;
|
||
}
|
||
if (LIVE.panelMountedRunId !== runId) {
|
||
mountLivePanel(runId);
|
||
LIVE.panelMountedRunId = runId;
|
||
}
|
||
updateLivePanelContents(runId, snap);
|
||
}
|
||
|
||
function mountLivePanel(runId) {
|
||
// Build the structural skeleton — every card head + an empty body with
|
||
// a stable id. updateLivePanelContents() then fills the bodies on each
|
||
// tick without touching the surrounding DOM.
|
||
const panel = document.getElementById('live-panel');
|
||
panel.innerHTML = '';
|
||
|
||
panel.appendChild(el('div', { class: 'live-runhead', id: 'live-runhead' }));
|
||
|
||
// Row 1: processes + services
|
||
const row1 = el('div', { class: 'live-grid-row' });
|
||
row1.appendChild(el('div', { class: 'live-card' },
|
||
el('div', { class: 'live-card-head' }, 'Processes',
|
||
el('span', { class: 'muted small' }, '/proc · 5 s')),
|
||
el('div', { id: 'live-procs-body' })));
|
||
row1.appendChild(el('div', { class: 'live-card' },
|
||
el('div', { class: 'live-card-head' }, 'Services',
|
||
el('span', { class: 'muted small' }, 'HTTP probe · 5 s')),
|
||
el('div', { id: 'live-services-body' })));
|
||
panel.appendChild(row1);
|
||
|
||
// Row 2: implementer + reviewer
|
||
const row2 = el('div', { class: 'live-grid-row' });
|
||
row2.appendChild(el('div', { class: 'live-card' },
|
||
el('div', { class: 'live-card-head' }, 'Implementer',
|
||
el('span', { class: 'muted small', id: 'live-impl-meta' }, '')),
|
||
el('div', { id: 'live-impl-body' })));
|
||
row2.appendChild(el('div', { class: 'live-card' },
|
||
el('div', { class: 'live-card-head' }, 'Reviewer'),
|
||
el('div', { id: 'live-rev-body' })));
|
||
panel.appendChild(row2);
|
||
|
||
// Row 2.5: session tree + transcript. These two cards are independently
|
||
// scrollable; renderLiveSessionsPanel preserves their scroll positions.
|
||
const sessRow = el('div', { class: 'live-grid-row live-sessions-row' });
|
||
sessRow.appendChild(el('div', { class: 'live-card live-sessions-tree-card' },
|
||
el('div', { class: 'live-card-head' },
|
||
'Agent sessions',
|
||
el('span', { class: 'muted small', id: 'live-sessions-count' }, '')),
|
||
el('div', { id: 'live-sessions-tree' })));
|
||
sessRow.appendChild(el('div', { class: 'live-card live-sessions-detail-card' },
|
||
el('div', { class: 'live-card-head' },
|
||
'Transcript',
|
||
el('span', { class: 'muted small' },
|
||
'click a session to see prompts, reasoning, tool calls')),
|
||
el('div', { id: 'live-sessions-detail' })));
|
||
panel.appendChild(sessRow);
|
||
|
||
// Row 3: infrastructure + errors
|
||
const row3 = el('div', { class: 'live-grid-row' });
|
||
row3.appendChild(el('div', { class: 'live-card' },
|
||
el('div', { class: 'live-card-head' }, 'Infrastructure'),
|
||
el('div', { id: 'live-infra-body' })));
|
||
row3.appendChild(el('div', { class: 'live-card' },
|
||
el('div', { class: 'live-card-head' }, 'Recent errors',
|
||
el('span', { class: 'muted small', id: 'live-errors-count' }, '')),
|
||
el('div', { id: 'live-errors-body' })));
|
||
panel.appendChild(row3);
|
||
|
||
// Row 4: event feed. renderLiveFeed mounts the filter bar + the
|
||
// feed-inner container (stable id). drawLiveFeed populates rows and
|
||
// is called every tick; preserveScroll inside it keeps the scrollTop
|
||
// stable so the user can read older entries while new ones land.
|
||
const feedCard = el('div', { class: 'live-card' });
|
||
feedCard.appendChild(el('div', { class: 'live-card-head' },
|
||
'Event feed',
|
||
el('span', { class: 'muted small' }, 'events.jsonl tail · 3 s')));
|
||
feedCard.appendChild(renderLiveFeed());
|
||
panel.appendChild(feedCard);
|
||
}
|
||
|
||
function updateLivePanelContents(runId, snap) {
|
||
// Header text changes every tick (uptime, remaining countdown) but the
|
||
// container is stable so the page layout doesn't reflow.
|
||
const head = document.getElementById('live-runhead');
|
||
if (head) {
|
||
const children = [
|
||
el('h3', {}, runId),
|
||
el('span', { class: 'muted small' },
|
||
`boot ${fmtDate(snap.run?.boot_ts)} · ` +
|
||
`uptime ${fmtAge(snap.run?.uptime_seconds)}`),
|
||
];
|
||
if (snap.run?.remaining_seconds != null) {
|
||
children.push(el('span', { class: 'live-countdown' },
|
||
'kill in ' + liveFmtCountdown(snap.run.remaining_seconds)));
|
||
}
|
||
head.replaceChildren(...children);
|
||
}
|
||
const procs = document.getElementById('live-procs-body');
|
||
if (procs) procs.replaceChildren(renderLiveProcGrid(snap.processes));
|
||
const svcs = document.getElementById('live-services-body');
|
||
if (svcs) svcs.replaceChildren(renderLiveServiceGrid(snap.services));
|
||
|
||
setText('live-impl-meta', `phase4: ${snap.telemetry?.rows_count ?? 0} rows`);
|
||
const implBody = document.getElementById('live-impl-body');
|
||
if (implBody) {
|
||
implBody.replaceChildren(renderLiveImplementerCard(
|
||
snap.implementer || {}, snap.telemetry || {}));
|
||
}
|
||
const revBody = document.getElementById('live-rev-body');
|
||
if (revBody) revBody.replaceChildren(renderLiveReviewerCard(snap.reviewer || {}));
|
||
|
||
setText('live-sessions-count',
|
||
`${(LIVE.sessionRows || []).length} session(s)`);
|
||
renderLiveSessionsPanel();
|
||
|
||
const infraBody = document.getElementById('live-infra-body');
|
||
if (infraBody) {
|
||
const i = snap.infrastructure || {};
|
||
const body = el('div', { class: 'live-card-body' });
|
||
const ikv = el('dl', { class: 'live-kv' });
|
||
function ikvRow(k, v) {
|
||
ikv.appendChild(el('dt', {}, k));
|
||
ikv.appendChild(el('dd', {}, v == null ? '—' : String(v)));
|
||
}
|
||
ikvRow('bare mirror age', fmtAge(i.bare_mirror_age_s));
|
||
ikvRow('bare mirror size', i.bare_mirror_size_bytes
|
||
? `${(i.bare_mirror_size_bytes / 1024 / 1024).toFixed(1)} MB`
|
||
: '—');
|
||
ikvRow('worktrees active', i.worktrees_active_count);
|
||
ikvRow('worker_infra age', fmtAge(i.worker_infra_seed_age_s));
|
||
body.appendChild(ikv);
|
||
infraBody.replaceChildren(body);
|
||
}
|
||
|
||
setText('live-errors-count',
|
||
`${(snap.recent_errors || []).length} buffered`);
|
||
const errBody = document.getElementById('live-errors-body');
|
||
if (errBody) errBody.replaceChildren(renderLiveErrors(snap.recent_errors));
|
||
|
||
// Feed: rows already live in LIVE.feedRows (refreshLive pushes them);
|
||
// just redraw so any filter/severity state stays current. The feed
|
||
// container's scroll position is preserved inside drawLiveFeed.
|
||
drawLiveFeed();
|
||
}
|
||
|
||
async function refreshLive() {
|
||
const runsResp = await api('/runs');
|
||
if (runsResp) {
|
||
renderLiveRunsList(runsResp.rows);
|
||
setText('live-runs-root', runsResp.runs_root || '?');
|
||
setText('live-runs-meta', `${runsResp.count} run(s)`);
|
||
// Auto-select most-recent live run on first load.
|
||
if (!LIVE.selectedRunId && runsResp.rows && runsResp.rows.length > 0) {
|
||
const firstLive = runsResp.rows.find(r => r.is_live) || runsResp.rows[0];
|
||
LIVE.selectedRunId = firstLive.run_id;
|
||
// Re-render to highlight the auto-selected row.
|
||
renderLiveRunsList(runsResp.rows);
|
||
}
|
||
}
|
||
if (!LIVE.selectedRunId) return;
|
||
// Snapshot for selected run.
|
||
const snap = await api(
|
||
`/snapshot?run=${encodeURIComponent(LIVE.selectedRunId)}`);
|
||
// Always re-render the panel so transient errors (e.g. snapshot
|
||
// briefly missing during writer restart) are visible immediately
|
||
// rather than waiting for the next tick.
|
||
renderLivePanel(LIVE.selectedRunId, snap);
|
||
// Incremental event tail.
|
||
const ev = await api(
|
||
`/events?run=${encodeURIComponent(LIVE.selectedRunId)}` +
|
||
`&since=${LIVE.lastEventTsMs}&limit=200`);
|
||
if (ev && ev.rows && ev.rows.length > 0) {
|
||
LIVE.feedRows.push(...ev.rows);
|
||
if (LIVE.feedRows.length > LIVE.feedMax) {
|
||
LIVE.feedRows.splice(0, LIVE.feedRows.length - LIVE.feedMax);
|
||
}
|
||
LIVE.lastEventTsMs = ev.next_since_ms || LIVE.lastEventTsMs;
|
||
drawLiveFeed();
|
||
}
|
||
// Session tree. Cheap server-side; one scan + a directory glob. Re-fetch
|
||
// every tick so the operator sees new sessions appear in near-real time.
|
||
const sessResp = await api(
|
||
`/api/run/sessions?run=${encodeURIComponent(LIVE.selectedRunId)}`);
|
||
if (sessResp && Array.isArray(sessResp.rows)) {
|
||
LIVE.sessionRows = sessResp.rows;
|
||
LIVE.sessionsRunId = LIVE.selectedRunId;
|
||
renderLiveSessionsPanel();
|
||
}
|
||
// If a session is selected, refresh its transcript on each tick too —
|
||
// running sessions grow new turns and the operator wants to see them
|
||
// without manually re-clicking.
|
||
if (LIVE.selectedSessionId) {
|
||
fetchSessionDetail();
|
||
}
|
||
}
|
||
|
||
PANES['live'] = {
|
||
intervalMs: 3000,
|
||
refresh() { refreshLive(); },
|
||
};
|
||
|
||
// ── boot ──────────────────────────────────────────────────────────────
|
||
|
||
showTab('overview');
|