diff --git a/src/temporal/workflow-errors.ts b/src/temporal/workflow-errors.ts new file mode 100644 index 0000000..3062080 --- /dev/null +++ b/src/temporal/workflow-errors.ts @@ -0,0 +1,94 @@ +// Copyright (C) 2025 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +/** + * Workflow error formatting utilities. + * Pure functions with no side effects — safe for Temporal workflow sandbox. + */ + +/** Maps Temporal error type strings to actionable remediation hints. */ +const REMEDIATION_HINTS: Record = { + 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. + */ +export 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('|'); +} diff --git a/src/temporal/workflows.ts b/src/temporal/workflows.ts index 6fbadeb..4950f6c 100644 --- a/src/temporal/workflows.ts +++ b/src/temporal/workflows.ts @@ -41,10 +41,10 @@ import { type AgentMetrics, type ResumeState, } from './shared.js'; -import type { VulnType } from '../services/queue-validation.js'; -import type { AgentName } from '../types/agents.js'; +import type { AgentName, VulnType } from '../types/agents.js'; import { ALL_AGENTS } from '../types/agents.js'; import { toWorkflowSummary } from './summary-mapper.js'; +import { formatWorkflowError } from './workflow-errors.js'; // Retry configuration for production (long intervals for billing recovery) const PRODUCTION_RETRY = { @@ -102,90 +102,6 @@ const preflightActs = proxyActivities({ retry: PREFLIGHT_RETRY, }); -/** Maps Temporal error type strings to actionable remediation hints. */ -const REMEDIATION_HINTS: Record = { - 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.