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
This commit is contained in:
ajmallesh
2026-08-31 16:16:14 -07:00
parent 1b440c853c
commit 7b67302a39
9 changed files with 1246 additions and 191 deletions
+28 -3
View File
@@ -12,7 +12,7 @@ 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';
@@ -25,6 +25,7 @@ import {
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';
@@ -214,6 +215,12 @@ export function writeLaunchStateAtomically(internalPath: string, outputDir: stri
}
}
/** 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. Resolve non-mutating inputs and classify the workspace before changing it.
initHome();
@@ -250,6 +257,7 @@ export async function start(args: StartArgs): Promise<void> {
const suffix = randomSuffix();
const taskQueue = `shannon-${suffix}`;
const containerName = `shannon-worker-${suffix}`;
const workflowId = createWorkflowId(workspace, launchDecision.isResume);
// 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
@@ -294,13 +302,23 @@ export async function start(args: StartArgs): Promise<void> {
initialResumeCount = Array.isArray(attempts) ? attempts.length : 0;
}
// 8. Spawn the 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 }),
@@ -366,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) {
File diff suppressed because it is too large Load Diff
+126 -50
View File
@@ -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];
@@ -256,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...');
@@ -274,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);
}
/**
@@ -377,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 };
@@ -399,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());
@@ -459,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);
}
@@ -482,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}`];
@@ -492,23 +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. This is the authoritative running-scan →
* workspace-name map. Best-effort: empty when Docker is unreachable, which is the
* correct answer anyway (no scan can be running without the daemon).
* 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 output = runOutput('docker', ['ps', ...WORKER_FILTER, '--format', `{{ index .Labels "${WORKSPACE_LABEL}" }}`]);
return output
.split('\n')
.map((name) => name.trim())
.filter(Boolean);
const result = runningScanWorkspacesChecked();
return result.kind === 'ok' ? result.value : [];
}
/**
@@ -520,40 +630,6 @@ export async function stopContainers(ids: string[]): Promise<void> {
await Promise.all(ids.map((id) => spawnQuiet('docker', ['stop', id])));
}
/** Request cooperative cancellation so the workflow can run its terminal finalizer. */
export function cancelWorkflow(workflowId: string): boolean {
return runQuiet('docker', temporalCmd('workflow', 'cancel', '--workflow-id', workflowId));
}
/**
* Terminate a Temporal workflow so a stopped scan doesn't linger as a running
* workflow with no worker. Best-effort: returns false if Temporal is unreachable
* 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));
}
/**
* 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.
*/
+139
View File
@@ -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 };
}
+121 -5
View File
@@ -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
@@ -13,10 +13,20 @@ 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;
@@ -66,11 +76,28 @@ 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) {
const pending = 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
@@ -95,6 +122,95 @@ function resetClient(only?: Promise<Client>): void {
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();
+3 -2
View File
@@ -8,8 +8,9 @@
* identity proof are separate steps: `resolveScanIdentity` turns a selected or explicit
* string into the one canonical (workspace, workflowId) pair the session records prove.
*
* Running scans are identified by Docker label (the authoritative source, shared with
* `stop`); recency for finished scans comes from each run's session.json createdAt,
* 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.
*/