bytelyst-devops-tools/agent-queue/dashboard.mjs
saravanakumardb1 af1bc6904e feat(agent-queue): build/ship lifecycle with auto-QA verify gate + manual ship
Redesign the kanban runner stages from inbox->doing->done/failed to
inbox->building->review->testing->shipped (+ failed):

- worker: agent rc=0 lands in review/, then runs the configurable
  verify command (frontmatter verify: / AGENT_QUEUE_VERIFY) in cwd;
  pass -> testing/ (QA), fail -> failed/, none -> parks in review/
- new commands: ship (testing->shipped, manual gate), promote
  (advance one stage), reject (review/testing->failed); requeue now
  also pulls from review/testing
- status + dashboard.mjs render all six stages; RECENT panel labels
  shipped/testing/review/verify_failed/timeout/rejected
- README: new lifecycle diagram, verify: frontmatter, result= glossary,
  command table + folder layout
- selftest: assert no-verify->review, verify-pass->testing->ship->shipped,
  verify-fail->failed
- rename queue/doing->building, queue/done->review; add testing/ shipped/
2026-05-29 16:03:01 -07:00

224 lines
8.6 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 live 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 the most recent shipped/failed jobs.
//
// Lifecycle: inbox → building → review → testing → shipped (+ failed)
//
// Usage: node dashboard.mjs [--interval 2] [--root /path/to/queue]
// AGENT_QUEUE_ROOT=/path node dashboard.mjs
// Quit: q or Ctrl-C
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
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;
};
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)));
};
// ── render ──────────────────────────────────────────────────────────
const ENGINE_COLOR = { devin: 'cyan', claude: 'yellow', codex: 'green' };
function render() {
const metas = readMetas();
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),
};
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 ${c('gray', 'press q to quit')}`);
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;
const line =
` ${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(line);
out.push(` ${c('dim', trunc(shortPath(m.cwd || ''), 70))}`);
const last = lastLogLine(m.job);
if (last) out.push(` ${c('cyan', ' ')}${c('dim', trunc(last, 70))}`);
}
}
out.push('');
// recent finished
out.push(` ${C.bold}RECENT${C.reset}`);
const recent = finished.slice(0, 6);
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';
const ok = !failedRes;
const mark = ok ? c('green', '▣') : c('red', '✕');
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 === 'rejected') label = c('red', 'rejected');
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)}`
);
}
}
out.push('');
// clear + paint
process.stdout.write('\x1b[2J\x1b[H' + out.join('\n') + '\n');
}
// ── 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);
}
render();
const timer = setInterval(render, 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);
};
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.setEncoding('utf8');
process.stdin.on('data', (key) => {
if (key === 'q' || key === '\u0003') quit(); // q or Ctrl-C
});
}
process.on('SIGINT', quit);
process.on('SIGTERM', quit);