mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-09-14 22:15:35 +02:00
merge: integrate Shannon 3.0 with public v2.6.0
- preserve the versioned and non-TTY banners from public main - keep workspace launch classification ahead of shared infrastructure setup - carry the eleven-commit Agentic SAST feature history unchanged - normalize Capella prompt endings to the accepted candidate tree
This commit is contained in:
+170
-23
@@ -9,6 +9,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { StringDecoder } from 'node:string_decoder';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { watch } from 'chokidar';
|
||||
import { fail } from '../errors.js';
|
||||
@@ -18,8 +19,69 @@ 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 TERMINAL_HEADINGS = new Set(['Scan COMPLETED', 'Scan PARTIAL', 'Scan FAILED', 'Scan CANCELLED']);
|
||||
|
||||
// The combined log resets completion on the bare `RESUMED` heading; a per-agent file carries the
|
||||
// distinct `--- RESUMED (<workflow id>) ---` boundary that WorkflowLogger.logResumeBoundary writes
|
||||
// (kept distinct per resume so it stays idempotent per file). Both mean a new execution began, so a
|
||||
// `--agent` tail must clear a stale terminal marker on either, matching the combined tail.
|
||||
const AGENT_RESUME_BOUNDARY = /^--- RESUMED \(.+\) ---$/u;
|
||||
|
||||
function isResumeBoundary(line: string): boolean {
|
||||
return line === 'RESUMED' || AGENT_RESUME_BOUNDARY.test(line);
|
||||
}
|
||||
|
||||
/** Tracks only complete structural lines while output remains byte-for-byte unchanged. */
|
||||
export class LogCompletionState {
|
||||
private pendingLine = '';
|
||||
private terminalIsLastMarker = false;
|
||||
private failureIsLastMarker = false;
|
||||
|
||||
ingest(chunk: string): void {
|
||||
const lines = `${this.pendingLine}${chunk}`.split('\n');
|
||||
this.pendingLine = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
if (isResumeBoundary(line)) {
|
||||
this.terminalIsLastMarker = false;
|
||||
this.failureIsLastMarker = false;
|
||||
} else if (TERMINAL_HEADINGS.has(line)) {
|
||||
this.terminalIsLastMarker = true;
|
||||
this.failureIsLastMarker = line === 'Scan FAILED';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isComplete(): boolean {
|
||||
return this.terminalIsLastMarker;
|
||||
}
|
||||
|
||||
hasFailureMarker(): boolean {
|
||||
return this.failureIsLastMarker;
|
||||
}
|
||||
}
|
||||
|
||||
/** Append the forced-stop marker after the worker has exited, unless this execution already ended. */
|
||||
export function appendCancellationFallback(logFile: string): void {
|
||||
fs.mkdirSync(path.dirname(logFile), { recursive: true });
|
||||
const state = new LogCompletionState();
|
||||
try {
|
||||
state.ingest(fs.readFileSync(logFile, 'utf8'));
|
||||
} catch {
|
||||
// A pre-registration stop may not have created the file yet.
|
||||
}
|
||||
if (state.isComplete()) return;
|
||||
|
||||
const descriptor = fs.openSync(logFile, 'a', 0o600);
|
||||
try {
|
||||
fs.writeSync(descriptor, '\nScan CANCELLED\n');
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a byte range without decoding across an arbitrary live-write boundary. */
|
||||
function readRange(filePath: string, start: number, end: number): Buffer {
|
||||
const length = end - start;
|
||||
const buffer = Buffer.alloc(length);
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
@@ -28,7 +90,7 @@ function readRange(filePath: string, start: number, end: number): string {
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
return buffer.toString('utf-8');
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/** Resolve a workspace ID to its workflow.log path, or exit with an error. */
|
||||
@@ -76,9 +138,6 @@ 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.
|
||||
@@ -88,24 +147,25 @@ const FAILURE_MARKER = /^Scan FAILED$/m;
|
||||
export function tailUntilComplete(logFile: string, opts: TailOptions = {}): Promise<TailResult> {
|
||||
return new Promise((resolve) => {
|
||||
let position = 0;
|
||||
const completion = new LogCompletionState();
|
||||
const completionDecoder = new StringDecoder('utf8');
|
||||
let done = false;
|
||||
let sawFailure = false;
|
||||
const controller = new AbortController();
|
||||
let watcher: ReturnType<typeof watch> | undefined;
|
||||
|
||||
/** Output any new content appended since the last read. */
|
||||
function flush(): void {
|
||||
function flush(): boolean {
|
||||
try {
|
||||
const { size } = fs.statSync(logFile);
|
||||
if (size <= position) return;
|
||||
if (size <= position) return completion.isComplete();
|
||||
const data = readRange(logFile, position, size);
|
||||
process.stdout.write(data);
|
||||
position = size;
|
||||
if (!sawFailure && FAILURE_MARKER.test(data)) {
|
||||
sawFailure = true;
|
||||
}
|
||||
completion.ingest(completionDecoder.write(data));
|
||||
return completion.isComplete();
|
||||
} catch {
|
||||
// File not present yet or transiently unreadable — nothing to flush this round.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,22 +173,32 @@ export function tailUntilComplete(logFile: string, opts: TailOptions = {}): Prom
|
||||
if (done) return;
|
||||
done = true;
|
||||
controller.abort();
|
||||
process.off('SIGINT', finish);
|
||||
const result = { sawFailure: completion.hasFailureMarker() };
|
||||
if (watcher) {
|
||||
watcher.close().finally(() => resolve({ sawFailure }));
|
||||
watcher.close().finally(() => resolve(result));
|
||||
// Safety net — resolve anyway if watcher.close() stalls.
|
||||
setTimeout(() => resolve({ sawFailure }), 1000).unref();
|
||||
setTimeout(() => resolve(result), 1000).unref();
|
||||
} else {
|
||||
resolve({ sawFailure });
|
||||
resolve(result);
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Output existing content, then stream anything appended.
|
||||
flush();
|
||||
// 1. Output existing content, then stream anything appended. A per-agent file can be created
|
||||
// after the watcher starts, so `add` is handled too and streams it from its first line.
|
||||
watcher = watch(logFile, { persistent: true });
|
||||
watcher.on('change', () => flush());
|
||||
const onFsEvent = (): void => {
|
||||
if (flush() && !opts.workflowId) finish();
|
||||
};
|
||||
watcher.on('change', onFsEvent);
|
||||
watcher.on('add', onFsEvent);
|
||||
if (flush() && !opts.workflowId) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Ctrl-C stops watching.
|
||||
process.on('SIGINT', finish);
|
||||
process.once('SIGINT', finish);
|
||||
|
||||
// 3. Temporal decides completion. Without a workflow id, the tail relies on Ctrl-C alone.
|
||||
if (opts.workflowId) {
|
||||
@@ -162,11 +232,51 @@ export function tailUntilComplete(logFile: string, opts: TailOptions = {}): Prom
|
||||
});
|
||||
}
|
||||
|
||||
export function logs(workspaceId: string): void {
|
||||
const logFile = resolveLogFile(workspaceId);
|
||||
const workflowId = resolveWorkflowId(workspaceId);
|
||||
console.error(stdoutIsTerminal() ? `Tailing scan log: ${logFile}` : 'Tailing scan log');
|
||||
/** The `.shannon/agents/` directory that sits beside a scan's combined workflow.log. */
|
||||
function agentsDirFor(logFile: string): string {
|
||||
return path.join(path.dirname(logFile), 'agents');
|
||||
}
|
||||
|
||||
/** List the per-agent log names available for a scan (filename stems, sorted), or an empty list. */
|
||||
export function listAgentLogNames(logFile: string): string[] {
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(agentsDirFor(logFile))
|
||||
.filter((entry) => entry.endsWith('.log'))
|
||||
.map((entry) => entry.slice(0, -'.log'.length))
|
||||
.sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an agent name to its per-agent log path. The name must be a closed-charset basename, and
|
||||
* the resolved file must stay inside the agents directory: traversal and symlink escapes are
|
||||
* rejected. Returns undefined when the name is structurally invalid or escapes the directory.
|
||||
*/
|
||||
export function resolveAgentLogFile(logFile: string, agentName: string): string | undefined {
|
||||
if (!/^[a-z0-9][a-z0-9-]{0,63}$/u.test(agentName)) return undefined;
|
||||
const agentsDir = agentsDirFor(logFile);
|
||||
const target = path.join(agentsDir, `${agentName}.log`);
|
||||
try {
|
||||
const realDir = fs.realpathSync(agentsDir);
|
||||
const realTarget = fs.realpathSync(target);
|
||||
if (realTarget !== path.join(realDir, `${agentName}.log`)) return undefined;
|
||||
} catch {
|
||||
// The file does not exist yet (scan still starting); the closed-charset check already proved
|
||||
// the path cannot traverse out of the agents directory, so it is safe to watch for creation.
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export interface LogsOptions {
|
||||
readonly agent?: string;
|
||||
readonly listAgents?: boolean;
|
||||
}
|
||||
|
||||
function tailFileToExit(logFile: string, workflowId: string | undefined, label: string): void {
|
||||
console.error(stdoutIsTerminal() ? `${label}: ${logFile}` : label);
|
||||
let unreachable = false;
|
||||
tailUntilComplete(logFile, {
|
||||
...(workflowId ? { workflowId } : {}),
|
||||
@@ -175,3 +285,40 @@ export function logs(workspaceId: string): void {
|
||||
},
|
||||
}).finally(() => process.exit(unreachable ? 1 : 0));
|
||||
}
|
||||
|
||||
export function logs(workspaceId: string, options: LogsOptions = {}): void {
|
||||
const logFile = resolveLogFile(workspaceId);
|
||||
|
||||
if (options.listAgents) {
|
||||
const names = listAgentLogNames(logFile);
|
||||
if (names.length === 0) {
|
||||
console.error('No per-agent logs for this scan yet.');
|
||||
process.exit(0);
|
||||
}
|
||||
for (const name of names) console.log(name);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const workflowId = resolveWorkflowId(workspaceId);
|
||||
|
||||
if (options.agent !== undefined) {
|
||||
const agentFile = resolveAgentLogFile(logFile, options.agent);
|
||||
if (agentFile === undefined) {
|
||||
fail(`No agent log named: ${options.agent}`, '', 'Available agents:', ...withBullets(listAgentLogNames(logFile)));
|
||||
}
|
||||
const known = listAgentLogNames(logFile);
|
||||
// If the directory already lists agents, a name not among them is a typo, not a not-yet-created
|
||||
// file; fail loudly rather than tailing a path that will never appear.
|
||||
if (known.length > 0 && !known.includes(options.agent)) {
|
||||
fail(`No agent log named: ${options.agent}`, '', 'Available agents:', ...withBullets(known));
|
||||
}
|
||||
tailFileToExit(agentFile, workflowId, `Tailing ${options.agent} log`);
|
||||
return;
|
||||
}
|
||||
|
||||
tailFileToExit(logFile, workflowId, 'Tailing scan log');
|
||||
}
|
||||
|
||||
function withBullets(names: readonly string[]): string[] {
|
||||
return names.length === 0 ? [' (none yet)'] : names.map((name) => ` - ${name}`);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
/**
|
||||
* `shannon scans` command — list completed scans and where each report lives.
|
||||
* `shannon scans` command — list scans, running and completed, and where each report lives.
|
||||
*
|
||||
* A scan counts as completed when it produced a report. The report can live in any of a
|
||||
* few locations depending on the version that ran it, so `findReport` probes them in order
|
||||
* and the first hit is both the completion signal and the link target behind the workspace
|
||||
* name. The date and wall-clock duration come from the run's session.json
|
||||
* (createdAt/completedAt), with the report file's mtime as the date fallback for
|
||||
* runs that lack a recorded time.
|
||||
* Running scans come from Docker: every worker container is stamped with the shannon.workspace
|
||||
* label, so `runningScanWorkspaces()` is the authoritative live-scan list (shared with `stop`).
|
||||
* A scan counts as completed once it has produced a report; the report can live in any of a few
|
||||
* locations depending on the version that ran it, so `findReport` probes them in order and the
|
||||
* first hit is both the completion signal and the link target behind the workspace name. Dates and
|
||||
* durations come from each run's session.json (createdAt/completedAt), with the report file's mtime
|
||||
* as the date fallback for runs that lack a recorded time; a running scan's duration is elapsed time
|
||||
* so far (now − createdAt).
|
||||
*
|
||||
* Human-readable by default; `--json` emits the same rows as raw machine values on stdout.
|
||||
* Running scans are listed first, then completed newest-first. Human-readable by default; `--json`
|
||||
* emits the same rows as raw machine values on stdout.
|
||||
*
|
||||
* Filesystem-only (local ./workspaces/ or npx ~/.shannon/workspaces/ via getWorkspacesDir);
|
||||
* no Temporal dependency.
|
||||
* The completed list is filesystem-only (local ./workspaces/ or npx ~/.shannon/workspaces/ via
|
||||
* getWorkspacesDir); the running list needs Docker but degrades to empty when the daemon is down,
|
||||
* which is the correct answer (no scan can be running then).
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { BOLD, GOLD, paint } from '../colors.js';
|
||||
import { BOLD, CYAN, GOLD, paint } from '../colors.js';
|
||||
import { runningScanWorkspaces } from '../docker.js';
|
||||
import { getWorkspacesDir } from '../home.js';
|
||||
import { commandPrefix } from '../mode.js';
|
||||
import { FINAL_REPORT_PDF_FILENAME, INTERNAL_DIR, resolveRunFile } from '../paths.js';
|
||||
@@ -31,23 +36,25 @@ const FINAL_REPORT_MD_FILENAME = 'Security-Assessment-Report.md';
|
||||
|
||||
const DELIVERABLES_SUBDIR = 'deliverables';
|
||||
|
||||
/** One completed scan; raw values so the table and --json render from one source. */
|
||||
/** One scan, running or completed; raw values so the table and --json render from one source. */
|
||||
interface ScanRow {
|
||||
readonly workspace: string;
|
||||
/** Completion time in ms — sort key and date source. */
|
||||
readonly finishedMs: number;
|
||||
/** Wall-clock duration (completedAt − createdAt) in ms, or null when unknown. */
|
||||
readonly state: 'running' | 'completed';
|
||||
/** Completion time in ms — sort key and date source. Null while a scan is still running. */
|
||||
readonly finishedMs: number | null;
|
||||
/** Wall-clock duration in ms: elapsed-so-far for running, total for completed. Null when unknown. */
|
||||
readonly durationMs: number | null;
|
||||
/** Absolute path to the report file — the link target behind the workspace name. */
|
||||
readonly report: string;
|
||||
/** Absolute path to the report file — the link target behind the workspace name. Null while running. */
|
||||
readonly report: string | null;
|
||||
}
|
||||
|
||||
/** The --json row shape: raw machine values, one per completed scan. */
|
||||
/** The --json row shape: raw machine values, one per scan. */
|
||||
interface JsonRow {
|
||||
readonly workspace: string;
|
||||
readonly finishedAt: string;
|
||||
readonly state: 'running' | 'completed';
|
||||
readonly finishedAt: string | null;
|
||||
readonly durationMs: number | null;
|
||||
readonly reportPath: string;
|
||||
readonly reportPath: string | null;
|
||||
}
|
||||
|
||||
/** Compact wall-clock duration from milliseconds: "47s", "1m 32s", "1h 47m". */
|
||||
@@ -130,7 +137,19 @@ function collectCompletedScans(workspacesDir: string): ScanRow[] {
|
||||
const finishedMs = Number.isNaN(completedMs) ? fs.statSync(reportPath).mtimeMs : completedMs;
|
||||
const durationMs = Number.isNaN(completedMs) || Number.isNaN(createdMs) ? null : completedMs - createdMs;
|
||||
|
||||
rows.push({ workspace: entry.name, finishedMs, durationMs, report: reportPath });
|
||||
rows.push({ workspace: entry.name, state: 'completed', finishedMs, durationMs, report: reportPath });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Gather every currently-running scan, one row each. Elapsed time is now − createdAt. */
|
||||
function collectRunningScans(workspacesDir: string, nowMs: number): ScanRow[] {
|
||||
const rows: ScanRow[] = [];
|
||||
for (const workspace of runningScanWorkspaces()) {
|
||||
const { session } = readSession(path.join(workspacesDir, workspace));
|
||||
const createdMs = Date.parse(session.createdAt ?? '');
|
||||
const durationMs = Number.isNaN(createdMs) ? null : nowMs - createdMs;
|
||||
rows.push({ workspace, state: 'running', finishedMs: null, durationMs, report: null });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -138,55 +157,68 @@ function collectCompletedScans(workspacesDir: string): ScanRow[] {
|
||||
function toJsonRow(row: ScanRow): JsonRow {
|
||||
return {
|
||||
workspace: row.workspace,
|
||||
finishedAt: new Date(row.finishedMs).toISOString(),
|
||||
state: row.state,
|
||||
finishedAt: row.finishedMs === null ? null : new Date(row.finishedMs).toISOString(),
|
||||
durationMs: row.durationMs,
|
||||
reportPath: row.report,
|
||||
};
|
||||
}
|
||||
|
||||
/** Print the completed scans as an aligned table with the workspace name linked to its report. */
|
||||
/** Print the scans as an aligned table with each completed workspace name linked to its report. */
|
||||
function printTable(workspacesDir: string, rows: readonly ScanRow[]): void {
|
||||
if (rows.length === 0) {
|
||||
const prefix = commandPrefix();
|
||||
console.log(`No completed scans yet. Run '${prefix} start -u <url> -r <path>' to begin.`);
|
||||
console.log(`No scans yet. Run '${prefix} start -u <url> -r <path>' to begin.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const color = supportsColor();
|
||||
// On a terminal the workspace name is an OSC 8 hyperlink that opens its report; when
|
||||
// piped there is nothing to click, so it prints as plain text.
|
||||
// On a terminal a completed workspace name is an OSC 8 hyperlink that opens its report; when
|
||||
// piped, or for a running scan that has no report yet, it prints as plain text.
|
||||
const linkable = stdoutIsTerminal();
|
||||
|
||||
const table = rows.map((row) => ({
|
||||
finished: new Date(row.finishedMs).toISOString().slice(0, 10),
|
||||
state: row.state === 'running' ? 'RUNNING' : 'COMPLETED',
|
||||
finished: row.finishedMs === null ? '—' : new Date(row.finishedMs).toISOString().slice(0, 10),
|
||||
duration: row.durationMs === null ? '—' : formatDuration(row.durationMs),
|
||||
workspace: row.workspace,
|
||||
report: row.report,
|
||||
}));
|
||||
|
||||
const stateWidth = Math.max('STATE'.length, ...table.map((row) => row.state.length));
|
||||
const dateWidth = Math.max('FINISHED'.length, 'YYYY-MM-DD'.length);
|
||||
const durationWidth = Math.max('DURATION'.length, ...table.map((row) => row.duration.length));
|
||||
|
||||
console.log(`\nCompleted scans in ${workspacesDir}:\n`);
|
||||
const header = `${'FINISHED'.padEnd(dateWidth)} ${'DURATION'.padEnd(durationWidth)} WORKSPACE`;
|
||||
console.log(`\nScans in ${workspacesDir}:\n`);
|
||||
const header = `${'STATE'.padEnd(stateWidth)} ${'FINISHED'.padEnd(dateWidth)} ${'DURATION'.padEnd(durationWidth)} WORKSPACE`;
|
||||
console.log(paint(header, BOLD, color));
|
||||
|
||||
for (const row of table) {
|
||||
const stateText = row.state.padEnd(stateWidth);
|
||||
const state = row.state === 'RUNNING' ? paint(stateText, CYAN, color) : stateText;
|
||||
const finished = row.finished.padEnd(dateWidth);
|
||||
const duration = row.duration.padEnd(durationWidth);
|
||||
const name = paint(row.workspace, GOLD, color);
|
||||
const workspace = linkable ? hyperlink(name, pathToFileURL(row.report).href) : name;
|
||||
console.log(`${finished} ${duration} ${workspace}`);
|
||||
// A running scan has no report to open, so its name stays plain; completed names are linked.
|
||||
const name = row.report ? paint(row.workspace, GOLD, color) : row.workspace;
|
||||
const workspace = row.report && linkable ? hyperlink(name, pathToFileURL(row.report).href) : name;
|
||||
console.log(`${state} ${finished} ${duration} ${workspace}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
export function scans(opts: { readonly json: boolean }): void {
|
||||
const workspacesDir = getWorkspacesDir();
|
||||
const rows = collectCompletedScans(workspacesDir);
|
||||
const nowMs = Date.now();
|
||||
|
||||
// Latest on top.
|
||||
rows.sort((a, b) => b.finishedMs - a.finishedMs);
|
||||
const running = collectRunningScans(workspacesDir, nowMs);
|
||||
const runningNames = new Set(running.map((row) => row.workspace));
|
||||
// A running scan has no final report, so it can't also be completed; guard anyway.
|
||||
const completed = collectCompletedScans(workspacesDir).filter((row) => !runningNames.has(row.workspace));
|
||||
|
||||
// Running scans on top (most recently started first), then completed newest-first.
|
||||
running.sort((a, b) => (a.durationMs ?? 0) - (b.durationMs ?? 0));
|
||||
completed.sort((a, b) => (b.finishedMs ?? 0) - (a.finishedMs ?? 0));
|
||||
const rows = [...running, ...completed];
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(rows.map(toJsonRow), null, 2));
|
||||
|
||||
+249
-60
@@ -18,6 +18,7 @@ import { commandPrefix, isLocal } from '../mode.js';
|
||||
import { resolveModelSpec } from '../model-spec.js';
|
||||
import {
|
||||
expandHome,
|
||||
FINAL_REPORT_MD_FILENAME,
|
||||
FINAL_REPORT_PDF_FILENAME,
|
||||
INTERNAL_DIR,
|
||||
resolveConfig,
|
||||
@@ -43,83 +44,219 @@ export interface StartArgs {
|
||||
version: string;
|
||||
}
|
||||
|
||||
const LAUNCH_STATE_SCHEMA_VERSION = 1 as const;
|
||||
const LAUNCH_STATE_FILENAME = 'launch.json';
|
||||
const FIXED_CLASSES = ['injection', 'xss', 'auth', 'authz', 'ssrf'] as const;
|
||||
|
||||
/**
|
||||
* Upgrade a pre-restructure workspace (flat layout, no INTERNAL_DIR) before it is mounted,
|
||||
* so resume finds the old deliverables and their git checkpoints instead of re-running every
|
||||
* agent. For a legacy run every top-level entry is internal, so move them all into INTERNAL_DIR
|
||||
* (a same-filesystem rename carries the deliverables .git along).
|
||||
* CLI-owned launch record at INTERNAL_DIR/launch.json, written once when a workspace is
|
||||
* created and never rewritten. It pins the customer output destination so a resume with a
|
||||
* different -o cannot silently redirect the final report. The worker does not read it.
|
||||
*/
|
||||
function migrateLegacyWorkspaceLayout(workspacePath: string): void {
|
||||
const legacySessionJson = path.join(workspacePath, 'session.json');
|
||||
const internalPath = path.join(workspacePath, INTERNAL_DIR);
|
||||
if (!fs.existsSync(legacySessionJson) || fs.existsSync(internalPath)) {
|
||||
return;
|
||||
interface LaunchState {
|
||||
readonly schema_version: typeof LAUNCH_STATE_SCHEMA_VERSION;
|
||||
readonly customer_output_path?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceLaunchDecision {
|
||||
readonly isResume: boolean;
|
||||
readonly outputDir?: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function arraysEqual(left: readonly unknown[], right: readonly unknown[]): boolean {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand-rolled twin of the worker's durable-state validator in
|
||||
* apps/worker/src/types/run-state.ts, which owns the session.json.durableScanState shape.
|
||||
* Each array check accepts two variants because the worker appends 'miscellaneous' and
|
||||
* 'miscellaneous-exploit' only after the miscellaneous pipeline admits findings. If the worker's shape
|
||||
* changes and this twin lags, resume fails fast as incompatible instead of launching a
|
||||
* worker against state it would misread.
|
||||
*/
|
||||
function isCurrentDurableState(value: unknown): boolean {
|
||||
if (!isRecord(value) || value.schema_version !== 1 || typeof value.exploit !== 'boolean') return false;
|
||||
if (!Array.isArray(value.participating_classes) || !Array.isArray(value.expected_agents)) return false;
|
||||
|
||||
const participating = value.participating_classes;
|
||||
const validParticipation =
|
||||
arraysEqual(participating, FIXED_CLASSES) || arraysEqual(participating, [...FIXED_CLASSES, 'miscellaneous']);
|
||||
if (!validParticipation) return false;
|
||||
|
||||
const baselineAgents = ['pre-recon', 'recon', ...FIXED_CLASSES.map((name) => `${name}-vuln`)];
|
||||
if (value.exploit) baselineAgents.push(...FIXED_CLASSES.map((name) => `${name}-exploit`));
|
||||
baselineAgents.push('report');
|
||||
const expected = value.expected_agents;
|
||||
return arraysEqual(expected, baselineAgents) || arraysEqual(expected, [...baselineAgents, 'miscellaneous-exploit']);
|
||||
}
|
||||
|
||||
/** One refusal for damaged CLI-owned or worker-owned workspace records, whichever reads first. */
|
||||
const DAMAGED_RECORDS_MESSAGE =
|
||||
"This workspace's internal records are damaged and it cannot be resumed. Its report files are untouched. Start a new scan with a different -w name.";
|
||||
|
||||
const NEWER_RELEASE_MESSAGE =
|
||||
'This workspace was created by a newer version of Shannon. Upgrade Shannon, or start a new scan with a different -w name.';
|
||||
|
||||
function readJsonFile(filePath: string): unknown {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch {
|
||||
fail(DAMAGED_RECORDS_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
function readLaunchState(filePath: string): LaunchState {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
fail(
|
||||
'This workspace was created by an earlier version of Shannon and cannot be resumed. Its files and report are untouched. Start a new scan with a different -w name.',
|
||||
);
|
||||
}
|
||||
const value = readJsonFile(filePath);
|
||||
if (!isRecord(value)) fail(NEWER_RELEASE_MESSAGE);
|
||||
// Unknown keys mean a newer release wrote this workspace; refuse rather than half-read it.
|
||||
const keys = Object.keys(value).sort();
|
||||
const keysAreValid = keys.every((key) => key === 'customer_output_path' || key === 'schema_version');
|
||||
const customerPath = value.customer_output_path;
|
||||
const pathIsValid =
|
||||
customerPath === undefined ||
|
||||
(typeof customerPath === 'string' && path.isAbsolute(customerPath) && path.resolve(customerPath) === customerPath);
|
||||
if (value.schema_version !== LAUNCH_STATE_SCHEMA_VERSION || !keysAreValid || !pathIsValid) {
|
||||
fail(NEWER_RELEASE_MESSAGE);
|
||||
}
|
||||
return {
|
||||
schema_version: LAUNCH_STATE_SCHEMA_VERSION,
|
||||
...(typeof customerPath === 'string' && { customer_output_path: customerPath }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide fresh-versus-resume from on-disk state alone, before start() mutates anything.
|
||||
* A fresh launch requires the workspace directory to be absent or empty; a resume requires
|
||||
* current-release session state, a matching target URL, and a customer output path that
|
||||
* agrees with the recorded one. Every other combination fails the launch, so a typo in
|
||||
* -w or -o stops here instead of spawning a worker into the wrong workspace.
|
||||
*/
|
||||
export function classifyWorkspaceLaunch(
|
||||
workspacePath: string,
|
||||
expectedUrl: string,
|
||||
requestedOutputDir: string | undefined,
|
||||
): WorkspaceLaunchDecision {
|
||||
const sessionPath = resolveRunFile(workspacePath, 'session.json');
|
||||
const sessionExists = fs.existsSync(sessionPath);
|
||||
if (!sessionExists) {
|
||||
if (fs.existsSync(workspacePath) && fs.readdirSync(workspacePath).length > 0) {
|
||||
fail(
|
||||
'This directory is not a Shannon workspace, or its scan state is missing. Start a new scan with a different -w name.',
|
||||
);
|
||||
}
|
||||
return { isResume: false, ...(requestedOutputDir !== undefined && { outputDir: requestedOutputDir }) };
|
||||
}
|
||||
|
||||
fs.mkdirSync(internalPath, { recursive: true });
|
||||
for (const entry of fs.readdirSync(workspacePath)) {
|
||||
if (entry === INTERNAL_DIR) {
|
||||
continue;
|
||||
const launchPath = path.join(workspacePath, INTERNAL_DIR, LAUNCH_STATE_FILENAME);
|
||||
const launch = readLaunchState(launchPath);
|
||||
const session = readJsonFile(sessionPath);
|
||||
if (!isRecord(session) || !isRecord(session.session) || session.session.webUrl !== expectedUrl) {
|
||||
fail(
|
||||
'This workspace was created for a different target URL, so it cannot be resumed against this one. Check -u, or start a new scan with a different -w name.',
|
||||
);
|
||||
}
|
||||
if (!isCurrentDurableState(session.durableScanState)) {
|
||||
fail(
|
||||
"This workspace's scan state cannot be read by this version. Its files are untouched. Start a new scan with a different -w name.",
|
||||
);
|
||||
}
|
||||
|
||||
const storedOutputDir = launch.customer_output_path;
|
||||
if (requestedOutputDir !== undefined && requestedOutputDir !== storedOutputDir) {
|
||||
fail(
|
||||
'This workspace already copies its report to a different location than the -o path you passed. Re-run without -o to keep the original location, or start a new scan with a different -w name.',
|
||||
);
|
||||
}
|
||||
return { isResume: true, ...(storedOutputDir !== undefined && { outputDir: storedOutputDir }) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Crash-safe single write: exclusive temp file (pid plus random suffix keeps concurrent
|
||||
* starts apart), fsync, rename into place, then directory fsync so the entry survives a
|
||||
* host crash. Callers invoke this only for a fresh workspace; an existing launch.json is
|
||||
* the resume contract and must never be replaced.
|
||||
*/
|
||||
export function writeLaunchStateAtomically(internalPath: string, outputDir: string | undefined): void {
|
||||
const finalPath = path.join(internalPath, LAUNCH_STATE_FILENAME);
|
||||
const temporaryPath = path.join(internalPath, `${LAUNCH_STATE_FILENAME}.tmp-${process.pid}-${randomSuffix()}`);
|
||||
const launchState: LaunchState = {
|
||||
schema_version: LAUNCH_STATE_SCHEMA_VERSION,
|
||||
...(outputDir !== undefined && { customer_output_path: outputDir }),
|
||||
};
|
||||
const descriptor = fs.openSync(temporaryPath, 'wx', 0o600);
|
||||
try {
|
||||
fs.writeFileSync(descriptor, `${JSON.stringify(launchState, null, 2)}\n`, 'utf8');
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
try {
|
||||
fs.renameSync(temporaryPath, finalPath);
|
||||
const directory = fs.openSync(internalPath, 'r');
|
||||
try {
|
||||
fs.fsyncSync(directory);
|
||||
} finally {
|
||||
fs.closeSync(directory);
|
||||
}
|
||||
fs.renameSync(path.join(workspacePath, entry), path.join(internalPath, entry));
|
||||
} catch (error) {
|
||||
fs.rmSync(temporaryPath, { force: true });
|
||||
throw error;
|
||||
}
|
||||
console.log(`Migrated workspace to ${INTERNAL_DIR}/ layout: ${workspacePath}`);
|
||||
}
|
||||
|
||||
export async function start(args: StartArgs): Promise<void> {
|
||||
// 1. Initialize state directories and load env
|
||||
// 1. Resolve non-mutating inputs and classify the workspace before changing it.
|
||||
initHome();
|
||||
loadEnv();
|
||||
|
||||
// 2. Validate credentials
|
||||
const creds = validateCredentials();
|
||||
if (!creds.valid) {
|
||||
fail(creds.error ?? 'Invalid credentials');
|
||||
}
|
||||
|
||||
// 3. Resolve paths
|
||||
const repo = resolveRepo(args.repo);
|
||||
const config = args.config ? resolveConfig(args.config) : undefined;
|
||||
const workspacesDir = getWorkspacesDir();
|
||||
const workspace =
|
||||
args.workspace ?? `${new URL(args.url).hostname.replace(/[^a-zA-Z0-9-]/g, '-')}_shannon-${Date.now()}`;
|
||||
const workspacePath = path.join(workspacesDir, workspace);
|
||||
const requestedOutputDir = args.output ? path.resolve(expandHome(args.output)) : undefined;
|
||||
const launchDecision = classifyWorkspaceLaunch(workspacePath, args.url, requestedOutputDir);
|
||||
|
||||
// Inputs are valid — identify the run before the Docker/Temporal setup work.
|
||||
// 2. Inputs are valid; identify the run before initializing shared infrastructure.
|
||||
const bannerVersion = isLocal() ? undefined : args.version;
|
||||
if (stdoutIsTerminal()) {
|
||||
displaySplash(bannerVersion);
|
||||
} else {
|
||||
displayPlainBanner(bannerVersion);
|
||||
}
|
||||
|
||||
// 4. Ensure workspaces dir is writable by container user (UID 1001)
|
||||
const workspacesDir = getWorkspacesDir();
|
||||
fs.mkdirSync(workspacesDir, { recursive: true });
|
||||
fs.chmodSync(workspacesDir, 0o777);
|
||||
|
||||
// 5. Ensure Docker and the worker image are available (pull/build prints its own progress).
|
||||
ensureDocker();
|
||||
ensureImage(args.version);
|
||||
|
||||
// One spinner spans the whole launch: bringing up Temporal and registering the worker.
|
||||
const spinner = p.spinner();
|
||||
spinner.start('Starting scan');
|
||||
await ensureInfra(spinner);
|
||||
|
||||
// 6. Generate unique task queue and container name
|
||||
// 3. Generate the invocation identity.
|
||||
const suffix = randomSuffix();
|
||||
const taskQueue = `shannon-${suffix}`;
|
||||
const containerName = `shannon-worker-${suffix}`;
|
||||
|
||||
// 7. Generate workspace name if not provided
|
||||
const workspace =
|
||||
args.workspace ?? `${new URL(args.url).hostname.replace(/[^a-zA-Z0-9-]/g, '-')}_shannon-${Date.now()}`;
|
||||
|
||||
// 8. Create writable overlay directories (mounted over :ro repo paths inside container)
|
||||
// 4. Create writable overlay directories after resume validation has succeeded.
|
||||
// The run dir and its INTERNAL_DIR must be 0o777 so the container user can create audit
|
||||
// subdirs and the overlay backing dirs.
|
||||
const workspacePath = path.join(workspacesDir, workspace);
|
||||
const internalPath = path.join(workspacePath, INTERNAL_DIR);
|
||||
fs.mkdirSync(workspacePath, { recursive: true });
|
||||
fs.chmodSync(workspacePath, 0o777);
|
||||
migrateLegacyWorkspaceLayout(workspacePath);
|
||||
fs.mkdirSync(internalPath, { recursive: true });
|
||||
fs.chmodSync(internalPath, 0o777);
|
||||
for (const dir of ['deliverables', 'scratchpad', '.playwright-cli', '.playwright']) {
|
||||
@@ -127,24 +264,37 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
fs.chmodSync(dirPath, 0o777);
|
||||
}
|
||||
if (!launchDecision.isResume) {
|
||||
writeLaunchStateAtomically(internalPath, launchDecision.outputDir);
|
||||
}
|
||||
|
||||
// 9. Pre-create overlay mount points (:ro mounts can't auto-create them)
|
||||
// 5. Pre-create overlay mount points (:ro mounts cannot create them).
|
||||
const shannonDir = path.join(repo.hostPath, '.shannon');
|
||||
for (const dir of ['deliverables', 'scratchpad', '.playwright-cli']) {
|
||||
fs.mkdirSync(path.join(shannonDir, dir), { recursive: true });
|
||||
}
|
||||
fs.mkdirSync(path.join(repo.hostPath, '.playwright'), { recursive: true });
|
||||
|
||||
// 10. Resolve output directory
|
||||
const outputDir = args.output ? path.resolve(expandHome(args.output)) : undefined;
|
||||
// 6. Create the validated customer-copy destination, if configured.
|
||||
const outputDir = launchDecision.outputDir;
|
||||
if (outputDir) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 11. Resolve prompts directory (local mode only)
|
||||
// 7. Resolve prompts and capture the pre-launch resume counter.
|
||||
const promptsDir = isLocal() ? path.resolve('apps/worker/prompts') : undefined;
|
||||
const sessionJson = resolveRunFile(workspacePath, 'session.json');
|
||||
const isResume = launchDecision.isResume;
|
||||
let initialResumeCount = 0;
|
||||
if (isResume) {
|
||||
// Docker and Temporal startup sit between this read and the classification that validated the
|
||||
// same file, so a file that changed in between is a workspace-state failure, not a CLI bug.
|
||||
const session = readJsonFile(sessionJson);
|
||||
const attempts = isRecord(session) && isRecord(session.session) ? session.session.resumeAttempts : undefined;
|
||||
initialResumeCount = Array.isArray(attempts) ? attempts.length : 0;
|
||||
}
|
||||
|
||||
// 12. Spawn worker container
|
||||
// 8. Spawn the worker container.
|
||||
const proc = spawnWorker({
|
||||
version: args.version,
|
||||
url: args.url,
|
||||
@@ -173,24 +323,16 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Detect whether this is a fresh workspace or a resume by checking session.json existence
|
||||
const sessionJson = resolveRunFile(path.join(workspacesDir, workspace), 'session.json');
|
||||
const isResume = fs.existsSync(sessionJson);
|
||||
let initialResumeCount = 0;
|
||||
if (isResume) {
|
||||
try {
|
||||
const session = JSON.parse(fs.readFileSync(sessionJson, 'utf-8'));
|
||||
initialResumeCount = session.session?.resumeAttempts?.length ?? 0;
|
||||
} catch {
|
||||
// Corrupted file — worker will handle validation
|
||||
}
|
||||
}
|
||||
|
||||
let started = false;
|
||||
|
||||
// Set when the startup poll times out but session.json already holds durable state this
|
||||
// release understands: the workflow is executing, so the exit handler must not stop its
|
||||
// worker. An operator abort is a different intent and still stops it.
|
||||
let scanRunningUnconfirmed = false;
|
||||
|
||||
// Stop the worker only if the scan hasn't registered yet (e.g. Ctrl-C mid-startup).
|
||||
let cleaned = false;
|
||||
const cleanup = (): void => {
|
||||
const stopWorker = (): void => {
|
||||
if (cleaned || started) return;
|
||||
cleaned = true;
|
||||
spinner.stop('Stopping scan');
|
||||
@@ -204,14 +346,17 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
}
|
||||
};
|
||||
process.on('SIGINT', () => {
|
||||
cleanup();
|
||||
stopWorker();
|
||||
process.exit(0);
|
||||
});
|
||||
process.on('SIGTERM', () => {
|
||||
cleanup();
|
||||
stopWorker();
|
||||
process.exit(0);
|
||||
});
|
||||
process.on('exit', cleanup);
|
||||
process.on('exit', () => {
|
||||
if (scanRunningUnconfirmed) return;
|
||||
stopWorker();
|
||||
});
|
||||
|
||||
// Poll for the workflow to register in session.json; the spinner resolves once it does.
|
||||
spinner.message('Waiting for the scan to start');
|
||||
@@ -238,10 +383,52 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
await sleep(2000);
|
||||
}
|
||||
|
||||
if (classifyStartupTimeout(sessionJson) === 'scan-running') {
|
||||
scanRunningUnconfirmed = true;
|
||||
spinner.error('The scan started, but this CLI could not confirm it');
|
||||
printUnconfirmedScanHint(workspace, taskQueue, containerName);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
spinner.error('Timed out waiting for the scan to start');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the startup timeout: 'scan-running' when session.json already holds durable state this
|
||||
* release understands, which only the worker writes and only after Temporal began executing the
|
||||
* workflow; 'unregistered' when nothing proves the scan started. The distinction decides whether
|
||||
* timing out may stop the worker container.
|
||||
*/
|
||||
export function classifyStartupTimeout(sessionJsonPath: string): 'unregistered' | 'scan-running' {
|
||||
let session: unknown;
|
||||
try {
|
||||
session = JSON.parse(fs.readFileSync(sessionJsonPath, 'utf-8'));
|
||||
} catch {
|
||||
return 'unregistered';
|
||||
}
|
||||
if (!isRecord(session) || !isCurrentDurableState(session.durableScanState)) {
|
||||
return 'unregistered';
|
||||
}
|
||||
return 'scan-running';
|
||||
}
|
||||
|
||||
/** Point the operator at a scan that is running but whose startup this CLI could not confirm. */
|
||||
function printUnconfirmedScanHint(workspace: string, taskQueue: string, containerName: string): void {
|
||||
console.log('');
|
||||
console.log(' The scan is running and was left alone; only its startup confirmation is missing.');
|
||||
console.log('');
|
||||
console.log(` Workspace: ${workspace}`);
|
||||
console.log(` Task queue: ${taskQueue}`);
|
||||
console.log(` Container: ${containerName}`);
|
||||
console.log('');
|
||||
console.log(' Inspect it:');
|
||||
console.log(` Live logs: ${commandPrefix()} logs ${workspace}`);
|
||||
console.log(` Worker logs: docker logs ${containerName}`);
|
||||
console.log(' Dashboard: http://localhost:8233');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow a just-started scan (for `--follow`, aimed at CI): stream its log while Temporal drives
|
||||
* completion, then exit on the workflow outcome — 0 if the assessment ran, 1 if the scan failed.
|
||||
@@ -331,7 +518,7 @@ function printInfo(args: StartArgs, workspace: string, repoPath: string, workspa
|
||||
return;
|
||||
}
|
||||
|
||||
const reportPath = path.join(workspacesDir, workspace, FINAL_REPORT_PDF_FILENAME);
|
||||
const reportDir = path.join(workspacesDir, workspace);
|
||||
|
||||
// When following, the scan log streams inline next, so the "run these to watch it" hints
|
||||
// would only contradict that.
|
||||
@@ -345,6 +532,8 @@ function printInfo(args: StartArgs, workspace: string, repoPath: string, workspa
|
||||
|
||||
console.log('');
|
||||
console.log(' Report (when the scan finishes):');
|
||||
console.log(` ${reportPath}`);
|
||||
console.log(` ${reportDir}${path.sep}`);
|
||||
console.log(` ${FINAL_REPORT_PDF_FILENAME}`);
|
||||
console.log(` ${FINAL_REPORT_MD_FILENAME}`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
@@ -3,21 +3,28 @@
|
||||
*
|
||||
* 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.
|
||||
* a terminal state, prints the overall result and exits. Local session records prove
|
||||
* the target's canonical workspace/workflow identity; the progress itself is read from
|
||||
* Temporal directly — no worker — so it needs Temporal up and shows scans within its
|
||||
* retention window (Shannon configures seven days by default; see SHANNON_TEMPORAL_RETENTION).
|
||||
*/
|
||||
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { fail } from '../errors.js';
|
||||
import { isLocal } from '../mode.js';
|
||||
import { failWith } from '../errors.js';
|
||||
import { commandPrefix, 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 {
|
||||
ActivityMirrorError,
|
||||
describeScan,
|
||||
getTerminalOutcome,
|
||||
queryProgress,
|
||||
type ScanDescription,
|
||||
} from '../temporal-client.js';
|
||||
import { stdoutIsTerminal, supportsColor } from '../tty.js';
|
||||
import { getVersion } from '../version.js';
|
||||
import { resolveScanIdentity } from '../workspaces.js';
|
||||
|
||||
const HIDE_CURSOR = '\x1b[?25l';
|
||||
const SHOW_CURSOR = '\x1b[?25h';
|
||||
@@ -30,6 +37,25 @@ function isTerminalStatus(status: string): boolean {
|
||||
return status !== 'RUNNING' && status !== 'UNSPECIFIED';
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one scan description, telling the two failure modes apart. A stale activity mirror
|
||||
* carries its own message and needs a CLI update; anything else is a read that did not reach
|
||||
* a usable answer, which is most often Temporal being down.
|
||||
*/
|
||||
async function readScanDescription(workflowId: string): Promise<ScanDescription | null> {
|
||||
try {
|
||||
return await describeScan(workflowId);
|
||||
} catch (error) {
|
||||
if (error instanceof ActivityMirrorError) failWith('CLI_SCAN_SCHEMA_UNSUPPORTED', error.message);
|
||||
failWith(
|
||||
'CLI_SCAN_STATUS_UNAVAILABLE',
|
||||
"Could not read this scan's progress.",
|
||||
'If Temporal is not running, start a scan to bring it up. If it is running, this build of the CLI',
|
||||
'does not recognise part of the scan and needs updating.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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');
|
||||
@@ -128,10 +154,10 @@ async function watch(workspace: string, workflowId: string): Promise<never> {
|
||||
}, RENDER_MS);
|
||||
|
||||
for (;;) {
|
||||
const desc = await describeScan(workflowId);
|
||||
const desc = await readScanDescription(workflowId);
|
||||
if (!desc) {
|
||||
clearInterval(ticker);
|
||||
fail(`Scan "${workspace}" is no longer in Temporal.`);
|
||||
failWith('CLI_SCAN_NOT_FOUND', `Scan "${workspace}" is no longer in Temporal.`);
|
||||
}
|
||||
|
||||
if (isTerminalStatus(desc.status)) {
|
||||
@@ -153,23 +179,40 @@ async function snapshot(workspace: string, workflowId: string, desc: ScanDescrip
|
||||
: buildRunningInput(workspace, workflowId, desc);
|
||||
}
|
||||
|
||||
export async function status(workspace: string, opts: { readonly json: boolean }): Promise<void> {
|
||||
// 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.');
|
||||
export async function status(target: string, opts: { readonly json: boolean }): Promise<void> {
|
||||
// Target selection picked a string; identity resolution proves the canonical workspace and
|
||||
// workflow pair from session records before Temporal is queried. A workspace name follows its
|
||||
// latest resume; an exact recorded workflow id keeps addressing that execution. A raw id with
|
||||
// no local record is refused rather than echoed into the required workspace field.
|
||||
const identity = resolveScanIdentity(target);
|
||||
if (identity.kind === 'ambiguous') {
|
||||
failWith(
|
||||
'CLI_SCAN_IDENTITY_AMBIGUOUS',
|
||||
`Multiple workspaces claim workflow ID "${target}": ${identity.claims.join(', ')}.`,
|
||||
`Run '${commandPrefix()} scans' and pass the workspace directory name instead.`,
|
||||
);
|
||||
}
|
||||
if (identity.kind === 'not-found') {
|
||||
failWith(
|
||||
'CLI_SCAN_IDENTITY_NOT_FOUND',
|
||||
identity.reason === 'unreadable-record'
|
||||
? `Workspace "${target}" has no readable session record (${identity.sessionPath}).`
|
||||
: `No scan matches "${target}" in the local workspace records.`,
|
||||
`Run '${commandPrefix()} scans' to list scans.`,
|
||||
'Temporal dashboard: http://localhost:8233',
|
||||
);
|
||||
}
|
||||
const { workspace, workflowId } = identity;
|
||||
|
||||
const desc = await readScanDescription(workflowId);
|
||||
|
||||
if (!desc) {
|
||||
fail(
|
||||
failWith(
|
||||
'CLI_SCAN_NOT_FOUND',
|
||||
`No scan found for "${workspace}".`,
|
||||
'',
|
||||
'Scans are visible while running and for ~24h after they finish (Temporal retention).',
|
||||
"Scan histories are available while a scan runs and within Temporal's retention window after it finishes.",
|
||||
"Shannon configures 7 days of retention by default (override: SHANNON_TEMPORAL_RETENTION). Expired histories can't be restored.",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+111
-17
@@ -3,23 +3,29 @@
|
||||
* Never touches infra or data; to wipe Temporal state entirely, use `shannon reset`.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import * as p from '@clack/prompts';
|
||||
import { confirmOrExit } from '../confirm.js';
|
||||
import {
|
||||
anyRunningScanWorkflow,
|
||||
cancelWorkflow,
|
||||
ensureDocker,
|
||||
isTemporalReady,
|
||||
isWorkflowRunning,
|
||||
runningContainers,
|
||||
runningScanWorkspaces,
|
||||
scanFilter,
|
||||
stopContainers,
|
||||
terminateAllWorkflows,
|
||||
terminateWorkflow,
|
||||
WORKER_FILTER,
|
||||
} from '../docker.js';
|
||||
import { fail, failUsage, warn } from '../errors.js';
|
||||
import { getWorkspacesDir } from '../home.js';
|
||||
import { commandPrefix } from '../mode.js';
|
||||
import { resolveRunFile } from '../paths.js';
|
||||
import { resolveWorkflowId } from '../session.js';
|
||||
import { resolveDefaultWorkspace } from '../workspaces.js';
|
||||
import { appendCancellationFallback } from './logs.js';
|
||||
|
||||
export interface StopOptions {
|
||||
all: boolean;
|
||||
@@ -27,11 +33,77 @@ export interface StopOptions {
|
||||
workspace?: string;
|
||||
}
|
||||
|
||||
const CANCELLATION_GRACE_MS = 10_000;
|
||||
const CANCELLATION_POLL_MS = 250;
|
||||
|
||||
export interface StopTarget {
|
||||
readonly workspace: string;
|
||||
readonly workflowId?: string;
|
||||
readonly workflowRunning: boolean;
|
||||
}
|
||||
|
||||
export interface StopLifecycle {
|
||||
readonly cancel: (workflowId: string) => boolean;
|
||||
readonly isRunning: (workflowId: string) => boolean;
|
||||
readonly terminate: (workflowId: string) => boolean;
|
||||
readonly containers: (workspace: string) => string[];
|
||||
readonly stopContainers: (ids: string[]) => Promise<void>;
|
||||
readonly appendFallback: (workspace: string) => void;
|
||||
readonly wait: (milliseconds: number) => Promise<void>;
|
||||
}
|
||||
|
||||
const stopLifecycle: StopLifecycle = {
|
||||
cancel: cancelWorkflow,
|
||||
isRunning: isWorkflowRunning,
|
||||
terminate: (workflowId) => terminateWorkflow(workflowId, 'Stopped after cancellation grace period'),
|
||||
containers: (workspace) => runningContainers(scanFilter(workspace)),
|
||||
stopContainers,
|
||||
appendFallback: (workspace) => {
|
||||
const logFile = resolveRunFile(path.join(getWorkspacesDir(), workspace), 'workflow.log');
|
||||
appendCancellationFallback(logFile);
|
||||
},
|
||||
wait: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
||||
};
|
||||
|
||||
/** Cancel first; terminate and write the fallback heading only when graceful closure misses its deadline. */
|
||||
export async function stopTargetCancelFirst(
|
||||
target: StopTarget,
|
||||
lifecycle: StopLifecycle = stopLifecycle,
|
||||
graceMs: number = CANCELLATION_GRACE_MS,
|
||||
pollMs: number = CANCELLATION_POLL_MS,
|
||||
): Promise<'graceful' | 'forced'> {
|
||||
let forced = !target.workflowRunning || target.workflowId === undefined;
|
||||
if (!forced && target.workflowId !== undefined) {
|
||||
lifecycle.cancel(target.workflowId);
|
||||
const deadline = Date.now() + graceMs;
|
||||
while (lifecycle.isRunning(target.workflowId) && Date.now() < deadline) {
|
||||
await lifecycle.wait(pollMs);
|
||||
}
|
||||
forced = lifecycle.isRunning(target.workflowId);
|
||||
if (forced) lifecycle.terminate(target.workflowId);
|
||||
}
|
||||
|
||||
await lifecycle.stopContainers(lifecycle.containers(target.workspace));
|
||||
if (forced && lifecycle.containers(target.workspace).length === 0) {
|
||||
lifecycle.appendFallback(target.workspace);
|
||||
}
|
||||
return forced ? 'forced' : 'graceful';
|
||||
}
|
||||
|
||||
/** Apply the same captured-target lifecycle concurrently for `stop --all`. */
|
||||
export function stopTargetsCancelFirst(
|
||||
targets: readonly StopTarget[],
|
||||
lifecycle: StopLifecycle = stopLifecycle,
|
||||
graceMs: number = CANCELLATION_GRACE_MS,
|
||||
pollMs: number = CANCELLATION_POLL_MS,
|
||||
): Promise<readonly ('graceful' | 'forced')[]> {
|
||||
return Promise.all(targets.map((target) => stopTargetCancelFirst(target, lifecycle, graceMs, pollMs)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a single scan. Terminating the workflow both clears Temporal's record and
|
||||
* brings the container down (the worker waits on the workflow result), so that runs
|
||||
* first; `docker stop` is the fallback for the pre-registration window and an
|
||||
* unreachable Temporal. The stop is then verified rather than assumed.
|
||||
* Stop a single scan. Cooperative cancellation gets the first ten seconds so the
|
||||
* workflow can flush its terminal log; termination and a host-written heading are
|
||||
* the fallback for the pre-registration window or an unavailable finalizer.
|
||||
*/
|
||||
async function stopSingleScan(workspace: string, yes: boolean): Promise<void> {
|
||||
const workflowId = resolveWorkflowId(workspace);
|
||||
@@ -55,10 +127,7 @@ async function stopSingleScan(workspace: string, yes: boolean): Promise<void> {
|
||||
const spinner = p.spinner();
|
||||
spinner.start(`Stopping scan ${workspace}`);
|
||||
|
||||
if (workflowId && workflowRunning) {
|
||||
terminateWorkflow(workflowId, `Stopped via shannon stop ${workspace}`);
|
||||
}
|
||||
await stopContainers(runningContainers(filter));
|
||||
await stopTargetCancelFirst({ workspace, ...(workflowId !== undefined && { workflowId }), workflowRunning });
|
||||
|
||||
const stillRunning = runningContainers(filter);
|
||||
if (stillRunning.length > 0) {
|
||||
@@ -77,6 +146,14 @@ async function stopSingleScan(workspace: string, yes: boolean): Promise<void> {
|
||||
async function stopAllScans(yes: boolean): Promise<void> {
|
||||
const temporalUp = isTemporalReady();
|
||||
const initial = runningContainers(WORKER_FILTER);
|
||||
const targets = [...new Set(runningScanWorkspaces())].map((workspace): StopTarget => {
|
||||
const workflowId = resolveWorkflowId(workspace);
|
||||
return {
|
||||
workspace,
|
||||
...(workflowId !== undefined && { workflowId }),
|
||||
workflowRunning: Boolean(workflowId && temporalUp && isWorkflowRunning(workflowId)),
|
||||
};
|
||||
});
|
||||
|
||||
// Resolve what is running before prompting, so we never confirm a no-op.
|
||||
if (initial.length === 0) {
|
||||
@@ -89,9 +166,8 @@ async function stopAllScans(yes: boolean): Promise<void> {
|
||||
const spinner = p.spinner();
|
||||
spinner.start('Stopping all scans');
|
||||
|
||||
if (temporalUp) {
|
||||
terminateAllWorkflows('Stopped via shannon stop --all');
|
||||
}
|
||||
await stopTargetsCancelFirst(targets);
|
||||
// Keep the legacy safety net for a worker whose workspace label was unavailable.
|
||||
await stopContainers(runningContainers(WORKER_FILTER));
|
||||
|
||||
const stillRunning = runningContainers(WORKER_FILTER);
|
||||
@@ -108,6 +184,24 @@ async function stopAllScans(yes: boolean): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer which scan `stop` acts on when neither a workspace nor --all was given: the single
|
||||
* running scan, announced on stderr so it is never a silent guess. Zero or several running
|
||||
* scans exit with guidance — there is no most-recent fallback, since stopping a finished
|
||||
* scan is a no-op.
|
||||
*/
|
||||
function resolveStopTarget(): string {
|
||||
const target = resolveDefaultWorkspace({ allowFinished: false });
|
||||
if (target.kind === 'ok') {
|
||||
console.error(`No workspace given; stopping running scan "${target.workspace}".`);
|
||||
return target.workspace;
|
||||
}
|
||||
if (target.kind === 'ambiguous') {
|
||||
failUsage('Multiple scans are running — specify which one, or use --all:', ` ${target.running.join(', ')}`);
|
||||
}
|
||||
fail('No running scans to stop.', 'Pass a workspace name to stop a specific scan.');
|
||||
}
|
||||
|
||||
export async function stop(opts: StopOptions): Promise<void> {
|
||||
ensureDocker();
|
||||
|
||||
@@ -115,12 +209,12 @@ export async function stop(opts: StopOptions): Promise<void> {
|
||||
if (opts.all && opts.workspace) {
|
||||
failUsage('Pass a workspace name or --all, not both.');
|
||||
}
|
||||
if (!opts.all && !opts.workspace) {
|
||||
failUsage('Specify which scan to stop: `stop <workspace>`, or `stop --all` to stop every scan.');
|
||||
}
|
||||
|
||||
if (opts.workspace) {
|
||||
await stopSingleScan(opts.workspace, opts.yes);
|
||||
// With no explicit target and no --all, default to the single running scan.
|
||||
const workspace = opts.all ? undefined : (opts.workspace ?? resolveStopTarget());
|
||||
|
||||
if (workspace) {
|
||||
await stopSingleScan(workspace, opts.yes);
|
||||
} else {
|
||||
await stopAllScans(opts.yes);
|
||||
}
|
||||
|
||||
+118
-19
@@ -14,7 +14,7 @@ import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { SpinnerResult } from '@clack/prompts';
|
||||
import { envBool, PI_AUTH_CONTAINER_PATH } from './env.js';
|
||||
import { fail } from './errors.js';
|
||||
import { fail, warn } from './errors.js';
|
||||
import { getMode, isDevMode } from './mode.js';
|
||||
import { INTERNAL_DIR } from './paths.js';
|
||||
import { runStep, spawnCaptured, surfaceOutput } from './ui.js';
|
||||
@@ -116,10 +116,8 @@ export function isTemporalReady(): boolean {
|
||||
return output.includes('SERVING');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure Temporal is running via compose.
|
||||
*/
|
||||
export async function ensureInfra(spinner: SpinnerResult): Promise<void> {
|
||||
/** Start (or find) Temporal via compose and wait until it serves; exits the process on failure. */
|
||||
async function ensureTemporalHealthy(spinner: SpinnerResult): Promise<void> {
|
||||
if (isTemporalReady()) {
|
||||
return;
|
||||
}
|
||||
@@ -146,6 +144,97 @@ export async function ensureInfra(spinner: SpinnerResult): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const DEFAULT_RETENTION_HOURS = 168;
|
||||
const RETENTION_ENV = 'SHANNON_TEMPORAL_RETENTION';
|
||||
const RETENTION_NAMESPACE = 'default';
|
||||
|
||||
/**
|
||||
* Desired retention in whole hours: unset or empty env → 168 (7 days); a positive
|
||||
* whole-hour override like `72h`; anything else warns and returns null (leave unchanged).
|
||||
*/
|
||||
function desiredRetentionHours(): number | null {
|
||||
const raw = process.env[RETENTION_ENV];
|
||||
if (raw === undefined || raw.trim() === '') {
|
||||
return DEFAULT_RETENTION_HOURS;
|
||||
}
|
||||
const match = raw.trim().match(/^([1-9][0-9]*)h$/);
|
||||
if (!match) {
|
||||
warn(
|
||||
`Ignoring invalid ${RETENTION_ENV} "${raw}" — Temporal retention left unchanged.`,
|
||||
'Use a positive whole number of hours, e.g. "168h".',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
/** Convert a Go duration such as "24h0m0s" or "168h" to whole seconds, or null when it doesn't parse. */
|
||||
function parseGoDurationSeconds(text: string): number | null {
|
||||
const match = text.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
|
||||
if (!match || (match[1] === undefined && match[2] === undefined && match[3] === undefined)) {
|
||||
return null;
|
||||
}
|
||||
const hours = Number(match[1] ?? 0);
|
||||
const minutes = Number(match[2] ?? 0);
|
||||
const seconds = Number(match[3] ?? 0);
|
||||
return hours * 3600 + minutes * 60 + seconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current retention of the `default` namespace in seconds, or null when it can't be read.
|
||||
* `runOutput` returns '' on a failed describe, so a failed read and an unparseable one both
|
||||
* collapse to null — either way the live value is unknown, which the caller handles the same way.
|
||||
*/
|
||||
function readCurrentRetentionSeconds(): number | null {
|
||||
const output = runOutput('docker', temporalCmd('operator', 'namespace', 'describe', RETENTION_NAMESPACE));
|
||||
const match = output.match(/WorkflowExecutionRetentionTtl\s+(\S+)/);
|
||||
if (!match || match[1] === undefined) {
|
||||
return null;
|
||||
}
|
||||
return parseGoDurationSeconds(match[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converge the `default` namespace's retention to the CLI-owned value after Temporal is
|
||||
* healthy. The CLI is the authority: a manual change is replaced on the next start unless
|
||||
* the operator sets the matching override. A describe or update failure warns once that the
|
||||
* requested value wasn't applied and never blocks the scan.
|
||||
*/
|
||||
function convergeNamespaceRetention(): void {
|
||||
const hours = desiredRetentionHours();
|
||||
if (hours === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSeconds = readCurrentRetentionSeconds();
|
||||
if (currentSeconds === null) {
|
||||
warn(
|
||||
`Could not read Temporal retention for namespace "${RETENTION_NAMESPACE}" — the requested value (${hours}h) was not applied.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentSeconds === hours * 3600) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = runQuiet(
|
||||
'docker',
|
||||
temporalCmd('operator', 'namespace', 'update', '--namespace', RETENTION_NAMESPACE, '--retention', `${hours}h`),
|
||||
);
|
||||
if (!updated) {
|
||||
warn(`Could not update Temporal retention to ${hours}h — the requested value was not applied.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure Temporal is running via compose, then converge its scan-history retention.
|
||||
*/
|
||||
export async function ensureInfra(spinner: SpinnerResult): Promise<void> {
|
||||
await ensureTemporalHealthy(spinner);
|
||||
convergeNamespaceRetention();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the worker image from the repository, tagged with the name this mode
|
||||
* resolves at run time.
|
||||
@@ -345,7 +434,7 @@ export function spawnWorker(opts: WorkerOptions): ChildProcess {
|
||||
args.push('-v', `${opts.config.hostPath}:${opts.config.containerPath}:ro`);
|
||||
}
|
||||
|
||||
// Output directory for deliverables copy
|
||||
// Customer-copy destination. The workflow surfaces only final report artifacts here.
|
||||
if (opts.outputDir) {
|
||||
args.push('-v', `${opts.outputDir}:/app/output`);
|
||||
}
|
||||
@@ -358,7 +447,10 @@ export function spawnWorker(opts: WorkerOptions): ChildProcess {
|
||||
// Environment
|
||||
args.push(...opts.envFlags);
|
||||
|
||||
// Container settings
|
||||
// Container settings. Chromium's own sandbox needs syscalls Docker's default seccomp
|
||||
// profile blocks, which is why the profile is dropped. `seccomp=unconfined` is a
|
||||
// container-wide setting, not a per-process one: every process here runs unfiltered,
|
||||
// the worker included — not just the browser automation that motivates it.
|
||||
args.push('--shm-size', '2gb', '--security-opt', 'seccomp=unconfined');
|
||||
|
||||
// Image
|
||||
@@ -405,6 +497,20 @@ export function runningContainers(filter: readonly string[]): string[] {
|
||||
return output.split('\n').filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace names of every running worker container, read from the shannon.workspace
|
||||
* label each scan is stamped with at spawn. This is the authoritative running-scan →
|
||||
* workspace-name map. Best-effort: empty when Docker is unreachable, which is the
|
||||
* correct answer anyway (no scan can be running without the daemon).
|
||||
*/
|
||||
export function runningScanWorkspaces(): string[] {
|
||||
const output = runOutput('docker', ['ps', ...WORKER_FILTER, '--format', `{{ index .Labels "${WORKSPACE_LABEL}" }}`]);
|
||||
return output
|
||||
.split('\n')
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop containers by ID, tolerating any that vanished between being listed and
|
||||
* stopped (a `--rm` worker exiting is success, not an error). Async so a spinner
|
||||
@@ -414,6 +520,11 @@ export async function stopContainers(ids: string[]): Promise<void> {
|
||||
await Promise.all(ids.map((id) => spawnQuiet('docker', ['stop', id])));
|
||||
}
|
||||
|
||||
/** Request cooperative cancellation so the workflow can run its terminal finalizer. */
|
||||
export function cancelWorkflow(workflowId: string): boolean {
|
||||
return runQuiet('docker', temporalCmd('workflow', 'cancel', '--workflow-id', workflowId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate a Temporal workflow so a stopped scan doesn't linger as a running
|
||||
* workflow with no worker. Best-effort: returns false if Temporal is unreachable
|
||||
@@ -423,18 +534,6 @@ export function terminateWorkflow(workflowId: string, reason: string): boolean {
|
||||
return runQuiet('docker', temporalCmd('workflow', 'terminate', '--workflow-id', workflowId, '--reason', reason));
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate every running pentest workflow in one batch, so `stop --all` doesn't
|
||||
* leave workflows running with no worker. Best-effort: returns false if Temporal
|
||||
* is unreachable. Requires Temporal to be up (guard with isTemporalReady).
|
||||
*/
|
||||
export function terminateAllWorkflows(reason: string): boolean {
|
||||
return runQuiet(
|
||||
'docker',
|
||||
temporalCmd('workflow', 'terminate', '--query', RUNNING_SCAN_QUERY, '--reason', reason, '--yes'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a specific workflow is still in the Running state. Re-querying this after
|
||||
* a terminate verifies it actually took effect, rather than trusting the terminate
|
||||
|
||||
+76
-37
@@ -1,37 +1,94 @@
|
||||
/**
|
||||
* Centralized error reporting.
|
||||
*
|
||||
* `fail` — an expected, user-fixable error (bad input, missing prerequisite):
|
||||
* a clean message on stderr and a non-zero exit, never a stack trace.
|
||||
* `fail` / `failWith` — an expected, user-fixable error (bad input, missing
|
||||
* prerequisite): a clean message on stderr and a non-zero exit, never a stack trace.
|
||||
* `failUsage` — a malformed invocation (unknown command, bad or missing
|
||||
* arguments): the same clean message, but a distinct exit code so callers can
|
||||
* tell a usage mistake from an operational failure.
|
||||
* `crash` — an unexpected error (a bug): a brief message, the full stack written
|
||||
* to a log file for a bug report, and a pointer to the issue tracker.
|
||||
* `crash` — an unexpected error (a bug): a fixed code and a pointer to the issue tracker.
|
||||
*
|
||||
* JSON mode (enabled once, before parsing, for the `--json` command surface) replaces
|
||||
* the text lines with one compact envelope on stderr — stdout stays empty — while the
|
||||
* exit-code split is unchanged. Call sites on a JSON-capable path must exit through
|
||||
* `failWith`/`failUsage`/`crash` (never a bare `fail` or `warn`) so every failure
|
||||
* carries a stable code and stderr stays parseable.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const ISSUES_URL = 'https://github.com/KeygraphHQ/shannon/issues';
|
||||
|
||||
/** Report an expected, user-fixable error (with optional extra lines) and exit non-zero. */
|
||||
export function fail(message: string, ...hints: string[]): never {
|
||||
const UNEXPECTED_MESSAGE = 'Shannon encountered an unexpected failure. Reference code: SHANNON_UNEXPECTED_ERROR';
|
||||
const REPORT_HINT = `If this looks like a bug, please report it: ${ISSUES_URL}`;
|
||||
|
||||
/** Stable machine-readable failure codes for the JSON error envelope. */
|
||||
export type ErrorCode =
|
||||
| 'CLI_USAGE'
|
||||
| 'CLI_SCAN_NOT_FOUND'
|
||||
| 'CLI_SCAN_IDENTITY_NOT_FOUND'
|
||||
| 'CLI_SCAN_IDENTITY_AMBIGUOUS'
|
||||
| 'CLI_SCAN_STATUS_UNAVAILABLE'
|
||||
| 'CLI_SCAN_SCHEMA_UNSUPPORTED'
|
||||
| 'CLI_PRECONDITION_FAILED'
|
||||
| 'CLI_INTERNAL_ERROR';
|
||||
|
||||
let jsonMode = false;
|
||||
|
||||
/** Switch failure reporting to the JSON envelope. Set once, before any guard, parse, or dispatch. */
|
||||
export function enableJsonErrors(): void {
|
||||
jsonMode = true;
|
||||
}
|
||||
|
||||
/** Whether failures are reported as the JSON envelope rather than text. */
|
||||
export function jsonErrorsEnabled(): boolean {
|
||||
return jsonMode;
|
||||
}
|
||||
|
||||
/** Fixed unexpected-failure projection shared by the runtime and focused safety tests. */
|
||||
export function unexpectedFailureLines(): readonly string[] {
|
||||
return [`ERROR: ${UNEXPECTED_MESSAGE}`, REPORT_HINT];
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a failure on stderr and exit. Text mode prints the message and every hint
|
||||
* verbatim; JSON mode writes one compact envelope (dropping the empty strings used
|
||||
* to space text output) synchronously so `process.exit` cannot truncate it.
|
||||
*/
|
||||
function emit(exitCode: 1 | 2, code: ErrorCode, message: string, hints: readonly string[]): never {
|
||||
if (jsonMode) {
|
||||
const payload = JSON.stringify({ error: { code, message, hints: hints.filter((hint) => hint.trim() !== '') } });
|
||||
fs.writeSync(process.stderr.fd, `${payload}\n`);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
console.error(`ERROR: ${message}`);
|
||||
for (const hint of hints) {
|
||||
console.error(hint);
|
||||
}
|
||||
process.exit(1);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Report an expected, user-fixable error (with optional extra lines) and exit non-zero.
|
||||
* Text-only paths use this; a JSON-capable path must use `failWith` so the envelope
|
||||
* carries a real code — if a bare `fail` is ever reached in JSON mode, the fixed
|
||||
* internal-error envelope is emitted instead of guessing a code for the message.
|
||||
*/
|
||||
export function fail(message: string, ...hints: string[]): never {
|
||||
if (jsonMode) {
|
||||
emit(1, 'CLI_INTERNAL_ERROR', UNEXPECTED_MESSAGE, [REPORT_HINT]);
|
||||
}
|
||||
emit(1, 'CLI_INTERNAL_ERROR', message, hints);
|
||||
}
|
||||
|
||||
/** Report an expected operational failure under a stable code and exit 1. */
|
||||
export function failWith(code: ErrorCode, message: string, ...hints: string[]): never {
|
||||
emit(1, code, message, hints);
|
||||
}
|
||||
|
||||
/** Report a usage/argument error (with optional extra lines) and exit 2. */
|
||||
export function failUsage(message: string, ...hints: string[]): never {
|
||||
console.error(`ERROR: ${message}`);
|
||||
for (const hint of hints) {
|
||||
console.error(hint);
|
||||
}
|
||||
process.exit(2);
|
||||
emit(2, 'CLI_USAGE', message, hints);
|
||||
}
|
||||
|
||||
/** Report a non-fatal warning on stderr (with optional extra lines) without exiting. */
|
||||
@@ -42,29 +99,11 @@ export function warn(message: string, ...hints: string[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Report an unexpected error: brief message, full stack to a log file, plus the issue link. */
|
||||
export function crash(error: unknown): never {
|
||||
console.error(`ERROR: ${error instanceof Error ? error.message : String(error)}`);
|
||||
if (process.env.DEBUG) {
|
||||
console.error(error instanceof Error ? error.stack : String(error));
|
||||
/** Report an unexpected error without projecting its message, stack, or attached values. */
|
||||
export function crash(_error: unknown): never {
|
||||
if (jsonMode) {
|
||||
emit(1, 'CLI_INTERNAL_ERROR', UNEXPECTED_MESSAGE, [REPORT_HINT]);
|
||||
}
|
||||
|
||||
const logPath = writeCrashLog(error);
|
||||
if (logPath) {
|
||||
console.error(`Details written to ${logPath}`);
|
||||
}
|
||||
console.error(`If this looks like a bug, please report it: ${ISSUES_URL}`);
|
||||
for (const line of unexpectedFailureLines()) console.error(line);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** Write the full error and stack to a log file; return its path, or null if it can't be written. */
|
||||
function writeCrashLog(error: unknown): string | null {
|
||||
try {
|
||||
const logPath = path.join(os.tmpdir(), 'shannon-error.log');
|
||||
const detail = error instanceof Error && error.stack ? error.stack : String(error);
|
||||
fs.writeFileSync(logPath, `${new Date().toISOString()}\n${detail}\n`);
|
||||
return logPath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+21
-10
@@ -47,30 +47,32 @@ const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
|
||||
],
|
||||
},
|
||||
stop: {
|
||||
usage: ['stop <workspace> [--yes]', 'stop --all [--yes]'],
|
||||
description: 'Stop one scan by workspace, or every scan with --all (Temporal stays up).',
|
||||
usage: ['stop [<workspace>] [--yes]', 'stop --all [--yes]'],
|
||||
description:
|
||||
'Stop one scan by workspace, or every scan with --all (Temporal stays up). With no workspace, stops the single running scan; when several are running, name one or use --all.',
|
||||
options: [['--all', 'Stop all running scans'], YES_OPTION],
|
||||
examples: ['stop q1-audit', 'stop --all'],
|
||||
examples: ['stop', 'stop q1-audit', 'stop --all'],
|
||||
},
|
||||
reset: {
|
||||
usage: ['reset'],
|
||||
description: 'Stop everything and permanently remove all Temporal data and volumes.',
|
||||
},
|
||||
logs: {
|
||||
usage: ['logs <workspace>'],
|
||||
description: "Tail a scan's live log until it completes.",
|
||||
examples: ['logs q1-audit'],
|
||||
usage: ['logs [<workspace>]'],
|
||||
description:
|
||||
"Tail a scan's live log until it completes. With no workspace, follows the single running scan, or the most recent workspace when none is running; when several are running, name one.",
|
||||
examples: ['logs', 'logs q1-audit'],
|
||||
},
|
||||
status: {
|
||||
usage: ['status <workspace> [--json]'],
|
||||
usage: ['status [<workspace>] [--json]'],
|
||||
description:
|
||||
"Show one scan's phase-by-phase progress, read live from Temporal. Watches and redraws until the scan finishes on a terminal; prints one frame when piped or already finished. With --json, prints a single machine-readable snapshot and exits.",
|
||||
"Show one scan's phase-by-phase progress, read live from Temporal. With no workspace, shows the single running scan, or the most recent workspace when none is running; when several are running, name one. Watches and redraws until the scan finishes on a terminal; prints one frame when piped or already finished. With --json, prints a single machine-readable snapshot and exits.",
|
||||
options: [['--json', 'Output a point-in-time snapshot as JSON, then exit']],
|
||||
examples: ['status q1-audit', 'status q1-audit --json'],
|
||||
examples: ['status', 'status q1-audit', 'status q1-audit --json'],
|
||||
},
|
||||
scans: {
|
||||
usage: ['scans [--json]'],
|
||||
description: 'List completed scans and where each report lives.',
|
||||
description: 'List running and completed scans, and where each finished report lives.',
|
||||
options: [['--json', 'Output the scan list as JSON']],
|
||||
examples: ['scans', 'scans --json'],
|
||||
},
|
||||
@@ -102,6 +104,15 @@ export function isHelpableCommand(command: string): boolean {
|
||||
return command in COMMAND_HELP;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every explicit help topic, mode-blind, with `help` itself as the known global topic.
|
||||
* Topic lookup is deliberately not mode-filtered (unlike `availableCommands`) so
|
||||
* cross-mode help such as local `help setup` and npx `help build` keeps working.
|
||||
*/
|
||||
export function helpTopics(): readonly string[] {
|
||||
return [...Object.keys(COMMAND_HELP), 'help'];
|
||||
}
|
||||
|
||||
/**
|
||||
* User-facing command names available in the current mode, for "did you mean?"
|
||||
* suggestions. Derived from the same table that backs per-command help, so the
|
||||
|
||||
+109
-19
@@ -18,14 +18,21 @@ import { setup } from './commands/setup.js';
|
||||
import { start } from './commands/start.js';
|
||||
import { status } from './commands/status.js';
|
||||
import { stop } from './commands/stop.js';
|
||||
import { crash, fail, failUsage } from './errors.js';
|
||||
import { availableCommands, isHelpableCommand, printCommandHelp, START_OPTIONS } from './help.js';
|
||||
import { crash, enableJsonErrors, fail, failUsage, failWith, jsonErrorsEnabled } from './errors.js';
|
||||
import { availableCommands, helpTopics, isHelpableCommand, printCommandHelp, START_OPTIONS } from './help.js';
|
||||
import { commandPrefix, getMode, isLocal, type Mode } from './mode.js';
|
||||
import { displaySplash } from './splash.js';
|
||||
import { closestMatch } from './suggest.js';
|
||||
import { stdoutIsTerminal } from './tty.js';
|
||||
import { getVersion, getVersionLine } from './version.js';
|
||||
import { resolveDefaultWorkspace } from './workspaces.js';
|
||||
|
||||
/**
|
||||
* Refuse to run as root or under sudo. The worker container's Linux UID remapping
|
||||
* (docker.ts) stamps bind-mounted files with the invoking user's real uid/gid; under
|
||||
* sudo that uid is 0, so the repo, workspace, and report files would come back
|
||||
* owned by root instead of the person who ran the scan.
|
||||
*/
|
||||
function blockSudo(): void {
|
||||
const isSudo = !!process.env.SUDO_USER;
|
||||
const isRoot = process.geteuid?.() === 0;
|
||||
@@ -37,15 +44,38 @@ function blockSudo(): void {
|
||||
: [];
|
||||
|
||||
if (isSudo) {
|
||||
fail('Shannon must not be run with sudo.', 'Re-run this command as your normal user.', ...linuxHints);
|
||||
failWith(
|
||||
'CLI_PRECONDITION_FAILED',
|
||||
'Shannon must not be run with sudo.',
|
||||
'Re-run this command as your normal user.',
|
||||
...linuxHints,
|
||||
);
|
||||
}
|
||||
fail(
|
||||
failWith(
|
||||
'CLI_PRECONDITION_FAILED',
|
||||
'Shannon must not be run as the root user.',
|
||||
'Switch to a regular user account and re-run this command.',
|
||||
...linuxHints,
|
||||
);
|
||||
}
|
||||
|
||||
/** Commands whose `--json` output contract extends to failures. */
|
||||
const JSON_CAPABLE_COMMANDS = new Set(['status', 'scans', 'version', '--version', '-v']);
|
||||
|
||||
/**
|
||||
* Raw-argv sniff for the JSON error latch, decided before any guard or parse so even
|
||||
* a pre-dispatch failure honors it. Latches on `--json` or a malformed `--json=<value>`
|
||||
* (which still fails as a parse error — inside the envelope). Any other command that
|
||||
* receives `--json` keeps its normal unknown-option behavior.
|
||||
*/
|
||||
function wantsJsonErrors(argv: readonly string[]): boolean {
|
||||
const command = argv[0];
|
||||
if (command === undefined || !JSON_CAPABLE_COMMANDS.has(command)) {
|
||||
return false;
|
||||
}
|
||||
return argv.slice(1).some((arg) => arg === '--json' || arg.startsWith('--json='));
|
||||
}
|
||||
|
||||
/** Render `start`'s flags for the global help, from the same source as `start --help`. */
|
||||
function renderStartOptions(): string {
|
||||
const flagWidth = Math.max(...START_OPTIONS.map(([flag]) => flag.length));
|
||||
@@ -60,12 +90,16 @@ function renderUsage(prefix: string, mode: Mode): string {
|
||||
const rows: ReadonlyArray<readonly [string, string]> = [
|
||||
...(mode === 'local' ? [] : [[`${prefix} setup`, 'Configure credentials'] as const]),
|
||||
[`${prefix} start --url <url> --repo <path> [options]`, 'Start a pentest scan'],
|
||||
[`${prefix} stop <workspace> [--yes]`, 'Stop one scan'],
|
||||
[`${prefix} stop [<workspace>] [--yes]`, 'Stop one scan (default: the single running scan)'],
|
||||
[`${prefix} stop --all [--yes]`, 'Stop all scans (Temporal stays up)'],
|
||||
[`${prefix} reset`, 'Stop everything and wipe all Temporal data'],
|
||||
[`${prefix} logs <workspace>`, "Show a scan's live log"],
|
||||
[`${prefix} status <workspace> [--json]`, 'Live phase/agent progress of one scan'],
|
||||
[`${prefix} scans [--json]`, 'List completed scans and their reports'],
|
||||
[`${prefix} logs [<workspace>]`, "Show a scan's live log (default: running or most recent)"],
|
||||
[`${prefix} logs [<workspace>] --agent <name>`, "Tail one agent's log; --list-agents to list them"],
|
||||
[
|
||||
`${prefix} status [<workspace>] [--json]`,
|
||||
'Live phase/agent progress of one scan (default: running or most recent)',
|
||||
],
|
||||
[`${prefix} scans [--json]`, 'List running and completed scans'],
|
||||
...(mode === 'local' ? [[`${prefix} build [--no-cache]`, 'Build worker image'] as const] : []),
|
||||
[`${prefix} version [--json]`, 'Show version'],
|
||||
[`${prefix} help`, 'Show this help'],
|
||||
@@ -152,6 +186,32 @@ function parseStartArgs(argv: string[]): ParsedStartArgs {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the workspace a viewing command (`logs`, `status`) acts on: the name the user
|
||||
* gave, or an inferred default. An inferred choice is announced on stderr so it is never a
|
||||
* silent guess; when nothing can be inferred, exit with usage guidance.
|
||||
*/
|
||||
function resolveViewingWorkspace(positional: string | undefined, usage: string): string {
|
||||
if (positional) {
|
||||
return positional;
|
||||
}
|
||||
|
||||
const target = resolveDefaultWorkspace({ allowFinished: true });
|
||||
if (target.kind === 'ok') {
|
||||
// In JSON mode stderr is reserved for the single error envelope, so a successful
|
||||
// inference stays silent — the JSON payload itself names the chosen workspace.
|
||||
if (!jsonErrorsEnabled()) {
|
||||
const which = target.running ? 'running scan' : 'most recent scan';
|
||||
console.error(`No workspace given; using ${which} "${target.workspace}".`);
|
||||
}
|
||||
return target.workspace;
|
||||
}
|
||||
if (target.kind === 'ambiguous') {
|
||||
failUsage('Multiple scans are running — specify which one:', ` ${target.running.join(', ')}`, '', usage);
|
||||
}
|
||||
failUsage('Workspace is required', usage);
|
||||
}
|
||||
|
||||
// === Main Dispatch ===
|
||||
|
||||
async function main(): Promise<void> {
|
||||
@@ -163,13 +223,17 @@ async function main(): Promise<void> {
|
||||
throw err;
|
||||
});
|
||||
|
||||
if (wantsJsonErrors(process.argv.slice(2))) {
|
||||
enableJsonErrors();
|
||||
}
|
||||
|
||||
blockSudo();
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
const rest = args.slice(1);
|
||||
|
||||
if (command === undefined || command === 'help' || command === '--help' || command === '-h') {
|
||||
if (command === undefined || command === '--help' || command === '-h') {
|
||||
const topic = rest[0];
|
||||
if (topic && isHelpableCommand(topic)) {
|
||||
printCommandHelp(topic);
|
||||
@@ -181,6 +245,27 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// An explicit `help <topic>` names a topic on purpose, so an unknown one is a usage
|
||||
// error — unlike `--help <junk>`, where the junk is ignored and global help wins.
|
||||
if (command === 'help') {
|
||||
const topic = rest[0];
|
||||
// A flag (`help --help`) is a help request, not a topic name.
|
||||
if (topic === undefined || topic === 'help' || topic.startsWith('-')) {
|
||||
showHelp(false);
|
||||
return;
|
||||
}
|
||||
if (isHelpableCommand(topic)) {
|
||||
printCommandHelp(topic);
|
||||
return;
|
||||
}
|
||||
const suggestion = closestMatch(topic, helpTopics());
|
||||
failUsage(
|
||||
`Unknown help topic: ${topic}`,
|
||||
...(suggestion ? [`Did you mean '${suggestion}'?`] : []),
|
||||
`Run '${commandPrefix()} help' to see available commands.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Reachable from any invocation: `-h`/`--help` anywhere wins over the rest of the line.
|
||||
if (isHelpableCommand(command) && (rest.includes('-h') || rest.includes('--help'))) {
|
||||
printCommandHelp(command);
|
||||
@@ -210,20 +295,25 @@ async function main(): Promise<void> {
|
||||
break;
|
||||
}
|
||||
case 'logs': {
|
||||
const { positionals } = parseArgs(rest, { maxPositionals: 1 });
|
||||
const workspaceId = positionals[0];
|
||||
if (!workspaceId) {
|
||||
failUsage('Workspace ID is required', `Usage: ${commandPrefix()} logs <workspace>`);
|
||||
}
|
||||
logs(workspaceId);
|
||||
const { flags, values, positionals } = parseArgs(rest, {
|
||||
booleans: { listAgents: ['--list-agents'] },
|
||||
values: { agent: ['--agent'] },
|
||||
maxPositionals: 1,
|
||||
});
|
||||
const workspaceId = resolveViewingWorkspace(
|
||||
positionals[0],
|
||||
`Usage: ${commandPrefix()} logs [<workspace>] [--agent <name>] [--list-agents]`,
|
||||
);
|
||||
logs(workspaceId, {
|
||||
...(values.agent !== undefined && { agent: values.agent }),
|
||||
...(flags.listAgents && { listAgents: true }),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'status': {
|
||||
const { flags, positionals } = parseArgs(rest, { booleans: { json: ['--json'] }, maxPositionals: 1 });
|
||||
const workspaceId = positionals[0];
|
||||
if (!workspaceId) {
|
||||
failUsage('Workspace is required', `Usage: ${commandPrefix()} status <workspace> [--json]`);
|
||||
}
|
||||
const usage = `Usage: ${commandPrefix()} status [<workspace>] [--json]`;
|
||||
const workspaceId = resolveViewingWorkspace(positionals[0], usage);
|
||||
await status(workspaceId, { json: !!flags.json });
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,12 @@ export const INTERNAL_DIR = '.shannon';
|
||||
*/
|
||||
export const FINAL_REPORT_PDF_FILENAME = 'Security-Assessment-Report.pdf';
|
||||
|
||||
/**
|
||||
* Customer-facing Markdown report name at the run root.
|
||||
* Must match FINAL_REPORT_MD_FILENAME in the worker package.
|
||||
*/
|
||||
export const FINAL_REPORT_MD_FILENAME = 'Security-Assessment-Report.md';
|
||||
|
||||
/**
|
||||
* Resolve a run-directory file (e.g. session.json, workflow.log), preferring the
|
||||
* current INTERNAL_DIR location and falling back to the legacy run-root location
|
||||
|
||||
+267
-18
@@ -8,8 +8,17 @@
|
||||
*/
|
||||
|
||||
import type { RunningAgent } from '../temporal-client.js';
|
||||
import { agentClass, PIPELINE, type PipelineState } from './pipeline.js';
|
||||
import {
|
||||
AGENTIC_SAST_STAGE_ORDER,
|
||||
agentClass,
|
||||
isModelBackedOperation,
|
||||
type OperationalStageState,
|
||||
operationFamilyKey,
|
||||
type PipelineState,
|
||||
pipelineForState,
|
||||
} from './pipeline.js';
|
||||
import type { RenderInput } from './render.js';
|
||||
import { safeFailureDetail, safeOperationKey, safeOperationLabel } from './safe-fields.js';
|
||||
|
||||
export type RunState = 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
|
||||
|
||||
@@ -22,14 +31,33 @@ export interface DerivedAgent {
|
||||
readonly durationMs: number | null;
|
||||
readonly runningElapsedMs: number | null;
|
||||
readonly attempt: number | null;
|
||||
/** The step a running operation row is currently on, merged in from its child activity. */
|
||||
readonly detail?: string;
|
||||
/** Reconciliation time for this agent's class, rendered as a trailing `+ duration`.
|
||||
* Reconciliation is model work that produces this agent's inputs, so it is shown
|
||||
* attached to the agent it feeds rather than as free-floating background work. */
|
||||
readonly attachedMs?: number;
|
||||
/** This class's findings could not be grouped, so each one became its own task. */
|
||||
readonly ungrouped?: boolean;
|
||||
readonly error?: string;
|
||||
}
|
||||
|
||||
/** How a phase line summarizes itself: its own wall time, or a k/N tally over its children. */
|
||||
export type PhaseMetaKind = 'duration' | 'count';
|
||||
|
||||
export interface DerivedPhase {
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly parallel: boolean;
|
||||
/** Whether the phase renders its agents as sub-rows. Independent of {@link meta}:
|
||||
* Agentic SAST lists its stages under a duration, exploitation lists its classes under a tally. */
|
||||
readonly children: boolean;
|
||||
readonly meta: PhaseMetaKind;
|
||||
readonly state: RunState;
|
||||
/** The phase's own span, when the worker records one for the phase rather than for a single
|
||||
* agent inside it (Agentic SAST). The phase line presents this exactly like an agent row. */
|
||||
readonly summary?: DerivedAgent;
|
||||
/** Rendered after the phase's summary, e.g. to mark work that overlaps other phases. */
|
||||
readonly note?: string;
|
||||
readonly agents: readonly DerivedAgent[];
|
||||
}
|
||||
|
||||
@@ -48,12 +76,12 @@ function isAgentActive(name: string, state: PipelineState | null, running: Set<s
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one agent's state. "Ran" is signalled by a metrics entry, not by
|
||||
* completedAgents — the workflow lists conditionally-skipped agents (e.g. exploit
|
||||
* agents when there is nothing to exploit) as completed but records no metrics for
|
||||
* them. `resolved` is true once we've moved past this agent's phase (the scan is
|
||||
* terminal, or a later phase is already active), at which point a metric-less,
|
||||
* non-running agent is skipped rather than still pending.
|
||||
* Resolve one agent's state. "Ran" is signalled by a metrics entry: a
|
||||
* conditionally-skipped agent (e.g. an exploit agent when there is nothing to
|
||||
* exploit) records no metrics, and the workflow tracks it in skippedAgents rather
|
||||
* than completedAgents. `resolved` is true once we've moved past this agent's phase
|
||||
* (the scan is terminal, or a later phase is already active), at which point a
|
||||
* metric-less, non-running agent is skipped rather than still pending.
|
||||
*/
|
||||
function agentState(name: string, state: PipelineState | null, running: Set<string>, resolved: boolean): RunState {
|
||||
if (running.has(name)) return 'running';
|
||||
@@ -62,13 +90,16 @@ function agentState(name: string, state: PipelineState | null, running: Set<stri
|
||||
return resolved ? 'skipped' : 'pending';
|
||||
}
|
||||
|
||||
/**
|
||||
* Only `failed`'s presence is used here, never its `.error` text: that string is the
|
||||
* worker's raw error for the failed class, not vetted for display, so it is reduced to
|
||||
* a boolean before reaching safeFailureDetail's fixed sentence.
|
||||
*/
|
||||
function agentError(name: string, state: PipelineState | null, byAgent: Map<string, RunningAgent>): string | undefined {
|
||||
const failed = state?.failedPipelines.find((f) => f.vulnType === agentClass(name));
|
||||
return (
|
||||
failed?.error ??
|
||||
byAgent.get(name)?.lastFailure ??
|
||||
(state?.failedAgent === name ? (state.error ?? undefined) : undefined)
|
||||
);
|
||||
const hasFailure =
|
||||
failed !== undefined || byAgent.get(name)?.lastFailure !== undefined || state?.failedAgent === name;
|
||||
return safeFailureDetail(hasFailure);
|
||||
}
|
||||
|
||||
/** Scan wall-clock elapsed ms: recorded duration for a closed scan, live elapsed for a running one. */
|
||||
@@ -99,16 +130,17 @@ export function phaseGlyphState(states: readonly RunState[]): RunState {
|
||||
* class had anything to exploit), not still pending.
|
||||
*/
|
||||
export function deriveAgentStates(input: RenderInput): Map<string, RunState> {
|
||||
const runningSet = new Set(input.running.map((r) => r.agent));
|
||||
const pipeline = pipelineForState(input.state);
|
||||
const runningSet = new Set(input.running.filter((runner) => runner.kind === 'agent').map((runner) => runner.agent));
|
||||
const terminal = isTerminal(input.temporalStatus);
|
||||
|
||||
let frontier = -1;
|
||||
PIPELINE.forEach((phase, idx) => {
|
||||
pipeline.forEach((phase, idx) => {
|
||||
if (phase.agents.some((a) => isAgentActive(a.name, input.state, runningSet))) frontier = idx;
|
||||
});
|
||||
|
||||
const states = new Map<string, RunState>();
|
||||
for (const [phaseIdx, phase] of PIPELINE.entries()) {
|
||||
for (const [phaseIdx, phase] of pipeline.entries()) {
|
||||
const resolved = terminal || phaseIdx < frontier;
|
||||
for (const agent of phase.agents) {
|
||||
states.set(agent.name, agentState(agent.name, input.state, runningSet, resolved));
|
||||
@@ -117,6 +149,52 @@ export function deriveAgentStates(input: RenderInput): Map<string, RunState> {
|
||||
return states;
|
||||
}
|
||||
|
||||
/** Which operation families have a running parent stage, and the step to show on it. */
|
||||
interface OperationFamilyView {
|
||||
/** Families whose parent stage row already represents their child activities. */
|
||||
readonly runningFamilies: ReadonlySet<string>;
|
||||
/** Family to current step, present only where the child activities agree on one. */
|
||||
readonly stepByFamily: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the parent stage rows that own their family's child activities. A family only
|
||||
* resolves to a step when its running children agree: several classes reconcile at once and
|
||||
* their pending activities carry no class, so a family caught mid-stride shows its parent
|
||||
* rows without a step rather than attributing one to the wrong class.
|
||||
*/
|
||||
function operationFamilyView(
|
||||
running: readonly RunningAgent[],
|
||||
persistedOperations: readonly OperationalStageState[],
|
||||
): OperationFamilyView {
|
||||
const runningFamilies = new Set(
|
||||
persistedOperations
|
||||
.filter((operation) => operation.status === 'running')
|
||||
.map((operation) => operationFamilyKey(operation.key)),
|
||||
);
|
||||
|
||||
const labelsByFamily = new Map<string, Set<string>>();
|
||||
for (const runner of running) {
|
||||
if (runner.kind !== 'operation' || runner.parentKey === undefined) continue;
|
||||
if (!runningFamilies.has(runner.parentKey)) continue;
|
||||
const labels = labelsByFamily.get(runner.parentKey) ?? new Set<string>();
|
||||
labels.add(runner.label);
|
||||
labelsByFamily.set(runner.parentKey, labels);
|
||||
}
|
||||
|
||||
const stepByFamily = new Map<string, string>();
|
||||
for (const [family, labels] of labelsByFamily) {
|
||||
const [onlyLabel] = labels;
|
||||
if (labels.size === 1 && onlyLabel !== undefined) stepByFamily.set(family, lowercaseFirst(onlyLabel));
|
||||
}
|
||||
return { runningFamilies, stepByFamily };
|
||||
}
|
||||
|
||||
/** Progress labels are written to start a row; as a detail they continue a sentence. */
|
||||
function lowercaseFirst(label: string): string {
|
||||
return label.charAt(0).toLowerCase() + label.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full structured view of the pipeline: every agent's state plus the raw
|
||||
* metrics/timing needed to present it, and each phase's collapsed state.
|
||||
@@ -124,8 +202,9 @@ export function deriveAgentStates(input: RenderInput): Map<string, RunState> {
|
||||
export function derivePipeline(input: RenderInput, now: number): DerivedPhase[] {
|
||||
const states = deriveAgentStates(input);
|
||||
const byAgent = new Map(input.running.map((r) => [r.agent, r]));
|
||||
const pipeline = pipelineForState(input.state);
|
||||
|
||||
return PIPELINE.map((phase) => {
|
||||
const agentPhases = pipeline.map((phase) => {
|
||||
const agents = phase.agents.map((a): DerivedAgent => {
|
||||
const state = states.get(a.name) ?? 'pending';
|
||||
const metrics = input.state?.agentMetrics[a.name];
|
||||
@@ -145,11 +224,181 @@ export function derivePipeline(input: RenderInput, now: number): DerivedPhase[]
|
||||
return {
|
||||
key: phase.key,
|
||||
label: phase.label,
|
||||
parallel: phase.parallel,
|
||||
children: phase.parallel,
|
||||
meta: phase.parallel ? ('count' as const) : ('duration' as const),
|
||||
state: phaseGlyphState(agents.map((ag) => ag.state)),
|
||||
agents,
|
||||
};
|
||||
});
|
||||
|
||||
// Operational rows merge two sources: stages the worker has persisted (durable truth,
|
||||
// including terminal outcomes) and pending activities whose stage record has not landed
|
||||
// yet. Persisted keys win, so a stage is never listed twice while the two views overlap.
|
||||
const persistedOperations = Object.values(input.state?.operationalStages ?? {});
|
||||
const persistedKeys = new Set(persistedOperations.map((operation) => operation.key));
|
||||
const { runningFamilies, stepByFamily } = operationFamilyView(input.running, persistedOperations);
|
||||
const unpersistedRunning = input.running
|
||||
.filter((runner) => runner.kind === 'operation' && !persistedKeys.has(runner.agent))
|
||||
// A child activity whose family already has a running parent stage is that stage's current
|
||||
// step, not separate work: the parent row below represents it, with the step as its detail
|
||||
// where the family's children agree on one. Without such a parent it keeps its own row.
|
||||
.filter((runner) => runner.parentKey === undefined || !runningFamilies.has(runner.parentKey))
|
||||
.map((runner) => ({
|
||||
key: runner.agent,
|
||||
label: runner.label,
|
||||
status: 'running' as const,
|
||||
...(runner.startedAt !== undefined && { startedAt: runner.startedAt }),
|
||||
...(runner.lastFailure !== undefined && { error: safeFailureDetail(true) }),
|
||||
}));
|
||||
const operationalAgents: DerivedAgent[] = [...persistedOperations, ...unpersistedRunning].map((operation) => {
|
||||
const runner = byAgent.get(operation.key);
|
||||
const operationState = operation.status as RunState;
|
||||
const persistedDurationMs = 'durationMs' in operation ? (operation.durationMs ?? null) : null;
|
||||
const detail = operationState === 'running' ? stepByFamily.get(operationFamilyKey(operation.key)) : undefined;
|
||||
return {
|
||||
name: safeOperationKey(operation.key),
|
||||
label: safeOperationLabel(operation.label),
|
||||
state: operationState,
|
||||
durationMs: operationState === 'completed' ? persistedDurationMs : null,
|
||||
runningElapsedMs:
|
||||
operationState === 'running' && operation.startedAt !== undefined ? now - operation.startedAt : null,
|
||||
attempt: operationState === 'running' ? (runner?.attempt ?? null) : null,
|
||||
...(detail !== undefined && { detail }),
|
||||
...(operation.error !== undefined && { error: safeFailureDetail(true) }),
|
||||
};
|
||||
});
|
||||
|
||||
// Operational rows are not peers of the agents. Each one is either model work that
|
||||
// belongs to an agent (reconciliation), model work that belongs to the SAST engine
|
||||
// (its stages), or bookkeeping that only earns a row when it is stuck or broken.
|
||||
return assemblePhases(agentPhases, operationalAgents);
|
||||
}
|
||||
|
||||
/** Reconciliation wall time per vulnerability class, plus the classes whose grouping degraded. */
|
||||
interface ReconciliationView {
|
||||
readonly durationByClass: ReadonlyMap<string, number>;
|
||||
readonly ungroupedClasses: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
function reconciliationView(operations: readonly DerivedAgent[]): ReconciliationView {
|
||||
const durationByClass = new Map<string, number>();
|
||||
const ungroupedClasses = new Set<string>();
|
||||
for (const operation of operations) {
|
||||
if (operationFamilyKey(operation.name) !== 'reconciliation') continue;
|
||||
const [, vulnerabilityClass] = operation.name.split(':');
|
||||
if (vulnerabilityClass === undefined) continue;
|
||||
if (operation.name.endsWith(':fallback')) {
|
||||
ungroupedClasses.add(vulnerabilityClass);
|
||||
continue;
|
||||
}
|
||||
if (operation.durationMs !== null) durationByClass.set(vulnerabilityClass, operation.durationMs);
|
||||
}
|
||||
return { durationByClass, ungroupedClasses };
|
||||
}
|
||||
|
||||
/** Attach each class's reconciliation time to the agent row it feeds. */
|
||||
function withReconciliation(phase: DerivedPhase, view: ReconciliationView): DerivedPhase {
|
||||
const agents = phase.agents.map((agent): DerivedAgent => {
|
||||
const vulnerabilityClass = agentClass(agent.name);
|
||||
const attachedMs = view.durationByClass.get(vulnerabilityClass);
|
||||
const ungrouped = view.ungroupedClasses.has(vulnerabilityClass);
|
||||
return {
|
||||
...agent,
|
||||
...(attachedMs !== undefined && { attachedMs }),
|
||||
...(ungrouped && { ungrouped }),
|
||||
};
|
||||
});
|
||||
return { ...phase, agents };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Agentic SAST phase from the aggregate span the parent workflow records and the
|
||||
* per-stage rows the SAST child signals up. Scans that predate stage signalling have the
|
||||
* aggregate but no stages, and render as a bare phase line rather than an error.
|
||||
*/
|
||||
function agenticSastPhase(operations: readonly DerivedAgent[]): DerivedPhase | undefined {
|
||||
const aggregate = operations.find((operation) => operation.name === 'agentic-sast');
|
||||
if (aggregate === undefined) return undefined;
|
||||
|
||||
const byStage = new Map<string, DerivedAgent>();
|
||||
for (const operation of operations) {
|
||||
const [family, stage] = operation.name.split(':');
|
||||
if (family !== 'agentic-sast' || stage === undefined) continue;
|
||||
// The worker's label is the scan log's Title Case form. These rows sit beside the
|
||||
// lowercase class rows below them, so they read in the same register here.
|
||||
byStage.set(stage, { ...operation, label: lowercaseFirst(operation.label) });
|
||||
}
|
||||
// Run order, not insertion order: a resumed or replayed run can persist stages out of order.
|
||||
const stages = AGENTIC_SAST_STAGE_ORDER.map((stage) => byStage.get(stage)).filter(
|
||||
(stage): stage is DerivedAgent => stage !== undefined,
|
||||
);
|
||||
|
||||
return {
|
||||
key: 'agentic-sast',
|
||||
label: 'Agentic SAST',
|
||||
children: stages.length > 0,
|
||||
meta: 'duration',
|
||||
state: aggregate.state,
|
||||
summary: aggregate,
|
||||
// It shares wall time with the pentest phases below it, so the times do not add up
|
||||
// in sequence. Saying so is cheaper than a layout that pretends to be two columns.
|
||||
note: 'concurrent',
|
||||
agents: stages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookkeeping rows worth showing. A deterministic stage that has completed says nothing —
|
||||
* it can only ever read 0s — but one that is still running, or that failed, is exactly what
|
||||
* an operator needs to see, so those keep a row under the phase they belong to.
|
||||
*/
|
||||
function troubledReportSteps(operations: readonly DerivedAgent[]): readonly DerivedAgent[] {
|
||||
return operations.filter((operation) => {
|
||||
if (isModelBackedOperation(operation.name)) return false;
|
||||
if (operationFamilyKey(operation.name) !== 'report') return false;
|
||||
return operation.state === 'running' || operation.state === 'failed';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold operational rows into the agent phases. Nothing here becomes a bucket of its own:
|
||||
* every surviving row is either a SAST stage, time attached to an agent, or a report step
|
||||
* that is currently in trouble.
|
||||
*/
|
||||
function assemblePhases(agentPhases: readonly DerivedPhase[], operations: readonly DerivedAgent[]): DerivedPhase[] {
|
||||
const view = reconciliationView(operations);
|
||||
// Reconciliation produces the exploitation queue, so its time belongs on the exploitation
|
||||
// row it feeds. With exploitation off there is no such row, and it falls back to the
|
||||
// analysis row for the same class so the time is never silently dropped.
|
||||
const attachTo = agentPhases.some((phase) => phase.key === 'exploitation')
|
||||
? 'exploitation'
|
||||
: 'vulnerability-analysis';
|
||||
const reportSteps = troubledReportSteps(operations);
|
||||
|
||||
const phases = agentPhases.map((phase) => {
|
||||
if (phase.key === attachTo) return withReconciliation(phase, view);
|
||||
if (phase.key === 'reporting' && reportSteps.length > 0) {
|
||||
// The report agent stays on the phase line it already titles; the steps in trouble
|
||||
// become its children, so nothing is listed twice.
|
||||
const summary = phase.agents[0];
|
||||
return {
|
||||
...phase,
|
||||
children: true,
|
||||
...(summary !== undefined && { summary }),
|
||||
state: phaseGlyphState([...phase.agents, ...reportSteps].map((row) => row.state)),
|
||||
agents: reportSteps,
|
||||
};
|
||||
}
|
||||
return phase;
|
||||
});
|
||||
|
||||
const sast = agenticSastPhase(operations);
|
||||
if (sast === undefined) return phases;
|
||||
|
||||
// Agentic SAST starts with the scan and runs alongside the pentest, so it reads after
|
||||
// the login check rather than appended past Reporting where it never ran.
|
||||
const afterAuth = phases.findIndex((phase) => phase.key === 'auth-validation') + 1;
|
||||
return [...phases.slice(0, afterAuth), sast, ...phases.slice(afterAuth)];
|
||||
}
|
||||
|
||||
export { agentError };
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* - apps/worker/src/temporal/activities.ts (the run*Agent activity names → `activityType`)
|
||||
* - apps/worker/src/temporal/shared.ts (PipelineState / PipelineSummary)
|
||||
* - apps/worker/src/types/metrics.ts (AgentMetrics)
|
||||
* - apps/worker/src/types/run-state.ts (PartialReasonView)
|
||||
*/
|
||||
|
||||
export interface AgentSpec {
|
||||
@@ -26,6 +27,18 @@ export interface PhaseSpec {
|
||||
readonly agents: readonly AgentSpec[];
|
||||
}
|
||||
|
||||
export interface ActivityProgressSpec {
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly kind: 'agent' | 'operation';
|
||||
/**
|
||||
* Operation rows whose work is already represented by a persisted parent stage. The parent
|
||||
* owns the row; this activity supplies the step shown as its detail. Parent stage keys are
|
||||
* the family key itself or the family key followed by ':' and a class or stage suffix.
|
||||
*/
|
||||
readonly parentKey?: string;
|
||||
}
|
||||
|
||||
/** The pipeline phases in execution order, each with its agents. */
|
||||
export const PIPELINE: readonly PhaseSpec[] = [
|
||||
{
|
||||
@@ -80,9 +93,183 @@ export const PIPELINE: readonly PhaseSpec[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/** Temporal activity type name → canonical agent name, for mapping pendingActivities. */
|
||||
const MISCELLANEOUS_EXPLOIT_AGENT: AgentSpec = {
|
||||
name: 'miscellaneous-exploit',
|
||||
label: 'miscellaneous',
|
||||
activityType: 'runMiscellaneousExploitAgent',
|
||||
};
|
||||
|
||||
/**
|
||||
* Shape the static PIPELINE to one scan's durable truth. expectedAgents, persisted by the
|
||||
* worker at scan start, names every exploit agent the scan can ever run: exploit rows it
|
||||
* excludes are dropped, 'miscellaneous-exploit' is appended only once the miscellaneous pipeline has
|
||||
* admitted findings, and a phase left with no agents disappears entirely. Without state
|
||||
* (the scan has not initialized durable state yet) the full static pipeline is the best
|
||||
* available guess.
|
||||
*/
|
||||
export function pipelineForState(state: PipelineState | null): readonly PhaseSpec[] {
|
||||
if (state?.expectedAgents === undefined) return PIPELINE;
|
||||
const expected = new Set(state.expectedAgents);
|
||||
return PIPELINE.map((phase) => {
|
||||
if (phase.key !== 'exploitation') return phase;
|
||||
const agents = phase.agents.filter((agent) => expected.has(agent.name));
|
||||
if (expected.has(MISCELLANEOUS_EXPLOIT_AGENT.name)) agents.push(MISCELLANEOUS_EXPLOIT_AGENT);
|
||||
return { ...phase, agents };
|
||||
}).filter((phase) => phase.agents.length > 0);
|
||||
}
|
||||
|
||||
const AGENT_ACTIVITY_PROGRESS: Readonly<Record<string, ActivityProgressSpec>> = Object.fromEntries(
|
||||
[...PIPELINE.flatMap((phase) => phase.agents), MISCELLANEOUS_EXPLOIT_AGENT].map((agent) => [
|
||||
agent.activityType,
|
||||
{ key: agent.name, label: agent.label, kind: 'agent' },
|
||||
]),
|
||||
);
|
||||
|
||||
/** Families whose per-class or per-stage work is already carried by one persisted stage row. */
|
||||
const RECONCILIATION_PARENT_KEY = 'reconciliation';
|
||||
const AGENTIC_SAST_PARENT_KEY = 'agentic-sast';
|
||||
|
||||
// Every production activity that is not an agent run must have a row here. describeScan
|
||||
// throws on an unmapped activity type, so adding a worker activity without updating this
|
||||
// table breaks `shannon status` loudly instead of hiding the new work. The authoritative
|
||||
// name lists live in apps/worker/src/temporal/worker.ts,
|
||||
// apps/worker/src/temporal/reconcile-activity-types.ts, and
|
||||
// apps/worker/src/ai/sast/capella/temporal/activity-types.ts.
|
||||
const OPERATION_ACTIVITY_PROGRESS: Readonly<Record<string, ActivityProgressSpec>> = {
|
||||
runPreflightValidation: { key: 'preflight', label: 'Preflight validation', kind: 'operation' },
|
||||
syncPlaywrightStealthConfig: { key: 'preflight', label: 'Browser setup', kind: 'operation' },
|
||||
initDeliverableGit: { key: 'scan-initialization', label: 'Initialize deliverables', kind: 'operation' },
|
||||
syncCodePathDenyRules: { key: 'scan-initialization', label: 'Apply source rules', kind: 'operation' },
|
||||
initializeDurableScanState: { key: 'durable-state', label: 'Saving scan state', kind: 'operation' },
|
||||
persistMiscellaneousOutcome: {
|
||||
key: 'miscellaneous-pipeline',
|
||||
label: 'Including miscellaneous findings',
|
||||
kind: 'operation',
|
||||
},
|
||||
initializeReportProgress: { key: 'report:initialize', label: 'Initialize report state', kind: 'operation' },
|
||||
renumberClassFindings: { key: 'report:renumber', label: 'Renumber findings', kind: 'operation' },
|
||||
assembleReportActivity: { key: 'report:assemble', label: 'Assemble report inputs', kind: 'operation' },
|
||||
compactReportFindings: { key: 'report:compact', label: 'Compact report findings', kind: 'operation' },
|
||||
persistCanonicalReportProgress: { key: 'report:checkpoint', label: 'Saving report progress', kind: 'operation' },
|
||||
finalizeReportOutputs: { key: 'report:finalize', label: 'Finalize report outputs', kind: 'operation' },
|
||||
persistFinalizedReportProgress: { key: 'report:terminal', label: 'Saving final report state', kind: 'operation' },
|
||||
surfaceReportOutputs: { key: 'report:surface', label: 'Surface customer report', kind: 'operation' },
|
||||
checkExploitationQueue: { key: 'queue-check', label: 'Check exploitation queue', kind: 'operation' },
|
||||
loadResumeState: { key: 'resume-validation', label: 'Validate resume state', kind: 'operation' },
|
||||
restoreGitCheckpoint: { key: 'resume-restore', label: 'Restore checkpoint', kind: 'operation' },
|
||||
registerResumeAttempt: { key: 'resume-registration', label: 'Register resume', kind: 'operation' },
|
||||
recordResumeAttempt: { key: 'resume-registration', label: 'Record resume', kind: 'operation' },
|
||||
logPhaseTransition: { key: 'audit-log', label: 'Update audit log', kind: 'operation' },
|
||||
logWorkflowComplete: { key: 'audit-log', label: 'Finalize audit log', kind: 'operation' },
|
||||
saveCheckpoint: { key: 'checkpoint', label: 'Save checkpoint', kind: 'operation' },
|
||||
seedEmptyProducerQueue: {
|
||||
key: 'miscellaneous-pipeline',
|
||||
label: 'Preparing miscellaneous findings',
|
||||
kind: 'operation',
|
||||
},
|
||||
prepareClassReconciliation: {
|
||||
key: 'reconciliation',
|
||||
label: 'Preparing findings',
|
||||
kind: 'operation',
|
||||
parentKey: RECONCILIATION_PARENT_KEY,
|
||||
},
|
||||
enrichClassSastObservations: {
|
||||
key: 'reconciliation',
|
||||
label: 'Adding code context',
|
||||
kind: 'operation',
|
||||
parentKey: RECONCILIATION_PARENT_KEY,
|
||||
},
|
||||
formClassExploitTasks: {
|
||||
key: 'reconciliation',
|
||||
label: 'Grouping into test cases',
|
||||
kind: 'operation',
|
||||
parentKey: RECONCILIATION_PARENT_KEY,
|
||||
},
|
||||
materializeClassExploitTasks: {
|
||||
key: 'reconciliation',
|
||||
label: 'Writing test cases',
|
||||
kind: 'operation',
|
||||
parentKey: RECONCILIATION_PARENT_KEY,
|
||||
},
|
||||
publishClassReconciliationOss: {
|
||||
key: 'reconciliation',
|
||||
label: 'Saving results',
|
||||
kind: 'operation',
|
||||
parentKey: RECONCILIATION_PARENT_KEY,
|
||||
},
|
||||
capellaArchitecture: {
|
||||
key: 'agentic-sast:architecture',
|
||||
label: 'Mapping architecture',
|
||||
kind: 'operation',
|
||||
parentKey: AGENTIC_SAST_PARENT_KEY,
|
||||
},
|
||||
capellaThreatModel: {
|
||||
key: 'agentic-sast:threat-model',
|
||||
label: 'Modelling threats',
|
||||
kind: 'operation',
|
||||
parentKey: AGENTIC_SAST_PARENT_KEY,
|
||||
},
|
||||
capellaPlan: {
|
||||
key: 'agentic-sast:plan',
|
||||
label: 'Planning the review',
|
||||
kind: 'operation',
|
||||
parentKey: AGENTIC_SAST_PARENT_KEY,
|
||||
},
|
||||
capellaResearch: {
|
||||
key: 'agentic-sast:research',
|
||||
label: 'Researching code',
|
||||
kind: 'operation',
|
||||
parentKey: AGENTIC_SAST_PARENT_KEY,
|
||||
},
|
||||
capellaDedupe: {
|
||||
key: 'agentic-sast:dedupe',
|
||||
label: 'Merging duplicates',
|
||||
kind: 'operation',
|
||||
parentKey: AGENTIC_SAST_PARENT_KEY,
|
||||
},
|
||||
capellaReview: {
|
||||
key: 'agentic-sast:review',
|
||||
label: 'Reviewing findings',
|
||||
kind: 'operation',
|
||||
parentKey: AGENTIC_SAST_PARENT_KEY,
|
||||
},
|
||||
capellaCritic: {
|
||||
key: 'agentic-sast:critic',
|
||||
label: 'Critiquing findings',
|
||||
kind: 'operation',
|
||||
parentKey: AGENTIC_SAST_PARENT_KEY,
|
||||
},
|
||||
capellaConfirm: {
|
||||
key: 'agentic-sast:confirm',
|
||||
label: 'Confirming findings',
|
||||
kind: 'operation',
|
||||
parentKey: AGENTIC_SAST_PARENT_KEY,
|
||||
},
|
||||
capellaCalibrate: {
|
||||
key: 'agentic-sast:calibrate',
|
||||
label: 'Calibrating risk',
|
||||
kind: 'operation',
|
||||
parentKey: AGENTIC_SAST_PARENT_KEY,
|
||||
},
|
||||
capellaExport: {
|
||||
key: 'agentic-sast:export',
|
||||
label: 'Exporting findings',
|
||||
kind: 'operation',
|
||||
parentKey: AGENTIC_SAST_PARENT_KEY,
|
||||
},
|
||||
};
|
||||
|
||||
/** Complete production activity mirror. Unknown names are errors, never hidden progress. */
|
||||
export const ACTIVITY_TO_PROGRESS: Readonly<Record<string, ActivityProgressSpec>> = Object.freeze({
|
||||
...AGENT_ACTIVITY_PROGRESS,
|
||||
...OPERATION_ACTIVITY_PROGRESS,
|
||||
});
|
||||
|
||||
/** Agent-only projection of ACTIVITY_TO_PROGRESS: activity type name to canonical agent name. */
|
||||
export const ACTIVITY_TO_AGENT: Readonly<Record<string, string>> = Object.fromEntries(
|
||||
PIPELINE.flatMap((phase) => phase.agents.map((agent) => [agent.activityType, agent.name])),
|
||||
Object.entries(ACTIVITY_TO_PROGRESS)
|
||||
.filter(([, progress]) => progress.kind === 'agent')
|
||||
.map(([activityType, progress]) => [activityType, progress.key]),
|
||||
);
|
||||
|
||||
/** The vuln/exploit class of an agent (e.g. "authz-vuln" → "authz"), for failedPipelines matching. */
|
||||
@@ -100,11 +287,65 @@ export interface AgentMetrics {
|
||||
readonly skipped?: boolean;
|
||||
}
|
||||
|
||||
export interface OperationalStageState {
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
|
||||
readonly startedAt?: number;
|
||||
readonly durationMs?: number;
|
||||
readonly error?: string;
|
||||
}
|
||||
|
||||
/** Family key a persisted operational stage belongs to, e.g. `reconciliation:xss` to `reconciliation`. */
|
||||
export function operationFamilyKey(stageKey: string): string {
|
||||
const separator = stageKey.indexOf(':');
|
||||
return separator === -1 ? stageKey : stageKey.slice(0, separator);
|
||||
}
|
||||
|
||||
/** The Capella stages that get a progress row, in run order. Mirrors CAPELLA_PROGRESS_STAGES
|
||||
* in apps/worker/src/ai/sast/types.ts — the deterministic `export` stage is not among them. */
|
||||
export const AGENTIC_SAST_STAGE_ORDER: readonly string[] = [
|
||||
'architecture',
|
||||
'threat-model',
|
||||
'plan',
|
||||
'research',
|
||||
'dedupe',
|
||||
'review',
|
||||
'critic',
|
||||
'confirm',
|
||||
'calibrate',
|
||||
];
|
||||
|
||||
/**
|
||||
* Whether an operational stage represents model work rather than bookkeeping.
|
||||
*
|
||||
* Only the agentic-SAST stages and per-class reconciliation run a model; every other
|
||||
* operational stage is a git commit or a durable-state write that can only ever record
|
||||
* sub-second wall time. The progress tree shows model work, so this is what decides
|
||||
* whether a stage is worth a row at all.
|
||||
*/
|
||||
export function isModelBackedOperation(stageKey: string): boolean {
|
||||
const family = operationFamilyKey(stageKey);
|
||||
if (family === 'agentic-sast') return true;
|
||||
// A `reconciliation:<class>:fallback` marker records a degradation, not a model span.
|
||||
return family === 'reconciliation' && !stageKey.endsWith(':fallback');
|
||||
}
|
||||
|
||||
export interface PipelineSummary {
|
||||
readonly totalCostUsd: number;
|
||||
readonly totalDurationMs: number; // Wall-clock (end - start)
|
||||
readonly totalTurns: number;
|
||||
readonly agentCount: number;
|
||||
/** False when operational (Capella/reconciliation) spend is known to be incomplete. */
|
||||
readonly usageAccountingComplete?: boolean;
|
||||
}
|
||||
|
||||
/** One durable degradation reason with its derived safe message (mirror of PartialReasonView). */
|
||||
export interface PartialReasonView {
|
||||
readonly code: string;
|
||||
readonly vulnerabilityClass?: string;
|
||||
readonly stage?: string;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
export type PipelineStatus = 'running' | 'completed' | 'failed' | 'cancelled' | 'partial';
|
||||
@@ -114,10 +355,29 @@ export interface PipelineState {
|
||||
readonly currentPhase: string | null;
|
||||
readonly currentAgent: string | null;
|
||||
readonly completedAgents: string[];
|
||||
readonly expectedAgents?: string[];
|
||||
readonly participatingClasses?: string[];
|
||||
readonly failedPipelines: { vulnType: string; error: string }[];
|
||||
readonly failedReconciliations?: { vulnerabilityClass: string; error: string }[];
|
||||
readonly failedAgent: string | null;
|
||||
readonly error: string | null;
|
||||
readonly startTime: number;
|
||||
readonly agentMetrics: Record<string, AgentMetrics>;
|
||||
readonly operationalMetrics?: Record<string, AgentMetrics>;
|
||||
readonly operationalStages?: Record<string, OperationalStageState>;
|
||||
/** `error` is the worker's sanitized failure sentence, safe to print verbatim. */
|
||||
readonly agenticSast?: {
|
||||
readonly status: string;
|
||||
readonly durationMs?: number;
|
||||
/** Reader-facing name of the failed stage, already projected by the worker. */
|
||||
readonly failedStageLabel?: string;
|
||||
readonly error?: string;
|
||||
readonly errorCode?: string;
|
||||
/** Usage-accounting warnings projected by the worker; empty when the ledger reconciled. */
|
||||
readonly warnings?: readonly string[];
|
||||
};
|
||||
readonly nonFatalFailures?: { readonly phase: string; readonly error: string }[];
|
||||
/** Ordered durable degradation reasons with safe messages; empty or absent for full success. */
|
||||
readonly partialReasons?: readonly PartialReasonView[];
|
||||
readonly summary: PipelineSummary | null;
|
||||
}
|
||||
|
||||
+99
-28
@@ -10,9 +10,9 @@
|
||||
import { BOLD, DIM, GOLD, paint, RED, YELLOW } from '../colors.js';
|
||||
import { commandPrefix } from '../mode.js';
|
||||
import type { RunningAgent } from '../temporal-client.js';
|
||||
import { agentError, deriveAgentStates, isTerminal, phaseGlyphState, type RunState, scanElapsedMs } from './derive.js';
|
||||
import { inlineFailureReason } from './failure.js';
|
||||
import { PIPELINE, type PipelineState } from './pipeline.js';
|
||||
import { derivePipeline, isTerminal, type RunState, scanElapsedMs } from './derive.js';
|
||||
import type { PipelineState } from './pipeline.js';
|
||||
import { safeAgenticSast, safeCliIdentifier, safePartialReasons, safeTerminalFailure } from './safe-fields.js';
|
||||
|
||||
export interface RenderInput {
|
||||
readonly workspace: string;
|
||||
@@ -68,7 +68,7 @@ function truncate(text: string, max: number): string {
|
||||
/** Temporal Web UI, published by compose on 8233; deep-links to the workflow when its id is known. */
|
||||
function temporalDashboardUrl(workflowId: string | undefined): string {
|
||||
const base = 'http://localhost:8233';
|
||||
return workflowId ? `${base}/namespaces/default/workflows/${workflowId}` : base;
|
||||
return workflowId ? `${base}/namespaces/default/workflows/${safeCliIdentifier(workflowId)}` : base;
|
||||
}
|
||||
|
||||
// === Glyphs & status ===
|
||||
@@ -95,6 +95,12 @@ const STATE_COLOR: Record<RunState, string> = {
|
||||
skipped: COLORS.dim,
|
||||
};
|
||||
|
||||
/** Column width for an agent or background-work label inside a phase. */
|
||||
const AGENT_LABEL_WIDTH = 18;
|
||||
|
||||
/** Inline budget for a failure sentence, wide enough to carry a whole first sentence. */
|
||||
const FAILURE_DETAIL_WIDTH = 120;
|
||||
|
||||
/** Braille spinner frames for running agents — the clack loader style. */
|
||||
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const;
|
||||
|
||||
@@ -112,42 +118,68 @@ function statusBadge(input: RenderInput, opts: RenderOptions): string {
|
||||
const workflowStatus = input.state?.status;
|
||||
if (!isTerminal(input.temporalStatus)) return paint('running', COLORS.gold, opts.color);
|
||||
if (workflowStatus === 'partial') return paint('partial', COLORS.yellow, opts.color);
|
||||
if (workflowStatus === 'cancelled') return paint('cancelled', COLORS.yellow, opts.color);
|
||||
if (input.temporalStatus === 'COMPLETED') return paint('completed', COLORS.gold, opts.color);
|
||||
if (input.temporalStatus === 'TERMINATED') return paint('stopped', COLORS.yellow, opts.color);
|
||||
if (input.temporalStatus === 'CANCELLED' || input.temporalStatus === 'CANCELED') {
|
||||
return paint('cancelled', COLORS.yellow, opts.color);
|
||||
}
|
||||
if (input.temporalStatus === 'TIMED_OUT') return paint('timed out', COLORS.red, opts.color);
|
||||
return paint('FAILED', COLORS.red, opts.color);
|
||||
return paint('failed', COLORS.red, opts.color);
|
||||
}
|
||||
|
||||
// === Line builders ===
|
||||
|
||||
/** The parts of a derived row agentMeta reads beyond its state and metrics. */
|
||||
interface RowExtras {
|
||||
readonly runningElapsedMs?: number | null;
|
||||
readonly attachedMs?: number;
|
||||
readonly ungrouped?: boolean;
|
||||
}
|
||||
|
||||
function agentMeta(
|
||||
state: RunState,
|
||||
metrics: { durationMs: number } | undefined,
|
||||
runner: RunningAgent | undefined,
|
||||
error: string | undefined,
|
||||
opts: RenderOptions,
|
||||
step?: string,
|
||||
extras?: RowExtras,
|
||||
): string {
|
||||
if (state === 'completed') {
|
||||
const duration = metrics?.durationMs != null ? formatDuration(metrics.durationMs) : 'done';
|
||||
return paint(duration, COLORS.dim, opts.color);
|
||||
return paint(`${duration}${attachedSuffix(extras)}`, COLORS.dim, opts.color);
|
||||
}
|
||||
if (state === 'running') {
|
||||
const parts = ['running'];
|
||||
if (runner?.startedAt !== undefined) parts.push(formatDuration(opts.now - runner.startedAt));
|
||||
if (step !== undefined) parts.push(step);
|
||||
// An operational row carries its own elapsed time: it is derived from the persisted stage
|
||||
// span, and has no pending activity on the parent workflow to read a start time from.
|
||||
const elapsedMs =
|
||||
runner?.startedAt !== undefined ? opts.now - runner.startedAt : (extras?.runningElapsedMs ?? null);
|
||||
if (elapsedMs !== null) parts.push(formatDuration(elapsedMs));
|
||||
if (runner && runner.attempt > 1) parts.push(`retry ${runner.attempt}`);
|
||||
return paint(parts.join(' · '), COLORS.gold, opts.color);
|
||||
}
|
||||
if (state === 'failed') {
|
||||
const detail = error ? ` · ${truncate(error, 46)}` : '';
|
||||
const detail = error ? ` · ${truncate(error, FAILURE_DETAIL_WIDTH)}` : '';
|
||||
return paint(`failed${detail}`, COLORS.red, opts.color);
|
||||
}
|
||||
if (state === 'skipped') return paint('skipped', COLORS.dim, opts.color);
|
||||
return paint('queued', COLORS.dim, opts.color);
|
||||
}
|
||||
|
||||
/**
|
||||
* Time a reconciliation lane contributed to this agent's class, shown as `+ duration` on the
|
||||
* row it feeds. `ungrouped` marks a class whose findings could not be grouped, so each one
|
||||
* was tested separately and duplicates are expected.
|
||||
*/
|
||||
function attachedSuffix(extras: RowExtras | undefined): string {
|
||||
if (extras === undefined) return '';
|
||||
const time = extras.attachedMs === undefined ? '' : ` + ${formatDuration(extras.attachedMs)}`;
|
||||
return extras.ungrouped ? `${time} · ungrouped` : time;
|
||||
}
|
||||
|
||||
function phaseMeta(states: readonly RunState[], inPlay: number, parallel: boolean, opts: RenderOptions): string {
|
||||
if (states.every((s) => s === 'pending')) return paint('pending', COLORS.dim, opts.color);
|
||||
if (states.every((s) => s === 'skipped')) return paint('skipped', COLORS.dim, opts.color);
|
||||
@@ -163,35 +195,43 @@ function phaseMeta(states: readonly RunState[], inPlay: number, parallel: boolea
|
||||
/** Render the full progress frame as one string (no trailing newline). */
|
||||
export function renderScan(input: RenderInput, opts: RenderOptions): string {
|
||||
const byAgent = new Map(input.running.map((r) => [r.agent, r]));
|
||||
const stateMap = deriveAgentStates(input);
|
||||
const phases = derivePipeline(input, opts.now);
|
||||
const lines: string[] = ['', ...headerLines(input, opts), ''];
|
||||
|
||||
const metaFor = (name: string, state: RunState): string =>
|
||||
agentMeta(state, input.state?.agentMetrics[name], byAgent.get(name), agentError(name, input.state, byAgent), opts);
|
||||
// Only agents that have actually entered play are shown; pending/skipped ones stay hidden.
|
||||
const inPlay = (s: RunState): boolean => s === 'running' || s === 'completed' || s === 'failed';
|
||||
|
||||
for (const phase of PIPELINE) {
|
||||
const states = phase.agents.map((a) => stateMap.get(a.name) ?? 'pending');
|
||||
for (const phase of phases) {
|
||||
const states = phase.agents.map((agent) => agent.state);
|
||||
const playing = states.filter(inPlay).length;
|
||||
const phaseRunState: RunState = phaseGlyphState(states);
|
||||
const phaseRunState = phase.state;
|
||||
const metaFor = (agent: (typeof phase.agents)[number]): string => {
|
||||
const metrics = agent.durationMs === null ? undefined : { durationMs: agent.durationMs };
|
||||
return agentMeta(agent.state, metrics, byAgent.get(agent.name), agent.error, opts, agent.detail, agent);
|
||||
};
|
||||
|
||||
// A single-agent phase carries that agent's own duration/cost on the phase line once it
|
||||
// starts; a parallel phase gets a "k/N done" summary over the agents in play.
|
||||
// A phase summarizes itself by wall time or by a "k/N done" tally. A phase with its own
|
||||
// recorded span (Agentic SAST) presents it like any agent row; otherwise a single-agent
|
||||
// phase borrows its one agent's duration once that agent starts.
|
||||
const first = phase.agents[0];
|
||||
const firstState = states[0];
|
||||
const phaseMetaStr =
|
||||
!phase.parallel && first && firstState && inPlay(firstState)
|
||||
? metaFor(first.name, firstState)
|
||||
: phaseMeta(states, playing, phase.parallel, opts);
|
||||
lines.push(` ${glyph(phaseRunState, opts)} ${phase.label.padEnd(26)}${phaseMetaStr}`);
|
||||
const borrowed = first && firstState && inPlay(firstState) ? metaFor(first) : undefined;
|
||||
const durationMeta = phase.summary === undefined ? borrowed : metaFor(phase.summary);
|
||||
const summaryMeta =
|
||||
phase.meta === 'duration' && durationMeta !== undefined
|
||||
? durationMeta
|
||||
: phaseMeta(states, playing, phase.meta === 'count', opts);
|
||||
const note = phase.note === undefined ? '' : paint(` · ${phase.note}`, COLORS.dim, opts.color);
|
||||
lines.push(` ${glyph(phaseRunState, opts)} ${phase.label.padEnd(26)}${summaryMeta}${note}`);
|
||||
|
||||
if (!phase.parallel) continue;
|
||||
if (!phase.children) continue;
|
||||
for (let i = 0; i < phase.agents.length; i++) {
|
||||
const agent = phase.agents[i];
|
||||
const state = states[i];
|
||||
if (!agent || !state || !inPlay(state)) continue;
|
||||
lines.push(` ${glyph(state, opts)} ${agent.label.padEnd(18)}${metaFor(agent.name, state)}`);
|
||||
// Two trailing spaces before padding, so a label wider than the column still separates
|
||||
// from its meta text; a label inside the column pads to the same width as before.
|
||||
lines.push(` ${glyph(state, opts)} ${`${agent.label} `.padEnd(AGENT_LABEL_WIDTH)}${metaFor(agent)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +242,7 @@ export function renderScan(input: RenderInput, opts: RenderOptions): string {
|
||||
function headerLines(input: RenderInput, opts: RenderOptions): string[] {
|
||||
const elapsedMs = scanElapsedMs(input, opts.now);
|
||||
const meta = [statusBadge(input, opts), elapsedMs !== undefined ? formatDuration(elapsedMs) : '—'].join(' · ');
|
||||
return [` ${paint('Scan:', COLORS.bold, opts.color)} ${input.workspace.padEnd(22)} ${meta}`];
|
||||
return [` ${paint('Scan:', COLORS.bold, opts.color)} ${safeCliIdentifier(input.workspace).padEnd(22)} ${meta}`];
|
||||
}
|
||||
|
||||
/** Aligned label column for the footer's Logs / Temporal rows. */
|
||||
@@ -223,15 +263,46 @@ function footerLines(input: RenderInput, opts: RenderOptions): string[] {
|
||||
|
||||
if (isTerminal(input.temporalStatus) && input.state?.summary) {
|
||||
const wall = formatDuration(input.state.summary.totalDurationMs);
|
||||
return ['', ` Time Taken ${wall}`];
|
||||
const lines = ['', ` Time Taken ${wall}`];
|
||||
|
||||
// A partial scan names each durable degradation reason through its safe message,
|
||||
// so the operator never has to guess why the badge is not "completed".
|
||||
const reasons = safePartialReasons(input.state.partialReasons ?? []);
|
||||
if (reasons.length > 0) {
|
||||
lines.push('', ` ${paint('Why this scan is partial:', COLORS.yellow, opts.color)}`);
|
||||
for (const reason of reasons) {
|
||||
lines.push(paint(` - ${reason.message}`, COLORS.dim, opts.color));
|
||||
}
|
||||
// The safe message names what degraded; these three name the agentic-SAST failure
|
||||
// behind it, under the same labels the scan log and worker output use.
|
||||
const agenticSast = safeAgenticSast(input.state.agenticSast);
|
||||
if (agenticSast?.status === 'failed') {
|
||||
if (agenticSast.failedStageLabel !== undefined) {
|
||||
lines.push(paint(` Agentic SAST stopped at: ${agenticSast.failedStageLabel}`, COLORS.dim, opts.color));
|
||||
}
|
||||
if (agenticSast.error !== undefined) {
|
||||
lines.push(paint(` What happened: ${agenticSast.error}`, COLORS.dim, opts.color));
|
||||
}
|
||||
if (agenticSast.errorCode !== undefined) {
|
||||
lines.push(paint(` Reference code (for a bug report): ${agenticSast.errorCode}`, COLORS.dim, opts.color));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (input.state.summary.usageAccountingComplete === false) {
|
||||
lines.push(
|
||||
paint(' Cost is incomplete — some background work is not included in this total.', COLORS.dim, opts.color),
|
||||
);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
const logsValue = `${prefix} logs ${input.workspace}`;
|
||||
const logsValue = `${prefix} logs ${safeCliIdentifier(input.workspace)}`;
|
||||
const temporalValue = temporalDashboardUrl(input.workflowId);
|
||||
|
||||
if (isTerminal(input.temporalStatus)) {
|
||||
const rawReason = input.failureMessage ?? input.state?.error;
|
||||
const reason = rawReason ? inlineFailureReason(rawReason) : 'no result recorded';
|
||||
const hasRecordedFailure =
|
||||
input.failureMessage !== undefined || (input.state !== null && input.state.error !== null);
|
||||
const reason = safeTerminalFailure(hasRecordedFailure) ?? 'no result recorded';
|
||||
return [
|
||||
footerDivider(opts),
|
||||
paint(
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Closed-field projection for Temporal values displayed by the CLI.
|
||||
*
|
||||
* PipelineState travels through Temporal from a worker container this process does not
|
||||
* control, so free-text fields are treated as unvetted: this module either matches a
|
||||
* value against a known closed set (safe to print as-is) or collapses it to a fixed,
|
||||
* bounded message. A value with no case here should fail closed to something generic,
|
||||
* never pass through untouched.
|
||||
*/
|
||||
|
||||
import type { PartialReasonView, PipelineState } from './pipeline.js';
|
||||
|
||||
const CLASS_NAMES: Readonly<Record<string, string>> = Object.freeze({
|
||||
injection: 'Injection',
|
||||
xss: 'Cross-Site Scripting',
|
||||
auth: 'Authentication',
|
||||
authz: 'Authorization',
|
||||
ssrf: 'Server-Side Request Forgery',
|
||||
miscellaneous: 'Miscellaneous',
|
||||
});
|
||||
|
||||
const STAGE_NAMES: Readonly<Record<string, string>> = Object.freeze({
|
||||
architecture: 'architecture mapping',
|
||||
'threat-model': 'threat modelling',
|
||||
plan: 'review planning',
|
||||
research: 'deep code research',
|
||||
dedupe: 'duplicate merging',
|
||||
review: 'independent review',
|
||||
critic: 'viability critique',
|
||||
confirm: 'static confirmation',
|
||||
calibrate: 'risk calibration',
|
||||
export: 'findings export',
|
||||
workflow: 'orchestration',
|
||||
});
|
||||
|
||||
const TERMINAL_STAGE_NAMES = new Set([
|
||||
'architecture',
|
||||
'threat model',
|
||||
'planning',
|
||||
'audit wave',
|
||||
'deduplication',
|
||||
'review',
|
||||
'critic',
|
||||
'confirmation',
|
||||
'calibration',
|
||||
'export',
|
||||
'orchestration',
|
||||
]);
|
||||
|
||||
const CAPELLA_FAILURE_MESSAGES = new Set([
|
||||
'Provider authentication failed. Verify the configured credential.',
|
||||
'Agentic SAST configuration is invalid.',
|
||||
'Agentic SAST received invalid input.',
|
||||
'An agentic SAST step returned an unusable result.',
|
||||
'An agentic SAST step failed.',
|
||||
'Agentic SAST infrastructure failed before producing a usable result.',
|
||||
'Agentic SAST had not finished when the scan stopped.',
|
||||
]);
|
||||
|
||||
// Mirrors apps/worker/src/types/errors.ts. The CLI cannot import from the worker package,
|
||||
// so keep this exact closed set in sync with ProviderFailureCategory.
|
||||
const PROVIDER_FAILURE_CATEGORIES = new Set([
|
||||
'rate_limit',
|
||||
'overloaded',
|
||||
'transport',
|
||||
'context_limit',
|
||||
'quota',
|
||||
'authentication',
|
||||
'configuration',
|
||||
'unknown',
|
||||
]);
|
||||
|
||||
function isProviderFailureCategory(value: unknown): value is string {
|
||||
return typeof value === 'string' && PROVIDER_FAILURE_CATEGORIES.has(value);
|
||||
}
|
||||
|
||||
const OPERATION_LABELS = new Set([
|
||||
'Agentic SAST',
|
||||
// Capella stage rows, signalled up from the SAST child workflow. Mirrors
|
||||
// CAPELLA_STAGE_LABELS in apps/worker/src/ai/sast/types.ts, minus the deterministic
|
||||
// export stage, which never becomes a row.
|
||||
'Architecture',
|
||||
'Threat model',
|
||||
'Plan',
|
||||
'Research',
|
||||
'Dedupe',
|
||||
'Review',
|
||||
'Critique',
|
||||
'Confirm',
|
||||
'Calibrate',
|
||||
'Reconcile injection',
|
||||
'Reconcile xss',
|
||||
'Reconcile auth',
|
||||
'Reconcile authz',
|
||||
'Reconcile ssrf',
|
||||
'Reconcile miscellaneous',
|
||||
'Prepare reconciliation',
|
||||
'Enrich observations',
|
||||
'Form exploit tasks',
|
||||
'Materialize exploit tasks',
|
||||
'Publish reconciliation',
|
||||
'Renumber injection',
|
||||
'Renumber xss',
|
||||
'Renumber auth',
|
||||
'Renumber authz',
|
||||
'Renumber ssrf',
|
||||
'Renumber miscellaneous',
|
||||
'Initialize report state',
|
||||
'Assemble report inputs',
|
||||
'Compact report findings',
|
||||
'Saving report progress',
|
||||
'Finalize report outputs',
|
||||
'Finalize report without SARIF',
|
||||
'Saving final report state',
|
||||
'Surface customer report',
|
||||
]);
|
||||
|
||||
function safeClassName(value: string | undefined): string | undefined {
|
||||
return value === undefined ? undefined : CLASS_NAMES[value];
|
||||
}
|
||||
|
||||
function safeStageName(value: string | undefined): string | undefined {
|
||||
return value === undefined ? undefined : STAGE_NAMES[value];
|
||||
}
|
||||
|
||||
function reasonMessage(reason: PartialReasonView): string | undefined {
|
||||
const className = safeClassName(reason.vulnerabilityClass);
|
||||
switch (reason.code) {
|
||||
case 'agentic_sast_failed': {
|
||||
const stageName = safeStageName(reason.stage);
|
||||
return stageName === undefined
|
||||
? 'Agentic SAST failed, so the pentest continued without its findings.'
|
||||
: `Agentic SAST failed during ${stageName}, so the pentest continued without its findings.`;
|
||||
}
|
||||
case 'agentic_sast_reduced':
|
||||
return 'Agentic SAST completed with reduced coverage.';
|
||||
case 'class_pipeline_failed':
|
||||
return className === undefined
|
||||
? undefined
|
||||
: `${className} could not be fully assessed. The other classes completed. Re-running this workspace retries only the part that failed.`;
|
||||
case 'class_reconciliation_failed':
|
||||
return className === undefined
|
||||
? undefined
|
||||
: `${className} findings could not be grouped into test cases, so that class was not exploited. Its analysis results are still in the report.`;
|
||||
case 'report_renumber_failed':
|
||||
return className === undefined
|
||||
? undefined
|
||||
: `${className} findings kept their working reference numbers, so numbering in the report may have gaps. The findings themselves are complete.`;
|
||||
case 'report_compaction_failed':
|
||||
return 'Finding reference numbers in the report may have gaps. Every finding is present; only the numbering is affected.';
|
||||
case 'report_class_omitted':
|
||||
return className === undefined
|
||||
? undefined
|
||||
: `${className} was assessed but could not be included in the final report.`;
|
||||
case 'report_sarif_failed':
|
||||
return 'Report SARIF could not be generated. JSON and Markdown remain available.';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function safePartialReasons(reasons: readonly PartialReasonView[]): readonly PartialReasonView[] {
|
||||
return reasons.flatMap((reason) => {
|
||||
const message = reasonMessage(reason);
|
||||
if (message === undefined) return [];
|
||||
const vulnerabilityClass =
|
||||
safeClassName(reason.vulnerabilityClass) === undefined ? undefined : reason.vulnerabilityClass;
|
||||
const stage = safeStageName(reason.stage) === undefined ? undefined : reason.stage;
|
||||
return [
|
||||
{
|
||||
code: reason.code,
|
||||
message,
|
||||
...(vulnerabilityClass !== undefined && { vulnerabilityClass }),
|
||||
...(stage !== undefined && { stage }),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** Upper bounds on the warning array crossing into cli.status.json, so a malformed state cannot bloat it. */
|
||||
const MAX_AGENTIC_SAST_WARNINGS = 20;
|
||||
const MAX_AGENTIC_SAST_WARNING_LENGTH = 2_000;
|
||||
|
||||
/** Sanitize the worker's usage-accounting warnings: strings only, bounded count and length. */
|
||||
function safeAgenticSastWarnings(value: PipelineState['agenticSast']): readonly string[] {
|
||||
const warnings = value?.warnings;
|
||||
if (!Array.isArray(warnings)) return [];
|
||||
return warnings
|
||||
.filter((warning): warning is string => typeof warning === 'string')
|
||||
.slice(0, MAX_AGENTIC_SAST_WARNINGS)
|
||||
.map((warning) => warning.slice(0, MAX_AGENTIC_SAST_WARNING_LENGTH));
|
||||
}
|
||||
|
||||
export function safeAgenticSast(value: PipelineState['agenticSast']):
|
||||
| {
|
||||
readonly status: string;
|
||||
readonly failedStageLabel?: string;
|
||||
readonly error?: string;
|
||||
readonly errorCode?: string;
|
||||
readonly warnings: readonly string[];
|
||||
}
|
||||
| undefined {
|
||||
if (value === undefined || !['disabled', 'running', 'succeeded', 'failed'].includes(value.status)) return undefined;
|
||||
const failedStageLabel = TERMINAL_STAGE_NAMES.has(value.failedStageLabel ?? '') ? value.failedStageLabel : undefined;
|
||||
let error: string | undefined;
|
||||
if (value.error !== undefined && CAPELLA_FAILURE_MESSAGES.has(value.error)) {
|
||||
error = value.error;
|
||||
} else if (value.status === 'failed') {
|
||||
error = 'An agentic SAST step failed.';
|
||||
}
|
||||
const errorCode =
|
||||
value.errorCode !== undefined &&
|
||||
(/^[A-Z][A-Z0-9_]{0,63}$/u.test(value.errorCode) || isProviderFailureCategory(value.errorCode))
|
||||
? value.errorCode
|
||||
: undefined;
|
||||
return {
|
||||
status: value.status,
|
||||
...(failedStageLabel !== undefined && { failedStageLabel }),
|
||||
...(error !== undefined && { error }),
|
||||
...(errorCode !== undefined && { errorCode }),
|
||||
warnings: safeAgenticSastWarnings(value),
|
||||
};
|
||||
}
|
||||
|
||||
export function safeOperationLabel(value: string): string {
|
||||
return OPERATION_LABELS.has(value) ? value : 'Background task';
|
||||
}
|
||||
|
||||
export function safeOperationKey(value: string): string {
|
||||
if (
|
||||
/^(?:agentic-sast|miscellaneous-pipeline|report:(?:initialize|assemble|compact|checkpoint|finalize|finalize-degraded|terminal|surface))$/u.test(
|
||||
value,
|
||||
) ||
|
||||
/^agentic-sast:(?:architecture|threat-model|plan|research|dedupe|review|critic|confirm|calibrate)$/u.test(value) ||
|
||||
/^(?:reconciliation|report:renumber):(?:injection|xss|auth|authz|ssrf|miscellaneous)$/u.test(value) ||
|
||||
/^reconciliation:(?:injection|xss|auth|authz|ssrf|miscellaneous):fallback$/u.test(value)
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return 'background-task';
|
||||
}
|
||||
|
||||
/**
|
||||
* A workspace or workflow id is printed straight into the progress display, so this
|
||||
* confines it to a plain identifier charset before that happens: no control or escape
|
||||
* characters survive to reach the terminal.
|
||||
*/
|
||||
export function safeCliIdentifier(value: string): string {
|
||||
return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(value) ? value : 'unknown';
|
||||
}
|
||||
|
||||
export function safeTemporalStatus(value: string): string {
|
||||
return [
|
||||
'RUNNING',
|
||||
'UNSPECIFIED',
|
||||
'COMPLETED',
|
||||
'FAILED',
|
||||
'CANCELLED',
|
||||
'CANCELED',
|
||||
'TERMINATED',
|
||||
'TIMED_OUT',
|
||||
'CONTINUED_AS_NEW',
|
||||
].includes(value)
|
||||
? value
|
||||
: 'UNKNOWN';
|
||||
}
|
||||
|
||||
export function safeFailureDetail(hasFailure: true): string;
|
||||
export function safeFailureDetail(hasFailure: false): undefined;
|
||||
export function safeFailureDetail(hasFailure: boolean): string | undefined;
|
||||
export function safeFailureDetail(hasFailure: boolean): string | undefined {
|
||||
return hasFailure ? 'This scan step could not be completed.' : undefined;
|
||||
}
|
||||
|
||||
/** Same closed-set trade-off as safeFailureDetail, for the scan-level (not per-agent) failure. */
|
||||
export function safeTerminalFailure(hasFailure: boolean): string | undefined {
|
||||
return hasFailure ? 'The scan could not be completed.' : undefined;
|
||||
}
|
||||
@@ -8,7 +8,15 @@
|
||||
|
||||
import type { DerivedPhase } from './derive.js';
|
||||
import { derivePipeline, isTerminal, scanElapsedMs } from './derive.js';
|
||||
import type { PartialReasonView } from './pipeline.js';
|
||||
import type { RenderInput } from './render.js';
|
||||
import {
|
||||
safeAgenticSast,
|
||||
safeCliIdentifier,
|
||||
safePartialReasons,
|
||||
safeTemporalStatus,
|
||||
safeTerminalFailure,
|
||||
} from './safe-fields.js';
|
||||
|
||||
/** Coarse scan status token, mirroring the human status badge in machine-friendly form. */
|
||||
export type ScanStatus = 'running' | 'completed' | 'partial' | 'failed' | 'stopped' | 'cancelled' | 'timed_out';
|
||||
@@ -27,6 +35,18 @@ export interface StatusJson {
|
||||
readonly endedAt?: string;
|
||||
/** Failure text when a failed scan left no readable state. */
|
||||
readonly failureMessage?: string;
|
||||
/** Ordered durable degradation reasons with safe messages; present only when non-empty. */
|
||||
readonly partialReasons?: readonly PartialReasonView[];
|
||||
/** Agentic SAST outcome, with the worker's sanitized failure sentence and bounded code. */
|
||||
readonly agenticSast?: {
|
||||
readonly status: string;
|
||||
readonly error?: string;
|
||||
readonly errorCode?: string;
|
||||
/** Usage-accounting warnings; always present (empty when the ledger reconciled) so it is never null. */
|
||||
readonly warnings: readonly string[];
|
||||
};
|
||||
/** False when operational (Capella/reconciliation) spend is known to be incomplete. */
|
||||
readonly usageAccountingComplete?: boolean;
|
||||
readonly phases: readonly DerivedPhase[];
|
||||
}
|
||||
|
||||
@@ -34,6 +54,7 @@ export interface StatusJson {
|
||||
function deriveStatus(input: RenderInput): ScanStatus {
|
||||
if (!isTerminal(input.temporalStatus)) return 'running';
|
||||
if (input.state?.status === 'partial') return 'partial';
|
||||
if (input.state?.status === 'cancelled') return 'cancelled';
|
||||
|
||||
switch (input.temporalStatus) {
|
||||
case 'COMPLETED':
|
||||
@@ -53,16 +74,32 @@ function deriveStatus(input: RenderInput): ScanStatus {
|
||||
/** Build the JSON snapshot for a scan at instant `now`. */
|
||||
export function toStatusJson(input: RenderInput, now: number): StatusJson {
|
||||
const elapsedMs = scanElapsedMs(input, now);
|
||||
const partialReasons = safePartialReasons(input.state?.partialReasons ?? []);
|
||||
const agenticSast = safeAgenticSast(input.state?.agenticSast);
|
||||
const usageAccountingComplete = input.state?.summary?.usageAccountingComplete;
|
||||
const failureMessage = safeTerminalFailure(input.failureMessage !== undefined);
|
||||
|
||||
return {
|
||||
workspace: input.workspace,
|
||||
...(input.workflowId !== undefined && { workflowId: input.workflowId }),
|
||||
workspace: safeCliIdentifier(input.workspace),
|
||||
...(input.workflowId !== undefined && { workflowId: safeCliIdentifier(input.workflowId) }),
|
||||
status: deriveStatus(input),
|
||||
temporalStatus: input.temporalStatus,
|
||||
temporalStatus: safeTemporalStatus(input.temporalStatus),
|
||||
elapsedMs: elapsedMs ?? null,
|
||||
...(input.startedAt !== undefined && { startedAt: new Date(input.startedAt).toISOString() }),
|
||||
...(input.endedAt !== undefined && { endedAt: new Date(input.endedAt).toISOString() }),
|
||||
...(input.failureMessage !== undefined && { failureMessage: input.failureMessage }),
|
||||
...(failureMessage !== undefined && { failureMessage }),
|
||||
...(partialReasons.length > 0 && { partialReasons }),
|
||||
// Present only when agentic SAST actually ran; a disabled scan omits the key entirely.
|
||||
...(agenticSast !== undefined &&
|
||||
agenticSast.status !== 'disabled' && {
|
||||
agenticSast: {
|
||||
status: agenticSast.status,
|
||||
...(agenticSast.error !== undefined && { error: agenticSast.error }),
|
||||
...(agenticSast.errorCode !== undefined && { errorCode: agenticSast.errorCode }),
|
||||
warnings: [...agenticSast.warnings],
|
||||
},
|
||||
}),
|
||||
...(usageAccountingComplete !== undefined && { usageAccountingComplete }),
|
||||
phases: derivePipeline(input, now),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* Workspace → Temporal workflow-id resolution.
|
||||
*
|
||||
* A workspace name is not always its workflow id: a fresh scan's id equals the
|
||||
* workspace name, but each resume spawns a new workflow (`<workspace>_resume_<ts>`).
|
||||
* The workspace's session.json records the authoritative id — the latest resume
|
||||
* attempt, or the original — so commands that query Temporal (status, stop) resolve
|
||||
* through here instead of assuming the name is the id.
|
||||
* A workspace name is not always its workflow id: a fresh named workspace gets
|
||||
* `<workspace>_shannon-<timestamp>` as its workflow id (only an auto-named workspace's
|
||||
* directory name equals its original id), and each resume spawns a new workflow
|
||||
* (`<workspace>_resume_<ts>`). The workspace's session.json records the authoritative
|
||||
* id — the latest resume attempt, or the original — so commands that query Temporal
|
||||
* (status, stop) resolve through here instead of assuming the name is the id.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { Client, Connection, WorkflowFailedError, WorkflowNotFoundError } from '@temporalio/client';
|
||||
import { ACTIVITY_TO_AGENT, type PipelineState } from './scan/pipeline.js';
|
||||
import { ACTIVITY_TO_PROGRESS, type PipelineState } from './scan/pipeline.js';
|
||||
|
||||
const ADDRESS = '127.0.0.1:7233';
|
||||
const NAMESPACE = 'default';
|
||||
@@ -20,11 +20,30 @@ const TERMINAL_STATUSES: ReadonlySet<string> = new Set(['COMPLETED', 'FAILED', '
|
||||
|
||||
export interface RunningAgent {
|
||||
readonly agent: string;
|
||||
readonly label: string;
|
||||
/** 'agent' rows join the static pipeline tree; 'operation' rows feed the background-work phase. */
|
||||
readonly kind: 'agent' | 'operation';
|
||||
/** Set when a persisted parent stage owns this row; the label then reads as that stage's step. */
|
||||
readonly parentKey?: string;
|
||||
readonly attempt: number;
|
||||
readonly startedAt?: number;
|
||||
readonly lastFailure?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The CLI's activity mirror does not know an activity type the running scan is using, so the
|
||||
* progress tree cannot be rendered completely. Distinct from a Temporal connection failure.
|
||||
*/
|
||||
export class ActivityMirrorError extends Error {
|
||||
override name = 'ActivityMirrorError' as const;
|
||||
|
||||
constructor(activityType: string) {
|
||||
super(
|
||||
`This version of the Shannon command line does not recognise part of the running scan\n(${activityType}). Update Shannon, or watch the scan with: shannon logs <workspace>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert a proto ITimestamp (seconds is a Long) to epoch millis. */
|
||||
function timestampMs(
|
||||
ts: { seconds?: { toString(): string } | number | null; nanos?: number | null } | null,
|
||||
@@ -66,12 +85,26 @@ export async function describeScan(workflowId: string): Promise<ScanDescription
|
||||
|
||||
const runningAgents: RunningAgent[] = [];
|
||||
for (const pending of desc.raw.pendingActivities ?? []) {
|
||||
const agent = ACTIVITY_TO_AGENT[pending.activityType?.name ?? ''];
|
||||
if (!agent) continue;
|
||||
const lastFailure = pending.lastFailure?.message;
|
||||
const activityType = pending.activityType?.name ?? '';
|
||||
const progress = ACTIVITY_TO_PROGRESS[activityType];
|
||||
// Fail closed: skipping an unknown activity would render a quietly incomplete tree.
|
||||
if (!progress) {
|
||||
throw new ActivityMirrorError(activityType || 'unknown activity');
|
||||
}
|
||||
// Temporal's own failure message is never forwarded verbatim: it can carry raw
|
||||
// exception text from inside the activity, which this client has no way to vet
|
||||
// before painting it into a terminal. Only its presence is kept; the boolean feeds
|
||||
// a fixed sentence downstream (see safeFailureDetail), and the real detail stays
|
||||
// one `shannon logs` away.
|
||||
// NOTE: the proto decoder writes an absent lastFailure as null, not undefined, so a
|
||||
// loose check is what distinguishes a healthy attempt from a failed one.
|
||||
const lastFailure = pending.lastFailure == null ? undefined : 'This activity attempt failed.';
|
||||
const startedAt = timestampMs(pending.scheduledTime ?? pending.lastStartedTime ?? null);
|
||||
runningAgents.push({
|
||||
agent,
|
||||
agent: progress.key,
|
||||
label: progress.label,
|
||||
kind: progress.kind,
|
||||
...(progress.parentKey !== undefined ? { parentKey: progress.parentKey } : {}),
|
||||
attempt: pending.attempt ?? 1,
|
||||
...(startedAt !== undefined ? { startedAt } : {}),
|
||||
...(lastFailure ? { lastFailure } : {}),
|
||||
@@ -153,8 +186,9 @@ export async function waitForWorkflowClose(workflowId: string, opts: WatchOption
|
||||
|
||||
while (!signal?.aborted) {
|
||||
try {
|
||||
const desc = await describeScan(workflowId);
|
||||
if (desc === null || TERMINAL_STATUSES.has(desc.status)) {
|
||||
const client = await getClient();
|
||||
const desc = await client.workflow.getHandle(workflowId).describe();
|
||||
if (TERMINAL_STATUSES.has(desc.status.name)) {
|
||||
return { reason: 'closed' };
|
||||
}
|
||||
// Reachable and still RUNNING — reset the failure streak and note any recovery.
|
||||
@@ -164,6 +198,9 @@ export async function waitForWorkflowClose(workflowId: string, opts: WatchOption
|
||||
}
|
||||
connectFailures = 0;
|
||||
} catch (err) {
|
||||
if (err instanceof WorkflowNotFoundError) {
|
||||
return { reason: 'closed' };
|
||||
}
|
||||
connectFailures++;
|
||||
lastError = err instanceof Error ? err.message : String(err);
|
||||
if (!warned && connectFailures >= warnAfterFailures) {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Workspace enumeration, default-target resolution, and scan identity proof.
|
||||
*
|
||||
* The action commands (`logs`, `status`, `stop`) each take a workspace name. When one
|
||||
* is omitted, `resolveDefaultWorkspace` picks the obvious candidate — the single running
|
||||
* scan, or the most recent workspace — so the common "I just started one scan, show me
|
||||
* its logs" path doesn't require retyping an auto-generated name. Target selection and
|
||||
* identity proof are separate steps: `resolveScanIdentity` turns a selected or explicit
|
||||
* string into the one canonical (workspace, workflowId) pair the session records prove.
|
||||
*
|
||||
* Running scans are identified by Docker label (the authoritative source, shared with
|
||||
* `stop`); recency for finished scans comes from each run's session.json createdAt,
|
||||
* with the workspace directory mtime as the fallback for runs that predate it.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { runningScanWorkspaces } from './docker.js';
|
||||
import { getWorkspacesDir } from './home.js';
|
||||
import { resolveRunFile } from './paths.js';
|
||||
import { resolveWorkflowId } from './session.js';
|
||||
|
||||
export interface WorkspaceInfo {
|
||||
readonly name: string;
|
||||
/** Creation time in ms — the recency sort key. Null when neither session.json nor stat is readable. */
|
||||
readonly createdMs: number | null;
|
||||
}
|
||||
|
||||
/** Creation time of a workspace: session.json createdAt, else directory mtime, else null. */
|
||||
function readCreatedMs(runDir: string): number | null {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(resolveRunFile(runDir, 'session.json'), 'utf-8'));
|
||||
const createdMs = Date.parse(parsed?.session?.createdAt ?? '');
|
||||
if (!Number.isNaN(createdMs)) {
|
||||
return createdMs;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the directory mtime.
|
||||
}
|
||||
|
||||
try {
|
||||
return fs.statSync(runDir).mtimeMs;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Every workspace directory, newest-first by createdAt (directory mtime fallback). */
|
||||
export function listWorkspaces(): WorkspaceInfo[] {
|
||||
const workspacesDir = getWorkspacesDir();
|
||||
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(workspacesDir, { withFileTypes: true });
|
||||
} catch {
|
||||
// Workspaces directory does not exist yet — no scans have ever run.
|
||||
return [];
|
||||
}
|
||||
|
||||
const workspaces: WorkspaceInfo[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
workspaces.push({ name: entry.name, createdMs: readCreatedMs(path.join(workspacesDir, entry.name)) });
|
||||
}
|
||||
|
||||
// Newest first; workspaces with no known time sort last.
|
||||
workspaces.sort((a, b) => (b.createdMs ?? 0) - (a.createdMs ?? 0));
|
||||
return workspaces;
|
||||
}
|
||||
|
||||
export type ScanIdentity =
|
||||
| { readonly kind: 'ok'; readonly workspace: string; readonly workflowId: string }
|
||||
| {
|
||||
readonly kind: 'not-found';
|
||||
readonly reason: 'no-match' | 'unreadable-record';
|
||||
/** For 'unreadable-record': the session.json path that could not prove the identity. */
|
||||
readonly sessionPath?: string;
|
||||
}
|
||||
| { readonly kind: 'ambiguous'; readonly claims: readonly string[] };
|
||||
|
||||
/** Every workflow id a run's session record has ever claimed: the original plus each resume. */
|
||||
function readRecordedWorkflowIds(runDir: string): readonly string[] {
|
||||
try {
|
||||
const session = JSON.parse(fs.readFileSync(resolveRunFile(runDir, 'session.json'), 'utf-8'));
|
||||
const resumeAttempts: { workflowId?: string }[] = session.session?.resumeAttempts ?? [];
|
||||
const ids = [session.session?.originalWorkflowId, ...resumeAttempts.map((attempt) => attempt.workflowId)];
|
||||
return ids.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prove the canonical (workspace, workflowId) pair for a status target.
|
||||
*
|
||||
* A directory with a readable session record takes precedence and follows its latest
|
||||
* resume (an auto-named directory whose name equals its original workflow id resolves
|
||||
* here). Otherwise the input is matched exactly against every workflow id the session
|
||||
* records have claimed — session.json is the only trustworthy reverse mapping, and a
|
||||
* valid workspace name may itself end in `_shannon-<digits>`, so the id naming
|
||||
* convention is never used to guess.
|
||||
*/
|
||||
export function resolveScanIdentity(input: string): ScanIdentity {
|
||||
const runDir = path.join(getWorkspacesDir(), input);
|
||||
let isDirectory = false;
|
||||
try {
|
||||
isDirectory = fs.statSync(runDir).isDirectory();
|
||||
} catch {
|
||||
// Not a workspace directory — fall through to the exact workflow-id match.
|
||||
}
|
||||
|
||||
if (isDirectory) {
|
||||
const workflowId = resolveWorkflowId(input);
|
||||
if (workflowId !== undefined) {
|
||||
return { kind: 'ok', workspace: input, workflowId };
|
||||
}
|
||||
return { kind: 'not-found', reason: 'unreadable-record', sessionPath: resolveRunFile(runDir, 'session.json') };
|
||||
}
|
||||
|
||||
const claims: string[] = [];
|
||||
for (const workspace of listWorkspaces()) {
|
||||
const recorded = readRecordedWorkflowIds(path.join(getWorkspacesDir(), workspace.name));
|
||||
if (recorded.includes(input)) {
|
||||
claims.push(workspace.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (claims.length === 1) {
|
||||
// The exact requested id is kept, so an older workflow id keeps addressing that older execution.
|
||||
return { kind: 'ok', workspace: claims[0] as string, workflowId: input };
|
||||
}
|
||||
if (claims.length > 1) {
|
||||
return { kind: 'ambiguous', claims: [...claims].sort() };
|
||||
}
|
||||
return { kind: 'not-found', reason: 'no-match' };
|
||||
}
|
||||
|
||||
export type DefaultTarget =
|
||||
| { readonly kind: 'ok'; readonly workspace: string; readonly running: boolean }
|
||||
| { readonly kind: 'none' }
|
||||
| { readonly kind: 'ambiguous'; readonly running: readonly string[] };
|
||||
|
||||
/**
|
||||
* Pick the default workspace when the user gave none.
|
||||
*
|
||||
* Exactly one scan running → that scan. Multiple running → ambiguous, so the caller can
|
||||
* list them and ask for an explicit name. None running → the most recent workspace when
|
||||
* `allowFinished` (viewing commands), otherwise none (stopping a finished scan is a no-op).
|
||||
*/
|
||||
export function resolveDefaultWorkspace(opts: { readonly allowFinished: boolean }): DefaultTarget {
|
||||
const running = runningScanWorkspaces();
|
||||
if (running.length === 1) {
|
||||
return { kind: 'ok', workspace: running[0] as string, running: true };
|
||||
}
|
||||
if (running.length > 1) {
|
||||
return { kind: 'ambiguous', running };
|
||||
}
|
||||
|
||||
if (!opts.allowFinished) {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
|
||||
const workspaces = listWorkspaces();
|
||||
const mostRecent = workspaces[0];
|
||||
if (!mostRecent) {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
return { kind: 'ok', workspace: mostRecent.name, running: false };
|
||||
}
|
||||
Reference in New Issue
Block a user