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
+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;
}