/** * `shannon status ` — one scan's live progress from Temporal. * * While the scan runs, polls Temporal and redraws the phase/agent tree on a * terminal (a pipe or a finished scan gets a single frame). When the scan reaches * a terminal state, prints the overall result and exits. Reads Temporal directly — * no worker, no session files — so it needs Temporal up and shows scans within its * ~24h retention window. */ import { setTimeout as sleep } from 'node:timers/promises'; import { fail } from '../errors.js'; import { isLocal } from '../mode.js'; import { type RenderInput, renderScan } from '../scan/render.js'; import { toStatusJson } from '../scan/status-json.js'; import { resolveWorkflowId } from '../session.js'; import { displaySplash } from '../splash.js'; import { describeScan, getTerminalOutcome, queryProgress, type ScanDescription } from '../temporal-client.js'; import { stdoutIsTerminal, supportsColor } from '../tty.js'; import { getVersion } from '../version.js'; const HIDE_CURSOR = '\x1b[?25l'; const SHOW_CURSOR = '\x1b[?25h'; /** Redraw cadence for the spinner animation; data is refreshed on the slower poll. */ const RENDER_MS = 120; const POLL_MS = 1200; /** Terminal = anything other than an open, running execution. */ function isTerminalStatus(status: string): boolean { return status !== 'RUNNING' && status !== 'UNSPECIFIED'; } // Match SGR color escapes (ESC[…m) so a line's on-screen width excludes them. Built from the ESC // char code so the source carries no literal control character. const ANSI_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'); /** * Physical terminal rows a frame occupies, so the live redraw moves the cursor up by the right * amount. A line wider than the terminal wraps onto extra rows, so counting logical lines alone * undercounts and the redraw drifts downward. Color escapes don't take screen columns, so strip them. */ function physicalRows(frame: string): number { const columns = process.stdout.columns || 80; return frame.split('\n').reduce((rows, line) => { const width = line.replace(ANSI_PATTERN, '').length; return rows + Math.max(1, Math.ceil(width / columns)); }, 0); } function exitCodeFor(input: RenderInput): number { if (input.temporalStatus === 'FAILED' || input.temporalStatus === 'TIMED_OUT') return 1; if (input.state?.status === 'failed') return 1; return 0; } /** Live view of a running scan: its progress query plus the in-flight agents from describe. */ async function buildRunningInput(workspace: string, workflowId: string, desc: ScanDescription): Promise { const state = await queryProgress(workflowId); return { workspace, workflowId, temporalStatus: desc.status, state, running: desc.runningAgents, ...(desc.startedAt !== undefined && { startedAt: desc.startedAt }), }; } /** Final view of a closed scan: its result (or the failure) plus timing from describe. */ async function buildTerminalInput(workspace: string, workflowId: string, desc: ScanDescription): Promise { const outcome = await getTerminalOutcome(workflowId); const timing = { ...(desc.startedAt !== undefined && { startedAt: desc.startedAt }), ...(desc.closedAt !== undefined && { endedAt: desc.closedAt }), }; if (outcome.kind === 'success') { return { workspace, workflowId, temporalStatus: desc.status, state: outcome.state, running: [], ...timing }; } return { workspace, workflowId, temporalStatus: desc.status, state: null, running: [], failureMessage: outcome.message, ...timing, }; } function printFrame(input: RenderInput): void { const frame = renderScan(input, { now: Date.now(), color: supportsColor(), unicode: stdoutIsTerminal(), live: false, frame: 0, }); process.stdout.write(`${frame}\n`); } /** * Poll Temporal and redraw until the scan reaches a terminal state, then print the * final frame and exit. A fast ticker animates the running spinner off the cached * snapshot; the network poll refreshes that snapshot on a slower cadence. */ async function watch(workspace: string, workflowId: string): Promise { let prevRows = 0; let frame = 0; let cached: RenderInput | null = null; const draw = (input: RenderInput, live: boolean): void => { const out = renderScan(input, { now: Date.now(), color: supportsColor(), unicode: true, live, frame }); if (prevRows > 0) process.stdout.write(`\x1b[${prevRows}A\x1b[0J`); process.stdout.write(`${out}\n`); prevRows = physicalRows(out); }; process.on('exit', () => process.stdout.write(SHOW_CURSOR)); process.on('SIGINT', () => { process.stdout.write('\n'); process.exit(0); }); process.stdout.write(HIDE_CURSOR); const ticker = setInterval(() => { frame++; if (cached) draw(cached, true); }, RENDER_MS); for (;;) { const desc = await describeScan(workflowId); if (!desc) { clearInterval(ticker); fail(`Scan "${workspace}" is no longer in Temporal.`); } if (isTerminalStatus(desc.status)) { clearInterval(ticker); const input = await buildTerminalInput(workspace, workflowId, desc); draw(input, false); process.exit(exitCodeFor(input)); } cached = await buildRunningInput(workspace, workflowId, desc); await sleep(POLL_MS); } } /** Read one point-in-time snapshot from Temporal: the terminal result if closed, else live progress. */ async function snapshot(workspace: string, workflowId: string, desc: ScanDescription): Promise { return isTerminalStatus(desc.status) ? buildTerminalInput(workspace, workflowId, desc) : buildRunningInput(workspace, workflowId, desc); } export async function status(workspace: string, opts: { readonly json: boolean }): Promise { // A resume spawns a new workflow id (recorded in session.json); resolve through there so status // follows the current resume, not the superseded original. Fresh scans: the name is the id. const workflowId = resolveWorkflowId(workspace) ?? workspace; let desc: ScanDescription | null; try { desc = await describeScan(workflowId); } catch { fail('Could not reach Temporal at 127.0.0.1:7233.', 'Start Temporal (it comes up with a scan) and try again.'); } if (!desc) { fail( `No scan found for "${workspace}".`, '', 'Scans are visible while running and for ~24h after they finish (Temporal retention).', ); } // --json is always a single snapshot then exit, even on a TTY — it never enters the live watch loop. if (opts.json) { const input = await snapshot(workspace, workflowId, desc); process.stdout.write(`${JSON.stringify(toStatusJson(input, Date.now()), null, 2)}\n`); process.exit(exitCodeFor(input)); } // Human-facing views open with the splash; skip it off a real terminal so piped output stays clean. if (stdoutIsTerminal()) { displaySplash(isLocal() ? undefined : getVersion()); } // A finished scan, or output that isn't a live terminal, gets a single frame. if (isTerminalStatus(desc.status) || !stdoutIsTerminal()) { const input = await snapshot(workspace, workflowId, desc); printFrame(input); process.exit(exitCodeFor(input)); } await watch(workspace, workflowId); }