bytelyst-devops-tools/agent-queue/dashboard.mjs
Saravanakumar D 8a2270e0a6 feat(dashboard): surface manifest tags (priority/profile/caps/tracker) on the board
Render a per-job tags line on the RUNNING workers and JOBS lists showing the
routing inputs operators care about: priority, profile, capabilities, and the
tracker-item reference. Tags come from the launched meta, falling back to the
job's .md frontmatter for never-launched inbox jobs (new readManifest parser).
The tracker-item becomes a clickable terminal hyperlink when AQ_TRACKER_WEB is
set. Also renders the new budget_exceeded result as a failed RECENT row.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-30 19:27:41 -07:00

532 lines
22 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.

#!/usr/bin/env node
// agent-queue dashboard — a zero-dependency, INTERACTIVE TUI for the folder queue.
//
// Reads the same queue/ state written by agent-queue.sh and re-renders a board
// every interval: kanban counts, running workers (engine, elapsed, last log line),
// and a navigable, numbered job list you can act on without leaving the screen.
//
// Lifecycle: inbox → building → review → testing → shipped (+ failed)
//
// Interactive keys (when run in a TTY):
// ↑/↓ or j/k or 1-9 select a job enter / l view its log
// p promote s ship (testing→shipped) x reject
// u requeue r run loop S stop g refresh now
// ? help q / Ctrl-C quit
// All actions shell out to agent-queue.sh — it stays the single source of truth.
//
// Usage: node dashboard.mjs [--interval 2] [--root /path/to/queue]
// AGENT_QUEUE_ROOT=/path node dashboard.mjs
// AQ_TRACKER_WEB=https://tracker.example.com node dashboard.mjs
// (makes job tracker-item tags clickable terminal hyperlinks)
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync, spawn } from 'node:child_process';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// ── args / config ───────────────────────────────────────────────────
const argv = process.argv.slice(2);
const getArg = (flag, def) => {
const i = argv.indexOf(flag);
return i !== -1 && argv[i + 1] ? argv[i + 1] : def;
};
const ROOT = path.resolve(getArg('--root', process.env.AGENT_QUEUE_ROOT || path.join(__dirname, 'queue')));
const INTERVAL = Math.max(1, parseInt(getArg('--interval', '2'), 10)) * 1000;
// A running worker is flagged stalled if its log has not changed in this many minutes.
const STALL_MIN = Math.max(1, parseInt(process.env.AGENT_QUEUE_STALL_MIN || '10', 10));
const DIRS = {
inbox: path.join(ROOT, 'inbox'),
building: path.join(ROOT, 'building'),
review: path.join(ROOT, 'review'),
testing: path.join(ROOT, 'testing'),
shipped: path.join(ROOT, 'shipped'),
failed: path.join(ROOT, 'failed'),
logs: path.join(ROOT, 'logs'),
state: path.join(ROOT, '.state'),
};
// ── ansi ────────────────────────────────────────────────────────────
const C = {
reset: '\x1b[0m', dim: '\x1b[2m', bold: '\x1b[1m',
red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m',
blue: '\x1b[34m', cyan: '\x1b[36m', gray: '\x1b[90m',
};
const c = (col, s) => `${C[col]}${s}${C.reset}`;
// ── helpers ─────────────────────────────────────────────────────────
const listMd = (dir) => {
try { return fs.readdirSync(dir).filter((f) => f.endsWith('.md')); }
catch { return []; }
};
const count = (dir) => listMd(dir).length;
const parseMeta = (file) => {
const out = {};
try {
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
const i = line.indexOf('=');
if (i > 0) out[line.slice(0, i)] = line.slice(i + 1);
}
} catch { /* ignore */ }
return out;
};
// Compact per-job insights (read-only from meta; agent-queue.sh is the source of
// truth). Surfaces tokens or cost + attempts + line deltas for finished jobs.
const insightsTag = (m) => {
const parts = [];
if (m.attempts && m.attempts !== '1') parts.push(`x${m.attempts}`);
if (m.cost_usd) parts.push(`$${m.cost_usd}${m.usage_estimated ? '~' : ''}`);
else if (m.tokens_in || m.tokens_out) parts.push(`tok ${m.tokens_in || 0}/${m.tokens_out || 0}`);
if (m.lines_added || m.lines_deleted) parts.push(`+${m.lines_added || 0}/-${m.lines_deleted || 0}`);
if (m.duration_s) parts.push(`${m.duration_s}s`);
return parts.join(' ');
};
// Manifest tags (read-only): the routing inputs an operator cares about when
// scanning the board — priority, profile, capabilities, and a tracker-item
// reference. Rendered from a job's meta (launched jobs) or, for never-launched
// inbox jobs, parsed from the .md frontmatter (see readManifest). The
// tracker-item becomes a real terminal hyperlink when AQ_TRACKER_WEB is set.
const TRACKER_WEB = (process.env.AQ_TRACKER_WEB || '').replace(/\/+$/, '');
const osc8 = (url, label) => `\x1b]8;;${url}\x07${label}\x1b]8;;\x07`;
const trackerTag = (id) => {
if (!id) return '';
const label = `${id}`;
return TRACKER_WEB ? osc8(`${TRACKER_WEB}/${encodeURIComponent(id)}`, label) : label;
};
const PRIORITY_COLOR = { critical: 'red', high: 'yellow', medium: 'gray', low: 'gray' };
const manifestTags = (m) => {
if (!m) return '';
const parts = [];
if (m.priority && m.priority !== 'medium') {
parts.push(c(PRIORITY_COLOR[m.priority] || 'gray', `${m.priority}`));
}
if (m.profile) parts.push(c('blue', `${m.profile}`));
if (m.capabilities) {
const caps = String(m.capabilities).replace(/^\[|\]$/g, '').trim();
if (caps) parts.push(c('gray', `caps ${trunc(caps, 36)}`));
}
if (m.tracker_item) parts.push(c('cyan', trackerTag(m.tracker_item)));
return parts.join(' ');
};
const pidAlive = (pid) => {
if (!pid) return false;
try { process.kill(Number(pid), 0); return true; } catch { return false; }
};
const lastLogLine = (job) => {
try {
const txt = fs.readFileSync(path.join(DIRS.logs, `${job}.log`), 'utf8');
const lines = txt.split('\n').map((l) => l.trim()).filter(Boolean);
return lines.length ? lines[lines.length - 1] : '';
} catch { return ''; }
};
// seconds since a job's log was last modified (no new agent output); null if no log
const logAgeSec = (job) => {
try {
const mt = fs.statSync(path.join(DIRS.logs, `${job}.log`)).mtimeMs;
return Math.max(0, Math.floor((Date.now() - mt) / 1000));
} catch { return null; }
};
const fmtElapsed = (startSec) => {
if (!startSec) return ' -- ';
const s = Math.max(0, Math.floor(Date.now() / 1000) - Number(startSec));
const m = Math.floor(s / 60);
const h = Math.floor(m / 60);
if (h > 0) return `${h}h${String(m % 60).padStart(2, '0')}m`;
return `${m}m${String(s % 60).padStart(2, '0')}s`;
};
const trunc = (s, n) => (s.length > n ? s.slice(0, n - 1) + '…' : s);
const shortPath = (p) => (p || '').replace(process.env.HOME || '~', '~');
const readMetas = () => {
let files = [];
try { files = fs.readdirSync(DIRS.state).filter((f) => f.endsWith('.meta')); }
catch { /* ignore */ }
return files.map((f) => parseMeta(path.join(DIRS.state, f)));
};
// readManifest(stage, job) — manifest tags for a job that has no launched meta
// yet (e.g. queued in inbox/). Parses the leading --- frontmatter block of the
// job's .md and maps the few fields manifestTags renders. Never throws.
const FM_TAG_KEYS = {
priority: 'priority', profile: 'profile',
capabilities: 'capabilities', 'tracker-item': 'tracker_item',
};
const readManifest = (stage, job) => {
const out = {};
try {
const lines = fs.readFileSync(path.join(DIRS[stage], `${job}.md`), 'utf8').split('\n');
if ((lines[0] || '').trim() !== '---') return out;
for (let i = 1; i < lines.length; i++) {
if (lines[i].trim() === '---') break;
const line = lines[i].replace(/^\s+/, '');
const ci = line.indexOf(':');
if (ci <= 0) continue;
const key = line.slice(0, ci).trim();
if (!FM_TAG_KEYS[key]) continue;
out[FM_TAG_KEYS[key]] = line.slice(ci + 1).trim().replace(/^["']|["']$/g, '');
}
} catch { /* ignore */ }
return out;
};
// ── agent-queue.sh control (single source of truth) ─────────────────
const AQ = path.join(__dirname, 'agent-queue.sh');
const stripAnsi = (s) => (s || '').replace(/\x1b\[[0-9;]*m/g, '');
const lastLine = (s) => {
const lines = stripAnsi(s).split('\n').map((l) => l.trim()).filter(Boolean);
return lines.length ? lines[lines.length - 1] : '';
};
// aq(args) — run an agent-queue.sh subcommand, capturing output (never throws).
const aq = (args) => {
try {
const out = execFileSync('bash', [AQ, ...args], {
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, AGENT_QUEUE_ROOT: ROOT },
});
return { ok: true, out };
} catch (e) {
return { ok: false, out: ((e.stdout || '') + (e.stderr || '') || e.message || '').toString() };
}
};
// daemonPid() — pid of a live `run` loop, or null.
const daemonPid = () => {
try {
const pid = fs.readFileSync(path.join(DIRS.state, 'daemon.pid'), 'utf8').trim();
return pid && pidAlive(pid) ? pid : null;
} catch { return null; }
};
// startRun() — spawn a detached `run` loop writing to logs/run-loop.log.
const startRun = () => {
if (daemonPid()) { setFlash(c('yellow', 'run loop already active')); return; }
try {
const fd = fs.openSync(path.join(DIRS.logs, 'run-loop.log'), 'a');
const child = spawn('bash', [AQ, 'run'], {
detached: true, stdio: ['ignore', fd, fd],
env: { ...process.env, AGENT_QUEUE_ROOT: ROOT },
});
child.unref();
setFlash(c('green', `▶ run loop started (max ${process.env.AGENT_QUEUE_MAX || 3})`));
} catch (e) { setFlash(c('red', `run failed: ${e.message}`)); }
};
// ── interactive state ───────────────────────────────────────────────
const INTERACTIVE = !!process.stdin.isTTY;
const ACTION_STAGES = ['review', 'testing', 'failed', 'inbox'];
let mode = 'board'; // 'board' | 'log' | 'help' | 'confirm'
let items = []; // actionable jobs, rebuilt each draw
let selIdx = 0; // selected index into items
let selJob = null; // selected job name (stable across refreshes)
let flash = ''; // transient status message
let flashUntil = 0;
let logJob = null; // job whose log is being viewed
let confirmAction = null; // { verb, job, run }
const setFlash = (msg, ms = 4000) => { flash = msg; flashUntil = Date.now() + ms; };
const flashLine = () => (flash && Date.now() < flashUntil ? flash : '');
const buildItems = () => {
const list = [];
for (const st of ACTION_STAGES) {
for (const f of listMd(DIRS[st]).sort()) list.push({ stage: st, job: f.replace(/\.md$/, '') });
}
return list;
};
const syncSelection = () => {
if (selJob) {
const i = items.findIndex((it) => it.job === selJob);
if (i >= 0) { selIdx = i; return; }
}
selIdx = Math.max(0, Math.min(selIdx, items.length - 1));
selJob = items[selIdx]?.job ?? null;
};
const STAGE_TAG = {
review: () => c('cyan', '[review ]'),
testing: () => c('cyan', '[testing]'),
failed: () => c('red', '[failed ]'),
inbox: () => c('blue', '[inbox ]'),
};
// gate(verb, stage) — is this action valid for a job in this stage?
const gate = (verb, stage) => ({
promote: stage === 'review' || stage === 'testing',
ship: stage === 'testing',
reject: stage === 'review' || stage === 'testing',
requeue: stage === 'failed' || stage === 'review' || stage === 'testing',
logs: true,
}[verb]);
// doAction(verb) — run the gated action on the selected job via agent-queue.sh.
const doAction = (verb) => {
const it = items[selIdx];
if (!it) { setFlash(c('gray', 'no job selected')); return; }
if (!gate(verb, it.stage)) { setFlash(c('gray', `${verb} not valid for a ${it.stage} job`)); return; }
if ((verb === 'reject' || verb === 'requeue') && mode !== 'confirm') {
confirmAction = { verb, job: it.job, run: () => doAction(verb) };
mode = 'confirm';
return;
}
const r = aq([verb, it.job]);
setFlash((r.ok ? c('green', '✓ ') : c('red', '✗ ')) + (lastLine(r.out) || `${verb} ${it.job}`));
mode = 'board'; confirmAction = null;
};
// ── render ──────────────────────────────────────────────────────────
const ENGINE_COLOR = { devin: 'cyan', claude: 'yellow', codex: 'green' };
function drawBoard() {
const metas = readMetas();
const metaByJob = Object.fromEntries(metas.filter((m) => m.job).map((m) => [m.job, m]));
const running = metas.filter((m) => !m.ended && pidAlive(m.pid));
const finished = metas
.filter((m) => m.ended)
.sort((a, b) => Number(b.ended) - Number(a.ended));
const counts = {
inbox: count(DIRS.inbox), building: count(DIRS.building),
review: count(DIRS.review), testing: count(DIRS.testing),
shipped: count(DIRS.shipped), failed: count(DIRS.failed),
};
// rebuild actionable list + keep selection stable
items = buildItems();
syncSelection();
const loop = daemonPid();
const out = [];
out.push('');
out.push(` ${C.bold}AGENT QUEUE${C.reset} ${c('gray', ROOT)}`);
out.push(
` ${c('gray', new Date().toLocaleTimeString())} refresh ${INTERVAL / 1000}s ` +
(loop ? c('green', `● run loop pid ${loop}`) : c('gray', '○ run loop stopped')) +
` ${c('gray', INTERACTIVE ? 'press ? for help' : 'read-only')}`
);
out.push('');
out.push(
` ${c('blue', '▢ inbox')} ${String(counts.inbox).padEnd(3)}` +
` ${c('yellow', '◧ building')} ${String(counts.building).padEnd(3)}` +
` ${c('cyan', '◔ review')} ${String(counts.review).padEnd(3)}` +
` ${c('cyan', '◕ testing')} ${String(counts.testing).padEnd(3)}` +
` ${c('green', '▣ shipped')} ${String(counts.shipped).padEnd(3)}` +
` ${c('red', '✕ failed')} ${String(counts.failed).padEnd(3)}` +
` ${C.bold}running ${running.length}${C.reset}`
);
out.push('');
// running table
out.push(` ${C.bold}RUNNING${C.reset}`);
if (running.length === 0) {
out.push(` ${c('dim', 'no workers running')}`);
} else {
for (const m of running) {
const eng = m.engine || '?';
const engC = ENGINE_COLOR[eng] || 'gray';
const age = logAgeSec(m.job);
const stalled = age !== null && age > STALL_MIN * 60;
out.push(
` ${c('bold', trunc(m.job || '?', 30).padEnd(30))} ` +
`${c(engC, eng.padEnd(7))} ` +
`${fmtElapsed(m.started).padStart(7)} ` +
`${c('gray', 'pid ' + (m.pid || '?'))}` +
`${stalled ? ' ' + c('red', '⚠ stalled') : ''}`
);
out.push(` ${c('dim', trunc(shortPath(m.cwd || ''), 70))}`);
const mtags = manifestTags(m);
if (mtags) out.push(` ${mtags}`);
const last = lastLogLine(m.job);
if (last) out.push(` ${c('cyan', ' ')}${c('dim', trunc(last, 70))}`);
}
}
out.push('');
// actionable job list (numbered + selectable)
out.push(` ${C.bold}JOBS${C.reset} ${c('gray', '(review · testing · failed · inbox)')}`);
if (items.length === 0) {
out.push(` ${c('dim', 'no actionable jobs')}`);
} else {
items.forEach((it, i) => {
const sel = i === selIdx;
const ptr = sel ? c('cyan', '▶') : ' ';
const num = c('gray', String(i + 1).padStart(2) + '.');
const tag = (STAGE_TAG[it.stage] || (() => `[${it.stage}]`))();
const name = sel ? `${C.bold}${trunc(it.job, 46)}${C.reset}` : trunc(it.job, 46);
out.push(` ${ptr} ${num} ${tag} ${name}`);
const jtags = manifestTags(metaByJob[it.job] || readManifest(it.stage, it.job));
if (jtags) out.push(` ${jtags}`);
});
}
out.push('');
// recent finished
out.push(` ${C.bold}RECENT${C.reset}`);
const recent = finished.slice(0, 5);
if (recent.length === 0) {
out.push(` ${c('dim', 'nothing finished yet')}`);
} else {
for (const m of recent) {
const res = m.result || '';
const failedRes = res === 'failed' || res === 'timeout' || res === 'verify_failed' ||
res === 'rejected' || res === 'retries_exhausted' || res === 'capability_mismatch' ||
res === 'budget_exceeded' || res === 'no_engine';
const mark = failedRes ? c('red', '✕') : c('green', '▣');
const when = m.ended ? new Date(Number(m.ended) * 1000).toLocaleTimeString() : '';
let label;
if (res === 'shipped') label = c('green', 'shipped');
else if (res === 'testing') label = c('cyan', 'testing (QA)');
else if (res === 'review') label = c('cyan', 'review');
else if (res === 'verify_failed') label = c('red', 'verify failed');
else if (res === 'timeout') label = c('red', 'timeout');
else if (res === 'budget_exceeded') label = c('red', 'budget exceeded');
else if (res === 'rejected') label = c('red', 'rejected');
else if (res === 'retries_exhausted') label = c('red', 'retries exhausted');
else if (res === 'failed') label = c('red', 'failed rc=' + (m.exit || '?'));
else label = c('gray', res || '?');
out.push(
` ${mark} ${trunc(m.job || '?', 34).padEnd(34)} ` +
`${c('gray', (m.engine || '').padEnd(7))} ` +
`${label} ${c('gray', when)} ${c('cyan', insightsTag(m))}`
);
}
}
out.push('');
// flash + footer
const fl = flashLine();
if (fl) out.push(` ${fl}`);
if (mode === 'confirm' && confirmAction) {
out.push(` ${c('yellow', `${confirmAction.verb} "${confirmAction.job}" ? `)}${C.bold}y${C.reset}${c('gray', '/')}${C.bold}n${C.reset}`);
} else if (INTERACTIVE) {
out.push(c('gray', ' ↑/↓ select · enter logs · p promote · s ship · x reject · u requeue'));
out.push(c('gray', ' r run · S stop · g refresh · ? help · q quit'));
}
process.stdout.write('\x1b[2J\x1b[H' + out.join('\n') + '\n');
}
function drawLog() {
const rows = (process.stdout.rows || 30) - 6;
let body = `no log for ${logJob}`;
try {
const txt = fs.readFileSync(path.join(DIRS.logs, `${logJob}.log`), 'utf8');
body = txt.split('\n').slice(-rows).join('\n');
} catch { /* keep default */ }
const head = ` ${C.bold}LOG${C.reset} ${c('cyan', logJob)} ${c('gray', 'q/esc back · g refresh')}`;
process.stdout.write('\x1b[2J\x1b[H' + head + '\n' + c('gray', ' ' + '─'.repeat(60)) + '\n' + body + '\n');
}
function drawHelp() {
const L = [
'', ` ${C.bold}AGENT QUEUE — keys${C.reset}`, '',
` ${c('cyan', '↑/↓, j/k, 1-9')} select a job in the JOBS list`,
` ${c('cyan', 'enter / l')} view the selected job's log (live)`,
` ${c('cyan', 'p')} promote (review → testing → shipped)`,
` ${c('cyan', 's')} ship (testing/QA → shipped, the manual gate)`,
` ${c('cyan', 'x')} reject (review/testing → failed) ${c('gray', '[confirm]')}`,
` ${c('cyan', 'u')} requeue (failed/review/testing → inbox) ${c('gray', '[confirm]')}`,
'',
` ${c('cyan', 'r')} start the run loop (detached, max ${process.env.AGENT_QUEUE_MAX || 3})`,
` ${c('cyan', 'S')} stop the run loop + running workers`,
` ${c('cyan', 'g')} refresh now`,
` ${c('cyan', '? / h')} toggle this help`,
` ${c('cyan', 'q / Ctrl-C')} quit`,
'',
` ${c('gray', 'Lifecycle: inbox → building → review → testing → shipped (+ failed)')}`,
` ${c('gray', 'auto: rc=0 → review; verify pass → testing; verify fail → failed')}`,
` ${c('gray', 'manual: ship (testing → shipped)')}`,
'', ` ${c('gray', 'press any key to return')}`, '',
];
process.stdout.write('\x1b[2J\x1b[H' + L.join('\n') + '\n');
}
const draw = () => {
if (mode === 'log') drawLog();
else if (mode === 'help') drawHelp();
else drawBoard();
};
// ── main loop + key handling ────────────────────────────────────────
if (!fs.existsSync(ROOT)) {
process.stdout.write(`agent-queue: queue root not found: ${ROOT}\nRun \`agent-queue.sh init\` first.\n`);
process.exit(1);
}
draw();
const timer = setInterval(draw, INTERVAL);
const quit = () => {
clearInterval(timer);
try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch { /* noop */ }
process.stdout.write(C.reset + '\n');
process.exit(0);
};
const moveSel = (delta) => {
if (items.length === 0) return;
selIdx = (selIdx + delta + items.length) % items.length;
selJob = items[selIdx]?.job ?? null;
};
function onKey(key) {
// global quit
if (key === '\u0003') return quit(); // Ctrl-C always quits
if (mode === 'help') { mode = 'board'; return draw(); }
if (mode === 'log') {
if (key === 'q' || key === '\u001b' || key === '\r' || key === '\n') { mode = 'board'; logJob = null; }
else if (key === 'g') { /* fallthrough to redraw */ }
return draw();
}
if (mode === 'confirm') {
if (key === 'y' || key === 'Y') confirmAction?.run();
else { mode = 'board'; confirmAction = null; setFlash(c('gray', 'cancelled')); }
return draw();
}
// board mode
switch (key) {
case 'q': return quit();
case '?': case 'h': mode = 'help'; break;
case 'g': break; // just redraw
case 'j': case '\u001b[B': moveSel(1); break;
case 'k': case '\u001b[A': moveSel(-1); break;
case '\r': case '\n': case 'l':
if (items[selIdx]) { logJob = items[selIdx].job; mode = 'log'; }
break;
case 'p': doAction('promote'); break;
case 's': doAction('ship'); break;
case 'x': doAction('reject'); break;
case 'u': doAction('requeue'); break;
case 'r': startRun(); break;
case 'S': { const res = aq(['stop']); setFlash(c('red', '■ ') + (lastLine(res.out) || 'stopped')); break; }
default:
if (/^[1-9]$/.test(key)) {
const i = parseInt(key, 10) - 1;
if (i < items.length) { selIdx = i; selJob = items[i].job; }
} else { return; } // ignore unknown keys (no redraw)
}
draw();
}
if (INTERACTIVE) {
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.setEncoding('utf8');
process.stdin.on('data', onKey);
}
process.on('SIGINT', quit);
process.on('SIGTERM', quit);