// Copyright (C) 2025 Keygraph, Inc. // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License version 3 // as published by the Free Software Foundation. /** * Current-release Temporal orchestration for the Shannon pentest pipeline. * * Every side effect (network, filesystem, git, model calls) is confined to an activity, reached * only through the proxied namespaces below (`acts`, `testActs`, `preflightActs`, and the rest) * or through `executeChild` for the Capella child workflow. The functions in this file must stay * deterministic: Temporal replays them from recorded history instead of re-running real time or * I/O, so calling `Date.now()` directly in workflow code is safe (the SDK records and replays the * value), but a raw file read, network call, or `Math.random()` is not. */ import type { ActivityOptions } from '@temporalio/workflow'; import { ActivityCancellationType, ApplicationFailure, CancellationScope, executeChild, isCancellation, log, proxyActivities, setHandler, workflowInfo, } from '@temporalio/workflow'; import type { StageMetrics } from '../ai/reconciliation/stage-contracts.js'; import { capellaTerminalStageLabel, isCapellaSafeFailureMessage } from '../ai/sast/capella/safe-failures.js'; import type { CapellaWorkflowInput } from '../ai/sast/capella/temporal/activity-types.js'; import { CAPELLA_CHILD_WORKFLOW_OPTIONS, capellaWorkflow } from '../ai/sast/capella/temporal/workflow.js'; import type { CapellaRunResult, SarifRef } from '../ai/sast/types.js'; import type { WorkflowPhase } from '../audit/safe-fields.js'; import type { AgentName, VulnType } from '../types/agents.js'; import { ALL_AGENTS } from '../types/agents.js'; import { ALL_VULN_CLASSES, type VulnClass } from '../types/config.js'; import type { ReconciliationClass } from '../types/reconciliation.js'; import { appendPartialReasons, type MiscellaneousOutcome, miscellaneousLaneIsSettled, type PartialReason, projectPartialReasons, renderSafeMessage, reportIsAuthored, } from '../types/run-state.js'; import type * as activities from './activities.js'; import type { ActivityInput } from './activities.js'; import { isAcceptedTaskFormationFallbackReason, RECONCILIATION_ACTIVITY_PROFILES, type ReconciliationActivityRegistry, type ReconciliationClassActivityName, reconciliationClassDeadlineFrom, resolveReconciliationActivityBudget, } from './reconcile-activity-types.js'; import { type AgentMetrics, type DurableStateSummary, type FinalizeReportActivityResult, getProgress, type NonFatalFailure, type OperationalMetrics, type PipelineInput, type PipelineProgress, type PipelineState, type PipelineSummary, type ResumeState, type VulnExploitPipelineResult, } from './shared.js'; import { toWorkflowSummary } from './summary-mapper.js'; import { classifyErrorCode, formatWorkflowError } from './workflow-errors.js'; export { capellaWorkflow }; // Ordinary agent activities get long timeouts and Temporal's own retry loop, since an // individual agent run (a model conversation plus tool calls) can legitimately take a long // time and the workflow, not the agent process, owns restart decisions. The error types listed // as non-retryable are ones a retry can never fix (bad credentials, invalid config, an // unreachable target, a failed login), so retrying them would only burn time before failing anyway. const PRODUCTION_RETRY = { initialInterval: '5 minutes', maximumInterval: '30 minutes', backoffCoefficient: 2, maximumAttempts: 50, nonRetryableErrorTypes: [ 'AuthenticationError', 'ConfigurationError', 'InvalidTargetError', 'AuthLoginFailedError', 'PermanentError', ], }; const TESTING_RETRY = { initialInterval: '10 seconds', maximumInterval: '30 seconds', backoffCoefficient: 2, maximumAttempts: 5, nonRetryableErrorTypes: PRODUCTION_RETRY.nonRetryableErrorTypes, }; const acts = proxyActivities({ startToCloseTimeout: '2 hours', heartbeatTimeout: '60 minutes', retry: PRODUCTION_RETRY, cancellationType: ActivityCancellationType.TRY_CANCEL, }); const testActs = proxyActivities({ startToCloseTimeout: '30 minutes', heartbeatTimeout: '30 minutes', retry: TESTING_RETRY, cancellationType: ActivityCancellationType.TRY_CANCEL, }); const SHORT_RETRY = { initialInterval: '10 seconds', maximumInterval: '1 minute', backoffCoefficient: 2, maximumAttempts: 3, nonRetryableErrorTypes: PRODUCTION_RETRY.nonRetryableErrorTypes, }; const preflightActs = proxyActivities({ startToCloseTimeout: '2 minutes', heartbeatTimeout: '2 minutes', retry: SHORT_RETRY, cancellationType: ActivityCancellationType.TRY_CANCEL, }); const authValidationActs = proxyActivities({ startToCloseTimeout: '10 minutes', heartbeatTimeout: '10 minutes', retry: SHORT_RETRY, cancellationType: ActivityCancellationType.TRY_CANCEL, }); // From here down, every proxied namespace mutates durable, git-checkpointed state (report // progress, reconciliation artifacts, finalization). They use WAIT_CANCELLATION_COMPLETED so a // cancelled scan lets an in-flight write finish cleanly instead of racing a mid-commit abort; // the agent activities above use the cheaper TRY_CANCEL because an agent process can simply be // killed without leaving a half-written checkpoint behind. const deterministicReportActs = proxyActivities({ startToCloseTimeout: '2 minutes', retry: { initialInterval: '1 second', backoffCoefficient: 2, maximumAttempts: 5 }, cancellationType: ActivityCancellationType.WAIT_CANCELLATION_COMPLETED, }); const finalReportActs = proxyActivities({ startToCloseTimeout: '10 minutes', retry: { initialInterval: '1 second', backoffCoefficient: 2, maximumAttempts: 3 }, cancellationType: ActivityCancellationType.WAIT_CANCELLATION_COMPLETED, }); const surfaceReportActs = proxyActivities({ startToCloseTimeout: '2 minutes', retry: { initialInterval: '1 second', backoffCoefficient: 2, maximumAttempts: 3 }, cancellationType: ActivityCancellationType.WAIT_CANCELLATION_COMPLETED, }); const seedMiscellaneousActs = proxyActivities>({ startToCloseTimeout: RECONCILIATION_ACTIVITY_PROFILES.seedEmptyProducerQueue.startToCloseTimeoutMs, scheduleToCloseTimeout: '12 minutes', retry: { initialInterval: RECONCILIATION_ACTIVITY_PROFILES.seedEmptyProducerQueue.retryInitialIntervalMs, backoffCoefficient: RECONCILIATION_ACTIVITY_PROFILES.seedEmptyProducerQueue.retryBackoffCoefficient, maximumAttempts: RECONCILIATION_ACTIVITY_PROFILES.seedEmptyProducerQueue.maximumAttempts, }, cancellationType: ActivityCancellationType.WAIT_CANCELLATION_COMPLETED, }); const MAX_CONCURRENT_PIPELINES = 5; const MAX_NON_FATAL_FAILURES = 32; const CAPELLA_OPERATION_KEY = 'agentic-sast'; const CAPELLA_OPERATION_LABEL = 'Agentic SAST'; const CAPELLA_INFRASTRUCTURE_FAILURE = 'Agentic SAST infrastructure failed before producing a usable result.'; const CAPELLA_UNFINISHED = 'Agentic SAST had not finished when the scan stopped.'; const OPERATION_FAILURE = 'This scan step could not be completed.'; const CLASS_PIPELINE_FAILURE = 'A vulnerability analysis lane could not be completed.'; const CLASS_RECONCILIATION_FAILURE = 'Findings reconciliation could not be completed.'; const MISCELLANEOUS_PIPELINE_FAILURE = 'The additional findings lane could not be completed.'; const REPORT_RENUMBER_FAILURE = 'Report finding identifiers could not be refreshed for this class.'; const REPORT_COMPACTION_FAILURE = 'Report findings could not be compacted.'; /** * The single Capella outcome every vulnerability class joins on. Agentic SAST overlaps the * pentest, so its result is settled once and read by all five classes: a usable SARIF, no * SARIF, or the original cancellation that every waiter rethrows unchanged. */ type CapellaSettlement = | { readonly outcome: 'settled'; readonly sarif?: SarifRef } | { readonly outcome: 'cancelled'; readonly error: unknown }; /** Walk a rejection's `.cause` chain into an array, deduped and depth-bounded against a cycle. */ function failureChain(error: unknown): unknown[] { const chain: unknown[] = []; const visited = new Set(); let current: unknown = error; while (current !== undefined && current !== null && !visited.has(current) && chain.length < 20) { chain.push(current); visited.add(current); current = current instanceof Error ? current.cause : undefined; } return chain; } function hasCancellationInCauseChain(error: unknown): boolean { return failureChain(error).some((cause) => isCancellation(cause)); } function applicationFailureInChain(error: unknown): ApplicationFailure | undefined { return failureChain(error).find((cause): cause is ApplicationFailure => cause instanceof ApplicationFailure); } function failureDetailRecord(failure: ApplicationFailure | undefined): Record | undefined { const first = failure?.details?.[0]; if (first === null || typeof first !== 'object' || Array.isArray(first)) return undefined; return first as Record; } /** * Semantic fallback is restricted to the executor's closed set of accepted Pass 1 * model-failure reasons. A bare Temporal timeout (heartbeat, schedule-to-close, dead * worker), an infrastructure failure, a cancellation, or a deterministic integrity error * must fail the class instead of silently publishing a zero-dedup queue as success. */ function shouldUseSingletonFallback(error: unknown): boolean { if (hasCancellationInCauseChain(error)) return false; const failure = applicationFailureInChain(error); if (failure?.type !== 'TaskFormationModelError' || failure.nonRetryable) return false; return isAcceptedTaskFormationFallbackReason(failureDetailRecord(failure)?.fallbackReason); } function fallbackMetrics(error: unknown): StageMetrics | undefined { const details = applicationFailureInChain(error)?.details; if (!Array.isArray(details)) return undefined; const first = details[0]; if (first === null || typeof first !== 'object') return undefined; const metrics = (first as { metrics?: unknown }).metrics; if (metrics === null || typeof metrics !== 'object') return undefined; const value = metrics as Partial; if ( typeof value.costUsd !== 'number' || typeof value.modelCalls !== 'number' || typeof value.inputTokens !== 'number' || typeof value.outputTokens !== 'number' ) { return undefined; } return { costUsd: value.costUsd, modelCalls: value.modelCalls, inputTokens: value.inputTokens, outputTokens: value.outputTokens, }; } function reconciliationActivityOptions( activityName: ReconciliationClassActivityName, classDeadlineMs: number, vulnerabilityClass: ReconciliationClass, ): ActivityOptions { const budget = resolveReconciliationActivityBudget(activityName, classDeadlineMs, Date.now()); if (!budget.shouldSchedule) { throw ApplicationFailure.nonRetryable( renderSafeMessage( '{Class} findings took too long to process and the scan stopped that class. Re-running this workspace retries it.', { vulnerabilityClass }, ), 'ConfigurationError', [{ activityName }], ); } const profile = RECONCILIATION_ACTIVITY_PROFILES[activityName]; return { scheduleToCloseTimeout: budget.scheduleToCloseTimeoutMs, startToCloseTimeout: budget.startToCloseTimeoutMs, ...(budget.heartbeatTimeoutMs !== null && { heartbeatTimeout: budget.heartbeatTimeoutMs }), retry: { initialInterval: profile.retryInitialIntervalMs, backoffCoefficient: profile.retryBackoffCoefficient, maximumAttempts: profile.maximumAttempts, }, cancellationType: ActivityCancellationType.WAIT_CANCELLATION_COMPLETED, }; } function reconciliationActs( activityName: ReconciliationClassActivityName, classDeadlineMs: number, vulnerabilityClass: ReconciliationClass, ): ReconciliationActivityRegistry { return proxyActivities( reconciliationActivityOptions(activityName, classDeadlineMs, vulnerabilityClass), ); } function capellaMetrics(result: CapellaRunResult, model: string): OperationalMetrics { return { durationMs: result.durationMs, inputTokens: result.usage.inputTokens, outputTokens: result.usage.outputTokens, cacheReadTokens: result.usage.cacheReadTokens, cacheWriteTokens: result.usage.cacheWriteTokens, costUsd: result.usage.costUsd, numTurns: result.usage.turns, model, usageComplete: result.usageComplete, }; } function stageMetrics(metrics: StageMetrics): OperationalMetrics { return { durationMs: 0, inputTokens: metrics.inputTokens, outputTokens: metrics.outputTokens, cacheReadTokens: 0, cacheWriteTokens: 0, costUsd: metrics.costUsd, numTurns: metrics.modelCalls, usageComplete: true, }; } function computeSummary(state: PipelineState, usageAccountingComplete: boolean): PipelineSummary { const metrics = [...Object.values(state.agentMetrics), ...Object.values(state.operationalMetrics)]; return { totalCostUsd: metrics.reduce((sum, metric) => sum + (metric.costUsd ?? 0), 0), totalDurationMs: Date.now() - state.startTime, totalTurns: metrics.reduce((sum, metric) => sum + (metric.numTurns ?? 0), 0), agentCount: state.completedAgents.length + state.skippedAgents.length, usageAccountingComplete, }; } function isAgentName(value: string): value is AgentName { return (ALL_AGENTS as readonly string[]).includes(value); } /** Core current-release pipeline orchestration. */ export async function pentestPipeline(input: PipelineInput): Promise { if (!input.repoPath || input.repoPath.includes('..')) { throw ApplicationFailure.nonRetryable('Invalid repository path.', 'ConfigurationError'); } if (!input.repoPath.startsWith('/')) { throw ApplicationFailure.nonRetryable('An absolute repository path is required.', 'ConfigurationError'); } if (input.agenticSast !== undefined && input.sastSarif !== undefined) { throw ApplicationFailure.nonRetryable( 'Agentic SAST cannot run when a static-analysis report is already supplied. Remove the agentic_sast block from your config file, or remove the supplied report.', 'ConfigurationError', ); } if (input.customerOutputPath !== undefined && input.customerOutputPath !== '/app/output') { throw ApplicationFailure.nonRetryable( 'The customer output mount must use the stable worker path.', 'ConfigurationError', ); } const { workflowId } = workflowInfo(); const a = input.pipelineTestingMode ? testActs : acts; const exploit = input.exploit ?? true; const sessionId = input.sessionId || input.resumeFromWorkspace || workflowId; const stateContext: 'fresh' | 'resume' = input.resumeFromWorkspace ? 'resume' : 'fresh'; const state: PipelineState = { status: 'running', currentPhase: null, currentAgent: null, completedAgents: [], expectedAgents: [], participatingClasses: [], failedPipelines: [], failedReconciliations: [], failedAgent: null, error: null, startTime: Date.now(), skippedAgents: [], agentMetrics: {}, operationalMetrics: {}, operationalStages: {}, agenticSast: { status: 'disabled' }, nonFatalFailures: [], partialReasons: [], summary: null, }; // The durable degradation record. Codes plus bounded context are the identity; the // projection into state carries derived safe messages for every consumer surface. let partialReasons: readonly PartialReason[] = []; // True once reconciliation adopted a prior run's publication, whose model spend is not // visible to this run's metrics. Surfaced instead of inventing the missing spend. let operationalSpendMissing = false; function addPartialReason(reason: PartialReason): void { partialReasons = appendPartialReasons(partialReasons, [reason]); state.partialReasons = [...projectPartialReasons(partialReasons)]; } function adoptDurableReasons(durable: readonly PartialReason[]): void { partialReasons = appendPartialReasons(partialReasons, durable); state.partialReasons = [...projectPartialReasons(partialReasons)]; } function usageAccountingComplete(): boolean { const everyOperationalMetricComplete = Object.values(state.operationalMetrics).every( (metric) => metric.usageComplete !== false, ); return everyOperationalMetricComplete && !operationalSpendMissing; } setHandler( getProgress, (): PipelineProgress => ({ ...state, workflowId, elapsedMs: Date.now() - state.startTime, }), ); const activityInput: ActivityInput = { webUrl: input.webUrl, repoPath: input.repoPath, workflowId, sessionId, analysisClasses: [...ALL_VULN_CLASSES], stateContext, customerOutputRoute: input.customerOutputPath === undefined ? 'workspace' : 'mounted', ...(input.configPath !== undefined && { configPath: input.configPath }), ...(input.pipelineTestingMode !== undefined && { pipelineTestingMode: input.pipelineTestingMode }), ...(input.configYAML !== undefined && { configYAML: input.configYAML }), ...(input.deliverablesSubdir !== undefined && { deliverablesSubdir: input.deliverablesSubdir }), ...(input.auditDir !== undefined && { auditDir: input.auditDir }), ...(input.promptDir !== undefined && { promptDir: input.promptDir }), }; let resumeState: ResumeState | null = null; let miscellaneousOutcome: MiscellaneousOutcome | undefined; // The one shared Capella settlement, once analysis has started it. The terminal paths read // it so a stopping run can account for a child that is still working. let capellaSettlement: Promise | null = null; // Latched when a stopped run has recorded the terminal Capella state. The hard-failure path // does not wait for the child, so the child can still return while the terminal activity // yields; from that point the recorded state is final and no continuation may rewrite it. let capellaTerminallyProjected = false; function applyDurableSummary(summary: DurableStateSummary): void { state.expectedAgents = [...summary.expectedAgents]; state.participatingClasses = [...summary.participatingClasses]; if (summary.miscellaneousOutcome !== undefined) miscellaneousOutcome = summary.miscellaneousOutcome; } /** An agent that actually ran and finished. Mutually exclusive from markSkipped. */ function markCompleted(agentName: AgentName): void { if (!state.expectedAgents.includes(agentName)) return; if (!state.completedAgents.includes(agentName)) state.completedAgents.push(agentName); } /** * An expected agent that never ran because its class had nothing to exploit. It is tracked * only in `skippedAgents`, mutually exclusive from `completedAgents`. Pipeline resolution is * the union of the two lists; the summary counts them together. */ function markSkipped(agentName: AgentName): void { if (!state.expectedAgents.includes(agentName)) return; if (!state.skippedAgents.includes(agentName)) state.skippedAgents.push(agentName); } function shouldSkip(agentName: AgentName): boolean { return resumeState?.completedAgents.includes(agentName) ?? false; } // Bounded so a pathological run cannot grow workflow state, and workflow history, without // limit; an entry past the cap is dropped silently rather than turned into a failure of its own. function addNonFatal(failure: NonFatalFailure): void { if (state.nonFatalFailures.length >= MAX_NON_FATAL_FAILURES) return; state.nonFatalFailures.push(failure); } function startOperation(key: string, label: string): number { const startedAt = Date.now(); state.operationalStages[key] = { key, label, status: 'running', startedAt }; return startedAt; } function completeOperation(key: string, label: string, startedAt: number): void { state.operationalStages[key] = { key, label, status: 'completed', startedAt, durationMs: Date.now() - startedAt, }; } function failOperation(key: string, label: string, startedAt: number, message: string = OPERATION_FAILURE): void { state.operationalStages[key] = { key, label, status: 'failed', startedAt, durationMs: Date.now() - startedAt, error: message, }; } /** A stage an earlier run already settled. It records no span, so it contributes no wall time. */ function skipOperation(key: string, label: string): void { state.operationalStages[key] = { key, label, status: 'skipped' }; } async function runOperation(key: string, label: string, operation: () => Promise): Promise { const startedAt = startOperation(key, label); try { const result = await operation(); completeOperation(key, label, startedAt); return result; } catch (error) { failOperation(key, label, startedAt); throw error; } } function addReconciliationMetrics( vulnerabilityClass: ReconciliationClass, stage: 'enrich' | 'form', metrics: StageMetrics, ): void { state.operationalMetrics[`reconciliation:${vulnerabilityClass}:${stage}`] = stageMetrics(metrics); } async function runSequentialPhase( phaseName: WorkflowPhase, agentName: AgentName, runAgent: (input: ActivityInput) => Promise, ): Promise { if (shouldSkip(agentName)) { log.info(`Skipping ${agentName} (already complete)`); markCompleted(agentName); return; } state.currentPhase = phaseName; state.currentAgent = agentName; await a.logPhaseTransition(activityInput, phaseName, 'start'); state.agentMetrics[agentName] = await runAgent(activityInput); markCompleted(agentName); if (input.checkpointsEnabled) await a.saveCheckpoint(activityInput, agentName, phaseName, state); await a.logPhaseTransition(activityInput, phaseName, 'complete'); } async function reconcileClass(vulnerabilityClass: ReconciliationClass, sarif?: SarifRef): Promise { const key = `reconciliation:${vulnerabilityClass}`; const label = `Reconcile ${vulnerabilityClass}`; await runOperation(key, label, async () => { const classDeadlineMs = reconciliationClassDeadlineFrom(Date.now()); const baseInput = { sessionId, vulnerabilityClass, classDeadlineMs }; const prepared = await reconciliationActs( 'prepareClassReconciliation', classDeadlineMs, vulnerabilityClass, ).prepareClassReconciliation({ ...baseInput, includeSastProvenance: sarif !== undefined, }); if (prepared.outcome === 'already_published') { // A prior run paid for this publication; its model spend is absent from this // run's metrics, so the cost total is surfaced as incomplete rather than invented. operationalSpendMissing = true; return; } const enriched = await reconciliationActs( 'enrichClassSastObservations', classDeadlineMs, vulnerabilityClass, ).enrichClassSastObservations({ ...baseInput, ...(sarif !== undefined && { sarif }), }); addReconciliationMetrics(vulnerabilityClass, 'enrich', enriched.metrics); let formation: | Awaited> | 'singleton_fallback'; try { formation = await reconciliationActs( 'formClassExploitTasks', classDeadlineMs, vulnerabilityClass, ).formClassExploitTasks({ ...baseInput, producerRef: prepared.ref, supplementalRef: enriched.ref, }); addReconciliationMetrics(vulnerabilityClass, 'form', formation.metrics); } catch (error) { if (!shouldUseSingletonFallback(error)) throw error; const metrics = fallbackMetrics(error); if (metrics !== undefined) addReconciliationMetrics(vulnerabilityClass, 'form', metrics); formation = 'singleton_fallback'; // Make the degradation visible in queryable state: every observation becomes its // own task, so duplicates are expected instead of silently absent dedup. const fallbackKey = `reconciliation:${vulnerabilityClass}:fallback`; completeOperation(fallbackKey, `Grouping skipped (${vulnerabilityClass})`, Date.now()); log.info( renderSafeMessage( '{Class} findings could not be grouped, so each one will be tested separately. Expect duplicates in the results.', { vulnerabilityClass }, ), ); } const materialized = await reconciliationActs( 'materializeClassExploitTasks', classDeadlineMs, vulnerabilityClass, ).materializeClassExploitTasks({ ...baseInput, producerRef: prepared.ref, supplementalRef: enriched.ref, form: formation, }); await reconciliationActs( 'publishClassReconciliationOss', classDeadlineMs, vulnerabilityClass, ).publishClassReconciliationOss({ ...baseInput, producerRef: prepared.ref, supplementalRef: enriched.ref, fixedTasksRef: materialized.ref, }); }); } function buildPipelineConfigs(): Array<{ vulnType: VulnType; runVuln: () => Promise; runExploit: () => Promise; }> { return [ { vulnType: 'injection', runVuln: () => a.runInjectionVulnAgent(activityInput), runExploit: () => a.runInjectionExploitAgent(activityInput), }, { vulnType: 'xss', runVuln: () => a.runXssVulnAgent(activityInput), runExploit: () => a.runXssExploitAgent(activityInput), }, { vulnType: 'auth', runVuln: () => a.runAuthVulnAgent(activityInput), runExploit: () => a.runAuthExploitAgent(activityInput), }, { vulnType: 'authz', runVuln: () => a.runAuthzVulnAgent(activityInput), runExploit: () => a.runAuthzExploitAgent(activityInput), }, { vulnType: 'ssrf', runVuln: () => a.runSsrfVulnAgent(activityInput), runExploit: () => a.runSsrfExploitAgent(activityInput), }, ]; } /** * One vulnerability class's full lane: the vuln agent, joining the shared Capella settlement, * reconciliation, the exploitation decision, and (if warranted) the exploit agent. Every * failure except cancellation is caught here and turned into a per-class result instead of * being rethrown, so one class failing never aborts the classes running alongside it. Whether * reconciliation had already started when the failure hit picks which of the two safe messages * and partial-reason codes the class is recorded under. */ async function runVulnExploitPipeline( vulnType: VulnType, runVulnAgent: () => Promise, runExploitAgent: () => Promise, capella: Promise, ): Promise { const vulnAgentName = `${vulnType}-vuln` as AgentName; const exploitAgentName = `${vulnType}-exploit` as AgentName; let reconciliationStarted = false; let reconciliationCompleted = false; try { let vulnMetrics: AgentMetrics | null = null; if (shouldSkip(vulnAgentName)) { markCompleted(vulnAgentName); } else { vulnMetrics = await runVulnAgent(); state.agentMetrics[vulnAgentName] = vulnMetrics; markCompleted(vulnAgentName); if (input.checkpointsEnabled) await a.saveCheckpoint(activityInput, vulnAgentName, 'vulnerability-analysis', state); } // The class joins the shared Capella outcome here: reconciliation needs the settled // SARIF, and every class awaits the same promise, so no class waits on another class. const settled = await capella; if (settled.outcome === 'cancelled') throw settled.error; reconciliationStarted = true; await reconcileClass(vulnType, settled.sarif); reconciliationCompleted = true; const decision = await a.checkExploitationQueue(activityInput, vulnType); let exploitMetrics: AgentMetrics | null = null; if (exploit && shouldSkip(exploitAgentName)) { markCompleted(exploitAgentName); } else if (exploit && decision.shouldExploit) { exploitMetrics = await runExploitAgent(); state.agentMetrics[exploitAgentName] = exploitMetrics; markCompleted(exploitAgentName); if (input.checkpointsEnabled) await a.saveCheckpoint(activityInput, exploitAgentName, 'exploitation', state); } else if (exploit) { markSkipped(exploitAgentName); if (input.checkpointsEnabled) await a.saveCheckpoint(activityInput, exploitAgentName, 'exploitation', state); } return { vulnType, vulnMetrics, exploitMetrics, exploitDecision: { shouldExploit: decision.shouldExploit, vulnerabilityCount: decision.vulnerabilityCount }, error: null, }; } catch (error) { if (hasCancellationInCauseChain(error)) throw error; const message = reconciliationStarted && !reconciliationCompleted ? CLASS_RECONCILIATION_FAILURE : CLASS_PIPELINE_FAILURE; if (reconciliationStarted && !reconciliationCompleted) { state.failedReconciliations.push({ vulnerabilityClass: vulnType, error: message }); addPartialReason({ code: 'class_reconciliation_failed', vulnerabilityClass: vulnType }); } else { addPartialReason({ code: 'class_pipeline_failed', vulnerabilityClass: vulnType }); } return { vulnType, vulnMetrics: state.agentMetrics[vulnAgentName] ?? null, exploitMetrics: state.agentMetrics[exploitAgentName] ?? null, exploitDecision: null, error: message, }; } } /** * Run `thunks` with at most `limit` in flight, collecting every settlement instead of * failing fast, so one class's rejection never cancels the classes still running alongside it. */ async function runWithConcurrencyLimit( thunks: Array<() => Promise>, limit: number, ): Promise[]> { const results: PromiseSettledResult[] = []; const inFlight = new Set>(); for (const thunk of thunks) { const slot = thunk() .then( (value) => results.push({ status: 'fulfilled', value }), (reason: unknown) => results.push({ status: 'rejected', reason }), ) .then(() => undefined) .finally(() => inFlight.delete(slot)); inFlight.add(slot); if (inFlight.size >= limit) await Promise.race(inFlight); } await Promise.allSettled(inFlight); return results; } function aggregatePipelineResults(results: PromiseSettledResult[]): void { const cancelled = results.find( (result): result is PromiseRejectedResult => result.status === 'rejected' && hasCancellationInCauseChain(result.reason), ); if (cancelled) throw cancelled.reason; const failed: { vulnType: VulnClass; error: string }[] = []; const unattributable: string[] = []; for (const result of results) { if (result.status === 'fulfilled') { if (result.value.error !== null) failed.push({ vulnType: result.value.vulnType, error: result.value.error }); } else { unattributable.push(CLASS_PIPELINE_FAILURE); } } if (failed.length === 0 && unattributable.length === 0) return; // Fail the whole phase when every class failed, or when any result is unattributable. // A class pipeline catches its own errors and reports them in `error`, so a rejected // thunk means a failure escaped that path and cannot be pinned to one class, which is // never safe to downgrade to a partial run. if (failed.length + unattributable.length === ALL_VULN_CLASSES.length || unattributable.length > 0) { const errors = [...failed.map((failure) => `${failure.vulnType}: ${failure.error}`), ...unattributable]; throw ApplicationFailure.nonRetryable( 'The vulnerability analysis phase failed and the scan cannot continue. Re-running this workspace retries it from the last checkpoint.', 'PipelineFailedError', [{ failures: errors }], ); } state.failedPipelines = failed; } /** * The failed projection shared by every Capella outcome that produced no usable result: * an infrastructure failure, and a parent that stopped while the child was still running. * `startedAt` is the operation's real start, so a projection made after the status already * moved on still records the true duration. */ function projectCapellaWorkflowFailure(message: string, startedAt: number): void { if (capellaTerminallyProjected) return; state.agenticSast = { status: 'failed', failedStage: 'workflow', failedStageLabel: capellaTerminalStageLabel('workflow'), error: message, completedStages: [], durationMs: Date.now() - startedAt, }; failOperation(CAPELLA_OPERATION_KEY, CAPELLA_OPERATION_LABEL, startedAt, message); } /** * The one recovery for a Capella failure that returned no result of its own: the failed * projection, its single durable reason, and one non-fatal entry. */ function projectCapellaInfrastructureFailure(startedAt: number): void { if (capellaTerminallyProjected) return; projectCapellaWorkflowFailure(CAPELLA_INFRASTRUCTURE_FAILURE, startedAt); addPartialReason({ code: 'agentic_sast_failed', stage: 'workflow' }); addNonFatal({ phase: 'agentic-sast', error: CAPELLA_INFRASTRUCTURE_FAILURE }); } /** * Run Capella as a child workflow when agentic SAST is configured, or pass through a * pre-supplied SARIF report unchanged when it is not. Every outcome this function can observe, * whether success, reduced coverage, a Capella-reported failure, or an escaped exception, is * projected into `state.agenticSast` and, where relevant, a durable partial reason before * returning, so a caller reads the settled SARIF (`undefined` on anything but success) without * needing its own failure-handling path. */ async function runCapella(): Promise { if (input.agenticSast === undefined) { state.agenticSast = { status: 'disabled' }; return input.sastSarif; } const startedAt = startOperation(CAPELLA_OPERATION_KEY, CAPELLA_OPERATION_LABEL); state.agenticSast = { status: 'running', startedAt }; const auditRoot = (input.auditDir ?? '/app/workspaces').replace(/\/+$/, ''); const capellaInput: CapellaWorkflowInput = { repoPath: input.repoPath, artifactRoot: `${auditRoot}/${sessionId}/.shannon/capella`, workflowLogPath: `${auditRoot}/${sessionId}/.shannon/workflow.log`, promptDir: input.promptDir ?? '/app/apps/worker/prompts', codePathAvoids: [...input.agenticSast.codePathAvoids], codePathFocus: [...input.agenticSast.codePathFocus], modelSpec: input.agenticSast.modelSpec, capellaFormatVersion: input.agenticSast.capellaFormatVersion, promptSetVersion: input.agenticSast.promptSetVersion, pipelineTestingMode: input.pipelineTestingMode ?? false, }; try { const result = await executeChild(capellaWorkflow, { ...CAPELLA_CHILD_WORKFLOW_OPTIONS, workflowId: `${workflowId}-capella`, args: [capellaInput], }); // A run that already stopped owns the terminal Capella state. Its projection is made // while this child is still working, so a late result must write nothing: not the // status, not the operational stage, not the metrics the summary was computed from. if (capellaTerminallyProjected) return undefined; const metricKey = result.status === 'succeeded' ? 'agentic-sast:export' : `agentic-sast:${result.failedStage}`; state.operationalMetrics[metricKey] = capellaMetrics(result, input.agenticSast.modelSpec); if (result.status === 'succeeded') { state.agenticSast = { status: 'succeeded', findingCount: result.findingCount, sarifSha256: result.sarif.sha256, coverage: result.coverage, warnings: [...result.warnings], durationMs: result.durationMs, }; completeOperation(CAPELLA_OPERATION_KEY, CAPELLA_OPERATION_LABEL, startedAt); if (result.coverage === 'reduced') { addPartialReason({ code: 'agentic_sast_reduced' }); addNonFatal({ phase: 'agentic-sast', error: 'Agentic SAST completed with reduced coverage.' }); } return result.sarif; } const safeFailureMessage = isCapellaSafeFailureMessage(result.error) ? result.error : 'An agentic SAST step failed.'; state.agenticSast = { status: 'failed', failedStage: result.failedStage, failedStageLabel: capellaTerminalStageLabel(result.failedStage), error: safeFailureMessage, ...(result.errorCode !== undefined && { errorCode: result.errorCode }), completedStages: [...result.completedStages], durationMs: result.durationMs, }; addPartialReason({ code: 'agentic_sast_failed', stage: result.failedStage }); failOperation(CAPELLA_OPERATION_KEY, CAPELLA_OPERATION_LABEL, startedAt, safeFailureMessage); addNonFatal({ phase: 'agentic-sast', error: safeFailureMessage, }); return undefined; } catch (error) { if (hasCancellationInCauseChain(error)) throw error; projectCapellaInfrastructureFailure(startedAt); return undefined; } } /** * Start the one shared Capella settlement. The returned promise is total: the classes that * join it later must never observe a background rejection, so a non-cancellation failure * resolves as the shared no-SARIF outcome and only cancellation is carried through. */ async function settleCapella(): Promise { try { const sarif = await runCapella(); if (sarif === undefined) return { outcome: 'settled' }; return { outcome: 'settled', sarif }; } catch (error) { if (hasCancellationInCauseChain(error)) return { outcome: 'cancelled', error }; // `runCapella` projects every failure it can see, so an escape means that projection // itself failed partway; only the part it never reached is recovered here, and it is one // shared Capella outcome, never five separate class failures. Nothing in this recovery // may reject: on some paths no class ever joins, and an unobserved rejection in the // workflow VM is escalated rather than dropped. const running = state.agenticSast; try { if (running.status === 'running') projectCapellaInfrastructureFailure(running.startedAt); } catch { try { log.warn('Capella failure projection did not complete', { code: 'CAPELLA_PROJECTION_FAILED' }); } catch { // Even the warning is best-effort. A log that cannot be written must not turn the // settlement every class joins into a rejected promise. } } return { outcome: 'settled' }; } } /** * The Capella child runs under wait-for-cancellation, so a cancelled parent observes its * settlement before projecting the terminal state. Waiting never replaces the cancellation * this path is already reporting. */ async function awaitCapellaSettlement(settlement: Promise): Promise { try { await settlement; } catch { log.warn('Capella settlement did not resolve while the scan was stopping', { code: 'CAPELLA_SETTLEMENT_FAILED', }); } } /** * A Capella child that never returned recorded no complete operational metric, so a run that * stops while it is still running reports the stage as failed and its spend as incomplete * rather than inventing either. A stopped run carries no partial reasons, so none is added. */ function projectUnfinishedCapella(): void { const running = state.agenticSast; if (running.status !== 'running') return; projectCapellaWorkflowFailure(CAPELLA_UNFINISHED, running.startedAt); capellaTerminallyProjected = true; operationalSpendMissing = true; } /** * The internal `miscellaneous` class: findings outside the five fixed vulnerability classes, * carried through the same reconciliation and exploitation-decision path those classes use. * Its outcome is durably recorded (not just success/failure) so a resumed run knows whether * the class was ever admitted for exploitation, rather than re-deciding admission from scratch. */ async function runMiscellaneousPipeline(effectiveSarif: SarifRef): Promise { const key = 'miscellaneous-pipeline'; const label = 'Miscellaneous findings'; if (miscellaneousLaneIsSettled(miscellaneousOutcome)) { if (miscellaneousOutcome === 'completed') markCompleted('miscellaneous-exploit'); skipOperation(key, label); return; } const startedAt = startOperation(key, label); let reconciliationCompleted = false; try { await seedMiscellaneousActs.seedEmptyProducerQueue({ sessionId }); await reconcileClass('miscellaneous', effectiveSarif); reconciliationCompleted = true; const decision = await a.checkExploitationQueue(activityInput, 'miscellaneous' as VulnType); let outcome: MiscellaneousOutcome; if (!exploit) { outcome = 'exploitation_disabled'; } else if (!decision.shouldExploit) { outcome = 'not_actionable'; } else { const admitted = await deterministicReportActs.persistMiscellaneousOutcome(activityInput, 'expected'); applyDurableSummary(admitted); if (!shouldSkip('miscellaneous-exploit')) state.agentMetrics['miscellaneous-exploit'] = await a.runMiscellaneousExploitAgent(activityInput); markCompleted('miscellaneous-exploit'); outcome = 'completed'; } const persisted = await deterministicReportActs.persistMiscellaneousOutcome(activityInput, outcome); applyDurableSummary(persisted); completeOperation(key, label, startedAt); } catch (error) { if (hasCancellationInCauseChain(error)) throw error; const message = reconciliationCompleted ? MISCELLANEOUS_PIPELINE_FAILURE : CLASS_RECONCILIATION_FAILURE; failOperation(key, label, startedAt, message); if (!reconciliationCompleted) { state.failedReconciliations.push({ vulnerabilityClass: 'miscellaneous', error: message }); addPartialReason({ code: 'class_reconciliation_failed', vulnerabilityClass: 'miscellaneous' }); } else { addPartialReason({ code: 'class_pipeline_failed', vulnerabilityClass: 'miscellaneous' }); } addNonFatal({ phase: reconciliationCompleted ? 'miscellaneous-pipeline' : 'reconciliation:miscellaneous', error: message, }); } } /** * The sixth exploitation lane. It joins the same settled Capella SARIF the five fixed classes * join and runs concurrently with them, rather than trailing them serially. Only cancellation * escapes; runMiscellaneousPipeline reports every other outcome as durable state and non-fatal reasons. */ async function runMiscellaneousExploitLane(capella: Promise): Promise { const settled = await capella; if (settled.outcome === 'cancelled') throw settled.error; if (settled.sarif !== undefined) await runMiscellaneousPipeline(settled.sarif); } function recordAssemblyOmissions(failedClasses: readonly ReconciliationClass[]): void { for (const vulnerabilityClass of failedClasses) { // The append rules drop the omission when the class already carries an upstream reason. addPartialReason({ code: 'report_class_omitted', vulnerabilityClass }); const isAnalysisClass = (ALL_VULN_CLASSES as readonly string[]).includes(vulnerabilityClass); if (isAnalysisClass && !(activityInput.failedClasses ?? []).includes(vulnerabilityClass as VulnClass)) { activityInput.failedClasses = [...(activityInput.failedClasses ?? []), vulnerabilityClass as VulnClass]; } } } /** True only for the exact retryable SARIF-render failure type after its own activity retry policy exhausted; nothing else may trigger degraded finalization. */ function isSarifRenderExhaustion(error: unknown): boolean { return applicationFailureInChain(error)?.type === 'ReportSarifRenderError'; } /** * Drive the durable report state machine from wherever a fresh or resumed run finds it * (pending, draft, or finalized) through to a finalized, surfaced report. Each stage below * persists its result before the next stage begins, so a crash mid-pipeline resumes from the * last persisted stage instead of re-running work that already completed. */ async function finalizeReportPipeline(): Promise { state.currentPhase = 'reporting'; state.currentAgent = 'report'; await a.logPhaseTransition(activityInput, 'reporting', 'start'); if (state.reportProgress === undefined) { const renumberFailed: ReconciliationClass[] = []; if (exploit) { for (const vulnerabilityClass of state.participatingClasses) { const key = `report:renumber:${vulnerabilityClass}`; try { await runOperation(key, `Renumber ${vulnerabilityClass}`, () => deterministicReportActs.renumberClassFindings(activityInput, vulnerabilityClass), ); } catch (error) { if (hasCancellationInCauseChain(error)) throw error; renumberFailed.push(vulnerabilityClass); addPartialReason({ code: 'report_renumber_failed', vulnerabilityClass }); addNonFatal({ phase: key, error: REPORT_RENUMBER_FAILURE }); } } } state.reportProgress = await runOperation('report:initialize', 'Initialize report state', () => deterministicReportActs.initializeReportProgress(activityInput, renumberFailed, partialReasons), ); adoptDurableReasons(state.reportProgress.partial_reasons); } if (state.reportProgress.stage === 'pending') { const assembled = await runOperation('report:assemble', 'Assemble report inputs', () => deterministicReportActs.assembleReportActivity(activityInput, exploit), ); recordAssemblyOmissions(assembled.failedClasses); const reportMetrics = await a.runReportAgent(activityInput, exploit); state.agentMetrics.report = reportMetrics; if (reportMetrics.checkpoint === undefined) { throw ApplicationFailure.nonRetryable( 'The report was written but could not be saved. Re-running this workspace retries the reporting phase without repeating the analysis.', 'ReportDraftError', ); } state.reportProgress = { stage: 'draft', renumber_failed_classes: [...state.reportProgress.renumber_failed_classes], partial_reasons: [...state.reportProgress.partial_reasons], model_checkpoint: reportMetrics.checkpoint, }; } if (state.reportProgress.stage === 'draft' && state.reportProgress.canonical_checkpoint === undefined) { let canonicalCheckpoint = state.reportProgress.model_checkpoint; if (exploit) { try { const compacted = await runOperation('report:compact', 'Compact report findings', () => deterministicReportActs.compactReportFindings(activityInput), ); canonicalCheckpoint = compacted.checkpoint ?? canonicalCheckpoint; } catch (error) { if (hasCancellationInCauseChain(error)) throw error; addPartialReason({ code: 'report_compaction_failed' }); addNonFatal({ phase: 'report:compact', error: REPORT_COMPACTION_FAILURE }); } } state.reportProgress = await runOperation('report:checkpoint', 'Saving report progress', () => deterministicReportActs.persistCanonicalReportProgress(activityInput, canonicalCheckpoint, partialReasons), ); adoptDurableReasons(state.reportProgress.partial_reasons); } let finalized: FinalizeReportActivityResult; try { finalized = await runOperation('report:finalize', 'Finalize report outputs', () => finalReportActs.finalizeReportOutputs(activityInput), ); } catch (error) { if (hasCancellationInCauseChain(error)) throw error; // Only the exact retryable SARIF render type may degrade, and only after its ordinary // three-attempt policy exhausted. The degraded call still adopts a coherent earlier // commit first, so a prior committed finalization keeps its committed disposition. if (!isSarifRenderExhaustion(error)) throw error; finalized = await runOperation('report:finalize-degraded', 'Finalize report without SARIF', () => finalReportActs.finalizeReportOutputs(activityInput, true), ); } if (finalized.sarifDisposition === 'render_failed') { addPartialReason({ code: 'report_sarif_failed' }); } state.reportProgress = await runOperation('report:terminal', 'Saving final report state', () => deterministicReportActs.persistFinalizedReportProgress( activityInput, finalized.checkpoint, finalized.manifestSha256, { sarifDisposition: finalized.sarifDisposition, pdfProvenance: finalized.pdfProvenance, partialReasons, }, ), ); adoptDurableReasons(state.reportProgress.partial_reasons); markCompleted('report'); if (finalized.warningCount > 0) { addNonFatal({ phase: 'report-output', error: 'One or more derived report outputs emitted warnings.' }); } try { const surfaced = await runOperation('report:surface', 'Surface customer report', () => surfaceReportActs.surfaceReportOutputs(activityInput), ); if (surfaced.warningCount > 0) { addNonFatal({ phase: 'report-surface', error: 'One or more customer report copies emitted warnings.' }); } } catch (error) { if (hasCancellationInCauseChain(error)) throw error; addNonFatal({ phase: 'report-surface', error: 'Customer report copies could not be refreshed; canonical outputs remain finalized.', }); } await a.logPhaseTransition(activityInput, 'reporting', 'complete'); } try { const durable = await deterministicReportActs.initializeDurableScanState(activityInput, exploit, stateContext); applyDurableSummary(durable); if (input.resumeFromWorkspace) { // The new workflow id lands in session.json before anything that can reject the resume, so a // validation or checkpoint-restore failure still leaves the CLI an attempt to follow. await deterministicReportActs.registerResumeAttempt(activityInput, input.terminatedWorkflows ?? []); resumeState = await deterministicReportActs.loadResumeState( input.resumeFromWorkspace, input.webUrl, input.repoPath, { ...(input.deliverablesSubdir !== undefined && { deliverablesSubdir: input.deliverablesSubdir }), expectedExploit: exploit, }, ); state.expectedAgents = [...resumeState.expectedAgents]; state.participatingClasses = [...resumeState.participatingClasses]; if (resumeState.miscellaneousOutcome !== undefined) miscellaneousOutcome = resumeState.miscellaneousOutcome; if (resumeState.reportProgress !== undefined) { state.reportProgress = resumeState.reportProgress; // Durable reasons are restored, never reconstructed from session status or errors. adoptDurableReasons(resumeState.reportProgress.partial_reasons); } const expectedAgentNames = resumeState.expectedAgents.filter(isAgentName); const incompleteAgents = expectedAgentNames.filter( (agentName) => !resumeState?.completedAgents.includes(agentName), ); await deterministicReportActs.restoreGitCheckpoint( input.repoPath, resumeState.checkpointHash, incompleteAgents, input.deliverablesSubdir, { expectedAgents: expectedAgentNames, participatingClasses: resumeState.participatingClasses, ...(resumeState.reportProgress !== undefined && { reportProgress: resumeState.reportProgress }), }, ); await deterministicReportActs.recordResumeAttempt( activityInput, resumeState.checkpointHash, resumeState.originalWorkflowId, resumeState.completedAgents, ); for (const agentName of resumeState.completedAgents) { if (isAgentName(agentName)) markCompleted(agentName); } } state.currentPhase = 'preflight'; state.currentAgent = null; await preflightActs.runPreflightValidation(activityInput); await preflightActs.syncPlaywrightStealthConfig(activityInput); state.currentPhase = 'auth-validation'; state.currentAgent = 'validate-authentication'; const authMetrics = await authValidationActs.runAuthenticationValidation(activityInput); if (authMetrics !== null) state.agentMetrics['validate-authentication'] = authMetrics; state.currentAgent = null; await a.initDeliverableGit(activityInput); await a.syncCodePathDenyRules(activityInput); const allExpectedDone = state.expectedAgents.every((agentName) => state.completedAgents.includes(agentName)); // A durable draft means report.json is already committed, so re-running the pentest phase // cannot change what the report says. It would only re-pay for the analysis and observe new // degradation reasons that the finalized deliverable, rendered from durable state, could never // carry — leaving the report claiming complete coverage while the session records a partial // run. An invalid draft is rolled back to `pending` during resume, so it still re-runs here. const reportAlreadyAuthored = reportIsAuthored(resumeState?.reportProgress?.stage); if (!allExpectedDone && !reportAlreadyAuthored) { // Agentic SAST overlaps the pentest: it starts here and is joined per class before // reconciliation, so preliminary analysis and reconnaissance never wait on it. const settlement = settleCapella(); capellaSettlement = settlement; await runSequentialPhase('pre-recon', 'pre-recon', a.runPreReconAgent); await runSequentialPhase('recon', 'recon', a.runReconAgent); state.currentPhase = 'vulnerability-exploitation'; state.currentAgent = 'pipelines'; await a.logPhaseTransition(activityInput, 'vulnerability-exploitation', 'start'); const pipelineThunks = buildPipelineConfigs().map( (config) => () => runVulnExploitPipeline(config.vulnType, config.runVuln, config.runExploit, settlement), ); // Launch the Miscellaneous lane concurrently with the five fixed classes; it shares the same // settled SARIF and joins the common barrier below. const miscellaneousLane = runMiscellaneousExploitLane(settlement); const pipelineResults = await runWithConcurrencyLimit(pipelineThunks, MAX_CONCURRENT_PIPELINES); // Join the sixth lane before aggregation so its outcome is always observed (never a // dropped rejection in the workflow VM) and a cancellation from either path propagates. await miscellaneousLane; aggregatePipelineResults(pipelineResults); if (state.failedPipelines.length > 0) { activityInput.failedClasses = state.failedPipelines.map((failure) => failure.vulnType); } await a.logPhaseTransition(activityInput, 'vulnerability-exploitation', 'complete'); } await finalizeReportPipeline(); // One terminal contract everywhere: reaching this point proved the canonical report // (a failed proof throws), so the durable reason set alone decides completed vs partial. // PDF and customer-copy warnings never create reasons and never change the status. const terminalStatus: 'completed' | 'partial' = partialReasons.length > 0 ? 'partial' : 'completed'; state.status = terminalStatus; state.currentPhase = null; state.currentAgent = null; state.summary = computeSummary(state, usageAccountingComplete()); await a.logWorkflowComplete(activityInput, toWorkflowSummary(state, terminalStatus)); return state; } catch (error) { if (hasCancellationInCauseChain(error)) { // A cancelled parent lets the child settle first; its cancellation is only complete once // the child's own promise settles. The cancellation being reported is unchanged by it. if (capellaSettlement !== null) await awaitCapellaSettlement(capellaSettlement); projectUnfinishedCapella(); state.status = 'cancelled'; state.error = `Cancelled during phase: ${state.currentPhase ?? 'unknown'}`; state.summary = computeSummary(state, usageAccountingComplete()); await CancellationScope.nonCancellable(async () => { try { await a.logWorkflowComplete(activityInput, toWorkflowSummary(state, 'cancelled')); } catch { log.warn('Failed to finalize cancelled workflow', { code: 'WORKFLOW_LOG_WRITE_FAILED' }); } }); return state; } // A hard failure does not wait for the child; parent-close termination is its cleanup. projectUnfinishedCapella(); state.status = 'failed'; state.failedAgent = state.currentAgent; state.error = formatWorkflowError(error, state.currentPhase, state.currentAgent); const errorCode = classifyErrorCode(error); if (errorCode) state.errorCode = errorCode; state.summary = computeSummary(state, usageAccountingComplete()); try { await a.logWorkflowComplete(activityInput, toWorkflowSummary(state, 'failed')); } catch { log.warn('Failed to finalize failed workflow', { code: 'WORKFLOW_LOG_WRITE_FAILED' }); } // Terminate the workflow in Temporal's FAILED state. WARNING: this must be an // ApplicationFailure — any other thrown type becomes an unhandled workflow-task failure // that Temporal retries indefinitely, leaving the run stuck in RUNNING. throw ApplicationFailure.nonRetryable(state.error ?? 'Pipeline failed', 'PipelineExecutionError'); } } /** OSS workflow entry point. */ export async function pentestPipelineWorkflow(input: PipelineInput): Promise { return pentestPipeline(input); }