From 7465017600345059a21a9d63a201838197d68ac1 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Fri, 16 Jan 2026 00:21:49 -0500 Subject: [PATCH] feat: implement server logging and event hook features - Introduced server log level configuration and HTTP request logging settings, allowing users to control the verbosity of server logs and enable or disable request logging at runtime. - Added an Event Hook Service to execute custom actions based on system events, supporting shell commands and HTTP webhooks. - Enhanced the UI with new sections for managing server logging preferences and event hooks, including a dialog for creating and editing hooks. - Updated global settings to include server log level and request logging options, ensuring persistence across sessions. These changes aim to improve debugging capabilities and provide users with customizable event-driven actions within the application. --- apps/server/src/index.ts | 79 ++++- .../routes/settings/routes/update-global.ts | 29 ++ .../server/src/services/event-hook-service.ts | 316 ++++++++++++++++++ .../ui/src/components/views/settings-view.tsx | 6 + .../views/settings-view/config/navigation.ts | 7 + .../developer/developer-section.tsx | 91 +++++ .../event-hooks/event-hook-dialog.tsx | 289 ++++++++++++++++ .../event-hooks/event-hooks-section.tsx | 202 +++++++++++ .../views/settings-view/event-hooks/index.ts | 1 + .../settings-view/hooks/use-settings-view.ts | 2 + apps/ui/src/hooks/use-settings-migration.ts | 4 + apps/ui/src/hooks/use-settings-sync.ts | 5 + apps/ui/src/store/app-store.ts | 27 ++ libs/types/src/index.ts | 10 + libs/types/src/settings.ts | 109 ++++++ 15 files changed, 1160 insertions(+), 17 deletions(-) create mode 100644 apps/server/src/services/event-hook-service.ts create mode 100644 apps/ui/src/components/views/settings-view/developer/developer-section.tsx create mode 100644 apps/ui/src/components/views/settings-view/event-hooks/event-hook-dialog.tsx create mode 100644 apps/ui/src/components/views/settings-view/event-hooks/event-hooks-section.tsx create mode 100644 apps/ui/src/components/views/settings-view/event-hooks/index.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 0e52d03b..3a59d4d3 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -17,9 +17,19 @@ import dotenv from 'dotenv'; import { createEventEmitter, type EventEmitter } from './lib/events.js'; import { initAllowedPaths } from '@automaker/platform'; -import { createLogger } from '@automaker/utils'; +import { createLogger, setLogLevel, LogLevel } from '@automaker/utils'; const logger = createLogger('Server'); + +/** + * Map server log level string to LogLevel enum + */ +const LOG_LEVEL_MAP: Record = { + error: LogLevel.ERROR, + warn: LogLevel.WARN, + info: LogLevel.INFO, + debug: LogLevel.DEBUG, +}; import { authMiddleware, validateWsConnectionToken, checkRawAuthentication } from './lib/auth.js'; import { requireJsonContentType } from './middleware/require-json-content-type.js'; import { createAuthRoutes } from './routes/auth/index.js'; @@ -68,13 +78,31 @@ import { pipelineService } from './services/pipeline-service.js'; import { createIdeationRoutes } from './routes/ideation/index.js'; import { IdeationService } from './services/ideation-service.js'; import { getDevServerService } from './services/dev-server-service.js'; +import { eventHookService } from './services/event-hook-service.js'; // Load environment variables dotenv.config(); const PORT = parseInt(process.env.PORT || '3008', 10); const DATA_DIR = process.env.DATA_DIR || './data'; -const ENABLE_REQUEST_LOGGING = process.env.ENABLE_REQUEST_LOGGING !== 'false'; // Default to true +const ENABLE_REQUEST_LOGGING_DEFAULT = process.env.ENABLE_REQUEST_LOGGING !== 'false'; // Default to true + +// Runtime-configurable request logging flag (can be changed via settings) +let requestLoggingEnabled = ENABLE_REQUEST_LOGGING_DEFAULT; + +/** + * Enable or disable HTTP request logging at runtime + */ +export function setRequestLoggingEnabled(enabled: boolean): void { + requestLoggingEnabled = enabled; +} + +/** + * Get current request logging state + */ +export function isRequestLoggingEnabled(): boolean { + return requestLoggingEnabled; +} // Check for required environment variables const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY; @@ -103,22 +131,21 @@ initAllowedPaths(); const app = express(); // Middleware -// Custom colored logger showing only endpoint and status code (configurable via ENABLE_REQUEST_LOGGING env var) -if (ENABLE_REQUEST_LOGGING) { - morgan.token('status-colored', (_req, res) => { - const status = res.statusCode; - if (status >= 500) return `\x1b[31m${status}\x1b[0m`; // Red for server errors - if (status >= 400) return `\x1b[33m${status}\x1b[0m`; // Yellow for client errors - if (status >= 300) return `\x1b[36m${status}\x1b[0m`; // Cyan for redirects - return `\x1b[32m${status}\x1b[0m`; // Green for success - }); +// Custom colored logger showing only endpoint and status code (dynamically configurable) +morgan.token('status-colored', (_req, res) => { + const status = res.statusCode; + if (status >= 500) return `\x1b[31m${status}\x1b[0m`; // Red for server errors + if (status >= 400) return `\x1b[33m${status}\x1b[0m`; // Yellow for client errors + if (status >= 300) return `\x1b[36m${status}\x1b[0m`; // Cyan for redirects + return `\x1b[32m${status}\x1b[0m`; // Green for success +}); - app.use( - morgan(':method :url :status-colored', { - skip: (req) => req.url === '/api/health', // Skip health check logs - }) - ); -} +app.use( + morgan(':method :url :status-colored', { + // Skip when request logging is disabled or for health check endpoints + skip: (req) => !requestLoggingEnabled || req.url === '/api/health', + }) +); // CORS configuration // When using credentials (cookies), origin cannot be '*' // We dynamically allow the requesting origin for local development @@ -181,8 +208,26 @@ const ideationService = new IdeationService(events, settingsService, featureLoad const devServerService = getDevServerService(); devServerService.setEventEmitter(events); +// Initialize Event Hook Service for custom event triggers +eventHookService.initialize(events, settingsService); + // Initialize services (async () => { + // Apply logging settings from saved settings + try { + const settings = await settingsService.getGlobalSettings(); + if (settings.serverLogLevel && LOG_LEVEL_MAP[settings.serverLogLevel] !== undefined) { + setLogLevel(LOG_LEVEL_MAP[settings.serverLogLevel]); + logger.info(`Server log level set to: ${settings.serverLogLevel}`); + } + // Apply request logging setting (default true if not set) + const enableRequestLog = settings.enableRequestLogging ?? true; + setRequestLoggingEnabled(enableRequestLog); + logger.info(`HTTP request logging: ${enableRequestLog ? 'enabled' : 'disabled'}`); + } catch (err) { + logger.warn('Failed to load logging settings, using defaults'); + } + await agentService.initialize(); logger.info('Agent service initialized'); diff --git a/apps/server/src/routes/settings/routes/update-global.ts b/apps/server/src/routes/settings/routes/update-global.ts index aafbc5b1..a04227d8 100644 --- a/apps/server/src/routes/settings/routes/update-global.ts +++ b/apps/server/src/routes/settings/routes/update-global.ts @@ -12,6 +12,18 @@ import type { Request, Response } from 'express'; import type { SettingsService } from '../../../services/settings-service.js'; import type { GlobalSettings } from '../../../types/settings.js'; import { getErrorMessage, logError, logger } from '../common.js'; +import { setLogLevel, LogLevel } from '@automaker/utils'; +import { setRequestLoggingEnabled } from '../../../index.js'; + +/** + * Map server log level string to LogLevel enum + */ +const LOG_LEVEL_MAP: Record = { + error: LogLevel.ERROR, + warn: LogLevel.WARN, + info: LogLevel.INFO, + debug: LogLevel.DEBUG, +}; /** * Create handler factory for PUT /api/settings/global @@ -46,6 +58,23 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) { const settings = await settingsService.updateGlobalSettings(updates); + // Apply server log level if it was updated + if ('serverLogLevel' in updates && updates.serverLogLevel) { + const level = LOG_LEVEL_MAP[updates.serverLogLevel]; + if (level !== undefined) { + setLogLevel(level); + logger.info(`Server log level changed to: ${updates.serverLogLevel}`); + } + } + + // Apply request logging setting if it was updated + if ('enableRequestLogging' in updates && typeof updates.enableRequestLogging === 'boolean') { + setRequestLoggingEnabled(updates.enableRequestLogging); + logger.info( + `HTTP request logging ${updates.enableRequestLogging ? 'enabled' : 'disabled'}` + ); + } + res.json({ success: true, settings, diff --git a/apps/server/src/services/event-hook-service.ts b/apps/server/src/services/event-hook-service.ts new file mode 100644 index 00000000..d6d2d0b8 --- /dev/null +++ b/apps/server/src/services/event-hook-service.ts @@ -0,0 +1,316 @@ +/** + * 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 + * + * Supported events: + * - 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 { + 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; +} + +/** + * Event Hook Service + * + * Manages execution of user-configured event hooks in response to system events. + */ +export class EventHookService { + private emitter: EventEmitter | null = null; + private settingsService: SettingsService | null = null; + private unsubscribe: (() => void) | null = null; + + /** + * Initialize the service with event emitter and settings service + */ + initialize(emitter: EventEmitter, settingsService: SettingsService): void { + this.emitter = emitter; + this.settingsService = settingsService; + + // Subscribe to auto-mode events + this.unsubscribe = emitter.subscribe((type, payload) => { + if (type === 'auto-mode:event') { + this.handleAutoModeEvent(payload as AutoModeEventPayload); + } + }); + + logger.info('Event hook service initialized'); + } + + /** + * Cleanup subscriptions + */ + destroy(): void { + if (this.unsubscribe) { + this.unsubscribe(); + this.unsubscribe = null; + } + this.emitter = null; + this.settingsService = 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 + await this.executeHooksForTrigger(trigger, context); + } + + /** + * Execute all enabled hooks matching the given trigger + */ + private async executeHooksForTrigger( + trigger: EventHookTrigger, + context: HookContext + ): Promise { + 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(); diff --git a/apps/ui/src/components/views/settings-view.tsx b/apps/ui/src/components/views/settings-view.tsx index 353bcf85..ff614302 100644 --- a/apps/ui/src/components/views/settings-view.tsx +++ b/apps/ui/src/components/views/settings-view.tsx @@ -19,6 +19,7 @@ import { WorktreesSection } from './settings-view/worktrees'; import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section'; import { AccountSection } from './settings-view/account'; import { SecuritySection } from './settings-view/security'; +import { DeveloperSection } from './settings-view/developer/developer-section'; import { ClaudeSettingsTab, CursorSettingsTab, @@ -27,6 +28,7 @@ import { } from './settings-view/providers'; import { MCPServersSection } from './settings-view/mcp-servers'; import { PromptCustomizationSection } from './settings-view/prompts'; +import { EventHooksSection } from './settings-view/event-hooks'; import type { Project as SettingsProject, Theme } from './settings-view/shared/types'; import type { Project as ElectronProject } from '@/lib/electron'; @@ -183,6 +185,8 @@ export function SettingsView() { return ( ); + case 'event-hooks': + return ; case 'defaults': return ( ); + case 'developer': + return ; case 'danger': return ( +
+
+
+ +
+

Developer

+
+

+ Advanced settings for debugging and development. +

+
+
+ {/* Server Log Level */} +
+ +

+ Control the verbosity of API server logs. Set to "Error" to only see error messages in + the server console. +

+ +
+ + {/* HTTP Request Logging */} +
+
+ +

+ Log all HTTP requests (method, URL, status) to the server console. +

+
+ { + setEnableRequestLogging(checked); + toast.success(checked ? 'Request logging enabled' : 'Request logging disabled', { + description: 'HTTP request logging updated', + }); + }} + /> +
+
+ + ); +} diff --git a/apps/ui/src/components/views/settings-view/event-hooks/event-hook-dialog.tsx b/apps/ui/src/components/views/settings-view/event-hooks/event-hook-dialog.tsx new file mode 100644 index 00000000..68233b5a --- /dev/null +++ b/apps/ui/src/components/views/settings-view/event-hooks/event-hook-dialog.tsx @@ -0,0 +1,289 @@ +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Terminal, Globe } from 'lucide-react'; +import type { + EventHook, + EventHookTrigger, + EventHookHttpMethod, + EventHookShellAction, + EventHookHttpAction, +} from '@automaker/types'; +import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types'; + +interface EventHookDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + editingHook: EventHook | null; + onSave: (hook: EventHook) => void; +} + +type ActionType = 'shell' | 'http'; + +const TRIGGER_OPTIONS: EventHookTrigger[] = [ + 'feature_success', + 'feature_error', + 'auto_mode_complete', + 'auto_mode_error', +]; + +const HTTP_METHODS: EventHookHttpMethod[] = ['POST', 'GET', 'PUT', 'PATCH']; + +export function EventHookDialog({ open, onOpenChange, editingHook, onSave }: EventHookDialogProps) { + // Form state + const [name, setName] = useState(''); + const [trigger, setTrigger] = useState('feature_success'); + const [actionType, setActionType] = useState('shell'); + + // Shell action state + const [command, setCommand] = useState(''); + const [timeout, setTimeout] = useState('30000'); + + // HTTP action state + const [url, setUrl] = useState(''); + const [method, setMethod] = useState('POST'); + const [headers, setHeaders] = useState(''); + const [body, setBody] = useState(''); + + // Reset form when dialog opens/closes or editingHook changes + useEffect(() => { + if (open) { + if (editingHook) { + // Populate form with existing hook data + setName(editingHook.name || ''); + setTrigger(editingHook.trigger); + setActionType(editingHook.action.type); + + if (editingHook.action.type === 'shell') { + const shellAction = editingHook.action as EventHookShellAction; + setCommand(shellAction.command); + setTimeout(String(shellAction.timeout || 30000)); + // Reset HTTP fields + setUrl(''); + setMethod('POST'); + setHeaders(''); + setBody(''); + } else { + const httpAction = editingHook.action as EventHookHttpAction; + setUrl(httpAction.url); + setMethod(httpAction.method); + setHeaders(httpAction.headers ? JSON.stringify(httpAction.headers, null, 2) : ''); + setBody(httpAction.body || ''); + // Reset shell fields + setCommand(''); + setTimeout('30000'); + } + } else { + // Reset to defaults for new hook + setName(''); + setTrigger('feature_success'); + setActionType('shell'); + setCommand(''); + setTimeout('30000'); + setUrl(''); + setMethod('POST'); + setHeaders(''); + setBody(''); + } + } + }, [open, editingHook]); + + const handleSave = () => { + const hook: EventHook = { + id: editingHook?.id || crypto.randomUUID(), + name: name.trim() || undefined, + trigger, + enabled: editingHook?.enabled ?? true, + action: + actionType === 'shell' + ? { + type: 'shell', + command, + timeout: parseInt(timeout, 10) || 30000, + } + : { + type: 'http', + url, + method, + headers: headers.trim() ? JSON.parse(headers) : undefined, + body: body.trim() || undefined, + }, + }; + + onSave(hook); + }; + + const isValid = actionType === 'shell' ? command.trim().length > 0 : url.trim().length > 0; + + return ( + + + + {editingHook ? 'Edit Event Hook' : 'Add Event Hook'} + + Configure an action to run when a specific event occurs. + + + +
+ {/* Name (optional) */} +
+ + setName(e.target.value)} + placeholder="My notification hook" + /> +
+ + {/* Trigger selection */} +
+ + +
+ + {/* Action type tabs */} +
+ + setActionType(v as ActionType)}> + + + + Shell Command + + + + HTTP Request + + + + {/* Shell command form */} + +
+ +