Files
automaker/apps/server/src/services/event-hook-service.ts
webdevcody 82e22b4362 feat: enhance auto mode functionality with worktree support
- Updated auto mode handlers to support branch-specific operations, allowing for better management of features across different worktrees.
- Introduced normalization of branch names to handle undefined values gracefully.
- Enhanced status and response messages to reflect the current worktree context.
- Updated the auto mode service to manage state and concurrency settings per worktree, improving user experience and flexibility.
- Added UI elements to display current max concurrency for auto mode in both board and mobile views.

This update aims to streamline the auto mode experience, making it more intuitive for users working with multiple branches and worktrees.
2026-01-19 17:17:40 -05:00

376 lines
11 KiB
TypeScript

/**
* 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;
featureName?: 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<void> {
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,
featureName: payload.featureName,
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<void> {
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<void> {
// 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<void> {
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<void> {
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<void> {
const url = this.substituteVariables(action.url, context);
const method = action.method || 'POST';
// Substitute variables in headers
const headers: Record<string, string> = {
'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();