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/
224 lines
8.6 KiB
JavaScript
224 lines
8.6 KiB
JavaScript
#!/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);
|