diff --git a/agent-queue/README.md b/agent-queue/README.md index e6cf8a5..fdac26c 100644 --- a/agent-queue/README.md +++ b/agent-queue/README.md @@ -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 [-f]` | print / follow a job's log | | `promote ` | 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): diff --git a/agent-queue/dashboard.mjs b/agent-queue/dashboard.mjs index aaf9c31..4ca5ba4 100644 --- a/agent-queue/dashboard.mjs +++ b/agent-queue/dashboard.mjs @@ -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);