mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-10-03 14:56:50 +02:00
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>
184 lines
6.8 KiB
TypeScript
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));
|
|
}
|