Skip to content

Commit b13788d

Browse files
authored
fix: terminate failed scans in Temporal and surface the reason when following (#429)
* fix(cli): skip splash screen off a TTY (e.g. CI) * fix: terminate failed scans in Temporal and surface the reason when following * fix(cli): indent embedded newlines within failure-error segments * fix(worker): omit the Agent Breakdown section when no agents completed * fix(cli): don't reprint the failure reason when the log already showed it * fix(worker): indent embedded newlines within the workflow.log error block
1 parent 53118c6 commit b13788d

10 files changed

Lines changed: 263 additions & 83 deletions

File tree

apps/cli/src/commands/logs.ts

Lines changed: 95 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,23 @@
11
/**
22
* `shannon logs` command — tail a scan's live log.
33
*
4-
* Uses chokidar for reliable cross-platform file watching and
5-
* bounded synchronous reads to prevent duplicate output.
4+
* The log file is streamed for its content; completion is decided by Temporal (the
5+
* workflow's status), so a worker that dies mid-run can't leave the tail hanging. Uses
6+
* chokidar for reliable cross-platform file watching and bounded synchronous reads to
7+
* prevent duplicate output.
68
*/
79

810
import fs from 'node:fs';
911
import path from 'node:path';
12+
import { setTimeout as sleep } from 'node:timers/promises';
1013
import { watch } from 'chokidar';
1114
import { fail } from '../errors.js';
1215
import { getWorkspacesDir } from '../home.js';
1316
import { resolveRunFile } from '../paths.js';
17+
import { resolveWorkflowId } from '../session.js';
18+
import { waitForWorkflowClose } from '../temporal-client.js';
1419
import { stdoutIsTerminal } from '../tty.js';
1520

16-
// Match the exact line the worker writes — anchored to prevent false positives from agent output
17-
const COMPLETION_PATTERN = /^Scan (COMPLETED|FAILED)$/m;
18-
1921
/** Read a byte range from a file and return it as a UTF-8 string. */
2022
function readRange(filePath: string, start: number, end: number): string {
2123
const length = end - start;
@@ -62,60 +64,114 @@ export function resolveLogFile(workspaceId: string): string {
6264
);
6365
}
6466

67+
export interface TailOptions {
68+
/** Workflow whose Temporal status decides when the tail stops. Without it, only Ctrl-C ends the tail. */
69+
readonly workflowId?: string;
70+
/** Called if the tail ends because Temporal became unreachable, with the captured error. */
71+
readonly onUnreachable?: (lastError: string) => void;
72+
}
73+
74+
/** Outcome of a tail: whether the streamed log already contained the worker's `Scan FAILED` block. */
75+
export interface TailResult {
76+
readonly sawFailure: boolean;
77+
}
78+
79+
// The worker writes this exact line at the head of its terminal failure summary.
80+
const FAILURE_MARKER = /^Scan FAILED$/m;
81+
6582
/**
66-
* Tail a scan's log until it reports completion, resolving when the completion marker appears
67-
* (or the file is gone, or Ctrl-C stops it). Never exits the process, so the caller decides what
68-
* happens next: plain `logs` exits 0; `start --follow` reads the workflow outcome first.
83+
* Stream a scan's log to the terminal until the workflow closes (completion comes from Temporal,
84+
* or Ctrl-C). A Temporal outage is warned about and, if sustained, ends the tail with a diagnostic.
85+
* Never exits the process: plain `logs` exits; `start --follow` reads the workflow outcome first.
86+
* Reports whether the log already showed the failure, so a caller need not print it a second time.
6987
*/
70-
export function tailUntilComplete(logFile: string): Promise<void> {
88+
export function tailUntilComplete(logFile: string, opts: TailOptions = {}): Promise<TailResult> {
7189
return new Promise((resolve) => {
7290
let position = 0;
91+
let done = false;
92+
let sawFailure = false;
93+
const controller = new AbortController();
94+
let watcher: ReturnType<typeof watch> | undefined;
7395

74-
/**
75-
* Output any new content appended since the last read.
76-
* Returns true when the workflow completion marker is detected.
77-
*/
78-
function flush(): boolean {
96+
/** Output any new content appended since the last read. */
97+
function flush(): void {
7998
try {
8099
const { size } = fs.statSync(logFile);
81-
if (size <= position) return false;
82-
100+
if (size <= position) return;
83101
const data = readRange(logFile, position, size);
84102
process.stdout.write(data);
85103
position = size;
86-
87-
return COMPLETION_PATTERN.test(data);
104+
if (!sawFailure && FAILURE_MARKER.test(data)) {
105+
sawFailure = true;
106+
}
88107
} catch {
89-
// File deleted or unreadable — treat as done
90-
return true;
108+
// File not present yet or transiently unreadable — nothing to flush this round.
91109
}
92110
}
93111

94-
// 1. Output existing content
95-
if (flush()) {
96-
resolve();
97-
return;
112+
function finish(): void {
113+
if (done) return;
114+
done = true;
115+
controller.abort();
116+
if (watcher) {
117+
watcher.close().finally(() => resolve({ sawFailure }));
118+
// Safety net — resolve anyway if watcher.close() stalls.
119+
setTimeout(() => resolve({ sawFailure }), 1000).unref();
120+
} else {
121+
resolve({ sawFailure });
122+
}
98123
}
99124

100-
// 2. Watch for appended content via chokidar
101-
const watcher = watch(logFile, { persistent: true });
102-
103-
const stop = (): void => {
104-
watcher.close().finally(() => resolve());
105-
// Safety net — resolve anyway if watcher.close() stalls
106-
setTimeout(() => resolve(), 1000).unref();
107-
};
108-
109-
watcher.on('change', () => {
110-
if (flush()) stop();
111-
});
112-
113-
process.on('SIGINT', stop);
125+
// 1. Output existing content, then stream anything appended.
126+
flush();
127+
watcher = watch(logFile, { persistent: true });
128+
watcher.on('change', () => flush());
129+
130+
// 2. Ctrl-C stops watching.
131+
process.on('SIGINT', finish);
132+
133+
// 3. Temporal decides completion. Without a workflow id, the tail relies on Ctrl-C alone.
134+
if (opts.workflowId) {
135+
waitForWorkflowClose(opts.workflowId, {
136+
signal: controller.signal,
137+
onConnectionTrouble: (lastError) => {
138+
if (!done) console.error(`\n⚠ Lost contact with Temporal, retrying… (${lastError})`);
139+
},
140+
onReconnected: () => {
141+
if (!done) console.error(' Reconnected to Temporal.');
142+
},
143+
})
144+
.then(async (end) => {
145+
if (done) return;
146+
// Flush, let a just-written final summary land, then flush the tail once more.
147+
flush();
148+
await sleep(750).catch(() => {});
149+
flush();
150+
if (end.reason === 'unreachable') {
151+
console.error('\nScan watch aborted: lost contact with Temporal.');
152+
console.error(` Last error: ${end.lastError}`);
153+
console.error(' Temporal may have crashed — check `docker compose logs temporal`.');
154+
opts.onUnreachable?.(end.lastError);
155+
}
156+
finish();
157+
})
158+
.catch(() => {
159+
// waitForWorkflowClose never rejects; guard only against an aborted race.
160+
});
161+
}
114162
});
115163
}
116164

117165
export function logs(workspaceId: string): void {
118166
const logFile = resolveLogFile(workspaceId);
167+
const workflowId = resolveWorkflowId(workspaceId);
119168
console.error(stdoutIsTerminal() ? `Tailing scan log: ${logFile}` : 'Tailing scan log');
120-
tailUntilComplete(logFile).finally(() => process.exit(0));
169+
170+
let unreachable = false;
171+
tailUntilComplete(logFile, {
172+
...(workflowId ? { workflowId } : {}),
173+
onUnreachable: () => {
174+
unreachable = true;
175+
},
176+
}).finally(() => process.exit(unreachable ? 1 : 0));
121177
}

apps/cli/src/commands/start.ts

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
resolveRepo,
2525
resolveRunFile,
2626
} from '../paths.js';
27+
import { indentFailureSegments } from '../scan/failure.js';
2728
import { resolveWorkflowId } from '../session.js';
2829
import { displaySplash } from '../splash.js';
2930
import { getTerminalOutcome } from '../temporal-client.js';
@@ -81,7 +82,10 @@ export async function start(args: StartArgs): Promise<void> {
8182
const config = args.config ? resolveConfig(args.config) : undefined;
8283

8384
// Inputs are valid — show the splash before the Docker/Temporal setup work.
84-
displaySplash(isLocal() ? undefined : args.version);
85+
// Skip it off a real terminal (e.g. CI) so piped/logged output stays clean.
86+
if (stdoutIsTerminal()) {
87+
displaySplash(isLocal() ? undefined : args.version);
88+
}
8589

8690
// 4. Ensure workspaces dir is writable by container user (UID 1001)
8791
const workspacesDir = getWorkspacesDir();
@@ -237,12 +241,14 @@ export async function start(args: StartArgs): Promise<void> {
237241
}
238242

239243
/**
240-
* Follow a just-started scan (for `--follow`, aimed at CI): stream its log to completion, then
241-
* exit on the workflow outcome — 0 if the assessment ran, 1 if the scan failed. That tracks
242-
* whether the pipeline ran, not whether vulnerabilities were found.
244+
* Follow a just-started scan (for `--follow`, aimed at CI): stream its log while Temporal drives
245+
* completion, then exit on the workflow outcome — 0 if the assessment ran, 1 if the scan failed.
246+
* That tracks whether the pipeline ran, not whether vulnerabilities were found. On failure the
247+
* root-cause message is printed so a red CI build says why.
243248
*/
244249
async function followScan(workspace: string, workspacesDir: string): Promise<never> {
245250
const logFile = resolveRunFile(path.join(workspacesDir, workspace), 'workflow.log');
251+
const workflowId = resolveWorkflowId(workspace);
246252

247253
// The worker creates workflow.log as it starts; wait briefly so the first read doesn't
248254
// mistake a not-yet-created file for an already-finished scan.
@@ -253,18 +259,38 @@ async function followScan(workspace: string, workspacesDir: string): Promise<nev
253259
if (stdoutIsTerminal()) {
254260
console.error('\n Following scan log (Ctrl-C to stop watching):\n');
255261
}
256-
await tailUntilComplete(logFile);
257262

258-
const workflowId = resolveWorkflowId(workspace);
263+
let temporalUnreachable = false;
264+
const { sawFailure } = await tailUntilComplete(logFile, {
265+
...(workflowId && { workflowId }),
266+
onUnreachable: () => {
267+
temporalUnreachable = true;
268+
},
269+
});
270+
271+
// The tail already printed the diagnostic; reading the outcome would only fail the same way.
272+
if (temporalUnreachable) {
273+
process.exit(1);
274+
}
275+
259276
if (!workflowId) {
260277
fail('Scan finished but its workflow id could not be resolved from session.json.');
261278
}
262279

263280
try {
264281
const outcome = await getTerminalOutcome(workflowId);
265-
process.exit(outcome.kind === 'success' ? 0 : 1);
266-
} catch {
267-
fail('Could not reach Temporal at 127.0.0.1:7233 to read the scan outcome.');
282+
if (outcome.kind === 'failed') {
283+
// Print the reason only when the streamed log didn't already show the worker's failure
284+
// summary — otherwise the worker crashed before writing it, and this is the only report.
285+
if (!sawFailure) {
286+
console.error(`\nScan failed:\n${indentFailureSegments(outcome.message)}`);
287+
}
288+
process.exit(1);
289+
}
290+
process.exit(0);
291+
} catch (err) {
292+
const detail = err instanceof Error ? err.message : String(err);
293+
fail('Could not read the scan outcome from Temporal at 127.0.0.1:7233.', ` ${detail}`);
268294
}
269295
}
270296

apps/cli/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { availableCommands, isHelpableCommand, printCommandHelp, START_OPTIONS }
2323
import { commandPrefix, getMode, isLocal, type Mode } from './mode.js';
2424
import { displaySplash } from './splash.js';
2525
import { closestMatch } from './suggest.js';
26+
import { stdoutIsTerminal } from './tty.js';
2627
import { getVersion, getVersionLine } from './version.js';
2728

2829
function blockSudo(): void {
@@ -174,7 +175,7 @@ async function main(): Promise<void> {
174175
printCommandHelp(topic);
175176
} else {
176177
const bare = command === undefined;
177-
if (bare) displaySplash(isLocal() ? undefined : getVersion());
178+
if (bare && stdoutIsTerminal()) displaySplash(isLocal() ? undefined : getVersion());
178179
showHelp(bare);
179180
}
180181
return;

apps/cli/src/scan/failure.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Rendering for the worker's '|'-delimited failure string.
3+
*
4+
* `formatWorkflowError` in the worker joins error segments — phase context, error type,
5+
* message, and remediation hint — with '|' as a delimiter. These helpers turn that raw
6+
* string into readable output for the CLI's own surfaces.
7+
*/
8+
9+
/**
10+
* Split the failure string into trimmed, non-empty lines. Segments are delimited by '|', and a
11+
* segment's own embedded newlines (e.g. a multi-line validation message) become their own lines so
12+
* each aligns with the rest of the block.
13+
*/
14+
export function parseFailureSegments(message: string): string[] {
15+
return message
16+
.split(/[|\n]/)
17+
.map((segment) => segment.trim())
18+
.filter((segment) => segment.length > 0);
19+
}
20+
21+
/** Multi-line block: one segment per indented line (the caller prints the header). */
22+
export function indentFailureSegments(message: string, indent = ' '): string {
23+
return parseFailureSegments(message)
24+
.map((segment) => `${indent}${segment}`)
25+
.join('\n');
26+
}
27+
28+
/** Single-line summary for compact contexts like the status footer. */
29+
export function inlineFailureReason(message: string): string {
30+
return parseFailureSegments(message).join(' — ');
31+
}

apps/cli/src/scan/render.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { BOLD, DIM, GOLD, paint, RED, YELLOW } from '../colors.js';
1111
import { commandPrefix } from '../mode.js';
1212
import type { RunningAgent } from '../temporal-client.js';
1313
import { agentError, deriveAgentStates, isTerminal, phaseGlyphState, type RunState, scanElapsedMs } from './derive.js';
14+
import { inlineFailureReason } from './failure.js';
1415
import { PIPELINE, type PipelineState } from './pipeline.js';
1516

1617
export interface RenderInput {
@@ -229,7 +230,8 @@ function footerLines(input: RenderInput, opts: RenderOptions): string[] {
229230
const temporalValue = temporalDashboardUrl(input.workflowId);
230231

231232
if (isTerminal(input.temporalStatus)) {
232-
const reason = input.failureMessage ?? input.state?.error ?? 'no result recorded';
233+
const rawReason = input.failureMessage ?? input.state?.error;
234+
const reason = rawReason ? inlineFailureReason(rawReason) : 'no result recorded';
233235
return [
234236
footerDivider(opts),
235237
paint(

0 commit comments

Comments
 (0)