Files
shannon/apps/cli/src/commands/logs.ts
T
george-keygraphandClaude Opus 4.8 d017d74519 feat(cli): sunset wordmark and section hierarchy in terminal output
Add a section-chrome module (chrome.ts) and a log decorator
(log-render.ts) that give the CLI a coherent visual hierarchy beneath
the SHANNON splash, all drawn from the existing sunset ramp:

- splash.ts now sources the ramp from chrome.ts (one definition)
- start's launch info gets a solid-yellow rule under each section
  label, with aligned Label: values greyed
- streaming scan logs (logs, and start --follow) get one per-phase
  gutter bar that walks the ramp, dimmed timestamps, red on [ERROR],
  and the end-of-run summary framed in a corner panel

Named chrome.ts to avoid the existing ui.ts (spinner/step helpers).
Presentation only: the worker and workflow.log are untouched, the log
on disk stays plain text (tail/grep and the failure marker keep
working), and non-TTY / NO_COLOR output is byte-identical to before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 10:19:30 -07:00

184 lines
6.8 KiB
TypeScript

/**
* `shannon logs` command — tail a scan's live log.
*
* The log file is streamed for its content; completion is decided by Temporal (the
* workflow's status), so a worker that dies mid-run can't leave the tail hanging. Uses
* chokidar for reliable cross-platform file watching and bounded synchronous reads to
* prevent duplicate output.
*/
import fs from 'node:fs';
import path from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
import { watch } from 'chokidar';
import { field } from '../chrome.js';
import { fail } from '../errors.js';
import { getWorkspacesDir } from '../home.js';
import { LogRenderer } from '../log-render.js';
import { resolveRunFile } from '../paths.js';
import { resolveWorkflowId } from '../session.js';
import { waitForWorkflowClose } from '../temporal-client.js';
import { stdoutIsTerminal } from '../tty.js';
/** Read a byte range from a file and return it as a UTF-8 string. */
function readRange(filePath: string, start: number, end: number): string {
const length = end - start;
const buffer = Buffer.alloc(length);
const fd = fs.openSync(filePath, 'r');
try {
fs.readSync(fd, buffer, 0, length, start);
} finally {
fs.closeSync(fd);
}
return buffer.toString('utf-8');
}
/** Resolve a workspace ID to its workflow.log path, or exit with an error. */
export function resolveLogFile(workspaceId: string): string {
const workspacesDir = getWorkspacesDir();
// 1. Direct match
const directPath = resolveRunFile(path.join(workspacesDir, workspaceId), 'workflow.log');
if (fs.existsSync(directPath)) return directPath;
// 2. Resume workflow ID (e.g. workspace_resume_123)
const resumeBase = workspaceId.replace(/_resume_\d+$/, '');
if (resumeBase !== workspaceId) {
const resumePath = resolveRunFile(path.join(workspacesDir, resumeBase), 'workflow.log');
if (fs.existsSync(resumePath)) return resumePath;
}
// 3. Named workspace ID (e.g. workspace_shannon-123)
const namedBase = workspaceId.replace(/_shannon-\d+$/, '');
if (namedBase !== workspaceId) {
const namedPath = resolveRunFile(path.join(workspacesDir, namedBase), 'workflow.log');
if (fs.existsSync(namedPath)) return namedPath;
}
fail(
`No scan found named: ${workspaceId}`,
'',
'Possible causes:',
" - The scan hasn't started yet",
' - The workspace name is incorrect',
'',
'Check the dashboard at http://localhost:8233 for scan details',
);
}
export interface TailOptions {
/** Workflow whose Temporal status decides when the tail stops. Without it, only Ctrl-C ends the tail. */
readonly workflowId?: string;
/** Called if the tail ends because Temporal became unreachable, with the captured error. */
readonly onUnreachable?: (lastError: string) => void;
}
/** Outcome of a tail: whether the streamed log already contained the worker's `Scan FAILED` block. */
export interface TailResult {
readonly sawFailure: boolean;
}
// The worker writes this exact line at the head of its terminal failure summary.
const FAILURE_MARKER = /^Scan FAILED$/m;
/**
* Stream a scan's log to the terminal until the workflow closes (completion comes from Temporal,
* or Ctrl-C). A Temporal outage is warned about and, if sustained, ends the tail with a diagnostic.
* Never exits the process: plain `logs` exits; `start --follow` reads the workflow outcome first.
* Reports whether the log already showed the failure, so a caller need not print it a second time.
*/
export function tailUntilComplete(logFile: string, opts: TailOptions = {}): Promise<TailResult> {
return new Promise((resolve) => {
let position = 0;
let done = false;
let sawFailure = false;
// Decorates the streamed log for the terminal; a pass-through when colour is off, so
// piped/redirected output stays byte-identical and the failure check still sees raw text.
const renderer = new LogRenderer();
const controller = new AbortController();
let watcher: ReturnType<typeof watch> | undefined;
/** Output any new content appended since the last read. */
function flush(): void {
try {
const { size } = fs.statSync(logFile);
if (size <= position) return;
const data = readRange(logFile, position, size);
process.stdout.write(renderer.write(data));
position = size;
if (!sawFailure && FAILURE_MARKER.test(data)) {
sawFailure = true;
}
} catch {
// File not present yet or transiently unreadable — nothing to flush this round.
}
}
function finish(): void {
if (done) return;
done = true;
process.stdout.write(renderer.end());
controller.abort();
if (watcher) {
watcher.close().finally(() => resolve({ sawFailure }));
// Safety net — resolve anyway if watcher.close() stalls.
setTimeout(() => resolve({ sawFailure }), 1000).unref();
} else {
resolve({ sawFailure });
}
}
// 1. Output existing content, then stream anything appended.
flush();
watcher = watch(logFile, { persistent: true });
watcher.on('change', () => flush());
// 2. Ctrl-C stops watching.
process.on('SIGINT', finish);
// 3. Temporal decides completion. Without a workflow id, the tail relies on Ctrl-C alone.
if (opts.workflowId) {
waitForWorkflowClose(opts.workflowId, {
signal: controller.signal,
onConnectionTrouble: (lastError) => {
if (!done) console.error(`\n⚠ Lost contact with Temporal, retrying… (${lastError})`);
},
onReconnected: () => {
if (!done) console.error(' Reconnected to Temporal.');
},
})
.then(async (end) => {
if (done) return;
// Flush, let a just-written final summary land, then flush the tail once more.
flush();
await sleep(750).catch(() => {});
flush();
if (end.reason === 'unreachable') {
console.error('\nScan watch aborted: lost contact with Temporal.');
console.error(` Last error: ${end.lastError}`);
console.error(' Temporal may have crashed — check `docker compose logs temporal`.');
opts.onUnreachable?.(end.lastError);
}
finish();
})
.catch(() => {
// waitForWorkflowClose never rejects; guard only against an aborted race.
});
}
});
}
export function logs(workspaceId: string): void {
const logFile = resolveLogFile(workspaceId);
const workflowId = resolveWorkflowId(workspaceId);
console.error(stdoutIsTerminal() ? field(`Tailing scan log: ${logFile}`) : 'Tailing scan log');
let unreachable = false;
tailUntilComplete(logFile, {
...(workflowId ? { workflowId } : {}),
onUnreachable: () => {
unreachable = true;
},
}).finally(() => process.exit(unreachable ? 1 : 0));
}