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:
ajmallesh
2026-02-19 19:09:02 -08:00
parent afa0e9b701
commit c0d46cb6b9
6 changed files with 590 additions and 2 deletions
+68
View File
@@ -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
View File
@@ -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