Files
shannon/apps/cli/src/commands/stop.ts
T
Arjun Malleswaranandezl-keygraph 9767ebe633 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>
2026-09-02 14:34:26 +05:30

884 lines
35 KiB
TypeScript

/**
* `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 {
type CommandQueryResult,
ensureDocker,
type RunningScanContainer,
runningContainersChecked,
runningScanContainersChecked,
scanFilter,
stopContainers,
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;
yes: boolean;
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));
}
}
/**
* 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 filter = scanFilter(workspace);
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);
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}`);
}
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}`);
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;
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}`);
}
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 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);
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');
const initialResult = await executeStopPlan(targets, containers, WORKER_FILTER);
const visibilitySettle = await stopVisibleWorkflowsUntilSettled(initialResult.workflows);
const results = visibilitySettle.results;
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);
}
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.');
const workspace = opts.all ? undefined : (opts.workspace ?? resolveStopTarget());
if (workspace) await stopSingleScan(workspace, opts.yes);
else await stopAllScans(opts.yes);
}