mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-09-13 13:39:04 +02:00
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:
+149
-27
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user