feat(agent-queue): interactive dashboard — navigable job list + single-key actions

Turn dash into a menu-driven control panel (single mjs script):
- numbered, arrow/j-k/1-9 selectable JOBS list (review/testing/failed/inbox)
- single-key actions wired to agent-queue.sh (single source of truth):
  p promote, s ship, x reject, u requeue (reject/requeue confirm y/n)
- enter/l opens a live log viewer; r starts a detached run loop, S stops it
- run-loop pid indicator, transient action flashes, ? help overlay
- non-TTY falls back to the read-only live view
- README: dash command + interactive key table
This commit is contained in:
saravanakumardb1 2026-05-29 16:19:23 -07:00
parent 4ed4d75a67
commit dde677f4b9
2 changed files with 274 additions and 19 deletions

View File

@ -112,7 +112,7 @@ misparsed as a flag.
| `run [--max N] [--engine E] [--once]` | process the inbox (foreground loop) |
| `status` | kanban counts + running-worker table (marks `⚠ stalled` workers) |
| `watch [interval]` | live `status` (bash), redrawn every N seconds (default 2) |
| `dash [--interval N]` | richer **Node** live dashboard — running workers (engine, elapsed, last log line, stall) + recent shipped/failed |
| `dash [--interval N]` | **interactive Node dashboard** — navigable numbered job list with single-key actions (see below) |
| `stop` | kill running workers + the run loop |
| `logs <job> [-f]` | print / follow a job's log |
| `promote <job>` | advance one stage forward: `review → testing → shipped` |
@ -124,6 +124,29 @@ misparsed as a flag.
Only one `run` loop may be active per queue — a second `run` against the same
queue is refused while the first is alive (a stale `daemon.pid` is cleared).
### Interactive dashboard (`dash`)
`dash` is a single-script, menu-driven control panel (think a tiny "glassbox").
It shows the kanban counts, live RUNNING workers (engine, elapsed, last log
line, stall), a **navigable numbered JOBS list**, and RECENT finished jobs — and
lets you act on jobs without leaving the screen. Every action shells out to
`agent-queue.sh`, so the script stays the single source of truth.
| Key | Action |
| --- | ------ |
| `↑`/`↓`, `j`/`k`, `1``9` | select a job in the JOBS list |
| `enter` / `l` | view the selected job's log (live, auto-refreshing) |
| `p` | promote (`review → testing → shipped`) |
| `s` | ship (`testing`/QA → `shipped`, the manual gate) |
| `x` | reject (`review`/`testing` → `failed`) — asks `y/n` |
| `u` | requeue (`failed`/`review`/`testing` → `inbox`) — asks `y/n` |
| `r` | start the `run` loop (detached → `logs/run-loop.log`) |
| `S` | stop the run loop + running workers |
| `g` | refresh now · `?`/`h` help · `q`/`Ctrl-C` quit |
The header shows a `● run loop pid N` / `○ run loop stopped` indicator. Run it in
a TTY for the interactive mode; piped/non-TTY it falls back to a read-only live view.
## Via `bytelyst-cli.sh`
Wired into the repo's unified CLI (no GitHub token required for this subcommand):

View File

@ -1,19 +1,26 @@
#!/usr/bin/env node
// agent-queue dashboard — a zero-dependency live TUI for the folder queue.
// 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 the most recent shipped/failed jobs.
// 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
// Quit: q or Ctrl-C
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));
@ -105,10 +112,116 @@ const readMetas = () => {
return files.map((f) => parseMeta(path.join(DIRS.state, f)));
};
// ── 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 render() {
function drawBoard() {
const metas = readMetas();
const running = metas.filter((m) => !m.ended && pidAlive(m.pid));
const finished = metas
@ -121,10 +234,19 @@ function render() {
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 ${c('gray', 'press q to quit')}`);
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)}` +
@ -147,13 +269,13 @@ function render() {
const engC = ENGINE_COLOR[eng] || 'gray';
const age = logAgeSec(m.job);
const stalled = age !== null && age > STALL_MIN * 60;
const line =
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(line);
`${stalled ? ' ' + c('red', '⚠ stalled') : ''}`
);
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))}`);
@ -161,17 +283,32 @@ function render() {
}
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}`);
});
}
out.push('');
// recent finished
out.push(` ${C.bold}RECENT${C.reset}`);
const recent = finished.slice(0, 6);
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';
const ok = !failedRes;
const mark = ok ? c('green', '▣') : c('red', '✕');
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');
@ -191,18 +328,68 @@ function render() {
}
out.push('');
// clear + paint
// 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);
}
render();
const timer = setInterval(render, INTERVAL);
draw();
const timer = setInterval(draw, INTERVAL);
const quit = () => {
clearInterval(timer);
@ -211,13 +398,58 @@ const quit = () => {
process.exit(0);
};
if (process.stdin.isTTY) {
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', (key) => {
if (key === 'q' || key === '\u0003') quit(); // q or Ctrl-C
});
process.stdin.on('data', onKey);
}
process.on('SIGINT', quit);
process.on('SIGTERM', quit);