feat(logging): trace tool calls and write a log per agent

Record complete tool-call arguments in the workflow log and project each agent's events into its own durable log.

Add agent listing and agent-specific log tailing while preserving byte-exact output and draining log handles before
activities return.
This commit is contained in:
ajmallesh
2026-08-26 20:13:03 -07:00
parent 242f85f158
commit f5e7143619
43 changed files with 2694 additions and 1177 deletions
+8 -9
View File
@@ -16,6 +16,7 @@ import {
pipelineForState,
} from './pipeline.js';
import type { RenderInput } from './render.js';
import { safeFailureDetail, safeOperationKey, safeOperationLabel } from './safe-fields.js';
export type RunState = 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
@@ -77,11 +78,9 @@ function agentState(name: string, state: PipelineState | null, running: Set<stri
*/
function agentError(name: string, state: PipelineState | null, byAgent: Map<string, RunningAgent>): string | undefined {
const failed = state?.failedPipelines.find((f) => f.vulnType === agentClass(name));
return (
failed?.error ??
byAgent.get(name)?.lastFailure ??
(state?.failedAgent === name ? (state.error ?? undefined) : undefined)
);
const hasFailure =
failed !== undefined || byAgent.get(name)?.lastFailure !== undefined || state?.failedAgent === name;
return safeFailureDetail(hasFailure);
}
/** Scan wall-clock elapsed ms: recorded duration for a closed scan, live elapsed for a running one. */
@@ -229,7 +228,7 @@ export function derivePipeline(input: RenderInput, now: number): DerivedPhase[]
label: runner.label,
status: 'running' as const,
...(runner.startedAt !== undefined && { startedAt: runner.startedAt }),
...(runner.lastFailure !== undefined && { error: runner.lastFailure }),
...(runner.lastFailure !== undefined && { error: safeFailureDetail(true) }),
}));
const operationalAgents: DerivedAgent[] = [...persistedOperations, ...unpersistedRunning].map((operation) => {
const runner = byAgent.get(operation.key);
@@ -237,15 +236,15 @@ export function derivePipeline(input: RenderInput, now: number): DerivedPhase[]
const persistedDurationMs = 'durationMs' in operation ? (operation.durationMs ?? null) : null;
const detail = operationState === 'running' ? stepByFamily.get(operationFamilyKey(operation.key)) : undefined;
return {
name: operation.key,
label: operation.label,
name: safeOperationKey(operation.key),
label: safeOperationLabel(operation.label),
state: operationState,
durationMs: operationState === 'completed' ? persistedDurationMs : null,
runningElapsedMs:
operationState === 'running' && operation.startedAt !== undefined ? now - operation.startedAt : null,
attempt: operationState === 'running' ? (runner?.attempt ?? null) : null,
...(detail !== undefined && { detail }),
...(operation.error !== undefined && { error: operation.error }),
...(operation.error !== undefined && { error: safeFailureDetail(true) }),
};
});
+9 -6
View File
@@ -12,6 +12,7 @@ import { commandPrefix } from '../mode.js';
import type { RunningAgent } from '../temporal-client.js';
import { derivePipeline, isTerminal, type RunState, scanElapsedMs } from './derive.js';
import type { PipelineState } from './pipeline.js';
import { safeAgenticSast, safeCliIdentifier, safePartialReasons, safeTerminalFailure } from './safe-fields.js';
export interface RenderInput {
readonly workspace: string;
@@ -67,7 +68,7 @@ function truncate(text: string, max: number): string {
/** Temporal Web UI, published by compose on 8233; deep-links to the workflow when its id is known. */
function temporalDashboardUrl(workflowId: string | undefined): string {
const base = 'http://localhost:8233';
return workflowId ? `${base}/namespaces/default/workflows/${workflowId}` : base;
return workflowId ? `${base}/namespaces/default/workflows/${safeCliIdentifier(workflowId)}` : base;
}
// === Glyphs & status ===
@@ -214,7 +215,7 @@ export function renderScan(input: RenderInput, opts: RenderOptions): string {
function headerLines(input: RenderInput, opts: RenderOptions): string[] {
const elapsedMs = scanElapsedMs(input, opts.now);
const meta = [statusBadge(input, opts), elapsedMs !== undefined ? formatDuration(elapsedMs) : '—'].join(' · ');
return [` ${paint('Scan:', COLORS.bold, opts.color)} ${input.workspace.padEnd(22)} ${meta}`];
return [` ${paint('Scan:', COLORS.bold, opts.color)} ${safeCliIdentifier(input.workspace).padEnd(22)} ${meta}`];
}
/** Aligned label column for the footer's Logs / Temporal rows. */
@@ -239,7 +240,7 @@ function footerLines(input: RenderInput, opts: RenderOptions): string[] {
// A partial scan names each durable degradation reason through its safe message,
// so the operator never has to guess why the badge is not "completed".
const reasons = input.state.partialReasons ?? [];
const reasons = safePartialReasons(input.state.partialReasons ?? []);
if (reasons.length > 0) {
lines.push('', ` ${paint('Why this scan is partial:', COLORS.yellow, opts.color)}`);
for (const reason of reasons) {
@@ -247,7 +248,7 @@ function footerLines(input: RenderInput, opts: RenderOptions): string[] {
}
// The safe message names what degraded; these three name the agentic-SAST failure
// behind it, under the same labels the scan log and worker output use.
const agenticSast = input.state.agenticSast;
const agenticSast = safeAgenticSast(input.state.agenticSast);
if (agenticSast?.status === 'failed') {
if (agenticSast.failedStageLabel !== undefined) {
lines.push(paint(` Agentic SAST stopped at: ${agenticSast.failedStageLabel}`, COLORS.dim, opts.color));
@@ -268,11 +269,13 @@ function footerLines(input: RenderInput, opts: RenderOptions): string[] {
return lines;
}
const logsValue = `${prefix} logs ${input.workspace}`;
const logsValue = `${prefix} logs ${safeCliIdentifier(input.workspace)}`;
const temporalValue = temporalDashboardUrl(input.workflowId);
if (isTerminal(input.temporalStatus)) {
const reason = input.failureMessage ?? input.state?.error ?? 'no result recorded';
const hasRecordedFailure =
input.failureMessage !== undefined || (input.state !== null && input.state.error !== null);
const reason = safeTerminalFailure(hasRecordedFailure) ?? 'no result recorded';
return [
footerDivider(opts),
paint(
+215
View File
@@ -0,0 +1,215 @@
/** Closed-field projection for Temporal values displayed by the CLI. */
import type { PartialReasonView, PipelineState } from './pipeline.js';
const CLASS_NAMES: Readonly<Record<string, string>> = Object.freeze({
injection: 'Injection',
xss: 'Cross-Site Scripting',
auth: 'Authentication',
authz: 'Authorization',
ssrf: 'Server-Side Request Forgery',
miscellaneous: 'Miscellaneous',
});
const STAGE_NAMES: Readonly<Record<string, string>> = Object.freeze({
architecture: 'architecture mapping',
'threat-model': 'threat modelling',
plan: 'review planning',
research: 'deep code research',
dedupe: 'duplicate merging',
review: 'independent review',
critic: 'viability critique',
confirm: 'static confirmation',
calibrate: 'risk calibration',
export: 'findings export',
workflow: 'orchestration',
});
const TERMINAL_STAGE_NAMES = new Set([
'architecture',
'threat model',
'planning',
'audit wave',
'deduplication',
'review',
'critic',
'confirmation',
'calibration',
'export',
'orchestration',
]);
const CAPELLA_FAILURE_MESSAGES = new Set([
'Provider authentication failed. Verify the configured credential.',
'Agentic SAST configuration is invalid.',
'Agentic SAST received invalid input.',
'An agentic SAST step returned an unusable result.',
'An agentic SAST step failed.',
'Agentic SAST infrastructure failed before producing a usable result.',
'Agentic SAST had not finished when the scan stopped.',
]);
const OPERATION_LABELS = new Set([
'Agentic SAST',
'Miscellaneous findings',
'Reconcile injection',
'Reconcile xss',
'Reconcile auth',
'Reconcile authz',
'Reconcile ssrf',
'Reconcile miscellaneous',
'Prepare reconciliation',
'Enrich observations',
'Form exploit tasks',
'Materialize exploit tasks',
'Publish reconciliation',
'Renumber injection',
'Renumber xss',
'Renumber auth',
'Renumber authz',
'Renumber ssrf',
'Renumber miscellaneous',
'Initialize report state',
'Assemble report inputs',
'Compact report findings',
'Saving report progress',
'Finalize report outputs',
'Finalize report without SARIF',
'Saving final report state',
'Surface customer report',
]);
function safeClassName(value: string | undefined): string | undefined {
return value === undefined ? undefined : CLASS_NAMES[value];
}
function safeStageName(value: string | undefined): string | undefined {
return value === undefined ? undefined : STAGE_NAMES[value];
}
function reasonMessage(reason: PartialReasonView): string | undefined {
const className = safeClassName(reason.vulnerabilityClass);
switch (reason.code) {
case 'agentic_sast_failed': {
const stageName = safeStageName(reason.stage);
return stageName === undefined
? 'Agentic SAST failed, so the pentest continued without its findings.'
: `Agentic SAST failed during ${stageName}, so the pentest continued without its findings.`;
}
case 'agentic_sast_reduced':
return 'Agentic SAST completed with reduced coverage.';
case 'class_pipeline_failed':
return className === undefined
? undefined
: `${className} could not be fully assessed. The other classes completed. Re-running this workspace retries only the part that failed.`;
case 'class_reconciliation_failed':
return className === undefined
? undefined
: `${className} findings could not be grouped into test cases, so that class was not exploited. Its analysis results are still in the report.`;
case 'report_renumber_failed':
return className === undefined
? undefined
: `${className} findings kept their working reference numbers, so numbering in the report may have gaps. The findings themselves are complete.`;
case 'report_compaction_failed':
return 'Finding reference numbers in the report may have gaps. Every finding is present; only the numbering is affected.';
case 'report_class_omitted':
return className === undefined
? undefined
: `${className} was assessed but could not be included in the final report.`;
case 'report_sarif_failed':
return 'Report SARIF could not be generated. JSON and Markdown remain available.';
default:
return undefined;
}
}
export function safePartialReasons(reasons: readonly PartialReasonView[]): readonly PartialReasonView[] {
return reasons.flatMap((reason) => {
const message = reasonMessage(reason);
if (message === undefined) return [];
const vulnerabilityClass =
safeClassName(reason.vulnerabilityClass) === undefined ? undefined : reason.vulnerabilityClass;
const stage = safeStageName(reason.stage) === undefined ? undefined : reason.stage;
return [
{
code: reason.code,
message,
...(vulnerabilityClass !== undefined && { vulnerabilityClass }),
...(stage !== undefined && { stage }),
},
];
});
}
export function safeAgenticSast(value: PipelineState['agenticSast']):
| {
readonly status: string;
readonly failedStageLabel?: string;
readonly error?: string;
readonly errorCode?: string;
}
| undefined {
if (value === undefined || !['disabled', 'running', 'succeeded', 'failed'].includes(value.status)) return undefined;
const failedStageLabel = TERMINAL_STAGE_NAMES.has(value.failedStageLabel ?? '') ? value.failedStageLabel : undefined;
let error: string | undefined;
if (value.error !== undefined && CAPELLA_FAILURE_MESSAGES.has(value.error)) {
error = value.error;
} else if (value.status === 'failed') {
error = 'An agentic SAST step failed.';
}
const errorCode =
value.errorCode !== undefined && /^[A-Z][A-Z0-9_]{0,63}$/u.test(value.errorCode) ? value.errorCode : undefined;
return {
status: value.status,
...(failedStageLabel !== undefined && { failedStageLabel }),
...(error !== undefined && { error }),
...(errorCode !== undefined && { errorCode }),
};
}
export function safeOperationLabel(value: string): string {
return OPERATION_LABELS.has(value) ? value : 'Background task';
}
export function safeOperationKey(value: string): string {
if (
/^(?: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)
) {
return value;
}
return 'background-task';
}
export function safeCliIdentifier(value: string): string {
return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(value) ? value : 'unknown';
}
export function safeTemporalStatus(value: string): string {
return [
'RUNNING',
'UNSPECIFIED',
'COMPLETED',
'FAILED',
'CANCELLED',
'CANCELED',
'TERMINATED',
'TIMED_OUT',
'CONTINUED_AS_NEW',
].includes(value)
? value
: 'UNKNOWN';
}
export function safeFailureDetail(hasFailure: true): string;
export function safeFailureDetail(hasFailure: false): undefined;
export function safeFailureDetail(hasFailure: boolean): string | undefined;
export function safeFailureDetail(hasFailure: boolean): string | undefined {
return hasFailure ? 'This scan step could not be completed.' : undefined;
}
export function safeTerminalFailure(hasFailure: boolean): string | undefined {
return hasFailure ? 'The scan could not be completed.' : undefined;
}
+14 -6
View File
@@ -10,6 +10,13 @@ import type { DerivedPhase } from './derive.js';
import { derivePipeline, isTerminal, scanElapsedMs } from './derive.js';
import type { PartialReasonView } from './pipeline.js';
import type { RenderInput } from './render.js';
import {
safeAgenticSast,
safeCliIdentifier,
safePartialReasons,
safeTemporalStatus,
safeTerminalFailure,
} from './safe-fields.js';
/** Coarse scan status token, mirroring the human status badge in machine-friendly form. */
export type ScanStatus = 'running' | 'completed' | 'partial' | 'failed' | 'stopped' | 'cancelled' | 'timed_out';
@@ -61,19 +68,20 @@ function deriveStatus(input: RenderInput): ScanStatus {
/** Build the JSON snapshot for a scan at instant `now`. */
export function toStatusJson(input: RenderInput, now: number): StatusJson {
const elapsedMs = scanElapsedMs(input, now);
const partialReasons = input.state?.partialReasons ?? [];
const agenticSast = input.state?.agenticSast;
const partialReasons = safePartialReasons(input.state?.partialReasons ?? []);
const agenticSast = safeAgenticSast(input.state?.agenticSast);
const usageAccountingComplete = input.state?.summary?.usageAccountingComplete;
const failureMessage = safeTerminalFailure(input.failureMessage !== undefined);
return {
workspace: input.workspace,
...(input.workflowId !== undefined && { workflowId: input.workflowId }),
workspace: safeCliIdentifier(input.workspace),
...(input.workflowId !== undefined && { workflowId: safeCliIdentifier(input.workflowId) }),
status: deriveStatus(input),
temporalStatus: input.temporalStatus,
temporalStatus: safeTemporalStatus(input.temporalStatus),
elapsedMs: elapsedMs ?? null,
...(input.startedAt !== undefined && { startedAt: new Date(input.startedAt).toISOString() }),
...(input.endedAt !== undefined && { endedAt: new Date(input.endedAt).toISOString() }),
...(input.failureMessage !== undefined && { failureMessage: input.failureMessage }),
...(failureMessage !== undefined && { failureMessage }),
...(partialReasons.length > 0 && { partialReasons }),
// Present only when agentic SAST actually ran; a disabled scan omits the key entirely.
...(agenticSast !== undefined &&