From 242f85f158857eb2387601dc2479dbc5eed97887 Mon Sep 17 00:00:00 2001 From: ajmallesh Date: Wed, 26 Aug 2026 20:00:11 -0700 Subject: [PATCH] feat(cli)!: default the scan target and add a JSON error contract List local scans, resolve the active or most recent workspace automatically, and make logs, status, and stop use one canonical scan identity. Add stable machine-readable failures, richer status output, explicit help errors, and seven-day Temporal retention. Treat absent Temporal pending-activity failures as absent whether the decoder represents them as `null` or missing. BREAKING CHANGE: `status --json` now returns a fixed `failureMessage`. Read `partialReasons`, `agenticSast`, and `workflow.log` for diagnostic detail. --- apps/cli/src/commands/scans.ts | 102 ++++++++++++------- apps/cli/src/commands/start.ts | 8 +- apps/cli/src/commands/status.ts | 54 +++++++--- apps/cli/src/commands/stop.ts | 29 +++++- apps/cli/src/docker.ts | 117 ++++++++++++++++++++-- apps/cli/src/errors.ts | 113 ++++++++++++++------- apps/cli/src/help.ts | 31 ++++-- apps/cli/src/index.ts | 113 +++++++++++++++++---- apps/cli/src/scan/derive.ts | 5 + apps/cli/src/scan/pipeline.ts | 26 +++-- apps/cli/src/session.ts | 11 +- apps/cli/src/temporal-client.ts | 17 +++- apps/cli/src/workspaces.ts | 171 ++++++++++++++++++++++++++++++++ 13 files changed, 651 insertions(+), 146 deletions(-) create mode 100644 apps/cli/src/workspaces.ts diff --git a/apps/cli/src/commands/scans.ts b/apps/cli/src/commands/scans.ts index d184eef7..bb78fd42 100644 --- a/apps/cli/src/commands/scans.ts +++ b/apps/cli/src/commands/scans.ts @@ -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 -r ' to begin.`); + console.log(`No scans yet. Run '${prefix} start -u -r ' 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)); diff --git a/apps/cli/src/commands/start.ts b/apps/cli/src/commands/start.ts index d94e7382..6ded3138 100644 --- a/apps/cli/src/commands/start.ts +++ b/apps/cli/src/commands/start.ts @@ -74,8 +74,8 @@ function arraysEqual(left: readonly unknown[], right: readonly unknown[]): boole /** * 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 'other' and - * 'other-exploit' only after the other pipeline admits findings. If the worker's 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. */ @@ -85,14 +85,14 @@ function isCurrentDurableState(value: unknown): boolean { const participating = value.participating_classes; const validParticipation = - arraysEqual(participating, FIXED_CLASSES) || arraysEqual(participating, [...FIXED_CLASSES, 'other']); + 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, 'other-exploit']); + return arraysEqual(expected, baselineAgents) || arraysEqual(expected, [...baselineAgents, 'miscellaneous-exploit']); } /** One refusal for damaged CLI-owned or worker-owned workspace records, whichever reads first. */ diff --git a/apps/cli/src/commands/status.ts b/apps/cli/src/commands/status.ts index ab3765a1..ba343e78 100644 --- a/apps/cli/src/commands/status.ts +++ b/apps/cli/src/commands/status.ts @@ -3,17 +3,17 @@ * * 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 { ActivityMirrorError, @@ -24,6 +24,7 @@ import { } 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'; @@ -45,8 +46,9 @@ async function readScanDescription(workflowId: string): Promise { 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)) { @@ -177,18 +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 { - // 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; +export async function status(target: string, opts: { readonly json: boolean }): Promise { + // 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.", ); } diff --git a/apps/cli/src/commands/stop.ts b/apps/cli/src/commands/stop.ts index 9096823c..e7651e5c 100644 --- a/apps/cli/src/commands/stop.ts +++ b/apps/cli/src/commands/stop.ts @@ -20,6 +20,7 @@ import { import { fail, failUsage, warn } from '../errors.js'; import { commandPrefix } from '../mode.js'; import { resolveWorkflowId } from '../session.js'; +import { resolveDefaultWorkspace } from '../workspaces.js'; export interface StopOptions { all: boolean; @@ -108,6 +109,24 @@ async function stopAllScans(yes: boolean): Promise { } } +/** + * 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 { ensureDocker(); @@ -115,12 +134,12 @@ export async function stop(opts: StopOptions): Promise { 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 `, 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); } diff --git a/apps/cli/src/docker.ts b/apps/cli/src/docker.ts index 8f317633..fd774e3e 100644 --- a/apps/cli/src/docker.ts +++ b/apps/cli/src/docker.ts @@ -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 { +/** Start (or find) Temporal via compose and wait until it serves; exits the process on failure. */ +async function ensureTemporalHealthy(spinner: SpinnerResult): Promise { if (isTemporalReady()) { return; } @@ -146,6 +144,97 @@ export async function ensureInfra(spinner: SpinnerResult): Promise { 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 { + await ensureTemporalHealthy(spinner); + convergeNamespaceRetention(); +} + /** * Build the worker image from the repository, tagged with the name this mode * resolves at run time. @@ -358,7 +447,9 @@ 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, so it is loosened for the in-container browser automation; the + // worker process itself is not granted any extra privilege by this. args.push('--shm-size', '2gb', '--security-opt', 'seccomp=unconfined'); // Image @@ -405,6 +496,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 diff --git a/apps/cli/src/errors.ts b/apps/cli/src/errors.ts index b067deba..d40e3ffc 100644 --- a/apps/cli/src/errors.ts +++ b/apps/cli/src/errors.ts @@ -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; - } -} diff --git a/apps/cli/src/help.ts b/apps/cli/src/help.ts index f9da8808..3c22a7a7 100644 --- a/apps/cli/src/help.ts +++ b/apps/cli/src/help.ts @@ -47,30 +47,32 @@ const COMMAND_HELP: Readonly> = { ], }, stop: { - usage: ['stop [--yes]', 'stop --all [--yes]'], - description: 'Stop one scan by workspace, or every scan with --all (Temporal stays up).', + usage: ['stop [] [--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 '], - description: "Tail a scan's live log until it completes.", - examples: ['logs q1-audit'], + usage: ['logs []'], + 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 [--json]'], + usage: ['status [] [--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 diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 20d5e810..5b95479f 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -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=` + * (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,15 @@ function renderUsage(prefix: string, mode: Mode): string { const rows: ReadonlyArray = [ ...(mode === 'local' ? [] : [[`${prefix} setup`, 'Configure credentials'] as const]), [`${prefix} start --url --repo [options]`, 'Start a pentest scan'], - [`${prefix} stop [--yes]`, 'Stop one scan'], + [`${prefix} stop [] [--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 `, "Show a scan's live log"], - [`${prefix} status [--json]`, 'Live phase/agent progress of one scan'], - [`${prefix} scans [--json]`, 'List completed scans and their reports'], + [`${prefix} logs []`, "Show a scan's live log (default: running or most recent)"], + [ + `${prefix} status [] [--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 +185,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 { @@ -163,13 +222,17 @@ async function main(): Promise { 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 +244,27 @@ async function main(): Promise { return; } + // An explicit `help ` names a topic on purpose, so an unknown one is a usage + // error — unlike `--help `, 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); @@ -211,19 +295,14 @@ async function main(): Promise { } case 'logs': { const { positionals } = parseArgs(rest, { maxPositionals: 1 }); - const workspaceId = positionals[0]; - if (!workspaceId) { - failUsage('Workspace ID is required', `Usage: ${commandPrefix()} logs `); - } + const workspaceId = resolveViewingWorkspace(positionals[0], `Usage: ${commandPrefix()} logs []`); logs(workspaceId); 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 [--json]`); - } + const usage = `Usage: ${commandPrefix()} status [] [--json]`; + const workspaceId = resolveViewingWorkspace(positionals[0], usage); await status(workspaceId, { json: !!flags.json }); break; } diff --git a/apps/cli/src/scan/derive.ts b/apps/cli/src/scan/derive.ts index 3c20453d..c9b76051 100644 --- a/apps/cli/src/scan/derive.ts +++ b/apps/cli/src/scan/derive.ts @@ -70,6 +70,11 @@ function agentState(name: string, state: PipelineState | null, running: Set): string | undefined { const failed = state?.failedPipelines.find((f) => f.vulnType === agentClass(name)); return ( diff --git a/apps/cli/src/scan/pipeline.ts b/apps/cli/src/scan/pipeline.ts index dd09d639..a18bbe3e 100644 --- a/apps/cli/src/scan/pipeline.ts +++ b/apps/cli/src/scan/pipeline.ts @@ -93,16 +93,16 @@ export const PIPELINE: readonly PhaseSpec[] = [ }, ]; -const OTHER_EXPLOIT_AGENT: AgentSpec = { - name: 'other-exploit', - label: 'other', - activityType: 'runOtherExploitAgent', +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, 'other-exploit' is appended only once the other pipeline has + * 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. @@ -113,13 +113,13 @@ export function pipelineForState(state: PipelineState | null): readonly PhaseSpe return PIPELINE.map((phase) => { if (phase.key !== 'exploitation') return phase; const agents = phase.agents.filter((agent) => expected.has(agent.name)); - if (expected.has(OTHER_EXPLOIT_AGENT.name)) agents.push(OTHER_EXPLOIT_AGENT); + 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> = Object.fromEntries( - [...PIPELINE.flatMap((phase) => phase.agents), OTHER_EXPLOIT_AGENT].map((agent) => [ + [...PIPELINE.flatMap((phase) => phase.agents), MISCELLANEOUS_EXPLOIT_AGENT].map((agent) => [ agent.activityType, { key: agent.name, label: agent.label, kind: 'agent' }, ]), @@ -141,7 +141,11 @@ const OPERATION_ACTIVITY_PROGRESS: Readonly 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' }, - persistOtherOutcome: { key: 'other-pipeline', label: 'Including other findings', 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' }, @@ -158,7 +162,11 @@ const OPERATION_ACTIVITY_PROGRESS: Readonly 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: 'other-pipeline', label: 'Preparing other findings', kind: 'operation' }, + seedEmptyProducerQueue: { + key: 'miscellaneous-pipeline', + label: 'Preparing miscellaneous findings', + kind: 'operation', + }, prepareClassReconciliation: { key: 'reconciliation', label: 'Preparing findings', diff --git a/apps/cli/src/session.ts b/apps/cli/src/session.ts index cbee1285..87fcace4 100644 --- a/apps/cli/src/session.ts +++ b/apps/cli/src/session.ts @@ -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 (`_resume_`). - * 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 + * `_shannon-` as its workflow id (only an auto-named workspace's + * directory name equals its original id), and each resume spawns a new workflow + * (`_resume_`). 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'; diff --git a/apps/cli/src/temporal-client.ts b/apps/cli/src/temporal-client.ts index 3d493062..d98f1ea5 100644 --- a/apps/cli/src/temporal-client.ts +++ b/apps/cli/src/temporal-client.ts @@ -91,7 +91,14 @@ export async function describeScan(workflowId: string): Promise= warnAfterFailures) { diff --git a/apps/cli/src/workspaces.ts b/apps/cli/src/workspaces.ts new file mode 100644 index 00000000..a41260cd --- /dev/null +++ b/apps/cli/src/workspaces.ts @@ -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-`, 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 }; +}