|
1 | 1 | /** |
2 | 2 | * `shannon logs` command — tail a scan's live log. |
3 | 3 | * |
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. |
6 | 8 | */ |
7 | 9 |
|
8 | 10 | import fs from 'node:fs'; |
9 | 11 | import path from 'node:path'; |
| 12 | +import { setTimeout as sleep } from 'node:timers/promises'; |
10 | 13 | import { watch } from 'chokidar'; |
11 | 14 | import { fail } from '../errors.js'; |
12 | 15 | import { getWorkspacesDir } from '../home.js'; |
13 | 16 | import { resolveRunFile } from '../paths.js'; |
| 17 | +import { resolveWorkflowId } from '../session.js'; |
| 18 | +import { waitForWorkflowClose } from '../temporal-client.js'; |
14 | 19 | import { stdoutIsTerminal } from '../tty.js'; |
15 | 20 |
|
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 | | - |
19 | 21 | /** Read a byte range from a file and return it as a UTF-8 string. */ |
20 | 22 | function readRange(filePath: string, start: number, end: number): string { |
21 | 23 | const length = end - start; |
@@ -62,60 +64,114 @@ export function resolveLogFile(workspaceId: string): string { |
62 | 64 | ); |
63 | 65 | } |
64 | 66 |
|
| 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 | + |
65 | 82 | /** |
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. |
69 | 87 | */ |
70 | | -export function tailUntilComplete(logFile: string): Promise<void> { |
| 88 | +export function tailUntilComplete(logFile: string, opts: TailOptions = {}): Promise<TailResult> { |
71 | 89 | return new Promise((resolve) => { |
72 | 90 | let position = 0; |
| 91 | + let done = false; |
| 92 | + let sawFailure = false; |
| 93 | + const controller = new AbortController(); |
| 94 | + let watcher: ReturnType<typeof watch> | undefined; |
73 | 95 |
|
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 { |
79 | 98 | try { |
80 | 99 | const { size } = fs.statSync(logFile); |
81 | | - if (size <= position) return false; |
82 | | - |
| 100 | + if (size <= position) return; |
83 | 101 | const data = readRange(logFile, position, size); |
84 | 102 | process.stdout.write(data); |
85 | 103 | position = size; |
86 | | - |
87 | | - return COMPLETION_PATTERN.test(data); |
| 104 | + if (!sawFailure && FAILURE_MARKER.test(data)) { |
| 105 | + sawFailure = true; |
| 106 | + } |
88 | 107 | } 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. |
91 | 109 | } |
92 | 110 | } |
93 | 111 |
|
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 | + } |
98 | 123 | } |
99 | 124 |
|
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 | + } |
114 | 162 | }); |
115 | 163 | } |
116 | 164 |
|
117 | 165 | export function logs(workspaceId: string): void { |
118 | 166 | const logFile = resolveLogFile(workspaceId); |
| 167 | + const workflowId = resolveWorkflowId(workspaceId); |
119 | 168 | 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)); |
121 | 177 | } |
0 commit comments