Files
cleveragents-core/.opencode/telemetry/app.js
T
drew 2658deee94 feat(auto-agents): PR State Warmer substrate + supporting infra
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>
2026-05-17 10:02:53 -04:00

1886 lines
73 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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');