mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-09-20 00:42:24 +02:00
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:
@@ -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) }),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 &&
|
||||
|
||||
Reference in New Issue
Block a user