/** * Event Hook Service - Executes custom actions when system events occur * * Listens to the event emitter and triggers configured hooks: * - Shell commands: Executed with configurable timeout * - HTTP webhooks: POST/GET/PUT/PATCH requests with variable substitution * * Also stores events to history for debugging and replay. * * Supported events: * - feature_created: A new feature was created * - feature_success: Feature completed successfully * - feature_error: Feature failed with an error * - auto_mode_complete: Auto mode finished all features (idle state) * - auto_mode_error: Auto mode encountered a critical error */ import { exec } from 'child_process'; import { promisify } from 'util'; import { createLogger } from '@automaker/utils'; import type { EventEmitter } from '../lib/events.js'; import type { SettingsService } from './settings-service.js'; import type { EventHistoryService } from './event-history-service.js'; import type { EventHook, EventHookTrigger, EventHookShellAction, EventHookHttpAction, } from '@automaker/types'; const execAsync = promisify(exec); const logger = createLogger('EventHooks'); /** Default timeout for shell commands (30 seconds) */ const DEFAULT_SHELL_TIMEOUT = 30000; /** Default timeout for HTTP requests (10 seconds) */ const DEFAULT_HTTP_TIMEOUT = 10000; /** * Context available for variable substitution in hooks */ interface HookContext { featureId?: string; featureName?: string; projectPath?: string; projectName?: string; error?: string; errorType?: string; timestamp: string; eventType: EventHookTrigger; } /** * Auto-mode event payload structure */ interface AutoModeEventPayload { type?: string; featureId?: string; passes?: boolean; message?: string; error?: string; errorType?: string; projectPath?: string; } /** * Feature created event payload structure */ interface FeatureCreatedPayload { featureId: string; featureName?: string; projectPath: string; } /** * Event Hook Service * * Manages execution of user-configured event hooks in response to system events. * Also stores events to history for debugging and replay. */ export class EventHookService { private emitter: EventEmitter | null = null; private settingsService: SettingsService | null = null; private eventHistoryService: EventHistoryService | null = null; private unsubscribe: (() => void) | null = null; /** * Initialize the service with event emitter, settings service, and event history service */ initialize( emitter: EventEmitter, settingsService: SettingsService, eventHistoryService?: EventHistoryService ): void { this.emitter = emitter; this.settingsService = settingsService; this.eventHistoryService = eventHistoryService || null; // Subscribe to events this.unsubscribe = emitter.subscribe((type, payload) => { if (type === 'auto-mode:event') { this.handleAutoModeEvent(payload as AutoModeEventPayload); } else if (type === 'feature:created') { this.handleFeatureCreatedEvent(payload as FeatureCreatedPayload); } }); logger.info('Event hook service initialized'); } /** * Cleanup subscriptions */ destroy(): void { if (this.unsubscribe) { this.unsubscribe(); this.unsubscribe = null; } this.emitter = null; this.settingsService = null; this.eventHistoryService = null; } /** * Handle auto-mode events and trigger matching hooks */ private async handleAutoModeEvent(payload: AutoModeEventPayload): Promise { if (!payload.type) return; // Map internal event types to hook triggers let trigger: EventHookTrigger | null = null; switch (payload.type) { case 'auto_mode_feature_complete': trigger = payload.passes ? 'feature_success' : 'feature_error'; break; case 'auto_mode_error': // Feature-level error (has featureId) vs auto-mode level error trigger = payload.featureId ? 'feature_error' : 'auto_mode_error'; break; case 'auto_mode_idle': trigger = 'auto_mode_complete'; break; default: // Other event types don't trigger hooks return; } if (!trigger) return; // Build context for variable substitution const context: HookContext = { featureId: payload.featureId, projectPath: payload.projectPath, projectName: payload.projectPath ? this.extractProjectName(payload.projectPath) : undefined, error: payload.error || payload.message, errorType: payload.errorType, timestamp: new Date().toISOString(), eventType: trigger, }; // Execute matching hooks (pass passes for feature completion events) await this.executeHooksForTrigger(trigger, context, { passes: payload.passes }); } /** * Handle feature:created events and trigger matching hooks */ private async handleFeatureCreatedEvent(payload: FeatureCreatedPayload): Promise { const context: HookContext = { featureId: payload.featureId, featureName: payload.featureName, projectPath: payload.projectPath, projectName: this.extractProjectName(payload.projectPath), timestamp: new Date().toISOString(), eventType: 'feature_created', }; await this.executeHooksForTrigger('feature_created', context); } /** * Execute all enabled hooks matching the given trigger and store event to history */ private async executeHooksForTrigger( trigger: EventHookTrigger, context: HookContext, additionalData?: { passes?: boolean } ): Promise { // Store event to history (even if no hooks match) if (this.eventHistoryService && context.projectPath) { try { await this.eventHistoryService.storeEvent({ trigger, projectPath: context.projectPath, featureId: context.featureId, featureName: context.featureName, error: context.error, errorType: context.errorType, passes: additionalData?.passes, }); } catch (error) { logger.error('Failed to store event to history:', error); } } if (!this.settingsService) { logger.warn('Settings service not available'); return; } try { const settings = await this.settingsService.getGlobalSettings(); const hooks = settings.eventHooks || []; // Filter to enabled hooks matching this trigger const matchingHooks = hooks.filter((hook) => hook.enabled && hook.trigger === trigger); if (matchingHooks.length === 0) { return; } logger.info(`Executing ${matchingHooks.length} hook(s) for trigger: ${trigger}`); // Execute hooks in parallel (don't wait for one to finish before starting next) await Promise.allSettled(matchingHooks.map((hook) => this.executeHook(hook, context))); } catch (error) { logger.error('Error executing hooks:', error); } } /** * Execute a single hook */ private async executeHook(hook: EventHook, context: HookContext): Promise { const hookName = hook.name || hook.id; try { if (hook.action.type === 'shell') { await this.executeShellHook(hook.action, context, hookName); } else if (hook.action.type === 'http') { await this.executeHttpHook(hook.action, context, hookName); } } catch (error) { logger.error(`Hook "${hookName}" failed:`, error); } } /** * Execute a shell command hook */ private async executeShellHook( action: EventHookShellAction, context: HookContext, hookName: string ): Promise { const command = this.substituteVariables(action.command, context); const timeout = action.timeout || DEFAULT_SHELL_TIMEOUT; logger.info(`Executing shell hook "${hookName}": ${command}`); try { const { stdout, stderr } = await execAsync(command, { timeout, maxBuffer: 1024 * 1024, // 1MB buffer }); if (stdout) { logger.debug(`Hook "${hookName}" stdout: ${stdout.trim()}`); } if (stderr) { logger.warn(`Hook "${hookName}" stderr: ${stderr.trim()}`); } logger.info(`Shell hook "${hookName}" completed successfully`); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ETIMEDOUT') { logger.error(`Shell hook "${hookName}" timed out after ${timeout}ms`); } throw error; } } /** * Execute an HTTP webhook hook */ private async executeHttpHook( action: EventHookHttpAction, context: HookContext, hookName: string ): Promise { const url = this.substituteVariables(action.url, context); const method = action.method || 'POST'; // Substitute variables in headers const headers: Record = { 'Content-Type': 'application/json', }; if (action.headers) { for (const [key, value] of Object.entries(action.headers)) { headers[key] = this.substituteVariables(value, context); } } // Substitute variables in body let body: string | undefined; if (action.body) { body = this.substituteVariables(action.body, context); } else if (method !== 'GET') { // Default body with context information body = JSON.stringify({ eventType: context.eventType, timestamp: context.timestamp, featureId: context.featureId, projectPath: context.projectPath, projectName: context.projectName, error: context.error, }); } logger.info(`Executing HTTP hook "${hookName}": ${method} ${url}`); try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), DEFAULT_HTTP_TIMEOUT); const response = await fetch(url, { method, headers, body: method !== 'GET' ? body : undefined, signal: controller.signal, }); clearTimeout(timeoutId); if (!response.ok) { logger.warn(`HTTP hook "${hookName}" received status ${response.status}`); } else { logger.info(`HTTP hook "${hookName}" completed successfully (status: ${response.status})`); } } catch (error) { if ((error as Error).name === 'AbortError') { logger.error(`HTTP hook "${hookName}" timed out after ${DEFAULT_HTTP_TIMEOUT}ms`); } throw error; } } /** * Substitute {{variable}} placeholders in a string */ private substituteVariables(template: string, context: HookContext): string { return template.replace(/\{\{(\w+)\}\}/g, (match, variable) => { const value = context[variable as keyof HookContext]; if (value === undefined || value === null) { return ''; } return String(value); }); } /** * Extract project name from path */ private extractProjectName(projectPath: string): string { const parts = projectPath.split(/[/\\]/); return parts[parts.length - 1] || projectPath; } } // Singleton instance export const eventHookService = new EventHookService();