feat: add preflight validation phase with structured error reporting
- Add preflight activity that validates repo path, config, and credentials before agent execution - Add formatWorkflowError() with pipe-delimited segments for multi-line log rendering - Add remediation hints for common failures (auth, billing, config errors) - Add REPO_NOT_FOUND, AUTH_FAILED, BILLING_ERROR codes with error classification - Add formatErrorBlock() in WorkflowLogger for indented error display
This commit is contained in:
@@ -36,6 +36,8 @@ import { AGENTS } from '../session-manager.js';
|
||||
import { executeGitCommandWithRetry } from '../services/git-manager.js';
|
||||
import type { ResumeAttempt } from '../audit/metrics-tracker.js';
|
||||
import { createActivityLogger } from './activity-logger.js';
|
||||
import { runPreflightChecks } from '../services/preflight.js';
|
||||
import { isErr } from '../types/result.js';
|
||||
|
||||
// Max lengths to prevent Temporal protobuf buffer overflow
|
||||
const MAX_ERROR_MESSAGE_LENGTH = 2000;
|
||||
@@ -246,6 +248,72 @@ export async function runReportAgent(input: ActivityInput): Promise<AgentMetrics
|
||||
return runAgentActivity('report', input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preflight validation activity.
|
||||
*
|
||||
* Runs cheap checks before any agent execution:
|
||||
* 1. Repository path exists with .git
|
||||
* 2. Config file validates (if provided)
|
||||
* 3. Credential validation (API key, OAuth, or router mode)
|
||||
*
|
||||
* NOT using runAgentActivity — preflight doesn't run an agent via the SDK.
|
||||
*/
|
||||
export async function runPreflightValidation(input: ActivityInput): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
const attemptNumber = Context.current().info.attempt;
|
||||
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
heartbeat({ phase: 'preflight', elapsedSeconds: elapsed, attempt: attemptNumber });
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
|
||||
try {
|
||||
const logger = createActivityLogger();
|
||||
logger.info('Running preflight validation...', { attempt: attemptNumber });
|
||||
|
||||
const result = await runPreflightChecks(input.repoPath, input.configPath, logger);
|
||||
|
||||
if (isErr(result)) {
|
||||
const classified = classifyErrorForTemporal(result.error);
|
||||
const message = truncateErrorMessage(result.error.message);
|
||||
|
||||
if (classified.retryable) {
|
||||
const failure = ApplicationFailure.create({
|
||||
message,
|
||||
type: classified.type,
|
||||
details: [{ phase: 'preflight', attemptNumber, elapsed: Date.now() - startTime }],
|
||||
});
|
||||
truncateStackTrace(failure);
|
||||
throw failure;
|
||||
} else {
|
||||
const failure = ApplicationFailure.nonRetryable(message, classified.type, [
|
||||
{ phase: 'preflight', attemptNumber, elapsed: Date.now() - startTime },
|
||||
]);
|
||||
truncateStackTrace(failure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Preflight validation passed');
|
||||
} catch (error) {
|
||||
if (error instanceof ApplicationFailure) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const classified = classifyErrorForTemporal(error);
|
||||
const rawMessage = error instanceof Error ? error.message : String(error);
|
||||
const message = truncateErrorMessage(rawMessage);
|
||||
|
||||
const failure = ApplicationFailure.nonRetryable(message, classified.type, [
|
||||
{ phase: 'preflight', attemptNumber, elapsed: Date.now() - startTime },
|
||||
]);
|
||||
truncateStackTrace(failure);
|
||||
throw failure;
|
||||
} finally {
|
||||
clearInterval(heartbeatInterval);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the final report by concatenating exploitation evidence files.
|
||||
*/
|
||||
|
||||
+109
-1
@@ -86,6 +86,106 @@ const testActs = proxyActivities<typeof activities>({
|
||||
retry: TESTING_RETRY,
|
||||
});
|
||||
|
||||
// Retry configuration for preflight validation (short timeout, few retries)
|
||||
const PREFLIGHT_RETRY = {
|
||||
initialInterval: '10 seconds',
|
||||
maximumInterval: '1 minute',
|
||||
backoffCoefficient: 2,
|
||||
maximumAttempts: 3,
|
||||
nonRetryableErrorTypes: PRODUCTION_RETRY.nonRetryableErrorTypes,
|
||||
};
|
||||
|
||||
// Activity proxy for preflight validation (short timeout)
|
||||
const preflightActs = proxyActivities<typeof activities>({
|
||||
startToCloseTimeout: '2 minutes',
|
||||
heartbeatTimeout: '2 minutes',
|
||||
retry: PREFLIGHT_RETRY,
|
||||
});
|
||||
|
||||
/** Maps Temporal error type strings to actionable remediation hints. */
|
||||
const REMEDIATION_HINTS: Record<string, string> = {
|
||||
AuthenticationError:
|
||||
'Verify ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN in .env is valid and not expired.',
|
||||
ConfigurationError: 'Check your CONFIG file path and contents.',
|
||||
BillingError:
|
||||
'Check your Anthropic billing dashboard. Add credits or wait for spending cap reset.',
|
||||
GitError: 'Check repository path and git state.',
|
||||
InvalidTargetError: 'Verify the target URL is correct and accessible.',
|
||||
PermissionError: 'Check file and network permissions.',
|
||||
ExecutionLimitError: 'Agent exceeded maximum turns or budget. Review prompt complexity.',
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk the .cause chain to find the innermost error with a .type property.
|
||||
* Temporal wraps ApplicationFailure in ActivityFailure — the useful info is inside.
|
||||
*
|
||||
* Uses duck-typing because workflow code cannot import @temporalio/activity types.
|
||||
*/
|
||||
function unwrapActivityError(error: unknown): {
|
||||
message: string;
|
||||
type: string | null;
|
||||
} {
|
||||
let current: unknown = error;
|
||||
let typed: { message: string; type: string } | null = null;
|
||||
|
||||
while (current instanceof Error) {
|
||||
if ('type' in current && typeof (current as { type: unknown }).type === 'string') {
|
||||
typed = {
|
||||
message: current.message,
|
||||
type: (current as { type: string }).type,
|
||||
};
|
||||
}
|
||||
current = (current as { cause?: unknown }).cause;
|
||||
}
|
||||
|
||||
if (typed) {
|
||||
return typed;
|
||||
}
|
||||
|
||||
return {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
type: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a structured error string from workflow catch context.
|
||||
* Segments are delimited by | for multi-line rendering by WorkflowLogger.
|
||||
*/
|
||||
function formatWorkflowError(
|
||||
error: unknown,
|
||||
currentPhase: string | null,
|
||||
currentAgent: string | null
|
||||
): string {
|
||||
const unwrapped = unwrapActivityError(error);
|
||||
|
||||
// Phase context (first segment)
|
||||
let phaseContext = 'Pipeline failed';
|
||||
if (currentPhase && currentAgent && currentPhase !== currentAgent) {
|
||||
phaseContext = `${currentPhase} failed (agent: ${currentAgent})`;
|
||||
} else if (currentPhase) {
|
||||
phaseContext = `${currentPhase} failed`;
|
||||
}
|
||||
|
||||
const segments: string[] = [phaseContext];
|
||||
|
||||
if (unwrapped.type) {
|
||||
segments.push(unwrapped.type);
|
||||
}
|
||||
|
||||
// Sanitize pipe characters from message to preserve delimiter format
|
||||
segments.push(unwrapped.message.replaceAll('|', '/'));
|
||||
|
||||
if (unwrapped.type) {
|
||||
const hint = REMEDIATION_HINTS[unwrapped.type];
|
||||
if (hint) {
|
||||
segments.push(`Hint: ${hint}`);
|
||||
}
|
||||
}
|
||||
|
||||
return segments.join('|');
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute aggregated metrics from the current pipeline state.
|
||||
* Called on both success and failure to provide partial metrics.
|
||||
@@ -298,6 +398,14 @@ export async function pentestPipelineWorkflow(
|
||||
}
|
||||
|
||||
try {
|
||||
// === Preflight Validation ===
|
||||
// Quick sanity checks before committing to expensive agent runs.
|
||||
// NOT using runSequentialPhase — preflight doesn't produce AgentMetrics.
|
||||
state.currentPhase = 'preflight';
|
||||
state.currentAgent = null;
|
||||
await preflightActs.runPreflightValidation(activityInput);
|
||||
log.info('Preflight validation passed');
|
||||
|
||||
// === Phase 1: Pre-Reconnaissance ===
|
||||
await runSequentialPhase('pre-recon', 'pre-recon', a.runPreReconAgent);
|
||||
|
||||
@@ -409,7 +517,7 @@ export async function pentestPipelineWorkflow(
|
||||
} catch (error) {
|
||||
state.status = 'failed';
|
||||
state.failedAgent = state.currentAgent;
|
||||
state.error = error instanceof Error ? error.message : String(error);
|
||||
state.error = formatWorkflowError(error, state.currentPhase, state.currentAgent);
|
||||
state.summary = computeSummary(state);
|
||||
|
||||
// Log workflow failure summary
|
||||
|
||||
Reference in New Issue
Block a user