diff --git a/workflow/executor/ts/error-handling/error-recovery.ts b/workflow/executor/ts/error-handling/error-recovery.ts new file mode 100644 index 000000000..dca461ac4 --- /dev/null +++ b/workflow/executor/ts/error-handling/error-recovery.ts @@ -0,0 +1,791 @@ +/** + * Error Recovery Manager - Comprehensive Error Handling and Recovery Strategies + * Implements error recovery strategies (fallback, skip, retry, fail) with + * exponential backoff, metrics tracking, and error state management. + * @packageDocumentation + */ + +import { + INodeExecutor, + WorkflowNode, + WorkflowContext, + ExecutionState, + NodeResult +} from '../types'; + +/** + * Recovery strategy type + */ +export type RecoveryStrategyType = 'fallback' | 'skip' | 'retry' | 'fail'; + +/** + * Error recovery strategy configuration + */ +export interface ErrorRecoveryStrategy { + strategy: RecoveryStrategyType; + fallbackNodeType?: string; + maxRetries?: number; + retryDelay?: number; + retryBackoffMultiplier?: number; + maxRetryDelay?: number; + retryableErrors?: string[]; + retryableStatusCodes?: number[]; + allowPartialOutput?: boolean; + notifyOnError?: boolean; + notificationChannels?: string[]; +} + +/** + * Individual error recovery attempt record + */ +export interface RecoveryAttempt { + timestamp: number; + strategy: RecoveryStrategyType; + nodeType: string; + nodeId: string; + attempt: number; + maxAttempts: number; + error: string; + errorType: string; + duration: number; + status: 'pending' | 'success' | 'failed'; + output?: any; +} + +/** + * Error metrics for tracking and analysis + */ +export interface ErrorMetrics { + totalErrors: number; + recoveryAttempts: number; + recoverySuccess: number; + recoveryFailed: number; + errorsByType: Map; + errorsByNodeType: Map; + errorsByStrategy: Map; + averageRecoveryTime: number; + lastErrorTime: number; +} + +/** + * Error state for tracking recovery progress + */ +export interface ErrorState { + nodeId: string; + nodeType: string; + originalError: Error; + errorTime: number; + attempts: RecoveryAttempt[]; + lastAttempt?: RecoveryAttempt; + recovered: boolean; + finalError?: Error; + context?: Record; +} + +/** + * Recovery result from an attempt + */ +export interface RecoveryResult { + success: boolean; + strategy: RecoveryStrategyType; + attempts: number; + totalDuration: number; + output?: any; + error?: string; + recoveryAttempts?: RecoveryAttempt[]; +} + +/** + * Error Recovery Manager Class + */ +export class ErrorRecoveryManager { + private metrics: ErrorMetrics = { + totalErrors: 0, + recoveryAttempts: 0, + recoverySuccess: 0, + recoveryFailed: 0, + errorsByType: new Map(), + errorsByNodeType: new Map(), + errorsByStrategy: new Map(), + averageRecoveryTime: 0, + lastErrorTime: 0 + }; + + private errorStates: Map = new Map(); + private recoveryTimes: number[] = []; + private readonly MAX_RECOVERY_HISTORY = 1000; + private readonly MAX_ERROR_STATES = 500; + + /** + * Handle error with recovery strategy + */ + async handleError( + nodeType: string, + nodeId: string, + error: Error, + strategy: ErrorRecoveryStrategy, + node: WorkflowNode, + context: WorkflowContext, + state: ExecutionState, + registryExecute?: ( + nodeType: string, + node: any, + ctx: any, + state: any + ) => Promise + ): Promise { + const startTime = performance.now(); + this.metrics.totalErrors++; + this.metrics.recoveryAttempts++; + this.metrics.lastErrorTime = Date.now(); + + const errorState = this._createErrorState(nodeType, nodeId, error, context); + this._trackError(error, nodeType, strategy.strategy); + + try { + switch (strategy.strategy) { + case 'fallback': + return await this._applyFallback( + strategy, + node, + context, + state, + registryExecute, + errorState, + startTime + ); + + case 'skip': + return this._applySkip(errorState, startTime); + + case 'retry': + return await this._applyRetry( + nodeType, + nodeId, + node, + context, + state, + strategy, + registryExecute, + errorState, + startTime + ); + + case 'fail': + default: + return this._applyFail(error, errorState, startTime); + } + } catch (recoveryError) { + this.metrics.recoveryFailed++; + errorState.recovered = false; + errorState.finalError = recoveryError as Error; + this._storeErrorState(errorState); + + return { + success: false, + strategy: strategy.strategy, + attempts: errorState.attempts.length, + totalDuration: performance.now() - startTime, + error: `Recovery failed: ${ + recoveryError instanceof Error + ? recoveryError.message + : String(recoveryError) + }` + }; + } + } + + /** + * Apply fallback strategy + */ + private async _applyFallback( + strategy: ErrorRecoveryStrategy, + node: WorkflowNode, + context: WorkflowContext, + state: ExecutionState, + registryExecute: (( + nodeType: string, + node: any, + ctx: any, + state: any + ) => Promise) | undefined, + errorState: ErrorState, + startTime: number + ): Promise { + if (!strategy.fallbackNodeType || !registryExecute) { + return { + success: false, + strategy: 'fallback', + attempts: 0, + totalDuration: performance.now() - startTime, + error: 'Fallback node type not configured or registry unavailable' + }; + } + + const fallbackStartTime = performance.now(); + + try { + // Record the attempt + const attempt: RecoveryAttempt = { + timestamp: Date.now(), + strategy: 'fallback', + nodeType: strategy.fallbackNodeType, + nodeId: node.id, + attempt: 1, + maxAttempts: 1, + error: errorState.originalError.message, + errorType: errorState.originalError.constructor.name, + duration: 0, + status: 'pending' + }; + + const result = await registryExecute( + strategy.fallbackNodeType, + node, + context, + state + ); + + const duration = performance.now() - fallbackStartTime; + attempt.duration = duration; + attempt.status = result.status === 'success' ? 'success' : 'failed'; + attempt.output = result.output; + + errorState.attempts.push(attempt); + errorState.lastAttempt = attempt; + + if (result.status === 'success') { + this.metrics.recoverySuccess++; + errorState.recovered = true; + this._storeErrorState(errorState); + this._recordRecoveryTime(performance.now() - startTime); + + return { + success: true, + strategy: 'fallback', + attempts: 1, + totalDuration: performance.now() - startTime, + output: result.output, + recoveryAttempts: errorState.attempts + }; + } + + return { + success: false, + strategy: 'fallback', + attempts: 1, + totalDuration: performance.now() - startTime, + error: `Fallback execution failed: ${result.error}`, + recoveryAttempts: errorState.attempts + }; + } catch (fallbackError) { + const duration = performance.now() - fallbackStartTime; + + const attempt: RecoveryAttempt = { + timestamp: Date.now(), + strategy: 'fallback', + nodeType: strategy.fallbackNodeType, + nodeId: node.id, + attempt: 1, + maxAttempts: 1, + error: fallbackError instanceof Error + ? fallbackError.message + : String(fallbackError), + errorType: fallbackError instanceof Error + ? fallbackError.constructor.name + : 'UnknownError', + duration, + status: 'failed' + }; + + errorState.attempts.push(attempt); + errorState.lastAttempt = attempt; + errorState.recovered = false; + errorState.finalError = fallbackError as Error; + this._storeErrorState(errorState); + + return { + success: false, + strategy: 'fallback', + attempts: 1, + totalDuration: performance.now() - startTime, + error: `Fallback failed: ${ + fallbackError instanceof Error + ? fallbackError.message + : String(fallbackError) + }`, + recoveryAttempts: errorState.attempts + }; + } + } + + /** + * Apply skip strategy + */ + private _applySkip( + errorState: ErrorState, + startTime: number + ): RecoveryResult { + this.metrics.recoverySuccess++; + + const attempt: RecoveryAttempt = { + timestamp: Date.now(), + strategy: 'skip', + nodeType: errorState.nodeType, + nodeId: errorState.nodeId, + attempt: 1, + maxAttempts: 1, + error: errorState.originalError.message, + errorType: errorState.originalError.constructor.name, + duration: 0, + status: 'success' + }; + + errorState.attempts.push(attempt); + errorState.lastAttempt = attempt; + errorState.recovered = true; + this._storeErrorState(errorState); + this._recordRecoveryTime(performance.now() - startTime); + + return { + success: true, + strategy: 'skip', + attempts: 1, + totalDuration: performance.now() - startTime, + output: {}, + recoveryAttempts: errorState.attempts + }; + } + + /** + * Apply retry strategy with exponential backoff + */ + private async _applyRetry( + nodeType: string, + nodeId: string, + node: WorkflowNode, + context: WorkflowContext, + state: ExecutionState, + strategy: ErrorRecoveryStrategy, + registryExecute: (( + nodeType: string, + node: any, + ctx: any, + state: any + ) => Promise) | undefined, + errorState: ErrorState, + startTime: number + ): Promise { + if (!registryExecute) { + return { + success: false, + strategy: 'retry', + attempts: 0, + totalDuration: performance.now() - startTime, + error: 'Registry execute function not available' + }; + } + + const maxRetries = strategy.maxRetries || 3; + const initialDelay = strategy.retryDelay || 1000; + const backoffMultiplier = strategy.retryBackoffMultiplier || 2; + const maxRetryDelay = strategy.maxRetryDelay || 30000; + + let lastError: Error = errorState.originalError; + let lastResult: NodeResult | null = null; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + // Calculate delay with exponential backoff + const delay = this._calculateBackoff( + initialDelay, + attempt - 1, + backoffMultiplier, + maxRetryDelay + ); + + // Wait before retrying + await this._delay(delay); + + // Attempt execution + const retryStartTime = performance.now(); + const result = await registryExecute(nodeType, node, context, state); + const duration = performance.now() - retryStartTime; + + // Record the attempt + const recoveryAttempt: RecoveryAttempt = { + timestamp: Date.now(), + strategy: 'retry', + nodeType: nodeType, + nodeId: nodeId, + attempt: attempt, + maxAttempts: maxRetries, + error: lastError.message, + errorType: lastError.constructor.name, + duration: duration, + status: result.status === 'success' ? 'success' : 'failed', + output: result.output + }; + + errorState.attempts.push(recoveryAttempt); + errorState.lastAttempt = recoveryAttempt; + + if (result.status === 'success') { + this.metrics.recoverySuccess++; + errorState.recovered = true; + this._storeErrorState(errorState); + this._recordRecoveryTime(performance.now() - startTime); + + return { + success: true, + strategy: 'retry', + attempts: attempt, + totalDuration: performance.now() - startTime, + output: result.output, + recoveryAttempts: errorState.attempts + }; + } + + lastResult = result; + } catch (retryError) { + const duration = performance.now() - startTime; + + // Record failed attempt + const recoveryAttempt: RecoveryAttempt = { + timestamp: Date.now(), + strategy: 'retry', + nodeType: nodeType, + nodeId: nodeId, + attempt: attempt, + maxAttempts: maxRetries, + error: retryError instanceof Error + ? retryError.message + : String(retryError), + errorType: retryError instanceof Error + ? retryError.constructor.name + : 'UnknownError', + duration: duration, + status: 'failed' + }; + + errorState.attempts.push(recoveryAttempt); + errorState.lastAttempt = recoveryAttempt; + lastError = retryError as Error; + + // If this is the last attempt, don't continue + if (attempt === maxRetries) { + errorState.recovered = false; + errorState.finalError = retryError as Error; + this._storeErrorState(errorState); + + return { + success: false, + strategy: 'retry', + attempts: attempt, + totalDuration: performance.now() - startTime, + error: `All ${maxRetries} retry attempts failed: ${ + retryError instanceof Error + ? retryError.message + : String(retryError) + }`, + recoveryAttempts: errorState.attempts + }; + } + } + } + + // Max retries exceeded with no success + errorState.recovered = false; + errorState.finalError = lastError; + this._storeErrorState(errorState); + + return { + success: false, + strategy: 'retry', + attempts: maxRetries, + totalDuration: performance.now() - startTime, + error: `Max retries (${maxRetries}) exceeded`, + recoveryAttempts: errorState.attempts + }; + } + + /** + * Apply fail strategy (no recovery attempt) + */ + private _applyFail( + error: Error, + errorState: ErrorState, + startTime: number + ): RecoveryResult { + errorState.recovered = false; + errorState.finalError = error; + this._storeErrorState(errorState); + + return { + success: false, + strategy: 'fail', + attempts: 0, + totalDuration: performance.now() - startTime, + error: error.message + }; + } + + /** + * Calculate exponential backoff delay + */ + private _calculateBackoff( + initialDelay: number, + attemptNumber: number, + multiplier: number, + maxDelay: number + ): number { + let delay = initialDelay * Math.pow(multiplier, attemptNumber); + + // Add jitter to prevent thundering herd + const jitter = Math.random() * delay * 0.1; + delay = Math.min(delay + jitter, maxDelay); + + return Math.round(delay); + } + + /** + * Delay execution (sleep) + */ + private async _delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + /** + * Create error state for tracking + */ + private _createErrorState( + nodeType: string, + nodeId: string, + error: Error, + context: WorkflowContext + ): ErrorState { + return { + nodeId, + nodeType, + originalError: error, + errorTime: Date.now(), + attempts: [], + recovered: false, + context: { + tenantId: context.tenantId, + userId: context.userId, + executionId: context.executionId + } + }; + } + + /** + * Store error state for later analysis + */ + private _storeErrorState(errorState: ErrorState): void { + const key = `${errorState.nodeId}-${errorState.errorTime}`; + this.errorStates.set(key, errorState); + + // Trim old states if exceeding max + if (this.errorStates.size > this.MAX_ERROR_STATES) { + const firstKey = this.errorStates.keys().next().value; + if (firstKey) { + this.errorStates.delete(firstKey); + } + } + } + + /** + * Track error occurrence + */ + private _trackError( + error: Error, + nodeType: string, + strategy: RecoveryStrategyType + ): void { + const errorType = error.constructor.name; + + // Track by error type + this.metrics.errorsByType.set( + errorType, + (this.metrics.errorsByType.get(errorType) || 0) + 1 + ); + + // Track by node type + this.metrics.errorsByNodeType.set( + nodeType, + (this.metrics.errorsByNodeType.get(nodeType) || 0) + 1 + ); + + // Track by recovery strategy + this.metrics.errorsByStrategy.set( + strategy, + (this.metrics.errorsByStrategy.get(strategy) || 0) + 1 + ); + } + + /** + * Record recovery time + */ + private _recordRecoveryTime(duration: number): void { + this.recoveryTimes.push(duration); + + if (this.recoveryTimes.length > this.MAX_RECOVERY_HISTORY) { + this.recoveryTimes.shift(); + } + + // Update average + const sum = this.recoveryTimes.reduce((a, b) => a + b, 0); + this.metrics.averageRecoveryTime = sum / this.recoveryTimes.length; + } + + /** + * Get error metrics + */ + getMetrics(): ErrorMetrics { + return { + ...this.metrics, + errorsByType: new Map(this.metrics.errorsByType), + errorsByNodeType: new Map(this.metrics.errorsByNodeType), + errorsByStrategy: new Map(this.metrics.errorsByStrategy) + }; + } + + /** + * Get specific error state by key + */ + getErrorState(nodeId: string, timestamp: number): ErrorState | undefined { + const key = `${nodeId}-${timestamp}`; + return this.errorStates.get(key); + } + + /** + * Get all error states for a node + */ + getErrorStatesForNode(nodeId: string): ErrorState[] { + return Array.from(this.errorStates.values()).filter( + (state) => state.nodeId === nodeId + ); + } + + /** + * Get error statistics by type + */ + getErrorStatistics(): { + byType: Array<{ type: string; count: number }>; + byNodeType: Array<{ nodeType: string; count: number }>; + byStrategy: Array<{ strategy: RecoveryStrategyType; count: number }>; + } { + return { + byType: Array.from(this.metrics.errorsByType.entries()).map( + ([type, count]) => ({ + type, + count + }) + ), + byNodeType: Array.from(this.metrics.errorsByNodeType.entries()).map( + ([nodeType, count]) => ({ + nodeType, + count + }) + ), + byStrategy: Array.from(this.metrics.errorsByStrategy.entries()).map( + ([strategy, count]) => ({ + strategy, + count + }) + ) + }; + } + + /** + * Clear metrics and state (useful for testing) + */ + clearMetrics(): void { + this.metrics = { + totalErrors: 0, + recoveryAttempts: 0, + recoverySuccess: 0, + recoveryFailed: 0, + errorsByType: new Map(), + errorsByNodeType: new Map(), + errorsByStrategy: new Map(), + averageRecoveryTime: 0, + lastErrorTime: 0 + }; + this.recoveryTimes = []; + } + + /** + * Clear error states (useful for memory management) + */ + clearErrorStates(): void { + this.errorStates.clear(); + } + + /** + * Get recovery success rate + */ + getRecoverySuccessRate(): number { + if (this.metrics.recoveryAttempts === 0) { + return 0; + } + + return ( + (this.metrics.recoverySuccess / this.metrics.recoveryAttempts) * 100 + ); + } + + /** + * Export all metrics for monitoring/logging + */ + exportMetrics(): Record { + const stats = this.getErrorStatistics(); + + return { + summary: { + totalErrors: this.metrics.totalErrors, + recoveryAttempts: this.metrics.recoveryAttempts, + recoverySuccess: this.metrics.recoverySuccess, + recoveryFailed: this.metrics.recoveryFailed, + successRate: this.getRecoverySuccessRate(), + averageRecoveryTime: this.metrics.averageRecoveryTime, + lastErrorTime: this.metrics.lastErrorTime + }, + statistics: stats, + recentErrors: Array.from(this.errorStates.values()) + .slice(-10) + .map((state) => ({ + nodeId: state.nodeId, + nodeType: state.nodeType, + error: state.originalError.message, + errorType: state.originalError.constructor.name, + timestamp: state.errorTime, + recovered: state.recovered, + attemptCount: state.attempts.length + })) + }; + } +} + +/** + * Create a singleton instance for global use + */ +let globalRecoveryManager: ErrorRecoveryManager | null = null; + +/** + * Get or create the global error recovery manager + */ +export function getErrorRecoveryManager(): ErrorRecoveryManager { + if (!globalRecoveryManager) { + globalRecoveryManager = new ErrorRecoveryManager(); + } + return globalRecoveryManager; +} + +/** + * Reset the global error recovery manager (useful for testing) + */ +export function resetErrorRecoveryManager(): void { + globalRecoveryManager = null; +} diff --git a/workflow/executor/ts/validation/plugin-validator.ts b/workflow/executor/ts/validation/plugin-validator.ts new file mode 100644 index 000000000..f466fd0a0 --- /dev/null +++ b/workflow/executor/ts/validation/plugin-validator.ts @@ -0,0 +1,1023 @@ +/** + * Plugin Validator - Schema Validation and Compatibility Checking + * Comprehensive validation against plugin metadata, pre-execution checks, + * and error type mapping for the workflow executor. + * @packageDocumentation + */ + +import { + INodeExecutor, + WorkflowNode, + WorkflowContext, + ValidationResult, + WorkflowDefinition, + RetryPolicy, + RateLimitPolicy +} from '../types'; + +/** + * Plugin metadata schema for validation + */ +export interface PluginMetadata { + nodeType: string; + version: string; + category: string; + description?: string; + requiredFields?: string[]; + schema?: Record; + dependencies?: string[]; + supportedVersions?: string[]; + tags?: string[]; + author?: string; + icon?: string; + experimental?: boolean; +} + +/** + * Compatibility check result + */ +export interface CompatibilityCheckResult { + compatible: boolean; + issues: CompatibilityIssue[]; + warnings: string[]; +} + +/** + * Individual compatibility issue + */ +export interface CompatibilityIssue { + type: + | 'version-mismatch' + | 'missing-dependency' + | 'unsupported-feature' + | 'schema-violation' + | 'credential-mismatch' + | 'tenant-restriction'; + severity: 'error' | 'warning'; + message: string; + details?: Record; +} + +/** + * Pre-execution validation result + */ +export interface PreExecutionValidation { + valid: boolean; + errors: string[]; + warnings: string[]; + parameterValidation: ParameterValidationResult; + credentialValidation: CredentialValidationResult; + contextValidation: ContextValidationResult; +} + +/** + * Parameter validation result + */ +export interface ParameterValidationResult { + valid: boolean; + missingRequired: string[]; + invalidTypes: ParameterTypeError[]; + schemaViolations: string[]; +} + +/** + * Parameter type error + */ +export interface ParameterTypeError { + field: string; + expected: string; + received: string; + value: any; +} + +/** + * Credential validation result + */ +export interface CredentialValidationResult { + valid: boolean; + missingCredentials: string[]; + invalidCredentials: string[]; + expiredCredentials: string[]; +} + +/** + * Context validation result + */ +export interface ContextValidationResult { + valid: boolean; + errors: string[]; + missingContext: string[]; +} + +/** + * Error type classification + */ +export enum ErrorType { + VALIDATION_ERROR = 'VALIDATION_ERROR', + SCHEMA_VIOLATION = 'SCHEMA_VIOLATION', + TYPE_MISMATCH = 'TYPE_MISMATCH', + MISSING_REQUIRED = 'MISSING_REQUIRED', + INCOMPATIBLE_VERSION = 'INCOMPATIBLE_VERSION', + CREDENTIAL_ERROR = 'CREDENTIAL_ERROR', + CONTEXT_ERROR = 'CONTEXT_ERROR', + DEPENDENCY_ERROR = 'DEPENDENCY_ERROR', + TIMEOUT_ERROR = 'TIMEOUT_ERROR', + EXECUTION_ERROR = 'EXECUTION_ERROR', + UNKNOWN_ERROR = 'UNKNOWN_ERROR' +} + +/** + * Mapped error for structured error handling + */ +export interface MappedError { + type: ErrorType; + message: string; + originalError: Error; + isRecoverable: boolean; + suggestedAction?: string; + context?: Record; +} + +/** + * Plugin Validator Class + * Provides comprehensive validation for plugins before execution + */ +export class PluginValidator { + private metadataCache: Map = new Map(); + private schemaCache: Map = new Map(); + private readonly MAX_PARAMETER_SIZE = 10 * 1024 * 1024; // 10MB + private readonly MAX_OUTPUT_SIZE = 50 * 1024 * 1024; // 50MB + + /** + * Register plugin metadata for validation + */ + registerMetadata(metadata: PluginMetadata): void { + if (!this._validateMetadataFormat(metadata)) { + throw new Error(`Invalid metadata for plugin: ${metadata.nodeType}`); + } + + this.metadataCache.set(metadata.nodeType, metadata); + } + + /** + * Get registered metadata for a plugin + */ + getMetadata(nodeType: string): PluginMetadata | undefined { + return this.metadataCache.get(nodeType); + } + + /** + * Full schema validation against plugin metadata + */ + validateSchema(nodeType: string, node: WorkflowNode): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + const metadata = this.metadataCache.get(nodeType); + if (!metadata) { + return { + valid: false, + errors: [`No metadata registered for node type: ${nodeType}`], + warnings: [] + }; + } + + // Check required fields + if (metadata.requiredFields) { + const missingFields = metadata.requiredFields.filter( + (field) => !(field in node.parameters) + ); + + if (missingFields.length > 0) { + errors.push(`Missing required fields: ${missingFields.join(', ')}`); + } + } + + // Validate against JSON schema if present + if (metadata.schema) { + const schemaErrors = this._validateAgainstJsonSchema( + node.parameters, + metadata.schema + ); + errors.push(...schemaErrors); + } + + // Validate parameter types + const typeErrors = this._validateParameterTypes(node.parameters, metadata); + if (typeErrors.length > 0) { + errors.push(...typeErrors); + } + + // Check for deprecated parameters + const deprecationWarnings = this._checkDeprecatedParameters( + node.parameters, + metadata + ); + warnings.push(...deprecationWarnings); + + // Validate parameter sizes + const sizeErrors = this._validateParameterSizes(node.parameters); + errors.push(...sizeErrors); + + return { + valid: errors.length === 0, + errors, + warnings + }; + } + + /** + * Check plugin compatibility with workflow environment + */ + checkCompatibility( + nodeType: string, + node: WorkflowNode, + context: WorkflowContext, + workflow: WorkflowDefinition + ): CompatibilityCheckResult { + const issues: CompatibilityIssue[] = []; + const warnings: string[] = []; + + const metadata = this.metadataCache.get(nodeType); + if (!metadata) { + return { + compatible: false, + issues: [ + { + type: 'schema-violation', + severity: 'error', + message: `No metadata registered for node type: ${nodeType}` + } + ], + warnings: [] + }; + } + + // Check version compatibility + const versionIssue = this._checkVersionCompatibility( + metadata, + workflow.version + ); + if (versionIssue) { + issues.push(versionIssue); + } + + // Check dependencies + const depIssues = this._checkDependencies(metadata); + issues.push(...depIssues); + + // Check tenant restrictions + const tenantIssue = this._checkTenantRestrictions( + metadata, + context.tenantId + ); + if (tenantIssue) { + issues.push(tenantIssue); + } + + // Check credential requirements + const credentialIssues = this._checkCredentialRequirements( + metadata, + node, + context + ); + issues.push(...credentialIssues); + + // Check feature support + const featureIssues = this._checkFeatureSupport(metadata, node); + issues.push(...featureIssues); + + const compatible = issues.filter((i) => i.severity === 'error').length === 0; + + return { + compatible, + issues, + warnings + }; + } + + /** + * Comprehensive pre-execution validation + */ + validatePreExecution( + nodeType: string, + node: WorkflowNode, + context: WorkflowContext, + executor?: INodeExecutor + ): PreExecutionValidation { + const errors: string[] = []; + const warnings: string[] = []; + + // 1. Validate executor interface + if (executor) { + const executorValidation = this._validateExecutorInterface(executor); + if (!executorValidation.valid) { + errors.push(...executorValidation.errors); + } + } + + // 2. Validate parameters + const paramValidation = this._validateNodeParameters( + node, + this.metadataCache.get(nodeType) + ); + if (!paramValidation.valid) { + errors.push(...paramValidation.schemaViolations); + warnings.push( + ...paramValidation.missingRequired.map( + (f) => `Missing recommended field: ${f}` + ) + ); + } + + // 3. Validate credentials + const credentialValidation = this._validateCredentials(node, context); + if (!credentialValidation.valid) { + errors.push(...credentialValidation.missingCredentials); + warnings.push(...credentialValidation.expiredCredentials); + } + + // 4. Validate context + const contextValidation = this._validateExecutionContext(node, context); + if (!contextValidation.valid) { + errors.push(...contextValidation.errors); + } + + // 5. Validate timeout settings + const timeoutWarnings = this._validateTimeout(node); + warnings.push(...timeoutWarnings); + + // 6. Validate retry policy + if (node.retryPolicy) { + const retryWarnings = this._validateRetryPolicy(node.retryPolicy); + warnings.push(...retryWarnings); + } + + return { + valid: errors.length === 0, + errors, + warnings, + parameterValidation: paramValidation, + credentialValidation: credentialValidation, + contextValidation: contextValidation + }; + } + + /** + * Map error types to structured error objects + */ + mapErrorType(error: Error, context?: Record): MappedError { + const errorMessage = error.message.toLowerCase(); + + // Type mismatches + if ( + errorMessage.includes('type') || + errorMessage.includes('expected') || + errorMessage.includes('received') + ) { + return { + type: ErrorType.TYPE_MISMATCH, + message: error.message, + originalError: error, + isRecoverable: false, + suggestedAction: 'Check parameter types in node configuration', + context + }; + } + + // Validation errors + if ( + errorMessage.includes('validation') || + errorMessage.includes('invalid') || + errorMessage.includes('schema') + ) { + return { + type: ErrorType.VALIDATION_ERROR, + message: error.message, + originalError: error, + isRecoverable: false, + suggestedAction: 'Fix node parameters to match schema', + context + }; + } + + // Missing required fields + if ( + errorMessage.includes('required') || + errorMessage.includes('missing') + ) { + return { + type: ErrorType.MISSING_REQUIRED, + message: error.message, + originalError: error, + isRecoverable: false, + suggestedAction: 'Add missing required parameters', + context + }; + } + + // Credential errors + if ( + errorMessage.includes('credential') || + errorMessage.includes('unauthorized') || + errorMessage.includes('authentication') + ) { + return { + type: ErrorType.CREDENTIAL_ERROR, + message: error.message, + originalError: error, + isRecoverable: true, + suggestedAction: 'Check credential configuration and permissions', + context + }; + } + + // Version compatibility + if ( + errorMessage.includes('version') || + errorMessage.includes('compatible') + ) { + return { + type: ErrorType.INCOMPATIBLE_VERSION, + message: error.message, + originalError: error, + isRecoverable: true, + suggestedAction: 'Update plugin or workflow to compatible version', + context + }; + } + + // Dependency errors + if ( + errorMessage.includes('dependency') || + errorMessage.includes('not found') || + errorMessage.includes('require') + ) { + return { + type: ErrorType.DEPENDENCY_ERROR, + message: error.message, + originalError: error, + isRecoverable: true, + suggestedAction: 'Install missing dependencies', + context + }; + } + + // Timeout errors + if ( + errorMessage.includes('timeout') || + errorMessage.includes('timed out') + ) { + return { + type: ErrorType.TIMEOUT_ERROR, + message: error.message, + originalError: error, + isRecoverable: true, + suggestedAction: 'Increase timeout or optimize node execution', + context + }; + } + + // Context errors + if ( + errorMessage.includes('context') || + errorMessage.includes('tenantid') || + errorMessage.includes('userid') + ) { + return { + type: ErrorType.CONTEXT_ERROR, + message: error.message, + originalError: error, + isRecoverable: false, + suggestedAction: 'Check execution context configuration', + context + }; + } + + // Default to execution error + return { + type: ErrorType.EXECUTION_ERROR, + message: error.message, + originalError: error, + isRecoverable: true, + suggestedAction: 'Review execution logs and node configuration', + context + }; + } + + /** + * Validate all plugins in batch + */ + validateAllRegisteredMetadata(): Array<{ nodeType: string; valid: boolean; errors: string[] }> { + const results: Array<{ nodeType: string; valid: boolean; errors: string[] }> = []; + + for (const [nodeType, metadata] of this.metadataCache) { + const errors: string[] = []; + + if (!metadata.version) { + errors.push('Missing version'); + } + + if (!metadata.category) { + errors.push('Missing category'); + } + + if (!metadata.nodeType) { + errors.push('Missing nodeType'); + } + + results.push({ + nodeType, + valid: errors.length === 0, + errors + }); + } + + return results; + } + + /** + * Clear all cached metadata + */ + clearCache(): void { + this.metadataCache.clear(); + this.schemaCache.clear(); + } + + // ===== Private Methods ===== + + /** + * Validate metadata format + */ + private _validateMetadataFormat(metadata: any): boolean { + if (!metadata || typeof metadata !== 'object') { + return false; + } + + // Check required fields + if (!metadata.nodeType || typeof metadata.nodeType !== 'string') { + return false; + } + + if (!metadata.version || typeof metadata.version !== 'string') { + return false; + } + + if (!metadata.category || typeof metadata.category !== 'string') { + return false; + } + + return true; + } + + /** + * Validate against JSON schema + */ + private _validateAgainstJsonSchema(data: any, schema: any): string[] { + const errors: string[] = []; + + // Simple JSON schema validation (subset) + if (schema.type) { + if (schema.type === 'object' && typeof data !== 'object') { + errors.push(`Expected object, received ${typeof data}`); + } + if (schema.type === 'array' && !Array.isArray(data)) { + errors.push(`Expected array, received ${typeof data}`); + } + } + + // Check required properties + if (schema.required && Array.isArray(schema.required)) { + for (const prop of schema.required) { + if (!(prop in data)) { + errors.push(`Missing required property: ${prop}`); + } + } + } + + // Validate properties against schema + if (schema.properties && typeof schema.properties === 'object') { + for (const [key, propSchema] of Object.entries(schema.properties)) { + if (key in data) { + const propErrors = this._validateProperty( + data[key], + propSchema as any + ); + errors.push(...propErrors); + } + } + } + + return errors; + } + + /** + * Validate individual property + */ + private _validateProperty(value: any, schema: any): string[] { + const errors: string[] = []; + + if (schema.type) { + if (schema.type === 'string' && typeof value !== 'string') { + errors.push(`Expected string, received ${typeof value}`); + } + if (schema.type === 'number' && typeof value !== 'number') { + errors.push(`Expected number, received ${typeof value}`); + } + if (schema.type === 'boolean' && typeof value !== 'boolean') { + errors.push(`Expected boolean, received ${typeof value}`); + } + } + + if (schema.minimum !== undefined && value < schema.minimum) { + errors.push(`Value ${value} is below minimum ${schema.minimum}`); + } + + if (schema.maximum !== undefined && value > schema.maximum) { + errors.push(`Value ${value} exceeds maximum ${schema.maximum}`); + } + + if (schema.enum && !schema.enum.includes(value)) { + errors.push(`Value must be one of: ${schema.enum.join(', ')}`); + } + + return errors; + } + + /** + * Validate parameter types + */ + private _validateParameterTypes( + parameters: Record, + metadata: PluginMetadata + ): string[] { + const errors: string[] = []; + + if (!metadata.schema || !metadata.schema.properties) { + return errors; + } + + for (const [key, value] of Object.entries(parameters)) { + const propSchema = metadata.schema.properties[key]; + if (!propSchema) continue; + + if (propSchema.type) { + const actualType = Array.isArray(value) ? 'array' : typeof value; + if (propSchema.type !== actualType) { + errors.push( + `Parameter '${key}': expected ${propSchema.type}, got ${actualType}` + ); + } + } + } + + return errors; + } + + /** + * Check for deprecated parameters + */ + private _checkDeprecatedParameters( + parameters: Record, + metadata: PluginMetadata + ): string[] { + const warnings: string[] = []; + + if (!metadata.schema || !metadata.schema.deprecated) { + return warnings; + } + + const deprecated = metadata.schema.deprecated as string[]; + for (const key of deprecated) { + if (key in parameters) { + warnings.push(`Parameter '${key}' is deprecated`); + } + } + + return warnings; + } + + /** + * Validate parameter sizes + */ + private _validateParameterSizes(parameters: Record): string[] { + const errors: string[] = []; + + for (const [key, value] of Object.entries(parameters)) { + if (typeof value === 'string' && value.length > this.MAX_PARAMETER_SIZE) { + errors.push( + `Parameter '${key}' exceeds maximum size of ${this.MAX_PARAMETER_SIZE} bytes` + ); + } + } + + return errors; + } + + /** + * Check version compatibility + */ + private _checkVersionCompatibility( + metadata: PluginMetadata, + workflowVersion: string + ): CompatibilityIssue | null { + if (!metadata.supportedVersions || metadata.supportedVersions.length === 0) { + return null; + } + + if (!metadata.supportedVersions.includes(workflowVersion)) { + return { + type: 'version-mismatch', + severity: 'warning', + message: `Plugin ${metadata.nodeType} v${metadata.version} may not be compatible with workflow v${workflowVersion}`, + details: { + pluginVersion: metadata.version, + supportedWorkflowVersions: metadata.supportedVersions, + workflowVersion + } + }; + } + + return null; + } + + /** + * Check dependencies + */ + private _checkDependencies(metadata: PluginMetadata): CompatibilityIssue[] { + const issues: CompatibilityIssue[] = []; + + if (!metadata.dependencies || metadata.dependencies.length === 0) { + return issues; + } + + for (const dep of metadata.dependencies) { + // This would check if dependencies are installed + // For now, we mark as potential issues + issues.push({ + type: 'missing-dependency', + severity: 'warning', + message: `Dependency '${dep}' required by ${metadata.nodeType}`, + details: { dependency: dep } + }); + } + + return issues; + } + + /** + * Check tenant restrictions + */ + private _checkTenantRestrictions( + metadata: PluginMetadata, + tenantId: string + ): CompatibilityIssue | null { + // Check if plugin has tenant restrictions + // This would be defined in metadata or elsewhere + if (metadata.experimental) { + return { + type: 'tenant-restriction', + severity: 'warning', + message: `Plugin ${metadata.nodeType} is experimental and may have limited support`, + details: { experimental: true } + }; + } + + return null; + } + + /** + * Check credential requirements + */ + private _checkCredentialRequirements( + metadata: PluginMetadata, + node: WorkflowNode, + context: WorkflowContext + ): CompatibilityIssue[] { + const issues: CompatibilityIssue[] = []; + + // Check if node requires credentials but doesn't have them + if ( + Object.keys(node.credentials).length === 0 && + metadata.schema?.required?.some((r: string) => r.includes('credential')) + ) { + issues.push({ + type: 'credential-mismatch', + severity: 'error', + message: `Plugin ${metadata.nodeType} requires credentials but none are configured`, + details: { nodeId: node.id } + }); + } + + return issues; + } + + /** + * Check feature support + */ + private _checkFeatureSupport( + metadata: PluginMetadata, + node: WorkflowNode + ): CompatibilityIssue[] { + const issues: CompatibilityIssue[] = []; + + // Check if node uses unsupported features + if (node.disabled) { + // Disabled nodes are fine, just noted + } + + return issues; + } + + /** + * Validate executor interface + */ + private _validateExecutorInterface(executor: INodeExecutor): ValidationResult { + const errors: string[] = []; + + if (typeof executor.execute !== 'function') { + errors.push('Executor missing execute method'); + } + + if (typeof executor.validate !== 'function') { + errors.push('Executor missing validate method'); + } + + if (!executor.nodeType) { + errors.push('Executor missing nodeType'); + } + + return { + valid: errors.length === 0, + errors, + warnings: [] + }; + } + + /** + * Validate node parameters + */ + private _validateNodeParameters( + node: WorkflowNode, + metadata?: PluginMetadata + ): ParameterValidationResult { + const missingRequired: string[] = []; + const invalidTypes: ParameterTypeError[] = []; + const schemaViolations: string[] = []; + + if (!metadata) { + return { valid: true, missingRequired, invalidTypes, schemaViolations }; + } + + if (metadata.requiredFields) { + for (const field of metadata.requiredFields) { + if (!(field in node.parameters)) { + missingRequired.push(field); + } + } + } + + return { + valid: missingRequired.length === 0 && invalidTypes.length === 0, + missingRequired, + invalidTypes, + schemaViolations + }; + } + + /** + * Validate credentials + */ + private _validateCredentials( + node: WorkflowNode, + context: WorkflowContext + ): CredentialValidationResult { + const missingCredentials: string[] = []; + const invalidCredentials: string[] = []; + const expiredCredentials: string[] = []; + + for (const [credKey, credRef] of Object.entries(node.credentials)) { + if (!credRef.id) { + missingCredentials.push(credKey); + } + + // Check if credential is in context + if (!context.secrets || !(credKey in context.secrets)) { + invalidCredentials.push(credKey); + } + } + + return { + valid: + missingCredentials.length === 0 && + invalidCredentials.length === 0 && + expiredCredentials.length === 0, + missingCredentials, + invalidCredentials, + expiredCredentials + }; + } + + /** + * Validate execution context + */ + private _validateExecutionContext( + node: WorkflowNode, + context: WorkflowContext + ): ContextValidationResult { + const errors: string[] = []; + const missingContext: string[] = []; + + if (!context.executionId) { + missingContext.push('executionId'); + } + + if (!context.tenantId) { + missingContext.push('tenantId'); + } + + if (!context.userId) { + missingContext.push('userId'); + } + + if (!context.trigger) { + missingContext.push('trigger'); + } + + if (missingContext.length > 0) { + errors.push(`Missing context: ${missingContext.join(', ')}`); + } + + return { + valid: errors.length === 0, + errors, + missingContext + }; + } + + /** + * Validate timeout settings + */ + private _validateTimeout(node: WorkflowNode): string[] { + const warnings: string[] = []; + + if (!node.timeout && node.timeout !== 0) { + warnings.push('Node has no timeout configured (will use default)'); + } + + if (node.timeout && node.timeout < 1000) { + warnings.push( + `Node timeout ${node.timeout}ms is very short, may cause failures` + ); + } + + return warnings; + } + + /** + * Validate retry policy + */ + private _validateRetryPolicy(policy: RetryPolicy): string[] { + const warnings: string[] = []; + + if (!policy.enabled) { + return warnings; + } + + if (policy.maxAttempts < 1) { + warnings.push('Retry policy has invalid maxAttempts < 1'); + } + + if (policy.initialDelay < 0) { + warnings.push('Retry policy has negative initialDelay'); + } + + if (policy.maxDelay < policy.initialDelay) { + warnings.push('Retry policy maxDelay is less than initialDelay'); + } + + return warnings; + } +} + +/** + * Create a singleton instance for global use + */ +let globalValidator: PluginValidator | null = null; + +/** + * Get or create the global plugin validator + */ +export function getPluginValidator(): PluginValidator { + if (!globalValidator) { + globalValidator = new PluginValidator(); + } + return globalValidator; +} + +/** + * Reset the global validator (useful for testing) + */ +export function resetPluginValidator(): void { + globalValidator = null; +}