mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 20:43:36 +00:00
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.
This commit is contained in:
@@ -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<string, LogLevel> = {
|
||||
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');
|
||||
|
||||
|
||||
@@ -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<string, LogLevel> = {
|
||||
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,
|
||||
|
||||
316
apps/server/src/services/event-hook-service.ts
Normal file
316
apps/server/src/services/event-hook-service.ts
Normal file
@@ -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<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,
|
||||
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<void> {
|
||||
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();
|
||||
Reference in New Issue
Block a user