mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-09-17 07:25:35 +02:00
feat: Shannon 3.0 Agentic SAST (#433)
* feat(worker): add agentic static analysis Add the ten-stage Agentic SAST pipeline, confined repository tools, model runtime, prompt templates, and SARIF export. Make retries, repair sessions, reduced coverage, usage accounting, and model-output drift durable across Temporal replay and resume. Keep retry diagnostics in their actionable closed vocabulary. Package the Mantis-derived license material with the prompts that require it. * feat(worker): deduplicate static and runtime findings before exploitation Parse Agentic SAST SARIF into typed observations, enrich and route those observations, and reconcile them with pentest findings before exploitation. Publish deterministic exploitation queues with stable lineage, exact-path Git commits, retry-safe manifests, named drop reasons, and confined task formation. Reject duplicate producer IDs before commit and adopt either legal provenance shape after a lost acknowledgement. * feat(config)!: replace vuln_classes with agentic_sast Wire Agentic SAST and reconciliation into the main pipeline, persist their durable state, and add the Miscellaneous finding and exploitation lane. Make scan completion, cancellation, partial outcomes, resume identity, and report recovery use the integrated final workflow contract. Introduce the atomic finalization, ordering, renumbering, compaction, and output services that workflow calls. Keep completed Miscellaneous work and report drafts idempotent across resume, preserve public main's default-on exploit SARIF behavior, and describe stage-fallback candidates without claiming they were exported. BREAKING CHANGE: `vuln_classes` has been removed. Configs containing it now fail validation, and all five core pentest classes run on every scan. Workspaces created by Shannon 2.x cannot be resumed. Finish or discard in-flight scans before upgrading, then start a new workspace name. * perf: overlap static analysis and the Miscellaneous lane with the pentest Run Agentic SAST alongside vulnerability analysis and run Miscellaneous exploitation alongside the specialist exploitation lanes. Keep reconciliation dependent on the completed static-analysis result while preserving parallel work everywhere that has no data dependency. * 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. * feat(logging): trace tool calls and write a log per agent Record complete tool-call arguments in the workflow log and project each agent's events into its own durable log. Add agent listing and agent-specific log tailing while preserving byte-exact output and draining log handles before activities return. * feat(worker): standardize severity and reporting guidance in exploit prompts Give every exploit agent the same status, confidence, severity-reasoning, report-writing, credential-handling, and scope contract. Apply the same task-formation and SAST-enrichment procedure to the Miscellaneous lane. * feat(worker): disclose scan coverage and make reporting auditable Build on the retry-safe finalization foundation to preserve correct identities, source locations, scan dates, partial-coverage limitations, and consistent report JSON, Markdown, SARIF, and PDF output. Report Agentic SAST, reconciliation wall-clock time, stage usage, retry spend, and background work without duplicate or hardcoded totals. Keep report findings canonical, drop cross-class restatements, name enrichment losses, and render the executive-summary narrative in the PDF. * chore(license): attribute Mantis and Pi and refresh the docs Add the final Mantis and Pi notices, license copies, acknowledgements, and residual copyright updates. Update the README, maintained documentation, contributor guidance, and hand-maintained mirrors to describe Agentic SAST, reconciliation, the Miscellaneous lane, current CLI behavior, and the final release contract. Correct stale workspace and container guidance and annotate long-standing internals for maintainers. * fix(logging): treat a slash as a word separator in agent labels * feat(cli)!: rebuild scan status around model work - show Capella stages beneath the concurrent Agentic SAST phase - attach reconciliation time to the class row it feeds - hide completed bookkeeping and the duplicate miscellaneous wrapper - carry validated child-workflow progress into durable parent state - derive the terminal tree and status JSON from the same phase shape BREAKING CHANGE: `status --json` replaces phase `parallel` with `children` and `meta`, adds phase summaries and notes plus agent attachment fields, and removes the `analysis-engines` and `operational-work` phases. * fix(report): drop the empty Critical Findings section from the PDF summary * fix(sast): align Capella export with the submit-time code-path contract The export gate required every code_paths entry to be file:line, but submit only requires the primary sink to be file:line and accepts bare trace steps. A single malformed trace step therefore dropped an otherwise-valid finding at export. - add isValidPrimaryCodePath as the one shared primary-sink contract - validate only the primary at export; buildResult already drops unusable steps - route the submit-time validator through the same helper so the two cannot drift * feat(sast): tolerate hygiene-only Capella reductions instead of going partial A reduction only makes a run partial when it loses real coverage or a whole finding. Malformed model output, salvaged turn-limit work, and rejected duplicate verdicts are recorded as evidence but no longer flip the run to partial. - add reductionIsTolerable: partial only when genuine-loss counts are nonzero - drive runCapella's partial reasons and display coverage off non-tolerable ones - keep every reduction in agenticSast.reductions so nothing is lost as evidence * feat(logging): record the provider reason for a failed agent turn A failed provider turn collapsed to AGENT_EXECUTION_FAILED/unknown with the underlying reason discarded, so a model-side rejection or safeguard was indistinguishable from a transport fault in the error log. - add safeProviderTurnDetails: write bounded, non-sensitive fields (provider, model, responseId, stop reason, tool-in-flight, category, retryable) to error.log - gate a sanitized errorMessage snippet behind SHANNON_DEBUG_PROVIDER_ERRORS, off by default - forward SHANNON_DEBUG_PROVIDER_ERRORS from the CLI into the worker container * fix(cli): keep shannon logs tailing through a Temporal blip - End the interactive tail on the log's own terminal marker or Ctrl-C, so a transient Temporal outage no longer aborts the command with exit 1. - Rebuild the memoized Temporal client after a failed poll: a wedged gRPC channel was cached forever, so "retrying…" could never reconnect. - Keep start --follow (CI) bounded — a genuinely dead Temporal still fails the run instead of hanging. * fix(worker): correct PDF finding reporting - Render OWASP category, authentication state, and remediation - Omit the redundant per-finding exploited status - Preserve canonical category and field ordering across report modes - Continue Proof of Impact numbering across embedded code blocks - Wrap long PDF code lines without changing canonical report content * fix: attribute a reconciliation failure to exploitation only - Stop marking a class's vulnerability-analysis agent failed when that agent succeeded and only reconciliation failed; the status tree now renders the analysis row completed and the exploitation row failed - Consume the worker's failedReconciliations signal in the CLI, which the mirrored PipelineState already declared but never read - Correct the class_reconciliation_failed message, which claimed the class's analysis results were still in the report when the class is excluded from it * fix(pi): give each task sub-session its own resource loader to prevent stale extension ctx * fix(prompts): scope exploit agents to in-band proof, mark OOB-only findings blocked * fix(cli): reject a shell credential that shadows a gateway config.toml key * fix(cli): make scan shutdown verifiable - preselect and persist workflow identity before worker launch - cancel first, then verify bounded Temporal termination - reconcile Docker workers with Temporal open workflows - fail closed on stale images and unavailable lifecycle state - mark cancellation only after confirmed shutdown * feat(cli): prompt for setup on a bare npx invocation with no credentials * fix(cli): don't blame anthropic when no credentials are configured at all * chore(release): bump beta base version to 3.0.0 * feat(cli): show a 'start your first scan' box in help on a TTY * docs: refresh README and platform overview for Shannon 3.0 - lead with the 3.0 launch note and rewrite key capabilities around security code analysis, the rebuilt terminal experience, native CI/CD, and PDF/SARIF - recast the editions table as Shannon Open Source against the Keygraph Enterprise Platform, stating open source is not a trial edition - rewrite the platform overview around exhaustive agentic SAST, canonical findings, automated remediation, targeted verification, and governance - add five product screenshots under assets/keygraph-platform/, referenced relative to docs/ * docs: add the Shannon naming section and swap in the 3.0 demo GIF - explain the Claude Shannon information-theory origin under "What is Shannon?" - point "Shannon in Action" at the 3.0 recording in assets/Shannon3GIF.gif Both taken from the README half of #438. * docs: document CI/CD integrations and the reconciled analysis pipeline - add a CI/CD Integrations section covering the official GitHub Action and GitLab component, pipeline artifacts, and exploit-only severity gates - redraw the architecture section as a Mermaid flow: agentic code analysis and recon feed finding reconciliation, then exploitation and reporting - describe open-source code analysis as a multi-stage agentic workflow and reserve parsed-code CPGs and exhaustive verification for Enterprise - sharpen the privacy wording: results stay local, but model requests carry source context to whichever endpoint you configure - drop the "not recommended" framing on local models and add a section on why Shannon complements rather than replaces human pentesters - regenerate llms-full.txt from the updated README and docs * docs: add the Photoview benchmark across three models - Add a "Shannon in Action" table for Photoview 2.4.0 runs on DeepSeek v4 Flash, Grok 4.6, and Claude Opus 5, each linking its PDF report and SARIF output - Store the per-model reports under benchmark/ - Link the (forthcoming) benchmark writeup from the section intro * docs: add the Shannon vs XBOW/Aikido Photoview benchmark writeup - Add docs/shannon-xbow-aikido-benchmark.md with methodology, per-model cost/coverage tables, and links to each model's report and SARIF - Link the writeup from the README "Shannon in Action" section * docs: link the benchmark announcement discussion from the README * fix(readme): restore theme-aware banner, badge, and buttons * feat!: trigger the Shannon 3.0 major release --------- Co-authored-by: ezl-keygraph <ezhil@keygraph.io>
This commit is contained in:
co-authored by
ezl-keygraph
parent
6108de3cfc
commit
9767ebe633
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.1.0",
|
||||
"@temporalio/client": "^1.11.0",
|
||||
"@temporalio/client": "1.15.0",
|
||||
"chokidar": "^5.0.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"smol-toml": "^1.6.1"
|
||||
|
||||
+199
-38
@@ -1,14 +1,17 @@
|
||||
/**
|
||||
* `shannon logs` command — tail a scan's live log.
|
||||
*
|
||||
* The log file is streamed for its content; completion is decided by Temporal (the
|
||||
* workflow's status), so a worker that dies mid-run can't leave the tail hanging. Uses
|
||||
* chokidar for reliable cross-platform file watching and bounded synchronous reads to
|
||||
* prevent duplicate output.
|
||||
* The log file is streamed for its content and ends the tail on its own terminal marker
|
||||
* (`Scan COMPLETED/PARTIAL/FAILED/CANCELLED`) or Ctrl-C. Temporal's workflow status is a backstop
|
||||
* that also closes the tail when a worker dies without writing a marker — for interactive `logs` a
|
||||
* Temporal outage is never fatal (it keeps tailing); only `start --follow` (CI) treats a sustained
|
||||
* outage as a failure. Uses chokidar for reliable cross-platform file watching and bounded
|
||||
* synchronous reads to prevent duplicate output.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { StringDecoder } from 'node:string_decoder';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { watch } from 'chokidar';
|
||||
import { fail } from '../errors.js';
|
||||
@@ -18,8 +21,69 @@ import { resolveWorkflowId } from '../session.js';
|
||||
import { waitForWorkflowClose } from '../temporal-client.js';
|
||||
import { stdoutIsTerminal } from '../tty.js';
|
||||
|
||||
/** Read a byte range from a file and return it as a UTF-8 string. */
|
||||
function readRange(filePath: string, start: number, end: number): string {
|
||||
const TERMINAL_HEADINGS = new Set(['Scan COMPLETED', 'Scan PARTIAL', 'Scan FAILED', 'Scan CANCELLED']);
|
||||
|
||||
// The combined log resets completion on the bare `RESUMED` heading; a per-agent file carries the
|
||||
// distinct `--- RESUMED (<workflow id>) ---` boundary that WorkflowLogger.logResumeBoundary writes
|
||||
// (kept distinct per resume so it stays idempotent per file). Both mean a new execution began, so a
|
||||
// `--agent` tail must clear a stale terminal marker on either, matching the combined tail.
|
||||
const AGENT_RESUME_BOUNDARY = /^--- RESUMED \(.+\) ---$/u;
|
||||
|
||||
function isResumeBoundary(line: string): boolean {
|
||||
return line === 'RESUMED' || AGENT_RESUME_BOUNDARY.test(line);
|
||||
}
|
||||
|
||||
/** Tracks only complete structural lines while output remains byte-for-byte unchanged. */
|
||||
export class LogCompletionState {
|
||||
private pendingLine = '';
|
||||
private terminalIsLastMarker = false;
|
||||
private failureIsLastMarker = false;
|
||||
|
||||
ingest(chunk: string): void {
|
||||
const lines = `${this.pendingLine}${chunk}`.split('\n');
|
||||
this.pendingLine = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
if (isResumeBoundary(line)) {
|
||||
this.terminalIsLastMarker = false;
|
||||
this.failureIsLastMarker = false;
|
||||
} else if (TERMINAL_HEADINGS.has(line)) {
|
||||
this.terminalIsLastMarker = true;
|
||||
this.failureIsLastMarker = line === 'Scan FAILED';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isComplete(): boolean {
|
||||
return this.terminalIsLastMarker;
|
||||
}
|
||||
|
||||
hasFailureMarker(): boolean {
|
||||
return this.failureIsLastMarker;
|
||||
}
|
||||
}
|
||||
|
||||
/** Append the forced-stop marker after the worker has exited, unless this execution already ended. */
|
||||
export function appendCancellationFallback(logFile: string): void {
|
||||
fs.mkdirSync(path.dirname(logFile), { recursive: true });
|
||||
const state = new LogCompletionState();
|
||||
try {
|
||||
state.ingest(fs.readFileSync(logFile, 'utf8'));
|
||||
} catch {
|
||||
// A pre-registration stop may not have created the file yet.
|
||||
}
|
||||
if (state.isComplete()) return;
|
||||
|
||||
const descriptor = fs.openSync(logFile, 'a', 0o600);
|
||||
try {
|
||||
fs.writeSync(descriptor, '\nScan CANCELLED\n');
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a byte range without decoding across an arbitrary live-write boundary. */
|
||||
function readRange(filePath: string, start: number, end: number): Buffer {
|
||||
const length = end - start;
|
||||
const buffer = Buffer.alloc(length);
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
@@ -28,7 +92,7 @@ function readRange(filePath: string, start: number, end: number): string {
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
return buffer.toString('utf-8');
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/** Resolve a workspace ID to its workflow.log path, or exit with an error. */
|
||||
@@ -65,10 +129,16 @@ export function resolveLogFile(workspaceId: string): string {
|
||||
}
|
||||
|
||||
export interface TailOptions {
|
||||
/** Workflow whose Temporal status decides when the tail stops. Without it, only Ctrl-C ends the tail. */
|
||||
/** Workflow whose Temporal status can end the tail (alongside the file's own terminal marker). */
|
||||
readonly workflowId?: string;
|
||||
/** Called if the tail ends because Temporal became unreachable, with the captured error. */
|
||||
readonly onUnreachable?: (lastError: string) => void;
|
||||
/**
|
||||
* Consecutive Temporal-outage polls before the watch gives up. Interactive `logs` passes
|
||||
* Infinity so a blip never ends the tail (the file marker or Ctrl-C do); `start --follow` (CI)
|
||||
* leaves it bounded so a genuinely dead Temporal fails the run instead of hanging.
|
||||
*/
|
||||
readonly maxConnectFailures?: number;
|
||||
}
|
||||
|
||||
/** Outcome of a tail: whether the streamed log already contained the worker's `Scan FAILED` block. */
|
||||
@@ -76,36 +146,35 @@ export interface TailResult {
|
||||
readonly sawFailure: boolean;
|
||||
}
|
||||
|
||||
// The worker writes this exact line at the head of its terminal failure summary.
|
||||
const FAILURE_MARKER = /^Scan FAILED$/m;
|
||||
|
||||
/**
|
||||
* Stream a scan's log to the terminal until the workflow closes (completion comes from Temporal,
|
||||
* or Ctrl-C). A Temporal outage is warned about and, if sustained, ends the tail with a diagnostic.
|
||||
* Never exits the process: plain `logs` exits; `start --follow` reads the workflow outcome first.
|
||||
* Reports whether the log already showed the failure, so a caller need not print it a second time.
|
||||
* Stream a scan's log to the terminal until the file shows a terminal marker, the workflow closes,
|
||||
* or Ctrl-C. A Temporal outage is warned about; if it reaches `maxConnectFailures` the tail ends
|
||||
* with a diagnostic (bounded for `start --follow`), but interactive `logs` sets that to Infinity so
|
||||
* an outage keeps tailing. Never exits the process itself. Reports whether the log already showed
|
||||
* the failure, so a caller need not print it a second time.
|
||||
*/
|
||||
export function tailUntilComplete(logFile: string, opts: TailOptions = {}): Promise<TailResult> {
|
||||
return new Promise((resolve) => {
|
||||
let position = 0;
|
||||
const completion = new LogCompletionState();
|
||||
const completionDecoder = new StringDecoder('utf8');
|
||||
let done = false;
|
||||
let sawFailure = false;
|
||||
const controller = new AbortController();
|
||||
let watcher: ReturnType<typeof watch> | undefined;
|
||||
|
||||
/** Output any new content appended since the last read. */
|
||||
function flush(): void {
|
||||
function flush(): boolean {
|
||||
try {
|
||||
const { size } = fs.statSync(logFile);
|
||||
if (size <= position) return;
|
||||
if (size <= position) return completion.isComplete();
|
||||
const data = readRange(logFile, position, size);
|
||||
process.stdout.write(data);
|
||||
position = size;
|
||||
if (!sawFailure && FAILURE_MARKER.test(data)) {
|
||||
sawFailure = true;
|
||||
}
|
||||
completion.ingest(completionDecoder.write(data));
|
||||
return completion.isComplete();
|
||||
} catch {
|
||||
// File not present yet or transiently unreadable — nothing to flush this round.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,27 +182,42 @@ export function tailUntilComplete(logFile: string, opts: TailOptions = {}): Prom
|
||||
if (done) return;
|
||||
done = true;
|
||||
controller.abort();
|
||||
process.off('SIGINT', finish);
|
||||
const result = { sawFailure: completion.hasFailureMarker() };
|
||||
if (watcher) {
|
||||
watcher.close().finally(() => resolve({ sawFailure }));
|
||||
watcher.close().finally(() => resolve(result));
|
||||
// Safety net — resolve anyway if watcher.close() stalls.
|
||||
setTimeout(() => resolve({ sawFailure }), 1000).unref();
|
||||
setTimeout(() => resolve(result), 1000).unref();
|
||||
} else {
|
||||
resolve({ sawFailure });
|
||||
resolve(result);
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Output existing content, then stream anything appended.
|
||||
flush();
|
||||
// 1. Output existing content, then stream anything appended. A per-agent file can be created
|
||||
// after the watcher starts, so `add` is handled too and streams it from its first line.
|
||||
// The file's own `Scan COMPLETED/PARTIAL/FAILED/CANCELLED` marker ends the tail on its own —
|
||||
// a Temporal round-trip is a backstop for a worker that dies without writing one, not the
|
||||
// only way to stop.
|
||||
watcher = watch(logFile, { persistent: true });
|
||||
watcher.on('change', () => flush());
|
||||
const onFsEvent = (): void => {
|
||||
if (flush()) finish();
|
||||
};
|
||||
watcher.on('change', onFsEvent);
|
||||
watcher.on('add', onFsEvent);
|
||||
if (flush()) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Ctrl-C stops watching.
|
||||
process.on('SIGINT', finish);
|
||||
process.once('SIGINT', finish);
|
||||
|
||||
// 3. Temporal decides completion. Without a workflow id, the tail relies on Ctrl-C alone.
|
||||
// 3. Temporal backstops completion for a worker that dies without a marker. Without a workflow
|
||||
// id, the tail relies on the file marker or Ctrl-C alone.
|
||||
if (opts.workflowId) {
|
||||
waitForWorkflowClose(opts.workflowId, {
|
||||
signal: controller.signal,
|
||||
...(opts.maxConnectFailures !== undefined ? { maxConnectFailures: opts.maxConnectFailures } : {}),
|
||||
onConnectionTrouble: (lastError) => {
|
||||
if (!done) console.error(`\n⚠ Lost contact with Temporal, retrying… (${lastError})`);
|
||||
},
|
||||
@@ -162,16 +246,93 @@ export function tailUntilComplete(logFile: string, opts: TailOptions = {}): Prom
|
||||
});
|
||||
}
|
||||
|
||||
export function logs(workspaceId: string): void {
|
||||
const logFile = resolveLogFile(workspaceId);
|
||||
const workflowId = resolveWorkflowId(workspaceId);
|
||||
console.error(stdoutIsTerminal() ? `Tailing scan log: ${logFile}` : 'Tailing scan log');
|
||||
/** The `.shannon/agents/` directory that sits beside a scan's combined workflow.log. */
|
||||
function agentsDirFor(logFile: string): string {
|
||||
return path.join(path.dirname(logFile), 'agents');
|
||||
}
|
||||
|
||||
let unreachable = false;
|
||||
/** List the per-agent log names available for a scan (filename stems, sorted), or an empty list. */
|
||||
export function listAgentLogNames(logFile: string): string[] {
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(agentsDirFor(logFile))
|
||||
.filter((entry) => entry.endsWith('.log'))
|
||||
.map((entry) => entry.slice(0, -'.log'.length))
|
||||
.sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an agent name to its per-agent log path. The name must be a closed-charset basename, and
|
||||
* the resolved file must stay inside the agents directory: traversal and symlink escapes are
|
||||
* rejected. Returns undefined when the name is structurally invalid or escapes the directory.
|
||||
*/
|
||||
export function resolveAgentLogFile(logFile: string, agentName: string): string | undefined {
|
||||
if (!/^[a-z0-9][a-z0-9-]{0,63}$/u.test(agentName)) return undefined;
|
||||
const agentsDir = agentsDirFor(logFile);
|
||||
const target = path.join(agentsDir, `${agentName}.log`);
|
||||
try {
|
||||
const realDir = fs.realpathSync(agentsDir);
|
||||
const realTarget = fs.realpathSync(target);
|
||||
if (realTarget !== path.join(realDir, `${agentName}.log`)) return undefined;
|
||||
} catch {
|
||||
// The file does not exist yet (scan still starting); the closed-charset check already proved
|
||||
// the path cannot traverse out of the agents directory, so it is safe to watch for creation.
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export interface LogsOptions {
|
||||
readonly agent?: string;
|
||||
readonly listAgents?: boolean;
|
||||
}
|
||||
|
||||
function tailFileToExit(logFile: string, workflowId: string | undefined, label: string): void {
|
||||
console.error(stdoutIsTerminal() ? `${label}: ${logFile}` : label);
|
||||
tailUntilComplete(logFile, {
|
||||
...(workflowId ? { workflowId } : {}),
|
||||
onUnreachable: () => {
|
||||
unreachable = true;
|
||||
},
|
||||
}).finally(() => process.exit(unreachable ? 1 : 0));
|
||||
// Interactive tail: a Temporal outage must never end the session. The file's terminal marker or
|
||||
// Ctrl-C stop it; Temporal stays a soft backstop that reconnects and closes the tail on a
|
||||
// silent worker death, but its unreachability is never fatal here.
|
||||
maxConnectFailures: Number.POSITIVE_INFINITY,
|
||||
}).finally(() => process.exit(0));
|
||||
}
|
||||
|
||||
export function logs(workspaceId: string, options: LogsOptions = {}): void {
|
||||
const logFile = resolveLogFile(workspaceId);
|
||||
|
||||
if (options.listAgents) {
|
||||
const names = listAgentLogNames(logFile);
|
||||
if (names.length === 0) {
|
||||
console.error('No per-agent logs for this scan yet.');
|
||||
process.exit(0);
|
||||
}
|
||||
for (const name of names) console.log(name);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const workflowId = resolveWorkflowId(workspaceId);
|
||||
|
||||
if (options.agent !== undefined) {
|
||||
const agentFile = resolveAgentLogFile(logFile, options.agent);
|
||||
if (agentFile === undefined) {
|
||||
fail(`No agent log named: ${options.agent}`, '', 'Available agents:', ...withBullets(listAgentLogNames(logFile)));
|
||||
}
|
||||
const known = listAgentLogNames(logFile);
|
||||
// If the directory already lists agents, a name not among them is a typo, not a not-yet-created
|
||||
// file; fail loudly rather than tailing a path that will never appear.
|
||||
if (known.length > 0 && !known.includes(options.agent)) {
|
||||
fail(`No agent log named: ${options.agent}`, '', 'Available agents:', ...withBullets(known));
|
||||
}
|
||||
tailFileToExit(agentFile, workflowId, `Tailing ${options.agent} log`);
|
||||
return;
|
||||
}
|
||||
|
||||
tailFileToExit(logFile, workflowId, 'Tailing scan log');
|
||||
}
|
||||
|
||||
function withBullets(names: readonly string[]): string[] {
|
||||
return names.length === 0 ? [' (none yet)'] : names.map((name) => ` - ${name}`);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
/**
|
||||
* `shannon scans` command — list completed scans and where each report lives.
|
||||
* `shannon scans` command — list scans, running and completed, and where each report lives.
|
||||
*
|
||||
* A scan counts as completed when it produced a report. The report can live in any of a
|
||||
* few locations depending on the version that ran it, so `findReport` probes them in order
|
||||
* and the first hit is both the completion signal and the link target behind the workspace
|
||||
* name. The date and wall-clock duration come from the run's session.json
|
||||
* (createdAt/completedAt), with the report file's mtime as the date fallback for
|
||||
* runs that lack a recorded time.
|
||||
* Running scans come from Docker: every worker container is stamped with the shannon.workspace
|
||||
* label, so `runningScanWorkspaces()` is the authoritative live-scan list (shared with `stop`).
|
||||
* A scan counts as completed once it has produced a report; the report can live in any of a few
|
||||
* locations depending on the version that ran it, so `findReport` probes them in order and the
|
||||
* first hit is both the completion signal and the link target behind the workspace name. Dates and
|
||||
* durations come from each run's session.json (createdAt/completedAt), with the report file's mtime
|
||||
* as the date fallback for runs that lack a recorded time; a running scan's duration is elapsed time
|
||||
* so far (now − createdAt).
|
||||
*
|
||||
* Human-readable by default; `--json` emits the same rows as raw machine values on stdout.
|
||||
* Running scans are listed first, then completed newest-first. Human-readable by default; `--json`
|
||||
* emits the same rows as raw machine values on stdout.
|
||||
*
|
||||
* Filesystem-only (local ./workspaces/ or npx ~/.shannon/workspaces/ via getWorkspacesDir);
|
||||
* no Temporal dependency.
|
||||
* The completed list is filesystem-only (local ./workspaces/ or npx ~/.shannon/workspaces/ via
|
||||
* getWorkspacesDir); the running list needs Docker but degrades to empty when the daemon is down,
|
||||
* which is the correct answer (no scan can be running then).
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { BOLD, GOLD, paint } from '../colors.js';
|
||||
import { BOLD, CYAN, GOLD, paint } from '../colors.js';
|
||||
import { runningScanWorkspaces } from '../docker.js';
|
||||
import { getWorkspacesDir } from '../home.js';
|
||||
import { commandPrefix } from '../mode.js';
|
||||
import { FINAL_REPORT_PDF_FILENAME, INTERNAL_DIR, resolveRunFile } from '../paths.js';
|
||||
@@ -31,23 +36,25 @@ const FINAL_REPORT_MD_FILENAME = 'Security-Assessment-Report.md';
|
||||
|
||||
const DELIVERABLES_SUBDIR = 'deliverables';
|
||||
|
||||
/** One completed scan; raw values so the table and --json render from one source. */
|
||||
/** One scan, running or completed; raw values so the table and --json render from one source. */
|
||||
interface ScanRow {
|
||||
readonly workspace: string;
|
||||
/** Completion time in ms — sort key and date source. */
|
||||
readonly finishedMs: number;
|
||||
/** Wall-clock duration (completedAt − createdAt) in ms, or null when unknown. */
|
||||
readonly state: 'running' | 'completed';
|
||||
/** Completion time in ms — sort key and date source. Null while a scan is still running. */
|
||||
readonly finishedMs: number | null;
|
||||
/** Wall-clock duration in ms: elapsed-so-far for running, total for completed. Null when unknown. */
|
||||
readonly durationMs: number | null;
|
||||
/** Absolute path to the report file — the link target behind the workspace name. */
|
||||
readonly report: string;
|
||||
/** Absolute path to the report file — the link target behind the workspace name. Null while running. */
|
||||
readonly report: string | null;
|
||||
}
|
||||
|
||||
/** The --json row shape: raw machine values, one per completed scan. */
|
||||
/** The --json row shape: raw machine values, one per scan. */
|
||||
interface JsonRow {
|
||||
readonly workspace: string;
|
||||
readonly finishedAt: string;
|
||||
readonly state: 'running' | 'completed';
|
||||
readonly finishedAt: string | null;
|
||||
readonly durationMs: number | null;
|
||||
readonly reportPath: string;
|
||||
readonly reportPath: string | null;
|
||||
}
|
||||
|
||||
/** Compact wall-clock duration from milliseconds: "47s", "1m 32s", "1h 47m". */
|
||||
@@ -130,7 +137,19 @@ function collectCompletedScans(workspacesDir: string): ScanRow[] {
|
||||
const finishedMs = Number.isNaN(completedMs) ? fs.statSync(reportPath).mtimeMs : completedMs;
|
||||
const durationMs = Number.isNaN(completedMs) || Number.isNaN(createdMs) ? null : completedMs - createdMs;
|
||||
|
||||
rows.push({ workspace: entry.name, finishedMs, durationMs, report: reportPath });
|
||||
rows.push({ workspace: entry.name, state: 'completed', finishedMs, durationMs, report: reportPath });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Gather every currently-running scan, one row each. Elapsed time is now − createdAt. */
|
||||
function collectRunningScans(workspacesDir: string, nowMs: number): ScanRow[] {
|
||||
const rows: ScanRow[] = [];
|
||||
for (const workspace of runningScanWorkspaces()) {
|
||||
const { session } = readSession(path.join(workspacesDir, workspace));
|
||||
const createdMs = Date.parse(session.createdAt ?? '');
|
||||
const durationMs = Number.isNaN(createdMs) ? null : nowMs - createdMs;
|
||||
rows.push({ workspace, state: 'running', finishedMs: null, durationMs, report: null });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -138,55 +157,68 @@ function collectCompletedScans(workspacesDir: string): ScanRow[] {
|
||||
function toJsonRow(row: ScanRow): JsonRow {
|
||||
return {
|
||||
workspace: row.workspace,
|
||||
finishedAt: new Date(row.finishedMs).toISOString(),
|
||||
state: row.state,
|
||||
finishedAt: row.finishedMs === null ? null : new Date(row.finishedMs).toISOString(),
|
||||
durationMs: row.durationMs,
|
||||
reportPath: row.report,
|
||||
};
|
||||
}
|
||||
|
||||
/** Print the completed scans as an aligned table with the workspace name linked to its report. */
|
||||
/** Print the scans as an aligned table with each completed workspace name linked to its report. */
|
||||
function printTable(workspacesDir: string, rows: readonly ScanRow[]): void {
|
||||
if (rows.length === 0) {
|
||||
const prefix = commandPrefix();
|
||||
console.log(`No completed scans yet. Run '${prefix} start -u <url> -r <path>' to begin.`);
|
||||
console.log(`No scans yet. Run '${prefix} start -u <url> -r <path>' to begin.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const color = supportsColor();
|
||||
// On a terminal the workspace name is an OSC 8 hyperlink that opens its report; when
|
||||
// piped there is nothing to click, so it prints as plain text.
|
||||
// On a terminal a completed workspace name is an OSC 8 hyperlink that opens its report; when
|
||||
// piped, or for a running scan that has no report yet, it prints as plain text.
|
||||
const linkable = stdoutIsTerminal();
|
||||
|
||||
const table = rows.map((row) => ({
|
||||
finished: new Date(row.finishedMs).toISOString().slice(0, 10),
|
||||
state: row.state === 'running' ? 'RUNNING' : 'COMPLETED',
|
||||
finished: row.finishedMs === null ? '—' : new Date(row.finishedMs).toISOString().slice(0, 10),
|
||||
duration: row.durationMs === null ? '—' : formatDuration(row.durationMs),
|
||||
workspace: row.workspace,
|
||||
report: row.report,
|
||||
}));
|
||||
|
||||
const stateWidth = Math.max('STATE'.length, ...table.map((row) => row.state.length));
|
||||
const dateWidth = Math.max('FINISHED'.length, 'YYYY-MM-DD'.length);
|
||||
const durationWidth = Math.max('DURATION'.length, ...table.map((row) => row.duration.length));
|
||||
|
||||
console.log(`\nCompleted scans in ${workspacesDir}:\n`);
|
||||
const header = `${'FINISHED'.padEnd(dateWidth)} ${'DURATION'.padEnd(durationWidth)} WORKSPACE`;
|
||||
console.log(`\nScans in ${workspacesDir}:\n`);
|
||||
const header = `${'STATE'.padEnd(stateWidth)} ${'FINISHED'.padEnd(dateWidth)} ${'DURATION'.padEnd(durationWidth)} WORKSPACE`;
|
||||
console.log(paint(header, BOLD, color));
|
||||
|
||||
for (const row of table) {
|
||||
const stateText = row.state.padEnd(stateWidth);
|
||||
const state = row.state === 'RUNNING' ? paint(stateText, CYAN, color) : stateText;
|
||||
const finished = row.finished.padEnd(dateWidth);
|
||||
const duration = row.duration.padEnd(durationWidth);
|
||||
const name = paint(row.workspace, GOLD, color);
|
||||
const workspace = linkable ? hyperlink(name, pathToFileURL(row.report).href) : name;
|
||||
console.log(`${finished} ${duration} ${workspace}`);
|
||||
// A running scan has no report to open, so its name stays plain; completed names are linked.
|
||||
const name = row.report ? paint(row.workspace, GOLD, color) : row.workspace;
|
||||
const workspace = row.report && linkable ? hyperlink(name, pathToFileURL(row.report).href) : name;
|
||||
console.log(`${state} ${finished} ${duration} ${workspace}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
export function scans(opts: { readonly json: boolean }): void {
|
||||
const workspacesDir = getWorkspacesDir();
|
||||
const rows = collectCompletedScans(workspacesDir);
|
||||
const nowMs = Date.now();
|
||||
|
||||
// Latest on top.
|
||||
rows.sort((a, b) => b.finishedMs - a.finishedMs);
|
||||
const running = collectRunningScans(workspacesDir, nowMs);
|
||||
const runningNames = new Set(running.map((row) => row.workspace));
|
||||
// A running scan has no final report, so it can't also be completed; guard anyway.
|
||||
const completed = collectCompletedScans(workspacesDir).filter((row) => !runningNames.has(row.workspace));
|
||||
|
||||
// Running scans on top (most recently started first), then completed newest-first.
|
||||
running.sort((a, b) => (a.durationMs ?? 0) - (b.durationMs ?? 0));
|
||||
completed.sort((a, b) => (b.finishedMs ?? 0) - (a.finishedMs ?? 0));
|
||||
const rows = [...running, ...completed];
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(rows.map(toJsonRow), null, 2));
|
||||
|
||||
+277
-63
@@ -12,18 +12,20 @@ import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import * as p from '@clack/prompts';
|
||||
import { ensureDocker, ensureImage, ensureInfra, randomSuffix, spawnWorker } from '../docker.js';
|
||||
import { buildEnvFlags, loadEnv, resolveHostPiAuthPath, shouldUsePiAuth, validateCredentials } from '../env.js';
|
||||
import { fail } from '../errors.js';
|
||||
import { fail, warn } from '../errors.js';
|
||||
import { getWorkspacesDir, initHome } from '../home.js';
|
||||
import { commandPrefix, isLocal } from '../mode.js';
|
||||
import { resolveModelSpec } from '../model-spec.js';
|
||||
import {
|
||||
expandHome,
|
||||
FINAL_REPORT_MD_FILENAME,
|
||||
FINAL_REPORT_PDF_FILENAME,
|
||||
INTERNAL_DIR,
|
||||
resolveConfig,
|
||||
resolveRepo,
|
||||
resolveRunFile,
|
||||
} from '../paths.js';
|
||||
import { clearPendingWorkflowIdentity, writePendingWorkflowIdentity } from '../pending-workflow.js';
|
||||
import { indentFailureSegments } from '../scan/failure.js';
|
||||
import { resolveWorkflowId } from '../session.js';
|
||||
import { displayPlainBanner, displaySplash } from '../splash.js';
|
||||
@@ -43,83 +45,226 @@ export interface StartArgs {
|
||||
version: string;
|
||||
}
|
||||
|
||||
const LAUNCH_STATE_SCHEMA_VERSION = 1 as const;
|
||||
const LAUNCH_STATE_FILENAME = 'launch.json';
|
||||
const FIXED_CLASSES = ['injection', 'xss', 'auth', 'authz', 'ssrf'] as const;
|
||||
|
||||
/**
|
||||
* Upgrade a pre-restructure workspace (flat layout, no INTERNAL_DIR) before it is mounted,
|
||||
* so resume finds the old deliverables and their git checkpoints instead of re-running every
|
||||
* agent. For a legacy run every top-level entry is internal, so move them all into INTERNAL_DIR
|
||||
* (a same-filesystem rename carries the deliverables .git along).
|
||||
* CLI-owned launch record at INTERNAL_DIR/launch.json, written once when a workspace is
|
||||
* created and never rewritten. It pins the customer output destination so a resume with a
|
||||
* different -o cannot silently redirect the final report. The worker does not read it.
|
||||
*/
|
||||
function migrateLegacyWorkspaceLayout(workspacePath: string): void {
|
||||
const legacySessionJson = path.join(workspacePath, 'session.json');
|
||||
const internalPath = path.join(workspacePath, INTERNAL_DIR);
|
||||
if (!fs.existsSync(legacySessionJson) || fs.existsSync(internalPath)) {
|
||||
return;
|
||||
interface LaunchState {
|
||||
readonly schema_version: typeof LAUNCH_STATE_SCHEMA_VERSION;
|
||||
readonly customer_output_path?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceLaunchDecision {
|
||||
readonly isResume: boolean;
|
||||
readonly outputDir?: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function arraysEqual(left: readonly unknown[], right: readonly unknown[]): boolean {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand-rolled twin of the worker's durable-state validator in
|
||||
* apps/worker/src/types/run-state.ts, which owns the session.json.durableScanState shape.
|
||||
* Each array check accepts two variants because the worker appends 'miscellaneous' and
|
||||
* 'miscellaneous-exploit' only after the miscellaneous pipeline admits findings. If the worker's shape
|
||||
* changes and this twin lags, resume fails fast as incompatible instead of launching a
|
||||
* worker against state it would misread.
|
||||
*/
|
||||
function isCurrentDurableState(value: unknown): boolean {
|
||||
if (!isRecord(value) || value.schema_version !== 1 || typeof value.exploit !== 'boolean') return false;
|
||||
if (!Array.isArray(value.participating_classes) || !Array.isArray(value.expected_agents)) return false;
|
||||
|
||||
const participating = value.participating_classes;
|
||||
const validParticipation =
|
||||
arraysEqual(participating, FIXED_CLASSES) || arraysEqual(participating, [...FIXED_CLASSES, 'miscellaneous']);
|
||||
if (!validParticipation) return false;
|
||||
|
||||
const baselineAgents = ['pre-recon', 'recon', ...FIXED_CLASSES.map((name) => `${name}-vuln`)];
|
||||
if (value.exploit) baselineAgents.push(...FIXED_CLASSES.map((name) => `${name}-exploit`));
|
||||
baselineAgents.push('report');
|
||||
const expected = value.expected_agents;
|
||||
return arraysEqual(expected, baselineAgents) || arraysEqual(expected, [...baselineAgents, 'miscellaneous-exploit']);
|
||||
}
|
||||
|
||||
/** One refusal for damaged CLI-owned or worker-owned workspace records, whichever reads first. */
|
||||
const DAMAGED_RECORDS_MESSAGE =
|
||||
"This workspace's internal records are damaged and it cannot be resumed. Its report files are untouched. Start a new scan with a different -w name.";
|
||||
|
||||
const NEWER_RELEASE_MESSAGE =
|
||||
'This workspace was created by a newer version of Shannon. Upgrade Shannon, or start a new scan with a different -w name.';
|
||||
|
||||
function readJsonFile(filePath: string): unknown {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch {
|
||||
fail(DAMAGED_RECORDS_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
function readLaunchState(filePath: string): LaunchState {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
fail(
|
||||
'This workspace was created by an earlier version of Shannon and cannot be resumed. Its files and report are untouched. Start a new scan with a different -w name.',
|
||||
);
|
||||
}
|
||||
const value = readJsonFile(filePath);
|
||||
if (!isRecord(value)) fail(NEWER_RELEASE_MESSAGE);
|
||||
// Unknown keys mean a newer release wrote this workspace; refuse rather than half-read it.
|
||||
const keys = Object.keys(value).sort();
|
||||
const keysAreValid = keys.every((key) => key === 'customer_output_path' || key === 'schema_version');
|
||||
const customerPath = value.customer_output_path;
|
||||
const pathIsValid =
|
||||
customerPath === undefined ||
|
||||
(typeof customerPath === 'string' && path.isAbsolute(customerPath) && path.resolve(customerPath) === customerPath);
|
||||
if (value.schema_version !== LAUNCH_STATE_SCHEMA_VERSION || !keysAreValid || !pathIsValid) {
|
||||
fail(NEWER_RELEASE_MESSAGE);
|
||||
}
|
||||
return {
|
||||
schema_version: LAUNCH_STATE_SCHEMA_VERSION,
|
||||
...(typeof customerPath === 'string' && { customer_output_path: customerPath }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide fresh-versus-resume from on-disk state alone, before start() mutates anything.
|
||||
* A fresh launch requires the workspace directory to be absent or empty; a resume requires
|
||||
* current-release session state, a matching target URL, and a customer output path that
|
||||
* agrees with the recorded one. Every other combination fails the launch, so a typo in
|
||||
* -w or -o stops here instead of spawning a worker into the wrong workspace.
|
||||
*/
|
||||
export function classifyWorkspaceLaunch(
|
||||
workspacePath: string,
|
||||
expectedUrl: string,
|
||||
requestedOutputDir: string | undefined,
|
||||
): WorkspaceLaunchDecision {
|
||||
const sessionPath = resolveRunFile(workspacePath, 'session.json');
|
||||
const sessionExists = fs.existsSync(sessionPath);
|
||||
if (!sessionExists) {
|
||||
if (fs.existsSync(workspacePath) && fs.readdirSync(workspacePath).length > 0) {
|
||||
fail(
|
||||
'This directory is not a Shannon workspace, or its scan state is missing. Start a new scan with a different -w name.',
|
||||
);
|
||||
}
|
||||
return { isResume: false, ...(requestedOutputDir !== undefined && { outputDir: requestedOutputDir }) };
|
||||
}
|
||||
|
||||
fs.mkdirSync(internalPath, { recursive: true });
|
||||
for (const entry of fs.readdirSync(workspacePath)) {
|
||||
if (entry === INTERNAL_DIR) {
|
||||
continue;
|
||||
}
|
||||
fs.renameSync(path.join(workspacePath, entry), path.join(internalPath, entry));
|
||||
const launchPath = path.join(workspacePath, INTERNAL_DIR, LAUNCH_STATE_FILENAME);
|
||||
const launch = readLaunchState(launchPath);
|
||||
const session = readJsonFile(sessionPath);
|
||||
if (!isRecord(session) || !isRecord(session.session) || session.session.webUrl !== expectedUrl) {
|
||||
fail(
|
||||
'This workspace was created for a different target URL, so it cannot be resumed against this one. Check -u, or start a new scan with a different -w name.',
|
||||
);
|
||||
}
|
||||
console.log(`Migrated workspace to ${INTERNAL_DIR}/ layout: ${workspacePath}`);
|
||||
if (!isCurrentDurableState(session.durableScanState)) {
|
||||
fail(
|
||||
"This workspace's scan state cannot be read by this version. Its files are untouched. Start a new scan with a different -w name.",
|
||||
);
|
||||
}
|
||||
|
||||
const storedOutputDir = launch.customer_output_path;
|
||||
if (requestedOutputDir !== undefined && requestedOutputDir !== storedOutputDir) {
|
||||
fail(
|
||||
'This workspace already copies its report to a different location than the -o path you passed. Re-run without -o to keep the original location, or start a new scan with a different -w name.',
|
||||
);
|
||||
}
|
||||
return { isResume: true, ...(storedOutputDir !== undefined && { outputDir: storedOutputDir }) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Crash-safe single write: exclusive temp file (pid plus random suffix keeps concurrent
|
||||
* starts apart), fsync, rename into place, then directory fsync so the entry survives a
|
||||
* host crash. Callers invoke this only for a fresh workspace; an existing launch.json is
|
||||
* the resume contract and must never be replaced.
|
||||
*/
|
||||
export function writeLaunchStateAtomically(internalPath: string, outputDir: string | undefined): void {
|
||||
const finalPath = path.join(internalPath, LAUNCH_STATE_FILENAME);
|
||||
const temporaryPath = path.join(internalPath, `${LAUNCH_STATE_FILENAME}.tmp-${process.pid}-${randomSuffix()}`);
|
||||
const launchState: LaunchState = {
|
||||
schema_version: LAUNCH_STATE_SCHEMA_VERSION,
|
||||
...(outputDir !== undefined && { customer_output_path: outputDir }),
|
||||
};
|
||||
const descriptor = fs.openSync(temporaryPath, 'wx', 0o600);
|
||||
try {
|
||||
fs.writeFileSync(descriptor, `${JSON.stringify(launchState, null, 2)}\n`, 'utf8');
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
try {
|
||||
fs.renameSync(temporaryPath, finalPath);
|
||||
const directory = fs.openSync(internalPath, 'r');
|
||||
try {
|
||||
fs.fsyncSync(directory);
|
||||
} finally {
|
||||
fs.closeSync(directory);
|
||||
}
|
||||
} catch (error) {
|
||||
fs.rmSync(temporaryPath, { force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Select the workflow ID before Docker starts so the container can carry it as immutable identity. */
|
||||
export function createWorkflowId(workspace: string, isResume: boolean, timestamp: number = Date.now()): string {
|
||||
if (isResume) return `${workspace}_resume_${timestamp}`;
|
||||
return /_shannon-\d+$/.test(workspace) ? workspace : `${workspace}_shannon-${timestamp}`;
|
||||
}
|
||||
|
||||
export async function start(args: StartArgs): Promise<void> {
|
||||
// 1. Initialize state directories and load env
|
||||
// 1. Resolve non-mutating inputs and classify the workspace before changing it.
|
||||
initHome();
|
||||
loadEnv();
|
||||
|
||||
// 2. Validate credentials
|
||||
const creds = validateCredentials();
|
||||
if (!creds.valid) {
|
||||
fail(creds.error ?? 'Invalid credentials');
|
||||
}
|
||||
|
||||
// 3. Resolve paths
|
||||
const repo = resolveRepo(args.repo);
|
||||
const config = args.config ? resolveConfig(args.config) : undefined;
|
||||
const workspacesDir = getWorkspacesDir();
|
||||
const workspace =
|
||||
args.workspace ?? `${new URL(args.url).hostname.replace(/[^a-zA-Z0-9-]/g, '-')}_shannon-${Date.now()}`;
|
||||
const workspacePath = path.join(workspacesDir, workspace);
|
||||
const requestedOutputDir = args.output ? path.resolve(expandHome(args.output)) : undefined;
|
||||
const launchDecision = classifyWorkspaceLaunch(workspacePath, args.url, requestedOutputDir);
|
||||
|
||||
// Inputs are valid — identify the run before the Docker/Temporal setup work.
|
||||
// 2. Inputs are valid; identify the run before initializing shared infrastructure.
|
||||
const bannerVersion = isLocal() ? undefined : args.version;
|
||||
if (stdoutIsTerminal()) {
|
||||
displaySplash(bannerVersion);
|
||||
} else {
|
||||
displayPlainBanner(bannerVersion);
|
||||
}
|
||||
|
||||
// 4. Ensure workspaces dir is writable by container user (UID 1001)
|
||||
const workspacesDir = getWorkspacesDir();
|
||||
fs.mkdirSync(workspacesDir, { recursive: true });
|
||||
fs.chmodSync(workspacesDir, 0o777);
|
||||
|
||||
// 5. Ensure Docker and the worker image are available (pull/build prints its own progress).
|
||||
ensureDocker();
|
||||
ensureImage(args.version);
|
||||
|
||||
// One spinner spans the whole launch: bringing up Temporal and registering the worker.
|
||||
const spinner = p.spinner();
|
||||
spinner.start('Starting scan');
|
||||
await ensureInfra(spinner);
|
||||
|
||||
// 6. Generate unique task queue and container name
|
||||
// 3. Generate the invocation identity.
|
||||
const suffix = randomSuffix();
|
||||
const taskQueue = `shannon-${suffix}`;
|
||||
const containerName = `shannon-worker-${suffix}`;
|
||||
const workflowId = createWorkflowId(workspace, launchDecision.isResume);
|
||||
|
||||
// 7. Generate workspace name if not provided
|
||||
const workspace =
|
||||
args.workspace ?? `${new URL(args.url).hostname.replace(/[^a-zA-Z0-9-]/g, '-')}_shannon-${Date.now()}`;
|
||||
|
||||
// 8. Create writable overlay directories (mounted over :ro repo paths inside container)
|
||||
// 4. Create writable overlay directories after resume validation has succeeded.
|
||||
// The run dir and its INTERNAL_DIR must be 0o777 so the container user can create audit
|
||||
// subdirs and the overlay backing dirs.
|
||||
const workspacePath = path.join(workspacesDir, workspace);
|
||||
const internalPath = path.join(workspacePath, INTERNAL_DIR);
|
||||
fs.mkdirSync(workspacePath, { recursive: true });
|
||||
fs.chmodSync(workspacePath, 0o777);
|
||||
migrateLegacyWorkspaceLayout(workspacePath);
|
||||
fs.mkdirSync(internalPath, { recursive: true });
|
||||
fs.chmodSync(internalPath, 0o777);
|
||||
for (const dir of ['deliverables', 'scratchpad', '.playwright-cli', '.playwright']) {
|
||||
@@ -127,30 +272,53 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
fs.chmodSync(dirPath, 0o777);
|
||||
}
|
||||
if (!launchDecision.isResume) {
|
||||
writeLaunchStateAtomically(internalPath, launchDecision.outputDir);
|
||||
}
|
||||
|
||||
// 9. Pre-create overlay mount points (:ro mounts can't auto-create them)
|
||||
// 5. Pre-create overlay mount points (:ro mounts cannot create them).
|
||||
const shannonDir = path.join(repo.hostPath, '.shannon');
|
||||
for (const dir of ['deliverables', 'scratchpad', '.playwright-cli']) {
|
||||
fs.mkdirSync(path.join(shannonDir, dir), { recursive: true });
|
||||
}
|
||||
fs.mkdirSync(path.join(repo.hostPath, '.playwright'), { recursive: true });
|
||||
|
||||
// 10. Resolve output directory
|
||||
const outputDir = args.output ? path.resolve(expandHome(args.output)) : undefined;
|
||||
// 6. Create the validated customer-copy destination, if configured.
|
||||
const outputDir = launchDecision.outputDir;
|
||||
if (outputDir) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 11. Resolve prompts directory (local mode only)
|
||||
// 7. Resolve prompts and capture the pre-launch resume counter.
|
||||
const promptsDir = isLocal() ? path.resolve('apps/worker/prompts') : undefined;
|
||||
const sessionJson = resolveRunFile(workspacePath, 'session.json');
|
||||
const isResume = launchDecision.isResume;
|
||||
let initialResumeCount = 0;
|
||||
if (isResume) {
|
||||
// Docker and Temporal startup sit between this read and the classification that validated the
|
||||
// same file, so a file that changed in between is a workspace-state failure, not a CLI bug.
|
||||
const session = readJsonFile(sessionJson);
|
||||
const attempts = isRecord(session) && isRecord(session.session) ? session.session.resumeAttempts : undefined;
|
||||
initialResumeCount = Array.isArray(attempts) ? attempts.length : 0;
|
||||
}
|
||||
|
||||
// 12. Spawn worker container
|
||||
// 8. Persist the exact launch candidate before Docker can start the worker. Session
|
||||
// registration later replaces this bridge as the durable workflow identity.
|
||||
try {
|
||||
writePendingWorkflowIdentity(workspacePath, workflowId, taskQueue);
|
||||
} catch {
|
||||
spinner.error('Could not record the scan workflow identity');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 9. Spawn the worker container.
|
||||
const proc = spawnWorker({
|
||||
version: args.version,
|
||||
url: args.url,
|
||||
repo,
|
||||
workspacesDir,
|
||||
taskQueue,
|
||||
workflowId,
|
||||
containerName,
|
||||
envFlags: buildEnvFlags(),
|
||||
...(config && { config }),
|
||||
@@ -173,24 +341,16 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Detect whether this is a fresh workspace or a resume by checking session.json existence
|
||||
const sessionJson = resolveRunFile(path.join(workspacesDir, workspace), 'session.json');
|
||||
const isResume = fs.existsSync(sessionJson);
|
||||
let initialResumeCount = 0;
|
||||
if (isResume) {
|
||||
try {
|
||||
const session = JSON.parse(fs.readFileSync(sessionJson, 'utf-8'));
|
||||
initialResumeCount = session.session?.resumeAttempts?.length ?? 0;
|
||||
} catch {
|
||||
// Corrupted file — worker will handle validation
|
||||
}
|
||||
}
|
||||
|
||||
let started = false;
|
||||
|
||||
// Set when the startup poll times out but session.json already holds durable state this
|
||||
// release understands: the workflow is executing, so the exit handler must not stop its
|
||||
// worker. An operator abort is a different intent and still stops it.
|
||||
let scanRunningUnconfirmed = false;
|
||||
|
||||
// Stop the worker only if the scan hasn't registered yet (e.g. Ctrl-C mid-startup).
|
||||
let cleaned = false;
|
||||
const cleanup = (): void => {
|
||||
const stopWorker = (): void => {
|
||||
if (cleaned || started) return;
|
||||
cleaned = true;
|
||||
spinner.stop('Stopping scan');
|
||||
@@ -204,14 +364,17 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
}
|
||||
};
|
||||
process.on('SIGINT', () => {
|
||||
cleanup();
|
||||
stopWorker();
|
||||
process.exit(0);
|
||||
});
|
||||
process.on('SIGTERM', () => {
|
||||
cleanup();
|
||||
stopWorker();
|
||||
process.exit(0);
|
||||
});
|
||||
process.on('exit', cleanup);
|
||||
process.on('exit', () => {
|
||||
if (scanRunningUnconfirmed) return;
|
||||
stopWorker();
|
||||
});
|
||||
|
||||
// Poll for the workflow to register in session.json; the spinner resolves once it does.
|
||||
spinner.message('Waiting for the scan to start');
|
||||
@@ -221,10 +384,17 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
const resumeAttempts: { workflowId: string }[] = session.session?.resumeAttempts ?? [];
|
||||
|
||||
// Fresh: session.json appears with originalWorkflowId. Resume: new resumeAttempts entry.
|
||||
const ready = isResume ? resumeAttempts.length > initialResumeCount : !!session.session?.originalWorkflowId;
|
||||
const ready = isResume
|
||||
? resumeAttempts.slice(initialResumeCount).some((attempt) => attempt.workflowId === workflowId)
|
||||
: session.session?.originalWorkflowId === workflowId;
|
||||
|
||||
if (ready) {
|
||||
started = true;
|
||||
try {
|
||||
clearPendingWorkflowIdentity(workspacePath, taskQueue);
|
||||
} catch {
|
||||
warn(`Scan ${workspace} started, but its launch record could not be removed.`);
|
||||
}
|
||||
spinner.stop(`Scan started — ${workspace}`);
|
||||
printInfo(args, workspace, repo.hostPath, workspacesDir);
|
||||
if (args.follow) {
|
||||
@@ -238,10 +408,52 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
await sleep(2000);
|
||||
}
|
||||
|
||||
if (classifyStartupTimeout(sessionJson) === 'scan-running') {
|
||||
scanRunningUnconfirmed = true;
|
||||
spinner.error('The scan started, but this CLI could not confirm it');
|
||||
printUnconfirmedScanHint(workspace, taskQueue, containerName);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
spinner.error('Timed out waiting for the scan to start');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the startup timeout: 'scan-running' when session.json already holds durable state this
|
||||
* release understands, which only the worker writes and only after Temporal began executing the
|
||||
* workflow; 'unregistered' when nothing proves the scan started. The distinction decides whether
|
||||
* timing out may stop the worker container.
|
||||
*/
|
||||
export function classifyStartupTimeout(sessionJsonPath: string): 'unregistered' | 'scan-running' {
|
||||
let session: unknown;
|
||||
try {
|
||||
session = JSON.parse(fs.readFileSync(sessionJsonPath, 'utf-8'));
|
||||
} catch {
|
||||
return 'unregistered';
|
||||
}
|
||||
if (!isRecord(session) || !isCurrentDurableState(session.durableScanState)) {
|
||||
return 'unregistered';
|
||||
}
|
||||
return 'scan-running';
|
||||
}
|
||||
|
||||
/** Point the operator at a scan that is running but whose startup this CLI could not confirm. */
|
||||
function printUnconfirmedScanHint(workspace: string, taskQueue: string, containerName: string): void {
|
||||
console.log('');
|
||||
console.log(' The scan is running and was left alone; only its startup confirmation is missing.');
|
||||
console.log('');
|
||||
console.log(` Workspace: ${workspace}`);
|
||||
console.log(` Task queue: ${taskQueue}`);
|
||||
console.log(` Container: ${containerName}`);
|
||||
console.log('');
|
||||
console.log(' Inspect it:');
|
||||
console.log(` Live logs: ${commandPrefix()} logs ${workspace}`);
|
||||
console.log(` Worker logs: docker logs ${containerName}`);
|
||||
console.log(' Dashboard: http://localhost:8233');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow a just-started scan (for `--follow`, aimed at CI): stream its log while Temporal drives
|
||||
* completion, then exit on the workflow outcome — 0 if the assessment ran, 1 if the scan failed.
|
||||
@@ -331,7 +543,7 @@ function printInfo(args: StartArgs, workspace: string, repoPath: string, workspa
|
||||
return;
|
||||
}
|
||||
|
||||
const reportPath = path.join(workspacesDir, workspace, FINAL_REPORT_PDF_FILENAME);
|
||||
const reportDir = path.join(workspacesDir, workspace);
|
||||
|
||||
// When following, the scan log streams inline next, so the "run these to watch it" hints
|
||||
// would only contradict that.
|
||||
@@ -345,6 +557,8 @@ function printInfo(args: StartArgs, workspace: string, repoPath: string, workspa
|
||||
|
||||
console.log('');
|
||||
console.log(' Report (when the scan finishes):');
|
||||
console.log(` ${reportPath}`);
|
||||
console.log(` ${reportDir}${path.sep}`);
|
||||
console.log(` ${FINAL_REPORT_PDF_FILENAME}`);
|
||||
console.log(` ${FINAL_REPORT_MD_FILENAME}`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
@@ -3,21 +3,28 @@
|
||||
*
|
||||
* While the scan runs, polls Temporal and redraws the phase/agent tree on a
|
||||
* terminal (a pipe or a finished scan gets a single frame). When the scan reaches
|
||||
* a terminal state, prints the overall result and exits. Reads Temporal directly —
|
||||
* no worker, no session files — so it needs Temporal up and shows scans within its
|
||||
* ~24h retention window.
|
||||
* a terminal state, prints the overall result and exits. Local session records prove
|
||||
* the target's canonical workspace/workflow identity; the progress itself is read from
|
||||
* Temporal directly — no worker — so it needs Temporal up and shows scans within its
|
||||
* retention window (Shannon configures seven days by default; see SHANNON_TEMPORAL_RETENTION).
|
||||
*/
|
||||
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { fail } from '../errors.js';
|
||||
import { isLocal } from '../mode.js';
|
||||
import { failWith } from '../errors.js';
|
||||
import { commandPrefix, isLocal } from '../mode.js';
|
||||
import { type RenderInput, renderScan } from '../scan/render.js';
|
||||
import { toStatusJson } from '../scan/status-json.js';
|
||||
import { resolveWorkflowId } from '../session.js';
|
||||
import { displaySplash } from '../splash.js';
|
||||
import { describeScan, getTerminalOutcome, queryProgress, type ScanDescription } from '../temporal-client.js';
|
||||
import {
|
||||
ActivityMirrorError,
|
||||
describeScan,
|
||||
getTerminalOutcome,
|
||||
queryProgress,
|
||||
type ScanDescription,
|
||||
} from '../temporal-client.js';
|
||||
import { stdoutIsTerminal, supportsColor } from '../tty.js';
|
||||
import { getVersion } from '../version.js';
|
||||
import { resolveScanIdentity } from '../workspaces.js';
|
||||
|
||||
const HIDE_CURSOR = '\x1b[?25l';
|
||||
const SHOW_CURSOR = '\x1b[?25h';
|
||||
@@ -30,6 +37,25 @@ function isTerminalStatus(status: string): boolean {
|
||||
return status !== 'RUNNING' && status !== 'UNSPECIFIED';
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one scan description, telling the two failure modes apart. A stale activity mirror
|
||||
* carries its own message and needs a CLI update; anything else is a read that did not reach
|
||||
* a usable answer, which is most often Temporal being down.
|
||||
*/
|
||||
async function readScanDescription(workflowId: string): Promise<ScanDescription | null> {
|
||||
try {
|
||||
return await describeScan(workflowId);
|
||||
} catch (error) {
|
||||
if (error instanceof ActivityMirrorError) failWith('CLI_SCAN_SCHEMA_UNSUPPORTED', error.message);
|
||||
failWith(
|
||||
'CLI_SCAN_STATUS_UNAVAILABLE',
|
||||
"Could not read this scan's progress.",
|
||||
'If Temporal is not running, start a scan to bring it up. If it is running, this build of the CLI',
|
||||
'does not recognise part of the scan and needs updating.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Match SGR color escapes (ESC[…m) so a line's on-screen width excludes them. Built from the ESC
|
||||
// char code so the source carries no literal control character.
|
||||
const ANSI_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g');
|
||||
@@ -128,10 +154,10 @@ async function watch(workspace: string, workflowId: string): Promise<never> {
|
||||
}, RENDER_MS);
|
||||
|
||||
for (;;) {
|
||||
const desc = await describeScan(workflowId);
|
||||
const desc = await readScanDescription(workflowId);
|
||||
if (!desc) {
|
||||
clearInterval(ticker);
|
||||
fail(`Scan "${workspace}" is no longer in Temporal.`);
|
||||
failWith('CLI_SCAN_NOT_FOUND', `Scan "${workspace}" is no longer in Temporal.`);
|
||||
}
|
||||
|
||||
if (isTerminalStatus(desc.status)) {
|
||||
@@ -153,23 +179,40 @@ async function snapshot(workspace: string, workflowId: string, desc: ScanDescrip
|
||||
: buildRunningInput(workspace, workflowId, desc);
|
||||
}
|
||||
|
||||
export async function status(workspace: string, opts: { readonly json: boolean }): Promise<void> {
|
||||
// A resume spawns a new workflow id (recorded in session.json); resolve through there so status
|
||||
// follows the current resume, not the superseded original. Fresh scans: the name is the id.
|
||||
const workflowId = resolveWorkflowId(workspace) ?? workspace;
|
||||
|
||||
let desc: ScanDescription | null;
|
||||
try {
|
||||
desc = await describeScan(workflowId);
|
||||
} catch {
|
||||
fail('Could not reach Temporal at 127.0.0.1:7233.', 'Start Temporal (it comes up with a scan) and try again.');
|
||||
export async function status(target: string, opts: { readonly json: boolean }): Promise<void> {
|
||||
// Target selection picked a string; identity resolution proves the canonical workspace and
|
||||
// workflow pair from session records before Temporal is queried. A workspace name follows its
|
||||
// latest resume; an exact recorded workflow id keeps addressing that execution. A raw id with
|
||||
// no local record is refused rather than echoed into the required workspace field.
|
||||
const identity = resolveScanIdentity(target);
|
||||
if (identity.kind === 'ambiguous') {
|
||||
failWith(
|
||||
'CLI_SCAN_IDENTITY_AMBIGUOUS',
|
||||
`Multiple workspaces claim workflow ID "${target}": ${identity.claims.join(', ')}.`,
|
||||
`Run '${commandPrefix()} scans' and pass the workspace directory name instead.`,
|
||||
);
|
||||
}
|
||||
if (identity.kind === 'not-found') {
|
||||
failWith(
|
||||
'CLI_SCAN_IDENTITY_NOT_FOUND',
|
||||
identity.reason === 'unreadable-record'
|
||||
? `Workspace "${target}" has no readable session record (${identity.sessionPath}).`
|
||||
: `No scan matches "${target}" in the local workspace records.`,
|
||||
`Run '${commandPrefix()} scans' to list scans.`,
|
||||
'Temporal dashboard: http://localhost:8233',
|
||||
);
|
||||
}
|
||||
const { workspace, workflowId } = identity;
|
||||
|
||||
const desc = await readScanDescription(workflowId);
|
||||
|
||||
if (!desc) {
|
||||
fail(
|
||||
failWith(
|
||||
'CLI_SCAN_NOT_FOUND',
|
||||
`No scan found for "${workspace}".`,
|
||||
'',
|
||||
'Scans are visible while running and for ~24h after they finish (Temporal retention).',
|
||||
"Scan histories are available while a scan runs and within Temporal's retention window after it finishes.",
|
||||
"Shannon configures 7 days of retention by default (override: SHANNON_TEMPORAL_RETENTION). Expired histories can't be restored.",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+820
-64
@@ -1,25 +1,43 @@
|
||||
/**
|
||||
* `shannon stop` command — stop one scan by workspace, or every scan with --all.
|
||||
* `shannon stop` command: stop one scan by workspace, or every scan with --all.
|
||||
* Never touches infra or data; to wipe Temporal state entirely, use `shannon reset`.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import * as p from '@clack/prompts';
|
||||
import { confirmOrExit } from '../confirm.js';
|
||||
import {
|
||||
anyRunningScanWorkflow,
|
||||
type CommandQueryResult,
|
||||
ensureDocker,
|
||||
isTemporalReady,
|
||||
isWorkflowRunning,
|
||||
runningContainers,
|
||||
type RunningScanContainer,
|
||||
runningContainersChecked,
|
||||
runningScanContainersChecked,
|
||||
scanFilter,
|
||||
stopContainers,
|
||||
terminateAllWorkflows,
|
||||
terminateWorkflow,
|
||||
WORKER_FILTER,
|
||||
WORKFLOW_ID_PROTOCOL,
|
||||
} from '../docker.js';
|
||||
import { fail, failUsage, warn } from '../errors.js';
|
||||
import { getWorkspacesDir } from '../home.js';
|
||||
import { commandPrefix } from '../mode.js';
|
||||
import { resolveRunFile } from '../paths.js';
|
||||
import {
|
||||
clearPendingWorkflowIdentity,
|
||||
type PendingWorkflowIdentity,
|
||||
readPendingWorkflowIdentities,
|
||||
} from '../pending-workflow.js';
|
||||
import { resolveWorkflowId } from '../session.js';
|
||||
import {
|
||||
describeWorkflowLifecycle,
|
||||
listRunningScanWorkflows,
|
||||
type RunningScanWorkflow,
|
||||
refreshWorkflowLifecycleConnection,
|
||||
requestWorkflowCancellation,
|
||||
requestWorkflowTermination,
|
||||
type WorkflowLifecycleState,
|
||||
} from '../temporal-client.js';
|
||||
import { listWorkspaces, resolveScanIdentity } from '../workspaces.js';
|
||||
import { appendCancellationFallback } from './logs.js';
|
||||
|
||||
export interface StopOptions {
|
||||
all: boolean;
|
||||
@@ -27,101 +45,839 @@ export interface StopOptions {
|
||||
workspace?: string;
|
||||
}
|
||||
|
||||
const CANCELLATION_GRACE_MS = 10_000;
|
||||
const CANCELLATION_POLL_MS = 250;
|
||||
const TERMINATION_VERIFY_MS = 5_000;
|
||||
const TERMINATION_ATTEMPTS = 2;
|
||||
const TERMINATION_REASON = 'Stopped after cancellation grace period';
|
||||
const CANDIDATE_REGISTRATION_SETTLE_MS = 3_000;
|
||||
const VISIBILITY_SETTLE_MS = 1_000;
|
||||
const VISIBILITY_MAX_SETTLE_MS = 5_000;
|
||||
|
||||
export type WorkflowStopOutcome =
|
||||
| { readonly kind: 'graceful' }
|
||||
| { readonly kind: 'forced' }
|
||||
| { readonly kind: 'already-closed' }
|
||||
| { readonly kind: 'unverified' };
|
||||
|
||||
export type ContainerStopOutcome =
|
||||
| { readonly kind: 'stopped'; readonly hadContainers: boolean }
|
||||
| { readonly kind: 'still-running'; readonly remaining: number }
|
||||
| { readonly kind: 'unverified' };
|
||||
|
||||
export interface StopLifecycle {
|
||||
readonly cancel: (workflowId: string) => Promise<'requested' | 'not-found'>;
|
||||
readonly describe: (workflowId: string) => Promise<WorkflowLifecycleState>;
|
||||
readonly refresh: () => Promise<void>;
|
||||
readonly terminate: (workflowId: string) => Promise<'requested' | 'not-found'>;
|
||||
readonly containers: (filter: readonly string[]) => CommandQueryResult<string[]>;
|
||||
readonly stopContainers: (ids: readonly string[]) => Promise<void>;
|
||||
readonly appendFallback: (workspace: string) => void;
|
||||
readonly wait: (milliseconds: number) => Promise<void>;
|
||||
readonly now: () => number;
|
||||
}
|
||||
|
||||
export interface WorkflowStopTarget {
|
||||
readonly workflowId: string;
|
||||
readonly workspace?: string;
|
||||
readonly containerCandidate: boolean;
|
||||
/** Safe to synthesize a log marker when this CLI-owned launch never reached session registration. */
|
||||
readonly preRegistrationFallback?: boolean;
|
||||
}
|
||||
|
||||
export interface WorkflowStopResult {
|
||||
readonly target: WorkflowStopTarget;
|
||||
readonly outcome: WorkflowStopOutcome;
|
||||
}
|
||||
|
||||
export interface StopExecutionResult {
|
||||
readonly workflows: readonly WorkflowStopResult[];
|
||||
readonly containers: ContainerStopOutcome;
|
||||
readonly preRegistrationWorkspaces: readonly string[];
|
||||
}
|
||||
|
||||
export interface WorkflowTargetPlan {
|
||||
readonly targets: readonly WorkflowStopTarget[];
|
||||
readonly containersWithoutVerifiedWorkflowId: readonly string[];
|
||||
}
|
||||
|
||||
interface PendingWorkflowReference {
|
||||
readonly workspace: string;
|
||||
readonly identity: PendingWorkflowIdentity;
|
||||
}
|
||||
|
||||
interface PendingWorkflowTargets {
|
||||
readonly byWorkspace: ReadonlyMap<string, readonly PendingWorkflowIdentity[]>;
|
||||
readonly references: readonly PendingWorkflowReference[];
|
||||
readonly unreadableCount: number;
|
||||
}
|
||||
|
||||
const stopLifecycle: StopLifecycle = {
|
||||
cancel: requestWorkflowCancellation,
|
||||
describe: describeWorkflowLifecycle,
|
||||
refresh: refreshWorkflowLifecycleConnection,
|
||||
terminate: (workflowId) => requestWorkflowTermination(workflowId, TERMINATION_REASON),
|
||||
containers: runningContainersChecked,
|
||||
stopContainers: (ids) => stopContainers([...ids]),
|
||||
appendFallback: (workspace) => {
|
||||
const logFile = resolveRunFile(path.join(getWorkspacesDir(), workspace), 'workflow.log');
|
||||
appendCancellationFallback(logFile);
|
||||
},
|
||||
wait: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
||||
now: Date.now,
|
||||
};
|
||||
|
||||
function readPendingTargets(workspaces: readonly string[]): PendingWorkflowTargets {
|
||||
const byWorkspace = new Map<string, readonly PendingWorkflowIdentity[]>();
|
||||
const references: PendingWorkflowReference[] = [];
|
||||
let unreadableCount = 0;
|
||||
|
||||
for (const workspace of new Set(workspaces)) {
|
||||
const workspacePath = path.join(getWorkspacesDir(), workspace);
|
||||
const result = readPendingWorkflowIdentities(workspacePath);
|
||||
unreadableCount += result.unreadableCount;
|
||||
if (result.identities.length === 0) continue;
|
||||
byWorkspace.set(workspace, result.identities);
|
||||
for (const identity of result.identities) references.push({ workspace, identity });
|
||||
}
|
||||
|
||||
return { byWorkspace, references, unreadableCount };
|
||||
}
|
||||
|
||||
function clearPendingTargets(references: readonly PendingWorkflowReference[]): number {
|
||||
let failures = 0;
|
||||
for (const reference of references) {
|
||||
try {
|
||||
clearPendingWorkflowIdentity(path.join(getWorkspacesDir(), reference.workspace), reference.identity.task_queue);
|
||||
} catch {
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
function workflowClosed(state: WorkflowLifecycleState): boolean {
|
||||
return state.kind === 'terminal' || state.kind === 'not-found';
|
||||
}
|
||||
|
||||
/** Poll direct workflow state until Temporal positively confirms closure or the deadline expires. */
|
||||
async function waitForWorkflowClosure(
|
||||
workflowId: string,
|
||||
lifecycle: StopLifecycle,
|
||||
deadline: number,
|
||||
pollMs: number,
|
||||
): Promise<boolean> {
|
||||
while (true) {
|
||||
if (deadline - lifecycle.now() <= 0) return false;
|
||||
try {
|
||||
if (workflowClosed(await lifecycle.describe(workflowId))) return true;
|
||||
} catch {
|
||||
// An unavailable status is unknown, never evidence that the workflow closed.
|
||||
}
|
||||
|
||||
const remaining = deadline - lifecycle.now();
|
||||
if (remaining <= 0) return false;
|
||||
await lifecycle.wait(Math.min(pollMs, remaining));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a single scan. Terminating the workflow both clears Temporal's record and
|
||||
* brings the container down (the worker waits on the workflow result), so that runs
|
||||
* first; `docker stop` is the fallback for the pre-registration window and an
|
||||
* unreachable Temporal. The stop is then verified rather than assumed.
|
||||
* Request cooperative cancellation, then make at most two termination attempts when the
|
||||
* workflow does not close during its grace period. Every success is backed by direct state.
|
||||
*/
|
||||
export async function stopWorkflowCancelFirst(
|
||||
workflowId: string,
|
||||
lifecycle: StopLifecycle = stopLifecycle,
|
||||
graceMs: number = CANCELLATION_GRACE_MS,
|
||||
pollMs: number = CANCELLATION_POLL_MS,
|
||||
verifyMs: number = TERMINATION_VERIFY_MS,
|
||||
): Promise<WorkflowStopOutcome> {
|
||||
try {
|
||||
if ((await lifecycle.cancel(workflowId)) === 'not-found') return { kind: 'already-closed' };
|
||||
} catch {
|
||||
// The request may have reached Temporal even when its acknowledgement was lost.
|
||||
}
|
||||
|
||||
if (await waitForWorkflowClosure(workflowId, lifecycle, lifecycle.now() + graceMs, pollMs)) {
|
||||
return { kind: 'graceful' };
|
||||
}
|
||||
|
||||
const verifyPerAttemptMs = Math.max(pollMs, Math.ceil(verifyMs / TERMINATION_ATTEMPTS));
|
||||
for (let attempt = 0; attempt < TERMINATION_ATTEMPTS; attempt++) {
|
||||
if (attempt > 0) {
|
||||
try {
|
||||
await lifecycle.refresh();
|
||||
} catch {
|
||||
// The termination call below makes one final bounded connection attempt.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if ((await lifecycle.terminate(workflowId)) === 'not-found') return { kind: 'already-closed' };
|
||||
} catch {
|
||||
// A lost acknowledgement is resolved by the direct verification below.
|
||||
}
|
||||
if (await waitForWorkflowClosure(workflowId, lifecycle, lifecycle.now() + verifyPerAttemptMs, pollMs)) {
|
||||
return { kind: 'forced' };
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: 'unverified' };
|
||||
}
|
||||
|
||||
/** Apply the same bounded lifecycle concurrently to a captured set of workflow IDs. */
|
||||
export async function stopWorkflowsCancelFirst(
|
||||
workflowIds: readonly string[],
|
||||
lifecycle: StopLifecycle = stopLifecycle,
|
||||
graceMs: number = CANCELLATION_GRACE_MS,
|
||||
pollMs: number = CANCELLATION_POLL_MS,
|
||||
verifyMs: number = TERMINATION_VERIFY_MS,
|
||||
): Promise<readonly WorkflowStopOutcome[]> {
|
||||
const settlements = await Promise.allSettled(
|
||||
workflowIds.map((workflowId) => stopWorkflowCancelFirst(workflowId, lifecycle, graceMs, pollMs, verifyMs)),
|
||||
);
|
||||
return settlements.map((settlement) =>
|
||||
settlement.status === 'fulfilled' ? settlement.value : { kind: 'unverified' },
|
||||
);
|
||||
}
|
||||
|
||||
/** Stop exactly the captured workers, then fail closed if any matching worker remains or appears. */
|
||||
export async function stopContainersAndVerify(
|
||||
initialIds: readonly string[],
|
||||
filter: readonly string[],
|
||||
lifecycle: StopLifecycle = stopLifecycle,
|
||||
): Promise<ContainerStopOutcome> {
|
||||
try {
|
||||
await lifecycle.stopContainers(initialIds);
|
||||
} catch {
|
||||
// The post-stop query below decides whether the operation actually succeeded.
|
||||
}
|
||||
|
||||
let after: CommandQueryResult<string[]>;
|
||||
try {
|
||||
after = lifecycle.containers(filter);
|
||||
} catch {
|
||||
return { kind: 'unverified' };
|
||||
}
|
||||
if (after.kind === 'unavailable') return { kind: 'unverified' };
|
||||
if (after.value.length > 0) return { kind: 'still-running', remaining: after.value.length };
|
||||
return { kind: 'stopped', hadContainers: initialIds.length > 0 };
|
||||
}
|
||||
|
||||
function addWorkflowTarget(targets: Map<string, WorkflowStopTarget>, candidate: WorkflowStopTarget): void {
|
||||
const current = targets.get(candidate.workflowId);
|
||||
if (current === undefined) {
|
||||
targets.set(candidate.workflowId, candidate);
|
||||
return;
|
||||
}
|
||||
const workspace = current.workspace ?? candidate.workspace;
|
||||
targets.set(candidate.workflowId, {
|
||||
workflowId: candidate.workflowId,
|
||||
...(workspace !== undefined && { workspace }),
|
||||
containerCandidate: current.containerCandidate || candidate.containerCandidate,
|
||||
...((current.preRegistrationFallback === true || candidate.preRegistrationFallback === true) && {
|
||||
preRegistrationFallback: true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the stop union from immutable container candidates, recorded session IDs,
|
||||
* pre-registration launch records, and Temporal visibility. Visibility supplies positive
|
||||
* targets but never proves absence.
|
||||
*/
|
||||
function verifiedContainerWorkflowId(
|
||||
container: RunningScanContainer,
|
||||
visibleWorkflows: readonly RunningScanWorkflow[],
|
||||
): string | undefined {
|
||||
if (container.workerProtocol === WORKFLOW_ID_PROTOCOL && container.workflowId !== undefined) {
|
||||
return container.workflowId;
|
||||
}
|
||||
if (container.taskQueue === undefined) return undefined;
|
||||
const matches = visibleWorkflows.filter((workflow) => workflow.taskQueue === container.taskQueue);
|
||||
return matches.length === 1 ? matches[0]?.workflowId : undefined;
|
||||
}
|
||||
|
||||
export function buildWorkflowTargetPlan(
|
||||
containers: readonly RunningScanContainer[],
|
||||
recordedByWorkspace: ReadonlyMap<string, string>,
|
||||
visibleWorkflows: readonly RunningScanWorkflow[],
|
||||
pendingByWorkspace: ReadonlyMap<string, readonly PendingWorkflowIdentity[]> = new Map(),
|
||||
): WorkflowTargetPlan {
|
||||
const targets = new Map<string, WorkflowStopTarget>();
|
||||
const containersWithoutVerifiedWorkflowId: string[] = [];
|
||||
|
||||
for (const container of containers) {
|
||||
const verifiedWorkflowId = verifiedContainerWorkflowId(container, visibleWorkflows);
|
||||
if (verifiedWorkflowId === undefined) containersWithoutVerifiedWorkflowId.push(container.id);
|
||||
else {
|
||||
addWorkflowTarget(targets, {
|
||||
workflowId: verifiedWorkflowId,
|
||||
...(container.workspace !== undefined && { workspace: container.workspace }),
|
||||
containerCandidate: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [workspace, workflowId] of recordedByWorkspace) {
|
||||
addWorkflowTarget(targets, {
|
||||
workflowId,
|
||||
workspace,
|
||||
containerCandidate:
|
||||
containers.some((container) => verifiedContainerWorkflowId(container, visibleWorkflows) === workflowId) ||
|
||||
pendingByWorkspace.get(workspace)?.some((identity) => identity.workflow_id === workflowId) === true,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [workspace, identities] of pendingByWorkspace) {
|
||||
for (const identity of identities) {
|
||||
addWorkflowTarget(targets, {
|
||||
workflowId: identity.workflow_id,
|
||||
workspace,
|
||||
containerCandidate: true,
|
||||
...(!recordedByWorkspace.has(workspace) && { preRegistrationFallback: true }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const workflow of visibleWorkflows) {
|
||||
const matchingWorkspaces = new Set(
|
||||
containers
|
||||
.filter((container) => container.taskQueue === workflow.taskQueue && container.workspace !== undefined)
|
||||
.flatMap((container) => container.workspace ?? []),
|
||||
);
|
||||
for (const [workspace, identities] of pendingByWorkspace) {
|
||||
if (identities.some((identity) => identity.task_queue === workflow.taskQueue)) matchingWorkspaces.add(workspace);
|
||||
}
|
||||
const workspace = matchingWorkspaces.size === 1 ? [...matchingWorkspaces][0] : undefined;
|
||||
addWorkflowTarget(targets, {
|
||||
workflowId: workflow.workflowId,
|
||||
...(workspace !== undefined && { workspace }),
|
||||
containerCandidate:
|
||||
containers.some((container) => container.taskQueue === workflow.taskQueue) ||
|
||||
[...pendingByWorkspace.values()].some((identities) =>
|
||||
identities.some((identity) => identity.task_queue === workflow.taskQueue),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return { targets: [...targets.values()], containersWithoutVerifiedWorkflowId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop known workflows while their workers can finalize, stop the captured workers, then
|
||||
* re-describe every container candidate. The last pass closes a NotFound-to-started race.
|
||||
*/
|
||||
export async function executeStopPlan(
|
||||
targets: readonly WorkflowStopTarget[],
|
||||
containers: readonly RunningScanContainer[],
|
||||
filter: readonly string[],
|
||||
lifecycle: StopLifecycle = stopLifecycle,
|
||||
graceMs: number = CANCELLATION_GRACE_MS,
|
||||
pollMs: number = CANCELLATION_POLL_MS,
|
||||
verifyMs: number = TERMINATION_VERIFY_MS,
|
||||
candidateSettleMs: number = CANDIDATE_REGISTRATION_SETTLE_MS,
|
||||
): Promise<StopExecutionResult> {
|
||||
const initialOutcomes = await stopWorkflowsCancelFirst(
|
||||
targets.map((target) => target.workflowId),
|
||||
lifecycle,
|
||||
graceMs,
|
||||
pollMs,
|
||||
verifyMs,
|
||||
);
|
||||
const outcomes = new Map<string, WorkflowStopOutcome>();
|
||||
for (let index = 0; index < targets.length; index++) {
|
||||
const target = targets[index];
|
||||
const outcome = initialOutcomes[index];
|
||||
if (target !== undefined && outcome !== undefined) outcomes.set(target.workflowId, outcome);
|
||||
}
|
||||
|
||||
const containerOutcome = await stopContainersAndVerify(
|
||||
containers.map((container) => container.id),
|
||||
filter,
|
||||
lifecycle,
|
||||
);
|
||||
const preRegistrationWorkspaces = new Set<string>();
|
||||
|
||||
if (containerOutcome.kind === 'stopped') {
|
||||
const candidates = targets.filter((target) => target.containerCandidate);
|
||||
|
||||
for (const target of candidates) {
|
||||
const initialOutcome = outcomes.get(target.workflowId) ?? { kind: 'unverified' };
|
||||
const settleDeadline = lifecycle.now() + candidateSettleMs;
|
||||
let onlyObservedNotFound = initialOutcome.kind === 'already-closed' || initialOutcome.kind === 'unverified';
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const state = await lifecycle.describe(target.workflowId);
|
||||
if (state.kind === 'open') {
|
||||
onlyObservedNotFound = false;
|
||||
const outcome = await stopWorkflowCancelFirst(target.workflowId, lifecycle, graceMs, pollMs, verifyMs);
|
||||
outcomes.set(target.workflowId, outcome);
|
||||
if (outcome.kind !== 'already-closed') break;
|
||||
}
|
||||
if (state.kind === 'terminal') {
|
||||
onlyObservedNotFound = false;
|
||||
if (initialOutcome.kind === 'unverified') outcomes.set(target.workflowId, { kind: 'already-closed' });
|
||||
}
|
||||
if (state.kind === 'unknown') {
|
||||
onlyObservedNotFound = false;
|
||||
outcomes.set(target.workflowId, { kind: 'unverified' });
|
||||
break;
|
||||
}
|
||||
if (initialOutcome.kind === 'unverified') outcomes.set(target.workflowId, { kind: 'already-closed' });
|
||||
} catch {
|
||||
onlyObservedNotFound = false;
|
||||
outcomes.set(target.workflowId, { kind: 'unverified' });
|
||||
break;
|
||||
}
|
||||
|
||||
const remaining = settleDeadline - lifecycle.now();
|
||||
if (remaining <= 0) {
|
||||
if (onlyObservedNotFound && target.workspace !== undefined && target.preRegistrationFallback === true) {
|
||||
preRegistrationWorkspaces.add(target.workspace);
|
||||
}
|
||||
break;
|
||||
}
|
||||
try {
|
||||
await lifecycle.wait(Math.min(pollMs, remaining));
|
||||
} catch {
|
||||
outcomes.set(target.workflowId, { kind: 'unverified' });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
workflows: targets.map((target) => ({
|
||||
target,
|
||||
outcome: outcomes.get(target.workflowId) ?? { kind: 'unverified' },
|
||||
})),
|
||||
containers: containerOutcome,
|
||||
preRegistrationWorkspaces: [...preRegistrationWorkspaces],
|
||||
};
|
||||
}
|
||||
|
||||
function appendFallback(workspace: string, lifecycle: StopLifecycle = stopLifecycle): void {
|
||||
try {
|
||||
lifecycle.appendFallback(workspace);
|
||||
} catch {
|
||||
warn(`scan ${workspace} stopped, but workflow.log could not be marked cancelled.`);
|
||||
}
|
||||
}
|
||||
|
||||
function reportContainerFailure(workspace: string | undefined, outcome: ContainerStopOutcome): void {
|
||||
const target = workspace === undefined ? '--all' : workspace;
|
||||
if (outcome.kind === 'still-running') console.error(`${outcome.remaining} scan worker(s) did not stop.`);
|
||||
else console.error('Docker could not verify that every targeted scan worker stopped.');
|
||||
console.error(`Retry: ${commandPrefix()} stop ${target}`);
|
||||
}
|
||||
|
||||
function withRecordedWorkflows(containers: readonly RunningScanContainer[]): Map<string, string> {
|
||||
const recorded = new Map<string, string>();
|
||||
for (const container of containers) {
|
||||
if (container.workspace === undefined || recorded.has(container.workspace)) continue;
|
||||
const workflowId = resolveWorkflowId(container.workspace);
|
||||
if (workflowId !== undefined) recorded.set(container.workspace, workflowId);
|
||||
}
|
||||
return recorded;
|
||||
}
|
||||
|
||||
function resolveTargetWorkspaces(targets: readonly WorkflowStopTarget[]): readonly WorkflowStopTarget[] {
|
||||
return targets.map((target) => {
|
||||
if (target.workspace !== undefined) return target;
|
||||
const identity = resolveScanIdentity(target.workflowId);
|
||||
return identity.kind === 'ok' ? { ...target, workspace: identity.workspace } : target;
|
||||
});
|
||||
}
|
||||
|
||||
function unverifiedWorkflowCount(results: readonly WorkflowStopResult[]): number {
|
||||
return results.filter((result) => result.outcome.kind === 'unverified').length;
|
||||
}
|
||||
|
||||
function appendVerifiedFallbacks(result: StopExecutionResult): void {
|
||||
const workspaces = new Set(result.preRegistrationWorkspaces);
|
||||
for (const workflow of result.workflows) {
|
||||
if (workflow.outcome.kind === 'forced' && workflow.target.workspace !== undefined) {
|
||||
workspaces.add(workflow.target.workspace);
|
||||
}
|
||||
}
|
||||
for (const workspace of workspaces) appendFallback(workspace);
|
||||
}
|
||||
|
||||
function visibleWorkflowsForWorkspace(
|
||||
workspace: string,
|
||||
containers: readonly RunningScanContainer[],
|
||||
pending: PendingWorkflowTargets,
|
||||
visible: readonly RunningScanWorkflow[],
|
||||
): readonly RunningScanWorkflow[] {
|
||||
const taskQueues = new Set(containers.flatMap((container) => container.taskQueue ?? []));
|
||||
for (const identity of pending.byWorkspace.get(workspace) ?? []) taskQueues.add(identity.task_queue);
|
||||
|
||||
return visible.filter((workflow) => {
|
||||
if (taskQueues.has(workflow.taskQueue)) return true;
|
||||
const identity = resolveScanIdentity(workflow.workflowId);
|
||||
return identity.kind === 'ok' && identity.workspace === workspace;
|
||||
});
|
||||
}
|
||||
|
||||
/** Stop one scan while keeping its worker alive long enough to flush a graceful cancellation. */
|
||||
async function stopSingleScan(workspace: string, yes: boolean): Promise<void> {
|
||||
const workflowId = resolveWorkflowId(workspace);
|
||||
const filter = scanFilter(workspace);
|
||||
const temporalUp = isTemporalReady();
|
||||
const containerQuery = runningScanContainersChecked(filter);
|
||||
if (containerQuery.kind === 'unavailable') {
|
||||
fail(`Could not inspect the scan worker for ${workspace}.`, `Retry: ${commandPrefix()} stop ${workspace}`);
|
||||
}
|
||||
const containers = containerQuery.value.map((container) => ({ ...container, workspace }));
|
||||
const recordedWorkflowId = resolveWorkflowId(workspace);
|
||||
const recorded = new Map<string, string>();
|
||||
if (recordedWorkflowId !== undefined) recorded.set(workspace, recordedWorkflowId);
|
||||
const pending = readPendingTargets([workspace]);
|
||||
const discovery = await discoverRunningWorkflows();
|
||||
const visible =
|
||||
discovery.kind === 'ok' ? visibleWorkflowsForWorkspace(workspace, containers, pending, discovery.workflows) : [];
|
||||
const plan = buildWorkflowTargetPlan(containers, recorded, visible, pending.byWorkspace);
|
||||
|
||||
const initialContainers = runningContainers(filter);
|
||||
const workflowRunning = Boolean(workflowId && temporalUp && isWorkflowRunning(workflowId));
|
||||
|
||||
// Resolve what is running before prompting, so we never confirm a no-op.
|
||||
if (initialContainers.length === 0 && !workflowRunning) {
|
||||
if (!workflowId) {
|
||||
if (containers.length === 0) {
|
||||
if (plan.targets.length === 0) {
|
||||
if (pending.unreadableCount > 0) {
|
||||
fail(
|
||||
`The launch records for ${workspace} could not be read safely.`,
|
||||
`Retry: ${commandPrefix()} stop ${workspace}`,
|
||||
);
|
||||
}
|
||||
if (discovery.kind === 'unavailable') {
|
||||
fail(
|
||||
`Could not verify whether scan ${workspace} is still running in Temporal.`,
|
||||
`Retry: ${commandPrefix()} stop ${workspace}`,
|
||||
);
|
||||
}
|
||||
fail(`No scan found for workspace: ${workspace}`);
|
||||
}
|
||||
console.log(`Nothing was running for ${workspace}.`);
|
||||
return;
|
||||
|
||||
const onlyRecordedTarget =
|
||||
recordedWorkflowId !== undefined &&
|
||||
pending.references.length === 0 &&
|
||||
pending.unreadableCount === 0 &&
|
||||
discovery.kind === 'ok' &&
|
||||
plan.targets.every((target) => target.workflowId === recordedWorkflowId);
|
||||
if (onlyRecordedTarget) {
|
||||
try {
|
||||
const state = await describeWorkflowLifecycle(recordedWorkflowId);
|
||||
if (state.kind === 'terminal' || state.kind === 'not-found') {
|
||||
console.log(`Nothing was running for ${workspace}.`);
|
||||
return;
|
||||
}
|
||||
if (state.kind === 'unknown') {
|
||||
fail(
|
||||
`Temporal returned an unknown lifecycle state for ${workspace}.`,
|
||||
`Retry: ${commandPrefix()} stop ${workspace}`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
fail(
|
||||
`Could not verify whether scan ${workspace} is still running in Temporal.`,
|
||||
`Retry: ${commandPrefix()} stop ${workspace}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await confirmOrExit('stop', `Stop the scan "${workspace}"?`, yes);
|
||||
|
||||
const spinner = p.spinner();
|
||||
spinner.start(`Stopping scan ${workspace}`);
|
||||
|
||||
if (workflowId && workflowRunning) {
|
||||
terminateWorkflow(workflowId, `Stopped via shannon stop ${workspace}`);
|
||||
}
|
||||
await stopContainers(runningContainers(filter));
|
||||
const initialResult = await executeStopPlan(plan.targets, containers, filter);
|
||||
const visibilitySettle = await stopVisibleWorkflowsUntilSettled(
|
||||
initialResult.workflows,
|
||||
stopLifecycle,
|
||||
VISIBILITY_SETTLE_MS,
|
||||
VISIBILITY_MAX_SETTLE_MS,
|
||||
async () => {
|
||||
const current = await discoverRunningWorkflows();
|
||||
return current.kind === 'ok'
|
||||
? {
|
||||
kind: 'ok',
|
||||
workflows: visibleWorkflowsForWorkspace(workspace, containers, pending, current.workflows),
|
||||
}
|
||||
: current;
|
||||
},
|
||||
);
|
||||
const result: StopExecutionResult = {
|
||||
workflows: visibilitySettle.results,
|
||||
containers: initialResult.containers,
|
||||
preRegistrationWorkspaces: initialResult.preRegistrationWorkspaces,
|
||||
};
|
||||
const unverified = unverifiedWorkflowCount(result.workflows);
|
||||
const finalPending = readPendingTargets([workspace]);
|
||||
const initialPendingKeys = new Set(
|
||||
pending.references.map((reference) => `${reference.identity.task_queue}\0${reference.identity.workflow_id}`),
|
||||
);
|
||||
const newPendingCount = finalPending.references.filter(
|
||||
(reference) => !initialPendingKeys.has(`${reference.identity.task_queue}\0${reference.identity.workflow_id}`),
|
||||
).length;
|
||||
const unreadablePendingCount = Math.max(pending.unreadableCount, finalPending.unreadableCount);
|
||||
const incomplete =
|
||||
result.containers.kind !== 'stopped' ||
|
||||
plan.containersWithoutVerifiedWorkflowId.length > 0 ||
|
||||
discovery.kind === 'unavailable' ||
|
||||
visibilitySettle.kind !== 'settled' ||
|
||||
unreadablePendingCount > 0 ||
|
||||
newPendingCount > 0 ||
|
||||
unverified > 0;
|
||||
|
||||
const stillRunning = runningContainers(filter);
|
||||
if (stillRunning.length > 0) {
|
||||
spinner.error(`Scan ${workspace} may still be running`);
|
||||
console.error(`${stillRunning.length} container(s) did not stop. Retry: ${commandPrefix()} stop ${workspace}`);
|
||||
if (incomplete) {
|
||||
spinner.error(`Scan ${workspace} shutdown could not be fully verified`);
|
||||
if (result.containers.kind !== 'stopped') reportContainerFailure(workspace, result.containers);
|
||||
if (unverified > 0) console.error(`Temporal could not confirm closure for ${unverified} workflow(s).`);
|
||||
if (discovery.kind === 'unavailable') console.error('Temporal could not enumerate every running scan workflow.');
|
||||
if (visibilitySettle.kind === 'unavailable') {
|
||||
console.error('Temporal could not complete the final scan workflow check.');
|
||||
}
|
||||
if (visibilitySettle.kind === 'timed-out') {
|
||||
console.error('Temporal workflow discovery did not settle before its deadline.');
|
||||
}
|
||||
if (unreadablePendingCount > 0) {
|
||||
console.error(`${unreadablePendingCount} launch record(s) could not be read safely.`);
|
||||
}
|
||||
if (newPendingCount > 0) console.error('A new scan launch began while shutdown was running.');
|
||||
if (plan.containersWithoutVerifiedWorkflowId.length > 0) {
|
||||
console.error('A legacy scan worker could not prove its candidate workflow ID.');
|
||||
}
|
||||
console.error(`Retry: ${commandPrefix()} stop ${workspace}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
appendVerifiedFallbacks(result);
|
||||
const clearFailures = clearPendingTargets(pending.references);
|
||||
if (clearFailures > 0) {
|
||||
spinner.error(`Scan ${workspace} stopped, but its launch record could not be cleared`);
|
||||
console.error(`Retry: ${commandPrefix()} stop ${workspace}`);
|
||||
process.exit(1);
|
||||
}
|
||||
spinner.stop(`Stopped scan ${workspace}`);
|
||||
}
|
||||
|
||||
if (workflowId && temporalUp && isWorkflowRunning(workflowId)) {
|
||||
warn(`scan ${workspace} stopped, but its workflow is still Running in Temporal.`);
|
||||
export type WorkflowDiscoveryResult =
|
||||
| { readonly kind: 'ok'; readonly workflows: readonly RunningScanWorkflow[] }
|
||||
| { readonly kind: 'unavailable' };
|
||||
|
||||
async function discoverRunningWorkflows(): Promise<WorkflowDiscoveryResult> {
|
||||
try {
|
||||
return { kind: 'ok', workflows: await listRunningScanWorkflows() };
|
||||
} catch {
|
||||
return { kind: 'unavailable' };
|
||||
}
|
||||
}
|
||||
|
||||
interface VisibilitySettleResult {
|
||||
readonly results: readonly WorkflowStopResult[];
|
||||
readonly kind: 'settled' | 'unavailable' | 'timed-out';
|
||||
}
|
||||
|
||||
/** Re-enumerate visibility until no new open workflow appears during a bounded quiet horizon. */
|
||||
export async function stopVisibleWorkflowsUntilSettled(
|
||||
seed: readonly WorkflowStopResult[],
|
||||
lifecycle: StopLifecycle = stopLifecycle,
|
||||
settleMs: number = VISIBILITY_SETTLE_MS,
|
||||
maxSettleMs: number = VISIBILITY_MAX_SETTLE_MS,
|
||||
discover: () => Promise<WorkflowDiscoveryResult> = discoverRunningWorkflows,
|
||||
): Promise<VisibilitySettleResult> {
|
||||
const results = new Map(seed.map((result) => [result.target.workflowId, result]));
|
||||
const retriedUnverified = new Set<string>();
|
||||
const recheckedAlreadyClosed = new Set<string>();
|
||||
let quietSince = lifecycle.now();
|
||||
const maxDeadline = quietSince + maxSettleMs;
|
||||
|
||||
while (true) {
|
||||
const discovery = await discover();
|
||||
if (discovery.kind === 'unavailable') return { kind: 'unavailable', results: [...results.values()] };
|
||||
|
||||
const visibleTargets = resolveTargetWorkspaces(
|
||||
buildWorkflowTargetPlan([], new Map(), discovery.workflows).targets,
|
||||
).map((target) => {
|
||||
const existingWorkspace = results.get(target.workflowId)?.target.workspace;
|
||||
return target.workspace === undefined && existingWorkspace !== undefined
|
||||
? { ...target, workspace: existingWorkspace }
|
||||
: target;
|
||||
});
|
||||
const residualTargets = visibleTargets.filter((target) => {
|
||||
const current = results.get(target.workflowId);
|
||||
if (current === undefined) return true;
|
||||
if (current.outcome.kind === 'unverified') return !retriedUnverified.has(target.workflowId);
|
||||
return current.outcome.kind === 'already-closed' && !recheckedAlreadyClosed.has(target.workflowId);
|
||||
});
|
||||
if (residualTargets.length > 0) {
|
||||
for (const target of residualTargets) {
|
||||
if (results.get(target.workflowId)?.outcome.kind === 'unverified') {
|
||||
retriedUnverified.add(target.workflowId);
|
||||
}
|
||||
if (results.get(target.workflowId)?.outcome.kind === 'already-closed') {
|
||||
recheckedAlreadyClosed.add(target.workflowId);
|
||||
}
|
||||
}
|
||||
const outcomes = await stopWorkflowsCancelFirst(
|
||||
residualTargets.map((target) => target.workflowId),
|
||||
lifecycle,
|
||||
);
|
||||
for (let index = 0; index < residualTargets.length; index++) {
|
||||
const target = residualTargets[index];
|
||||
const outcome = outcomes[index];
|
||||
if (target !== undefined && outcome !== undefined) results.set(target.workflowId, { target, outcome });
|
||||
}
|
||||
quietSince = lifecycle.now();
|
||||
}
|
||||
|
||||
const now = lifecycle.now();
|
||||
if (now - quietSince >= settleMs) return { kind: 'settled', results: [...results.values()] };
|
||||
if (now >= maxDeadline) return { kind: 'timed-out', results: [...results.values()] };
|
||||
try {
|
||||
await lifecycle.wait(Math.min(CANCELLATION_POLL_MS, settleMs - (now - quietSince)));
|
||||
} catch {
|
||||
return { kind: 'timed-out', results: [...results.values()] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function stopAllScans(yes: boolean): Promise<void> {
|
||||
const temporalUp = isTemporalReady();
|
||||
const initial = runningContainers(WORKER_FILTER);
|
||||
const containerQuery = runningScanContainersChecked();
|
||||
if (containerQuery.kind === 'unavailable') {
|
||||
fail('Could not inspect running scan workers.', `Retry: ${commandPrefix()} stop --all`);
|
||||
}
|
||||
const containers = containerQuery.value;
|
||||
let pending = readPendingTargets(listWorkspaces().map((workspace) => workspace.name));
|
||||
const initialDiscovery = await discoverRunningWorkflows();
|
||||
let visible = initialDiscovery.kind === 'ok' ? initialDiscovery.workflows : [];
|
||||
let plan = buildWorkflowTargetPlan(containers, withRecordedWorkflows(containers), visible, pending.byWorkspace);
|
||||
let targets = resolveTargetWorkspaces(plan.targets);
|
||||
|
||||
// Resolve what is running before prompting, so we never confirm a no-op.
|
||||
if (initial.length === 0) {
|
||||
console.log('No running scans to stop.');
|
||||
return;
|
||||
if (containers.length === 0 && targets.length === 0) {
|
||||
if (pending.unreadableCount > 0) {
|
||||
fail('One or more scan launch records could not be read safely.', `Retry: ${commandPrefix()} stop --all`);
|
||||
}
|
||||
if (initialDiscovery.kind === 'unavailable') {
|
||||
fail('Could not verify whether scan workflows are running in Temporal.', `Retry: ${commandPrefix()} stop --all`);
|
||||
}
|
||||
const emptySettleDeadline = stopLifecycle.now() + VISIBILITY_MAX_SETTLE_MS;
|
||||
while (targets.length === 0) {
|
||||
const remaining = emptySettleDeadline - stopLifecycle.now();
|
||||
if (remaining <= 0) {
|
||||
console.log('No running scans to stop.');
|
||||
return;
|
||||
}
|
||||
await stopLifecycle.wait(Math.min(CANCELLATION_POLL_MS, remaining));
|
||||
const confirmation = await discoverRunningWorkflows();
|
||||
if (confirmation.kind === 'unavailable') {
|
||||
fail(
|
||||
'Could not verify whether scan workflows are running in Temporal.',
|
||||
`Retry: ${commandPrefix()} stop --all`,
|
||||
);
|
||||
}
|
||||
visible = confirmation.workflows;
|
||||
pending = readPendingTargets(listWorkspaces().map((workspace) => workspace.name));
|
||||
if (pending.unreadableCount > 0) {
|
||||
fail('One or more scan launch records could not be read safely.', `Retry: ${commandPrefix()} stop --all`);
|
||||
}
|
||||
plan = buildWorkflowTargetPlan(containers, withRecordedWorkflows(containers), visible, pending.byWorkspace);
|
||||
targets = resolveTargetWorkspaces(plan.targets);
|
||||
}
|
||||
}
|
||||
|
||||
await confirmOrExit('stop', 'This will stop all running scans. Continue?', yes);
|
||||
|
||||
const spinner = p.spinner();
|
||||
spinner.start('Stopping all scans');
|
||||
|
||||
if (temporalUp) {
|
||||
terminateAllWorkflows('Stopped via shannon stop --all');
|
||||
}
|
||||
await stopContainers(runningContainers(WORKER_FILTER));
|
||||
const initialResult = await executeStopPlan(targets, containers, WORKER_FILTER);
|
||||
const visibilitySettle = await stopVisibleWorkflowsUntilSettled(initialResult.workflows);
|
||||
const results = visibilitySettle.results;
|
||||
|
||||
const stillRunning = runningContainers(WORKER_FILTER);
|
||||
if (stillRunning.length > 0) {
|
||||
spinner.error(`Stopped ${initial.length - stillRunning.length} of ${initial.length} scans`);
|
||||
console.error(`${stillRunning.length} container(s) did not stop. Retry: ${commandPrefix()} stop --all`);
|
||||
const combinedResult: StopExecutionResult = {
|
||||
workflows: results,
|
||||
containers: initialResult.containers,
|
||||
preRegistrationWorkspaces: initialResult.preRegistrationWorkspaces,
|
||||
};
|
||||
const unverified = unverifiedWorkflowCount(results);
|
||||
const finalPending = readPendingTargets(listWorkspaces().map((workspace) => workspace.name));
|
||||
const initialPendingKeys = new Set(
|
||||
pending.references.map(
|
||||
(reference) => `${reference.workspace}\0${reference.identity.task_queue}\0${reference.identity.workflow_id}`,
|
||||
),
|
||||
);
|
||||
const newPendingCount = finalPending.references.filter(
|
||||
(reference) =>
|
||||
!initialPendingKeys.has(
|
||||
`${reference.workspace}\0${reference.identity.task_queue}\0${reference.identity.workflow_id}`,
|
||||
),
|
||||
).length;
|
||||
const unreadablePendingCount = Math.max(pending.unreadableCount, finalPending.unreadableCount);
|
||||
const temporalDiscoveryFailed = initialDiscovery.kind === 'unavailable' || visibilitySettle.kind === 'unavailable';
|
||||
const temporalDiscoveryTimedOut = visibilitySettle.kind === 'timed-out';
|
||||
const incomplete =
|
||||
combinedResult.containers.kind !== 'stopped' ||
|
||||
plan.containersWithoutVerifiedWorkflowId.length > 0 ||
|
||||
temporalDiscoveryFailed ||
|
||||
temporalDiscoveryTimedOut ||
|
||||
unreadablePendingCount > 0 ||
|
||||
newPendingCount > 0 ||
|
||||
unverified > 0;
|
||||
|
||||
if (incomplete) {
|
||||
spinner.error('Scan shutdown incomplete');
|
||||
if (combinedResult.containers.kind !== 'stopped') reportContainerFailure(undefined, combinedResult.containers);
|
||||
if (unverified > 0) console.error(`Temporal could not confirm closure for ${unverified} workflow(s).`);
|
||||
if (temporalDiscoveryFailed) console.error('Temporal could not enumerate every running scan workflow.');
|
||||
if (temporalDiscoveryTimedOut) console.error('Temporal workflow discovery did not settle before its deadline.');
|
||||
if (unreadablePendingCount > 0) {
|
||||
console.error(`${unreadablePendingCount} launch record(s) could not be read safely.`);
|
||||
}
|
||||
if (newPendingCount > 0) console.error(`${newPendingCount} scan launch(es) began while shutdown was running.`);
|
||||
if (plan.containersWithoutVerifiedWorkflowId.length > 0) {
|
||||
console.error(
|
||||
`${plan.containersWithoutVerifiedWorkflowId.length} legacy worker(s) could not prove a candidate workflow ID.`,
|
||||
);
|
||||
}
|
||||
console.error(`Retry: ${commandPrefix()} stop --all`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
spinner.stop(`Stopped ${initial.length} scan${initial.length === 1 ? '' : 's'}`);
|
||||
|
||||
if (temporalUp && anyRunningScanWorkflow()) {
|
||||
warn('some scan workflows are still Running in Temporal — check http://localhost:8233');
|
||||
appendVerifiedFallbacks(combinedResult);
|
||||
const clearFailures = clearPendingTargets(pending.references);
|
||||
if (clearFailures > 0) {
|
||||
spinner.error('Scans stopped, but one or more launch records could not be cleared');
|
||||
console.error(`Retry: ${commandPrefix()} stop --all`);
|
||||
process.exit(1);
|
||||
}
|
||||
const stoppedCount = Math.max(containers.length, results.length);
|
||||
spinner.stop(`Stopped ${stoppedCount} scan${stoppedCount === 1 ? '' : 's'}`);
|
||||
}
|
||||
|
||||
/** Resolve the omitted target from Docker without turning a failed query into an empty scan list. */
|
||||
function resolveStopTarget(): string {
|
||||
const result = runningScanContainersChecked();
|
||||
if (result.kind === 'unavailable') {
|
||||
fail('Could not inspect running scan workers.', `Retry with a workspace: ${commandPrefix()} stop <workspace>`);
|
||||
}
|
||||
const running = [...new Set(result.value.flatMap((container) => container.workspace ?? []))];
|
||||
if (running.length === 1) {
|
||||
const workspace = running[0] as string;
|
||||
console.error(`No workspace given; stopping running scan "${workspace}".`);
|
||||
return workspace;
|
||||
}
|
||||
if (running.length > 1) {
|
||||
failUsage('Multiple scans are running: specify which one, or use --all:', ` ${running.join(', ')}`);
|
||||
}
|
||||
if (result.value.length > 0) {
|
||||
fail('A running scan worker has no workspace label.', `Use ${commandPrefix()} stop --all`);
|
||||
}
|
||||
fail('No running scans to stop.', 'Pass a workspace name to stop a specific scan.');
|
||||
}
|
||||
|
||||
export async function stop(opts: StopOptions): Promise<void> {
|
||||
ensureDocker();
|
||||
if (opts.all && opts.workspace) failUsage('Pass a workspace name or --all, not both.');
|
||||
|
||||
// Validate the target: exactly one of <workspace> or --all.
|
||||
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);
|
||||
} else {
|
||||
await stopAllScans(opts.yes);
|
||||
}
|
||||
const workspace = opts.all ? undefined : (opts.workspace ?? resolveStopTarget());
|
||||
if (workspace) await stopSingleScan(workspace, opts.yes);
|
||||
else await stopAllScans(opts.yes);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
DEFAULT_MODEL_SPEC,
|
||||
GENERIC_API_KEY_ENV,
|
||||
isCuratedProvider,
|
||||
PROVIDER_API_KEY_ENV,
|
||||
parseModelSpec,
|
||||
} from '../model-spec.js';
|
||||
|
||||
@@ -235,6 +236,30 @@ function validateConfig(config: TOMLConfig): string[] {
|
||||
return errors;
|
||||
}
|
||||
|
||||
function assertNoCredentialConflict(toml: TOMLConfig): void {
|
||||
const tomlBaseUrl = typeof toml.core?.base_url === 'string' ? toml.core.base_url : undefined;
|
||||
if (!tomlBaseUrl || process.env.SHANNON_AI_BASE_URL) return;
|
||||
|
||||
const tomlModel = typeof toml.core?.model === 'string' ? toml.core.model : DEFAULT_MODEL_SPEC;
|
||||
const spec = parseModelSpec(process.env.SHANNON_AI_MODEL ?? tomlModel);
|
||||
if (typeof spec === 'string' || !isCuratedProvider(spec.providerId)) return;
|
||||
|
||||
for (const envVar of PROVIDER_API_KEY_ENV[spec.providerId]) {
|
||||
const mapping = CONFIG_MAP.find((entry) => entry.env === envVar);
|
||||
const tomlHasCredential = mapping ? getTomlValue(toml, mapping) !== undefined : false;
|
||||
const envHasCredential = Boolean(process.env[envVar]);
|
||||
if (!envHasCredential && !tomlHasCredential) continue;
|
||||
|
||||
if (envHasCredential) {
|
||||
fail(
|
||||
`${envVar} in your environment conflicts with the gateway credential in config.toml (core.base_url = ${tomlBaseUrl}).`,
|
||||
`Unset ${envVar}, or set SHANNON_AI_BASE_URL to override both from the environment.`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// === Public API ===
|
||||
|
||||
/**
|
||||
@@ -243,7 +268,8 @@ function validateConfig(config: TOMLConfig): string[] {
|
||||
* For each mapped variable: if not already set in the environment,
|
||||
* look it up in ~/.shannon/config.toml and inject it into process.env.
|
||||
* Local mode uses .env exclusively — TOML is skipped.
|
||||
* Exits with an error if the TOML contains unknown or invalid keys.
|
||||
* Exits with an error if the TOML contains unknown or invalid keys, or if an
|
||||
* ambient credential conflicts with a TOML-configured gateway credential.
|
||||
*/
|
||||
export function resolveConfig(): void {
|
||||
if (getMode() === 'local') return;
|
||||
@@ -261,6 +287,8 @@ export function resolveConfig(): void {
|
||||
);
|
||||
}
|
||||
|
||||
assertNoCredentialConflict(toml);
|
||||
|
||||
for (const mapping of CONFIG_MAP) {
|
||||
if (process.env[mapping.env]) continue;
|
||||
|
||||
|
||||
+231
-56
@@ -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';
|
||||
@@ -27,6 +27,16 @@ const DEV_IMAGE = 'shannon-worker';
|
||||
/** Docker label stamped on each worker container, mapping it back to its workspace so a single scan can be stopped by name. */
|
||||
const WORKSPACE_LABEL = 'shannon.workspace';
|
||||
|
||||
/** Docker label that joins a worker container to the Temporal workflow polling its unique task queue. */
|
||||
const TASK_QUEUE_LABEL = 'shannon.task-queue';
|
||||
|
||||
/** Docker label carrying the workflow ID selected before the worker starts. */
|
||||
const WORKFLOW_ID_LABEL = 'shannon.workflow-id';
|
||||
|
||||
/** Image/container protocol proving that the worker honors the preselected workflow ID. */
|
||||
const WORKER_PROTOCOL_LABEL = 'shannon.worker-protocol';
|
||||
export const WORKFLOW_ID_PROTOCOL = 'workflow-id-v1';
|
||||
|
||||
export function getWorkerImage(version: string): string {
|
||||
return getMode() === 'local' ? DEV_IMAGE : `${NPX_IMAGE_REPO}:${version}`;
|
||||
}
|
||||
@@ -84,9 +94,6 @@ function spawnQuiet(cmd: string, args: string[]): Promise<boolean> {
|
||||
const TEMPORAL_CONTAINER = 'shannon-temporal';
|
||||
const TEMPORAL_ADDRESS = 'localhost:7233';
|
||||
|
||||
/** Query matching every running pentest scan workflow. */
|
||||
const RUNNING_SCAN_QUERY = "ExecutionStatus = 'Running' AND WorkflowType = 'pentestPipelineWorkflow'";
|
||||
|
||||
/** Build `docker exec` args for a `temporal` CLI command run inside the Temporal container. */
|
||||
function temporalCmd(...args: string[]): string[] {
|
||||
return ['exec', TEMPORAL_CONTAINER, 'temporal', ...args, '--address', TEMPORAL_ADDRESS];
|
||||
@@ -116,10 +123,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 +151,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.
|
||||
@@ -167,7 +263,10 @@ export function buildImage(noCache: boolean, version: string): void {
|
||||
export function ensureImage(version: string): void {
|
||||
const image = getWorkerImage(version);
|
||||
const exists = runQuiet('docker', ['image', 'inspect', image]);
|
||||
if (exists) return;
|
||||
if (exists) {
|
||||
ensureWorkerImageProtocol(image);
|
||||
return;
|
||||
}
|
||||
|
||||
if (canBuildImage()) {
|
||||
console.log('Shannon image not found, building...');
|
||||
@@ -185,6 +284,22 @@ export function ensureImage(version: string): void {
|
||||
}
|
||||
pruneOldImages(version);
|
||||
}
|
||||
ensureWorkerImageProtocol(image);
|
||||
}
|
||||
|
||||
/** Refuse a stale worker image that would ignore the CLI-selected workflow ID. */
|
||||
function ensureWorkerImageProtocol(image: string): void {
|
||||
const protocol = runOutput('docker', [
|
||||
'image',
|
||||
'inspect',
|
||||
image,
|
||||
'--format',
|
||||
`{{ index .Config.Labels "${WORKER_PROTOCOL_LABEL}" }}`,
|
||||
]);
|
||||
if (protocol === WORKFLOW_ID_PROTOCOL) return;
|
||||
|
||||
const hint = canBuildImage() ? 'Run ./shannon build, then retry.' : 'Reinstall this Shannon version, then retry.';
|
||||
fail('The Shannon worker image is incompatible with this CLI.', hint);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -288,6 +403,7 @@ export interface WorkerOptions {
|
||||
repo: { hostPath: string; containerPath: string };
|
||||
workspacesDir: string;
|
||||
taskQueue: string;
|
||||
workflowId: string;
|
||||
containerName: string;
|
||||
envFlags: string[];
|
||||
config?: { hostPath: string; containerPath: string };
|
||||
@@ -310,8 +426,16 @@ export function spawnWorker(opts: WorkerOptions): ChildProcess {
|
||||
}
|
||||
args.push('--name', opts.containerName, '--network', 'shannon-net');
|
||||
|
||||
// Tag with the workspace so `stop <workspace>` can target this scan's container
|
||||
args.push('--label', `${WORKSPACE_LABEL}=${opts.workspace}`);
|
||||
// Keep the launch identity on the container before session.json exists. The fixed workflow
|
||||
// ID lets stop verify the pre-registration window without trusting visibility timing.
|
||||
args.push(
|
||||
'--label',
|
||||
`${WORKSPACE_LABEL}=${opts.workspace}`,
|
||||
'--label',
|
||||
`${TASK_QUEUE_LABEL}=${opts.taskQueue}`,
|
||||
'--label',
|
||||
`${WORKFLOW_ID_LABEL}=${opts.workflowId}`,
|
||||
);
|
||||
|
||||
// Add host flag for Linux
|
||||
args.push(...addHostFlag());
|
||||
@@ -345,7 +469,7 @@ export function spawnWorker(opts: WorkerOptions): ChildProcess {
|
||||
args.push('-v', `${opts.config.hostPath}:${opts.config.containerPath}:ro`);
|
||||
}
|
||||
|
||||
// Output directory for deliverables copy
|
||||
// Customer-copy destination. The workflow surfaces only final report artifacts here.
|
||||
if (opts.outputDir) {
|
||||
args.push('-v', `${opts.outputDir}:/app/output`);
|
||||
}
|
||||
@@ -358,7 +482,10 @@ export function spawnWorker(opts: WorkerOptions): ChildProcess {
|
||||
// Environment
|
||||
args.push(...opts.envFlags);
|
||||
|
||||
// Container settings
|
||||
// Container settings. Chromium's own sandbox needs syscalls Docker's default seccomp
|
||||
// profile blocks, which is why the profile is dropped. `seccomp=unconfined` is a
|
||||
// container-wide setting, not a per-process one: every process here runs unfiltered,
|
||||
// the worker included — not just the browser automation that motivates it.
|
||||
args.push('--shm-size', '2gb', '--security-opt', 'seccomp=unconfined');
|
||||
|
||||
// Image
|
||||
@@ -367,6 +494,7 @@ export function spawnWorker(opts: WorkerOptions): ChildProcess {
|
||||
// Worker command
|
||||
args.push('node', 'apps/worker/dist/temporal/worker.js', opts.url, opts.repo.containerPath);
|
||||
args.push('--task-queue', opts.taskQueue);
|
||||
args.push('--workflow-id', opts.workflowId);
|
||||
if (opts.config) {
|
||||
args.push('--config', opts.config.containerPath);
|
||||
}
|
||||
@@ -390,6 +518,18 @@ export function spawnWorker(opts: WorkerOptions): ChildProcess {
|
||||
/** `docker ps --filter` args matching every running worker container. */
|
||||
export const WORKER_FILTER: readonly string[] = ['--filter', 'name=shannon-worker-'];
|
||||
|
||||
/** Result of a command-backed query whose unavailable state must not be mistaken for an empty result. */
|
||||
export type CommandQueryResult<T> = { kind: 'ok'; value: T } | { kind: 'unavailable' };
|
||||
|
||||
/** Identity carried by a running scan worker container. Older workers may lack the newer labels. */
|
||||
export interface RunningScanContainer {
|
||||
readonly id: string;
|
||||
readonly workspace?: string;
|
||||
readonly taskQueue?: string;
|
||||
readonly workflowId?: string;
|
||||
readonly workerProtocol?: string;
|
||||
}
|
||||
|
||||
/** `docker ps --filter` args matching one scan's worker container(s), by workspace label. */
|
||||
export function scanFilter(workspace: string): readonly string[] {
|
||||
return ['--filter', `label=${WORKSPACE_LABEL}=${workspace}`];
|
||||
@@ -400,9 +540,85 @@ export function scanFilter(workspace: string): readonly string[] {
|
||||
* the authoritative check for whether containers actually stopped — `docker stop`'s
|
||||
* exit code can't distinguish "already gone" from "failed to stop".
|
||||
*/
|
||||
export function runningContainersChecked(filter: readonly string[]): CommandQueryResult<string[]> {
|
||||
try {
|
||||
const output = execFileSync('docker', ['ps', '-q', ...filter], { stdio: 'pipe', encoding: 'utf-8' }).trim();
|
||||
return { kind: 'ok', value: output.split('\n').filter(Boolean) };
|
||||
} catch {
|
||||
return { kind: 'unavailable' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort counterpart for callers where Docker unavailability is intentionally
|
||||
* presented as no local running containers.
|
||||
*/
|
||||
export function runningContainers(filter: readonly string[]): string[] {
|
||||
const output = runOutput('docker', ['ps', '-q', ...filter]);
|
||||
return output.split('\n').filter(Boolean);
|
||||
const result = runningContainersChecked(filter);
|
||||
return result.kind === 'ok' ? result.value : [];
|
||||
}
|
||||
|
||||
function normalizedLabel(value: string | undefined): string | undefined {
|
||||
const normalized = value?.trim();
|
||||
return normalized && normalized !== '<no value>' ? normalized : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Running scan containers with the labels needed to correlate a worker to its Temporal
|
||||
* workflow. A successful query keeps unlabeled legacy workers in the result by ID.
|
||||
*/
|
||||
export function runningScanContainersChecked(
|
||||
filter: readonly string[] = WORKER_FILTER,
|
||||
): CommandQueryResult<RunningScanContainer[]> {
|
||||
try {
|
||||
const format = `{{.ID}}\t{{ index .Labels "${WORKSPACE_LABEL}" }}\t{{ index .Labels "${TASK_QUEUE_LABEL}" }}\t{{ index .Labels "${WORKFLOW_ID_LABEL}" }}\t{{ index .Labels "${WORKER_PROTOCOL_LABEL}" }}`;
|
||||
const output = execFileSync('docker', ['ps', ...filter, '--format', format], {
|
||||
stdio: 'pipe',
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
if (!output) return { kind: 'ok', value: [] };
|
||||
|
||||
const containers: RunningScanContainer[] = [];
|
||||
for (const line of output.split('\n')) {
|
||||
const [rawId, rawWorkspace, rawTaskQueue, rawWorkflowId, rawWorkerProtocol] = line.split('\t');
|
||||
const id = rawId?.trim();
|
||||
if (!id) return { kind: 'unavailable' };
|
||||
const workspace = normalizedLabel(rawWorkspace);
|
||||
const taskQueue = normalizedLabel(rawTaskQueue);
|
||||
const workflowId = normalizedLabel(rawWorkflowId);
|
||||
const workerProtocol = normalizedLabel(rawWorkerProtocol);
|
||||
containers.push({
|
||||
id,
|
||||
...(workspace !== undefined && { workspace }),
|
||||
...(taskQueue !== undefined && { taskQueue }),
|
||||
...(workflowId !== undefined && { workflowId }),
|
||||
...(workerProtocol !== undefined && { workerProtocol }),
|
||||
});
|
||||
}
|
||||
return { kind: 'ok', value: containers };
|
||||
} catch {
|
||||
return { kind: 'unavailable' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace names of every running worker container, read from the shannon.workspace
|
||||
* label each scan is stamped with at spawn. The checked form preserves Docker query
|
||||
* failures so lifecycle commands do not mistake an unavailable daemon for an empty list.
|
||||
*/
|
||||
export function runningScanWorkspacesChecked(): CommandQueryResult<string[]> {
|
||||
const result = runningScanContainersChecked();
|
||||
if (result.kind === 'unavailable') return result;
|
||||
return {
|
||||
kind: 'ok',
|
||||
value: result.value.flatMap((container) => (container.workspace === undefined ? [] : [container.workspace])),
|
||||
};
|
||||
}
|
||||
|
||||
/** Best-effort counterpart for callers that only need the local scan list. */
|
||||
export function runningScanWorkspaces(): string[] {
|
||||
const result = runningScanWorkspacesChecked();
|
||||
return result.kind === 'ok' ? result.value : [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -414,47 +630,6 @@ export async function stopContainers(ids: string[]): Promise<void> {
|
||||
await Promise.all(ids.map((id) => spawnQuiet('docker', ['stop', id])));
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate a Temporal workflow so a stopped scan doesn't linger as a running
|
||||
* workflow with no worker. Best-effort: returns false if Temporal is unreachable
|
||||
* or the workflow already closed. Requires Temporal to be up (guard with isTemporalReady).
|
||||
*/
|
||||
export function terminateWorkflow(workflowId: string, reason: string): boolean {
|
||||
return runQuiet('docker', temporalCmd('workflow', 'terminate', '--workflow-id', workflowId, '--reason', reason));
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate every running pentest workflow in one batch, so `stop --all` doesn't
|
||||
* leave workflows running with no worker. Best-effort: returns false if Temporal
|
||||
* is unreachable. Requires Temporal to be up (guard with isTemporalReady).
|
||||
*/
|
||||
export function terminateAllWorkflows(reason: string): boolean {
|
||||
return runQuiet(
|
||||
'docker',
|
||||
temporalCmd('workflow', 'terminate', '--query', RUNNING_SCAN_QUERY, '--reason', reason, '--yes'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a specific workflow is still in the Running state. Re-querying this after
|
||||
* a terminate verifies it actually took effect, rather than trusting the terminate
|
||||
* command's exit code. Requires Temporal to be up (guard with isTemporalReady).
|
||||
*/
|
||||
export function isWorkflowRunning(workflowId: string): boolean {
|
||||
const query = `WorkflowId = '${workflowId}' AND ExecutionStatus = 'Running'`;
|
||||
const output = runOutput('docker', temporalCmd('workflow', 'list', '--query', query));
|
||||
return output.includes(workflowId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any pentest scan workflow is still Running — the `stop --all` counterpart
|
||||
* to isWorkflowRunning. Requires Temporal to be up (guard with isTemporalReady).
|
||||
*/
|
||||
export function anyRunningScanWorkflow(): boolean {
|
||||
const output = runOutput('docker', temporalCmd('workflow', 'list', '--query', RUNNING_SCAN_QUERY));
|
||||
return output.includes('pentestPipelineWorkflow');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down the compose stack. When `clean` is set, volumes are removed too.
|
||||
*/
|
||||
|
||||
+48
-11
@@ -31,6 +31,10 @@ const COMMON_FORWARD_VARS = [
|
||||
'SHANNON_AI_MODEL',
|
||||
'SHANNON_AI_BASE_URL',
|
||||
'SHANNON_AI_OPENAI_FORMAT',
|
||||
// Opt-in debug flag: when set, the worker persists a bounded, sanitized snippet of a failed
|
||||
// provider turn's raw error message to error.log. Off by default; provider prose stays out of
|
||||
// durable state unless an operator deliberately enables it for a diagnosis.
|
||||
'SHANNON_DEBUG_PROVIDER_ERRORS',
|
||||
GENERIC_API_KEY_ENV,
|
||||
] as const;
|
||||
|
||||
@@ -111,6 +115,17 @@ interface CredentialValidation {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the shell environment already carries a usable credential — the host's
|
||||
* pi login, or an API key for the selected provider. Reads process.env only.
|
||||
*/
|
||||
export function hasExportedCredentials(): boolean {
|
||||
if (shouldUsePiAuth()) return true;
|
||||
const spec = resolveModelSpec();
|
||||
if (typeof spec === 'string') return false;
|
||||
return hasCredential(spec.providerId);
|
||||
}
|
||||
|
||||
/** Whether a curated provider has its own named credential set (API key plus any extra var). */
|
||||
function hasNamedCredential(providerId: CuratedProviderId): boolean {
|
||||
const apiKeys = PROVIDER_API_KEY_ENV[providerId];
|
||||
@@ -130,6 +145,38 @@ function configuredProviders(): CuratedProviderId[] {
|
||||
return CURATED_PROVIDERS.filter((providerId) => hasNamedCredential(providerId));
|
||||
}
|
||||
|
||||
/** Whether SHANNON_AI_MODEL was set by the user, rather than falling back to the default. */
|
||||
function modelExplicitlySelected(): boolean {
|
||||
return Boolean(process.env.SHANNON_AI_MODEL?.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Explain why the selected provider has no usable credential. With no model chosen
|
||||
* the provider is only the default (anthropic), so the real state is "nothing
|
||||
* configured" — or, if another provider's key is set, an unselected model.
|
||||
*/
|
||||
function describeMissingCredential(providerId: string): string {
|
||||
if (modelExplicitlySelected()) {
|
||||
const requirement = isCuratedProvider(providerId) ? PROVIDER_CREDENTIAL_HINT[providerId] : GENERIC_API_KEY_ENV;
|
||||
const hint =
|
||||
getMode() === 'local'
|
||||
? `Set ${requirement} in .env or export it.`
|
||||
: `Export the variables or run 'npx @keygraph/shannon setup'.`;
|
||||
return `No credentials found for provider "${providerId}". ${hint}`;
|
||||
}
|
||||
|
||||
const [provider] = configuredProviders();
|
||||
if (provider) {
|
||||
return `A credential for "${provider}" is set, but no model is selected. Set SHANNON_AI_MODEL=${provider}:<model-id> to use it.`;
|
||||
}
|
||||
|
||||
const hint =
|
||||
getMode() === 'local'
|
||||
? 'Set a provider API key in .env (for example ANTHROPIC_API_KEY).'
|
||||
: "Run 'npx @keygraph/shannon setup' to get started.";
|
||||
return `No credentials configured. ${hint}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the model selection parses and its provider has a credential.
|
||||
* Runs before any Docker work so mistakes fail immediately.
|
||||
@@ -155,17 +202,7 @@ export function validateCredentials(): CredentialValidation {
|
||||
|
||||
// 2. The selected provider must have a credential
|
||||
if (!hasCredential(spec.providerId)) {
|
||||
const requirement = isCuratedProvider(spec.providerId)
|
||||
? PROVIDER_CREDENTIAL_HINT[spec.providerId]
|
||||
: GENERIC_API_KEY_ENV;
|
||||
const hint =
|
||||
getMode() === 'local'
|
||||
? `Set ${requirement} in .env or export it.`
|
||||
: `Export the variables or run 'npx @keygraph/shannon setup'.`;
|
||||
return {
|
||||
valid: false,
|
||||
error: `No credentials found for provider "${spec.providerId}". ${hint}`,
|
||||
};
|
||||
return { valid: false, error: describeMissingCredential(spec.providerId) };
|
||||
}
|
||||
|
||||
// 3. Exactly one provider may be configured. Several complete credentials make
|
||||
|
||||
+76
-37
@@ -1,37 +1,94 @@
|
||||
/**
|
||||
* Centralized error reporting.
|
||||
*
|
||||
* `fail` — an expected, user-fixable error (bad input, missing prerequisite):
|
||||
* a clean message on stderr and a non-zero exit, never a stack trace.
|
||||
* `fail` / `failWith` — an expected, user-fixable error (bad input, missing
|
||||
* prerequisite): a clean message on stderr and a non-zero exit, never a stack trace.
|
||||
* `failUsage` — a malformed invocation (unknown command, bad or missing
|
||||
* arguments): the same clean message, but a distinct exit code so callers can
|
||||
* tell a usage mistake from an operational failure.
|
||||
* `crash` — an unexpected error (a bug): a brief message, the full stack written
|
||||
* to a log file for a bug report, and a pointer to the issue tracker.
|
||||
* `crash` — an unexpected error (a bug): a fixed code and a pointer to the issue tracker.
|
||||
*
|
||||
* JSON mode (enabled once, before parsing, for the `--json` command surface) replaces
|
||||
* the text lines with one compact envelope on stderr — stdout stays empty — while the
|
||||
* exit-code split is unchanged. Call sites on a JSON-capable path must exit through
|
||||
* `failWith`/`failUsage`/`crash` (never a bare `fail` or `warn`) so every failure
|
||||
* carries a stable code and stderr stays parseable.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const ISSUES_URL = 'https://github.com/KeygraphHQ/shannon/issues';
|
||||
|
||||
/** Report an expected, user-fixable error (with optional extra lines) and exit non-zero. */
|
||||
export function fail(message: string, ...hints: string[]): never {
|
||||
const UNEXPECTED_MESSAGE = 'Shannon encountered an unexpected failure. Reference code: SHANNON_UNEXPECTED_ERROR';
|
||||
const REPORT_HINT = `If this looks like a bug, please report it: ${ISSUES_URL}`;
|
||||
|
||||
/** Stable machine-readable failure codes for the JSON error envelope. */
|
||||
export type ErrorCode =
|
||||
| 'CLI_USAGE'
|
||||
| 'CLI_SCAN_NOT_FOUND'
|
||||
| 'CLI_SCAN_IDENTITY_NOT_FOUND'
|
||||
| 'CLI_SCAN_IDENTITY_AMBIGUOUS'
|
||||
| 'CLI_SCAN_STATUS_UNAVAILABLE'
|
||||
| 'CLI_SCAN_SCHEMA_UNSUPPORTED'
|
||||
| 'CLI_PRECONDITION_FAILED'
|
||||
| 'CLI_INTERNAL_ERROR';
|
||||
|
||||
let jsonMode = false;
|
||||
|
||||
/** Switch failure reporting to the JSON envelope. Set once, before any guard, parse, or dispatch. */
|
||||
export function enableJsonErrors(): void {
|
||||
jsonMode = true;
|
||||
}
|
||||
|
||||
/** Whether failures are reported as the JSON envelope rather than text. */
|
||||
export function jsonErrorsEnabled(): boolean {
|
||||
return jsonMode;
|
||||
}
|
||||
|
||||
/** Fixed unexpected-failure projection shared by the runtime and focused safety tests. */
|
||||
export function unexpectedFailureLines(): readonly string[] {
|
||||
return [`ERROR: ${UNEXPECTED_MESSAGE}`, REPORT_HINT];
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a failure on stderr and exit. Text mode prints the message and every hint
|
||||
* verbatim; JSON mode writes one compact envelope (dropping the empty strings used
|
||||
* to space text output) synchronously so `process.exit` cannot truncate it.
|
||||
*/
|
||||
function emit(exitCode: 1 | 2, code: ErrorCode, message: string, hints: readonly string[]): never {
|
||||
if (jsonMode) {
|
||||
const payload = JSON.stringify({ error: { code, message, hints: hints.filter((hint) => hint.trim() !== '') } });
|
||||
fs.writeSync(process.stderr.fd, `${payload}\n`);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
console.error(`ERROR: ${message}`);
|
||||
for (const hint of hints) {
|
||||
console.error(hint);
|
||||
}
|
||||
process.exit(1);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Report an expected, user-fixable error (with optional extra lines) and exit non-zero.
|
||||
* Text-only paths use this; a JSON-capable path must use `failWith` so the envelope
|
||||
* carries a real code — if a bare `fail` is ever reached in JSON mode, the fixed
|
||||
* internal-error envelope is emitted instead of guessing a code for the message.
|
||||
*/
|
||||
export function fail(message: string, ...hints: string[]): never {
|
||||
if (jsonMode) {
|
||||
emit(1, 'CLI_INTERNAL_ERROR', UNEXPECTED_MESSAGE, [REPORT_HINT]);
|
||||
}
|
||||
emit(1, 'CLI_INTERNAL_ERROR', message, hints);
|
||||
}
|
||||
|
||||
/** Report an expected operational failure under a stable code and exit 1. */
|
||||
export function failWith(code: ErrorCode, message: string, ...hints: string[]): never {
|
||||
emit(1, code, message, hints);
|
||||
}
|
||||
|
||||
/** Report a usage/argument error (with optional extra lines) and exit 2. */
|
||||
export function failUsage(message: string, ...hints: string[]): never {
|
||||
console.error(`ERROR: ${message}`);
|
||||
for (const hint of hints) {
|
||||
console.error(hint);
|
||||
}
|
||||
process.exit(2);
|
||||
emit(2, 'CLI_USAGE', message, hints);
|
||||
}
|
||||
|
||||
/** Report a non-fatal warning on stderr (with optional extra lines) without exiting. */
|
||||
@@ -42,29 +99,11 @@ export function warn(message: string, ...hints: string[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Report an unexpected error: brief message, full stack to a log file, plus the issue link. */
|
||||
export function crash(error: unknown): never {
|
||||
console.error(`ERROR: ${error instanceof Error ? error.message : String(error)}`);
|
||||
if (process.env.DEBUG) {
|
||||
console.error(error instanceof Error ? error.stack : String(error));
|
||||
/** Report an unexpected error without projecting its message, stack, or attached values. */
|
||||
export function crash(_error: unknown): never {
|
||||
if (jsonMode) {
|
||||
emit(1, 'CLI_INTERNAL_ERROR', UNEXPECTED_MESSAGE, [REPORT_HINT]);
|
||||
}
|
||||
|
||||
const logPath = writeCrashLog(error);
|
||||
if (logPath) {
|
||||
console.error(`Details written to ${logPath}`);
|
||||
}
|
||||
console.error(`If this looks like a bug, please report it: ${ISSUES_URL}`);
|
||||
for (const line of unexpectedFailureLines()) console.error(line);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** Write the full error and stack to a log file; return its path, or null if it can't be written. */
|
||||
function writeCrashLog(error: unknown): string | null {
|
||||
try {
|
||||
const logPath = path.join(os.tmpdir(), 'shannon-error.log');
|
||||
const detail = error instanceof Error && error.stack ? error.stack : String(error);
|
||||
fs.writeFileSync(logPath, `${new Date().toISOString()}\n${detail}\n`);
|
||||
return logPath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+21
-10
@@ -47,30 +47,32 @@ const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
|
||||
],
|
||||
},
|
||||
stop: {
|
||||
usage: ['stop <workspace> [--yes]', 'stop --all [--yes]'],
|
||||
description: 'Stop one scan by workspace, or every scan with --all (Temporal stays up).',
|
||||
usage: ['stop [<workspace>] [--yes]', 'stop --all [--yes]'],
|
||||
description:
|
||||
'Stop one scan by workspace, or every scan with --all (Temporal stays up). With no workspace, stops the single running scan; when several are running, name one or use --all.',
|
||||
options: [['--all', 'Stop all running scans'], YES_OPTION],
|
||||
examples: ['stop q1-audit', 'stop --all'],
|
||||
examples: ['stop', 'stop q1-audit', 'stop --all'],
|
||||
},
|
||||
reset: {
|
||||
usage: ['reset'],
|
||||
description: 'Stop everything and permanently remove all Temporal data and volumes.',
|
||||
},
|
||||
logs: {
|
||||
usage: ['logs <workspace>'],
|
||||
description: "Tail a scan's live log until it completes.",
|
||||
examples: ['logs q1-audit'],
|
||||
usage: ['logs [<workspace>]'],
|
||||
description:
|
||||
"Tail a scan's live log until it completes. With no workspace, follows the single running scan, or the most recent workspace when none is running; when several are running, name one.",
|
||||
examples: ['logs', 'logs q1-audit'],
|
||||
},
|
||||
status: {
|
||||
usage: ['status <workspace> [--json]'],
|
||||
usage: ['status [<workspace>] [--json]'],
|
||||
description:
|
||||
"Show one scan's phase-by-phase progress, read live from Temporal. Watches and redraws until the scan finishes on a terminal; prints one frame when piped or already finished. With --json, prints a single machine-readable snapshot and exits.",
|
||||
"Show one scan's phase-by-phase progress, read live from Temporal. With no workspace, shows the single running scan, or the most recent workspace when none is running; when several are running, name one. Watches and redraws until the scan finishes on a terminal; prints one frame when piped or already finished. With --json, prints a single machine-readable snapshot and exits.",
|
||||
options: [['--json', 'Output a point-in-time snapshot as JSON, then exit']],
|
||||
examples: ['status q1-audit', 'status q1-audit --json'],
|
||||
examples: ['status', 'status q1-audit', 'status q1-audit --json'],
|
||||
},
|
||||
scans: {
|
||||
usage: ['scans [--json]'],
|
||||
description: 'List completed scans and where each report lives.',
|
||||
description: 'List running and completed scans, and where each finished report lives.',
|
||||
options: [['--json', 'Output the scan list as JSON']],
|
||||
examples: ['scans', 'scans --json'],
|
||||
},
|
||||
@@ -102,6 +104,15 @@ export function isHelpableCommand(command: string): boolean {
|
||||
return command in COMMAND_HELP;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every explicit help topic, mode-blind, with `help` itself as the known global topic.
|
||||
* Topic lookup is deliberately not mode-filtered (unlike `availableCommands`) so
|
||||
* cross-mode help such as local `help setup` and npx `help build` keeps working.
|
||||
*/
|
||||
export function helpTopics(): readonly string[] {
|
||||
return [...Object.keys(COMMAND_HELP), 'help'];
|
||||
}
|
||||
|
||||
/**
|
||||
* User-facing command names available in the current mode, for "did you mean?"
|
||||
* suggestions. Derived from the same table that backs per-command help, so the
|
||||
|
||||
@@ -16,6 +16,11 @@ export function getConfigFile(): string {
|
||||
return path.join(SHANNON_HOME, 'config.toml');
|
||||
}
|
||||
|
||||
/** Whether the npx-mode credential file (`~/.shannon/config.toml`) exists on disk. */
|
||||
export function configFileExists(): boolean {
|
||||
return fs.existsSync(getConfigFile());
|
||||
}
|
||||
|
||||
export function getWorkspacesDir(): string {
|
||||
return getMode() === 'local' ? path.resolve('workspaces') : path.join(SHANNON_HOME, 'workspaces');
|
||||
}
|
||||
|
||||
+148
-21
@@ -18,14 +18,23 @@ 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 { hasExportedCredentials } from './env.js';
|
||||
import { crash, enableJsonErrors, fail, failUsage, failWith, jsonErrorsEnabled } from './errors.js';
|
||||
import { availableCommands, helpTopics, isHelpableCommand, printCommandHelp, START_OPTIONS } from './help.js';
|
||||
import { configFileExists } from './home.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 +46,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 +92,16 @@ function renderUsage(prefix: string, mode: Mode): string {
|
||||
const rows: ReadonlyArray<readonly [string, string]> = [
|
||||
...(mode === 'local' ? [] : [[`${prefix} setup`, 'Configure credentials'] as const]),
|
||||
[`${prefix} start --url <url> --repo <path> [options]`, 'Start a pentest scan'],
|
||||
[`${prefix} stop <workspace> [--yes]`, 'Stop one scan'],
|
||||
[`${prefix} stop [<workspace>] [--yes]`, 'Stop one scan (default: the single running scan)'],
|
||||
[`${prefix} stop --all [--yes]`, 'Stop all scans (Temporal stays up)'],
|
||||
[`${prefix} reset`, 'Stop everything and wipe all Temporal data'],
|
||||
[`${prefix} logs <workspace>`, "Show a scan's live log"],
|
||||
[`${prefix} status <workspace> [--json]`, 'Live phase/agent progress of one scan'],
|
||||
[`${prefix} scans [--json]`, 'List completed scans and their reports'],
|
||||
[`${prefix} logs [<workspace>]`, "Show a scan's live log (default: running or most recent)"],
|
||||
[`${prefix} logs [<workspace>] --agent <name>`, "Tail one agent's log; --list-agents to list them"],
|
||||
[
|
||||
`${prefix} status [<workspace>] [--json]`,
|
||||
'Live phase/agent progress of one scan (default: running or most recent)',
|
||||
],
|
||||
[`${prefix} scans [--json]`, 'List running and completed scans'],
|
||||
...(mode === 'local' ? [[`${prefix} build [--no-cache]`, 'Build worker image'] as const] : []),
|
||||
[`${prefix} version [--json]`, 'Show version'],
|
||||
[`${prefix} help`, 'Show this help'],
|
||||
@@ -75,13 +111,28 @@ function renderUsage(prefix: string, mode: Mode): string {
|
||||
return rows.map(([command, desc]) => ` ${command.padEnd(commandWidth)} ${desc}`).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* A boxed "start your first scan" call to action, shown in help when no scans exist
|
||||
* yet. Prefix-aware, so local mode renders `./shannon start …`.
|
||||
*/
|
||||
function renderFirstScanBox(prefix: string): string {
|
||||
const command = `${prefix} start -u <url> -r <path>`;
|
||||
const title = 'Start your first scan';
|
||||
const padX = 3;
|
||||
const inner = Math.max(command.length, title.length) + padX * 2;
|
||||
const rule = (left: string, right: string): string => ` ${left}${'─'.repeat(inner)}${right}`;
|
||||
const line = (text: string): string => ` │${' '.repeat(padX)}${text}${' '.repeat(inner - padX - text.length)}│`;
|
||||
return [rule('╭', '╮'), line(title), line(''), line(command), rule('╰', '╯')].join('\n');
|
||||
}
|
||||
|
||||
function showHelp(withSplash: boolean): void {
|
||||
const mode = getMode();
|
||||
const prefix = commandPrefix();
|
||||
|
||||
const header = withSplash ? '' : '\nShannon — AI Pentester by Keygraph\n';
|
||||
const firstScan = stdoutIsTerminal() ? `\n${renderFirstScanBox(prefix)}\n` : '';
|
||||
|
||||
console.log(`${header}
|
||||
console.log(`${header}${firstScan}
|
||||
Usage:
|
||||
${renderUsage(prefix, mode)}
|
||||
|
||||
@@ -101,6 +152,21 @@ Docs & source: https://github.com/KeygraphHQ/shannon
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* First-run guidance for a bare `npx @keygraph/shannon` invocation when neither a
|
||||
* credentials file nor an exported shell credential exists. Walks the user to `setup`.
|
||||
*/
|
||||
function showSetupPrompt(): void {
|
||||
const prefix = commandPrefix();
|
||||
console.log(`
|
||||
Welcome to Shannon — AI Pentester by Keygraph
|
||||
|
||||
No credentials configured yet. To get started, run:
|
||||
|
||||
${prefix} setup
|
||||
`);
|
||||
}
|
||||
|
||||
interface ParsedStartArgs {
|
||||
url: string;
|
||||
repo: string;
|
||||
@@ -152,6 +218,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,24 +255,54 @@ 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);
|
||||
} else {
|
||||
const bare = command === undefined;
|
||||
if (bare && stdoutIsTerminal()) displaySplash(isLocal() ? undefined : getVersion());
|
||||
showHelp(bare);
|
||||
const needsSetup = bare && !isLocal() && !configFileExists() && !hasExportedCredentials();
|
||||
if (needsSetup) {
|
||||
showSetupPrompt();
|
||||
} else {
|
||||
showHelp(bare);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// An explicit `help <topic>` names a topic on purpose, so an unknown one is a usage
|
||||
// error — unlike `--help <junk>`, where the junk is ignored and global help wins.
|
||||
if (command === 'help') {
|
||||
const topic = rest[0];
|
||||
// A flag (`help --help`) is a help request, not a topic name.
|
||||
if (topic === undefined || topic === 'help' || topic.startsWith('-')) {
|
||||
showHelp(false);
|
||||
return;
|
||||
}
|
||||
if (isHelpableCommand(topic)) {
|
||||
printCommandHelp(topic);
|
||||
return;
|
||||
}
|
||||
const suggestion = closestMatch(topic, helpTopics());
|
||||
failUsage(
|
||||
`Unknown help topic: ${topic}`,
|
||||
...(suggestion ? [`Did you mean '${suggestion}'?`] : []),
|
||||
`Run '${commandPrefix()} help' to see available commands.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Reachable from any invocation: `-h`/`--help` anywhere wins over the rest of the line.
|
||||
if (isHelpableCommand(command) && (rest.includes('-h') || rest.includes('--help'))) {
|
||||
printCommandHelp(command);
|
||||
@@ -210,20 +332,25 @@ async function main(): Promise<void> {
|
||||
break;
|
||||
}
|
||||
case 'logs': {
|
||||
const { positionals } = parseArgs(rest, { maxPositionals: 1 });
|
||||
const workspaceId = positionals[0];
|
||||
if (!workspaceId) {
|
||||
failUsage('Workspace ID is required', `Usage: ${commandPrefix()} logs <workspace>`);
|
||||
}
|
||||
logs(workspaceId);
|
||||
const { flags, values, positionals } = parseArgs(rest, {
|
||||
booleans: { listAgents: ['--list-agents'] },
|
||||
values: { agent: ['--agent'] },
|
||||
maxPositionals: 1,
|
||||
});
|
||||
const workspaceId = resolveViewingWorkspace(
|
||||
positionals[0],
|
||||
`Usage: ${commandPrefix()} logs [<workspace>] [--agent <name>] [--list-agents]`,
|
||||
);
|
||||
logs(workspaceId, {
|
||||
...(values.agent !== undefined && { agent: values.agent }),
|
||||
...(flags.listAgents && { listAgents: true }),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'status': {
|
||||
const { flags, positionals } = parseArgs(rest, { booleans: { json: ['--json'] }, maxPositionals: 1 });
|
||||
const workspaceId = positionals[0];
|
||||
if (!workspaceId) {
|
||||
failUsage('Workspace is required', `Usage: ${commandPrefix()} status <workspace> [--json]`);
|
||||
}
|
||||
const usage = `Usage: ${commandPrefix()} status [<workspace>] [--json]`;
|
||||
const workspaceId = resolveViewingWorkspace(positionals[0], usage);
|
||||
await status(workspaceId, { json: !!flags.json });
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,12 @@ export const INTERNAL_DIR = '.shannon';
|
||||
*/
|
||||
export const FINAL_REPORT_PDF_FILENAME = 'Security-Assessment-Report.pdf';
|
||||
|
||||
/**
|
||||
* Customer-facing Markdown report name at the run root.
|
||||
* Must match FINAL_REPORT_MD_FILENAME in the worker package.
|
||||
*/
|
||||
export const FINAL_REPORT_MD_FILENAME = 'Security-Assessment-Report.md';
|
||||
|
||||
/**
|
||||
* Resolve a run-directory file (e.g. session.json, workflow.log), preferring the
|
||||
* current INTERNAL_DIR location and falling back to the legacy run-root location
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/** Durable CLI-owned workflow candidates that bridge Docker launch and session registration. */
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { INTERNAL_DIR } from './paths.js';
|
||||
|
||||
const SCHEMA_VERSION = 1 as const;
|
||||
const PENDING_DIR = 'pending-workflows';
|
||||
|
||||
export interface PendingWorkflowIdentity {
|
||||
readonly schema_version: typeof SCHEMA_VERSION;
|
||||
readonly workflow_id: string;
|
||||
readonly task_queue: string;
|
||||
readonly created_at: string;
|
||||
}
|
||||
|
||||
export interface PendingWorkflowReadResult {
|
||||
readonly identities: readonly PendingWorkflowIdentity[];
|
||||
readonly unreadableCount: number;
|
||||
}
|
||||
|
||||
function pendingDir(workspacePath: string): string {
|
||||
return path.join(workspacePath, INTERNAL_DIR, PENDING_DIR);
|
||||
}
|
||||
|
||||
function pendingFile(workspacePath: string, taskQueue: string): string {
|
||||
return path.join(pendingDir(workspacePath), `launch-${encodeURIComponent(taskQueue)}.json`);
|
||||
}
|
||||
|
||||
function syncDirectory(directory: string): void {
|
||||
const descriptor = fs.openSync(directory, 'r');
|
||||
try {
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the candidate before docker run, so a vanished pre-registration worker remains addressable. */
|
||||
export function writePendingWorkflowIdentity(workspacePath: string, workflowId: string, taskQueue: string): void {
|
||||
const directory = pendingDir(workspacePath);
|
||||
const directoryAlreadyExisted = fs.existsSync(directory);
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
if (!directoryAlreadyExisted) syncDirectory(path.dirname(directory));
|
||||
const destination = pendingFile(workspacePath, taskQueue);
|
||||
const temporary = `${destination}.tmp-${process.pid}-${Date.now()}`;
|
||||
const identity: PendingWorkflowIdentity = {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
workflow_id: workflowId,
|
||||
task_queue: taskQueue,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const descriptor = fs.openSync(temporary, 'wx', 0o600);
|
||||
try {
|
||||
fs.writeFileSync(descriptor, `${JSON.stringify(identity, null, 2)}\n`, 'utf8');
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
try {
|
||||
// Link installs the fully-fsynced inode without replacing an existing task-queue record.
|
||||
fs.linkSync(temporary, destination);
|
||||
fs.unlinkSync(temporary);
|
||||
syncDirectory(directory);
|
||||
} catch (error) {
|
||||
fs.rmSync(temporary, { force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove one candidate only after session registration or a fully verified stop. */
|
||||
export function clearPendingWorkflowIdentity(workspacePath: string, taskQueue: string): void {
|
||||
const directory = pendingDir(workspacePath);
|
||||
fs.rmSync(pendingFile(workspacePath, taskQueue), { force: true });
|
||||
if (fs.existsSync(directory)) syncDirectory(directory);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function isPendingWorkflowIdentity(
|
||||
value: unknown,
|
||||
workspace: string,
|
||||
expectedFilename: string,
|
||||
): value is PendingWorkflowIdentity {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
const keys = Object.keys(candidate).sort();
|
||||
const workflowId = candidate.workflow_id;
|
||||
const workflowPattern = new RegExp(`^${escapeRegExp(workspace)}_(?:shannon-|resume_)\\d+$`);
|
||||
const workspaceIsWorkflowId = workflowId === workspace && /_shannon-\d+$/.test(workspace);
|
||||
return (
|
||||
keys.length === 4 &&
|
||||
keys[0] === 'created_at' &&
|
||||
keys[1] === 'schema_version' &&
|
||||
keys[2] === 'task_queue' &&
|
||||
keys[3] === 'workflow_id' &&
|
||||
candidate.schema_version === SCHEMA_VERSION &&
|
||||
typeof workflowId === 'string' &&
|
||||
(workspaceIsWorkflowId || workflowPattern.test(workflowId)) &&
|
||||
typeof candidate.task_queue === 'string' &&
|
||||
/^shannon-[0-9a-f]{8}$/.test(candidate.task_queue) &&
|
||||
expectedFilename === `launch-${encodeURIComponent(candidate.task_queue)}.json` &&
|
||||
typeof candidate.created_at === 'string' &&
|
||||
!Number.isNaN(Date.parse(candidate.created_at)) &&
|
||||
new Date(candidate.created_at).toISOString() === candidate.created_at
|
||||
);
|
||||
}
|
||||
|
||||
/** Read every outstanding launch candidate, preserving corrupt records as an explicit failure count. */
|
||||
export function readPendingWorkflowIdentities(workspacePath: string): PendingWorkflowReadResult {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = fs.readdirSync(pendingDir(workspacePath)).filter((entry) => entry.endsWith('.json'));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { identities: [], unreadableCount: 0 };
|
||||
return { identities: [], unreadableCount: 1 };
|
||||
}
|
||||
|
||||
const identities: PendingWorkflowIdentity[] = [];
|
||||
let unreadableCount = 0;
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
const value: unknown = JSON.parse(fs.readFileSync(path.join(pendingDir(workspacePath), entry), 'utf8'));
|
||||
if (!isPendingWorkflowIdentity(value, path.basename(workspacePath), entry)) {
|
||||
unreadableCount++;
|
||||
continue;
|
||||
}
|
||||
identities.push(value);
|
||||
} catch {
|
||||
unreadableCount++;
|
||||
}
|
||||
}
|
||||
// Atomic-write temp files are intentionally ignored: start cannot spawn Docker until the
|
||||
// final .json rename and fsync above have both completed.
|
||||
return { identities, unreadableCount };
|
||||
}
|
||||
+285
-20
@@ -8,8 +8,17 @@
|
||||
*/
|
||||
|
||||
import type { RunningAgent } from '../temporal-client.js';
|
||||
import { agentClass, PIPELINE, type PipelineState } from './pipeline.js';
|
||||
import {
|
||||
AGENTIC_SAST_STAGE_ORDER,
|
||||
agentClass,
|
||||
isModelBackedOperation,
|
||||
type OperationalStageState,
|
||||
operationFamilyKey,
|
||||
type PipelineState,
|
||||
pipelineForState,
|
||||
} from './pipeline.js';
|
||||
import type { RenderInput } from './render.js';
|
||||
import { safeFailureDetail, safeOperationKey, safeOperationLabel } from './safe-fields.js';
|
||||
|
||||
export type RunState = 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
|
||||
|
||||
@@ -22,14 +31,33 @@ export interface DerivedAgent {
|
||||
readonly durationMs: number | null;
|
||||
readonly runningElapsedMs: number | null;
|
||||
readonly attempt: number | null;
|
||||
/** The step a running operation row is currently on, merged in from its child activity. */
|
||||
readonly detail?: string;
|
||||
/** Reconciliation time for this agent's class, rendered as a trailing `+ duration`.
|
||||
* Reconciliation is model work that produces this agent's inputs, so it is shown
|
||||
* attached to the agent it feeds rather than as free-floating background work. */
|
||||
readonly attachedMs?: number;
|
||||
/** This class's findings could not be grouped, so each one became its own task. */
|
||||
readonly ungrouped?: boolean;
|
||||
readonly error?: string;
|
||||
}
|
||||
|
||||
/** How a phase line summarizes itself: its own wall time, or a k/N tally over its children. */
|
||||
export type PhaseMetaKind = 'duration' | 'count';
|
||||
|
||||
export interface DerivedPhase {
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly parallel: boolean;
|
||||
/** Whether the phase renders its agents as sub-rows. Independent of {@link meta}:
|
||||
* Agentic SAST lists its stages under a duration, exploitation lists its classes under a tally. */
|
||||
readonly children: boolean;
|
||||
readonly meta: PhaseMetaKind;
|
||||
readonly state: RunState;
|
||||
/** The phase's own span, when the worker records one for the phase rather than for a single
|
||||
* agent inside it (Agentic SAST). The phase line presents this exactly like an agent row. */
|
||||
readonly summary?: DerivedAgent;
|
||||
/** Rendered after the phase's summary, e.g. to mark work that overlaps other phases. */
|
||||
readonly note?: string;
|
||||
readonly agents: readonly DerivedAgent[];
|
||||
}
|
||||
|
||||
@@ -38,8 +66,25 @@ export function isTerminal(status: string): boolean {
|
||||
return status !== 'RUNNING' && status !== 'UNSPECIFIED';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the class-level failure recorded for this agent's class applies to this agent.
|
||||
*
|
||||
* A class failure is recorded against the class as a whole, so it matches both of that class's
|
||||
* agents. A reconciliation failure, though, happens only after the analysis agent has already
|
||||
* succeeded, so it belongs to the exploitation lane: attributing it to the analysis row as well
|
||||
* would report an agent that completed as failed.
|
||||
*/
|
||||
function classFailureApplies(name: string, state: PipelineState | null): boolean {
|
||||
if (!state) return false;
|
||||
const vulnClass = agentClass(name);
|
||||
if (!state.failedPipelines.some((f) => f.vulnType === vulnClass)) return false;
|
||||
const reconciliationFailed = (state.failedReconciliations ?? []).some((r) => r.vulnerabilityClass === vulnClass);
|
||||
const isAnalysisAgent = name.endsWith('-vuln');
|
||||
return !(reconciliationFailed && isAnalysisAgent);
|
||||
}
|
||||
|
||||
function isFailedAgent(name: string, state: PipelineState | null): boolean {
|
||||
return !!state && (state.failedAgent === name || state.failedPipelines.some((f) => f.vulnType === agentClass(name)));
|
||||
return !!state && (state.failedAgent === name || classFailureApplies(name, state));
|
||||
}
|
||||
|
||||
/** An agent has entered play once it is running, has metrics, or has failed. */
|
||||
@@ -48,12 +93,12 @@ function isAgentActive(name: string, state: PipelineState | null, running: Set<s
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one agent's state. "Ran" is signalled by a metrics entry, not by
|
||||
* completedAgents — the workflow lists conditionally-skipped agents (e.g. exploit
|
||||
* agents when there is nothing to exploit) as completed but records no metrics for
|
||||
* them. `resolved` is true once we've moved past this agent's phase (the scan is
|
||||
* terminal, or a later phase is already active), at which point a metric-less,
|
||||
* non-running agent is skipped rather than still pending.
|
||||
* Resolve one agent's state. "Ran" is signalled by a metrics entry: a
|
||||
* conditionally-skipped agent (e.g. an exploit agent when there is nothing to
|
||||
* exploit) records no metrics, and the workflow tracks it in skippedAgents rather
|
||||
* than completedAgents. `resolved` is true once we've moved past this agent's phase
|
||||
* (the scan is terminal, or a later phase is already active), at which point a
|
||||
* metric-less, non-running agent is skipped rather than still pending.
|
||||
*/
|
||||
function agentState(name: string, state: PipelineState | null, running: Set<string>, resolved: boolean): RunState {
|
||||
if (running.has(name)) return 'running';
|
||||
@@ -62,13 +107,15 @@ function agentState(name: string, state: PipelineState | null, running: Set<stri
|
||||
return resolved ? 'skipped' : 'pending';
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the presence of a class failure is used here, never its `.error` text: that string is
|
||||
* the worker's raw error for the failed class, not vetted for display, so it is reduced to
|
||||
* a boolean before reaching safeFailureDetail's fixed sentence.
|
||||
*/
|
||||
function agentError(name: string, state: PipelineState | null, byAgent: Map<string, RunningAgent>): string | undefined {
|
||||
const failed = state?.failedPipelines.find((f) => f.vulnType === agentClass(name));
|
||||
return (
|
||||
failed?.error ??
|
||||
byAgent.get(name)?.lastFailure ??
|
||||
(state?.failedAgent === name ? (state.error ?? undefined) : undefined)
|
||||
);
|
||||
const hasFailure =
|
||||
classFailureApplies(name, state) || byAgent.get(name)?.lastFailure !== undefined || state?.failedAgent === name;
|
||||
return safeFailureDetail(hasFailure);
|
||||
}
|
||||
|
||||
/** Scan wall-clock elapsed ms: recorded duration for a closed scan, live elapsed for a running one. */
|
||||
@@ -99,16 +146,17 @@ export function phaseGlyphState(states: readonly RunState[]): RunState {
|
||||
* class had anything to exploit), not still pending.
|
||||
*/
|
||||
export function deriveAgentStates(input: RenderInput): Map<string, RunState> {
|
||||
const runningSet = new Set(input.running.map((r) => r.agent));
|
||||
const pipeline = pipelineForState(input.state);
|
||||
const runningSet = new Set(input.running.filter((runner) => runner.kind === 'agent').map((runner) => runner.agent));
|
||||
const terminal = isTerminal(input.temporalStatus);
|
||||
|
||||
let frontier = -1;
|
||||
PIPELINE.forEach((phase, idx) => {
|
||||
pipeline.forEach((phase, idx) => {
|
||||
if (phase.agents.some((a) => isAgentActive(a.name, input.state, runningSet))) frontier = idx;
|
||||
});
|
||||
|
||||
const states = new Map<string, RunState>();
|
||||
for (const [phaseIdx, phase] of PIPELINE.entries()) {
|
||||
for (const [phaseIdx, phase] of pipeline.entries()) {
|
||||
const resolved = terminal || phaseIdx < frontier;
|
||||
for (const agent of phase.agents) {
|
||||
states.set(agent.name, agentState(agent.name, input.state, runningSet, resolved));
|
||||
@@ -117,6 +165,52 @@ export function deriveAgentStates(input: RenderInput): Map<string, RunState> {
|
||||
return states;
|
||||
}
|
||||
|
||||
/** Which operation families have a running parent stage, and the step to show on it. */
|
||||
interface OperationFamilyView {
|
||||
/** Families whose parent stage row already represents their child activities. */
|
||||
readonly runningFamilies: ReadonlySet<string>;
|
||||
/** Family to current step, present only where the child activities agree on one. */
|
||||
readonly stepByFamily: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the parent stage rows that own their family's child activities. A family only
|
||||
* resolves to a step when its running children agree: several classes reconcile at once and
|
||||
* their pending activities carry no class, so a family caught mid-stride shows its parent
|
||||
* rows without a step rather than attributing one to the wrong class.
|
||||
*/
|
||||
function operationFamilyView(
|
||||
running: readonly RunningAgent[],
|
||||
persistedOperations: readonly OperationalStageState[],
|
||||
): OperationFamilyView {
|
||||
const runningFamilies = new Set(
|
||||
persistedOperations
|
||||
.filter((operation) => operation.status === 'running')
|
||||
.map((operation) => operationFamilyKey(operation.key)),
|
||||
);
|
||||
|
||||
const labelsByFamily = new Map<string, Set<string>>();
|
||||
for (const runner of running) {
|
||||
if (runner.kind !== 'operation' || runner.parentKey === undefined) continue;
|
||||
if (!runningFamilies.has(runner.parentKey)) continue;
|
||||
const labels = labelsByFamily.get(runner.parentKey) ?? new Set<string>();
|
||||
labels.add(runner.label);
|
||||
labelsByFamily.set(runner.parentKey, labels);
|
||||
}
|
||||
|
||||
const stepByFamily = new Map<string, string>();
|
||||
for (const [family, labels] of labelsByFamily) {
|
||||
const [onlyLabel] = labels;
|
||||
if (labels.size === 1 && onlyLabel !== undefined) stepByFamily.set(family, lowercaseFirst(onlyLabel));
|
||||
}
|
||||
return { runningFamilies, stepByFamily };
|
||||
}
|
||||
|
||||
/** Progress labels are written to start a row; as a detail they continue a sentence. */
|
||||
function lowercaseFirst(label: string): string {
|
||||
return label.charAt(0).toLowerCase() + label.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full structured view of the pipeline: every agent's state plus the raw
|
||||
* metrics/timing needed to present it, and each phase's collapsed state.
|
||||
@@ -124,8 +218,9 @@ export function deriveAgentStates(input: RenderInput): Map<string, RunState> {
|
||||
export function derivePipeline(input: RenderInput, now: number): DerivedPhase[] {
|
||||
const states = deriveAgentStates(input);
|
||||
const byAgent = new Map(input.running.map((r) => [r.agent, r]));
|
||||
const pipeline = pipelineForState(input.state);
|
||||
|
||||
return PIPELINE.map((phase) => {
|
||||
const agentPhases = pipeline.map((phase) => {
|
||||
const agents = phase.agents.map((a): DerivedAgent => {
|
||||
const state = states.get(a.name) ?? 'pending';
|
||||
const metrics = input.state?.agentMetrics[a.name];
|
||||
@@ -145,11 +240,181 @@ export function derivePipeline(input: RenderInput, now: number): DerivedPhase[]
|
||||
return {
|
||||
key: phase.key,
|
||||
label: phase.label,
|
||||
parallel: phase.parallel,
|
||||
children: phase.parallel,
|
||||
meta: phase.parallel ? ('count' as const) : ('duration' as const),
|
||||
state: phaseGlyphState(agents.map((ag) => ag.state)),
|
||||
agents,
|
||||
};
|
||||
});
|
||||
|
||||
// Operational rows merge two sources: stages the worker has persisted (durable truth,
|
||||
// including terminal outcomes) and pending activities whose stage record has not landed
|
||||
// yet. Persisted keys win, so a stage is never listed twice while the two views overlap.
|
||||
const persistedOperations = Object.values(input.state?.operationalStages ?? {});
|
||||
const persistedKeys = new Set(persistedOperations.map((operation) => operation.key));
|
||||
const { runningFamilies, stepByFamily } = operationFamilyView(input.running, persistedOperations);
|
||||
const unpersistedRunning = input.running
|
||||
.filter((runner) => runner.kind === 'operation' && !persistedKeys.has(runner.agent))
|
||||
// A child activity whose family already has a running parent stage is that stage's current
|
||||
// step, not separate work: the parent row below represents it, with the step as its detail
|
||||
// where the family's children agree on one. Without such a parent it keeps its own row.
|
||||
.filter((runner) => runner.parentKey === undefined || !runningFamilies.has(runner.parentKey))
|
||||
.map((runner) => ({
|
||||
key: runner.agent,
|
||||
label: runner.label,
|
||||
status: 'running' as const,
|
||||
...(runner.startedAt !== undefined && { startedAt: runner.startedAt }),
|
||||
...(runner.lastFailure !== undefined && { error: safeFailureDetail(true) }),
|
||||
}));
|
||||
const operationalAgents: DerivedAgent[] = [...persistedOperations, ...unpersistedRunning].map((operation) => {
|
||||
const runner = byAgent.get(operation.key);
|
||||
const operationState = operation.status as RunState;
|
||||
const persistedDurationMs = 'durationMs' in operation ? (operation.durationMs ?? null) : null;
|
||||
const detail = operationState === 'running' ? stepByFamily.get(operationFamilyKey(operation.key)) : undefined;
|
||||
return {
|
||||
name: safeOperationKey(operation.key),
|
||||
label: safeOperationLabel(operation.label),
|
||||
state: operationState,
|
||||
durationMs: operationState === 'completed' ? persistedDurationMs : null,
|
||||
runningElapsedMs:
|
||||
operationState === 'running' && operation.startedAt !== undefined ? now - operation.startedAt : null,
|
||||
attempt: operationState === 'running' ? (runner?.attempt ?? null) : null,
|
||||
...(detail !== undefined && { detail }),
|
||||
...(operation.error !== undefined && { error: safeFailureDetail(true) }),
|
||||
};
|
||||
});
|
||||
|
||||
// Operational rows are not peers of the agents. Each one is either model work that
|
||||
// belongs to an agent (reconciliation), model work that belongs to the SAST engine
|
||||
// (its stages), or bookkeeping that only earns a row when it is stuck or broken.
|
||||
return assemblePhases(agentPhases, operationalAgents);
|
||||
}
|
||||
|
||||
/** Reconciliation wall time per vulnerability class, plus the classes whose grouping degraded. */
|
||||
interface ReconciliationView {
|
||||
readonly durationByClass: ReadonlyMap<string, number>;
|
||||
readonly ungroupedClasses: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
function reconciliationView(operations: readonly DerivedAgent[]): ReconciliationView {
|
||||
const durationByClass = new Map<string, number>();
|
||||
const ungroupedClasses = new Set<string>();
|
||||
for (const operation of operations) {
|
||||
if (operationFamilyKey(operation.name) !== 'reconciliation') continue;
|
||||
const [, vulnerabilityClass] = operation.name.split(':');
|
||||
if (vulnerabilityClass === undefined) continue;
|
||||
if (operation.name.endsWith(':fallback')) {
|
||||
ungroupedClasses.add(vulnerabilityClass);
|
||||
continue;
|
||||
}
|
||||
if (operation.durationMs !== null) durationByClass.set(vulnerabilityClass, operation.durationMs);
|
||||
}
|
||||
return { durationByClass, ungroupedClasses };
|
||||
}
|
||||
|
||||
/** Attach each class's reconciliation time to the agent row it feeds. */
|
||||
function withReconciliation(phase: DerivedPhase, view: ReconciliationView): DerivedPhase {
|
||||
const agents = phase.agents.map((agent): DerivedAgent => {
|
||||
const vulnerabilityClass = agentClass(agent.name);
|
||||
const attachedMs = view.durationByClass.get(vulnerabilityClass);
|
||||
const ungrouped = view.ungroupedClasses.has(vulnerabilityClass);
|
||||
return {
|
||||
...agent,
|
||||
...(attachedMs !== undefined && { attachedMs }),
|
||||
...(ungrouped && { ungrouped }),
|
||||
};
|
||||
});
|
||||
return { ...phase, agents };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Agentic SAST phase from the aggregate span the parent workflow records and the
|
||||
* per-stage rows the SAST child signals up. Scans that predate stage signalling have the
|
||||
* aggregate but no stages, and render as a bare phase line rather than an error.
|
||||
*/
|
||||
function agenticSastPhase(operations: readonly DerivedAgent[]): DerivedPhase | undefined {
|
||||
const aggregate = operations.find((operation) => operation.name === 'agentic-sast');
|
||||
if (aggregate === undefined) return undefined;
|
||||
|
||||
const byStage = new Map<string, DerivedAgent>();
|
||||
for (const operation of operations) {
|
||||
const [family, stage] = operation.name.split(':');
|
||||
if (family !== 'agentic-sast' || stage === undefined) continue;
|
||||
// The worker's label is the scan log's Title Case form. These rows sit beside the
|
||||
// lowercase class rows below them, so they read in the same register here.
|
||||
byStage.set(stage, { ...operation, label: lowercaseFirst(operation.label) });
|
||||
}
|
||||
// Run order, not insertion order: a resumed or replayed run can persist stages out of order.
|
||||
const stages = AGENTIC_SAST_STAGE_ORDER.map((stage) => byStage.get(stage)).filter(
|
||||
(stage): stage is DerivedAgent => stage !== undefined,
|
||||
);
|
||||
|
||||
return {
|
||||
key: 'agentic-sast',
|
||||
label: 'Agentic SAST',
|
||||
children: stages.length > 0,
|
||||
meta: 'duration',
|
||||
state: aggregate.state,
|
||||
summary: aggregate,
|
||||
// It shares wall time with the pentest phases below it, so the times do not add up
|
||||
// in sequence. Saying so is cheaper than a layout that pretends to be two columns.
|
||||
note: 'concurrent',
|
||||
agents: stages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookkeeping rows worth showing. A deterministic stage that has completed says nothing —
|
||||
* it can only ever read 0s — but one that is still running, or that failed, is exactly what
|
||||
* an operator needs to see, so those keep a row under the phase they belong to.
|
||||
*/
|
||||
function troubledReportSteps(operations: readonly DerivedAgent[]): readonly DerivedAgent[] {
|
||||
return operations.filter((operation) => {
|
||||
if (isModelBackedOperation(operation.name)) return false;
|
||||
if (operationFamilyKey(operation.name) !== 'report') return false;
|
||||
return operation.state === 'running' || operation.state === 'failed';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold operational rows into the agent phases. Nothing here becomes a bucket of its own:
|
||||
* every surviving row is either a SAST stage, time attached to an agent, or a report step
|
||||
* that is currently in trouble.
|
||||
*/
|
||||
function assemblePhases(agentPhases: readonly DerivedPhase[], operations: readonly DerivedAgent[]): DerivedPhase[] {
|
||||
const view = reconciliationView(operations);
|
||||
// Reconciliation produces the exploitation queue, so its time belongs on the exploitation
|
||||
// row it feeds. With exploitation off there is no such row, and it falls back to the
|
||||
// analysis row for the same class so the time is never silently dropped.
|
||||
const attachTo = agentPhases.some((phase) => phase.key === 'exploitation')
|
||||
? 'exploitation'
|
||||
: 'vulnerability-analysis';
|
||||
const reportSteps = troubledReportSteps(operations);
|
||||
|
||||
const phases = agentPhases.map((phase) => {
|
||||
if (phase.key === attachTo) return withReconciliation(phase, view);
|
||||
if (phase.key === 'reporting' && reportSteps.length > 0) {
|
||||
// The report agent stays on the phase line it already titles; the steps in trouble
|
||||
// become its children, so nothing is listed twice.
|
||||
const summary = phase.agents[0];
|
||||
return {
|
||||
...phase,
|
||||
children: true,
|
||||
...(summary !== undefined && { summary }),
|
||||
state: phaseGlyphState([...phase.agents, ...reportSteps].map((row) => row.state)),
|
||||
agents: reportSteps,
|
||||
};
|
||||
}
|
||||
return phase;
|
||||
});
|
||||
|
||||
const sast = agenticSastPhase(operations);
|
||||
if (sast === undefined) return phases;
|
||||
|
||||
// Agentic SAST starts with the scan and runs alongside the pentest, so it reads after
|
||||
// the login check rather than appended past Reporting where it never ran.
|
||||
const afterAuth = phases.findIndex((phase) => phase.key === 'auth-validation') + 1;
|
||||
return [...phases.slice(0, afterAuth), sast, ...phases.slice(afterAuth)];
|
||||
}
|
||||
|
||||
export { agentError };
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* - apps/worker/src/temporal/activities.ts (the run*Agent activity names → `activityType`)
|
||||
* - apps/worker/src/temporal/shared.ts (PipelineState / PipelineSummary)
|
||||
* - apps/worker/src/types/metrics.ts (AgentMetrics)
|
||||
* - apps/worker/src/types/run-state.ts (PartialReasonView)
|
||||
*/
|
||||
|
||||
export interface AgentSpec {
|
||||
@@ -26,6 +27,18 @@ export interface PhaseSpec {
|
||||
readonly agents: readonly AgentSpec[];
|
||||
}
|
||||
|
||||
export interface ActivityProgressSpec {
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly kind: 'agent' | 'operation';
|
||||
/**
|
||||
* Operation rows whose work is already represented by a persisted parent stage. The parent
|
||||
* owns the row; this activity supplies the step shown as its detail. Parent stage keys are
|
||||
* the family key itself or the family key followed by ':' and a class or stage suffix.
|
||||
*/
|
||||
readonly parentKey?: string;
|
||||
}
|
||||
|
||||
/** The pipeline phases in execution order, each with its agents. */
|
||||
export const PIPELINE: readonly PhaseSpec[] = [
|
||||
{
|
||||
@@ -80,9 +93,183 @@ export const PIPELINE: readonly PhaseSpec[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/** Temporal activity type name → canonical agent name, for mapping pendingActivities. */
|
||||
const MISCELLANEOUS_EXPLOIT_AGENT: AgentSpec = {
|
||||
name: 'miscellaneous-exploit',
|
||||
label: 'miscellaneous',
|
||||
activityType: 'runMiscellaneousExploitAgent',
|
||||
};
|
||||
|
||||
/**
|
||||
* Shape the static PIPELINE to one scan's durable truth. expectedAgents, persisted by the
|
||||
* worker at scan start, names every exploit agent the scan can ever run: exploit rows it
|
||||
* excludes are dropped, 'miscellaneous-exploit' is appended only once the miscellaneous pipeline has
|
||||
* admitted findings, and a phase left with no agents disappears entirely. Without state
|
||||
* (the scan has not initialized durable state yet) the full static pipeline is the best
|
||||
* available guess.
|
||||
*/
|
||||
export function pipelineForState(state: PipelineState | null): readonly PhaseSpec[] {
|
||||
if (state?.expectedAgents === undefined) return PIPELINE;
|
||||
const expected = new Set(state.expectedAgents);
|
||||
return PIPELINE.map((phase) => {
|
||||
if (phase.key !== 'exploitation') return phase;
|
||||
const agents = phase.agents.filter((agent) => expected.has(agent.name));
|
||||
if (expected.has(MISCELLANEOUS_EXPLOIT_AGENT.name)) agents.push(MISCELLANEOUS_EXPLOIT_AGENT);
|
||||
return { ...phase, agents };
|
||||
}).filter((phase) => phase.agents.length > 0);
|
||||
}
|
||||
|
||||
const AGENT_ACTIVITY_PROGRESS: Readonly<Record<string, ActivityProgressSpec>> = Object.fromEntries(
|
||||
[...PIPELINE.flatMap((phase) => phase.agents), MISCELLANEOUS_EXPLOIT_AGENT].map((agent) => [
|
||||
agent.activityType,
|
||||
{ key: agent.name, label: agent.label, kind: 'agent' },
|
||||
]),
|
||||
);
|
||||
|
||||
/** Families whose per-class or per-stage work is already carried by one persisted stage row. */
|
||||
const RECONCILIATION_PARENT_KEY = 'reconciliation';
|
||||
const AGENTIC_SAST_PARENT_KEY = 'agentic-sast';
|
||||
|
||||
// Every production activity that is not an agent run must have a row here. describeScan
|
||||
// throws on an unmapped activity type, so adding a worker activity without updating this
|
||||
// table breaks `shannon status` loudly instead of hiding the new work. The authoritative
|
||||
// name lists live in apps/worker/src/temporal/worker.ts,
|
||||
// apps/worker/src/temporal/reconcile-activity-types.ts, and
|
||||
// apps/worker/src/ai/sast/capella/temporal/activity-types.ts.
|
||||
const OPERATION_ACTIVITY_PROGRESS: Readonly<Record<string, ActivityProgressSpec>> = {
|
||||
runPreflightValidation: { key: 'preflight', label: 'Preflight validation', kind: 'operation' },
|
||||
syncPlaywrightStealthConfig: { key: 'preflight', label: 'Browser setup', kind: 'operation' },
|
||||
initDeliverableGit: { key: 'scan-initialization', label: 'Initialize deliverables', kind: 'operation' },
|
||||
syncCodePathDenyRules: { key: 'scan-initialization', label: 'Apply source rules', kind: 'operation' },
|
||||
initializeDurableScanState: { key: 'durable-state', label: 'Saving scan state', kind: 'operation' },
|
||||
persistMiscellaneousOutcome: {
|
||||
key: 'miscellaneous-pipeline',
|
||||
label: 'Including miscellaneous findings',
|
||||
kind: 'operation',
|
||||
},
|
||||
initializeReportProgress: { key: 'report:initialize', label: 'Initialize report state', kind: 'operation' },
|
||||
renumberClassFindings: { key: 'report:renumber', label: 'Renumber findings', kind: 'operation' },
|
||||
assembleReportActivity: { key: 'report:assemble', label: 'Assemble report inputs', kind: 'operation' },
|
||||
compactReportFindings: { key: 'report:compact', label: 'Compact report findings', kind: 'operation' },
|
||||
persistCanonicalReportProgress: { key: 'report:checkpoint', label: 'Saving report progress', kind: 'operation' },
|
||||
finalizeReportOutputs: { key: 'report:finalize', label: 'Finalize report outputs', kind: 'operation' },
|
||||
persistFinalizedReportProgress: { key: 'report:terminal', label: 'Saving final report state', kind: 'operation' },
|
||||
surfaceReportOutputs: { key: 'report:surface', label: 'Surface customer report', kind: 'operation' },
|
||||
checkExploitationQueue: { key: 'queue-check', label: 'Check exploitation queue', kind: 'operation' },
|
||||
loadResumeState: { key: 'resume-validation', label: 'Validate resume state', kind: 'operation' },
|
||||
restoreGitCheckpoint: { key: 'resume-restore', label: 'Restore checkpoint', kind: 'operation' },
|
||||
registerResumeAttempt: { key: 'resume-registration', label: 'Register resume', kind: 'operation' },
|
||||
recordResumeAttempt: { key: 'resume-registration', label: 'Record resume', kind: 'operation' },
|
||||
logPhaseTransition: { key: 'audit-log', label: 'Update audit log', kind: 'operation' },
|
||||
logWorkflowComplete: { key: 'audit-log', label: 'Finalize audit log', kind: 'operation' },
|
||||
saveCheckpoint: { key: 'checkpoint', label: 'Save checkpoint', kind: 'operation' },
|
||||
seedEmptyProducerQueue: {
|
||||
key: 'miscellaneous-pipeline',
|
||||
label: 'Preparing miscellaneous findings',
|
||||
kind: 'operation',
|
||||
},
|
||||
prepareClassReconciliation: {
|
||||
key: 'reconciliation',
|
||||
label: 'Preparing findings',
|
||||
kind: 'operation',
|
||||
parentKey: RECONCILIATION_PARENT_KEY,
|
||||
},
|
||||
enrichClassSastObservations: {
|
||||
key: 'reconciliation',
|
||||
label: 'Adding code context',
|
||||
kind: 'operation',
|
||||
parentKey: RECONCILIATION_PARENT_KEY,
|
||||
},
|
||||
formClassExploitTasks: {
|
||||
key: 'reconciliation',
|
||||
label: 'Grouping into test cases',
|
||||
kind: 'operation',
|
||||
parentKey: RECONCILIATION_PARENT_KEY,
|
||||
},
|
||||
materializeClassExploitTasks: {
|
||||
key: 'reconciliation',
|
||||
label: 'Writing test cases',
|
||||
kind: 'operation',
|
||||
parentKey: RECONCILIATION_PARENT_KEY,
|
||||
},
|
||||
publishClassReconciliationOss: {
|
||||
key: 'reconciliation',
|
||||
label: 'Saving results',
|
||||
kind: 'operation',
|
||||
parentKey: RECONCILIATION_PARENT_KEY,
|
||||
},
|
||||
capellaArchitecture: {
|
||||
key: 'agentic-sast:architecture',
|
||||
label: 'Mapping architecture',
|
||||
kind: 'operation',
|
||||
parentKey: AGENTIC_SAST_PARENT_KEY,
|
||||
},
|
||||
capellaThreatModel: {
|
||||
key: 'agentic-sast:threat-model',
|
||||
label: 'Modelling threats',
|
||||
kind: 'operation',
|
||||
parentKey: AGENTIC_SAST_PARENT_KEY,
|
||||
},
|
||||
capellaPlan: {
|
||||
key: 'agentic-sast:plan',
|
||||
label: 'Planning the review',
|
||||
kind: 'operation',
|
||||
parentKey: AGENTIC_SAST_PARENT_KEY,
|
||||
},
|
||||
capellaResearch: {
|
||||
key: 'agentic-sast:research',
|
||||
label: 'Researching code',
|
||||
kind: 'operation',
|
||||
parentKey: AGENTIC_SAST_PARENT_KEY,
|
||||
},
|
||||
capellaDedupe: {
|
||||
key: 'agentic-sast:dedupe',
|
||||
label: 'Merging duplicates',
|
||||
kind: 'operation',
|
||||
parentKey: AGENTIC_SAST_PARENT_KEY,
|
||||
},
|
||||
capellaReview: {
|
||||
key: 'agentic-sast:review',
|
||||
label: 'Reviewing findings',
|
||||
kind: 'operation',
|
||||
parentKey: AGENTIC_SAST_PARENT_KEY,
|
||||
},
|
||||
capellaCritic: {
|
||||
key: 'agentic-sast:critic',
|
||||
label: 'Critiquing findings',
|
||||
kind: 'operation',
|
||||
parentKey: AGENTIC_SAST_PARENT_KEY,
|
||||
},
|
||||
capellaConfirm: {
|
||||
key: 'agentic-sast:confirm',
|
||||
label: 'Confirming findings',
|
||||
kind: 'operation',
|
||||
parentKey: AGENTIC_SAST_PARENT_KEY,
|
||||
},
|
||||
capellaCalibrate: {
|
||||
key: 'agentic-sast:calibrate',
|
||||
label: 'Calibrating risk',
|
||||
kind: 'operation',
|
||||
parentKey: AGENTIC_SAST_PARENT_KEY,
|
||||
},
|
||||
capellaExport: {
|
||||
key: 'agentic-sast:export',
|
||||
label: 'Exporting findings',
|
||||
kind: 'operation',
|
||||
parentKey: AGENTIC_SAST_PARENT_KEY,
|
||||
},
|
||||
};
|
||||
|
||||
/** Complete production activity mirror. Unknown names are errors, never hidden progress. */
|
||||
export const ACTIVITY_TO_PROGRESS: Readonly<Record<string, ActivityProgressSpec>> = Object.freeze({
|
||||
...AGENT_ACTIVITY_PROGRESS,
|
||||
...OPERATION_ACTIVITY_PROGRESS,
|
||||
});
|
||||
|
||||
/** Agent-only projection of ACTIVITY_TO_PROGRESS: activity type name to canonical agent name. */
|
||||
export const ACTIVITY_TO_AGENT: Readonly<Record<string, string>> = Object.fromEntries(
|
||||
PIPELINE.flatMap((phase) => phase.agents.map((agent) => [agent.activityType, agent.name])),
|
||||
Object.entries(ACTIVITY_TO_PROGRESS)
|
||||
.filter(([, progress]) => progress.kind === 'agent')
|
||||
.map(([activityType, progress]) => [activityType, progress.key]),
|
||||
);
|
||||
|
||||
/** The vuln/exploit class of an agent (e.g. "authz-vuln" → "authz"), for failedPipelines matching. */
|
||||
@@ -100,11 +287,65 @@ export interface AgentMetrics {
|
||||
readonly skipped?: boolean;
|
||||
}
|
||||
|
||||
export interface OperationalStageState {
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
|
||||
readonly startedAt?: number;
|
||||
readonly durationMs?: number;
|
||||
readonly error?: string;
|
||||
}
|
||||
|
||||
/** Family key a persisted operational stage belongs to, e.g. `reconciliation:xss` to `reconciliation`. */
|
||||
export function operationFamilyKey(stageKey: string): string {
|
||||
const separator = stageKey.indexOf(':');
|
||||
return separator === -1 ? stageKey : stageKey.slice(0, separator);
|
||||
}
|
||||
|
||||
/** The Capella stages that get a progress row, in run order. Mirrors CAPELLA_PROGRESS_STAGES
|
||||
* in apps/worker/src/ai/sast/types.ts — the deterministic `export` stage is not among them. */
|
||||
export const AGENTIC_SAST_STAGE_ORDER: readonly string[] = [
|
||||
'architecture',
|
||||
'threat-model',
|
||||
'plan',
|
||||
'research',
|
||||
'dedupe',
|
||||
'review',
|
||||
'critic',
|
||||
'confirm',
|
||||
'calibrate',
|
||||
];
|
||||
|
||||
/**
|
||||
* Whether an operational stage represents model work rather than bookkeeping.
|
||||
*
|
||||
* Only the agentic-SAST stages and per-class reconciliation run a model; every other
|
||||
* operational stage is a git commit or a durable-state write that can only ever record
|
||||
* sub-second wall time. The progress tree shows model work, so this is what decides
|
||||
* whether a stage is worth a row at all.
|
||||
*/
|
||||
export function isModelBackedOperation(stageKey: string): boolean {
|
||||
const family = operationFamilyKey(stageKey);
|
||||
if (family === 'agentic-sast') return true;
|
||||
// A `reconciliation:<class>:fallback` marker records a degradation, not a model span.
|
||||
return family === 'reconciliation' && !stageKey.endsWith(':fallback');
|
||||
}
|
||||
|
||||
export interface PipelineSummary {
|
||||
readonly totalCostUsd: number;
|
||||
readonly totalDurationMs: number; // Wall-clock (end - start)
|
||||
readonly totalTurns: number;
|
||||
readonly agentCount: number;
|
||||
/** False when operational (Capella/reconciliation) spend is known to be incomplete. */
|
||||
readonly usageAccountingComplete?: boolean;
|
||||
}
|
||||
|
||||
/** One durable degradation reason with its derived safe message (mirror of PartialReasonView). */
|
||||
export interface PartialReasonView {
|
||||
readonly code: string;
|
||||
readonly vulnerabilityClass?: string;
|
||||
readonly stage?: string;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
export type PipelineStatus = 'running' | 'completed' | 'failed' | 'cancelled' | 'partial';
|
||||
@@ -114,10 +355,29 @@ export interface PipelineState {
|
||||
readonly currentPhase: string | null;
|
||||
readonly currentAgent: string | null;
|
||||
readonly completedAgents: string[];
|
||||
readonly expectedAgents?: string[];
|
||||
readonly participatingClasses?: string[];
|
||||
readonly failedPipelines: { vulnType: string; error: string }[];
|
||||
readonly failedReconciliations?: { vulnerabilityClass: string; error: string }[];
|
||||
readonly failedAgent: string | null;
|
||||
readonly error: string | null;
|
||||
readonly startTime: number;
|
||||
readonly agentMetrics: Record<string, AgentMetrics>;
|
||||
readonly operationalMetrics?: Record<string, AgentMetrics>;
|
||||
readonly operationalStages?: Record<string, OperationalStageState>;
|
||||
/** `error` is the worker's sanitized failure sentence, safe to print verbatim. */
|
||||
readonly agenticSast?: {
|
||||
readonly status: string;
|
||||
readonly durationMs?: number;
|
||||
/** Reader-facing name of the failed stage, already projected by the worker. */
|
||||
readonly failedStageLabel?: string;
|
||||
readonly error?: string;
|
||||
readonly errorCode?: string;
|
||||
/** Usage-accounting warnings projected by the worker; empty when the ledger reconciled. */
|
||||
readonly warnings?: readonly string[];
|
||||
};
|
||||
readonly nonFatalFailures?: { readonly phase: string; readonly error: string }[];
|
||||
/** Ordered durable degradation reasons with safe messages; empty or absent for full success. */
|
||||
readonly partialReasons?: readonly PartialReasonView[];
|
||||
readonly summary: PipelineSummary | null;
|
||||
}
|
||||
|
||||
+99
-28
@@ -10,9 +10,9 @@
|
||||
import { BOLD, DIM, GOLD, paint, RED, YELLOW } from '../colors.js';
|
||||
import { commandPrefix } from '../mode.js';
|
||||
import type { RunningAgent } from '../temporal-client.js';
|
||||
import { agentError, deriveAgentStates, isTerminal, phaseGlyphState, type RunState, scanElapsedMs } from './derive.js';
|
||||
import { inlineFailureReason } from './failure.js';
|
||||
import { PIPELINE, type PipelineState } from './pipeline.js';
|
||||
import { derivePipeline, isTerminal, type RunState, scanElapsedMs } from './derive.js';
|
||||
import type { PipelineState } from './pipeline.js';
|
||||
import { safeAgenticSast, safeCliIdentifier, safePartialReasons, safeTerminalFailure } from './safe-fields.js';
|
||||
|
||||
export interface RenderInput {
|
||||
readonly workspace: string;
|
||||
@@ -68,7 +68,7 @@ function truncate(text: string, max: number): string {
|
||||
/** Temporal Web UI, published by compose on 8233; deep-links to the workflow when its id is known. */
|
||||
function temporalDashboardUrl(workflowId: string | undefined): string {
|
||||
const base = 'http://localhost:8233';
|
||||
return workflowId ? `${base}/namespaces/default/workflows/${workflowId}` : base;
|
||||
return workflowId ? `${base}/namespaces/default/workflows/${safeCliIdentifier(workflowId)}` : base;
|
||||
}
|
||||
|
||||
// === Glyphs & status ===
|
||||
@@ -95,6 +95,12 @@ const STATE_COLOR: Record<RunState, string> = {
|
||||
skipped: COLORS.dim,
|
||||
};
|
||||
|
||||
/** Column width for an agent or background-work label inside a phase. */
|
||||
const AGENT_LABEL_WIDTH = 18;
|
||||
|
||||
/** Inline budget for a failure sentence, wide enough to carry a whole first sentence. */
|
||||
const FAILURE_DETAIL_WIDTH = 120;
|
||||
|
||||
/** Braille spinner frames for running agents — the clack loader style. */
|
||||
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const;
|
||||
|
||||
@@ -112,42 +118,68 @@ function statusBadge(input: RenderInput, opts: RenderOptions): string {
|
||||
const workflowStatus = input.state?.status;
|
||||
if (!isTerminal(input.temporalStatus)) return paint('running', COLORS.gold, opts.color);
|
||||
if (workflowStatus === 'partial') return paint('partial', COLORS.yellow, opts.color);
|
||||
if (workflowStatus === 'cancelled') return paint('cancelled', COLORS.yellow, opts.color);
|
||||
if (input.temporalStatus === 'COMPLETED') return paint('completed', COLORS.gold, opts.color);
|
||||
if (input.temporalStatus === 'TERMINATED') return paint('stopped', COLORS.yellow, opts.color);
|
||||
if (input.temporalStatus === 'CANCELLED' || input.temporalStatus === 'CANCELED') {
|
||||
return paint('cancelled', COLORS.yellow, opts.color);
|
||||
}
|
||||
if (input.temporalStatus === 'TIMED_OUT') return paint('timed out', COLORS.red, opts.color);
|
||||
return paint('FAILED', COLORS.red, opts.color);
|
||||
return paint('failed', COLORS.red, opts.color);
|
||||
}
|
||||
|
||||
// === Line builders ===
|
||||
|
||||
/** The parts of a derived row agentMeta reads beyond its state and metrics. */
|
||||
interface RowExtras {
|
||||
readonly runningElapsedMs?: number | null;
|
||||
readonly attachedMs?: number;
|
||||
readonly ungrouped?: boolean;
|
||||
}
|
||||
|
||||
function agentMeta(
|
||||
state: RunState,
|
||||
metrics: { durationMs: number } | undefined,
|
||||
runner: RunningAgent | undefined,
|
||||
error: string | undefined,
|
||||
opts: RenderOptions,
|
||||
step?: string,
|
||||
extras?: RowExtras,
|
||||
): string {
|
||||
if (state === 'completed') {
|
||||
const duration = metrics?.durationMs != null ? formatDuration(metrics.durationMs) : 'done';
|
||||
return paint(duration, COLORS.dim, opts.color);
|
||||
return paint(`${duration}${attachedSuffix(extras)}`, COLORS.dim, opts.color);
|
||||
}
|
||||
if (state === 'running') {
|
||||
const parts = ['running'];
|
||||
if (runner?.startedAt !== undefined) parts.push(formatDuration(opts.now - runner.startedAt));
|
||||
if (step !== undefined) parts.push(step);
|
||||
// An operational row carries its own elapsed time: it is derived from the persisted stage
|
||||
// span, and has no pending activity on the parent workflow to read a start time from.
|
||||
const elapsedMs =
|
||||
runner?.startedAt !== undefined ? opts.now - runner.startedAt : (extras?.runningElapsedMs ?? null);
|
||||
if (elapsedMs !== null) parts.push(formatDuration(elapsedMs));
|
||||
if (runner && runner.attempt > 1) parts.push(`retry ${runner.attempt}`);
|
||||
return paint(parts.join(' · '), COLORS.gold, opts.color);
|
||||
}
|
||||
if (state === 'failed') {
|
||||
const detail = error ? ` · ${truncate(error, 46)}` : '';
|
||||
const detail = error ? ` · ${truncate(error, FAILURE_DETAIL_WIDTH)}` : '';
|
||||
return paint(`failed${detail}`, COLORS.red, opts.color);
|
||||
}
|
||||
if (state === 'skipped') return paint('skipped', COLORS.dim, opts.color);
|
||||
return paint('queued', COLORS.dim, opts.color);
|
||||
}
|
||||
|
||||
/**
|
||||
* Time a reconciliation lane contributed to this agent's class, shown as `+ duration` on the
|
||||
* row it feeds. `ungrouped` marks a class whose findings could not be grouped, so each one
|
||||
* was tested separately and duplicates are expected.
|
||||
*/
|
||||
function attachedSuffix(extras: RowExtras | undefined): string {
|
||||
if (extras === undefined) return '';
|
||||
const time = extras.attachedMs === undefined ? '' : ` + ${formatDuration(extras.attachedMs)}`;
|
||||
return extras.ungrouped ? `${time} · ungrouped` : time;
|
||||
}
|
||||
|
||||
function phaseMeta(states: readonly RunState[], inPlay: number, parallel: boolean, opts: RenderOptions): string {
|
||||
if (states.every((s) => s === 'pending')) return paint('pending', COLORS.dim, opts.color);
|
||||
if (states.every((s) => s === 'skipped')) return paint('skipped', COLORS.dim, opts.color);
|
||||
@@ -163,35 +195,43 @@ function phaseMeta(states: readonly RunState[], inPlay: number, parallel: boolea
|
||||
/** Render the full progress frame as one string (no trailing newline). */
|
||||
export function renderScan(input: RenderInput, opts: RenderOptions): string {
|
||||
const byAgent = new Map(input.running.map((r) => [r.agent, r]));
|
||||
const stateMap = deriveAgentStates(input);
|
||||
const phases = derivePipeline(input, opts.now);
|
||||
const lines: string[] = ['', ...headerLines(input, opts), ''];
|
||||
|
||||
const metaFor = (name: string, state: RunState): string =>
|
||||
agentMeta(state, input.state?.agentMetrics[name], byAgent.get(name), agentError(name, input.state, byAgent), opts);
|
||||
// Only agents that have actually entered play are shown; pending/skipped ones stay hidden.
|
||||
const inPlay = (s: RunState): boolean => s === 'running' || s === 'completed' || s === 'failed';
|
||||
|
||||
for (const phase of PIPELINE) {
|
||||
const states = phase.agents.map((a) => stateMap.get(a.name) ?? 'pending');
|
||||
for (const phase of phases) {
|
||||
const states = phase.agents.map((agent) => agent.state);
|
||||
const playing = states.filter(inPlay).length;
|
||||
const phaseRunState: RunState = phaseGlyphState(states);
|
||||
const phaseRunState = phase.state;
|
||||
const metaFor = (agent: (typeof phase.agents)[number]): string => {
|
||||
const metrics = agent.durationMs === null ? undefined : { durationMs: agent.durationMs };
|
||||
return agentMeta(agent.state, metrics, byAgent.get(agent.name), agent.error, opts, agent.detail, agent);
|
||||
};
|
||||
|
||||
// A single-agent phase carries that agent's own duration/cost on the phase line once it
|
||||
// starts; a parallel phase gets a "k/N done" summary over the agents in play.
|
||||
// A phase summarizes itself by wall time or by a "k/N done" tally. A phase with its own
|
||||
// recorded span (Agentic SAST) presents it like any agent row; otherwise a single-agent
|
||||
// phase borrows its one agent's duration once that agent starts.
|
||||
const first = phase.agents[0];
|
||||
const firstState = states[0];
|
||||
const phaseMetaStr =
|
||||
!phase.parallel && first && firstState && inPlay(firstState)
|
||||
? metaFor(first.name, firstState)
|
||||
: phaseMeta(states, playing, phase.parallel, opts);
|
||||
lines.push(` ${glyph(phaseRunState, opts)} ${phase.label.padEnd(26)}${phaseMetaStr}`);
|
||||
const borrowed = first && firstState && inPlay(firstState) ? metaFor(first) : undefined;
|
||||
const durationMeta = phase.summary === undefined ? borrowed : metaFor(phase.summary);
|
||||
const summaryMeta =
|
||||
phase.meta === 'duration' && durationMeta !== undefined
|
||||
? durationMeta
|
||||
: phaseMeta(states, playing, phase.meta === 'count', opts);
|
||||
const note = phase.note === undefined ? '' : paint(` · ${phase.note}`, COLORS.dim, opts.color);
|
||||
lines.push(` ${glyph(phaseRunState, opts)} ${phase.label.padEnd(26)}${summaryMeta}${note}`);
|
||||
|
||||
if (!phase.parallel) continue;
|
||||
if (!phase.children) continue;
|
||||
for (let i = 0; i < phase.agents.length; i++) {
|
||||
const agent = phase.agents[i];
|
||||
const state = states[i];
|
||||
if (!agent || !state || !inPlay(state)) continue;
|
||||
lines.push(` ${glyph(state, opts)} ${agent.label.padEnd(18)}${metaFor(agent.name, state)}`);
|
||||
// Two trailing spaces before padding, so a label wider than the column still separates
|
||||
// from its meta text; a label inside the column pads to the same width as before.
|
||||
lines.push(` ${glyph(state, opts)} ${`${agent.label} `.padEnd(AGENT_LABEL_WIDTH)}${metaFor(agent)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +242,7 @@ export function renderScan(input: RenderInput, opts: RenderOptions): string {
|
||||
function headerLines(input: RenderInput, opts: RenderOptions): string[] {
|
||||
const elapsedMs = scanElapsedMs(input, opts.now);
|
||||
const meta = [statusBadge(input, opts), elapsedMs !== undefined ? formatDuration(elapsedMs) : '—'].join(' · ');
|
||||
return [` ${paint('Scan:', COLORS.bold, opts.color)} ${input.workspace.padEnd(22)} ${meta}`];
|
||||
return [` ${paint('Scan:', COLORS.bold, opts.color)} ${safeCliIdentifier(input.workspace).padEnd(22)} ${meta}`];
|
||||
}
|
||||
|
||||
/** Aligned label column for the footer's Logs / Temporal rows. */
|
||||
@@ -223,15 +263,46 @@ function footerLines(input: RenderInput, opts: RenderOptions): string[] {
|
||||
|
||||
if (isTerminal(input.temporalStatus) && input.state?.summary) {
|
||||
const wall = formatDuration(input.state.summary.totalDurationMs);
|
||||
return ['', ` Time Taken ${wall}`];
|
||||
const lines = ['', ` Time Taken ${wall}`];
|
||||
|
||||
// A partial scan names each durable degradation reason through its safe message,
|
||||
// so the operator never has to guess why the badge is not "completed".
|
||||
const reasons = safePartialReasons(input.state.partialReasons ?? []);
|
||||
if (reasons.length > 0) {
|
||||
lines.push('', ` ${paint('Why this scan is partial:', COLORS.yellow, opts.color)}`);
|
||||
for (const reason of reasons) {
|
||||
lines.push(paint(` - ${reason.message}`, COLORS.dim, opts.color));
|
||||
}
|
||||
// The safe message names what degraded; these three name the agentic-SAST failure
|
||||
// behind it, under the same labels the scan log and worker output use.
|
||||
const agenticSast = safeAgenticSast(input.state.agenticSast);
|
||||
if (agenticSast?.status === 'failed') {
|
||||
if (agenticSast.failedStageLabel !== undefined) {
|
||||
lines.push(paint(` Agentic SAST stopped at: ${agenticSast.failedStageLabel}`, COLORS.dim, opts.color));
|
||||
}
|
||||
if (agenticSast.error !== undefined) {
|
||||
lines.push(paint(` What happened: ${agenticSast.error}`, COLORS.dim, opts.color));
|
||||
}
|
||||
if (agenticSast.errorCode !== undefined) {
|
||||
lines.push(paint(` Reference code (for a bug report): ${agenticSast.errorCode}`, COLORS.dim, opts.color));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (input.state.summary.usageAccountingComplete === false) {
|
||||
lines.push(
|
||||
paint(' Cost is incomplete — some background work is not included in this total.', COLORS.dim, opts.color),
|
||||
);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
const logsValue = `${prefix} logs ${input.workspace}`;
|
||||
const logsValue = `${prefix} logs ${safeCliIdentifier(input.workspace)}`;
|
||||
const temporalValue = temporalDashboardUrl(input.workflowId);
|
||||
|
||||
if (isTerminal(input.temporalStatus)) {
|
||||
const rawReason = input.failureMessage ?? input.state?.error;
|
||||
const reason = rawReason ? inlineFailureReason(rawReason) : 'no result recorded';
|
||||
const hasRecordedFailure =
|
||||
input.failureMessage !== undefined || (input.state !== null && input.state.error !== null);
|
||||
const reason = safeTerminalFailure(hasRecordedFailure) ?? 'no result recorded';
|
||||
return [
|
||||
footerDivider(opts),
|
||||
paint(
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Closed-field projection for Temporal values displayed by the CLI.
|
||||
*
|
||||
* PipelineState travels through Temporal from a worker container this process does not
|
||||
* control, so free-text fields are treated as unvetted: this module either matches a
|
||||
* value against a known closed set (safe to print as-is) or collapses it to a fixed,
|
||||
* bounded message. A value with no case here should fail closed to something generic,
|
||||
* never pass through untouched.
|
||||
*/
|
||||
|
||||
import type { PartialReasonView, PipelineState } from './pipeline.js';
|
||||
|
||||
const CLASS_NAMES: Readonly<Record<string, string>> = Object.freeze({
|
||||
injection: 'Injection',
|
||||
xss: 'Cross-Site Scripting',
|
||||
auth: 'Authentication',
|
||||
authz: 'Authorization',
|
||||
ssrf: 'Server-Side Request Forgery',
|
||||
miscellaneous: 'Miscellaneous',
|
||||
});
|
||||
|
||||
const STAGE_NAMES: Readonly<Record<string, string>> = Object.freeze({
|
||||
architecture: 'architecture mapping',
|
||||
'threat-model': 'threat modelling',
|
||||
plan: 'review planning',
|
||||
research: 'deep code research',
|
||||
dedupe: 'duplicate merging',
|
||||
review: 'independent review',
|
||||
critic: 'viability critique',
|
||||
confirm: 'static confirmation',
|
||||
calibrate: 'risk calibration',
|
||||
export: 'findings export',
|
||||
workflow: 'orchestration',
|
||||
});
|
||||
|
||||
const TERMINAL_STAGE_NAMES = new Set([
|
||||
'architecture',
|
||||
'threat model',
|
||||
'planning',
|
||||
'audit wave',
|
||||
'deduplication',
|
||||
'review',
|
||||
'critic',
|
||||
'confirmation',
|
||||
'calibration',
|
||||
'export',
|
||||
'orchestration',
|
||||
]);
|
||||
|
||||
const CAPELLA_FAILURE_MESSAGES = new Set([
|
||||
'Provider authentication failed. Verify the configured credential.',
|
||||
'Agentic SAST configuration is invalid.',
|
||||
'Agentic SAST received invalid input.',
|
||||
'An agentic SAST step returned an unusable result.',
|
||||
'An agentic SAST step failed.',
|
||||
'Agentic SAST infrastructure failed before producing a usable result.',
|
||||
'Agentic SAST had not finished when the scan stopped.',
|
||||
]);
|
||||
|
||||
// Mirrors apps/worker/src/types/errors.ts. The CLI cannot import from the worker package,
|
||||
// so keep this exact closed set in sync with ProviderFailureCategory.
|
||||
const PROVIDER_FAILURE_CATEGORIES = new Set([
|
||||
'rate_limit',
|
||||
'overloaded',
|
||||
'transport',
|
||||
'context_limit',
|
||||
'quota',
|
||||
'authentication',
|
||||
'configuration',
|
||||
'unknown',
|
||||
]);
|
||||
|
||||
function isProviderFailureCategory(value: unknown): value is string {
|
||||
return typeof value === 'string' && PROVIDER_FAILURE_CATEGORIES.has(value);
|
||||
}
|
||||
|
||||
const OPERATION_LABELS = new Set([
|
||||
'Agentic SAST',
|
||||
// Capella stage rows, signalled up from the SAST child workflow. Mirrors
|
||||
// CAPELLA_STAGE_LABELS in apps/worker/src/ai/sast/types.ts, minus the deterministic
|
||||
// export stage, which never becomes a row.
|
||||
'Architecture',
|
||||
'Threat model',
|
||||
'Plan',
|
||||
'Research',
|
||||
'Dedupe',
|
||||
'Review',
|
||||
'Critique',
|
||||
'Confirm',
|
||||
'Calibrate',
|
||||
'Reconcile injection',
|
||||
'Reconcile xss',
|
||||
'Reconcile auth',
|
||||
'Reconcile authz',
|
||||
'Reconcile ssrf',
|
||||
'Reconcile miscellaneous',
|
||||
'Prepare reconciliation',
|
||||
'Enrich observations',
|
||||
'Form exploit tasks',
|
||||
'Materialize exploit tasks',
|
||||
'Publish reconciliation',
|
||||
'Renumber injection',
|
||||
'Renumber xss',
|
||||
'Renumber auth',
|
||||
'Renumber authz',
|
||||
'Renumber ssrf',
|
||||
'Renumber miscellaneous',
|
||||
'Initialize report state',
|
||||
'Assemble report inputs',
|
||||
'Compact report findings',
|
||||
'Saving report progress',
|
||||
'Finalize report outputs',
|
||||
'Finalize report without SARIF',
|
||||
'Saving final report state',
|
||||
'Surface customer report',
|
||||
]);
|
||||
|
||||
function safeClassName(value: string | undefined): string | undefined {
|
||||
return value === undefined ? undefined : CLASS_NAMES[value];
|
||||
}
|
||||
|
||||
function safeStageName(value: string | undefined): string | undefined {
|
||||
return value === undefined ? undefined : STAGE_NAMES[value];
|
||||
}
|
||||
|
||||
function reasonMessage(reason: PartialReasonView): string | undefined {
|
||||
const className = safeClassName(reason.vulnerabilityClass);
|
||||
switch (reason.code) {
|
||||
case 'agentic_sast_failed': {
|
||||
const stageName = safeStageName(reason.stage);
|
||||
return stageName === undefined
|
||||
? 'Agentic SAST failed, so the pentest continued without its findings.'
|
||||
: `Agentic SAST failed during ${stageName}, so the pentest continued without its findings.`;
|
||||
}
|
||||
case 'agentic_sast_reduced':
|
||||
return 'Agentic SAST completed with reduced coverage.';
|
||||
case 'class_pipeline_failed':
|
||||
return className === undefined
|
||||
? undefined
|
||||
: `${className} could not be fully assessed. The other classes completed. Re-running this workspace retries only the part that failed.`;
|
||||
case 'class_reconciliation_failed':
|
||||
return className === undefined
|
||||
? undefined
|
||||
: `${className} findings could not be grouped into test cases, so that class was not exploited and its findings are not in the report.`;
|
||||
case 'report_renumber_failed':
|
||||
return className === undefined
|
||||
? undefined
|
||||
: `${className} findings kept their working reference numbers, so numbering in the report may have gaps. The findings themselves are complete.`;
|
||||
case 'report_compaction_failed':
|
||||
return 'Finding reference numbers in the report may have gaps. Every finding is present; only the numbering is affected.';
|
||||
case 'report_class_omitted':
|
||||
return className === undefined
|
||||
? undefined
|
||||
: `${className} was assessed but could not be included in the final report.`;
|
||||
case 'report_sarif_failed':
|
||||
return 'Report SARIF could not be generated. JSON and Markdown remain available.';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function safePartialReasons(reasons: readonly PartialReasonView[]): readonly PartialReasonView[] {
|
||||
return reasons.flatMap((reason) => {
|
||||
const message = reasonMessage(reason);
|
||||
if (message === undefined) return [];
|
||||
const vulnerabilityClass =
|
||||
safeClassName(reason.vulnerabilityClass) === undefined ? undefined : reason.vulnerabilityClass;
|
||||
const stage = safeStageName(reason.stage) === undefined ? undefined : reason.stage;
|
||||
return [
|
||||
{
|
||||
code: reason.code,
|
||||
message,
|
||||
...(vulnerabilityClass !== undefined && { vulnerabilityClass }),
|
||||
...(stage !== undefined && { stage }),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** Upper bounds on the warning array crossing into cli.status.json, so a malformed state cannot bloat it. */
|
||||
const MAX_AGENTIC_SAST_WARNINGS = 20;
|
||||
const MAX_AGENTIC_SAST_WARNING_LENGTH = 2_000;
|
||||
|
||||
/** Sanitize the worker's usage-accounting warnings: strings only, bounded count and length. */
|
||||
function safeAgenticSastWarnings(value: PipelineState['agenticSast']): readonly string[] {
|
||||
const warnings = value?.warnings;
|
||||
if (!Array.isArray(warnings)) return [];
|
||||
return warnings
|
||||
.filter((warning): warning is string => typeof warning === 'string')
|
||||
.slice(0, MAX_AGENTIC_SAST_WARNINGS)
|
||||
.map((warning) => warning.slice(0, MAX_AGENTIC_SAST_WARNING_LENGTH));
|
||||
}
|
||||
|
||||
export function safeAgenticSast(value: PipelineState['agenticSast']):
|
||||
| {
|
||||
readonly status: string;
|
||||
readonly failedStageLabel?: string;
|
||||
readonly error?: string;
|
||||
readonly errorCode?: string;
|
||||
readonly warnings: readonly string[];
|
||||
}
|
||||
| undefined {
|
||||
if (value === undefined || !['disabled', 'running', 'succeeded', 'failed'].includes(value.status)) return undefined;
|
||||
const failedStageLabel = TERMINAL_STAGE_NAMES.has(value.failedStageLabel ?? '') ? value.failedStageLabel : undefined;
|
||||
let error: string | undefined;
|
||||
if (value.error !== undefined && CAPELLA_FAILURE_MESSAGES.has(value.error)) {
|
||||
error = value.error;
|
||||
} else if (value.status === 'failed') {
|
||||
error = 'An agentic SAST step failed.';
|
||||
}
|
||||
const errorCode =
|
||||
value.errorCode !== undefined &&
|
||||
(/^[A-Z][A-Z0-9_]{0,63}$/u.test(value.errorCode) || isProviderFailureCategory(value.errorCode))
|
||||
? value.errorCode
|
||||
: undefined;
|
||||
return {
|
||||
status: value.status,
|
||||
...(failedStageLabel !== undefined && { failedStageLabel }),
|
||||
...(error !== undefined && { error }),
|
||||
...(errorCode !== undefined && { errorCode }),
|
||||
warnings: safeAgenticSastWarnings(value),
|
||||
};
|
||||
}
|
||||
|
||||
export function safeOperationLabel(value: string): string {
|
||||
return OPERATION_LABELS.has(value) ? value : 'Background task';
|
||||
}
|
||||
|
||||
export function safeOperationKey(value: string): string {
|
||||
if (
|
||||
/^(?:agentic-sast|miscellaneous-pipeline|report:(?:initialize|assemble|compact|checkpoint|finalize|finalize-degraded|terminal|surface))$/u.test(
|
||||
value,
|
||||
) ||
|
||||
/^agentic-sast:(?:architecture|threat-model|plan|research|dedupe|review|critic|confirm|calibrate)$/u.test(value) ||
|
||||
/^(?:reconciliation|report:renumber):(?:injection|xss|auth|authz|ssrf|miscellaneous)$/u.test(value) ||
|
||||
/^reconciliation:(?:injection|xss|auth|authz|ssrf|miscellaneous):fallback$/u.test(value)
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return 'background-task';
|
||||
}
|
||||
|
||||
/**
|
||||
* A workspace or workflow id is printed straight into the progress display, so this
|
||||
* confines it to a plain identifier charset before that happens: no control or escape
|
||||
* characters survive to reach the terminal.
|
||||
*/
|
||||
export function safeCliIdentifier(value: string): string {
|
||||
return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(value) ? value : 'unknown';
|
||||
}
|
||||
|
||||
export function safeTemporalStatus(value: string): string {
|
||||
return [
|
||||
'RUNNING',
|
||||
'UNSPECIFIED',
|
||||
'COMPLETED',
|
||||
'FAILED',
|
||||
'CANCELLED',
|
||||
'CANCELED',
|
||||
'TERMINATED',
|
||||
'TIMED_OUT',
|
||||
'CONTINUED_AS_NEW',
|
||||
].includes(value)
|
||||
? value
|
||||
: 'UNKNOWN';
|
||||
}
|
||||
|
||||
export function safeFailureDetail(hasFailure: true): string;
|
||||
export function safeFailureDetail(hasFailure: false): undefined;
|
||||
export function safeFailureDetail(hasFailure: boolean): string | undefined;
|
||||
export function safeFailureDetail(hasFailure: boolean): string | undefined {
|
||||
return hasFailure ? 'This scan step could not be completed.' : undefined;
|
||||
}
|
||||
|
||||
/** Same closed-set trade-off as safeFailureDetail, for the scan-level (not per-agent) failure. */
|
||||
export function safeTerminalFailure(hasFailure: boolean): string | undefined {
|
||||
return hasFailure ? 'The scan could not be completed.' : undefined;
|
||||
}
|
||||
@@ -8,7 +8,15 @@
|
||||
|
||||
import type { DerivedPhase } from './derive.js';
|
||||
import { derivePipeline, isTerminal, scanElapsedMs } from './derive.js';
|
||||
import type { PartialReasonView } from './pipeline.js';
|
||||
import type { RenderInput } from './render.js';
|
||||
import {
|
||||
safeAgenticSast,
|
||||
safeCliIdentifier,
|
||||
safePartialReasons,
|
||||
safeTemporalStatus,
|
||||
safeTerminalFailure,
|
||||
} from './safe-fields.js';
|
||||
|
||||
/** Coarse scan status token, mirroring the human status badge in machine-friendly form. */
|
||||
export type ScanStatus = 'running' | 'completed' | 'partial' | 'failed' | 'stopped' | 'cancelled' | 'timed_out';
|
||||
@@ -27,6 +35,18 @@ export interface StatusJson {
|
||||
readonly endedAt?: string;
|
||||
/** Failure text when a failed scan left no readable state. */
|
||||
readonly failureMessage?: string;
|
||||
/** Ordered durable degradation reasons with safe messages; present only when non-empty. */
|
||||
readonly partialReasons?: readonly PartialReasonView[];
|
||||
/** Agentic SAST outcome, with the worker's sanitized failure sentence and bounded code. */
|
||||
readonly agenticSast?: {
|
||||
readonly status: string;
|
||||
readonly error?: string;
|
||||
readonly errorCode?: string;
|
||||
/** Usage-accounting warnings; always present (empty when the ledger reconciled) so it is never null. */
|
||||
readonly warnings: readonly string[];
|
||||
};
|
||||
/** False when operational (Capella/reconciliation) spend is known to be incomplete. */
|
||||
readonly usageAccountingComplete?: boolean;
|
||||
readonly phases: readonly DerivedPhase[];
|
||||
}
|
||||
|
||||
@@ -34,6 +54,7 @@ export interface StatusJson {
|
||||
function deriveStatus(input: RenderInput): ScanStatus {
|
||||
if (!isTerminal(input.temporalStatus)) return 'running';
|
||||
if (input.state?.status === 'partial') return 'partial';
|
||||
if (input.state?.status === 'cancelled') return 'cancelled';
|
||||
|
||||
switch (input.temporalStatus) {
|
||||
case 'COMPLETED':
|
||||
@@ -53,16 +74,32 @@ function deriveStatus(input: RenderInput): ScanStatus {
|
||||
/** Build the JSON snapshot for a scan at instant `now`. */
|
||||
export function toStatusJson(input: RenderInput, now: number): StatusJson {
|
||||
const elapsedMs = scanElapsedMs(input, now);
|
||||
const partialReasons = safePartialReasons(input.state?.partialReasons ?? []);
|
||||
const agenticSast = safeAgenticSast(input.state?.agenticSast);
|
||||
const usageAccountingComplete = input.state?.summary?.usageAccountingComplete;
|
||||
const failureMessage = safeTerminalFailure(input.failureMessage !== undefined);
|
||||
|
||||
return {
|
||||
workspace: input.workspace,
|
||||
...(input.workflowId !== undefined && { workflowId: input.workflowId }),
|
||||
workspace: safeCliIdentifier(input.workspace),
|
||||
...(input.workflowId !== undefined && { workflowId: safeCliIdentifier(input.workflowId) }),
|
||||
status: deriveStatus(input),
|
||||
temporalStatus: input.temporalStatus,
|
||||
temporalStatus: safeTemporalStatus(input.temporalStatus),
|
||||
elapsedMs: elapsedMs ?? null,
|
||||
...(input.startedAt !== undefined && { startedAt: new Date(input.startedAt).toISOString() }),
|
||||
...(input.endedAt !== undefined && { endedAt: new Date(input.endedAt).toISOString() }),
|
||||
...(input.failureMessage !== undefined && { failureMessage: input.failureMessage }),
|
||||
...(failureMessage !== undefined && { failureMessage }),
|
||||
...(partialReasons.length > 0 && { partialReasons }),
|
||||
// Present only when agentic SAST actually ran; a disabled scan omits the key entirely.
|
||||
...(agenticSast !== undefined &&
|
||||
agenticSast.status !== 'disabled' && {
|
||||
agenticSast: {
|
||||
status: agenticSast.status,
|
||||
...(agenticSast.error !== undefined && { error: agenticSast.error }),
|
||||
...(agenticSast.errorCode !== undefined && { errorCode: agenticSast.errorCode }),
|
||||
warnings: [...agenticSast.warnings],
|
||||
},
|
||||
}),
|
||||
...(usageAccountingComplete !== undefined && { usageAccountingComplete }),
|
||||
phases: derivePipeline(input, now),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* Workspace → Temporal workflow-id resolution.
|
||||
*
|
||||
* A workspace name is not always its workflow id: a fresh scan's id equals the
|
||||
* workspace name, but each resume spawns a new workflow (`<workspace>_resume_<ts>`).
|
||||
* The workspace's session.json records the authoritative id — the latest resume
|
||||
* attempt, or the original — so commands that query Temporal (status, stop) resolve
|
||||
* through here instead of assuming the name is the id.
|
||||
* A workspace name is not always its workflow id: a fresh named workspace gets
|
||||
* `<workspace>_shannon-<timestamp>` as its workflow id (only an auto-named workspace's
|
||||
* directory name equals its original id), and each resume spawns a new workflow
|
||||
* (`<workspace>_resume_<ts>`). The workspace's session.json records the authoritative
|
||||
* id — the latest resume attempt, or the original — so commands that query Temporal
|
||||
* (status, stop) resolve through here instead of assuming the name is the id.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
|
||||
+186
-12
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Thin Temporal client for reading one scan's state.
|
||||
* Thin Temporal client for reading scan state and controlling scan workflow lifecycle.
|
||||
*
|
||||
* A running scan is queried live (getProgress) and read via pendingActivities for
|
||||
* the in-flight agents; a closed scan is read once from its result. Everything goes
|
||||
@@ -9,22 +9,51 @@
|
||||
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { Client, Connection, WorkflowFailedError, WorkflowNotFoundError } from '@temporalio/client';
|
||||
import { ACTIVITY_TO_AGENT, type PipelineState } from './scan/pipeline.js';
|
||||
import { ACTIVITY_TO_PROGRESS, type PipelineState } from './scan/pipeline.js';
|
||||
|
||||
const ADDRESS = '127.0.0.1:7233';
|
||||
const NAMESPACE = 'default';
|
||||
const LIFECYCLE_RPC_DEADLINE_MS = 3_000;
|
||||
const OPEN_SCAN_WORKFLOW_QUERY =
|
||||
"WorkflowType = 'pentestPipelineWorkflow' AND (ExecutionStatus = 'Running' OR ExecutionStatus = 'Paused')";
|
||||
|
||||
// WorkflowExecutionStatusName values that mean the scan has closed. RUNNING (and the unused
|
||||
// CONTINUED_AS_NEW) are the only non-terminal states.
|
||||
const TERMINAL_STATUSES: ReadonlySet<string> = new Set(['COMPLETED', 'FAILED', 'CANCELLED', 'TERMINATED', 'TIMED_OUT']);
|
||||
// WorkflowExecutionStatusName values that positively prove this execution has closed.
|
||||
// PAUSED is open; UNSPECIFIED and UNKNOWN are not safe closure evidence.
|
||||
const TERMINAL_STATUSES: ReadonlySet<string> = new Set([
|
||||
'COMPLETED',
|
||||
'FAILED',
|
||||
'CANCELLED',
|
||||
'TERMINATED',
|
||||
'CONTINUED_AS_NEW',
|
||||
'TIMED_OUT',
|
||||
]);
|
||||
|
||||
export interface RunningAgent {
|
||||
readonly agent: string;
|
||||
readonly label: string;
|
||||
/** 'agent' rows join the static pipeline tree; 'operation' rows feed the background-work phase. */
|
||||
readonly kind: 'agent' | 'operation';
|
||||
/** Set when a persisted parent stage owns this row; the label then reads as that stage's step. */
|
||||
readonly parentKey?: string;
|
||||
readonly attempt: number;
|
||||
readonly startedAt?: number;
|
||||
readonly lastFailure?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The CLI's activity mirror does not know an activity type the running scan is using, so the
|
||||
* progress tree cannot be rendered completely. Distinct from a Temporal connection failure.
|
||||
*/
|
||||
export class ActivityMirrorError extends Error {
|
||||
override name = 'ActivityMirrorError' as const;
|
||||
|
||||
constructor(activityType: string) {
|
||||
super(
|
||||
`This version of the Shannon command line does not recognise part of the running scan\n(${activityType}). Update Shannon, or watch the scan with: shannon logs <workspace>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert a proto ITimestamp (seconds is a Long) to epoch millis. */
|
||||
function timestampMs(
|
||||
ts: { seconds?: { toString(): string } | number | null; nanos?: number | null } | null,
|
||||
@@ -47,17 +76,141 @@ export type TerminalOutcome =
|
||||
| { readonly kind: 'success'; readonly state: PipelineState }
|
||||
| { readonly kind: 'failed'; readonly message: string };
|
||||
|
||||
/**
|
||||
* The authoritative Temporal state used by lifecycle commands. Transport failures deliberately
|
||||
* remain errors instead of being represented as a closed workflow: callers must not report a
|
||||
* scan stopped unless Temporal has positively confirmed it.
|
||||
*/
|
||||
export type WorkflowLifecycleState =
|
||||
| { readonly kind: 'open'; readonly status: 'RUNNING' | 'PAUSED' }
|
||||
| { readonly kind: 'terminal'; readonly status: string }
|
||||
| { readonly kind: 'unknown'; readonly status: string }
|
||||
| { readonly kind: 'not-found' };
|
||||
|
||||
/** A scan workflow returned by Temporal's eventually consistent open-workflow visibility query. */
|
||||
export interface RunningScanWorkflow {
|
||||
readonly workflowId: string;
|
||||
readonly taskQueue: string;
|
||||
}
|
||||
|
||||
let clientPromise: Promise<Client> | null = null;
|
||||
|
||||
function getClient(): Promise<Client> {
|
||||
if (!clientPromise) {
|
||||
clientPromise = Connection.connect({ address: ADDRESS }).then(
|
||||
const pending = Connection.connect({ address: ADDRESS, connectTimeout: LIFECYCLE_RPC_DEADLINE_MS }).then(
|
||||
(connection) => new Client({ connection, namespace: NAMESPACE }),
|
||||
);
|
||||
// A rejected connect must not be cached forever: clear the memo so the next call rebuilds
|
||||
// instead of replaying the same failure. Scoped to `pending` so a later successful reconnect
|
||||
// that replaced the memo is left untouched.
|
||||
pending.catch(() => resetClient(pending));
|
||||
clientPromise = pending;
|
||||
}
|
||||
return clientPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the memoized client so the next {@link getClient} builds a fresh Connection. The underlying
|
||||
* gRPC channel can wedge such that every reused call fails identically ("Unexpected error while
|
||||
* making gRPC request"), and only a new Connection recovers. Best-effort closes the old channel.
|
||||
* When `only` is given, the memo is cleared only if it still holds that exact promise.
|
||||
*/
|
||||
function resetClient(only?: Promise<Client>): void {
|
||||
if (only !== undefined && clientPromise !== only) return;
|
||||
const previous = clientPromise;
|
||||
clientPromise = null;
|
||||
previous?.then((client) => client.connection.close()).catch(() => {});
|
||||
}
|
||||
|
||||
/** Close the current channel and establish another before a termination retry. */
|
||||
export async function refreshWorkflowLifecycleConnection(): Promise<void> {
|
||||
const previous = clientPromise;
|
||||
if (previous !== null) {
|
||||
if (clientPromise === previous) clientPromise = null;
|
||||
try {
|
||||
const client = await previous;
|
||||
await client.connection.close();
|
||||
} catch {
|
||||
// A failed prior connection is already detached. The new connection below is authoritative.
|
||||
}
|
||||
}
|
||||
await getClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a bounded lifecycle RPC and discard the connection when Temporal did not positively say
|
||||
* that the workflow is absent. A fresh connection is important after a gRPC timeout or transport
|
||||
* failure: reusing a wedged channel can turn a recoverable stop into an indefinitely ambiguous one.
|
||||
*/
|
||||
async function runLifecycleRpc<T>(operation: (client: Client) => Promise<T>): Promise<T> {
|
||||
const pending = getClient();
|
||||
try {
|
||||
const client = await pending;
|
||||
return await client.withDeadline(Date.now() + LIFECYCLE_RPC_DEADLINE_MS, () => operation(client));
|
||||
} catch (err) {
|
||||
if (!(err instanceof WorkflowNotFoundError)) resetClient(pending);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Describe a workflow for lifecycle control without reading its progress or pending activities. */
|
||||
export async function describeWorkflowLifecycle(workflowId: string): Promise<WorkflowLifecycleState> {
|
||||
try {
|
||||
const desc = await runLifecycleRpc((client) => client.workflow.getHandle(workflowId).describe());
|
||||
if (desc.status.name === 'RUNNING' || desc.status.name === 'PAUSED') {
|
||||
return { kind: 'open', status: desc.status.name };
|
||||
}
|
||||
if (TERMINAL_STATUSES.has(desc.status.name)) return { kind: 'terminal', status: desc.status.name };
|
||||
return { kind: 'unknown', status: desc.status.name };
|
||||
} catch (err) {
|
||||
if (err instanceof WorkflowNotFoundError) return { kind: 'not-found' };
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Request cooperative cancellation. This confirms request acceptance, not workflow closure. */
|
||||
export async function requestWorkflowCancellation(workflowId: string): Promise<'requested' | 'not-found'> {
|
||||
try {
|
||||
await runLifecycleRpc((client) => client.workflow.getHandle(workflowId).cancel());
|
||||
return 'requested';
|
||||
} catch (err) {
|
||||
if (err instanceof WorkflowNotFoundError) return 'not-found';
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Request forced termination. This confirms request acceptance, not workflow closure. */
|
||||
export async function requestWorkflowTermination(
|
||||
workflowId: string,
|
||||
reason: string,
|
||||
): Promise<'requested' | 'not-found'> {
|
||||
try {
|
||||
await runLifecycleRpc((client) => client.workflow.getHandle(workflowId).terminate(reason));
|
||||
return 'requested';
|
||||
} catch (err) {
|
||||
if (err instanceof WorkflowNotFoundError) return 'not-found';
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** List currently open Shannon scan workflows through Temporal visibility. */
|
||||
export async function listRunningScanWorkflows(): Promise<readonly RunningScanWorkflow[]> {
|
||||
return runLifecycleRpc(async (client) => {
|
||||
const workflows: RunningScanWorkflow[] = [];
|
||||
for await (const execution of client.workflow.list({ query: OPEN_SCAN_WORKFLOW_QUERY })) {
|
||||
// Visibility is eventually consistent. Keep only the open scan rows returned by this page;
|
||||
// each discovered workflow is described directly before `stop` accepts its closure.
|
||||
if (
|
||||
(execution.status.name === 'RUNNING' || execution.status.name === 'PAUSED') &&
|
||||
execution.type === 'pentestPipelineWorkflow'
|
||||
) {
|
||||
workflows.push({ workflowId: execution.workflowId, taskQueue: execution.taskQueue });
|
||||
}
|
||||
}
|
||||
return workflows;
|
||||
});
|
||||
}
|
||||
|
||||
/** Describe a scan: status, timing, and the agents currently running (from pendingActivities). Null if not found. */
|
||||
export async function describeScan(workflowId: string): Promise<ScanDescription | null> {
|
||||
const client = await getClient();
|
||||
@@ -66,12 +219,26 @@ export async function describeScan(workflowId: string): Promise<ScanDescription
|
||||
|
||||
const runningAgents: RunningAgent[] = [];
|
||||
for (const pending of desc.raw.pendingActivities ?? []) {
|
||||
const agent = ACTIVITY_TO_AGENT[pending.activityType?.name ?? ''];
|
||||
if (!agent) continue;
|
||||
const lastFailure = pending.lastFailure?.message;
|
||||
const activityType = pending.activityType?.name ?? '';
|
||||
const progress = ACTIVITY_TO_PROGRESS[activityType];
|
||||
// Fail closed: skipping an unknown activity would render a quietly incomplete tree.
|
||||
if (!progress) {
|
||||
throw new ActivityMirrorError(activityType || 'unknown activity');
|
||||
}
|
||||
// Temporal's own failure message is never forwarded verbatim: it can carry raw
|
||||
// exception text from inside the activity, which this client has no way to vet
|
||||
// before painting it into a terminal. Only its presence is kept; the boolean feeds
|
||||
// a fixed sentence downstream (see safeFailureDetail), and the real detail stays
|
||||
// one `shannon logs` away.
|
||||
// NOTE: the proto decoder writes an absent lastFailure as null, not undefined, so a
|
||||
// loose check is what distinguishes a healthy attempt from a failed one.
|
||||
const lastFailure = pending.lastFailure == null ? undefined : 'This activity attempt failed.';
|
||||
const startedAt = timestampMs(pending.scheduledTime ?? pending.lastStartedTime ?? null);
|
||||
runningAgents.push({
|
||||
agent,
|
||||
agent: progress.key,
|
||||
label: progress.label,
|
||||
kind: progress.kind,
|
||||
...(progress.parentKey !== undefined ? { parentKey: progress.parentKey } : {}),
|
||||
attempt: pending.attempt ?? 1,
|
||||
...(startedAt !== undefined ? { startedAt } : {}),
|
||||
...(lastFailure ? { lastFailure } : {}),
|
||||
@@ -153,8 +320,9 @@ export async function waitForWorkflowClose(workflowId: string, opts: WatchOption
|
||||
|
||||
while (!signal?.aborted) {
|
||||
try {
|
||||
const desc = await describeScan(workflowId);
|
||||
if (desc === null || TERMINAL_STATUSES.has(desc.status)) {
|
||||
const client = await getClient();
|
||||
const desc = await client.workflow.getHandle(workflowId).describe();
|
||||
if (TERMINAL_STATUSES.has(desc.status.name)) {
|
||||
return { reason: 'closed' };
|
||||
}
|
||||
// Reachable and still RUNNING — reset the failure streak and note any recovery.
|
||||
@@ -164,6 +332,12 @@ export async function waitForWorkflowClose(workflowId: string, opts: WatchOption
|
||||
}
|
||||
connectFailures = 0;
|
||||
} catch (err) {
|
||||
if (err instanceof WorkflowNotFoundError) {
|
||||
return { reason: 'closed' };
|
||||
}
|
||||
// Drop the wedged channel so the next poll dials a fresh one; a cached dead channel would
|
||||
// otherwise fail every retry identically and never recover.
|
||||
resetClient();
|
||||
connectFailures++;
|
||||
lastError = err instanceof Error ? err.message : String(err);
|
||||
if (!warned && connectFailures >= warnAfterFailures) {
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* 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 workers are identified by Docker workspace label for default-target selection.
|
||||
* `stop` supplements that local discovery with Temporal lifecycle state. Recency for
|
||||
* finished scans comes from each run's session.json createdAt,
|
||||
* with the workspace directory mtime as the fallback for runs that predate it.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { runningScanWorkspaces } from './docker.js';
|
||||
import { getWorkspacesDir } from './home.js';
|
||||
import { resolveRunFile } from './paths.js';
|
||||
import { resolveWorkflowId } from './session.js';
|
||||
|
||||
export interface WorkspaceInfo {
|
||||
readonly name: string;
|
||||
/** Creation time in ms — the recency sort key. Null when neither session.json nor stat is readable. */
|
||||
readonly createdMs: number | null;
|
||||
}
|
||||
|
||||
/** Creation time of a workspace: session.json createdAt, else directory mtime, else null. */
|
||||
function readCreatedMs(runDir: string): number | null {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(resolveRunFile(runDir, 'session.json'), 'utf-8'));
|
||||
const createdMs = Date.parse(parsed?.session?.createdAt ?? '');
|
||||
if (!Number.isNaN(createdMs)) {
|
||||
return createdMs;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the directory mtime.
|
||||
}
|
||||
|
||||
try {
|
||||
return fs.statSync(runDir).mtimeMs;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Every workspace directory, newest-first by createdAt (directory mtime fallback). */
|
||||
export function listWorkspaces(): WorkspaceInfo[] {
|
||||
const workspacesDir = getWorkspacesDir();
|
||||
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(workspacesDir, { withFileTypes: true });
|
||||
} catch {
|
||||
// Workspaces directory does not exist yet — no scans have ever run.
|
||||
return [];
|
||||
}
|
||||
|
||||
const workspaces: WorkspaceInfo[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
workspaces.push({ name: entry.name, createdMs: readCreatedMs(path.join(workspacesDir, entry.name)) });
|
||||
}
|
||||
|
||||
// Newest first; workspaces with no known time sort last.
|
||||
workspaces.sort((a, b) => (b.createdMs ?? 0) - (a.createdMs ?? 0));
|
||||
return workspaces;
|
||||
}
|
||||
|
||||
export type ScanIdentity =
|
||||
| { readonly kind: 'ok'; readonly workspace: string; readonly workflowId: string }
|
||||
| {
|
||||
readonly kind: 'not-found';
|
||||
readonly reason: 'no-match' | 'unreadable-record';
|
||||
/** For 'unreadable-record': the session.json path that could not prove the identity. */
|
||||
readonly sessionPath?: string;
|
||||
}
|
||||
| { readonly kind: 'ambiguous'; readonly claims: readonly string[] };
|
||||
|
||||
/** Every workflow id a run's session record has ever claimed: the original plus each resume. */
|
||||
function readRecordedWorkflowIds(runDir: string): readonly string[] {
|
||||
try {
|
||||
const session = JSON.parse(fs.readFileSync(resolveRunFile(runDir, 'session.json'), 'utf-8'));
|
||||
const resumeAttempts: { workflowId?: string }[] = session.session?.resumeAttempts ?? [];
|
||||
const ids = [session.session?.originalWorkflowId, ...resumeAttempts.map((attempt) => attempt.workflowId)];
|
||||
return ids.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prove the canonical (workspace, workflowId) pair for a status target.
|
||||
*
|
||||
* A directory with a readable session record takes precedence and follows its latest
|
||||
* resume (an auto-named directory whose name equals its original workflow id resolves
|
||||
* here). Otherwise the input is matched exactly against every workflow id the session
|
||||
* records have claimed — session.json is the only trustworthy reverse mapping, and a
|
||||
* valid workspace name may itself end in `_shannon-<digits>`, so the id naming
|
||||
* convention is never used to guess.
|
||||
*/
|
||||
export function resolveScanIdentity(input: string): ScanIdentity {
|
||||
const runDir = path.join(getWorkspacesDir(), input);
|
||||
let isDirectory = false;
|
||||
try {
|
||||
isDirectory = fs.statSync(runDir).isDirectory();
|
||||
} catch {
|
||||
// Not a workspace directory — fall through to the exact workflow-id match.
|
||||
}
|
||||
|
||||
if (isDirectory) {
|
||||
const workflowId = resolveWorkflowId(input);
|
||||
if (workflowId !== undefined) {
|
||||
return { kind: 'ok', workspace: input, workflowId };
|
||||
}
|
||||
return { kind: 'not-found', reason: 'unreadable-record', sessionPath: resolveRunFile(runDir, 'session.json') };
|
||||
}
|
||||
|
||||
const claims: string[] = [];
|
||||
for (const workspace of listWorkspaces()) {
|
||||
const recorded = readRecordedWorkflowIds(path.join(getWorkspacesDir(), workspace.name));
|
||||
if (recorded.includes(input)) {
|
||||
claims.push(workspace.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (claims.length === 1) {
|
||||
// The exact requested id is kept, so an older workflow id keeps addressing that older execution.
|
||||
return { kind: 'ok', workspace: claims[0] as string, workflowId: input };
|
||||
}
|
||||
if (claims.length > 1) {
|
||||
return { kind: 'ambiguous', claims: [...claims].sort() };
|
||||
}
|
||||
return { kind: 'not-found', reason: 'no-match' };
|
||||
}
|
||||
|
||||
export type DefaultTarget =
|
||||
| { readonly kind: 'ok'; readonly workspace: string; readonly running: boolean }
|
||||
| { readonly kind: 'none' }
|
||||
| { readonly kind: 'ambiguous'; readonly running: readonly string[] };
|
||||
|
||||
/**
|
||||
* Pick the default workspace when the user gave none.
|
||||
*
|
||||
* Exactly one scan running → that scan. Multiple running → ambiguous, so the caller can
|
||||
* list them and ask for an explicit name. None running → the most recent workspace when
|
||||
* `allowFinished` (viewing commands), otherwise none (stopping a finished scan is a no-op).
|
||||
*/
|
||||
export function resolveDefaultWorkspace(opts: { readonly allowFinished: boolean }): DefaultTarget {
|
||||
const running = runningScanWorkspaces();
|
||||
if (running.length === 1) {
|
||||
return { kind: 'ok', workspace: running[0] as string, running: true };
|
||||
}
|
||||
if (running.length > 1) {
|
||||
return { kind: 'ambiguous', running };
|
||||
}
|
||||
|
||||
if (!opts.allowFinished) {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
|
||||
const workspaces = listWorkspaces();
|
||||
const mostRecent = workspaces[0];
|
||||
if (!mostRecent) {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
return { kind: 'ok', workspace: mostRecent.name, running: false };
|
||||
}
|
||||
Reference in New Issue
Block a user