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.
This commit is contained in:
ajmallesh
2026-08-26 20:00:11 -07:00
parent 3bdcfac85d
commit 242f85f158
13 changed files with 651 additions and 146 deletions
+67 -35
View File
@@ -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));
+4 -4
View File
@@ -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. */
+39 -15
View File
@@ -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<ScanDescription
try {
return await describeScan(workflowId);
} catch (error) {
if (error instanceof ActivityMirrorError) fail(error.message);
fail(
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.',
@@ -155,7 +157,7 @@ async function watch(workspace: string, workflowId: string): Promise<never> {
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<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;
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.",
);
}
+24 -5
View File
@@ -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<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 +134,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);
}
+111 -6
View File
@@ -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.
@@ -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
+76 -37
View File
@@ -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
View File
@@ -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
+96 -17
View File
@@ -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,15 @@ 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} 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 +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<void> {
@@ -163,13 +222,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 +244,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);
@@ -211,19 +295,14 @@ async function main(): Promise<void> {
}
case 'logs': {
const { positionals } = parseArgs(rest, { maxPositionals: 1 });
const workspaceId = positionals[0];
if (!workspaceId) {
failUsage('Workspace ID is required', `Usage: ${commandPrefix()} logs <workspace>`);
}
const workspaceId = resolveViewingWorkspace(positionals[0], `Usage: ${commandPrefix()} logs [<workspace>]`);
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 <workspace> [--json]`);
}
const usage = `Usage: ${commandPrefix()} status [<workspace>] [--json]`;
const workspaceId = resolveViewingWorkspace(positionals[0], usage);
await status(workspaceId, { json: !!flags.json });
break;
}
+5
View File
@@ -70,6 +70,11 @@ 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 (
+17 -9
View File
@@ -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<Record<string, ActivityProgressSpec>> = 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<Record<string, ActivityProgressSpec>
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<Record<string, ActivityProgressSpec>
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',
+6 -5
View File
@@ -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';
+14 -3
View File
@@ -91,7 +91,14 @@ export async function describeScan(workflowId: string): Promise<ScanDescription
if (!progress) {
throw new ActivityMirrorError(activityType || 'unknown activity');
}
const lastFailure = pending.lastFailure?.message;
// 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: progress.key,
@@ -179,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.
@@ -190,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) {
+171
View File
@@ -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 };
}