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:
webdevcody
2026-01-16 00:21:49 -05:00
parent 874c5a36de
commit 7465017600
15 changed files with 1160 additions and 17 deletions

View File

@@ -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');

View File

@@ -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,

View 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();

View File

@@ -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 (
<AudioSection muteDoneSound={muteDoneSound} onMuteDoneSoundChange={setMuteDoneSound} />
);
case 'event-hooks':
return <EventHooksSection />;
case 'defaults':
return (
<FeatureDefaultsSection
@@ -215,6 +219,8 @@ export function SettingsView() {
onSkipSandboxWarningChange={setSkipSandboxWarning}
/>
);
case 'developer':
return <DeveloperSection />;
case 'danger':
return (
<DangerZoneSection

View File

@@ -16,6 +16,8 @@ import {
Shield,
Cpu,
GitBranch,
Code2,
Webhook,
} from 'lucide-react';
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
import type { SettingsViewId } from '../hooks/use-settings-view';
@@ -63,6 +65,7 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [
{ id: 'terminal', label: 'Terminal', icon: SquareTerminal },
{ id: 'keyboard', label: 'Keyboard Shortcuts', icon: Settings2 },
{ id: 'audio', label: 'Audio', icon: Volume2 },
{ id: 'event-hooks', label: 'Event Hooks', icon: Webhook },
],
},
{
@@ -72,6 +75,10 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [
{ id: 'security', label: 'Security', icon: Shield },
],
},
{
label: 'Advanced',
items: [{ id: 'developer', label: 'Developer', icon: Code2 }],
},
];
// Flat list of all global nav items for backwards compatibility

View File

@@ -0,0 +1,91 @@
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Code2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore, type ServerLogLevel } from '@/store/app-store';
import { toast } from 'sonner';
const LOG_LEVEL_OPTIONS: { value: ServerLogLevel; label: string; description: string }[] = [
{ value: 'error', label: 'Error', description: 'Only show error messages' },
{ value: 'warn', label: 'Warning', description: 'Show warnings and errors' },
{ value: 'info', label: 'Info', description: 'Show general information (default)' },
{ value: 'debug', label: 'Debug', description: 'Show all messages including debug' },
];
export function DeveloperSection() {
const { serverLogLevel, setServerLogLevel, enableRequestLogging, setEnableRequestLogging } =
useAppStore();
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-purple-500/20 to-purple-600/10 flex items-center justify-center border border-purple-500/20">
<Code2 className="w-5 h-5 text-purple-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Developer</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Advanced settings for debugging and development.
</p>
</div>
<div className="p-6 space-y-6">
{/* Server Log Level */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Server Log Level</Label>
<p className="text-xs text-muted-foreground">
Control the verbosity of API server logs. Set to "Error" to only see error messages in
the server console.
</p>
<select
value={serverLogLevel}
onChange={(e) => {
setServerLogLevel(e.target.value as ServerLogLevel);
toast.success(`Log level changed to ${e.target.value}`, {
description: 'Server logging verbosity updated',
});
}}
className={cn(
'w-full px-3 py-2 rounded-lg',
'bg-accent/30 border border-border/50',
'text-foreground text-sm',
'focus:outline-none focus:ring-2 focus:ring-purple-500/30'
)}
>
{LOG_LEVEL_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label} - {option.description}
</option>
))}
</select>
</div>
{/* HTTP Request Logging */}
<div className="flex items-center justify-between pt-4 border-t border-border/30">
<div className="space-y-1">
<Label className="text-foreground font-medium">HTTP Request Logging</Label>
<p className="text-xs text-muted-foreground">
Log all HTTP requests (method, URL, status) to the server console.
</p>
</div>
<Switch
checked={enableRequestLogging}
onCheckedChange={(checked) => {
setEnableRequestLogging(checked);
toast.success(checked ? 'Request logging enabled' : 'Request logging disabled', {
description: 'HTTP request logging updated',
});
}}
/>
</div>
</div>
</div>
);
}

View File

@@ -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<EventHookTrigger>('feature_success');
const [actionType, setActionType] = useState<ActionType>('shell');
// Shell action state
const [command, setCommand] = useState('');
const [timeout, setTimeout] = useState('30000');
// HTTP action state
const [url, setUrl] = useState('');
const [method, setMethod] = useState<EventHookHttpMethod>('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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingHook ? 'Edit Event Hook' : 'Add Event Hook'}</DialogTitle>
<DialogDescription>
Configure an action to run when a specific event occurs.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Name (optional) */}
<div className="space-y-2">
<Label htmlFor="hook-name">Name (optional)</Label>
<Input
id="hook-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My notification hook"
/>
</div>
{/* Trigger selection */}
<div className="space-y-2">
<Label htmlFor="hook-trigger">Trigger Event</Label>
<Select value={trigger} onValueChange={(v) => setTrigger(v as EventHookTrigger)}>
<SelectTrigger id="hook-trigger">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRIGGER_OPTIONS.map((t) => (
<SelectItem key={t} value={t}>
{EVENT_HOOK_TRIGGER_LABELS[t]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Action type tabs */}
<div className="space-y-2">
<Label>Action Type</Label>
<Tabs value={actionType} onValueChange={(v) => setActionType(v as ActionType)}>
<TabsList className="w-full">
<TabsTrigger value="shell" className="flex-1 gap-2">
<Terminal className="w-4 h-4" />
Shell Command
</TabsTrigger>
<TabsTrigger value="http" className="flex-1 gap-2">
<Globe className="w-4 h-4" />
HTTP Request
</TabsTrigger>
</TabsList>
{/* Shell command form */}
<TabsContent value="shell" className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor="shell-command">Command</Label>
<Textarea
id="shell-command"
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder='echo "Feature {{featureId}} completed!"'
className="font-mono text-sm"
rows={3}
/>
<p className="text-xs text-muted-foreground">
Use {'{{variable}}'} syntax for dynamic values
</p>
</div>
<div className="space-y-2">
<Label htmlFor="shell-timeout">Timeout (ms)</Label>
<Input
id="shell-timeout"
type="number"
value={timeout}
onChange={(e) => setTimeout(e.target.value)}
placeholder="30000"
/>
</div>
</TabsContent>
{/* HTTP request form */}
<TabsContent value="http" className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor="http-url">URL</Label>
<Input
id="http-url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://api.example.com/webhook"
/>
</div>
<div className="space-y-2">
<Label htmlFor="http-method">Method</Label>
<Select value={method} onValueChange={(v) => setMethod(v as EventHookHttpMethod)}>
<SelectTrigger id="http-method">
<SelectValue />
</SelectTrigger>
<SelectContent>
{HTTP_METHODS.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="http-headers">Headers (JSON, optional)</Label>
<Textarea
id="http-headers"
value={headers}
onChange={(e) => setHeaders(e.target.value)}
placeholder={'{\n "Authorization": "Bearer {{token}}"\n}'}
className="font-mono text-sm"
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="http-body">Body (JSON, optional)</Label>
<Textarea
id="http-body"
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder={'{\n "feature": "{{featureId}}",\n "status": "{{eventType}}"\n}'}
className="font-mono text-sm"
rows={4}
/>
<p className="text-xs text-muted-foreground">
Leave empty for default body with all event context
</p>
</div>
</TabsContent>
</Tabs>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!isValid}>
{editingHook ? 'Save Changes' : 'Add Hook'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,202 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { cn } from '@/lib/utils';
import { Webhook, Plus, Trash2, Pencil, Terminal, Globe } from 'lucide-react';
import { useAppStore } from '@/store/app-store';
import type { EventHook, EventHookTrigger } from '@automaker/types';
import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types';
import { EventHookDialog } from './event-hook-dialog';
export function EventHooksSection() {
const { eventHooks, setEventHooks } = useAppStore();
const [dialogOpen, setDialogOpen] = useState(false);
const [editingHook, setEditingHook] = useState<EventHook | null>(null);
const handleAddHook = () => {
setEditingHook(null);
setDialogOpen(true);
};
const handleEditHook = (hook: EventHook) => {
setEditingHook(hook);
setDialogOpen(true);
};
const handleDeleteHook = (hookId: string) => {
setEventHooks(eventHooks.filter((h) => h.id !== hookId));
};
const handleToggleHook = (hookId: string, enabled: boolean) => {
setEventHooks(eventHooks.map((h) => (h.id === hookId ? { ...h, enabled } : h)));
};
const handleSaveHook = (hook: EventHook) => {
if (editingHook) {
// Update existing
setEventHooks(eventHooks.map((h) => (h.id === hook.id ? hook : h)));
} else {
// Add new
setEventHooks([...eventHooks, hook]);
}
setDialogOpen(false);
setEditingHook(null);
};
// Group hooks by trigger type for better organization
const hooksByTrigger = eventHooks.reduce(
(acc, hook) => {
if (!acc[hook.trigger]) {
acc[hook.trigger] = [];
}
acc[hook.trigger].push(hook);
return acc;
},
{} as Record<EventHookTrigger, EventHook[]>
);
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
{/* Header */}
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Webhook className="w-5 h-5 text-brand-500" />
</div>
<div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Event Hooks</h2>
<p className="text-sm text-muted-foreground/80">
Run custom commands or webhooks when events occur
</p>
</div>
</div>
<Button onClick={handleAddHook} size="sm" className="gap-2">
<Plus className="w-4 h-4" />
Add Hook
</Button>
</div>
</div>
{/* Content */}
<div className="p-6">
{eventHooks.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Webhook className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="text-sm">No event hooks configured</p>
<p className="text-xs mt-1">
Add hooks to run commands or send webhooks when features complete
</p>
</div>
) : (
<div className="space-y-6">
{/* Group by trigger type */}
{Object.entries(hooksByTrigger).map(([trigger, hooks]) => (
<div key={trigger} className="space-y-3">
<h3 className="text-sm font-medium text-muted-foreground">
{EVENT_HOOK_TRIGGER_LABELS[trigger as EventHookTrigger]}
</h3>
<div className="space-y-2">
{hooks.map((hook) => (
<HookCard
key={hook.id}
hook={hook}
onEdit={() => handleEditHook(hook)}
onDelete={() => handleDeleteHook(hook.id)}
onToggle={(enabled) => handleToggleHook(hook.id, enabled)}
/>
))}
</div>
</div>
))}
</div>
)}
</div>
{/* Variable reference */}
<div className="px-6 pb-6">
<div className="rounded-lg bg-muted/30 p-4 text-xs text-muted-foreground">
<p className="font-medium mb-2">Available variables:</p>
<code className="text-[10px] leading-relaxed">
{'{{featureId}}'} {'{{featureName}}'} {'{{projectPath}}'} {'{{projectName}}'}{' '}
{'{{error}}'} {'{{timestamp}}'} {'{{eventType}}'}
</code>
</div>
</div>
{/* Dialog */}
<EventHookDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
editingHook={editingHook}
onSave={handleSaveHook}
/>
</div>
);
}
interface HookCardProps {
hook: EventHook;
onEdit: () => void;
onDelete: () => void;
onToggle: (enabled: boolean) => void;
}
function HookCard({ hook, onEdit, onDelete, onToggle }: HookCardProps) {
const isShell = hook.action.type === 'shell';
return (
<div
className={cn(
'flex items-center gap-3 p-3 rounded-lg border',
'bg-background/50 hover:bg-background/80 transition-colors',
!hook.enabled && 'opacity-60'
)}
>
{/* Type icon */}
<div
className={cn(
'w-8 h-8 rounded-lg flex items-center justify-center',
isShell ? 'bg-amber-500/10 text-amber-500' : 'bg-blue-500/10 text-blue-500'
)}
>
{isShell ? <Terminal className="w-4 h-4" /> : <Globe className="w-4 h-4" />}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{hook.name || (isShell ? 'Shell Command' : 'HTTP Webhook')}
</p>
<p className="text-xs text-muted-foreground truncate">
{isShell
? (hook.action as { type: 'shell'; command: string }).command
: (hook.action as { type: 'http'; url: string }).url}
</p>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<Switch checked={hook.enabled} onCheckedChange={onToggle} />
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onEdit}>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={onDelete}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
export { EventHooksSection } from './event-hooks-section';

View File

@@ -15,10 +15,12 @@ export type SettingsViewId =
| 'terminal'
| 'keyboard'
| 'audio'
| 'event-hooks'
| 'defaults'
| 'worktrees'
| 'account'
| 'security'
| 'developer'
| 'danger';
interface UseSettingsViewOptions {

View File

@@ -567,6 +567,8 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
defaultRequirePlanApproval: settings.defaultRequirePlanApproval ?? false,
defaultFeatureModel: settings.defaultFeatureModel ?? { model: 'opus' },
muteDoneSound: settings.muteDoneSound ?? false,
serverLogLevel: settings.serverLogLevel ?? 'info',
enableRequestLogging: settings.enableRequestLogging ?? true,
enhancementModel: settings.enhancementModel ?? 'sonnet',
validationModel: settings.validationModel ?? 'opus',
phaseModels: settings.phaseModels ?? current.phaseModels,
@@ -626,6 +628,8 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
defaultPlanningMode: state.defaultPlanningMode,
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
muteDoneSound: state.muteDoneSound,
serverLogLevel: state.serverLogLevel,
enableRequestLogging: state.enableRequestLogging,
enhancementModel: state.enhancementModel,
validationModel: state.validationModel,
phaseModels: state.phaseModels,

View File

@@ -44,6 +44,8 @@ const SETTINGS_FIELDS_TO_SYNC = [
'defaultRequirePlanApproval',
'defaultFeatureModel',
'muteDoneSound',
'serverLogLevel',
'enableRequestLogging',
'enhancementModel',
'validationModel',
'phaseModels',
@@ -58,6 +60,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
'mcpServers',
'defaultEditorCommand',
'promptCustomization',
'eventHooks',
'projects',
'trashedProjects',
'currentProjectId', // ID of currently open project
@@ -470,6 +473,8 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
defaultRequirePlanApproval: serverSettings.defaultRequirePlanApproval,
defaultFeatureModel: serverSettings.defaultFeatureModel ?? { model: 'opus' },
muteDoneSound: serverSettings.muteDoneSound,
serverLogLevel: serverSettings.serverLogLevel ?? 'info',
enableRequestLogging: serverSettings.enableRequestLogging ?? true,
enhancementModel: serverSettings.enhancementModel,
validationModel: serverSettings.validationModel,
phaseModels: serverSettings.phaseModels,

View File

@@ -24,6 +24,8 @@ import type {
PipelineStep,
PromptCustomization,
ModelDefinition,
ServerLogLevel,
EventHook,
} from '@automaker/types';
import {
getAllCursorModelIds,
@@ -43,6 +45,7 @@ export type {
PlanningMode,
ThinkingLevel,
ModelProvider,
ServerLogLevel,
FeatureTextFilePath,
FeatureImagePath,
};
@@ -564,6 +567,10 @@ export interface AppState {
// Audio Settings
muteDoneSound: boolean; // When true, mute the notification sound when agents complete (default: false)
// Server Log Level Settings
serverLogLevel: ServerLogLevel; // Log level for the API server (error, warn, info, debug)
enableRequestLogging: boolean; // Enable HTTP request logging (Morgan)
// Enhancement Model Settings
enhancementModel: ModelAlias; // Model used for feature enhancement (default: sonnet)
@@ -630,6 +637,9 @@ export interface AppState {
// Prompt Customization
promptCustomization: PromptCustomization; // Custom prompts for Auto Mode, Agent, Backlog Plan, Enhancement
// Event Hooks
eventHooks: EventHook[]; // Event hooks for custom commands or webhooks
// Project Analysis
projectAnalysis: ProjectAnalysis | null;
isAnalyzing: boolean;
@@ -982,6 +992,10 @@ export interface AppActions {
// Audio Settings actions
setMuteDoneSound: (muted: boolean) => void;
// Server Log Level actions
setServerLogLevel: (level: ServerLogLevel) => void;
setEnableRequestLogging: (enabled: boolean) => void;
// Enhancement Model actions
setEnhancementModel: (model: ModelAlias) => void;
@@ -1039,6 +1053,9 @@ export interface AppActions {
// Prompt Customization actions
setPromptCustomization: (customization: PromptCustomization) => Promise<void>;
// Event Hook actions
setEventHooks: (hooks: EventHook[]) => void;
// MCP Server actions
addMCPServer: (server: Omit<MCPServerConfig, 'id'>) => void;
updateMCPServer: (id: string, updates: Partial<MCPServerConfig>) => void;
@@ -1250,6 +1267,8 @@ const initialState: AppState = {
worktreesByProject: {},
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, // Default keyboard shortcuts
muteDoneSound: false, // Default to sound enabled (not muted)
serverLogLevel: 'info', // Default to info level for server logs
enableRequestLogging: true, // Default to enabled for HTTP request logging
enhancementModel: 'sonnet', // Default to sonnet for feature enhancement
validationModel: 'opus', // Default to opus for GitHub issue validation
phaseModels: DEFAULT_PHASE_MODELS, // Phase-specific model configuration
@@ -1282,6 +1301,7 @@ const initialState: AppState = {
enableSubagents: true, // Subagents enabled by default
subagentsSources: ['user', 'project'] as Array<'user' | 'project'>, // Load from both sources by default
promptCustomization: {}, // Empty by default - all prompts use built-in defaults
eventHooks: [], // No event hooks configured by default
projectAnalysis: null,
isAnalyzing: false,
boardBackgroundByProject: {},
@@ -2036,6 +2056,10 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
// Audio Settings actions
setMuteDoneSound: (muted) => set({ muteDoneSound: muted }),
// Server Log Level actions
setServerLogLevel: (level) => set({ serverLogLevel: level }),
setEnableRequestLogging: (enabled) => set({ enableRequestLogging: enabled }),
// Enhancement Model actions
setEnhancementModel: (model) => set({ enhancementModel: model }),
@@ -2207,6 +2231,9 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
await syncSettingsToServer();
},
// Event Hook actions
setEventHooks: (hooks) => set({ eventHooks: hooks }),
// MCP Server actions
addMCPServer: (server) => {
const id = `mcp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;