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.
This commit is contained in:
ajmallesh
2026-08-27 14:28:15 -07:00
parent e3c6e8df16
commit 321f441f4b
10 changed files with 407 additions and 103 deletions
+2 -1
View File
File diff suppressed because one or more lines are too long
+149 -27
View File
@@ -9,7 +9,9 @@
import type { RunningAgent } from '../temporal-client.js';
import {
AGENTIC_SAST_STAGE_ORDER,
agentClass,
isModelBackedOperation,
type OperationalStageState,
operationFamilyKey,
type PipelineState,
@@ -31,14 +33,31 @@ export interface DerivedAgent {
readonly attempt: number | null;
/** The step a running operation row is currently on, merged in from its child activity. */
readonly detail?: string;
/** Reconciliation time for this agent's class, rendered as a trailing `+ duration`.
* Reconciliation is model work that produces this agent's inputs, so it is shown
* attached to the agent it feeds rather than as free-floating background work. */
readonly attachedMs?: number;
/** This class's findings could not be grouped, so each one became its own task. */
readonly ungrouped?: boolean;
readonly error?: string;
}
/** How a phase line summarizes itself: its own wall time, or a k/N tally over its children. */
export type PhaseMetaKind = 'duration' | 'count';
export interface DerivedPhase {
readonly key: string;
readonly label: string;
readonly parallel: boolean;
/** Whether the phase renders its agents as sub-rows. Independent of {@link meta}:
* Agentic SAST lists its stages under a duration, exploitation lists its classes under a tally. */
readonly children: boolean;
readonly meta: PhaseMetaKind;
readonly state: RunState;
/** The phase's own span, when the worker records one for the phase rather than for a single
* agent inside it (Agentic SAST). The phase line presents this exactly like an agent row. */
readonly summary?: DerivedAgent;
/** Rendered after the phase's summary, e.g. to mark work that overlaps other phases. */
readonly note?: string;
readonly agents: readonly DerivedAgent[];
}
@@ -205,7 +224,8 @@ export function derivePipeline(input: RenderInput, now: number): DerivedPhase[]
return {
key: phase.key,
label: phase.label,
parallel: phase.parallel,
children: phase.parallel,
meta: phase.parallel ? ('count' as const) : ('duration' as const),
state: phaseGlyphState(agents.map((ag) => ag.state)),
agents,
};
@@ -248,35 +268,137 @@ export function derivePipeline(input: RenderInput, now: number): DerivedPhase[]
};
});
// The synthetic phase(s) appear only when there is operational work to show, so a scan
// with no recorded operational stages keeps the plain agent tree.
if (operationalAgents.length === 0) return agentPhases;
// Operational rows are not peers of the agents. Each one is either model work that
// belongs to an agent (reconciliation), model work that belongs to the SAST engine
// (its stages), or bookkeeping that only earns a row when it is stuck or broken.
return assemblePhases(agentPhases, operationalAgents);
}
// Agentic SAST is a pluggable analysis engine — a peer to the pentest, not background plumbing —
// so it stands in its own phase; reconciliation and report steps remain under "Background work".
const engineAgents = operationalAgents.filter((agent) => operationFamilyKey(agent.name) === 'agentic-sast');
const backgroundAgents = operationalAgents.filter((agent) => operationFamilyKey(agent.name) !== 'agentic-sast');
/** Reconciliation wall time per vulnerability class, plus the classes whose grouping degraded. */
interface ReconciliationView {
readonly durationByClass: ReadonlyMap<string, number>;
readonly ungroupedClasses: ReadonlySet<string>;
}
const syntheticPhases: DerivedPhase[] = [];
if (engineAgents.length > 0) {
syntheticPhases.push({
key: 'analysis-engines',
label: 'Analysis Engines',
parallel: true,
state: phaseGlyphState(engineAgents.map((operation) => operation.state)),
agents: engineAgents,
});
function reconciliationView(operations: readonly DerivedAgent[]): ReconciliationView {
const durationByClass = new Map<string, number>();
const ungroupedClasses = new Set<string>();
for (const operation of operations) {
if (operationFamilyKey(operation.name) !== 'reconciliation') continue;
const [, vulnerabilityClass] = operation.name.split(':');
if (vulnerabilityClass === undefined) continue;
if (operation.name.endsWith(':fallback')) {
ungroupedClasses.add(vulnerabilityClass);
continue;
}
if (operation.durationMs !== null) durationByClass.set(vulnerabilityClass, operation.durationMs);
}
if (backgroundAgents.length > 0) {
syntheticPhases.push({
key: 'operational-work',
label: 'Background work',
parallel: true,
state: phaseGlyphState(backgroundAgents.map((operation) => operation.state)),
agents: backgroundAgents,
});
return { durationByClass, ungroupedClasses };
}
/** Attach each class's reconciliation time to the agent row it feeds. */
function withReconciliation(phase: DerivedPhase, view: ReconciliationView): DerivedPhase {
const agents = phase.agents.map((agent): DerivedAgent => {
const vulnerabilityClass = agentClass(agent.name);
const attachedMs = view.durationByClass.get(vulnerabilityClass);
const ungrouped = view.ungroupedClasses.has(vulnerabilityClass);
return {
...agent,
...(attachedMs !== undefined && { attachedMs }),
...(ungrouped && { ungrouped }),
};
});
return { ...phase, agents };
}
/**
* Build the Agentic SAST phase from the aggregate span the parent workflow records and the
* per-stage rows the SAST child signals up. Scans that predate stage signalling have the
* aggregate but no stages, and render as a bare phase line rather than an error.
*/
function agenticSastPhase(operations: readonly DerivedAgent[]): DerivedPhase | undefined {
const aggregate = operations.find((operation) => operation.name === 'agentic-sast');
if (aggregate === undefined) return undefined;
const byStage = new Map<string, DerivedAgent>();
for (const operation of operations) {
const [family, stage] = operation.name.split(':');
if (family !== 'agentic-sast' || stage === undefined) continue;
// The worker's label is the scan log's Title Case form. These rows sit beside the
// lowercase class rows below them, so they read in the same register here.
byStage.set(stage, { ...operation, label: lowercaseFirst(operation.label) });
}
return [...agentPhases, ...syntheticPhases];
// Run order, not insertion order: a resumed or replayed run can persist stages out of order.
const stages = AGENTIC_SAST_STAGE_ORDER.map((stage) => byStage.get(stage)).filter(
(stage): stage is DerivedAgent => stage !== undefined,
);
return {
key: 'agentic-sast',
label: 'Agentic SAST',
children: stages.length > 0,
meta: 'duration',
state: aggregate.state,
summary: aggregate,
// It shares wall time with the pentest phases below it, so the times do not add up
// in sequence. Saying so is cheaper than a layout that pretends to be two columns.
note: 'concurrent',
agents: stages,
};
}
/**
* Bookkeeping rows worth showing. A deterministic stage that has completed says nothing —
* it can only ever read 0s — but one that is still running, or that failed, is exactly what
* an operator needs to see, so those keep a row under the phase they belong to.
*/
function troubledReportSteps(operations: readonly DerivedAgent[]): readonly DerivedAgent[] {
return operations.filter((operation) => {
if (isModelBackedOperation(operation.name)) return false;
if (operationFamilyKey(operation.name) !== 'report') return false;
return operation.state === 'running' || operation.state === 'failed';
});
}
/**
* Fold operational rows into the agent phases. Nothing here becomes a bucket of its own:
* every surviving row is either a SAST stage, time attached to an agent, or a report step
* that is currently in trouble.
*/
function assemblePhases(agentPhases: readonly DerivedPhase[], operations: readonly DerivedAgent[]): DerivedPhase[] {
const view = reconciliationView(operations);
// Reconciliation produces the exploitation queue, so its time belongs on the exploitation
// row it feeds. With exploitation off there is no such row, and it falls back to the
// analysis row for the same class so the time is never silently dropped.
const attachTo = agentPhases.some((phase) => phase.key === 'exploitation')
? 'exploitation'
: 'vulnerability-analysis';
const reportSteps = troubledReportSteps(operations);
const phases = agentPhases.map((phase) => {
if (phase.key === attachTo) return withReconciliation(phase, view);
if (phase.key === 'reporting' && reportSteps.length > 0) {
// The report agent stays on the phase line it already titles; the steps in trouble
// become its children, so nothing is listed twice.
const summary = phase.agents[0];
return {
...phase,
children: true,
...(summary !== undefined && { summary }),
state: phaseGlyphState([...phase.agents, ...reportSteps].map((row) => row.state)),
agents: reportSteps,
};
}
return phase;
});
const sast = agenticSastPhase(operations);
if (sast === undefined) return phases;
// Agentic SAST starts with the scan and runs alongside the pentest, so it reads after
// the login check rather than appended past Reporting where it never ran.
const afterAuth = phases.findIndex((phase) => phase.key === 'auth-validation') + 1;
return [...phases.slice(0, afterAuth), sast, ...phases.slice(afterAuth)];
}
export { agentError };
+29
View File
@@ -302,6 +302,35 @@ export function operationFamilyKey(stageKey: string): string {
return separator === -1 ? stageKey : stageKey.slice(0, separator);
}
/** The Capella stages that get a progress row, in run order. Mirrors CAPELLA_PROGRESS_STAGES
* in apps/worker/src/ai/sast/types.ts — the deterministic `export` stage is not among them. */
export const AGENTIC_SAST_STAGE_ORDER: readonly string[] = [
'architecture',
'threat-model',
'plan',
'research',
'dedupe',
'review',
'critic',
'confirm',
'calibrate',
];
/**
* Whether an operational stage represents model work rather than bookkeeping.
*
* Only the agentic-SAST stages and per-class reconciliation run a model; every other
* operational stage is a git commit or a durable-state write that can only ever record
* sub-second wall time. The progress tree shows model work, so this is what decides
* whether a stage is worth a row at all.
*/
export function isModelBackedOperation(stageKey: string): boolean {
const family = operationFamilyKey(stageKey);
if (family === 'agentic-sast') return true;
// A `reconciliation:<class>:fallback` marker records a degradation, not a model span.
return family === 'reconciliation' && !stageKey.endsWith(':fallback');
}
export interface PipelineSummary {
readonly totalCostUsd: number;
readonly totalDurationMs: number; // Wall-clock (end - start)
+38 -11
View File
@@ -130,6 +130,13 @@ function statusBadge(input: RenderInput, opts: RenderOptions): string {
// === Line builders ===
/** The parts of a derived row agentMeta reads beyond its state and metrics. */
interface RowExtras {
readonly runningElapsedMs?: number | null;
readonly attachedMs?: number;
readonly ungrouped?: boolean;
}
function agentMeta(
state: RunState,
metrics: { durationMs: number } | undefined,
@@ -137,15 +144,20 @@ function agentMeta(
error: string | undefined,
opts: RenderOptions,
step?: string,
extras?: RowExtras,
): string {
if (state === 'completed') {
const duration = metrics?.durationMs != null ? formatDuration(metrics.durationMs) : 'done';
return paint(duration, COLORS.dim, opts.color);
return paint(`${duration}${attachedSuffix(extras)}`, COLORS.dim, opts.color);
}
if (state === 'running') {
const parts = ['running'];
if (step !== undefined) parts.push(step);
if (runner?.startedAt !== undefined) parts.push(formatDuration(opts.now - runner.startedAt));
// An operational row carries its own elapsed time: it is derived from the persisted stage
// span, and has no pending activity on the parent workflow to read a start time from.
const elapsedMs =
runner?.startedAt !== undefined ? opts.now - runner.startedAt : (extras?.runningElapsedMs ?? null);
if (elapsedMs !== null) parts.push(formatDuration(elapsedMs));
if (runner && runner.attempt > 1) parts.push(`retry ${runner.attempt}`);
return paint(parts.join(' · '), COLORS.gold, opts.color);
}
@@ -157,6 +169,17 @@ function agentMeta(
return paint('queued', COLORS.dim, opts.color);
}
/**
* Time a reconciliation lane contributed to this agent's class, shown as `+ duration` on the
* row it feeds. `ungrouped` marks a class whose findings could not be grouped, so each one
* was tested separately and duplicates are expected.
*/
function attachedSuffix(extras: RowExtras | undefined): string {
if (extras === undefined) return '';
const time = extras.attachedMs === undefined ? '' : ` + ${formatDuration(extras.attachedMs)}`;
return extras.ungrouped ? `${time} · ungrouped` : time;
}
function phaseMeta(states: readonly RunState[], inPlay: number, parallel: boolean, opts: RenderOptions): string {
if (states.every((s) => s === 'pending')) return paint('pending', COLORS.dim, opts.color);
if (states.every((s) => s === 'skipped')) return paint('skipped', COLORS.dim, opts.color);
@@ -184,20 +207,24 @@ export function renderScan(input: RenderInput, opts: RenderOptions): string {
const phaseRunState = phase.state;
const metaFor = (agent: (typeof phase.agents)[number]): string => {
const metrics = agent.durationMs === null ? undefined : { durationMs: agent.durationMs };
return agentMeta(agent.state, metrics, byAgent.get(agent.name), agent.error, opts, agent.detail);
return agentMeta(agent.state, metrics, byAgent.get(agent.name), agent.error, opts, agent.detail, agent);
};
// A single-agent phase carries that agent's own duration/cost on the phase line once it
// starts; a parallel phase gets a "k/N done" summary over the agents in play.
// A phase summarizes itself by wall time or by a "k/N done" tally. A phase with its own
// recorded span (Agentic SAST) presents it like any agent row; otherwise a single-agent
// phase borrows its one agent's duration once that agent starts.
const first = phase.agents[0];
const firstState = states[0];
const phaseMetaStr =
!phase.parallel && first && firstState && inPlay(firstState)
? metaFor(first)
: phaseMeta(states, playing, phase.parallel, opts);
lines.push(` ${glyph(phaseRunState, opts)} ${phase.label.padEnd(26)}${phaseMetaStr}`);
const borrowed = first && firstState && inPlay(firstState) ? metaFor(first) : undefined;
const durationMeta = phase.summary === undefined ? borrowed : metaFor(phase.summary);
const summaryMeta =
phase.meta === 'duration' && durationMeta !== undefined
? durationMeta
: phaseMeta(states, playing, phase.meta === 'count', opts);
const note = phase.note === undefined ? '' : paint(` · ${phase.note}`, COLORS.dim, opts.color);
lines.push(` ${glyph(phaseRunState, opts)} ${phase.label.padEnd(26)}${summaryMeta}${note}`);
if (!phase.parallel) continue;
if (!phase.children) continue;
for (let i = 0; i < phase.agents.length; i++) {
const agent = phase.agents[i];
const state = states[i];
+15 -2
View File
@@ -76,7 +76,18 @@ function isProviderFailureCategory(value: unknown): value is string {
const OPERATION_LABELS = new Set([
'Agentic SAST',
'Miscellaneous findings',
// Capella stage rows, signalled up from the SAST child workflow. Mirrors
// CAPELLA_STAGE_LABELS in apps/worker/src/ai/sast/types.ts, minus the deterministic
// export stage, which never becomes a row.
'Architecture',
'Threat model',
'Plan',
'Research',
'Dedupe',
'Review',
'Critique',
'Confirm',
'Calibrate',
'Reconcile injection',
'Reconcile xss',
'Reconcile auth',
@@ -220,7 +231,9 @@ export function safeOperationKey(value: string): string {
/^(?:agentic-sast|miscellaneous-pipeline|report:(?:initialize|assemble|compact|checkpoint|finalize|finalize-degraded|terminal|surface))$/u.test(
value,
) ||
/^(?:reconciliation|report:renumber):(?:injection|xss|auth|authz|ssrf|miscellaneous)$/u.test(value)
/^agentic-sast:(?:architecture|threat-model|plan|research|dedupe|review|critic|confirm|calibrate)$/u.test(value) ||
/^(?:reconciliation|report:renumber):(?:injection|xss|auth|authz|ssrf|miscellaneous)$/u.test(value) ||
/^reconciliation:(?:injection|xss|auth|authz|ssrf|miscellaneous):fallback$/u.test(value)
) {
return value;
}
@@ -16,17 +16,21 @@ import {
ActivityCancellationType,
ApplicationFailure,
ChildWorkflowCancellationType,
getExternalWorkflowHandle,
isCancellation,
proxyActivities,
workflowInfo,
} from '@temporalio/workflow';
import { capellaStageProgress } from '../../../../temporal/shared.js';
import { isProviderFailureCategory } from '../../../../types/errors.js';
import type {
AgenticSastFallbackReduction,
AgenticSastReduction,
CapellaRecoveredFailure,
CapellaRunResult,
CapellaStage,
CapellaUsage,
import {
type AgenticSastFallbackReduction,
type AgenticSastReduction,
CAPELLA_PROGRESS_STAGES,
type CapellaRecoveredFailure,
type CapellaRunResult,
type CapellaStage,
type CapellaUsage,
} from '../../types.js';
import { capellaSafeFailureMessage } from '../safe-failures.js';
import { usageAccountingWarning } from '../types.js';
@@ -309,6 +313,7 @@ export async function capellaWorkflow(input: CapellaWorkflowInput): Promise<Cape
reductions: [],
};
let currentStage: CapellaStage = 'architecture';
const stageStartedAt = new Map<CapellaStage, number>();
let lastGoodFindings:
| {
readonly artifact: CapellaFindingActivityInput['findingsArtifact'];
@@ -317,78 +322,112 @@ export async function capellaWorkflow(input: CapellaWorkflowInput): Promise<Cape
}
| undefined;
// Capella's activities live in this child's history, so the parent cannot observe them.
// Each stage boundary is signalled up instead, which is what puts stage rows in
// `shannon status`. Export is skipped: it runs no model, so the parent drops it anyway.
const parent = workflowInfo().parent;
async function signalStage(stage: CapellaStage, status: 'running' | 'completed' | 'failed'): Promise<void> {
if (parent === undefined || !CAPELLA_PROGRESS_STAGES.includes(stage)) return;
const startedAt = stageStartedAt.get(stage) ?? Date.now();
try {
await getExternalWorkflowHandle(parent.workflowId, parent.runId).signal(capellaStageProgress, {
stage,
status,
startedAt,
...(status !== 'running' && { durationMs: Date.now() - startedAt }),
});
} catch {
// Progress reporting is cosmetic. A parent that has already closed, or a signal that
// cannot be delivered, must never take down a SAST run that is otherwise fine.
}
}
/** Opens a stage's span and returns it, so the caller's `currentStage` cursor is a
* visible assignment rather than a hidden write from inside this closure. */
async function beginStage(stage: CapellaStage): Promise<CapellaStage> {
stageStartedAt.set(stage, Date.now());
await signalStage(stage, 'running');
return stage;
}
async function endStage<T>(stage: CapellaStage, result: CapellaActivityResult<T>): Promise<void> {
acceptStage(accumulator, stage, result);
await signalStage(stage, 'completed');
}
try {
currentStage = await beginStage('architecture');
const architecture = await architectureActivities.capellaArchitecture(
baseInput(input, CAPELLA_ACTIVITY_POLICIES.capellaArchitecture),
);
acceptStage(accumulator, 'architecture', architecture);
await endStage('architecture', architecture);
currentStage = 'threat-model';
currentStage = await beginStage('threat-model');
const threatModelInput: CapellaThreatModelActivityInput = {
...baseInput(input, CAPELLA_ACTIVITY_POLICIES.capellaThreatModel),
architectureArtifact: architecture.artifact,
};
const threatModel = await threatModelActivities.capellaThreatModel(threatModelInput);
acceptStage(accumulator, 'threat-model', threatModel);
await endStage('threat-model', threatModel);
currentStage = 'plan';
currentStage = await beginStage('plan');
const planInput: CapellaPlanActivityInput = {
...baseInput(input, CAPELLA_ACTIVITY_POLICIES.capellaPlan),
architectureArtifact: architecture.artifact,
threatModelArtifact: threatModel.artifact,
};
const plan = await planActivities.capellaPlan(planInput);
acceptStage(accumulator, 'plan', plan);
await endStage('plan', plan);
if (plan.value.investigationCount === 0) {
// Nothing to research: still run export so the scan always ends with a valid,
// empty SARIF artifact rather than an absent one.
currentStage = 'export';
currentStage = await beginStage('export');
const exported = await exportActivities.capellaExport(exportInput(input));
acceptStage(accumulator, 'export', exported);
await endStage('export', exported);
return succeededResult(startedAt, accumulator, exported);
}
currentStage = 'research';
currentStage = await beginStage('research');
const researchInput: CapellaResearchActivityInput = {
...baseInput(input, CAPELLA_ACTIVITY_POLICIES.capellaResearch),
architectureArtifact: architecture.artifact,
planArtifact: plan.artifact,
};
const research = await researchActivities.capellaResearch(researchInput);
acceptStage(accumulator, 'research', research);
await endStage('research', research);
lastGoodFindings = { artifact: research.artifact, stage: 'research', findingCount: research.value.findingCount };
if (research.value.findingCount === 0) {
currentStage = 'export';
currentStage = await beginStage('export');
const exported = await exportActivities.capellaExport(exportInput(input));
acceptStage(accumulator, 'export', exported);
await endStage('export', exported);
return succeededResult(startedAt, accumulator, exported);
}
currentStage = 'dedupe';
currentStage = await beginStage('dedupe');
const dedupeInput: CapellaFindingActivityInput = {
...baseInput(input, CAPELLA_ACTIVITY_POLICIES.capellaDedupe),
findingsArtifact: research.artifact,
};
const dedupe = await dedupeActivities.capellaDedupe(dedupeInput);
acceptStage(accumulator, 'dedupe', dedupe);
await endStage('dedupe', dedupe);
lastGoodFindings = { artifact: dedupe.artifact, stage: 'dedupe', findingCount: dedupe.value.findingCount };
currentStage = 'review';
currentStage = await beginStage('review');
const reviewInput: CapellaFindingActivityInput = {
...baseInput(input, CAPELLA_ACTIVITY_POLICIES.capellaReview),
findingsArtifact: dedupe.artifact,
};
const review = await reviewActivities.capellaReview(reviewInput);
acceptStage(accumulator, 'review', review);
await endStage('review', review);
lastGoodFindings = { artifact: review.artifact, stage: 'review', findingCount: review.value.findingCount };
let exportArtifact = review.artifact;
let exportStage: CapellaExportSourceStage = 'review';
const reviewedSurvivors = review.value.validCount + review.value.provisionalCount;
if (reviewedSurvivors > 0) {
currentStage = 'critic';
currentStage = await beginStage('critic');
const criticInput: CapellaKnowledgeFindingActivityInput = {
...baseInput(input, CAPELLA_ACTIVITY_POLICIES.capellaCritic),
findingsArtifact: review.artifact,
@@ -396,19 +435,19 @@ export async function capellaWorkflow(input: CapellaWorkflowInput): Promise<Cape
threatModelArtifact: threatModel.artifact,
};
const critic = await criticActivities.capellaCritic(criticInput);
acceptStage(accumulator, 'critic', critic);
await endStage('critic', critic);
lastGoodFindings = { artifact: critic.artifact, stage: 'critic', findingCount: critic.value.findingCount };
currentStage = 'confirm';
currentStage = await beginStage('confirm');
const confirmInput: CapellaFindingActivityInput = {
...baseInput(input, CAPELLA_ACTIVITY_POLICIES.capellaConfirm),
findingsArtifact: critic.artifact,
};
const confirm = await confirmActivities.capellaConfirm(confirmInput);
acceptStage(accumulator, 'confirm', confirm);
await endStage('confirm', confirm);
lastGoodFindings = { artifact: confirm.artifact, stage: 'confirm', findingCount: confirm.value.findingCount };
currentStage = 'calibrate';
currentStage = await beginStage('calibrate');
const calibrateInput: CapellaKnowledgeFindingActivityInput = {
...baseInput(input, CAPELLA_ACTIVITY_POLICIES.capellaCalibrate),
findingsArtifact: confirm.artifact,
@@ -416,7 +455,7 @@ export async function capellaWorkflow(input: CapellaWorkflowInput): Promise<Cape
threatModelArtifact: threatModel.artifact,
};
const calibrate = await calibrateActivities.capellaCalibrate(calibrateInput);
acceptStage(accumulator, 'calibrate', calibrate);
await endStage('calibrate', calibrate);
lastGoodFindings = {
artifact: calibrate.artifact,
stage: 'calibrate',
@@ -426,9 +465,9 @@ export async function capellaWorkflow(input: CapellaWorkflowInput): Promise<Cape
exportStage = 'calibrate';
}
currentStage = 'export';
currentStage = await beginStage('export');
const exported = await exportActivities.capellaExport(exportInput(input, exportArtifact, exportStage));
acceptStage(accumulator, 'export', exported);
await endStage('export', exported);
return succeededResult(startedAt, accumulator, exported);
} catch (error) {
// Cancellation must escape: absorbing it into a failed result would make a
@@ -438,6 +477,7 @@ export async function capellaWorkflow(input: CapellaWorkflowInput): Promise<Cape
if (hasCancellationInCauseChain(error)) throw error;
const failedStage = currentStage;
await signalStage(failedStage, 'failed');
const details = acceptFailureDetails(accumulator, error);
const safeError = capellaSafeFailureMessage(applicationFailure(error)?.type);
if (failedStage === 'export') {
@@ -464,7 +504,7 @@ export async function capellaWorkflow(input: CapellaWorkflowInput): Promise<Cape
const fallbackExport = await exportActivities.capellaExport(
exportInput(input, lastGoodFindings?.artifact, lastGoodFindings?.stage, fallbackReduction, fallbackFailure),
);
acceptStage(accumulator, 'export', fallbackExport);
await endStage('export', fallbackExport);
const recoveredFailure: CapellaRecoveredFailure = {
failedStage,
error: safeError,
+24
View File
@@ -32,6 +32,30 @@ export function isCapellaStage(value: string): value is CapellaStage {
return CAPELLA_STAGE_SET.has(value);
}
/**
* The one human-facing name per stage, shared by the scan log and the `shannon status`
* progress tree so an operator reads the same word in both places. This module imports
* nothing, so the parent workflow can use it inside the Temporal sandbox.
*/
export const CAPELLA_STAGE_LABELS: Readonly<Record<CapellaStage, string>> = {
architecture: 'Architecture',
'threat-model': 'Threat model',
plan: 'Plan',
research: 'Research',
dedupe: 'Dedupe',
review: 'Review',
critic: 'Critique',
confirm: 'Confirm',
calibrate: 'Calibrate',
export: 'Export',
};
/**
* Export writes artifacts but runs no model, so it is the one stage the progress tree
* leaves out: a row that can only ever read 0s tells an operator nothing.
*/
export const CAPELLA_PROGRESS_STAGES: readonly CapellaStage[] = CAPELLA_STAGES.filter((stage) => stage !== 'export');
export type CapellaFailurePoint = CapellaStage | 'workflow';
export interface CapellaUsage {
+5 -18
View File
@@ -9,7 +9,7 @@
import { promises as fsPromises } from 'node:fs';
import path from 'node:path';
import { isCapellaSafeFailureMessage, isCapellaTerminalStageLabel } from '../ai/sast/capella/safe-failures.js';
import type { CapellaStage } from '../ai/sast/types.js';
import { CAPELLA_STAGE_LABELS, type CapellaStage } from '../ai/sast/types.js';
import { type ErrorCode, isProviderFailureCategory } from '../types/errors.js';
import { isPartialReason, type PartialReasonView, projectPartialReasons } from '../types/run-state.js';
import { formatDuration, formatTimestamp } from '../utils/formatting.js';
@@ -88,19 +88,6 @@ export interface WorkflowSummary {
export type ChildTaskFailureCode = 'CANCELLED' | 'CHILD_TASK_FAILED';
const AGENTIC_SAST_STAGE_LABELS: Readonly<Record<CapellaStage, string>> = {
architecture: 'Architecture',
'threat-model': 'Threat model',
plan: 'Planning',
research: 'Audit wave',
dedupe: 'Deduplication',
review: 'Review',
critic: 'Critic',
confirm: 'Confirmation',
calibrate: 'Calibration',
export: 'Export',
};
function isSafeCount(value: number): boolean {
return Number.isSafeInteger(value) && value >= 0 && value <= 1_000_000_000;
}
@@ -361,7 +348,7 @@ export class WorkflowLogger {
await WorkflowLogger.writeStageStructuralLine(
workflowLogPath,
stage,
`[${new Date().toISOString().replace('T', ' ').slice(0, 19)}] [AGENTIC-SAST] ${AGENTIC_SAST_STAGE_LABELS[stage]}: Starting (attempt ${safeAttempt} of ${safeMaximum})`,
`[${new Date().toISOString().replace('T', ' ').slice(0, 19)}] [AGENTIC-SAST] ${CAPELLA_STAGE_LABELS[stage]}: Starting (attempt ${safeAttempt} of ${safeMaximum})`,
);
}
@@ -382,7 +369,7 @@ export class WorkflowLogger {
await WorkflowLogger.writeStageStructuralLine(
workflowLogPath,
stage,
`[${new Date().toISOString().replace('T', ' ').slice(0, 19)}] [AGENTIC-SAST] ${AGENTIC_SAST_STAGE_LABELS[stage]}: Completed (${details.join(', ')})`,
`[${new Date().toISOString().replace('T', ' ').slice(0, 19)}] [AGENTIC-SAST] ${CAPELLA_STAGE_LABELS[stage]}: Completed (${details.join(', ')})`,
);
}
@@ -402,7 +389,7 @@ export class WorkflowLogger {
await WorkflowLogger.writeStageStructuralLine(
workflowLogPath,
stage,
`[${new Date().toISOString().replace('T', ' ').slice(0, 19)}] [AGENTIC-SAST] ${AGENTIC_SAST_STAGE_LABELS[stage]}: ${outcome} (attempt ${safeAttempt} of ${safeMaximum}, ${safeCode})`,
`[${new Date().toISOString().replace('T', ' ').slice(0, 19)}] [AGENTIC-SAST] ${CAPELLA_STAGE_LABELS[stage]}: ${outcome} (attempt ${safeAttempt} of ${safeMaximum}, ${safeCode})`,
);
}
@@ -418,7 +405,7 @@ export class WorkflowLogger {
await WorkflowLogger.writeStageStructuralLine(
workflowLogPath,
stage,
`[${new Date().toISOString().replace('T', ' ').slice(0, 19)}] [AGENTIC-SAST] ${AGENTIC_SAST_STAGE_LABELS[stage]}: Cancelled (attempt ${safeAttempt} of ${safeMaximum}, CANCELLED)`,
`[${new Date().toISOString().replace('T', ' ').slice(0, 19)}] [AGENTIC-SAST] ${CAPELLA_STAGE_LABELS[stage]}: Cancelled (attempt ${safeAttempt} of ${safeMaximum}, CANCELLED)`,
);
}
+18 -1
View File
@@ -1,4 +1,4 @@
import { defineQuery } from '@temporalio/workflow';
import { defineQuery, defineSignal } from '@temporalio/workflow';
export type { AgentMetrics } from '../types/metrics.js';
@@ -230,3 +230,20 @@ export interface VulnExploitPipelineResult {
}
export const getProgress = defineQuery<PipelineProgress>('getProgress');
/**
* One Capella stage transition, reported by the SAST child workflow to its parent.
*
* Capella runs as a child workflow, so its activities never appear in the parent's
* pending activities and the CLI cannot observe them. This signal is how per-stage
* progress reaches the parent's durable `operationalStages`, which is what both the
* live `getProgress` query and the terminal result render from.
*/
export interface CapellaStageProgress {
readonly stage: CapellaStage;
readonly status: 'running' | 'completed' | 'failed';
readonly startedAt: number;
readonly durationMs?: number;
}
export const capellaStageProgress = defineSignal<[CapellaStageProgress]>('capellaStageProgress');
+56 -12
View File
@@ -31,7 +31,12 @@ 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 {
CAPELLA_PROGRESS_STAGES,
CAPELLA_STAGE_LABELS,
type CapellaRunResult,
type 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';
@@ -59,6 +64,8 @@ import {
} from './reconcile-activity-types.js';
import {
type AgentMetrics,
type CapellaStageProgress,
capellaStageProgress,
type DurableStateSummary,
type FinalizeReportActivityResult,
getProgress,
@@ -429,6 +436,10 @@ export async function pentestPipeline(input: PipelineInput): Promise<PipelineSta
}),
);
setHandler(capellaStageProgress, (progress: CapellaStageProgress): void => {
recordCapellaStage(progress);
});
const activityInput: ActivityInput = {
webUrl: input.webUrl,
repoPath: input.repoPath,
@@ -519,11 +530,6 @@ export async function pentestPipeline(input: PipelineInput): Promise<PipelineSta
};
}
/** 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<T>(key: string, label: string, operation: () => Promise<T>): Promise<T> {
const startedAt = startOperation(key, label);
try {
@@ -536,6 +542,46 @@ export async function pentestPipeline(input: PipelineInput): Promise<PipelineSta
}
}
/**
* Record one Capella stage transition signalled by the SAST child workflow.
*
* The payload crosses a workflow boundary, so every field is revalidated here rather
* than trusted: only model-backed stages and valid state/timing payloads are accepted.
*/
function recordCapellaStage(progress: unknown): void {
if (typeof progress !== 'object' || progress === null) return;
const candidate = progress as Record<string, unknown>;
const stageValue = candidate.stage;
if (typeof stageValue !== 'string') return;
const stage = CAPELLA_PROGRESS_STAGES.find((value) => value === stageValue);
if (stage === undefined) return;
const status = candidate.status;
if (status !== 'running' && status !== 'completed' && status !== 'failed') return;
const startedAt = candidate.startedAt;
if (!Number.isSafeInteger(startedAt) || (startedAt as number) < 0) return;
const key = `${CAPELLA_OPERATION_KEY}:${stage}`;
const label = CAPELLA_STAGE_LABELS[stage];
if (status === 'running') {
state.operationalStages[key] = { key, label, status: 'running', startedAt: startedAt as number };
return;
}
// Trust the child's own span for duration: the signal may be delivered after the stage
// ended, so measuring from the parent's clock here would inflate every stage.
const durationMs = candidate.durationMs;
if (!Number.isSafeInteger(durationMs) || (durationMs as number) < 0) {
return;
}
state.operationalStages[key] = {
key,
label,
status,
startedAt: startedAt as number,
durationMs: durationMs as number,
...(status === 'failed' && { error: OPERATION_FAILURE }),
};
}
function addReconciliationMetrics(
vulnerabilityClass: ReconciliationClass,
stage: 'enrich' | 'form',
@@ -1013,17 +1059,17 @@ export async function pentestPipeline(input: PipelineInput): Promise<PipelineSta
* the class was ever admitted for exploitation, rather than re-deciding admission from scratch.
*/
async function runMiscellaneousPipeline(effectiveSarif: SarifRef): Promise<void> {
const key = 'miscellaneous-pipeline';
const label = 'Miscellaneous findings';
// This lane records no operational stage of its own. It is a span around work that
// already reports itself -- `reconcileClass('miscellaneous')` and the miscellaneous
// exploit agent -- so a row here would count both a second time.
//
// An earlier run already settled this class. Re-deciding admission would ask durable state to
// move backwards, which fails closed and would be recorded as a class failure that never
// happened; re-running the lane would also repeat work that run already paid for.
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 });
@@ -1045,11 +1091,9 @@ export async function pentestPipeline(input: PipelineInput): Promise<PipelineSta
}
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' });