Mark a running worker '⚠ stalled' when its log has not changed for more than AGENT_QUEUE_STALL_MIN minutes (default 10), using log mtime as the freshness signal. Implemented in both the bash status table and the Node dashboard.
206 lines
7.6 KiB
JavaScript
206 lines
7.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 done/failed jobs.
|
||
//
|
||
// 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'),
|
||
doing: path.join(ROOT, 'doing'),
|
||
done: path.join(ROOT, 'done'),
|
||
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), doing: count(DIRS.doing),
|
||
done: count(DIRS.done), 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', '◧ doing')} ${String(counts.doing).padEnd(3)}` +
|
||
` ${c('green', '▣ done')} ${String(counts.done).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 ok = m.result === 'done';
|
||
const mark = ok ? c('green', '▣') : c('red', '✕');
|
||
const when = m.ended ? new Date(Number(m.ended) * 1000).toLocaleTimeString() : '';
|
||
out.push(
|
||
` ${mark} ${trunc(m.job || '?', 34).padEnd(34)} ` +
|
||
`${c('gray', (m.engine || '').padEnd(7))} ` +
|
||
`${ok ? c('green', 'done') : c('red', 'failed rc=' + (m.exit || '?'))} ${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);
|