diff --git a/CLAUDE.md b/CLAUDE.md index 40664601..cdd85e30 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -170,3 +170,44 @@ Use `resolveModelString()` from `@automaker/model-resolver` to convert model ali - `DATA_DIR` - Data storage directory (default: ./data) - `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory - `AUTOMAKER_MOCK_AGENT=true` - Enable mock agent mode for CI testing +- `ENABLE_DEBUG_PANEL=true` - Enable debug panel in non-development builds + +## Debug System (Development Only) + +The debug system provides real-time monitoring of server performance. Toggle with `Cmd/Ctrl+Shift+D`. + +### Debug Services (`apps/server/src/services/`) + +- `PerformanceMonitorService` - Collects memory/CPU metrics, detects leaks via linear regression +- `ProcessRegistryService` - Tracks spawned agents, terminals, CLIs with lifecycle events + +### Debug API (`apps/server/src/routes/debug/`) + +- `GET /api/debug/metrics` - Current metrics snapshot +- `POST /api/debug/metrics/start` - Start collection with optional config +- `POST /api/debug/metrics/stop` - Stop collection +- `GET /api/debug/processes` - List tracked processes with filters + +### Debug Types (`libs/types/src/debug.ts`) + +All debug types are exported from `@automaker/types`: + +```typescript +import type { + DebugMetricsSnapshot, + TrackedProcess, + MemoryTrend, + ProcessSummary, +} from '@automaker/types'; +import { formatBytes, formatDuration } from '@automaker/types'; +``` + +### UI Components (`apps/ui/src/components/debug/`) + +- `DebugPanel` - Main draggable container with tabs +- `MemoryMonitor` - Heap usage charts and leak indicators +- `CPUMonitor` - CPU gauge and event loop lag display +- `ProcessKanban` - Visual board of tracked processes +- `RenderTracker` - React component render statistics + +See `docs/server/debug-api.md` for full API documentation. diff --git a/README.md b/README.md index 9ca0f368..21395daa 100644 --- a/README.md +++ b/README.md @@ -374,6 +374,7 @@ npm run lint - `VITE_SKIP_ELECTRON` - Skip Electron in dev mode - `OPEN_DEVTOOLS` - Auto-open DevTools in Electron +- `ENABLE_DEBUG_PANEL` - Enable the debug panel in non-development builds (for staging environments) ### Authentication Setup @@ -455,6 +456,7 @@ The application can store your API key securely in the settings UI. The key is p - 🎨 **Theme System** - 25+ themes including Dark, Light, Dracula, Nord, Catppuccin, and more - 🖥️ **Cross-Platform** - Desktop app for macOS (x64, arm64), Windows (x64), and Linux (x64) - 🌐 **Web Mode** - Run in browser or as Electron desktop app +- 🐛 **Debug Panel** - Floating debug overlay for monitoring memory, CPU, and process performance (dev mode only, toggle with `Cmd/Ctrl+Shift+D`) ### Advanced Features diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 9ba53ed8..f4288bcc 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -63,6 +63,12 @@ import { createPipelineRoutes } from './routes/pipeline/index.js'; import { pipelineService } from './services/pipeline-service.js'; import { createIdeationRoutes } from './routes/ideation/index.js'; import { IdeationService } from './services/ideation-service.js'; +import { + createDebugRoutes, + createDebugServices, + stopDebugServices, + type DebugServices, +} from './routes/debug/index.js'; // Load environment variables dotenv.config(); @@ -70,6 +76,8 @@ 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_DEBUG_PANEL = + process.env.NODE_ENV !== 'production' || process.env.ENABLE_DEBUG_PANEL === 'true'; // Check for required environment variables const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY; @@ -169,6 +177,13 @@ const claudeUsageService = new ClaudeUsageService(); const mcpTestService = new MCPTestService(settingsService); const ideationService = new IdeationService(events, settingsService, featureLoader); +// Create debug services (dev mode only) +let debugServices: DebugServices | null = null; +if (ENABLE_DEBUG_PANEL) { + debugServices = createDebugServices(events); + logger.info('Debug services enabled'); +} + // Initialize services (async () => { await agentService.initialize(); @@ -223,6 +238,12 @@ app.use('/api/mcp', createMCPRoutes(mcpTestService)); app.use('/api/pipeline', createPipelineRoutes(pipelineService)); app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader)); +// Debug routes (dev mode only) +if (debugServices) { + app.use('/api/debug', createDebugRoutes(debugServices)); + logger.info('Debug API routes mounted at /api/debug'); +} + // Create HTTP server const server = createServer(app); @@ -588,6 +609,9 @@ startServer(PORT); process.on('SIGTERM', () => { logger.info('SIGTERM received, shutting down...'); terminalService.cleanup(); + if (debugServices) { + stopDebugServices(debugServices); + } server.close(() => { logger.info('Server closed'); process.exit(0); @@ -597,6 +621,9 @@ process.on('SIGTERM', () => { process.on('SIGINT', () => { logger.info('SIGINT received, shutting down...'); terminalService.cleanup(); + if (debugServices) { + stopDebugServices(debugServices); + } server.close(() => { logger.info('Server closed'); process.exit(0); diff --git a/apps/server/src/routes/debug/index.ts b/apps/server/src/routes/debug/index.ts new file mode 100644 index 00000000..acba94e9 --- /dev/null +++ b/apps/server/src/routes/debug/index.ts @@ -0,0 +1,332 @@ +/** + * Debug routes - HTTP API for debug panel and performance monitoring + * + * These routes are only enabled in development mode. + */ + +import { Router } from 'express'; +import type { EventEmitter } from '../../lib/events.js'; +import { PerformanceMonitorService } from '../../services/performance-monitor-service.js'; +import { ProcessRegistryService } from '../../services/process-registry-service.js'; +import { + createGetMetricsHandler, + createStartMetricsHandler, + createStopMetricsHandler, + createForceGCHandler, + createClearHistoryHandler, +} from './routes/metrics.js'; +import { + createGetProcessesHandler, + createGetProcessHandler, + createGetSummaryHandler, + createGetAgentsHandler, + createGetAgentMetricsHandler, + createGetAgentSummaryHandler, +} from './routes/processes.js'; + +export interface DebugServices { + performanceMonitor: PerformanceMonitorService; + processRegistry: ProcessRegistryService; +} + +/** + * Create and initialize debug services + */ +export function createDebugServices(events: EventEmitter): DebugServices { + // Create services + const processRegistry = new ProcessRegistryService(events); + const performanceMonitor = new PerformanceMonitorService(events); + + // Wire them together - performance monitor gets processes from registry + performanceMonitor.setProcessProvider(processRegistry.getProcessProvider()); + + // Subscribe to AutoMode events to track feature execution as processes + // Events are wrapped in 'auto-mode:event' with the actual type in data.type + events.subscribe((eventType, data) => { + // Handle auto-mode:event + if (eventType === 'auto-mode:event') { + handleAutoModeEvent(processRegistry, data); + return; + } + + // Handle agent:stream events for chat sessions + if (eventType === 'agent:stream') { + handleAgentStreamEvent(processRegistry, data); + return; + } + }); + + /** + * Handle AutoMode events for feature execution tracking + */ + function handleAutoModeEvent(registry: ProcessRegistryService, data: unknown): void { + const eventData = data as { type?: string; [key: string]: unknown }; + const innerType = eventData.type; + + if (innerType === 'auto_mode_feature_start') { + const { featureId, projectPath, feature, model } = eventData as { + featureId: string; + projectPath: string; + feature?: { id: string; title: string; description?: string }; + model?: string; + }; + + // Register the feature as a tracked process + // Use -1 for pid since this isn't a real OS process + registry.registerProcess({ + id: `agent-${featureId}`, + pid: -1, + type: 'agent', + name: feature?.title || `Feature ${featureId}`, + featureId, + cwd: projectPath, + command: model ? `claude ${model}` : 'claude agent', + }); + + // Initialize resource metrics + registry.initializeAgentMetrics(`agent-${featureId}`, { featureId }); + + // Mark it as running + registry.markRunning(`agent-${featureId}`); + } else if (innerType === 'auto_mode_feature_complete') { + const { featureId, passes, message } = eventData as { + featureId: string; + passes: boolean; + message?: string; + }; + + const processId = `agent-${featureId}`; + if (registry.hasProcess(processId)) { + // Finalize the metrics before marking as stopped + registry.finalizeAgentMetrics(processId); + + if (passes) { + registry.markStopped(processId, 0); + } else { + registry.markError(processId, message || 'Feature failed'); + } + } + } else if (innerType === 'auto_mode_error') { + const { featureId, error } = eventData as { + featureId?: string; + error: string; + }; + + if (featureId) { + const processId = `agent-${featureId}`; + if (registry.hasProcess(processId)) { + registry.finalizeAgentMetrics(processId); + registry.markError(processId, error); + } + } + } else if (innerType === 'auto_mode_tool_use') { + // Track tool usage for the feature + const { featureId, tool } = eventData as { + featureId: string; + tool: { name: string; input?: unknown }; + }; + + const processId = `agent-${featureId}`; + if (registry.hasProcess(processId)) { + registry.recordToolUse(processId, { toolName: tool.name }); + + // Record file operations based on tool type + if (tool.name === 'Read' && tool.input) { + const input = tool.input as { file_path?: string }; + if (input.file_path) { + registry.recordFileOperation(processId, { + operation: 'read', + filePath: input.file_path, + }); + } + } else if (tool.name === 'Write' && tool.input) { + const input = tool.input as { file_path?: string; content?: string }; + if (input.file_path) { + registry.recordFileOperation(processId, { + operation: 'write', + filePath: input.file_path, + bytes: input.content?.length, + }); + } + } else if (tool.name === 'Edit' && tool.input) { + const input = tool.input as { file_path?: string; new_string?: string }; + if (input.file_path) { + registry.recordFileOperation(processId, { + operation: 'edit', + filePath: input.file_path, + bytes: input.new_string?.length, + }); + } + } else if (tool.name === 'Glob') { + const input = tool.input as { path?: string }; + registry.recordFileOperation(processId, { + operation: 'glob', + filePath: input?.path || '.', + }); + } else if (tool.name === 'Grep') { + const input = tool.input as { path?: string }; + registry.recordFileOperation(processId, { + operation: 'grep', + filePath: input?.path || '.', + }); + } else if (tool.name === 'Bash' && tool.input) { + const input = tool.input as { command?: string }; + if (input.command) { + registry.recordBashCommand(processId, { + command: input.command, + executionTime: 0, // Will be updated on completion + exitCode: null, + }); + } + } + } + } + } + + /** + * Handle agent:stream events for chat session tracking + */ + function handleAgentStreamEvent(registry: ProcessRegistryService, data: unknown): void { + const eventData = data as { + sessionId?: string; + type?: string; + tool?: { name: string; input?: unknown }; + [key: string]: unknown; + }; + + const { sessionId, type } = eventData; + if (!sessionId) return; + + const processId = `chat-${sessionId}`; + + // Register chat session as a process if not already tracked + if (!registry.hasProcess(processId) && type !== 'complete' && type !== 'error') { + registry.registerProcess({ + id: processId, + pid: -1, + type: 'agent', + name: `Chat Session`, + sessionId, + command: 'claude chat', + }); + registry.initializeAgentMetrics(processId, { sessionId }); + registry.markRunning(processId); + } + + // Handle different event types + if (type === 'tool_use' && eventData.tool) { + const tool = eventData.tool; + registry.recordToolUse(processId, { toolName: tool.name }); + + // Record file operations based on tool type + if (tool.name === 'Read' && tool.input) { + const input = tool.input as { file_path?: string }; + if (input.file_path) { + registry.recordFileOperation(processId, { + operation: 'read', + filePath: input.file_path, + }); + } + } else if (tool.name === 'Write' && tool.input) { + const input = tool.input as { file_path?: string; content?: string }; + if (input.file_path) { + registry.recordFileOperation(processId, { + operation: 'write', + filePath: input.file_path, + bytes: input.content?.length, + }); + } + } else if (tool.name === 'Edit' && tool.input) { + const input = tool.input as { file_path?: string; new_string?: string }; + if (input.file_path) { + registry.recordFileOperation(processId, { + operation: 'edit', + filePath: input.file_path, + bytes: input.new_string?.length, + }); + } + } else if (tool.name === 'Glob') { + const input = tool.input as { path?: string }; + registry.recordFileOperation(processId, { + operation: 'glob', + filePath: input?.path || '.', + }); + } else if (tool.name === 'Grep') { + const input = tool.input as { path?: string }; + registry.recordFileOperation(processId, { + operation: 'grep', + filePath: input?.path || '.', + }); + } else if (tool.name === 'Bash' && tool.input) { + const input = tool.input as { command?: string }; + if (input.command) { + registry.recordBashCommand(processId, { + command: input.command, + executionTime: 0, + exitCode: null, + }); + } + } + } else if (type === 'complete') { + if (registry.hasProcess(processId)) { + registry.finalizeAgentMetrics(processId); + // Keep the session as "idle" rather than "stopped" since it can receive more messages + registry.markIdle(processId); + } + } else if (type === 'error') { + if (registry.hasProcess(processId)) { + registry.finalizeAgentMetrics(processId); + const errorMsg = (eventData.error as string) || 'Unknown error'; + registry.markError(processId, errorMsg); + } + } + } + + // Start services + processRegistry.start(); + performanceMonitor.start(); + + return { + performanceMonitor, + processRegistry, + }; +} + +/** + * Stop debug services + */ +export function stopDebugServices(services: DebugServices): void { + services.performanceMonitor.stop(); + services.processRegistry.stop(); +} + +/** + * Create debug routes + */ +export function createDebugRoutes(services: DebugServices): Router { + const router = Router(); + const { performanceMonitor, processRegistry } = services; + + // Metrics routes + router.get('/metrics', createGetMetricsHandler(performanceMonitor)); + router.post('/metrics/start', createStartMetricsHandler(performanceMonitor)); + router.post('/metrics/stop', createStopMetricsHandler(performanceMonitor)); + router.post('/metrics/gc', createForceGCHandler(performanceMonitor)); + router.post('/metrics/clear', createClearHistoryHandler(performanceMonitor)); + + // Process routes + router.get('/processes', createGetProcessesHandler(processRegistry)); + router.get('/processes/summary', createGetSummaryHandler(processRegistry)); + router.get('/processes/:id', createGetProcessHandler(processRegistry)); + + // Agent resource metrics routes + router.get('/agents', createGetAgentsHandler(processRegistry)); + router.get('/agents/summary', createGetAgentSummaryHandler(processRegistry)); + router.get('/agents/:id/metrics', createGetAgentMetricsHandler(processRegistry)); + + return router; +} + +// Re-export services for use elsewhere +export { PerformanceMonitorService } from '../../services/performance-monitor-service.js'; +export { ProcessRegistryService } from '../../services/process-registry-service.js'; diff --git a/apps/server/src/routes/debug/routes/metrics.ts b/apps/server/src/routes/debug/routes/metrics.ts new file mode 100644 index 00000000..faef9092 --- /dev/null +++ b/apps/server/src/routes/debug/routes/metrics.ts @@ -0,0 +1,152 @@ +/** + * Debug metrics route handler + * + * GET /api/debug/metrics - Get current metrics snapshot + * POST /api/debug/metrics/start - Start metrics collection + * POST /api/debug/metrics/stop - Stop metrics collection + */ + +import type { Request, Response } from 'express'; +import type { PerformanceMonitorService } from '../../../services/performance-monitor-service.js'; +import type { StartDebugMetricsRequest, DebugMetricsResponse } from '@automaker/types'; + +/** + * Create handler for GET /api/debug/metrics + * Returns current metrics snapshot + */ +export function createGetMetricsHandler(performanceMonitor: PerformanceMonitorService) { + return (_req: Request, res: Response) => { + const snapshot = performanceMonitor.getLatestSnapshot(); + const config = performanceMonitor.getConfig(); + const active = performanceMonitor.isActive(); + + const response: DebugMetricsResponse = { + active, + config, + snapshot: snapshot ?? undefined, + }; + + res.json(response); + }; +} + +/** + * Validate and sanitize debug metrics config values + * Prevents DoS via extreme configuration values + */ +function sanitizeConfig( + config: Partial +): Partial { + const sanitized: Partial = {}; + + // Collection interval: min 100ms, max 60s (prevents CPU exhaustion) + if (typeof config.collectionInterval === 'number') { + sanitized.collectionInterval = Math.min( + 60000, + Math.max(100, Math.floor(config.collectionInterval)) + ); + } + + // Max data points: min 10, max 10000 (prevents memory exhaustion) + if (typeof config.maxDataPoints === 'number') { + sanitized.maxDataPoints = Math.min(10000, Math.max(10, Math.floor(config.maxDataPoints))); + } + + // Leak threshold: min 1KB, max 100MB (reasonable bounds) + if (typeof config.leakThreshold === 'number') { + sanitized.leakThreshold = Math.min( + 100 * 1024 * 1024, + Math.max(1024, Math.floor(config.leakThreshold)) + ); + } + + // Boolean flags - only accept actual booleans + if (typeof config.memoryEnabled === 'boolean') { + sanitized.memoryEnabled = config.memoryEnabled; + } + if (typeof config.cpuEnabled === 'boolean') { + sanitized.cpuEnabled = config.cpuEnabled; + } + if (typeof config.processTrackingEnabled === 'boolean') { + sanitized.processTrackingEnabled = config.processTrackingEnabled; + } + + return sanitized; +} + +/** + * Create handler for POST /api/debug/metrics/start + * Starts metrics collection with optional config overrides + */ +export function createStartMetricsHandler(performanceMonitor: PerformanceMonitorService) { + return (req: Request, res: Response) => { + const body = req.body as StartDebugMetricsRequest | undefined; + + // Update config if provided (with validation) + if (body?.config && typeof body.config === 'object') { + const sanitizedConfig = sanitizeConfig(body.config); + if (Object.keys(sanitizedConfig).length > 0) { + performanceMonitor.updateConfig(sanitizedConfig); + } + } + + // Start collection + performanceMonitor.start(); + + const response: DebugMetricsResponse = { + active: true, + config: performanceMonitor.getConfig(), + }; + + res.json(response); + }; +} + +/** + * Create handler for POST /api/debug/metrics/stop + * Stops metrics collection + */ +export function createStopMetricsHandler(performanceMonitor: PerformanceMonitorService) { + return (_req: Request, res: Response) => { + performanceMonitor.stop(); + + const response: DebugMetricsResponse = { + active: false, + config: performanceMonitor.getConfig(), + }; + + res.json(response); + }; +} + +/** + * Create handler for POST /api/debug/metrics/gc + * Forces garbage collection if available + */ +export function createForceGCHandler(performanceMonitor: PerformanceMonitorService) { + return (_req: Request, res: Response) => { + const success = performanceMonitor.forceGC(); + + res.json({ + success, + message: success + ? 'Garbage collection triggered' + : 'Garbage collection not available (start Node.js with --expose-gc flag)', + }); + }; +} + +/** + * Create handler for POST /api/debug/metrics/clear + * Clears metrics history + */ +export function createClearHistoryHandler(performanceMonitor: PerformanceMonitorService) { + return (_req: Request, res: Response) => { + performanceMonitor.clearHistory(); + + res.json({ + success: true, + message: 'Metrics history cleared', + }); + }; +} diff --git a/apps/server/src/routes/debug/routes/processes.ts b/apps/server/src/routes/debug/routes/processes.ts new file mode 100644 index 00000000..9999276a --- /dev/null +++ b/apps/server/src/routes/debug/routes/processes.ts @@ -0,0 +1,170 @@ +/** + * Debug processes route handler + * + * GET /api/debug/processes - Get list of tracked processes + * GET /api/debug/processes/:id - Get specific process by ID + * POST /api/debug/processes/:id/terminate - Terminate a process + */ + +import type { Request, Response } from 'express'; +import type { ProcessRegistryService } from '../../../services/process-registry-service.js'; +import type { + GetProcessesRequest, + GetProcessesResponse, + ProcessType, + ProcessStatus, +} from '@automaker/types'; + +/** + * Create handler for GET /api/debug/processes + * Returns list of tracked processes with optional filtering + */ +export function createGetProcessesHandler(processRegistry: ProcessRegistryService) { + return (req: Request, res: Response) => { + const query = req.query as { + type?: string; + status?: string; + includeStopped?: string; + sessionId?: string; + featureId?: string; + }; + + // Build query options + const options: GetProcessesRequest = {}; + + if (query.type) { + options.type = query.type as ProcessType; + } + + if (query.status) { + options.status = query.status as ProcessStatus; + } + + if (query.includeStopped === 'true') { + options.includeStoppedProcesses = true; + } + + const processes = processRegistry.getProcesses({ + type: options.type, + status: options.status, + includeStopped: options.includeStoppedProcesses, + sessionId: query.sessionId, + featureId: query.featureId, + }); + + const summary = processRegistry.getProcessSummary(); + + const response: GetProcessesResponse = { + processes, + summary, + }; + + res.json(response); + }; +} + +/** + * Validate process ID format + * Process IDs should be non-empty strings with reasonable length + */ +function isValidProcessId(id: unknown): id is string { + return typeof id === 'string' && id.length > 0 && id.length <= 256; +} + +/** + * Create handler for GET /api/debug/processes/:id + * Returns a specific process by ID + */ +export function createGetProcessHandler(processRegistry: ProcessRegistryService) { + return (req: Request, res: Response) => { + const { id } = req.params; + + // Validate process ID format + if (!isValidProcessId(id)) { + res.status(400).json({ + error: 'Invalid process ID format', + }); + return; + } + + const process = processRegistry.getProcess(id); + + if (!process) { + res.status(404).json({ + error: 'Process not found', + id, + }); + return; + } + + res.json(process); + }; +} + +/** + * Create handler for GET /api/debug/processes/summary + * Returns summary statistics + */ +export function createGetSummaryHandler(processRegistry: ProcessRegistryService) { + return (_req: Request, res: Response) => { + const summary = processRegistry.getProcessSummary(); + res.json(summary); + }; +} + +/** + * Create handler for GET /api/debug/agents + * Returns all agent processes with their resource metrics + */ +export function createGetAgentsHandler(processRegistry: ProcessRegistryService) { + return (_req: Request, res: Response) => { + const agents = processRegistry.getAgentProcessesWithMetrics(); + const summary = processRegistry.getAgentResourceSummary(); + + res.json({ + agents, + summary, + }); + }; +} + +/** + * Create handler for GET /api/debug/agents/:id/metrics + * Returns detailed resource metrics for a specific agent + */ +export function createGetAgentMetricsHandler(processRegistry: ProcessRegistryService) { + return (req: Request, res: Response) => { + const { id } = req.params; + + // Validate process ID format + if (!isValidProcessId(id)) { + res.status(400).json({ + error: 'Invalid agent ID format', + }); + return; + } + + const metrics = processRegistry.getAgentMetrics(id); + + if (!metrics) { + res.status(404).json({ + error: 'Agent metrics not found', + id, + }); + return; + } + + res.json(metrics); + }; +} + +/** + * Create handler for GET /api/debug/agents/summary + * Returns summary of resource usage across all agents + */ +export function createGetAgentSummaryHandler(processRegistry: ProcessRegistryService) { + return (_req: Request, res: Response) => { + const summary = processRegistry.getAgentResourceSummary(); + res.json(summary); + }; +} diff --git a/apps/server/src/services/performance-monitor-service.ts b/apps/server/src/services/performance-monitor-service.ts new file mode 100644 index 00000000..d4a12f2e --- /dev/null +++ b/apps/server/src/services/performance-monitor-service.ts @@ -0,0 +1,673 @@ +/** + * Performance Monitor Service + * + * Collects and streams server-side performance metrics including: + * - Memory usage (heap, rss, external) + * - CPU usage (user, system, percentage) + * - Event loop lag detection + * - Memory leak trend analysis + * + * Emits debug events for real-time streaming to connected clients. + */ + +import v8 from 'v8'; +import { createLogger } from '@automaker/utils'; +import type { EventEmitter } from '../lib/events.js'; +import type { + ServerMemoryMetrics, + ServerCPUMetrics, + MemoryMetrics, + CPUMetrics, + MemoryTrend, + DebugMetricsConfig, + DebugMetricsSnapshot, + ProcessSummary, + TrackedProcess, +} from '@automaker/types'; +import { DEFAULT_DEBUG_METRICS_CONFIG, formatBytes } from '@automaker/types'; + +const logger = createLogger('PerformanceMonitor'); + +/** + * Circular buffer for time-series data storage + * Uses index-based ring buffer for O(1) push operations instead of O(n) shift(). + * Efficiently stores a fixed number of data points, automatically discarding old ones. + */ +class CircularBuffer { + private buffer: (T | undefined)[]; + private maxSize: number; + private head = 0; // Write position + private count = 0; // Number of items + + constructor(maxSize: number) { + this.maxSize = maxSize; + this.buffer = new Array(maxSize); + } + + /** + * Add item to buffer - O(1) operation + */ + push(item: T): void { + this.buffer[this.head] = item; + this.head = (this.head + 1) % this.maxSize; + if (this.count < this.maxSize) { + this.count++; + } + } + + /** + * Get all items in chronological order - O(n) but only called when needed + */ + getAll(): T[] { + if (this.count === 0) return []; + + const result: T[] = new Array(this.count); + const start = this.count < this.maxSize ? 0 : this.head; + + for (let i = 0; i < this.count; i++) { + const idx = (start + i) % this.maxSize; + result[i] = this.buffer[idx] as T; + } + + return result; + } + + /** + * Get most recent item - O(1) + */ + getLast(): T | undefined { + if (this.count === 0) return undefined; + const idx = (this.head - 1 + this.maxSize) % this.maxSize; + return this.buffer[idx]; + } + + /** + * Get oldest item - O(1) + */ + getFirst(): T | undefined { + if (this.count === 0) return undefined; + const start = this.count < this.maxSize ? 0 : this.head; + return this.buffer[start]; + } + + /** + * Get current count - O(1) + */ + size(): number { + return this.count; + } + + /** + * Clear all items - O(1) + */ + clear(): void { + this.head = 0; + this.count = 0; + // Don't reallocate array, just reset indices + } + + /** + * Resize buffer, preserving existing data + */ + resize(newSize: number): void { + const oldData = this.getAll(); + this.maxSize = newSize; + this.buffer = new Array(newSize); + this.head = 0; + this.count = 0; + + // Copy over data (trim if necessary, keep most recent) + const startIdx = Math.max(0, oldData.length - newSize); + for (let i = startIdx; i < oldData.length; i++) { + this.push(oldData[i]); + } + } +} + +/** + * Memory data point for trend analysis + */ +interface MemoryDataPoint { + timestamp: number; + heapUsed: number; +} + +/** + * CPU data point for tracking + */ +interface CPUDataPoint { + timestamp: number; + user: number; + system: number; +} + +/** + * PerformanceMonitorService - Collects server-side performance metrics + * + * This service runs in the Node.js server process and periodically collects: + * - Memory metrics from process.memoryUsage() + * - CPU metrics from process.cpuUsage() + * - Event loop lag using setTimeout deviation + * + * It streams metrics to connected clients via the event emitter and + * analyzes memory trends to detect potential leaks. + */ +export class PerformanceMonitorService { + private events: EventEmitter; + private config: DebugMetricsConfig; + private isRunning = false; + private collectionInterval: NodeJS.Timeout | null = null; + private eventLoopCheckInterval: NodeJS.Timeout | null = null; + + // Data storage + private memoryHistory: CircularBuffer; + private cpuHistory: CircularBuffer; + + // CPU tracking state + private lastCpuUsage: NodeJS.CpuUsage | null = null; + private lastCpuTime: number = 0; + + // Event loop lag tracking + private lastEventLoopLag = 0; + private eventLoopLagThreshold = 100; // ms - threshold for warning + + // Memory warning thresholds (percentage of heap limit) + private memoryWarningThreshold = 70; // 70% of heap limit + private memoryCriticalThreshold = 90; // 90% of heap limit + private lastMemoryWarningTime = 0; + private memoryWarningCooldown = 30000; // 30 seconds between warnings + + // Process tracking (will be populated by ProcessRegistryService) + private getProcesses: () => TrackedProcess[] = () => []; + + constructor(events: EventEmitter, config?: Partial) { + this.events = events; + this.config = { ...DEFAULT_DEBUG_METRICS_CONFIG, ...config }; + this.memoryHistory = new CircularBuffer(this.config.maxDataPoints); + this.cpuHistory = new CircularBuffer(this.config.maxDataPoints); + + logger.info('PerformanceMonitorService initialized'); + } + + /** + * Set the process provider function (called by ProcessRegistryService) + */ + setProcessProvider(provider: () => TrackedProcess[]): void { + this.getProcesses = provider; + } + + /** + * Start metrics collection + */ + start(): void { + if (this.isRunning) { + logger.warn('PerformanceMonitorService is already running'); + return; + } + + this.isRunning = true; + this.lastCpuUsage = process.cpuUsage(); + this.lastCpuTime = Date.now(); + + // Start periodic metrics collection + this.collectionInterval = setInterval(() => { + this.collectAndEmitMetrics(); + }, this.config.collectionInterval); + + // Start event loop lag monitoring (more frequent for accurate detection) + this.startEventLoopMonitoring(); + + logger.info('PerformanceMonitorService started', { + interval: this.config.collectionInterval, + }); + } + + /** + * Stop metrics collection + */ + stop(): void { + if (!this.isRunning) { + return; + } + + this.isRunning = false; + + if (this.collectionInterval) { + clearInterval(this.collectionInterval); + this.collectionInterval = null; + } + + if (this.eventLoopCheckInterval) { + clearInterval(this.eventLoopCheckInterval); + this.eventLoopCheckInterval = null; + } + + logger.info('PerformanceMonitorService stopped'); + } + + /** + * Update configuration + */ + updateConfig(config: Partial): void { + const wasRunning = this.isRunning; + + if (wasRunning) { + this.stop(); + } + + this.config = { ...this.config, ...config }; + + // Resize buffers if maxDataPoints changed + if (config.maxDataPoints) { + this.memoryHistory.resize(config.maxDataPoints); + this.cpuHistory.resize(config.maxDataPoints); + } + + if (wasRunning) { + this.start(); + } + + logger.info('PerformanceMonitorService configuration updated', config); + } + + /** + * Get current configuration + */ + getConfig(): DebugMetricsConfig { + return { ...this.config }; + } + + /** + * Get whether monitoring is active + */ + isActive(): boolean { + return this.isRunning; + } + + /** + * Collect and emit current metrics + */ + private collectAndEmitMetrics(): void { + const timestamp = Date.now(); + const memoryMetrics = this.collectMemoryMetrics(timestamp); + const cpuMetrics = this.collectCPUMetrics(timestamp); + + // Store in history + if (this.config.memoryEnabled && memoryMetrics.server) { + this.memoryHistory.push({ + timestamp, + heapUsed: memoryMetrics.server.heapUsed, + }); + } + + // Analyze memory trend + const memoryTrend = this.analyzeMemoryTrend(); + + // Get process information + const processes = this.getProcesses(); + const processSummary = this.calculateProcessSummary(processes); + + // Build snapshot + const snapshot: DebugMetricsSnapshot = { + timestamp, + memory: memoryMetrics, + cpu: cpuMetrics, + processes, + processSummary, + memoryTrend, + }; + + // Emit metrics event + this.events.emit('debug:metrics', { + type: 'debug:metrics', + timestamp, + metrics: snapshot, + }); + + // Check for memory warnings + this.checkMemoryThresholds(memoryMetrics); + + // Check for memory leak + if (memoryTrend && memoryTrend.isLeaking) { + this.events.emit('debug:leak-detected', { + type: 'debug:leak-detected', + timestamp, + trend: memoryTrend, + message: `Potential memory leak detected: ${formatBytes(memoryTrend.growthRate)}/s sustained growth`, + }); + } + + // Check for high CPU + if (cpuMetrics.server && cpuMetrics.server.percentage > 80) { + this.events.emit('debug:high-cpu', { + type: 'debug:high-cpu', + timestamp, + cpu: cpuMetrics, + usagePercent: cpuMetrics.server.percentage, + threshold: 80, + message: `High CPU usage: ${cpuMetrics.server.percentage.toFixed(1)}%`, + }); + } + } + + /** + * Collect memory metrics from Node.js process + */ + private collectMemoryMetrics(timestamp: number): MemoryMetrics { + if (!this.config.memoryEnabled) { + return { timestamp }; + } + + const usage = process.memoryUsage(); + const serverMetrics: ServerMemoryMetrics = { + heapTotal: usage.heapTotal, + heapUsed: usage.heapUsed, + external: usage.external, + rss: usage.rss, + arrayBuffers: usage.arrayBuffers, + }; + + return { + timestamp, + server: serverMetrics, + }; + } + + /** + * Collect CPU metrics from Node.js process + */ + private collectCPUMetrics(timestamp: number): CPUMetrics { + if (!this.config.cpuEnabled) { + return { timestamp }; + } + + const currentCpuUsage = process.cpuUsage(); + const currentTime = Date.now(); + + let serverMetrics: ServerCPUMetrics | undefined; + + if (this.lastCpuUsage) { + // Calculate CPU usage since last measurement + const userDiff = currentCpuUsage.user - this.lastCpuUsage.user; + const systemDiff = currentCpuUsage.system - this.lastCpuUsage.system; + const timeDiff = (currentTime - this.lastCpuTime) * 1000; // Convert to microseconds + + // Calculate percentage (CPU usage is in microseconds) + // For multi-core systems, this can exceed 100% + const percentage = timeDiff > 0 ? ((userDiff + systemDiff) / timeDiff) * 100 : 0; + + serverMetrics = { + percentage: Math.min(100, percentage), // Cap at 100% for single-core representation + user: userDiff, + system: systemDiff, + }; + + // Store in history + this.cpuHistory.push({ + timestamp, + user: userDiff, + system: systemDiff, + }); + } + + this.lastCpuUsage = currentCpuUsage; + this.lastCpuTime = currentTime; + + return { + timestamp, + server: serverMetrics, + eventLoopLag: this.lastEventLoopLag, + }; + } + + /** + * Start event loop lag monitoring + * Uses setTimeout deviation to detect when the event loop is blocked + */ + private startEventLoopMonitoring(): void { + const checkInterval = 100; // Check every 100ms + + const measureLag = () => { + if (!this.isRunning) return; + + const start = Date.now(); + + // setImmediate runs after I/O events, giving us event loop lag + setImmediate(() => { + const lag = Date.now() - start; + this.lastEventLoopLag = lag; + + // Emit warning if lag exceeds threshold + if (lag > this.eventLoopLagThreshold) { + this.events.emit('debug:event-loop-blocked', { + type: 'debug:event-loop-blocked', + timestamp: Date.now(), + lag, + threshold: this.eventLoopLagThreshold, + message: `Event loop blocked for ${lag}ms`, + }); + } + }); + }; + + this.eventLoopCheckInterval = setInterval(measureLag, checkInterval); + } + + /** + * Analyze memory trend for leak detection + */ + private analyzeMemoryTrend(): MemoryTrend | undefined { + const history = this.memoryHistory.getAll(); + if (history.length < 10) { + return undefined; // Need at least 10 samples for meaningful analysis + } + + const first = history[0]; + const last = history[history.length - 1]; + const windowDuration = last.timestamp - first.timestamp; + + if (windowDuration === 0) { + return undefined; + } + + // Calculate linear regression for growth rate + const n = history.length; + let sumX = 0; + let sumY = 0; + let sumXY = 0; + let sumXX = 0; + + for (let i = 0; i < n; i++) { + const x = history[i].timestamp - first.timestamp; + const y = history[i].heapUsed; + sumX += x; + sumY += y; + sumXY += x * y; + sumXX += x * x; + } + + // Slope of linear regression (bytes per millisecond) + const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX); + const growthRate = slope * 1000; // Convert to bytes per second + + // Calculate R² for confidence + const meanY = sumY / n; + let ssRes = 0; + let ssTot = 0; + const intercept = (sumY - slope * sumX) / n; + + for (let i = 0; i < n; i++) { + const x = history[i].timestamp - first.timestamp; + const y = history[i].heapUsed; + const yPred = slope * x + intercept; + ssRes += (y - yPred) ** 2; + ssTot += (y - meanY) ** 2; + } + + const rSquared = ssTot > 0 ? 1 - ssRes / ssTot : 0; + const confidence = Math.max(0, Math.min(1, rSquared)); + + // Consider it a leak if: + // 1. Growth rate exceeds threshold + // 2. R² is high (indicating consistent growth, not just fluctuation) + const isLeaking = + growthRate > this.config.leakThreshold && confidence > 0.7 && windowDuration > 30000; // At least 30 seconds of data + + return { + growthRate, + isLeaking, + confidence, + sampleCount: n, + windowDuration, + }; + } + + /** + * Check memory thresholds and emit warnings + */ + private checkMemoryThresholds(memory: MemoryMetrics): void { + if (!memory.server) return; + + const now = Date.now(); + if (now - this.lastMemoryWarningTime < this.memoryWarningCooldown) { + return; // Don't spam warnings + } + + // Get V8 heap statistics for limit + const heapStats = v8.getHeapStatistics(); + const heapLimit = heapStats.heap_size_limit; + const usagePercent = (memory.server.heapUsed / heapLimit) * 100; + + if (usagePercent >= this.memoryCriticalThreshold) { + this.lastMemoryWarningTime = now; + this.events.emit('debug:memory-critical', { + type: 'debug:memory-critical', + timestamp: now, + memory, + usagePercent, + threshold: this.memoryCriticalThreshold, + message: `Critical memory usage: ${usagePercent.toFixed(1)}% of heap limit`, + }); + } else if (usagePercent >= this.memoryWarningThreshold) { + this.lastMemoryWarningTime = now; + this.events.emit('debug:memory-warning', { + type: 'debug:memory-warning', + timestamp: now, + memory, + usagePercent, + threshold: this.memoryWarningThreshold, + message: `High memory usage: ${usagePercent.toFixed(1)}% of heap limit`, + }); + } + } + + /** + * Calculate process summary from tracked processes + */ + private calculateProcessSummary(processes: TrackedProcess[]): ProcessSummary { + const summary: ProcessSummary = { + total: processes.length, + running: 0, + idle: 0, + stopped: 0, + errored: 0, + byType: { + agent: 0, + cli: 0, + terminal: 0, + worker: 0, + }, + }; + + for (const process of processes) { + // Count by status + switch (process.status) { + case 'running': + case 'starting': + summary.running++; + break; + case 'idle': + summary.idle++; + break; + case 'stopped': + case 'stopping': + summary.stopped++; + break; + case 'error': + summary.errored++; + break; + } + + // Count by type + if (process.type in summary.byType) { + summary.byType[process.type]++; + } + } + + return summary; + } + + /** + * Get latest metrics snapshot + */ + getLatestSnapshot(): DebugMetricsSnapshot | null { + const timestamp = Date.now(); + const lastMemory = this.memoryHistory.getLast(); + + if (!lastMemory) { + return null; + } + + const memoryMetrics = this.collectMemoryMetrics(timestamp); + const cpuMetrics = this.collectCPUMetrics(timestamp); + const memoryTrend = this.analyzeMemoryTrend(); + const processes = this.getProcesses(); + const processSummary = this.calculateProcessSummary(processes); + + return { + timestamp, + memory: memoryMetrics, + cpu: cpuMetrics, + processes, + processSummary, + memoryTrend, + }; + } + + /** + * Get memory history for charting + */ + getMemoryHistory(): MemoryDataPoint[] { + return this.memoryHistory.getAll(); + } + + /** + * Get CPU history for charting + */ + getCPUHistory(): CPUDataPoint[] { + return this.cpuHistory.getAll(); + } + + /** + * Force a garbage collection (if --expose-gc flag is used) + * Returns true if GC was triggered, false if not available + */ + forceGC(): boolean { + if (global.gc) { + global.gc(); + logger.info('Forced garbage collection'); + return true; + } + logger.warn('Garbage collection not available (start with --expose-gc flag)'); + return false; + } + + /** + * Clear collected history + */ + clearHistory(): void { + this.memoryHistory.clear(); + this.cpuHistory.clear(); + logger.info('Performance history cleared'); + } +} diff --git a/apps/server/src/services/process-registry-service.ts b/apps/server/src/services/process-registry-service.ts new file mode 100644 index 00000000..f227ea4f --- /dev/null +++ b/apps/server/src/services/process-registry-service.ts @@ -0,0 +1,982 @@ +/** + * Process Registry Service + * + * Tracks spawned agents, CLIs, and terminal processes for debugging and monitoring. + * Emits debug events for real-time updates to connected clients. + * + * This service provides: + * - Process registration and unregistration + * - Status updates for tracked processes + * - Integration with PerformanceMonitorService for metrics snapshots + * - Filtering and querying of tracked processes + * - Automatic cleanup of stopped processes after a retention period + */ + +import { createLogger } from '@automaker/utils'; +import type { EventEmitter } from '../lib/events.js'; +import type { + TrackedProcess, + ProcessType, + ProcessStatus, + ProcessSummary, + AgentResourceMetrics, + FileIOOperation, +} from '@automaker/types'; +import { createEmptyAgentResourceMetrics } from '@automaker/types'; + +const logger = createLogger('ProcessRegistry'); + +/** + * Options for recording a tool invocation + */ +export interface RecordToolUseOptions { + /** Tool name */ + toolName: string; + /** Execution time in milliseconds */ + executionTime?: number; + /** Whether the tool invocation failed */ + failed?: boolean; +} + +/** + * Options for recording a file operation + */ +export interface RecordFileOperationOptions { + /** Type of file operation */ + operation: FileIOOperation; + /** File path accessed */ + filePath: string; + /** Bytes read or written */ + bytes?: number; +} + +/** + * Options for recording a bash command + */ +export interface RecordBashCommandOptions { + /** Command executed */ + command: string; + /** Execution time in milliseconds */ + executionTime: number; + /** Exit code (null if still running or killed) */ + exitCode: number | null; +} + +/** + * Options for registering a new process + */ +export interface RegisterProcessOptions { + /** Unique identifier for the process */ + id: string; + /** Process ID from the operating system (-1 if not applicable) */ + pid: number; + /** Type of process */ + type: ProcessType; + /** Human-readable name/label */ + name: string; + /** Associated feature ID (for agent processes) */ + featureId?: string; + /** Associated session ID (for agent/terminal processes) */ + sessionId?: string; + /** Command that was executed */ + command?: string; + /** Working directory */ + cwd?: string; +} + +/** + * Options for updating a process + */ +export interface UpdateProcessOptions { + /** New status */ + status?: ProcessStatus; + /** Memory usage in bytes */ + memoryUsage?: number; + /** CPU usage percentage */ + cpuUsage?: number; + /** Exit code (when stopping) */ + exitCode?: number; + /** Error message */ + error?: string; +} + +/** + * Options for querying processes + */ +export interface QueryProcessOptions { + /** Filter by process type */ + type?: ProcessType; + /** Filter by status */ + status?: ProcessStatus; + /** Include stopped processes (default: false) */ + includeStopped?: boolean; + /** Filter by session ID */ + sessionId?: string; + /** Filter by feature ID */ + featureId?: string; +} + +/** + * Configuration for the ProcessRegistryService + */ +export interface ProcessRegistryConfig { + /** How long to keep stopped processes in the registry (ms) */ + stoppedProcessRetention: number; + /** Interval for cleanup of old stopped processes (ms) */ + cleanupInterval: number; + /** Maximum number of stopped processes to retain */ + maxStoppedProcesses: number; +} + +const DEFAULT_CONFIG: ProcessRegistryConfig = { + stoppedProcessRetention: 5 * 60 * 1000, // 5 minutes + cleanupInterval: 60 * 1000, // 1 minute + maxStoppedProcesses: 100, +}; + +/** + * ProcessRegistryService - Tracks spawned processes for debugging + * + * This service maintains a registry of all tracked processes including: + * - Agent sessions (AI conversations) + * - CLI processes (one-off commands) + * - Terminal sessions (persistent PTY sessions) + * - Worker processes (background tasks) + * + * It emits events when processes are spawned, updated, or stopped, + * allowing real-time monitoring in the debug panel. + */ +export class ProcessRegistryService { + private events: EventEmitter; + private config: ProcessRegistryConfig; + private processes: Map = new Map(); + private cleanupIntervalId: NodeJS.Timeout | null = null; + + constructor(events: EventEmitter, config?: Partial) { + this.events = events; + this.config = { ...DEFAULT_CONFIG, ...config }; + + logger.info('ProcessRegistryService initialized'); + } + + /** + * Start the process registry service + * Begins periodic cleanup of old stopped processes + */ + start(): void { + if (this.cleanupIntervalId) { + logger.warn('ProcessRegistryService is already running'); + return; + } + + this.cleanupIntervalId = setInterval(() => { + this.cleanupStoppedProcesses(); + }, this.config.cleanupInterval); + + logger.info('ProcessRegistryService started'); + } + + /** + * Stop the process registry service + */ + stop(): void { + if (this.cleanupIntervalId) { + clearInterval(this.cleanupIntervalId); + this.cleanupIntervalId = null; + } + + logger.info('ProcessRegistryService stopped'); + } + + /** + * Register a new process + */ + registerProcess(options: RegisterProcessOptions): TrackedProcess { + const now = Date.now(); + + const process: TrackedProcess = { + id: options.id, + pid: options.pid, + type: options.type, + name: options.name, + status: 'starting', + startedAt: now, + featureId: options.featureId, + sessionId: options.sessionId, + command: options.command, + cwd: options.cwd, + }; + + this.processes.set(options.id, process); + + logger.info('Process registered', { + id: process.id, + type: process.type, + name: process.name, + pid: process.pid, + }); + + // Emit process spawned event + this.events.emit('debug:process-spawned', { + type: 'debug:process-spawned', + timestamp: now, + process, + message: `Process ${process.name} (${process.type}) started`, + }); + + return process; + } + + /** + * Update an existing process + */ + updateProcess(id: string, updates: UpdateProcessOptions): TrackedProcess | null { + const process = this.processes.get(id); + if (!process) { + logger.warn('Attempted to update non-existent process', { id }); + return null; + } + + const now = Date.now(); + + // Apply updates + if (updates.status !== undefined) { + process.status = updates.status; + + // Set stoppedAt timestamp when process stops + if (updates.status === 'stopped' || updates.status === 'error') { + process.stoppedAt = now; + } + } + + if (updates.memoryUsage !== undefined) { + process.memoryUsage = updates.memoryUsage; + } + + if (updates.cpuUsage !== undefined) { + process.cpuUsage = updates.cpuUsage; + } + + if (updates.exitCode !== undefined) { + process.exitCode = updates.exitCode; + } + + if (updates.error !== undefined) { + process.error = updates.error; + } + + logger.debug('Process updated', { + id, + updates, + }); + + // Emit appropriate event based on status + if (updates.status === 'stopped') { + this.events.emit('debug:process-stopped', { + type: 'debug:process-stopped', + timestamp: now, + process, + message: `Process ${process.name} stopped${updates.exitCode !== undefined ? ` (exit code: ${updates.exitCode})` : ''}`, + }); + } else if (updates.status === 'error') { + this.events.emit('debug:process-error', { + type: 'debug:process-error', + timestamp: now, + process, + message: `Process ${process.name} encountered an error: ${updates.error || 'Unknown error'}`, + }); + } else { + this.events.emit('debug:process-updated', { + type: 'debug:process-updated', + timestamp: now, + process, + }); + } + + return process; + } + + /** + * Mark a process as running + */ + markRunning(id: string): TrackedProcess | null { + return this.updateProcess(id, { status: 'running' }); + } + + /** + * Mark a process as idle + */ + markIdle(id: string): TrackedProcess | null { + return this.updateProcess(id, { status: 'idle' }); + } + + /** + * Mark a process as stopping + */ + markStopping(id: string): TrackedProcess | null { + return this.updateProcess(id, { status: 'stopping' }); + } + + /** + * Mark a process as stopped + */ + markStopped(id: string, exitCode?: number): TrackedProcess | null { + return this.updateProcess(id, { status: 'stopped', exitCode }); + } + + /** + * Mark a process as errored + */ + markError(id: string, error: string): TrackedProcess | null { + return this.updateProcess(id, { status: 'error', error }); + } + + /** + * Unregister a process (remove immediately without retention) + */ + unregisterProcess(id: string): boolean { + const process = this.processes.get(id); + if (!process) { + return false; + } + + this.processes.delete(id); + + logger.info('Process unregistered', { + id, + type: process.type, + name: process.name, + }); + + return true; + } + + /** + * Get a process by ID + */ + getProcess(id: string): TrackedProcess | undefined { + return this.processes.get(id); + } + + /** + * Get all tracked processes, optionally filtered + * Optimized single-pass filtering to avoid multiple array allocations + */ + getProcesses(options?: QueryProcessOptions): TrackedProcess[] { + // Pre-allocate array with estimated capacity + const result: TrackedProcess[] = []; + + // Single-pass filtering + for (const process of this.processes.values()) { + // Filter by type + if (options?.type && process.type !== options.type) { + continue; + } + + // Filter by status + if (options?.status && process.status !== options.status) { + continue; + } + + // Filter out stopped processes by default + if (!options?.includeStopped) { + if (process.status === 'stopped' || process.status === 'error') { + continue; + } + } + + // Filter by session ID + if (options?.sessionId && process.sessionId !== options.sessionId) { + continue; + } + + // Filter by feature ID + if (options?.featureId && process.featureId !== options.featureId) { + continue; + } + + result.push(process); + } + + // Sort by start time (most recent first) + result.sort((a, b) => b.startedAt - a.startedAt); + + return result; + } + + /** + * Get all processes (for PerformanceMonitorService integration) + * This is used as the process provider function + */ + getAllProcesses(): TrackedProcess[] { + return Array.from(this.processes.values()); + } + + /** + * Get process provider function for PerformanceMonitorService + */ + getProcessProvider(): () => TrackedProcess[] { + return () => this.getAllProcesses(); + } + + /** + * Calculate summary statistics for tracked processes + */ + getProcessSummary(): ProcessSummary { + const processes = this.getAllProcesses(); + + const summary: ProcessSummary = { + total: processes.length, + running: 0, + idle: 0, + stopped: 0, + errored: 0, + byType: { + agent: 0, + cli: 0, + terminal: 0, + worker: 0, + }, + }; + + for (const process of processes) { + // Count by status + switch (process.status) { + case 'running': + case 'starting': + summary.running++; + break; + case 'idle': + summary.idle++; + break; + case 'stopped': + case 'stopping': + summary.stopped++; + break; + case 'error': + summary.errored++; + break; + } + + // Count by type + if (process.type in summary.byType) { + summary.byType[process.type]++; + } + } + + return summary; + } + + /** + * Get count of active (non-stopped) processes + */ + getActiveCount(): number { + let count = 0; + for (const process of this.processes.values()) { + if (process.status !== 'stopped' && process.status !== 'error') { + count++; + } + } + return count; + } + + /** + * Get count of processes by type + */ + getCountByType(type: ProcessType): number { + let count = 0; + for (const process of this.processes.values()) { + if (process.type === type) { + count++; + } + } + return count; + } + + /** + * Check if a process exists + */ + hasProcess(id: string): boolean { + return this.processes.has(id); + } + + /** + * Update configuration + */ + updateConfig(config: Partial): void { + this.config = { ...this.config, ...config }; + logger.info('ProcessRegistryService configuration updated', config); + } + + /** + * Get current configuration + */ + getConfig(): ProcessRegistryConfig { + return { ...this.config }; + } + + /** + * Clean up old stopped processes + */ + private cleanupStoppedProcesses(): void { + const now = Date.now(); + const stoppedProcesses: Array<{ id: string; stoppedAt: number }> = []; + + // Find all stopped processes + for (const [id, process] of this.processes.entries()) { + if ((process.status === 'stopped' || process.status === 'error') && process.stoppedAt) { + stoppedProcesses.push({ id, stoppedAt: process.stoppedAt }); + } + } + + // Sort by stoppedAt (oldest first) + stoppedProcesses.sort((a, b) => a.stoppedAt - b.stoppedAt); + + let removedCount = 0; + + // Remove processes that exceed retention time + for (const { id, stoppedAt } of stoppedProcesses) { + const age = now - stoppedAt; + if (age > this.config.stoppedProcessRetention) { + this.processes.delete(id); + removedCount++; + } + } + + // If still over max, remove oldest stopped processes + const remainingStoppedCount = stoppedProcesses.length - removedCount; + if (remainingStoppedCount > this.config.maxStoppedProcesses) { + const toRemove = remainingStoppedCount - this.config.maxStoppedProcesses; + let removed = 0; + + for (const { id } of stoppedProcesses) { + if (this.processes.has(id) && removed < toRemove) { + this.processes.delete(id); + removedCount++; + removed++; + } + } + } + + if (removedCount > 0) { + logger.debug('Cleaned up stopped processes', { removedCount }); + } + } + + /** + * Clear all tracked processes + */ + clear(): void { + this.processes.clear(); + logger.info('All tracked processes cleared'); + } + + // ============================================================================ + // Agent Resource Metrics Tracking + // ============================================================================ + + /** + * Initialize resource metrics for an agent process + * Call this when an agent starts to begin tracking its resource usage + */ + initializeAgentMetrics( + processId: string, + options?: { sessionId?: string; featureId?: string } + ): AgentResourceMetrics | null { + const process = this.processes.get(processId); + if (!process) { + logger.warn('Cannot initialize metrics for non-existent process', { processId }); + return null; + } + + if (process.type !== 'agent') { + logger.warn('Cannot initialize agent metrics for non-agent process', { + processId, + type: process.type, + }); + return null; + } + + const metrics = createEmptyAgentResourceMetrics(processId, { + sessionId: options?.sessionId || process.sessionId, + featureId: options?.featureId || process.featureId, + }); + + process.resourceMetrics = metrics; + + logger.debug('Agent metrics initialized', { processId }); + + return metrics; + } + + /** + * Get resource metrics for an agent process + */ + getAgentMetrics(processId: string): AgentResourceMetrics | undefined { + const process = this.processes.get(processId); + return process?.resourceMetrics; + } + + /** + * Record a tool invocation for an agent + */ + recordToolUse(processId: string, options: RecordToolUseOptions): void { + const process = this.processes.get(processId); + if (!process?.resourceMetrics) { + return; + } + + const metrics = process.resourceMetrics; + const now = Date.now(); + + // Update tool metrics + metrics.tools.totalInvocations++; + metrics.tools.byTool[options.toolName] = (metrics.tools.byTool[options.toolName] || 0) + 1; + + if (options.executionTime !== undefined) { + metrics.tools.totalExecutionTime += options.executionTime; + metrics.tools.avgExecutionTime = + metrics.tools.totalExecutionTime / metrics.tools.totalInvocations; + } + + if (options.failed) { + metrics.tools.failedInvocations++; + } + + // Update memory snapshot + this.updateMemorySnapshot(processId); + + metrics.lastUpdatedAt = now; + metrics.duration = now - metrics.startedAt; + + logger.debug('Tool use recorded', { + processId, + tool: options.toolName, + totalInvocations: metrics.tools.totalInvocations, + }); + } + + /** + * Record a file operation for an agent + */ + recordFileOperation(processId: string, options: RecordFileOperationOptions): void { + const process = this.processes.get(processId); + if (!process?.resourceMetrics) { + return; + } + + const metrics = process.resourceMetrics; + const now = Date.now(); + + // Update file I/O metrics based on operation type + switch (options.operation) { + case 'read': + metrics.fileIO.reads++; + if (options.bytes) { + metrics.fileIO.bytesRead += options.bytes; + } + break; + case 'write': + metrics.fileIO.writes++; + if (options.bytes) { + metrics.fileIO.bytesWritten += options.bytes; + } + break; + case 'edit': + metrics.fileIO.edits++; + if (options.bytes) { + metrics.fileIO.bytesWritten += options.bytes; + } + break; + case 'glob': + metrics.fileIO.globs++; + break; + case 'grep': + metrics.fileIO.greps++; + break; + } + + // Track unique files accessed + if (!metrics.fileIO.filesAccessed.includes(options.filePath)) { + // Limit to 100 files to prevent memory bloat + if (metrics.fileIO.filesAccessed.length < 100) { + metrics.fileIO.filesAccessed.push(options.filePath); + } + } + + metrics.lastUpdatedAt = now; + metrics.duration = now - metrics.startedAt; + + logger.debug('File operation recorded', { + processId, + operation: options.operation, + filePath: options.filePath, + }); + } + + /** + * Record a bash command execution for an agent + */ + recordBashCommand(processId: string, options: RecordBashCommandOptions): void { + const process = this.processes.get(processId); + if (!process?.resourceMetrics) { + return; + } + + const metrics = process.resourceMetrics; + const now = Date.now(); + + metrics.bash.commandCount++; + metrics.bash.totalExecutionTime += options.executionTime; + + if (options.exitCode !== null && options.exitCode !== 0) { + metrics.bash.failedCommands++; + } + + // Keep only last 20 commands to prevent memory bloat + if (metrics.bash.commands.length >= 20) { + metrics.bash.commands.shift(); + } + + metrics.bash.commands.push({ + command: options.command.substring(0, 200), // Truncate long commands + exitCode: options.exitCode, + duration: options.executionTime, + timestamp: now, + }); + + // Update memory snapshot + this.updateMemorySnapshot(processId); + + metrics.lastUpdatedAt = now; + metrics.duration = now - metrics.startedAt; + + logger.debug('Bash command recorded', { + processId, + command: options.command.substring(0, 50), + exitCode: options.exitCode, + }); + } + + /** + * Record an API turn/iteration for an agent + */ + recordAPITurn( + processId: string, + options?: { + inputTokens?: number; + outputTokens?: number; + thinkingTokens?: number; + duration?: number; + error?: boolean; + } + ): void { + const process = this.processes.get(processId); + if (!process?.resourceMetrics) { + return; + } + + const metrics = process.resourceMetrics; + const now = Date.now(); + + metrics.api.turns++; + + if (options?.inputTokens !== undefined) { + metrics.api.inputTokens = (metrics.api.inputTokens || 0) + options.inputTokens; + } + + if (options?.outputTokens !== undefined) { + metrics.api.outputTokens = (metrics.api.outputTokens || 0) + options.outputTokens; + } + + if (options?.thinkingTokens !== undefined) { + metrics.api.thinkingTokens = (metrics.api.thinkingTokens || 0) + options.thinkingTokens; + } + + if (options?.duration !== undefined) { + metrics.api.totalDuration += options.duration; + } + + if (options?.error) { + metrics.api.errors++; + } + + // Update memory snapshot + this.updateMemorySnapshot(processId); + + metrics.lastUpdatedAt = now; + metrics.duration = now - metrics.startedAt; + + logger.debug('API turn recorded', { + processId, + turns: metrics.api.turns, + }); + } + + /** + * Update memory snapshot for an agent process + * Takes a memory sample and updates peak/delta values + */ + updateMemorySnapshot(processId: string): void { + const process = this.processes.get(processId); + if (!process?.resourceMetrics) { + return; + } + + const metrics = process.resourceMetrics; + const now = Date.now(); + const heapUsed = process.memoryUsage || 0; + + // Update current heap + metrics.memory.currentHeapUsed = heapUsed; + + // Update peak if higher + if (heapUsed > metrics.memory.peakHeapUsed) { + metrics.memory.peakHeapUsed = heapUsed; + } + + // Calculate delta from start + metrics.memory.deltaHeapUsed = heapUsed - metrics.memory.startHeapUsed; + + // Add sample (keep max 60 samples = 1 minute at 1 sample/second) + if (metrics.memory.samples.length >= 60) { + metrics.memory.samples.shift(); + } + metrics.memory.samples.push({ timestamp: now, heapUsed }); + + metrics.lastUpdatedAt = now; + } + + /** + * Mark agent metrics as completed (agent finished running) + */ + finalizeAgentMetrics(processId: string): void { + const process = this.processes.get(processId); + if (!process?.resourceMetrics) { + return; + } + + const metrics = process.resourceMetrics; + const now = Date.now(); + + metrics.isRunning = false; + metrics.lastUpdatedAt = now; + metrics.duration = now - metrics.startedAt; + + // Final memory snapshot + this.updateMemorySnapshot(processId); + + logger.debug('Agent metrics finalized', { + processId, + duration: metrics.duration, + toolInvocations: metrics.tools.totalInvocations, + fileReads: metrics.fileIO.reads, + fileWrites: metrics.fileIO.writes, + bashCommands: metrics.bash.commandCount, + apiTurns: metrics.api.turns, + }); + } + + /** + * Get all agent processes with their resource metrics + */ + getAgentProcessesWithMetrics(): TrackedProcess[] { + const result: TrackedProcess[] = []; + + for (const process of this.processes.values()) { + if (process.type === 'agent' && process.resourceMetrics) { + result.push(process); + } + } + + return result.sort((a, b) => b.startedAt - a.startedAt); + } + + /** + * Get summary of resource usage across all running agents + */ + getAgentResourceSummary(): { + totalAgents: number; + runningAgents: number; + totalFileReads: number; + totalFileWrites: number; + totalBytesRead: number; + totalBytesWritten: number; + totalToolInvocations: number; + totalBashCommands: number; + totalAPITurns: number; + peakMemoryUsage: number; + totalDuration: number; + } { + const summary = { + totalAgents: 0, + runningAgents: 0, + totalFileReads: 0, + totalFileWrites: 0, + totalBytesRead: 0, + totalBytesWritten: 0, + totalToolInvocations: 0, + totalBashCommands: 0, + totalAPITurns: 0, + peakMemoryUsage: 0, + totalDuration: 0, + }; + + for (const process of this.processes.values()) { + if (process.type !== 'agent' || !process.resourceMetrics) { + continue; + } + + const metrics = process.resourceMetrics; + summary.totalAgents++; + + if (metrics.isRunning) { + summary.runningAgents++; + } + + summary.totalFileReads += metrics.fileIO.reads; + summary.totalFileWrites += metrics.fileIO.writes; + summary.totalBytesRead += metrics.fileIO.bytesRead; + summary.totalBytesWritten += metrics.fileIO.bytesWritten; + summary.totalToolInvocations += metrics.tools.totalInvocations; + summary.totalBashCommands += metrics.bash.commandCount; + summary.totalAPITurns += metrics.api.turns; + summary.totalDuration += metrics.duration; + + if (metrics.memory.peakHeapUsed > summary.peakMemoryUsage) { + summary.peakMemoryUsage = metrics.memory.peakHeapUsed; + } + } + + return summary; + } +} + +// Singleton instance +let processRegistryService: ProcessRegistryService | null = null; + +/** + * Get or create the ProcessRegistryService singleton + */ +export function getProcessRegistryService( + events?: EventEmitter, + config?: Partial +): ProcessRegistryService { + if (!processRegistryService) { + if (!events) { + throw new Error('EventEmitter is required to initialize ProcessRegistryService'); + } + processRegistryService = new ProcessRegistryService(events, config); + } + return processRegistryService; +} + +/** + * Reset the singleton (for testing) + */ +export function resetProcessRegistryService(): void { + if (processRegistryService) { + processRegistryService.stop(); + processRegistryService = null; + } +} diff --git a/apps/server/tests/unit/routes/debug/metrics.test.ts b/apps/server/tests/unit/routes/debug/metrics.test.ts new file mode 100644 index 00000000..a830b67a --- /dev/null +++ b/apps/server/tests/unit/routes/debug/metrics.test.ts @@ -0,0 +1,318 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Request, Response } from 'express'; +import { + createGetMetricsHandler, + createStartMetricsHandler, + createStopMetricsHandler, + createForceGCHandler, + createClearHistoryHandler, +} from '@/routes/debug/routes/metrics.js'; +import type { PerformanceMonitorService } from '@/services/performance-monitor-service.js'; +import type { DebugMetricsConfig, DebugMetricsSnapshot } from '@automaker/types'; +import { DEFAULT_DEBUG_METRICS_CONFIG } from '@automaker/types'; + +describe('Debug Metrics Routes', () => { + let mockPerformanceMonitor: Partial; + let mockReq: Partial; + let mockRes: Partial; + let jsonFn: ReturnType; + let statusFn: ReturnType; + + const mockConfig: DebugMetricsConfig = { ...DEFAULT_DEBUG_METRICS_CONFIG }; + const mockSnapshot: DebugMetricsSnapshot = { + timestamp: Date.now(), + memory: { + timestamp: Date.now(), + server: { + heapTotal: 100 * 1024 * 1024, + heapUsed: 50 * 1024 * 1024, + external: 5 * 1024 * 1024, + rss: 150 * 1024 * 1024, + arrayBuffers: 1 * 1024 * 1024, + }, + }, + cpu: { + timestamp: Date.now(), + server: { + percentage: 25.5, + user: 1000, + system: 500, + }, + eventLoopLag: 5, + }, + processes: [], + processSummary: { + total: 0, + running: 0, + idle: 0, + stopped: 0, + errored: 0, + byType: { agent: 0, cli: 0, terminal: 0, worker: 0 }, + }, + }; + + beforeEach(() => { + jsonFn = vi.fn(); + statusFn = vi.fn(() => ({ json: jsonFn })); + + mockPerformanceMonitor = { + getLatestSnapshot: vi.fn(() => mockSnapshot), + getConfig: vi.fn(() => mockConfig), + isActive: vi.fn(() => true), + start: vi.fn(), + stop: vi.fn(), + updateConfig: vi.fn(), + forceGC: vi.fn(() => true), + clearHistory: vi.fn(), + }; + + mockReq = { + body: {}, + query: {}, + params: {}, + }; + + mockRes = { + json: jsonFn, + status: statusFn, + }; + }); + + describe('GET /api/debug/metrics', () => { + it('should return current metrics snapshot', () => { + const handler = createGetMetricsHandler(mockPerformanceMonitor as PerformanceMonitorService); + handler(mockReq as Request, mockRes as Response); + + expect(jsonFn).toHaveBeenCalledWith({ + active: true, + config: mockConfig, + snapshot: mockSnapshot, + }); + }); + + it('should return undefined snapshot when no data available', () => { + (mockPerformanceMonitor.getLatestSnapshot as ReturnType).mockReturnValue(null); + + const handler = createGetMetricsHandler(mockPerformanceMonitor as PerformanceMonitorService); + handler(mockReq as Request, mockRes as Response); + + expect(jsonFn).toHaveBeenCalledWith({ + active: true, + config: mockConfig, + snapshot: undefined, + }); + }); + + it('should return active status correctly', () => { + (mockPerformanceMonitor.isActive as ReturnType).mockReturnValue(false); + + const handler = createGetMetricsHandler(mockPerformanceMonitor as PerformanceMonitorService); + handler(mockReq as Request, mockRes as Response); + + expect(jsonFn).toHaveBeenCalledWith( + expect.objectContaining({ + active: false, + }) + ); + }); + }); + + describe('POST /api/debug/metrics/start', () => { + it('should start metrics collection', () => { + const handler = createStartMetricsHandler( + mockPerformanceMonitor as PerformanceMonitorService + ); + handler(mockReq as Request, mockRes as Response); + + expect(mockPerformanceMonitor.start).toHaveBeenCalled(); + expect(jsonFn).toHaveBeenCalledWith({ + active: true, + config: mockConfig, + }); + }); + + it('should apply config overrides when provided', () => { + mockReq.body = { + config: { + collectionInterval: 5000, + maxDataPoints: 500, + }, + }; + + const handler = createStartMetricsHandler( + mockPerformanceMonitor as PerformanceMonitorService + ); + handler(mockReq as Request, mockRes as Response); + + expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({ + collectionInterval: 5000, + maxDataPoints: 500, + }); + }); + + it('should sanitize config values - clamp collectionInterval to min 100ms', () => { + mockReq.body = { + config: { + collectionInterval: 10, // Below minimum of 100ms + }, + }; + + const handler = createStartMetricsHandler( + mockPerformanceMonitor as PerformanceMonitorService + ); + handler(mockReq as Request, mockRes as Response); + + expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({ + collectionInterval: 100, + }); + }); + + it('should sanitize config values - clamp collectionInterval to max 60000ms', () => { + mockReq.body = { + config: { + collectionInterval: 100000, // Above maximum of 60000ms + }, + }; + + const handler = createStartMetricsHandler( + mockPerformanceMonitor as PerformanceMonitorService + ); + handler(mockReq as Request, mockRes as Response); + + expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({ + collectionInterval: 60000, + }); + }); + + it('should sanitize config values - clamp maxDataPoints to bounds', () => { + mockReq.body = { + config: { + maxDataPoints: 5, // Below minimum of 10 + }, + }; + + const handler = createStartMetricsHandler( + mockPerformanceMonitor as PerformanceMonitorService + ); + handler(mockReq as Request, mockRes as Response); + + expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({ + maxDataPoints: 10, + }); + }); + + it('should sanitize config values - clamp maxDataPoints to max', () => { + mockReq.body = { + config: { + maxDataPoints: 50000, // Above maximum of 10000 + }, + }; + + const handler = createStartMetricsHandler( + mockPerformanceMonitor as PerformanceMonitorService + ); + handler(mockReq as Request, mockRes as Response); + + expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({ + maxDataPoints: 10000, + }); + }); + + it('should ignore non-object config', () => { + mockReq.body = { + config: 'not-an-object', + }; + + const handler = createStartMetricsHandler( + mockPerformanceMonitor as PerformanceMonitorService + ); + handler(mockReq as Request, mockRes as Response); + + expect(mockPerformanceMonitor.updateConfig).not.toHaveBeenCalled(); + }); + + it('should ignore empty config object', () => { + mockReq.body = { + config: {}, + }; + + const handler = createStartMetricsHandler( + mockPerformanceMonitor as PerformanceMonitorService + ); + handler(mockReq as Request, mockRes as Response); + + expect(mockPerformanceMonitor.updateConfig).not.toHaveBeenCalled(); + }); + + it('should only accept boolean flags as actual booleans', () => { + mockReq.body = { + config: { + memoryEnabled: 'true', // String, not boolean - should be ignored + cpuEnabled: true, // Boolean - should be accepted + }, + }; + + const handler = createStartMetricsHandler( + mockPerformanceMonitor as PerformanceMonitorService + ); + handler(mockReq as Request, mockRes as Response); + + expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({ + cpuEnabled: true, + }); + }); + }); + + describe('POST /api/debug/metrics/stop', () => { + it('should stop metrics collection', () => { + const handler = createStopMetricsHandler(mockPerformanceMonitor as PerformanceMonitorService); + handler(mockReq as Request, mockRes as Response); + + expect(mockPerformanceMonitor.stop).toHaveBeenCalled(); + expect(jsonFn).toHaveBeenCalledWith({ + active: false, + config: mockConfig, + }); + }); + }); + + describe('POST /api/debug/metrics/gc', () => { + it('should trigger garbage collection when available', () => { + const handler = createForceGCHandler(mockPerformanceMonitor as PerformanceMonitorService); + handler(mockReq as Request, mockRes as Response); + + expect(mockPerformanceMonitor.forceGC).toHaveBeenCalled(); + expect(jsonFn).toHaveBeenCalledWith({ + success: true, + message: 'Garbage collection triggered', + }); + }); + + it('should report when garbage collection is not available', () => { + (mockPerformanceMonitor.forceGC as ReturnType).mockReturnValue(false); + + const handler = createForceGCHandler(mockPerformanceMonitor as PerformanceMonitorService); + handler(mockReq as Request, mockRes as Response); + + expect(jsonFn).toHaveBeenCalledWith({ + success: false, + message: 'Garbage collection not available (start Node.js with --expose-gc flag)', + }); + }); + }); + + describe('POST /api/debug/metrics/clear', () => { + it('should clear metrics history', () => { + const handler = createClearHistoryHandler( + mockPerformanceMonitor as PerformanceMonitorService + ); + handler(mockReq as Request, mockRes as Response); + + expect(mockPerformanceMonitor.clearHistory).toHaveBeenCalled(); + expect(jsonFn).toHaveBeenCalledWith({ + success: true, + message: 'Metrics history cleared', + }); + }); + }); +}); diff --git a/apps/server/tests/unit/routes/debug/processes.test.ts b/apps/server/tests/unit/routes/debug/processes.test.ts new file mode 100644 index 00000000..5cd23fc2 --- /dev/null +++ b/apps/server/tests/unit/routes/debug/processes.test.ts @@ -0,0 +1,293 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Request, Response } from 'express'; +import { + createGetProcessesHandler, + createGetProcessHandler, + createGetSummaryHandler, +} from '@/routes/debug/routes/processes.js'; +import type { ProcessRegistryService } from '@/services/process-registry-service.js'; +import type { TrackedProcess, ProcessSummary } from '@automaker/types'; + +describe('Debug Processes Routes', () => { + let mockProcessRegistry: Partial; + let mockReq: Partial; + let mockRes: Partial; + let jsonFn: ReturnType; + let statusFn: ReturnType; + + const mockProcesses: TrackedProcess[] = [ + { + id: 'process-1', + pid: 1234, + type: 'agent', + name: 'Agent 1', + status: 'running', + startedAt: Date.now() - 60000, + featureId: 'feature-1', + sessionId: 'session-1', + }, + { + id: 'process-2', + pid: 5678, + type: 'terminal', + name: 'Terminal 1', + status: 'idle', + startedAt: Date.now() - 30000, + sessionId: 'session-1', + }, + { + id: 'process-3', + pid: 9012, + type: 'cli', + name: 'CLI 1', + status: 'stopped', + startedAt: Date.now() - 120000, + stoppedAt: Date.now() - 60000, + exitCode: 0, + }, + ]; + + const mockSummary: ProcessSummary = { + total: 3, + running: 1, + idle: 1, + stopped: 1, + errored: 0, + byType: { + agent: 1, + cli: 1, + terminal: 1, + worker: 0, + }, + }; + + beforeEach(() => { + jsonFn = vi.fn(); + statusFn = vi.fn(() => ({ json: jsonFn })); + + mockProcessRegistry = { + getProcesses: vi.fn(() => mockProcesses), + getProcess: vi.fn((id: string) => mockProcesses.find((p) => p.id === id)), + getProcessSummary: vi.fn(() => mockSummary), + }; + + mockReq = { + body: {}, + query: {}, + params: {}, + }; + + mockRes = { + json: jsonFn, + status: statusFn, + }; + }); + + describe('GET /api/debug/processes', () => { + it('should return list of processes with summary', () => { + const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService); + handler(mockReq as Request, mockRes as Response); + + expect(mockProcessRegistry.getProcesses).toHaveBeenCalled(); + expect(mockProcessRegistry.getProcessSummary).toHaveBeenCalled(); + expect(jsonFn).toHaveBeenCalledWith({ + processes: mockProcesses, + summary: mockSummary, + }); + }); + + it('should pass type filter to service', () => { + mockReq.query = { type: 'agent' }; + + const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService); + handler(mockReq as Request, mockRes as Response); + + expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'agent', + }) + ); + }); + + it('should pass status filter to service', () => { + mockReq.query = { status: 'running' }; + + const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService); + handler(mockReq as Request, mockRes as Response); + + expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'running', + }) + ); + }); + + it('should pass includeStopped flag when set to "true"', () => { + mockReq.query = { includeStopped: 'true' }; + + const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService); + handler(mockReq as Request, mockRes as Response); + + expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith( + expect.objectContaining({ + includeStopped: true, + }) + ); + }); + + it('should not pass includeStopped when not "true"', () => { + mockReq.query = { includeStopped: 'false' }; + + const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService); + handler(mockReq as Request, mockRes as Response); + + expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith( + expect.objectContaining({ + includeStopped: undefined, + }) + ); + }); + + it('should pass sessionId filter to service', () => { + mockReq.query = { sessionId: 'session-1' }; + + const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService); + handler(mockReq as Request, mockRes as Response); + + expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'session-1', + }) + ); + }); + + it('should pass featureId filter to service', () => { + mockReq.query = { featureId: 'feature-1' }; + + const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService); + handler(mockReq as Request, mockRes as Response); + + expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith( + expect.objectContaining({ + featureId: 'feature-1', + }) + ); + }); + + it('should handle multiple filters', () => { + mockReq.query = { + type: 'agent', + status: 'running', + sessionId: 'session-1', + includeStopped: 'true', + }; + + const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService); + handler(mockReq as Request, mockRes as Response); + + expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith({ + type: 'agent', + status: 'running', + sessionId: 'session-1', + includeStopped: true, + featureId: undefined, + }); + }); + }); + + describe('GET /api/debug/processes/:id', () => { + it('should return a specific process by ID', () => { + mockReq.params = { id: 'process-1' }; + + const handler = createGetProcessHandler(mockProcessRegistry as ProcessRegistryService); + handler(mockReq as Request, mockRes as Response); + + expect(mockProcessRegistry.getProcess).toHaveBeenCalledWith('process-1'); + expect(jsonFn).toHaveBeenCalledWith(mockProcesses[0]); + }); + + it('should return 404 for non-existent process', () => { + mockReq.params = { id: 'non-existent' }; + (mockProcessRegistry.getProcess as ReturnType).mockReturnValue(undefined); + + const handler = createGetProcessHandler(mockProcessRegistry as ProcessRegistryService); + handler(mockReq as Request, mockRes as Response); + + expect(statusFn).toHaveBeenCalledWith(404); + expect(jsonFn).toHaveBeenCalledWith({ + error: 'Process not found', + id: 'non-existent', + }); + }); + + it('should return 400 for empty process ID', () => { + mockReq.params = { id: '' }; + + const handler = createGetProcessHandler(mockProcessRegistry as ProcessRegistryService); + handler(mockReq as Request, mockRes as Response); + + expect(statusFn).toHaveBeenCalledWith(400); + expect(jsonFn).toHaveBeenCalledWith({ + error: 'Invalid process ID format', + }); + }); + + it('should return 400 for process ID exceeding max length', () => { + mockReq.params = { id: 'a'.repeat(257) }; + + const handler = createGetProcessHandler(mockProcessRegistry as ProcessRegistryService); + handler(mockReq as Request, mockRes as Response); + + expect(statusFn).toHaveBeenCalledWith(400); + expect(jsonFn).toHaveBeenCalledWith({ + error: 'Invalid process ID format', + }); + }); + + it('should accept process ID at max length', () => { + mockReq.params = { id: 'a'.repeat(256) }; + (mockProcessRegistry.getProcess as ReturnType).mockReturnValue(undefined); + + const handler = createGetProcessHandler(mockProcessRegistry as ProcessRegistryService); + handler(mockReq as Request, mockRes as Response); + + // Should pass validation but process not found + expect(statusFn).toHaveBeenCalledWith(404); + }); + }); + + describe('GET /api/debug/processes/summary', () => { + it('should return process summary', () => { + const handler = createGetSummaryHandler(mockProcessRegistry as ProcessRegistryService); + handler(mockReq as Request, mockRes as Response); + + expect(mockProcessRegistry.getProcessSummary).toHaveBeenCalled(); + expect(jsonFn).toHaveBeenCalledWith(mockSummary); + }); + + it('should return correct counts', () => { + const customSummary: ProcessSummary = { + total: 10, + running: 5, + idle: 2, + stopped: 2, + errored: 1, + byType: { + agent: 4, + cli: 3, + terminal: 2, + worker: 1, + }, + }; + + (mockProcessRegistry.getProcessSummary as ReturnType).mockReturnValue( + customSummary + ); + + const handler = createGetSummaryHandler(mockProcessRegistry as ProcessRegistryService); + handler(mockReq as Request, mockRes as Response); + + expect(jsonFn).toHaveBeenCalledWith(customSummary); + }); + }); +}); diff --git a/apps/server/tests/unit/services/performance-monitor-service.test.ts b/apps/server/tests/unit/services/performance-monitor-service.test.ts new file mode 100644 index 00000000..53f0cf21 --- /dev/null +++ b/apps/server/tests/unit/services/performance-monitor-service.test.ts @@ -0,0 +1,418 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { PerformanceMonitorService } from '@/services/performance-monitor-service.js'; +import { createEventEmitter } from '@/lib/events.js'; +import type { EventEmitter } from '@/lib/events.js'; +import type { TrackedProcess, DebugMetricsConfig } from '@automaker/types'; +import { DEFAULT_DEBUG_METRICS_CONFIG } from '@automaker/types'; + +// Mock the logger to prevent console output during tests +vi.mock('@automaker/utils', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); + +describe('PerformanceMonitorService', () => { + let service: PerformanceMonitorService; + let events: EventEmitter; + + beforeEach(() => { + vi.useFakeTimers(); + events = createEventEmitter(); + service = new PerformanceMonitorService(events); + }); + + afterEach(() => { + service.stop(); + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + describe('initialization', () => { + it('should initialize with default configuration', () => { + const config = service.getConfig(); + expect(config.collectionInterval).toBe(DEFAULT_DEBUG_METRICS_CONFIG.collectionInterval); + expect(config.maxDataPoints).toBe(DEFAULT_DEBUG_METRICS_CONFIG.maxDataPoints); + expect(config.memoryEnabled).toBe(DEFAULT_DEBUG_METRICS_CONFIG.memoryEnabled); + expect(config.cpuEnabled).toBe(DEFAULT_DEBUG_METRICS_CONFIG.cpuEnabled); + }); + + it('should accept custom configuration on initialization', () => { + const customConfig: Partial = { + collectionInterval: 5000, + maxDataPoints: 500, + memoryEnabled: false, + }; + + const customService = new PerformanceMonitorService(events, customConfig); + const config = customService.getConfig(); + + expect(config.collectionInterval).toBe(5000); + expect(config.maxDataPoints).toBe(500); + expect(config.memoryEnabled).toBe(false); + expect(config.cpuEnabled).toBe(DEFAULT_DEBUG_METRICS_CONFIG.cpuEnabled); + + customService.stop(); + }); + + it('should not be running initially', () => { + expect(service.isActive()).toBe(false); + }); + }); + + describe('start/stop', () => { + it('should start metrics collection', () => { + service.start(); + expect(service.isActive()).toBe(true); + }); + + it('should stop metrics collection', () => { + service.start(); + expect(service.isActive()).toBe(true); + + service.stop(); + expect(service.isActive()).toBe(false); + }); + + it('should not start again if already running', () => { + service.start(); + const isActive1 = service.isActive(); + + service.start(); // Should log warning but not throw + const isActive2 = service.isActive(); + + expect(isActive1).toBe(true); + expect(isActive2).toBe(true); + }); + + it('should handle stop when not running', () => { + // Should not throw + expect(() => service.stop()).not.toThrow(); + }); + }); + + describe('configuration updates', () => { + it('should update configuration', () => { + service.updateConfig({ collectionInterval: 2000 }); + expect(service.getConfig().collectionInterval).toBe(2000); + }); + + it('should restart collection if running when config is updated', () => { + service.start(); + expect(service.isActive()).toBe(true); + + service.updateConfig({ collectionInterval: 5000 }); + + // Should still be running after config update + expect(service.isActive()).toBe(true); + expect(service.getConfig().collectionInterval).toBe(5000); + }); + + it('should resize data buffers when maxDataPoints changes', () => { + // Start and collect some data + service.start(); + + // Collect multiple data points + for (let i = 0; i < 50; i++) { + vi.advanceTimersByTime(service.getConfig().collectionInterval); + } + + // Reduce max data points + service.updateConfig({ maxDataPoints: 10 }); + + const history = service.getMemoryHistory(); + expect(history.length).toBeLessThanOrEqual(10); + }); + }); + + describe('metrics collection', () => { + it('should emit debug:metrics event on collection', () => { + const callback = vi.fn(); + events.subscribe(callback); + + service.start(); + vi.advanceTimersByTime(service.getConfig().collectionInterval); + + expect(callback).toHaveBeenCalled(); + const [eventType, eventData] = callback.mock.calls[0]; + expect(eventType).toBe('debug:metrics'); + expect(eventData).toHaveProperty('timestamp'); + expect(eventData).toHaveProperty('metrics'); + }); + + it('should collect memory metrics when memoryEnabled is true', () => { + const callback = vi.fn(); + events.subscribe(callback); + + service.start(); + vi.advanceTimersByTime(service.getConfig().collectionInterval); + + const [, eventData] = callback.mock.calls[0]; + expect(eventData.metrics.memory.server).toBeDefined(); + expect(eventData.metrics.memory.server.heapUsed).toBeGreaterThan(0); + expect(eventData.metrics.memory.server.heapTotal).toBeGreaterThan(0); + }); + + it('should not collect memory metrics when memoryEnabled is false', () => { + const customService = new PerformanceMonitorService(events, { memoryEnabled: false }); + const callback = vi.fn(); + events.subscribe(callback); + + customService.start(); + vi.advanceTimersByTime(customService.getConfig().collectionInterval); + + const [, eventData] = callback.mock.calls[0]; + expect(eventData.metrics.memory.server).toBeUndefined(); + + customService.stop(); + }); + + it('should collect CPU metrics when cpuEnabled is true', () => { + const callback = vi.fn(); + events.subscribe(callback); + + service.start(); + vi.advanceTimersByTime(service.getConfig().collectionInterval); + vi.advanceTimersByTime(service.getConfig().collectionInterval); + + // Need at least 2 collections for CPU diff + const lastCall = callback.mock.calls[callback.mock.calls.length - 1]; + const [, eventData] = lastCall; + expect(eventData.metrics.cpu.server).toBeDefined(); + }); + + it('should track event loop lag', () => { + const callback = vi.fn(); + events.subscribe(callback); + + service.start(); + vi.advanceTimersByTime(service.getConfig().collectionInterval); + + const [, eventData] = callback.mock.calls[0]; + expect(eventData.metrics.cpu.eventLoopLag).toBeDefined(); + }); + }); + + describe('memory history', () => { + it('should return empty history initially', () => { + const history = service.getMemoryHistory(); + expect(history).toEqual([]); + }); + + it('should accumulate memory history over time', () => { + service.start(); + + // Collect multiple data points + for (let i = 0; i < 5; i++) { + vi.advanceTimersByTime(service.getConfig().collectionInterval); + } + + const history = service.getMemoryHistory(); + expect(history.length).toBeGreaterThan(0); + }); + + it('should limit history to maxDataPoints', () => { + const maxPoints = 10; + const customService = new PerformanceMonitorService(events, { maxDataPoints: maxPoints }); + customService.start(); + + // Collect more data points than max + for (let i = 0; i < maxPoints + 10; i++) { + vi.advanceTimersByTime(customService.getConfig().collectionInterval); + } + + const history = customService.getMemoryHistory(); + expect(history.length).toBeLessThanOrEqual(maxPoints); + + customService.stop(); + }); + }); + + describe('CPU history', () => { + it('should return empty CPU history initially', () => { + const history = service.getCPUHistory(); + expect(history).toEqual([]); + }); + + it('should accumulate CPU history over time', () => { + service.start(); + + // Collect multiple data points (need at least 2 for CPU diff) + for (let i = 0; i < 5; i++) { + vi.advanceTimersByTime(service.getConfig().collectionInterval); + } + + const history = service.getCPUHistory(); + expect(history.length).toBeGreaterThan(0); + }); + }); + + describe('process provider', () => { + it('should use provided process provider', () => { + const mockProcesses: TrackedProcess[] = [ + { + id: 'test-1', + type: 'agent', + name: 'TestAgent', + status: 'running', + startedAt: Date.now(), + }, + { + id: 'test-2', + type: 'terminal', + name: 'TestTerminal', + status: 'idle', + startedAt: Date.now(), + }, + ]; + + const provider = vi.fn(() => mockProcesses); + service.setProcessProvider(provider); + + const callback = vi.fn(); + events.subscribe(callback); + + service.start(); + vi.advanceTimersByTime(service.getConfig().collectionInterval); + + const [, eventData] = callback.mock.calls[0]; + expect(eventData.metrics.processes).toEqual(mockProcesses); + expect(eventData.metrics.processSummary.total).toBe(2); + expect(eventData.metrics.processSummary.running).toBe(1); + expect(eventData.metrics.processSummary.idle).toBe(1); + expect(eventData.metrics.processSummary.byType.agent).toBe(1); + expect(eventData.metrics.processSummary.byType.terminal).toBe(1); + }); + }); + + describe('getLatestSnapshot', () => { + it('should return null when no data collected', () => { + const snapshot = service.getLatestSnapshot(); + expect(snapshot).toBeNull(); + }); + + it('should return snapshot after data collection', () => { + service.start(); + vi.advanceTimersByTime(service.getConfig().collectionInterval); + + const snapshot = service.getLatestSnapshot(); + expect(snapshot).not.toBeNull(); + expect(snapshot).toHaveProperty('timestamp'); + expect(snapshot).toHaveProperty('memory'); + expect(snapshot).toHaveProperty('cpu'); + expect(snapshot).toHaveProperty('processes'); + expect(snapshot).toHaveProperty('processSummary'); + }); + }); + + describe('clearHistory', () => { + it('should clear all history', () => { + service.start(); + + // Collect some data + for (let i = 0; i < 5; i++) { + vi.advanceTimersByTime(service.getConfig().collectionInterval); + } + + expect(service.getMemoryHistory().length).toBeGreaterThan(0); + + service.clearHistory(); + + expect(service.getMemoryHistory().length).toBe(0); + expect(service.getCPUHistory().length).toBe(0); + }); + }); + + describe('forceGC', () => { + it('should return false when gc is not available', () => { + const originalGc = global.gc; + global.gc = undefined; + + const result = service.forceGC(); + expect(result).toBe(false); + + // Restore + global.gc = originalGc; + }); + + it('should return true and call gc when available', () => { + const mockGc = vi.fn(); + global.gc = mockGc; + + const result = service.forceGC(); + expect(result).toBe(true); + expect(mockGc).toHaveBeenCalled(); + + // Cleanup + global.gc = undefined; + }); + }); + + describe('memory trend analysis', () => { + it('should not calculate trend with insufficient data', () => { + service.start(); + + // Collect only a few data points + for (let i = 0; i < 5; i++) { + vi.advanceTimersByTime(service.getConfig().collectionInterval); + } + + const snapshot = service.getLatestSnapshot(); + // Trend requires at least 10 samples + expect(snapshot?.memoryTrend).toBeUndefined(); + }); + + it('should calculate trend with sufficient data', () => { + service.start(); + + // Collect enough data points for trend analysis + for (let i = 0; i < 15; i++) { + vi.advanceTimersByTime(service.getConfig().collectionInterval); + } + + const snapshot = service.getLatestSnapshot(); + expect(snapshot?.memoryTrend).toBeDefined(); + expect(snapshot?.memoryTrend).toHaveProperty('growthRate'); + expect(snapshot?.memoryTrend).toHaveProperty('isLeaking'); + expect(snapshot?.memoryTrend).toHaveProperty('confidence'); + expect(snapshot?.memoryTrend).toHaveProperty('sampleCount'); + }); + }); + + describe('process summary calculation', () => { + it('should correctly categorize processes by status', () => { + const mockProcesses: TrackedProcess[] = [ + { id: '1', type: 'agent', name: 'A1', status: 'running', startedAt: Date.now() }, + { id: '2', type: 'agent', name: 'A2', status: 'starting', startedAt: Date.now() }, + { id: '3', type: 'terminal', name: 'T1', status: 'idle', startedAt: Date.now() }, + { id: '4', type: 'terminal', name: 'T2', status: 'stopped', startedAt: Date.now() }, + { id: '5', type: 'cli', name: 'C1', status: 'stopping', startedAt: Date.now() }, + { id: '6', type: 'worker', name: 'W1', status: 'error', startedAt: Date.now() }, + ]; + + service.setProcessProvider(() => mockProcesses); + + const callback = vi.fn(); + events.subscribe(callback); + + service.start(); + vi.advanceTimersByTime(service.getConfig().collectionInterval); + + const [, eventData] = callback.mock.calls[0]; + const summary = eventData.metrics.processSummary; + + expect(summary.total).toBe(6); + expect(summary.running).toBe(2); // running + starting + expect(summary.idle).toBe(1); + expect(summary.stopped).toBe(2); // stopped + stopping + expect(summary.errored).toBe(1); + expect(summary.byType.agent).toBe(2); + expect(summary.byType.terminal).toBe(2); + expect(summary.byType.cli).toBe(1); + expect(summary.byType.worker).toBe(1); + }); + }); +}); diff --git a/apps/server/tests/unit/services/process-registry-service.test.ts b/apps/server/tests/unit/services/process-registry-service.test.ts new file mode 100644 index 00000000..61d25eb6 --- /dev/null +++ b/apps/server/tests/unit/services/process-registry-service.test.ts @@ -0,0 +1,538 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + ProcessRegistryService, + getProcessRegistryService, + resetProcessRegistryService, +} from '@/services/process-registry-service.js'; +import { createEventEmitter } from '@/lib/events.js'; +import type { EventEmitter } from '@/lib/events.js'; +import type { TrackedProcess, ProcessType, ProcessStatus } from '@automaker/types'; + +// Mock the logger to prevent console output during tests +vi.mock('@automaker/utils', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); + +describe('ProcessRegistryService', () => { + let service: ProcessRegistryService; + let events: EventEmitter; + + beforeEach(() => { + vi.useFakeTimers(); + events = createEventEmitter(); + service = new ProcessRegistryService(events); + resetProcessRegistryService(); + }); + + afterEach(() => { + service.stop(); + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + describe('initialization', () => { + it('should initialize with default configuration', () => { + const config = service.getConfig(); + expect(config.stoppedProcessRetention).toBe(5 * 60 * 1000); + expect(config.cleanupInterval).toBe(60 * 1000); + expect(config.maxStoppedProcesses).toBe(100); + }); + + it('should accept custom configuration', () => { + const customService = new ProcessRegistryService(events, { + stoppedProcessRetention: 10000, + maxStoppedProcesses: 50, + }); + + const config = customService.getConfig(); + expect(config.stoppedProcessRetention).toBe(10000); + expect(config.maxStoppedProcesses).toBe(50); + expect(config.cleanupInterval).toBe(60 * 1000); + + customService.stop(); + }); + }); + + describe('start/stop', () => { + it('should start the service', () => { + expect(() => service.start()).not.toThrow(); + }); + + it('should stop the service', () => { + service.start(); + expect(() => service.stop()).not.toThrow(); + }); + + it('should not start again if already running', () => { + service.start(); + // Should log warning but not throw + expect(() => service.start()).not.toThrow(); + }); + }); + + describe('process registration', () => { + it('should register a new process', () => { + const process = service.registerProcess({ + id: 'test-1', + pid: 1234, + type: 'agent', + name: 'TestAgent', + }); + + expect(process.id).toBe('test-1'); + expect(process.pid).toBe(1234); + expect(process.type).toBe('agent'); + expect(process.name).toBe('TestAgent'); + expect(process.status).toBe('starting'); + expect(process.startedAt).toBeDefined(); + }); + + it('should register a process with all optional fields', () => { + const process = service.registerProcess({ + id: 'test-2', + pid: 5678, + type: 'terminal', + name: 'TestTerminal', + featureId: 'feature-123', + sessionId: 'session-456', + command: 'bash', + cwd: '/home/user', + }); + + expect(process.featureId).toBe('feature-123'); + expect(process.sessionId).toBe('session-456'); + expect(process.command).toBe('bash'); + expect(process.cwd).toBe('/home/user'); + }); + + it('should emit debug:process-spawned event on registration', () => { + const callback = vi.fn(); + events.subscribe(callback); + + service.registerProcess({ + id: 'test-3', + pid: 111, + type: 'cli', + name: 'TestCLI', + }); + + expect(callback).toHaveBeenCalled(); + const [eventType, eventData] = callback.mock.calls[0]; + expect(eventType).toBe('debug:process-spawned'); + expect(eventData.process.id).toBe('test-3'); + }); + }); + + describe('process retrieval', () => { + beforeEach(() => { + // Register test processes + service.registerProcess({ + id: 'p1', + pid: 1, + type: 'agent', + name: 'Agent1', + featureId: 'f1', + sessionId: 's1', + }); + service.registerProcess({ + id: 'p2', + pid: 2, + type: 'terminal', + name: 'Terminal1', + sessionId: 's1', + }); + service.registerProcess({ id: 'p3', pid: 3, type: 'cli', name: 'CLI1', featureId: 'f2' }); + }); + + it('should get a process by ID', () => { + const process = service.getProcess('p1'); + expect(process).toBeDefined(); + expect(process?.name).toBe('Agent1'); + }); + + it('should return undefined for non-existent process', () => { + const process = service.getProcess('non-existent'); + expect(process).toBeUndefined(); + }); + + it('should check if process exists', () => { + expect(service.hasProcess('p1')).toBe(true); + expect(service.hasProcess('non-existent')).toBe(false); + }); + + it('should get all processes without filters', () => { + const processes = service.getProcesses({ includeStopped: true }); + expect(processes.length).toBe(3); + }); + + it('should filter by type', () => { + const agents = service.getProcesses({ type: 'agent', includeStopped: true }); + expect(agents.length).toBe(1); + expect(agents[0].type).toBe('agent'); + }); + + it('should filter by session ID', () => { + const sessionProcesses = service.getProcesses({ sessionId: 's1', includeStopped: true }); + expect(sessionProcesses.length).toBe(2); + }); + + it('should filter by feature ID', () => { + const featureProcesses = service.getProcesses({ featureId: 'f1', includeStopped: true }); + expect(featureProcesses.length).toBe(1); + expect(featureProcesses[0].id).toBe('p1'); + }); + + it('should exclude stopped processes by default', () => { + service.markStopped('p1'); + const processes = service.getProcesses(); + expect(processes.length).toBe(2); + expect(processes.find((p) => p.id === 'p1')).toBeUndefined(); + }); + + it('should include stopped processes when requested', () => { + service.markStopped('p1'); + const processes = service.getProcesses({ includeStopped: true }); + expect(processes.length).toBe(3); + }); + + it('should sort processes by start time (most recent first)', () => { + // Re-register processes with different timestamps + service.clear(); + + // Register p1 at time 0 + service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'Agent1' }); + + // Advance time and register p2 + vi.advanceTimersByTime(1000); + service.registerProcess({ id: 'p2', pid: 2, type: 'terminal', name: 'Terminal1' }); + + // Advance time and register p3 + vi.advanceTimersByTime(1000); + service.registerProcess({ id: 'p3', pid: 3, type: 'cli', name: 'CLI1' }); + + const processes = service.getProcesses({ includeStopped: true }); + // p3 was registered last (most recent), so it should be first + expect(processes[0].id).toBe('p3'); + expect(processes[1].id).toBe('p2'); + expect(processes[2].id).toBe('p1'); + }); + }); + + describe('process status updates', () => { + let process: TrackedProcess; + + beforeEach(() => { + process = service.registerProcess({ + id: 'test-proc', + pid: 100, + type: 'agent', + name: 'TestProcess', + }); + }); + + it('should update process status', () => { + const updated = service.updateProcess('test-proc', { status: 'running' }); + expect(updated?.status).toBe('running'); + }); + + it('should update memory usage', () => { + const updated = service.updateProcess('test-proc', { memoryUsage: 1024 * 1024 }); + expect(updated?.memoryUsage).toBe(1024 * 1024); + }); + + it('should update CPU usage', () => { + const updated = service.updateProcess('test-proc', { cpuUsage: 45.5 }); + expect(updated?.cpuUsage).toBe(45.5); + }); + + it('should return null for non-existent process', () => { + const updated = service.updateProcess('non-existent', { status: 'running' }); + expect(updated).toBeNull(); + }); + + it('should set stoppedAt when status is stopped', () => { + const updated = service.markStopped('test-proc'); + expect(updated?.stoppedAt).toBeDefined(); + }); + + it('should set stoppedAt when status is error', () => { + const updated = service.markError('test-proc', 'Something went wrong'); + expect(updated?.stoppedAt).toBeDefined(); + expect(updated?.error).toBe('Something went wrong'); + }); + }); + + describe('status shortcut methods', () => { + beforeEach(() => { + service.registerProcess({ + id: 'test-proc', + pid: 100, + type: 'agent', + name: 'TestProcess', + }); + }); + + it('should mark process as running', () => { + const updated = service.markRunning('test-proc'); + expect(updated?.status).toBe('running'); + }); + + it('should mark process as idle', () => { + const updated = service.markIdle('test-proc'); + expect(updated?.status).toBe('idle'); + }); + + it('should mark process as stopping', () => { + const updated = service.markStopping('test-proc'); + expect(updated?.status).toBe('stopping'); + }); + + it('should mark process as stopped with exit code', () => { + const updated = service.markStopped('test-proc', 0); + expect(updated?.status).toBe('stopped'); + expect(updated?.exitCode).toBe(0); + }); + + it('should mark process as error with message', () => { + const updated = service.markError('test-proc', 'Process crashed'); + expect(updated?.status).toBe('error'); + expect(updated?.error).toBe('Process crashed'); + }); + }); + + describe('event emissions', () => { + let callback: ReturnType; + + beforeEach(() => { + callback = vi.fn(); + events.subscribe(callback); + service.registerProcess({ + id: 'test-proc', + pid: 100, + type: 'agent', + name: 'TestProcess', + }); + callback.mockClear(); + }); + + it('should emit debug:process-stopped when stopped', () => { + service.markStopped('test-proc', 0); + + expect(callback).toHaveBeenCalled(); + const [eventType] = callback.mock.calls[0]; + expect(eventType).toBe('debug:process-stopped'); + }); + + it('should emit debug:process-error when errored', () => { + service.markError('test-proc', 'Error message'); + + expect(callback).toHaveBeenCalled(); + const [eventType, eventData] = callback.mock.calls[0]; + expect(eventType).toBe('debug:process-error'); + expect(eventData.message).toContain('Error message'); + }); + + it('should emit debug:process-updated for other status changes', () => { + service.markRunning('test-proc'); + + expect(callback).toHaveBeenCalled(); + const [eventType] = callback.mock.calls[0]; + expect(eventType).toBe('debug:process-updated'); + }); + }); + + describe('process unregistration', () => { + it('should unregister an existing process', () => { + service.registerProcess({ + id: 'test-proc', + pid: 100, + type: 'agent', + name: 'TestProcess', + }); + + const result = service.unregisterProcess('test-proc'); + expect(result).toBe(true); + expect(service.getProcess('test-proc')).toBeUndefined(); + }); + + it('should return false for non-existent process', () => { + const result = service.unregisterProcess('non-existent'); + expect(result).toBe(false); + }); + }); + + describe('process summary', () => { + beforeEach(() => { + service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' }); + service.registerProcess({ id: 'p2', pid: 2, type: 'agent', name: 'A2' }); + service.registerProcess({ id: 'p3', pid: 3, type: 'terminal', name: 'T1' }); + service.registerProcess({ id: 'p4', pid: 4, type: 'cli', name: 'C1' }); + service.registerProcess({ id: 'p5', pid: 5, type: 'worker', name: 'W1' }); + + // Update statuses + service.markRunning('p1'); + service.markIdle('p2'); + service.markStopped('p3'); + service.markError('p4', 'error'); + service.markRunning('p5'); + }); + + it('should calculate correct summary statistics', () => { + const summary = service.getProcessSummary(); + + expect(summary.total).toBe(5); + expect(summary.running).toBe(2); // p1 running, p5 running + expect(summary.idle).toBe(1); // p2 idle + expect(summary.stopped).toBe(1); // p3 stopped + expect(summary.errored).toBe(1); // p4 error + }); + + it('should count processes by type', () => { + const summary = service.getProcessSummary(); + + expect(summary.byType.agent).toBe(2); + expect(summary.byType.terminal).toBe(1); + expect(summary.byType.cli).toBe(1); + expect(summary.byType.worker).toBe(1); + }); + }); + + describe('active count', () => { + beforeEach(() => { + service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' }); + service.registerProcess({ id: 'p2', pid: 2, type: 'agent', name: 'A2' }); + service.registerProcess({ id: 'p3', pid: 3, type: 'terminal', name: 'T1' }); + + service.markRunning('p1'); + service.markStopped('p2'); + service.markIdle('p3'); + }); + + it('should return count of active processes', () => { + expect(service.getActiveCount()).toBe(2); // p1 running, p3 idle + }); + + it('should return count by type', () => { + expect(service.getCountByType('agent')).toBe(2); + expect(service.getCountByType('terminal')).toBe(1); + expect(service.getCountByType('cli')).toBe(0); + }); + }); + + describe('process provider', () => { + it('should return a process provider function', () => { + service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' }); + + const provider = service.getProcessProvider(); + expect(typeof provider).toBe('function'); + + const processes = provider(); + expect(processes.length).toBe(1); + expect(processes[0].id).toBe('p1'); + }); + + it('should return all processes including stopped', () => { + service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' }); + service.registerProcess({ id: 'p2', pid: 2, type: 'agent', name: 'A2' }); + service.markStopped('p2'); + + const provider = service.getProcessProvider(); + const processes = provider(); + + expect(processes.length).toBe(2); + }); + }); + + describe('cleanup', () => { + it('should clean up old stopped processes', () => { + // Register and stop a process + service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' }); + service.markStopped('p1'); + + // Start service to enable cleanup + service.start(); + + // Advance time past retention period + vi.advanceTimersByTime(6 * 60 * 1000); // 6 minutes (past default 5 min retention) + + // Process should be cleaned up + expect(service.getProcess('p1')).toBeUndefined(); + }); + + it('should enforce max stopped processes limit', () => { + const customService = new ProcessRegistryService(events, { + maxStoppedProcesses: 3, + cleanupInterval: 1000, + }); + + // Register and stop more processes than max + for (let i = 0; i < 5; i++) { + customService.registerProcess({ id: `p${i}`, pid: i, type: 'agent', name: `A${i}` }); + customService.markStopped(`p${i}`); + } + + customService.start(); + + // Trigger cleanup + vi.advanceTimersByTime(1000); + + // Should only have max stopped processes + const allProcesses = customService.getAllProcesses(); + expect(allProcesses.length).toBeLessThanOrEqual(3); + + customService.stop(); + }); + }); + + describe('configuration update', () => { + it('should update configuration', () => { + service.updateConfig({ maxStoppedProcesses: 200 }); + expect(service.getConfig().maxStoppedProcesses).toBe(200); + }); + }); + + describe('clear', () => { + it('should clear all tracked processes', () => { + service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' }); + service.registerProcess({ id: 'p2', pid: 2, type: 'terminal', name: 'T1' }); + + service.clear(); + + expect(service.getAllProcesses().length).toBe(0); + }); + }); + + describe('singleton pattern', () => { + beforeEach(() => { + resetProcessRegistryService(); + }); + + afterEach(() => { + resetProcessRegistryService(); + }); + + it('should create singleton instance', () => { + const instance1 = getProcessRegistryService(events); + const instance2 = getProcessRegistryService(); + + expect(instance1).toBe(instance2); + }); + + it('should throw if no events provided on first call', () => { + expect(() => getProcessRegistryService()).toThrow(); + }); + + it('should reset singleton', () => { + const instance1 = getProcessRegistryService(events); + resetProcessRegistryService(); + const instance2 = getProcessRegistryService(events); + + expect(instance1).not.toBe(instance2); + }); + }); +}); diff --git a/apps/ui/src/components/debug/cpu-monitor.tsx b/apps/ui/src/components/debug/cpu-monitor.tsx new file mode 100644 index 00000000..ae1c3eb1 --- /dev/null +++ b/apps/ui/src/components/debug/cpu-monitor.tsx @@ -0,0 +1,175 @@ +/** + * CPU Monitor Component + * + * Displays CPU usage percentage with historical chart and event loop lag indicator. + */ + +import { useMemo } from 'react'; +import { Cpu, Activity, AlertTriangle } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { CPUDataPoint, ServerCPUMetrics } from '@automaker/types'; + +interface CPUMonitorProps { + history: CPUDataPoint[]; + current: ServerCPUMetrics | null; + eventLoopLag?: number; + className?: string; +} + +/** + * Simple sparkline chart for CPU data + */ +function CPUSparkline({ data, className }: { data: CPUDataPoint[]; className?: string }) { + const pathD = useMemo(() => { + if (data.length < 2) { + return ''; + } + + const w = 200; + const h = 40; + const padding = 2; + + // CPU percentage is 0-100, but we'll use 0-100 as our range + const points = data.map((d, i) => { + const x = (i / (data.length - 1)) * (w - padding * 2) + padding; + const y = h - padding - (d.percentage / 100) * (h - padding * 2); + return `${x},${y}`; + }); + + return `M ${points.join(' L ')}`; + }, [data]); + + if (data.length < 2) { + return ( +
+ Collecting data... +
+ ); + } + + return ( + + + + ); +} + +/** + * CPU usage gauge + */ +function CPUGauge({ percentage }: { percentage: number }) { + const isHigh = percentage > 60; + const isCritical = percentage > 80; + + return ( +
+ {/* Background circle */} + + + + + {/* Center text */} +
+ + {percentage.toFixed(0)}% + +
+
+ ); +} + +/** + * Event loop lag indicator + */ +function EventLoopLag({ lag }: { lag?: number }) { + if (lag === undefined) { + return null; + } + + const isBlocked = lag > 50; + const isSevere = lag > 100; + + return ( +
+ {isSevere ? : } + Event Loop: {lag.toFixed(0)}ms +
+ ); +} + +export function CPUMonitor({ history, current, eventLoopLag, className }: CPUMonitorProps) { + const percentage = current?.percentage ?? 0; + + return ( +
+ {/* Header */} +
+
+ + CPU +
+ +
+ + {/* Main content */} +
+ {/* Gauge */} + + + {/* Sparkline */} +
+ +
+
+ + {/* Details */} + {current && ( +
+
+ User: + {(current.user / 1000).toFixed(1)}ms +
+
+ System: + {(current.system / 1000).toFixed(1)}ms +
+
+ )} +
+ ); +} diff --git a/apps/ui/src/components/debug/debug-docked-panel.tsx b/apps/ui/src/components/debug/debug-docked-panel.tsx new file mode 100644 index 00000000..59b508ab --- /dev/null +++ b/apps/ui/src/components/debug/debug-docked-panel.tsx @@ -0,0 +1,229 @@ +/** + * Debug Docked Panel Component + * + * Expandable panel that appears above the status bar when expanded. + * Contains the full debug interface with tabs. + */ + +import { useRef, useCallback, useEffect } from 'react'; +import { HardDrive, Cpu, Bot, RefreshCw, Trash2, Play, Pause, GripHorizontal } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { + useDebugStore, + MIN_DOCKED_HEIGHT, + MAX_DOCKED_HEIGHT_RATIO, + type DebugTab, +} from '@/store/debug-store'; +import { useDebugMetrics } from '@/hooks/use-debug-metrics'; +import { useRenderTracking } from '@/hooks/use-render-tracking'; +import { MemoryMonitor } from './memory-monitor'; +import { CPUMonitor } from './cpu-monitor'; +import { ProcessKanban } from './process-kanban'; +import { RenderTracker } from './render-tracker'; +import { LeakIndicator } from './leak-indicator'; +import { useRenderTrackingContext } from './render-profiler'; + +const TAB_CONFIG: { id: DebugTab; label: string; icon: React.ReactNode }[] = [ + { id: 'memory', label: 'Memory', icon: }, + { id: 'cpu', label: 'CPU', icon: }, + { id: 'processes', label: 'Processes', icon: }, + { id: 'renders', label: 'Renders', icon: }, +]; + +interface DebugDockedPanelProps { + className?: string; +} + +export function DebugDockedPanel({ className }: DebugDockedPanelProps) { + const { + isOpen, + isDockedExpanded, + panelMode, + dockedHeight, + activeTab, + setActiveTab, + setDockedHeight, + isResizing, + setIsResizing, + } = useDebugStore(); + + const metrics = useDebugMetrics(); + const renderTrackingFromContext = useRenderTrackingContext(); + const localRenderTracking = useRenderTracking(); + const renderTracking = renderTrackingFromContext ?? localRenderTracking; + + // Ref for resize handling + const panelRef = useRef(null); + const resizeStartRef = useRef<{ y: number; height: number } | null>(null); + + // Handle resize start (drag from top edge) + const handleResizeStart = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + setIsResizing(true); + resizeStartRef.current = { + y: e.clientY, + height: dockedHeight, + }; + }, + [setIsResizing, dockedHeight] + ); + + // Handle resize move + useEffect(() => { + if (!isResizing) return; + + const handleMouseMove = (e: MouseEvent) => { + if (!resizeStartRef.current) return; + + // Dragging up increases height, dragging down decreases + const deltaY = resizeStartRef.current.y - e.clientY; + const newHeight = resizeStartRef.current.height + deltaY; + + // Clamp to min/max bounds + const maxHeight = window.innerHeight * MAX_DOCKED_HEIGHT_RATIO; + const clampedHeight = Math.max(MIN_DOCKED_HEIGHT, Math.min(maxHeight, newHeight)); + + setDockedHeight(clampedHeight); + }; + + const handleMouseUp = () => { + setIsResizing(false); + resizeStartRef.current = null; + }; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, [isResizing, setIsResizing, setDockedHeight]); + + // Only show in docked mode when expanded + if (panelMode !== 'docked' || !isDockedExpanded || !isOpen) { + return null; + } + + return ( +
+ {/* Resize handle - top edge */} +
+ +
+ + {/* Tabs */} +
+
+ {TAB_CONFIG.map((tab) => ( + + ))} +
+ + {/* Right side controls */} +
+ + + +
+
+ + {/* Content */} +
+ {activeTab === 'memory' && ( +
+ + +
+ )} + + {activeTab === 'cpu' && ( + + )} + + {activeTab === 'processes' && ( + + )} + + {activeTab === 'renders' && ( + + )} +
+
+ ); +} + +/** + * Debug Docked Panel Wrapper - Only renders in development mode + */ +export function DebugDockedPanelWrapper({ className }: DebugDockedPanelProps) { + const isDev = import.meta.env.DEV || import.meta.env.VITE_ENABLE_DEBUG_PANEL === 'true'; + + if (!isDev) { + return null; + } + + return ; +} diff --git a/apps/ui/src/components/debug/debug-panel.tsx b/apps/ui/src/components/debug/debug-panel.tsx new file mode 100644 index 00000000..e107c8d7 --- /dev/null +++ b/apps/ui/src/components/debug/debug-panel.tsx @@ -0,0 +1,427 @@ +/** + * Debug Panel Component + * + * Main container for the floating debug overlay with: + * - Draggable positioning + * - Resizable panels + * - Tab-based navigation + * - Minimize/maximize states + */ + +import { useRef, useCallback, useEffect } from 'react'; +import { + Bug, + X, + Minimize2, + Maximize2, + HardDrive, + Cpu, + Bot, + RefreshCw, + Trash2, + Play, + Pause, + GripHorizontal, + PanelBottom, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { + useDebugStore, + MIN_PANEL_SIZE, + MAX_PANEL_SIZE_RATIO, + type DebugTab, +} from '@/store/debug-store'; +import { useDebugMetrics } from '@/hooks/use-debug-metrics'; +import { useRenderTracking } from '@/hooks/use-render-tracking'; +import { MemoryMonitor } from './memory-monitor'; +import { CPUMonitor } from './cpu-monitor'; +import { ProcessKanban } from './process-kanban'; +import { RenderTracker } from './render-tracker'; +import { LeakIndicator } from './leak-indicator'; +import { useRenderTrackingContext } from './render-profiler'; + +const TAB_CONFIG: { id: DebugTab; label: string; icon: React.ReactNode }[] = [ + { id: 'memory', label: 'Memory', icon: }, + { id: 'cpu', label: 'CPU', icon: }, + { id: 'processes', label: 'Processes', icon: }, + { id: 'renders', label: 'Renders', icon: }, +]; + +interface DebugPanelProps { + className?: string; +} + +export function DebugPanel({ className }: DebugPanelProps) { + const { + isOpen, + isMinimized, + position, + size, + activeTab, + setOpen, + toggleMinimized, + setPosition, + setSize, + togglePanelMode, + setActiveTab, + isDragging, + setIsDragging, + isResizing, + setIsResizing, + } = useDebugStore(); + + const metrics = useDebugMetrics(); + const renderTrackingFromContext = useRenderTrackingContext(); + const localRenderTracking = useRenderTracking(); + // Use context if available (when wrapped in RenderTrackingProvider), otherwise use local + const renderTracking = renderTrackingFromContext ?? localRenderTracking; + + // Refs for drag handling + const panelRef = useRef(null); + const dragStartRef = useRef<{ x: number; y: number; posX: number; posY: number } | null>(null); + const resizeStartRef = useRef<{ x: number; y: number; width: number; height: number } | null>( + null + ); + + // Calculate actual position (handle negative values for right-edge positioning) + const actualPosition = useCallback(() => { + if (!panelRef.current) return { x: position.x, y: position.y }; + + const rect = panelRef.current.getBoundingClientRect(); + const windowWidth = window.innerWidth; + + // If x is negative, position from right edge + const x = position.x < 0 ? windowWidth + position.x - rect.width : position.x; + + return { x, y: position.y }; + }, [position]); + + // Handle drag start + const handleDragStart = useCallback( + (e: React.MouseEvent) => { + if ((e.target as HTMLElement).closest('button')) return; + + e.preventDefault(); + setIsDragging(true); + + const rect = panelRef.current?.getBoundingClientRect(); + if (!rect) return; + + dragStartRef.current = { + x: e.clientX, + y: e.clientY, + posX: rect.left, + posY: rect.top, + }; + }, + [setIsDragging] + ); + + // Handle resize start + const handleResizeStart = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsResizing(true); + + resizeStartRef.current = { + x: e.clientX, + y: e.clientY, + width: size.width, + height: size.height, + }; + }, + [setIsResizing, size] + ); + + // Handle drag move + useEffect(() => { + if (!isDragging) return; + + const handleMouseMove = (e: MouseEvent) => { + if (!dragStartRef.current || !panelRef.current) return; + + const deltaX = e.clientX - dragStartRef.current.x; + const deltaY = e.clientY - dragStartRef.current.y; + + const newX = dragStartRef.current.posX + deltaX; + const newY = dragStartRef.current.posY + deltaY; + + // Clamp to window bounds + const rect = panelRef.current.getBoundingClientRect(); + const clampedX = Math.max(0, Math.min(window.innerWidth - rect.width, newX)); + const clampedY = Math.max(0, Math.min(window.innerHeight - rect.height, newY)); + + setPosition({ x: clampedX, y: clampedY }); + }; + + const handleMouseUp = () => { + setIsDragging(false); + dragStartRef.current = null; + }; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, [isDragging, setIsDragging, setPosition]); + + // Handle resize move + useEffect(() => { + if (!isResizing) return; + + const handleMouseMove = (e: MouseEvent) => { + if (!resizeStartRef.current) return; + + const deltaX = e.clientX - resizeStartRef.current.x; + const deltaY = e.clientY - resizeStartRef.current.y; + + const newWidth = resizeStartRef.current.width + deltaX; + const newHeight = resizeStartRef.current.height + deltaY; + + // Clamp to min/max bounds + const maxWidth = window.innerWidth * MAX_PANEL_SIZE_RATIO.width; + const maxHeight = window.innerHeight * MAX_PANEL_SIZE_RATIO.height; + + const clampedWidth = Math.max(MIN_PANEL_SIZE.width, Math.min(maxWidth, newWidth)); + const clampedHeight = Math.max(MIN_PANEL_SIZE.height, Math.min(maxHeight, newHeight)); + + setSize({ width: clampedWidth, height: clampedHeight }); + }; + + const handleMouseUp = () => { + setIsResizing(false); + resizeStartRef.current = null; + }; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, [isResizing, setIsResizing, setSize]); + + // Don't render if not open + if (!isOpen) { + return null; + } + + const pos = actualPosition(); + + return ( +
+ {/* Header - Draggable */} +
+
+ + Debug + {metrics.isActive && ( + + )} + {/* Dock to bottom */} + +
+
+ {/* Toggle collection */} + + {/* Minimize */} + + {/* Close */} + +
+
+ + {/* Minimized state - just show quick stats */} + {isMinimized ? ( +
+
+ Heap: + + {metrics.latestSnapshot?.memory.server + ? `${(metrics.latestSnapshot.memory.server.heapUsed / 1024 / 1024).toFixed(0)}MB` + : '-'} + +
+
+ CPU: + + {metrics.latestSnapshot?.cpu.server + ? `${metrics.latestSnapshot.cpu.server.percentage.toFixed(0)}%` + : '-'} + +
+
+ Processes: + {metrics.processSummary?.running ?? 0} +
+
+ ) : ( + <> + {/* Tabs */} +
+ {TAB_CONFIG.map((tab) => ( + + ))} +
+ + {/* Content */} +
+ {activeTab === 'memory' && ( +
+ + +
+ )} + + {activeTab === 'cpu' && ( + + )} + + {activeTab === 'processes' && ( + + )} + + {activeTab === 'renders' && ( + + )} +
+ + {/* Footer with actions */} +
+ + {metrics.isLoading + ? 'Loading...' + : metrics.error + ? `Error: ${metrics.error}` + : `Updated ${new Date().toLocaleTimeString()}`} + +
+ + +
+
+ + {/* Resize handle - bottom right corner */} +
+ +
+ + )} +
+ ); +} + +/** + * Debug Panel Wrapper - Only renders in development mode and floating mode + */ +export function DebugPanelWrapper() { + const panelMode = useDebugStore((s) => s.panelMode); + + // Only show in development mode + const isDev = import.meta.env.DEV || import.meta.env.VITE_ENABLE_DEBUG_PANEL === 'true'; + + // Only show in floating mode + if (!isDev || panelMode !== 'floating') { + return null; + } + + return ; +} diff --git a/apps/ui/src/components/debug/debug-status-bar.tsx b/apps/ui/src/components/debug/debug-status-bar.tsx new file mode 100644 index 00000000..6dff6a6b --- /dev/null +++ b/apps/ui/src/components/debug/debug-status-bar.tsx @@ -0,0 +1,171 @@ +/** + * Debug Status Bar Component + * + * VS Code-style status bar at the bottom of the screen showing quick debug stats. + * Clicking expands to show the full debug panel. + */ + +import { memo } from 'react'; +import { Bug, HardDrive, Cpu, Bot, ChevronUp, X, Maximize2, Minimize2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useDebugStore } from '@/store/debug-store'; +import { useDebugMetrics } from '@/hooks/use-debug-metrics'; +import { formatBytes } from '@automaker/types'; + +interface DebugStatusBarProps { + className?: string; +} + +/** + * Quick stat display component + */ +const QuickStat = memo(function QuickStat({ + icon, + label, + value, + onClick, + className, +}: { + icon: React.ReactNode; + label: string; + value: string; + onClick?: () => void; + className?: string; +}) { + return ( + + ); +}); + +export function DebugStatusBar({ className }: DebugStatusBarProps) { + const { + isOpen, + isDockedExpanded, + panelMode, + setOpen, + toggleDockedExpanded, + setActiveTab, + togglePanelMode, + } = useDebugStore(); + + const metrics = useDebugMetrics(); + + // Only show in docked mode when debug is enabled + if (panelMode !== 'docked') { + return null; + } + + // Don't render if debug panel is not open (toggled off with Ctrl+Shift+D) + if (!isOpen) { + return null; + } + + const heapUsed = metrics.latestSnapshot?.memory.server?.heapUsed ?? 0; + const cpuPercent = metrics.latestSnapshot?.cpu.server?.percentage ?? 0; + const processCount = metrics.processSummary?.running ?? 0; + + return ( +
+ {/* Left side - Debug label and quick stats */} +
+ {/* Debug label with status indicator */} + + +
+ + {/* Quick stats */} + } + label="Heap" + value={formatBytes(heapUsed)} + onClick={() => { + setActiveTab('memory'); + if (!isDockedExpanded) toggleDockedExpanded(); + }} + /> + } + label="CPU" + value={`${cpuPercent.toFixed(0)}%`} + onClick={() => { + setActiveTab('cpu'); + if (!isDockedExpanded) toggleDockedExpanded(); + }} + /> + } + label="Processes" + value={String(processCount)} + onClick={() => { + setActiveTab('processes'); + if (!isDockedExpanded) toggleDockedExpanded(); + }} + /> +
+ + {/* Right side - Actions */} +
+ {/* Toggle to floating mode */} + + {/* Close debug panel */} + +
+
+ ); +} + +/** + * Debug Status Bar Wrapper - Only renders in development mode + */ +export function DebugStatusBarWrapper({ className }: DebugStatusBarProps) { + const isDev = import.meta.env.DEV || import.meta.env.VITE_ENABLE_DEBUG_PANEL === 'true'; + + if (!isDev) { + return null; + } + + return ; +} diff --git a/apps/ui/src/components/debug/index.ts b/apps/ui/src/components/debug/index.ts new file mode 100644 index 00000000..dd3b99bc --- /dev/null +++ b/apps/ui/src/components/debug/index.ts @@ -0,0 +1,26 @@ +/** + * Debug Components + * + * Exports all debug-related UI components for the debug panel. + * Supports both floating overlay and docked (VS Code-style) modes. + */ + +// Floating mode panel +export { DebugPanel, DebugPanelWrapper } from './debug-panel'; + +// Docked mode components (VS Code-style) +export { DebugStatusBar, DebugStatusBarWrapper } from './debug-status-bar'; +export { DebugDockedPanel, DebugDockedPanelWrapper } from './debug-docked-panel'; + +// Shared components +export { MemoryMonitor } from './memory-monitor'; +export { CPUMonitor } from './cpu-monitor'; +export { ProcessKanban } from './process-kanban'; +export { RenderTracker } from './render-tracker'; +export { LeakIndicator } from './leak-indicator'; +export { + RenderProfiler, + RenderTrackingProvider, + useRenderTrackingContext, + withRenderProfiler, +} from './render-profiler'; diff --git a/apps/ui/src/components/debug/leak-indicator.tsx b/apps/ui/src/components/debug/leak-indicator.tsx new file mode 100644 index 00000000..6c87e7f2 --- /dev/null +++ b/apps/ui/src/components/debug/leak-indicator.tsx @@ -0,0 +1,102 @@ +/** + * Leak Indicator Component + * + * Alerts when memory growth patterns exceed threshold. + */ + +import { AlertTriangle, TrendingUp, Info } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { formatBytes } from '@automaker/types'; +import type { MemoryTrend } from '@automaker/types'; + +interface LeakIndicatorProps { + trend: MemoryTrend | null; + onForceGC?: () => void; + className?: string; +} + +export function LeakIndicator({ trend, onForceGC, className }: LeakIndicatorProps) { + if (!trend) { + return ( +
+
+ + Collecting memory data for leak analysis... +
+
+ ); + } + + const isLeaking = trend.isLeaking; + const isGrowing = trend.growthRate > 1024 * 100; // > 100KB/s + const growthPerSecond = formatBytes(Math.abs(trend.growthRate)); + const confidencePercent = (trend.confidence * 100).toFixed(0); + + if (isLeaking) { + return ( +
+
+ +
+
Memory Leak Detected
+
+
+ + Growing at {growthPerSecond}/s +
+
Confidence: {confidencePercent}%
+
Samples: {trend.sampleCount}
+
+
+ Memory is consistently growing without garbage collection. This may indicate detached + DOM nodes, event listener leaks, or objects held in closures. +
+ {onForceGC && ( + + )} +
+
+
+ ); + } + + if (isGrowing) { + return ( +
+
+ +
+
Memory Growing
+
+
Rate: {growthPerSecond}/s
+
Confidence: {confidencePercent}%
+
+
+ Memory is growing but not yet at leak threshold. Monitor for sustained growth. +
+
+
+
+ ); + } + + // Healthy state + return ( +
+
+ +
+
Memory Stable
+
+ No memory leak patterns detected ({trend.sampleCount} samples) +
+
+
+
+ ); +} diff --git a/apps/ui/src/components/debug/memory-monitor.tsx b/apps/ui/src/components/debug/memory-monitor.tsx new file mode 100644 index 00000000..b9aee713 --- /dev/null +++ b/apps/ui/src/components/debug/memory-monitor.tsx @@ -0,0 +1,276 @@ +/** + * Memory Monitor Component + * + * Displays real-time heap usage with a line chart showing historical data. + */ + +import { useMemo, memo } from 'react'; +import { HardDrive, TrendingUp, TrendingDown, Minus, HelpCircle } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { formatBytes } from '@automaker/types'; +import type { MemoryDataPoint, MemoryTrend, ServerMemoryMetrics } from '@automaker/types'; +import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from '@/components/ui/tooltip'; + +/** Tooltip explanations for memory metrics */ +const METRIC_TOOLTIPS = { + heap: 'JavaScript heap memory - memory used by V8 engine for JavaScript objects and data', + rss: 'Resident Set Size - total memory allocated for the process including code, stack, and heap', + external: 'Memory used by C++ objects bound to JavaScript objects (e.g., Buffers)', + arrayBuffers: 'Memory allocated for ArrayBuffer and SharedArrayBuffer objects', +} as const; + +interface MemoryMonitorProps { + history: MemoryDataPoint[]; + current: ServerMemoryMetrics | null; + trend: MemoryTrend | null; + className?: string; +} + +/** + * Simple sparkline chart for memory data - Memoized to prevent unnecessary re-renders + */ +const MemorySparkline = memo(function MemorySparkline({ + data, + className, +}: { + data: MemoryDataPoint[]; + className?: string; +}) { + const { pathD, width, height } = useMemo(() => { + if (data.length < 2) { + return { pathD: '', width: 200, height: 40 }; + } + + const w = 200; + const h = 40; + const padding = 2; + + const values = data.map((d) => d.heapUsed); + const max = Math.max(...values) * 1.1; // Add 10% headroom + const min = Math.min(...values) * 0.9; + const range = max - min || 1; + + const points = data.map((d, i) => { + const x = (i / (data.length - 1)) * (w - padding * 2) + padding; + const y = h - padding - ((d.heapUsed - min) / range) * (h - padding * 2); + return `${x},${y}`; + }); + + return { + pathD: `M ${points.join(' L ')}`, + width: w, + height: h, + }; + }, [data]); + + if (data.length < 2) { + return ( +
+ Collecting data... +
+ ); + } + + return ( + + + + ); +}); + +/** + * Label with optional tooltip - Memoized + */ +const MetricLabel = memo(function MetricLabel({ + label, + tooltip, +}: { + label: string; + tooltip?: string; +}) { + if (!tooltip) { + return {label}; + } + + return ( + + + + + {label} + + + + + {tooltip} + + + + ); +}); + +/** + * Memory usage bar - Memoized to prevent unnecessary re-renders + */ +const MemoryBar = memo(function MemoryBar({ + used, + total, + label, + tooltip, +}: { + used: number; + total: number; + label: string; + tooltip?: string; +}) { + const percentage = total > 0 ? (used / total) * 100 : 0; + const isHigh = percentage > 70; + const isCritical = percentage > 90; + + return ( +
+
+ + + {formatBytes(used)} / {formatBytes(total)} + +
+
+
+
+
+ ); +}); + +/** + * Trend indicator - Memoized to prevent unnecessary re-renders + */ +const TrendIndicator = memo(function TrendIndicator({ trend }: { trend: MemoryTrend | null }) { + if (!trend) { + return null; + } + + const isGrowing = trend.growthRate > 1024 * 100; // > 100KB/s + const isShrinking = trend.growthRate < -1024 * 100; // < -100KB/s + const isStable = !isGrowing && !isShrinking; + + return ( +
+ {trend.isLeaking ? ( + <> + + Leak detected + + ) : isGrowing ? ( + <> + + {formatBytes(Math.abs(trend.growthRate))}/s + + ) : isShrinking ? ( + <> + + {formatBytes(Math.abs(trend.growthRate))}/s + + ) : ( + <> + + Stable + + )} +
+ ); +}); + +export function MemoryMonitor({ history, current, trend, className }: MemoryMonitorProps) { + return ( +
+ {/* Header */} +
+
+ + Memory +
+ +
+ + {/* Current values */} + {current ? ( +
+ {/* Heap with integrated sparkline */} +
+
+ + 90 && 'text-red-400', + (current.heapUsed / current.heapTotal) * 100 > 70 && + (current.heapUsed / current.heapTotal) * 100 <= 90 && + 'text-yellow-400' + )} + > + {formatBytes(current.heapUsed)} / {formatBytes(current.heapTotal)} + +
+ {/* Sparkline chart for heap history */} +
+ +
+
+ + {/* RSS bar */} + + + {/* Additional metrics with tooltips */} +
+
+ + {formatBytes(current.external)} +
+
+ + {formatBytes(current.arrayBuffers)} +
+
+
+ ) : ( +
No data available
+ )} +
+ ); +} diff --git a/apps/ui/src/components/debug/process-kanban.tsx b/apps/ui/src/components/debug/process-kanban.tsx new file mode 100644 index 00000000..7195dc2c --- /dev/null +++ b/apps/ui/src/components/debug/process-kanban.tsx @@ -0,0 +1,364 @@ +/** + * Process Kanban Component + * + * Visual board showing active agents/CLIs with status indicators. + * Columns: Active | Idle | Stopped | Error + */ + +import { useMemo, memo, useState } from 'react'; +import { + Bot, + Terminal, + Cpu, + Circle, + Clock, + AlertCircle, + CheckCircle2, + Pause, + Play, + FileText, + Hammer, + ChevronDown, + ChevronRight, + HardDrive, + Activity, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { formatBytes, formatDuration } from '@automaker/types'; +import type { + TrackedProcess, + ProcessType, + ProcessStatus, + ProcessSummary, + AgentResourceMetrics, +} from '@automaker/types'; + +interface ProcessKanbanProps { + processes: TrackedProcess[]; + summary: ProcessSummary | null; + className?: string; + /** Panel width for responsive layout - uses 2x2 grid when narrow */ + panelWidth?: number; +} + +/** + * Get icon for process type + */ +function getProcessIcon(type: ProcessType) { + switch (type) { + case 'agent': + return ; + case 'terminal': + return ; + case 'cli': + return ; + case 'worker': + return ; + default: + return ; + } +} + +/** + * Get status indicator + */ +function getStatusIndicator(status: ProcessStatus) { + switch (status) { + case 'running': + return ; + case 'starting': + return ; + case 'idle': + return ; + case 'stopping': + return ; + case 'stopped': + return ; + case 'error': + return ; + default: + return ; + } +} + +/** + * Resource metrics display component for agent processes + */ +const ResourceMetrics = memo(function ResourceMetrics({ + metrics, +}: { + metrics: AgentResourceMetrics; +}) { + return ( +
+ {/* File I/O */} +
+ + Files: + + {metrics.fileIO.reads}R / {metrics.fileIO.writes}W / {metrics.fileIO.edits}E + +
+ + {/* Bytes transferred */} + {(metrics.fileIO.bytesRead > 0 || metrics.fileIO.bytesWritten > 0) && ( +
+ + I/O: + + {formatBytes(metrics.fileIO.bytesRead)} read /{' '} + {formatBytes(metrics.fileIO.bytesWritten)} written + +
+ )} + + {/* Tool usage */} +
+ + Tools: + {metrics.tools.totalInvocations} calls +
+ + {/* API turns */} + {metrics.api.turns > 0 && ( +
+ + API: + {metrics.api.turns} turns +
+ )} + + {/* Bash commands */} + {metrics.bash.commandCount > 0 && ( +
+ + Bash: + + {metrics.bash.commandCount} cmds + {metrics.bash.failedCommands > 0 && ( + ({metrics.bash.failedCommands} failed) + )} + +
+ )} + + {/* Memory delta */} + {metrics.memory.deltaHeapUsed !== 0 && ( +
+ + Mem delta: + 0 ? 'text-orange-400' : 'text-green-400' + )} + > + {metrics.memory.deltaHeapUsed > 0 ? '+' : ''} + {formatBytes(metrics.memory.deltaHeapUsed)} + +
+ )} +
+ ); +}); + +/** + * Process card component - Memoized to prevent unnecessary re-renders + */ +const ProcessCard = memo(function ProcessCard({ process }: { process: TrackedProcess }) { + const [expanded, setExpanded] = useState(false); + + const runtime = useMemo(() => { + const end = process.stoppedAt || Date.now(); + return end - process.startedAt; + }, [process.startedAt, process.stoppedAt]); + + const isActive = process.status === 'running' || process.status === 'starting'; + const isError = process.status === 'error'; + const hasMetrics = process.type === 'agent' && process.resourceMetrics; + + return ( +
+ {/* Header */} +
hasMetrics && setExpanded(!expanded)} + > + {hasMetrics && + (expanded ? ( + + ) : ( + + ))} + {getProcessIcon(process.type)} + {process.name} + {getStatusIndicator(process.status)} +
+ + {/* Basic Details */} +
+
+ + {formatDuration(runtime)} + {hasMetrics && ( + + {process.resourceMetrics!.tools.totalInvocations} tools + + )} +
+ + {process.memoryUsage !== undefined && ( +
+ Memory: + {formatBytes(process.memoryUsage)} +
+ )} + + {process.cpuUsage !== undefined && ( +
+ CPU: + {process.cpuUsage.toFixed(1)}% +
+ )} + + {process.error && ( +
+ {process.error} +
+ )} +
+ + {/* Expanded resource metrics */} + {hasMetrics && expanded && } +
+ ); +}); + +/** + * Column component - Memoized to prevent unnecessary re-renders + */ +const ProcessColumn = memo(function ProcessColumn({ + title, + processes, + count, + colorClass, +}: { + title: string; + processes: TrackedProcess[]; + count: number; + colorClass: string; +}) { + return ( +
+ {/* Column header */} +
+ {title} + {count} +
+ + {/* Cards */} +
+ {processes.length > 0 ? ( + processes.map((process) => ) + ) : ( +
No processes
+ )} +
+
+ ); +}); + +/** Threshold width for switching to 2x2 grid layout */ +const NARROW_THRESHOLD = 450; + +export function ProcessKanban({ processes, summary, className, panelWidth }: ProcessKanbanProps) { + // Determine if we should use narrow (2x2) layout + const isNarrow = panelWidth !== undefined && panelWidth < NARROW_THRESHOLD; + + // Group processes by status + const grouped = useMemo(() => { + const active: TrackedProcess[] = []; + const idle: TrackedProcess[] = []; + const stopped: TrackedProcess[] = []; + const errored: TrackedProcess[] = []; + + for (const process of processes) { + switch (process.status) { + case 'running': + case 'starting': + active.push(process); + break; + case 'idle': + idle.push(process); + break; + case 'stopped': + case 'stopping': + stopped.push(process); + break; + case 'error': + errored.push(process); + break; + } + } + + return { active, idle, stopped, errored }; + }, [processes]); + + return ( +
+ {/* Header with summary */} +
+
+ + Processes +
+ {summary && ( +
+ Total: {summary.total} + {summary.byType.agent > 0 && ( + {summary.byType.agent} agents + )} + {summary.byType.terminal > 0 && ( + {summary.byType.terminal} terminals + )} +
+ )} +
+ + {/* Kanban board - 2x2 grid when narrow, 4-column when wide */} +
+ + + + +
+
+ ); +} diff --git a/apps/ui/src/components/debug/render-profiler.tsx b/apps/ui/src/components/debug/render-profiler.tsx new file mode 100644 index 00000000..343e84a3 --- /dev/null +++ b/apps/ui/src/components/debug/render-profiler.tsx @@ -0,0 +1,110 @@ +/** + * RenderProfiler Component + * + * A wrapper component that uses React.Profiler to track render performance + * of wrapped components. Data is collected and displayed in the Debug Panel's + * Render Tracker tab. + * + * Usage: + * ```tsx + * + * + * + * ``` + */ + +import { + Profiler, + createContext, + useContext, + type ReactNode, + type ProfilerOnRenderCallback, +} from 'react'; +import { useRenderTracking, type RenderTrackingContextType } from '@/hooks/use-render-tracking'; + +/** + * Context for sharing render tracking across the app + */ +const RenderTrackingContext = createContext(null); + +/** + * Hook to access render tracking context + */ +export function useRenderTrackingContext(): RenderTrackingContextType | null { + return useContext(RenderTrackingContext); +} + +/** + * Provider component that enables render tracking throughout the app + */ +export function RenderTrackingProvider({ children }: { children: ReactNode }) { + const renderTracking = useRenderTracking(); + + return ( + + {children} + + ); +} + +/** + * Props for RenderProfiler component + */ +interface RenderProfilerProps { + /** Name of the component being profiled (displayed in Render Tracker) */ + name: string; + /** Children to render and profile */ + children: ReactNode; +} + +/** + * RenderProfiler wraps a component with React.Profiler to track render performance. + * + * When the Debug Panel is open and render tracking is enabled, this component + * records render data including: + * - Render count + * - Render duration (actual and base) + * - Render phase (mount/update/nested-update) + * - Render frequency (renders per second) + * + * The data appears in the Debug Panel's "Renders" tab. + */ +export function RenderProfiler({ name, children }: RenderProfilerProps) { + const renderTracking = useContext(RenderTrackingContext); + + // If no context available, just render children without profiling + if (!renderTracking) { + return <>{children}; + } + + const onRender: ProfilerOnRenderCallback = renderTracking.createProfilerCallback(name); + + return ( + + {children} + + ); +} + +/** + * Higher-order component version of RenderProfiler + * + * Usage: + * ```tsx + * const ProfiledComponent = withRenderProfiler(MyComponent, 'MyComponent'); + * ``` + */ +export function withRenderProfiler

( + WrappedComponent: React.ComponentType

, + name: string +): React.FC

{ + const ProfiledComponent: React.FC

= (props) => ( + + + + ); + + ProfiledComponent.displayName = `RenderProfiler(${name})`; + + return ProfiledComponent; +} diff --git a/apps/ui/src/components/debug/render-tracker.tsx b/apps/ui/src/components/debug/render-tracker.tsx new file mode 100644 index 00000000..fa61826d --- /dev/null +++ b/apps/ui/src/components/debug/render-tracker.tsx @@ -0,0 +1,145 @@ +/** + * Render Tracker Component + * + * Displays component render statistics and highlights frequently re-rendering components. + */ + +import { RefreshCw, AlertTriangle, TrendingUp, Clock } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { formatDuration } from '@automaker/types'; +import type { ComponentRenderStats, RenderTrackingSummary } from '@automaker/types'; + +interface RenderTrackerProps { + summary: RenderTrackingSummary; + stats: ComponentRenderStats[]; + onClear?: () => void; + className?: string; +} + +/** + * Component stats row + */ +function ComponentStatsRow({ stats }: { stats: ComponentRenderStats }) { + return ( +

+ {/* Component name */} +
+
+ {stats.isHighRender && } + + {stats.componentName} + +
+
+ + {/* Stats */} +
+
+ + {stats.renderCount} +
+
+ + + {stats.rendersPerSecond.toFixed(1)}/s + +
+
+ + {formatDuration(stats.avgDuration)} +
+
+
+ ); +} + +/** + * Summary stats + */ +function SummaryStats({ summary }: { summary: RenderTrackingSummary }) { + return ( +
+
+
{summary.totalRenders}
+
Total Renders
+
+
+
{summary.uniqueComponents}
+
Components
+
+
0 ? 'bg-red-500/20' : 'bg-muted/30' + )} + > +
0 && 'text-red-400' + )} + > + {summary.highRenderComponents.length} +
+
High Render
+
+
+ ); +} + +export function RenderTracker({ summary, stats, onClear, className }: RenderTrackerProps) { + // Sort by render count (highest first) + const sortedStats = [...stats].sort((a, b) => b.renderCount - a.renderCount); + const topStats = sortedStats.slice(0, 10); + + return ( +
+ {/* Header */} +
+
+ + Render Tracker +
+ {onClear && ( + + )} +
+ + {/* Summary */} + + + {/* High render warnings */} + {summary.highRenderComponents.length > 0 && ( +
+
+ + High render rate detected +
+
{summary.highRenderComponents.join(', ')}
+
+ )} + + {/* Component list */} +
+ {topStats.length > 0 ? ( + topStats.map((s) => ) + ) : ( +
+

No render data yet.

+

Wrap components with RenderProfiler to track renders.

+
+ )} +
+
+ ); +} diff --git a/apps/ui/src/hooks/use-debug-metrics.ts b/apps/ui/src/hooks/use-debug-metrics.ts new file mode 100644 index 00000000..96055a5f --- /dev/null +++ b/apps/ui/src/hooks/use-debug-metrics.ts @@ -0,0 +1,317 @@ +/** + * Hook for consuming debug metrics from the server + * + * Provides real-time metrics data including: + * - Memory usage (server-side) + * - CPU usage (server-side) + * - Tracked processes + * - Memory leak detection + * + * Uses polling for metrics data with configurable interval. + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { apiGet, apiPost } from '@/lib/api-fetch'; +import { useDebugStore } from '@/store/debug-store'; +import type { + DebugMetricsSnapshot, + DebugMetricsResponse, + MemoryDataPoint, + CPUDataPoint, + TrackedProcess, + ProcessSummary, + MemoryTrend, + BrowserMemoryMetrics, +} from '@automaker/types'; + +/** + * Maximum data points to store in history buffers + */ +const MAX_HISTORY_POINTS = 60; + +/** + * Browser memory metrics (from Chrome's performance.memory API) + */ +interface BrowserMetrics { + memory?: BrowserMemoryMetrics; + available: boolean; +} + +/** + * Get browser memory metrics (Chrome only) + */ +function getBrowserMemoryMetrics(): BrowserMetrics { + // performance.memory is Chrome-specific + const perf = performance as Performance & { + memory?: { + jsHeapSizeLimit: number; + totalJSHeapSize: number; + usedJSHeapSize: number; + }; + }; + + if (!perf.memory) { + return { available: false }; + } + + return { + available: true, + memory: { + jsHeapSizeLimit: perf.memory.jsHeapSizeLimit, + totalJSHeapSize: perf.memory.totalJSHeapSize, + usedJSHeapSize: perf.memory.usedJSHeapSize, + }, + }; +} + +/** + * Debug metrics state + */ +export interface DebugMetricsState { + /** Whether metrics collection is active */ + isActive: boolean; + /** Whether data is currently loading */ + isLoading: boolean; + /** Error message if any */ + error: string | null; + /** Latest metrics snapshot from server */ + latestSnapshot: DebugMetricsSnapshot | null; + /** Memory history for charting */ + memoryHistory: MemoryDataPoint[]; + /** CPU history for charting */ + cpuHistory: CPUDataPoint[]; + /** Tracked processes */ + processes: TrackedProcess[]; + /** Process summary */ + processSummary: ProcessSummary | null; + /** Memory trend analysis */ + memoryTrend: MemoryTrend | null; + /** Browser-side memory metrics */ + browserMetrics: BrowserMetrics; +} + +/** + * Debug metrics actions + */ +export interface DebugMetricsActions { + /** Start metrics collection */ + start: () => Promise; + /** Stop metrics collection */ + stop: () => Promise; + /** Force garbage collection (if available) */ + forceGC: () => Promise<{ success: boolean; message: string }>; + /** Clear history */ + clearHistory: () => Promise; + /** Refresh metrics immediately */ + refresh: () => Promise; +} + +/** + * Hook for consuming debug metrics + */ +export function useDebugMetrics(): DebugMetricsState & DebugMetricsActions { + const preferences = useDebugStore((state) => state.preferences); + const isOpen = useDebugStore((state) => state.isOpen); + + const [state, setState] = useState({ + isActive: false, + isLoading: true, + error: null, + latestSnapshot: null, + memoryHistory: [], + cpuHistory: [], + processes: [], + processSummary: null, + memoryTrend: null, + browserMetrics: { available: false }, + }); + + // Use ref to store history to avoid re-renders during updates + const memoryHistoryRef = useRef([]); + const cpuHistoryRef = useRef([]); + const pollingIntervalRef = useRef(null); + + /** + * Fetch metrics from server + */ + const fetchMetrics = useCallback(async () => { + try { + const response = await apiGet('/api/debug/metrics'); + + // Get browser metrics + const browserMetrics = getBrowserMemoryMetrics(); + + if (response.snapshot) { + const snapshot = response.snapshot; + + // Add to history buffers + if (snapshot.memory.server) { + const memoryPoint: MemoryDataPoint = { + timestamp: snapshot.timestamp, + heapUsed: snapshot.memory.server.heapUsed, + heapTotal: snapshot.memory.server.heapTotal, + rss: snapshot.memory.server.rss, + }; + + memoryHistoryRef.current.push(memoryPoint); + if (memoryHistoryRef.current.length > MAX_HISTORY_POINTS) { + memoryHistoryRef.current.shift(); + } + } + + if (snapshot.cpu.server) { + const cpuPoint: CPUDataPoint = { + timestamp: snapshot.timestamp, + percentage: snapshot.cpu.server.percentage, + eventLoopLag: snapshot.cpu.eventLoopLag, + }; + + cpuHistoryRef.current.push(cpuPoint); + if (cpuHistoryRef.current.length > MAX_HISTORY_POINTS) { + cpuHistoryRef.current.shift(); + } + } + + setState((prev) => ({ + ...prev, + isActive: response.active, + isLoading: false, + error: null, + latestSnapshot: snapshot, + memoryHistory: [...memoryHistoryRef.current], + cpuHistory: [...cpuHistoryRef.current], + processes: snapshot.processes, + processSummary: snapshot.processSummary, + memoryTrend: snapshot.memoryTrend || null, + browserMetrics, + })); + } else { + setState((prev) => ({ + ...prev, + isActive: response.active, + isLoading: false, + browserMetrics, + })); + } + } catch (error) { + setState((prev) => ({ + ...prev, + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to fetch metrics', + })); + } + }, []); + + /** + * Start metrics collection + */ + const start = useCallback(async () => { + try { + await apiPost('/api/debug/metrics/start'); + await fetchMetrics(); + } catch (error) { + setState((prev) => ({ + ...prev, + error: error instanceof Error ? error.message : 'Failed to start metrics', + })); + } + }, [fetchMetrics]); + + /** + * Stop metrics collection + */ + const stop = useCallback(async () => { + try { + await apiPost('/api/debug/metrics/stop'); + setState((prev) => ({ + ...prev, + isActive: false, + })); + } catch (error) { + setState((prev) => ({ + ...prev, + error: error instanceof Error ? error.message : 'Failed to stop metrics', + })); + } + }, []); + + /** + * Force garbage collection + */ + const forceGC = useCallback(async () => { + try { + const response = await apiPost<{ success: boolean; message: string }>( + '/api/debug/metrics/gc' + ); + return response; + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'Failed to trigger GC', + }; + } + }, []); + + /** + * Clear metrics history + */ + const clearHistory = useCallback(async () => { + try { + await apiPost('/api/debug/metrics/clear'); + memoryHistoryRef.current = []; + cpuHistoryRef.current = []; + setState((prev) => ({ + ...prev, + memoryHistory: [], + cpuHistory: [], + })); + } catch (error) { + setState((prev) => ({ + ...prev, + error: error instanceof Error ? error.message : 'Failed to clear history', + })); + } + }, []); + + /** + * Refresh metrics immediately + */ + const refresh = useCallback(async () => { + setState((prev) => ({ ...prev, isLoading: true })); + await fetchMetrics(); + }, [fetchMetrics]); + + // Set up polling when debug panel is open and monitoring is enabled + useEffect(() => { + if (!isOpen || !preferences.memoryMonitorEnabled) { + // Clear polling when panel is closed or monitoring disabled + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + return; + } + + // Initial fetch + fetchMetrics(); + + // Set up polling interval + pollingIntervalRef.current = setInterval(fetchMetrics, preferences.updateInterval); + + return () => { + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + }; + }, [isOpen, preferences.memoryMonitorEnabled, preferences.updateInterval, fetchMetrics]); + + return { + ...state, + start, + stop, + forceGC, + clearHistory, + refresh, + }; +} diff --git a/apps/ui/src/hooks/use-render-tracking.ts b/apps/ui/src/hooks/use-render-tracking.ts new file mode 100644 index 00000000..50bcf0e1 --- /dev/null +++ b/apps/ui/src/hooks/use-render-tracking.ts @@ -0,0 +1,237 @@ +/** + * Hook for tracking React component render performance + * + * Uses React Profiler API to track: + * - Component render counts + * - Render durations + * - Render frequency (renders per second) + * - High-render component detection + */ + +import { useState, useCallback, useRef, useEffect } from 'react'; +import type { ProfilerOnRenderCallback } from 'react'; +import type { + ComponentRender, + ComponentRenderStats, + RenderTrackingSummary, +} from '@automaker/types'; +import { useDebugStore } from '@/store/debug-store'; + +/** + * Maximum render records to keep per component + */ +const MAX_RENDER_RECORDS = 100; + +/** + * Time window for calculating renders per second (ms) + */ +const RENDER_RATE_WINDOW = 5000; + +/** + * Hook for tracking render performance + */ +export function useRenderTracking() { + const isOpen = useDebugStore((state) => state.isOpen); + const preferences = useDebugStore((state) => state.preferences); + + // Store render records per component + const renderRecordsRef = useRef>(new Map()); + + // Store computed stats + const [stats, setStats] = useState>(new Map()); + const [summary, setSummary] = useState({ + totalRenders: 0, + uniqueComponents: 0, + highRenderComponents: [], + topRenderers: [], + windowStart: Date.now(), + windowDuration: 0, + }); + + /** + * Create a profiler callback for a specific component + */ + const createProfilerCallback = useCallback( + (componentName: string): ProfilerOnRenderCallback => { + return ( + _id: string, + phase: 'mount' | 'update' | 'nested-update', + actualDuration: number, + baseDuration: number, + startTime: number, + commitTime: number + ) => { + if (!isOpen || !preferences.renderTrackingEnabled) { + return; + } + + const record: ComponentRender = { + componentName, + phase, + actualDuration, + baseDuration, + startTime, + commitTime, + }; + + // Add to records + let records = renderRecordsRef.current.get(componentName); + if (!records) { + records = []; + renderRecordsRef.current.set(componentName, records); + } + + records.push(record); + + // Trim old records + if (records.length > MAX_RENDER_RECORDS) { + records.shift(); + } + }; + }, + [isOpen, preferences.renderTrackingEnabled] + ); + + /** + * Calculate stats for a component + */ + const calculateComponentStats = useCallback( + (componentName: string, records: ComponentRender[]): ComponentRenderStats => { + const now = Date.now(); + const windowStart = now - RENDER_RATE_WINDOW; + + // Filter records in the rate calculation window + const recentRecords = records.filter((r) => r.commitTime >= windowStart); + const rendersPerSecond = recentRecords.length / (RENDER_RATE_WINDOW / 1000); + + // Calculate duration stats + let totalDuration = 0; + let maxDuration = 0; + let minDuration = Infinity; + + for (const record of records) { + totalDuration += record.actualDuration; + maxDuration = Math.max(maxDuration, record.actualDuration); + minDuration = Math.min(minDuration, record.actualDuration); + } + + const avgDuration = records.length > 0 ? totalDuration / records.length : 0; + const lastRender = records[records.length - 1]; + + return { + componentName, + renderCount: records.length, + rendersPerSecond, + avgDuration, + maxDuration, + minDuration: minDuration === Infinity ? 0 : minDuration, + totalDuration, + isHighRender: rendersPerSecond > preferences.renderAlertThreshold, + lastRenderAt: lastRender?.commitTime || 0, + }; + }, + [preferences.renderAlertThreshold] + ); + + /** + * Update all stats + */ + const updateStats = useCallback(() => { + const newStats = new Map(); + let totalRenders = 0; + const highRenderComponents: string[] = []; + const allStats: ComponentRenderStats[] = []; + let windowStart = Date.now(); + + for (const [componentName, records] of renderRecordsRef.current.entries()) { + if (records.length === 0) continue; + + const componentStats = calculateComponentStats(componentName, records); + newStats.set(componentName, componentStats); + allStats.push(componentStats); + totalRenders += componentStats.renderCount; + + if (componentStats.isHighRender) { + highRenderComponents.push(componentName); + } + + // Track earliest record + const firstRecord = records[0]; + if (firstRecord && firstRecord.commitTime < windowStart) { + windowStart = firstRecord.commitTime; + } + } + + // Sort by render count to get top renderers + const topRenderers = allStats.sort((a, b) => b.renderCount - a.renderCount).slice(0, 5); + + setStats(newStats); + setSummary({ + totalRenders, + uniqueComponents: newStats.size, + highRenderComponents, + topRenderers, + windowStart, + windowDuration: Date.now() - windowStart, + }); + }, [calculateComponentStats]); + + /** + * Clear all render records + */ + const clearRecords = useCallback(() => { + renderRecordsRef.current.clear(); + setStats(new Map()); + setSummary({ + totalRenders: 0, + uniqueComponents: 0, + highRenderComponents: [], + topRenderers: [], + windowStart: Date.now(), + windowDuration: 0, + }); + }, []); + + /** + * Get stats for a specific component + */ + const getComponentStats = useCallback( + (componentName: string): ComponentRenderStats | null => { + return stats.get(componentName) || null; + }, + [stats] + ); + + /** + * Get all component stats as array + */ + const getAllStats = useCallback((): ComponentRenderStats[] => { + return Array.from(stats.values()); + }, [stats]); + + // Periodically update stats when panel is open + useEffect(() => { + if (!isOpen || !preferences.renderTrackingEnabled) { + return; + } + + // Update stats every second + const interval = setInterval(updateStats, 1000); + return () => clearInterval(interval); + }, [isOpen, preferences.renderTrackingEnabled, updateStats]); + + return { + stats, + summary, + createProfilerCallback, + updateStats, + clearRecords, + getComponentStats, + getAllStats, + }; +} + +/** + * Context for sharing render tracking across components + */ +export type RenderTrackingContextType = ReturnType; diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index 34dbd00e..3dd6a967 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -24,6 +24,14 @@ import { ThemeOption, themeOptions } from '@/config/theme-options'; import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog'; import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen'; import { LoadingState } from '@/components/ui/loading-state'; +import { + DebugPanelWrapper, + DebugStatusBarWrapper, + DebugDockedPanelWrapper, + RenderTrackingProvider, + RenderProfiler, +} from '@/components/debug'; +import { useDebugStore } from '@/store/debug-store'; const logger = createLogger('RootLayout'); @@ -46,6 +54,7 @@ function RootLayoutContent() { const authChecked = useAuthStore((s) => s.authChecked); const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const { openFileBrowser } = useFileBrowser(); + const toggleDebugPanel = useDebugStore((s) => s.togglePanel); const isSetupRoute = location.pathname === '/setup'; const isLoginRoute = location.pathname === '/login'; @@ -87,12 +96,31 @@ function RootLayoutContent() { } }, []); + // Debug panel shortcut - Cmd/Ctrl+Shift+D + const handleDebugPanelShortcut = useCallback( + (event: KeyboardEvent) => { + // Only in dev mode + if (!import.meta.env.DEV && import.meta.env.VITE_ENABLE_DEBUG_PANEL !== 'true') { + return; + } + + const cmdCtrl = event.metaKey || event.ctrlKey; + if (cmdCtrl && event.shiftKey && event.key.toLowerCase() === 'd') { + event.preventDefault(); + toggleDebugPanel(); + } + }, + [toggleDebugPanel] + ); + useEffect(() => { window.addEventListener('keydown', handleStreamerPanelShortcut); + window.addEventListener('keydown', handleDebugPanelShortcut); return () => { window.removeEventListener('keydown', handleStreamerPanelShortcut); + window.removeEventListener('keydown', handleDebugPanelShortcut); }; - }, [handleStreamerPanelShortcut]); + }, [handleStreamerPanelShortcut, handleDebugPanelShortcut]); const effectiveTheme = getEffectiveTheme(); // Defer the theme value to keep UI responsive during rapid hover changes @@ -394,12 +422,25 @@ function RootLayoutContent() { aria-hidden="true" /> )} - + + +
- + {/* Main content area */} +
+ + + +
+ + {/* Docked Debug Panel - expands above status bar */} + + + {/* Docked Debug Status Bar - VS Code style footer */} +
{/* Hidden streamer panel - opens with "\" key, pushes content */} @@ -410,6 +451,9 @@ function RootLayoutContent() { /> + {/* Floating Debug Panel - alternative mode */} + + {/* Show sandbox dialog if needed */} - + {isDev ? ( + + + + ) : ( + + )} ); } diff --git a/apps/ui/src/routes/board.tsx b/apps/ui/src/routes/board.tsx index 4c018cb5..8f2a17c6 100644 --- a/apps/ui/src/routes/board.tsx +++ b/apps/ui/src/routes/board.tsx @@ -1,6 +1,15 @@ import { createFileRoute } from '@tanstack/react-router'; import { BoardView } from '@/components/views/board-view'; +import { RenderProfiler } from '@/components/debug'; + +function ProfiledBoardView() { + return ( + + + + ); +} export const Route = createFileRoute('/board')({ - component: BoardView, + component: ProfiledBoardView, }); diff --git a/apps/ui/src/store/debug-store.ts b/apps/ui/src/store/debug-store.ts new file mode 100644 index 00000000..4f685be7 --- /dev/null +++ b/apps/ui/src/store/debug-store.ts @@ -0,0 +1,312 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +/** + * Debug Panel Position - coordinates for draggable panel + */ +export interface DebugPanelPosition { + x: number; + y: number; +} + +/** + * Debug Panel Size - dimensions for resizable panel + */ +export interface DebugPanelSize { + width: number; + height: number; +} + +/** + * Debug Tab - available tabs in the debug panel + */ +export type DebugTab = 'memory' | 'cpu' | 'processes' | 'renders'; + +/** + * Debug Panel Mode - floating overlay or docked to bottom + */ +export type DebugPanelMode = 'floating' | 'docked'; + +/** + * Debug Panel Preferences - user customization options + */ +export interface DebugPanelPreferences { + /** Update interval for metrics polling in milliseconds */ + updateInterval: number; + /** Maximum data points to retain in charts (circular buffer) */ + maxDataPoints: number; + /** Enable/disable memory monitoring */ + memoryMonitorEnabled: boolean; + /** Enable/disable CPU monitoring */ + cpuMonitorEnabled: boolean; + /** Enable/disable process tracking */ + processTrackingEnabled: boolean; + /** Enable/disable render tracking */ + renderTrackingEnabled: boolean; + /** Threshold for highlighting high-render components (renders/second) */ + renderAlertThreshold: number; + /** Show mini chart in collapsed mode */ + showMiniChart: boolean; +} + +/** + * Default preferences for the debug panel + */ +export const DEFAULT_DEBUG_PREFERENCES: DebugPanelPreferences = { + updateInterval: 1000, // 1 second + maxDataPoints: 60, // 60 data points = 60 seconds of history + memoryMonitorEnabled: true, + cpuMonitorEnabled: true, + processTrackingEnabled: true, + renderTrackingEnabled: true, + renderAlertThreshold: 10, // 10 renders/second triggers alert + showMiniChart: true, +}; + +/** + * Debug Store State + */ +export interface DebugState { + /** Whether the debug panel is open/visible */ + isOpen: boolean; + /** Whether the panel is minimized (collapsed view) */ + isMinimized: boolean; + /** Panel mode: floating overlay or docked to bottom */ + panelMode: DebugPanelMode; + /** Whether the docked panel detail view is expanded */ + isDockedExpanded: boolean; + /** Height of the docked panel when expanded */ + dockedHeight: number; + /** Current position of the panel (for dragging - floating mode only) */ + position: DebugPanelPosition; + /** Current size of the panel (for resizing - floating mode only) */ + size: DebugPanelSize; + /** Currently active tab */ + activeTab: DebugTab; + /** User preferences */ + preferences: DebugPanelPreferences; + /** Whether the panel is currently being dragged */ + isDragging: boolean; + /** Whether the panel is currently being resized */ + isResizing: boolean; +} + +/** + * Debug Store Actions + */ +export interface DebugActions { + // Panel visibility + /** Toggle the debug panel open/closed */ + togglePanel: () => void; + /** Set the panel open state directly */ + setOpen: (open: boolean) => void; + /** Toggle minimized state */ + toggleMinimized: () => void; + /** Set minimized state directly */ + setMinimized: (minimized: boolean) => void; + + // Panel mode (floating vs docked) + /** Set panel mode */ + setPanelMode: (mode: DebugPanelMode) => void; + /** Toggle between floating and docked mode */ + togglePanelMode: () => void; + /** Toggle docked panel expanded state */ + toggleDockedExpanded: () => void; + /** Set docked expanded state */ + setDockedExpanded: (expanded: boolean) => void; + /** Set docked panel height */ + setDockedHeight: (height: number) => void; + + // Position & Size + /** Update panel position (called during drag) */ + setPosition: (position: DebugPanelPosition) => void; + /** Update panel size (called during resize) */ + setSize: (size: DebugPanelSize) => void; + /** Reset position to default (top-right corner) */ + resetPosition: () => void; + /** Reset size to default */ + resetSize: () => void; + + // Tab management + /** Set the active tab */ + setActiveTab: (tab: DebugTab) => void; + + // Preferences + /** Update preferences (partial update supported) */ + setPreferences: (preferences: Partial) => void; + /** Reset preferences to defaults */ + resetPreferences: () => void; + + // Drag/Resize state (for UI feedback) + /** Set dragging state */ + setIsDragging: (dragging: boolean) => void; + /** Set resizing state */ + setIsResizing: (resizing: boolean) => void; + + // Reset + /** Reset entire store to initial state */ + reset: () => void; +} + +/** + * Default position - top-right corner with offset + */ +const DEFAULT_POSITION: DebugPanelPosition = { + x: -20, // 20px from right edge (negative = from right) + y: 20, // 20px from top +}; + +/** + * Default size for the debug panel + */ +const DEFAULT_SIZE: DebugPanelSize = { + width: 450, + height: 350, +}; + +/** + * Minimum size constraints for resize + */ +export const MIN_PANEL_SIZE: DebugPanelSize = { + width: 350, + height: 250, +}; + +/** + * Maximum size constraints for resize (relative to viewport) + */ +export const MAX_PANEL_SIZE_RATIO = { + width: 0.9, // 90% of viewport width + height: 0.9, // 90% of viewport height +}; + +/** + * Default height for docked panel when expanded + */ +export const DEFAULT_DOCKED_HEIGHT = 250; + +/** + * Minimum height for docked panel when expanded + */ +export const MIN_DOCKED_HEIGHT = 150; + +/** + * Maximum height ratio for docked panel (relative to viewport) + */ +export const MAX_DOCKED_HEIGHT_RATIO = 0.5; // 50% of viewport height + +/** + * Initial state for the debug store + */ +const initialState: DebugState = { + isOpen: false, + isMinimized: false, + panelMode: 'docked', // Default to docked mode (VS Code style) + isDockedExpanded: false, + dockedHeight: DEFAULT_DOCKED_HEIGHT, + position: DEFAULT_POSITION, + size: DEFAULT_SIZE, + activeTab: 'memory', + preferences: DEFAULT_DEBUG_PREFERENCES, + isDragging: false, + isResizing: false, +}; + +/** + * Debug Store + * + * Manages state for the floating debug panel including: + * - Panel visibility (open/closed, minimized/expanded) + * - Position and size (for dragging and resizing) + * - Active tab selection + * - User preferences for metrics collection + * + * Uses Zustand with persist middleware to save preferences across sessions. + * Only UI-related state is persisted; runtime metrics data is stored separately. + */ +export const useDebugStore = create()( + persist( + (set, get) => ({ + ...initialState, + + // Panel visibility + togglePanel: () => set((state) => ({ isOpen: !state.isOpen })), + + setOpen: (open) => set({ isOpen: open }), + + toggleMinimized: () => set((state) => ({ isMinimized: !state.isMinimized })), + + setMinimized: (minimized) => set({ isMinimized: minimized }), + + // Panel mode (floating vs docked) + setPanelMode: (mode) => set({ panelMode: mode }), + + togglePanelMode: () => + set((state) => ({ + panelMode: state.panelMode === 'floating' ? 'docked' : 'floating', + })), + + toggleDockedExpanded: () => set((state) => ({ isDockedExpanded: !state.isDockedExpanded })), + + setDockedExpanded: (expanded) => set({ isDockedExpanded: expanded }), + + setDockedHeight: (height) => set({ dockedHeight: height }), + + // Position & Size + setPosition: (position) => set({ position }), + + setSize: (size) => set({ size }), + + resetPosition: () => set({ position: DEFAULT_POSITION }), + + resetSize: () => set({ size: DEFAULT_SIZE }), + + // Tab management + setActiveTab: (tab) => set({ activeTab: tab }), + + // Preferences + setPreferences: (preferences) => + set((state) => ({ + preferences: { ...state.preferences, ...preferences }, + })), + + resetPreferences: () => set({ preferences: DEFAULT_DEBUG_PREFERENCES }), + + // Drag/Resize state + setIsDragging: (dragging) => set({ isDragging: dragging }), + + setIsResizing: (resizing) => set({ isResizing: resizing }), + + // Reset + reset: () => set(initialState), + }), + { + name: 'automaker-debug-panel', + version: 2, // Bumped for new fields + partialize: (state) => ({ + // Only persist UI preferences, not runtime state + position: state.position, + size: state.size, + activeTab: state.activeTab, + preferences: state.preferences, + isMinimized: state.isMinimized, + panelMode: state.panelMode, + dockedHeight: state.dockedHeight, + // Don't persist: isOpen, isDragging, isResizing, isDockedExpanded (runtime state) + }), + } + ) +); + +/** + * Selector hooks for common patterns + */ +export const selectDebugPanelOpen = (state: DebugState) => state.isOpen; +export const selectDebugPanelMinimized = (state: DebugState) => state.isMinimized; +export const selectDebugPanelMode = (state: DebugState) => state.panelMode; +export const selectDebugDockedExpanded = (state: DebugState) => state.isDockedExpanded; +export const selectDebugDockedHeight = (state: DebugState) => state.dockedHeight; +export const selectDebugPosition = (state: DebugState) => state.position; +export const selectDebugSize = (state: DebugState) => state.size; +export const selectDebugActiveTab = (state: DebugState) => state.activeTab; +export const selectDebugPreferences = (state: DebugState) => state.preferences; diff --git a/docs/server/debug-api.md b/docs/server/debug-api.md new file mode 100644 index 00000000..ebc2d7b4 --- /dev/null +++ b/docs/server/debug-api.md @@ -0,0 +1,726 @@ +# Debug API Documentation + +The Debug API provides endpoints for monitoring server performance, memory usage, CPU metrics, and process tracking. These endpoints are only available in development mode or when `ENABLE_DEBUG_PANEL=true`. + +## Table of Contents + +- [Overview](#overview) +- [Authentication](#authentication) +- [Metrics Endpoints](#metrics-endpoints) + - [GET /api/debug/metrics](#get-apidebugmetrics) + - [POST /api/debug/metrics/start](#post-apidebugmetricsstart) + - [POST /api/debug/metrics/stop](#post-apidebugmetricsstop) + - [POST /api/debug/metrics/gc](#post-apidebugmetricsgc) + - [POST /api/debug/metrics/clear](#post-apidebugmetricsclear) +- [Process Endpoints](#process-endpoints) + - [GET /api/debug/processes](#get-apidebugprocesses) + - [GET /api/debug/processes/summary](#get-apidebugprocessessummary) + - [GET /api/debug/processes/:id](#get-apidebugprocessesid) +- [Agent Resource Metrics Endpoints](#agent-resource-metrics-endpoints) + - [GET /api/debug/agents](#get-apidebugagents) + - [GET /api/debug/agents/summary](#get-apidebugagentssummary) + - [GET /api/debug/agents/:id/metrics](#get-apidebugagentsidmetrics) +- [Types](#types) +- [Events](#events) + +--- + +## Overview + +The Debug API is designed for development and debugging purposes. It provides: + +- **Memory Monitoring**: Track heap usage, RSS, and detect memory leaks +- **CPU Monitoring**: Track CPU usage percentage and event loop lag +- **Process Tracking**: Monitor agents, terminals, CLIs, and worker processes +- **Trend Analysis**: Detect memory leaks using linear regression + +### Enabling the Debug API + +The Debug API is enabled when: + +- `NODE_ENV !== 'production'` (development mode), OR +- `ENABLE_DEBUG_PANEL=true` environment variable is set + +--- + +## Authentication + +All debug endpoints require authentication. Requests must include a valid session token or use the standard Automaker authentication mechanism. + +--- + +## Metrics Endpoints + +### GET /api/debug/metrics + +Returns the current metrics snapshot including memory, CPU, and process information. + +**Response** + +```json +{ + "active": true, + "config": { + "memoryEnabled": true, + "cpuEnabled": true, + "processTrackingEnabled": true, + "collectionInterval": 1000, + "maxDataPoints": 60, + "leakThreshold": 1048576 + }, + "snapshot": { + "timestamp": 1704067200000, + "memory": { + "timestamp": 1704067200000, + "server": { + "heapTotal": 104857600, + "heapUsed": 52428800, + "external": 5242880, + "rss": 157286400, + "arrayBuffers": 1048576 + } + }, + "cpu": { + "timestamp": 1704067200000, + "server": { + "percentage": 25.5, + "user": 1000000, + "system": 500000 + }, + "eventLoopLag": 5 + }, + "processes": [], + "processSummary": { + "total": 0, + "running": 0, + "idle": 0, + "stopped": 0, + "errored": 0, + "byType": { + "agent": 0, + "cli": 0, + "terminal": 0, + "worker": 0 + } + }, + "memoryTrend": { + "growthRate": 1024, + "isLeaking": false, + "confidence": 0.85, + "sampleCount": 30, + "windowDuration": 30000 + } + } +} +``` + +--- + +### POST /api/debug/metrics/start + +Starts metrics collection with optional configuration overrides. + +**Request Body** (optional) + +```json +{ + "config": { + "collectionInterval": 2000, + "maxDataPoints": 100, + "memoryEnabled": true, + "cpuEnabled": true, + "leakThreshold": 2097152 + } +} +``` + +**Configuration Limits** (enforced server-side) + +| Field | Min | Max | Default | +| -------------------- | ----- | ------- | ------- | +| `collectionInterval` | 100ms | 60000ms | 1000ms | +| `maxDataPoints` | 10 | 10000 | 60 | +| `leakThreshold` | 1KB | 100MB | 1MB | + +**Response** + +```json +{ + "active": true, + "config": { + "memoryEnabled": true, + "cpuEnabled": true, + "processTrackingEnabled": true, + "collectionInterval": 2000, + "maxDataPoints": 100, + "leakThreshold": 2097152 + } +} +``` + +--- + +### POST /api/debug/metrics/stop + +Stops metrics collection. + +**Response** + +```json +{ + "active": false, + "config": { + "memoryEnabled": true, + "cpuEnabled": true, + "processTrackingEnabled": true, + "collectionInterval": 1000, + "maxDataPoints": 60, + "leakThreshold": 1048576 + } +} +``` + +--- + +### POST /api/debug/metrics/gc + +Forces garbage collection if Node.js was started with `--expose-gc` flag. + +**Response (success)** + +```json +{ + "success": true, + "message": "Garbage collection triggered" +} +``` + +**Response (not available)** + +```json +{ + "success": false, + "message": "Garbage collection not available (start Node.js with --expose-gc flag)" +} +``` + +--- + +### POST /api/debug/metrics/clear + +Clears the metrics history buffer. + +**Response** + +```json +{ + "success": true, + "message": "Metrics history cleared" +} +``` + +--- + +## Process Endpoints + +### GET /api/debug/processes + +Returns a list of tracked processes with optional filtering. + +**Query Parameters** + +| Parameter | Type | Description | +| ---------------- | ------ | ------------------------------------------------------------------------------- | +| `type` | string | Filter by process type: `agent`, `cli`, `terminal`, `worker` | +| `status` | string | Filter by status: `starting`, `running`, `idle`, `stopping`, `stopped`, `error` | +| `includeStopped` | string | Set to `"true"` to include stopped processes | +| `sessionId` | string | Filter by session ID | +| `featureId` | string | Filter by feature ID | + +**Example Request** + +``` +GET /api/debug/processes?type=agent&status=running&includeStopped=true +``` + +**Response** + +```json +{ + "processes": [ + { + "id": "agent-12345", + "pid": 1234, + "type": "agent", + "name": "Feature Agent", + "status": "running", + "startedAt": 1704067200000, + "memoryUsage": 52428800, + "cpuUsage": 15.5, + "featureId": "feature-123", + "sessionId": "session-456" + } + ], + "summary": { + "total": 5, + "running": 2, + "idle": 1, + "stopped": 1, + "errored": 1, + "byType": { + "agent": 2, + "cli": 1, + "terminal": 2, + "worker": 0 + } + } +} +``` + +--- + +### GET /api/debug/processes/summary + +Returns summary statistics for all tracked processes. + +**Response** + +```json +{ + "total": 5, + "running": 2, + "idle": 1, + "stopped": 1, + "errored": 1, + "byType": { + "agent": 2, + "cli": 1, + "terminal": 2, + "worker": 0 + } +} +``` + +--- + +### GET /api/debug/processes/:id + +Returns details for a specific process. + +**Path Parameters** + +| Parameter | Type | Description | +| --------- | ------ | ------------------------------- | +| `id` | string | Process ID (max 256 characters) | + +**Response (success)** + +```json +{ + "id": "agent-12345", + "pid": 1234, + "type": "agent", + "name": "Feature Agent", + "status": "running", + "startedAt": 1704067200000, + "memoryUsage": 52428800, + "cpuUsage": 15.5, + "featureId": "feature-123", + "sessionId": "session-456", + "command": "node agent.js", + "cwd": "/path/to/project" +} +``` + +**Response (not found)** + +```json +{ + "error": "Process not found", + "id": "non-existent-id" +} +``` + +**Response (invalid ID)** + +```json +{ + "error": "Invalid process ID format" +} +``` + +--- + +## Agent Resource Metrics Endpoints + +These endpoints provide detailed resource usage metrics for agent processes, including file I/O, tool usage, bash commands, and memory tracking. + +### GET /api/debug/agents + +Returns all agent processes with their detailed resource metrics. + +**Response** + +```json +{ + "agents": [ + { + "id": "agent-feature-123", + "pid": -1, + "type": "agent", + "name": "Feature Agent", + "status": "running", + "startedAt": 1704067200000, + "featureId": "feature-123", + "resourceMetrics": { + "agentId": "agent-feature-123", + "featureId": "feature-123", + "startedAt": 1704067200000, + "lastUpdatedAt": 1704067260000, + "duration": 60000, + "isRunning": true, + "memory": { + "startHeapUsed": 52428800, + "currentHeapUsed": 57671680, + "peakHeapUsed": 58720256, + "deltaHeapUsed": 5242880, + "samples": [...] + }, + "fileIO": { + "reads": 25, + "bytesRead": 524288, + "writes": 5, + "bytesWritten": 10240, + "edits": 3, + "globs": 10, + "greps": 8, + "filesAccessed": ["src/index.ts", "src/utils.ts", ...] + }, + "tools": { + "totalInvocations": 51, + "byTool": { + "Read": 25, + "Glob": 10, + "Grep": 8, + "Write": 5, + "Edit": 3 + }, + "avgExecutionTime": 150, + "totalExecutionTime": 7650, + "failedInvocations": 1 + }, + "bash": { + "commandCount": 5, + "totalExecutionTime": 2500, + "failedCommands": 0, + "commands": [...] + }, + "api": { + "turns": 12, + "totalDuration": 45000, + "errors": 0 + } + } + } + ], + "summary": { + "totalAgents": 3, + "runningAgents": 1, + "totalFileReads": 75, + "totalFileWrites": 15, + "totalBytesRead": 1572864, + "totalBytesWritten": 30720, + "totalToolInvocations": 153, + "totalBashCommands": 12, + "totalAPITurns": 36, + "peakMemoryUsage": 58720256, + "totalDuration": 180000 + } +} +``` + +--- + +### GET /api/debug/agents/summary + +Returns aggregate resource usage statistics across all agent processes. + +**Response** + +```json +{ + "totalAgents": 3, + "runningAgents": 1, + "totalFileReads": 75, + "totalFileWrites": 15, + "totalBytesRead": 1572864, + "totalBytesWritten": 30720, + "totalToolInvocations": 153, + "totalBashCommands": 12, + "totalAPITurns": 36, + "peakMemoryUsage": 58720256, + "totalDuration": 180000 +} +``` + +--- + +### GET /api/debug/agents/:id/metrics + +Returns detailed resource metrics for a specific agent. + +**Path Parameters** + +| Parameter | Type | Description | +| --------- | ------ | ------------------------------------------------------------------ | +| `id` | string | Agent process ID (e.g., `agent-feature-123` or `chat-session-456`) | + +**Response (success)** + +```json +{ + "agentId": "agent-feature-123", + "featureId": "feature-123", + "startedAt": 1704067200000, + "lastUpdatedAt": 1704067260000, + "duration": 60000, + "isRunning": true, + "memory": { + "startHeapUsed": 52428800, + "currentHeapUsed": 57671680, + "peakHeapUsed": 58720256, + "deltaHeapUsed": 5242880, + "samples": [ + { "timestamp": 1704067200000, "heapUsed": 52428800 }, + { "timestamp": 1704067201000, "heapUsed": 53477376 } + ] + }, + "fileIO": { + "reads": 25, + "bytesRead": 524288, + "writes": 5, + "bytesWritten": 10240, + "edits": 3, + "globs": 10, + "greps": 8, + "filesAccessed": ["src/index.ts", "src/utils.ts", "package.json"] + }, + "tools": { + "totalInvocations": 51, + "byTool": { + "Read": 25, + "Glob": 10, + "Grep": 8, + "Write": 5, + "Edit": 3 + }, + "avgExecutionTime": 150, + "totalExecutionTime": 7650, + "failedInvocations": 1 + }, + "bash": { + "commandCount": 5, + "totalExecutionTime": 2500, + "failedCommands": 0, + "commands": [ + { + "command": "npm test", + "exitCode": 0, + "duration": 1500, + "timestamp": 1704067230000 + } + ] + }, + "api": { + "turns": 12, + "inputTokens": 15000, + "outputTokens": 8000, + "thinkingTokens": 5000, + "totalDuration": 45000, + "errors": 0 + } +} +``` + +**Response (not found)** + +```json +{ + "error": "Agent metrics not found", + "id": "non-existent-id" +} +``` + +--- + +## Types + +### TrackedProcess + +```typescript +interface TrackedProcess { + id: string; // Unique identifier + pid?: number; // OS process ID + type: ProcessType; // 'agent' | 'cli' | 'terminal' | 'worker' + name: string; // Human-readable name + status: ProcessStatus; // Current status + startedAt: number; // Start timestamp (ms) + stoppedAt?: number; // Stop timestamp (ms) + memoryUsage?: number; // Memory in bytes + cpuUsage?: number; // CPU percentage + featureId?: string; // Associated feature + sessionId?: string; // Associated session + command?: string; // Command executed + cwd?: string; // Working directory + exitCode?: number; // Exit code (if stopped) + error?: string; // Error message (if failed) + resourceMetrics?: AgentResourceMetrics; // Detailed metrics for agents +} +``` + +### AgentResourceMetrics + +```typescript +interface AgentResourceMetrics { + agentId: string; // Agent/process ID + sessionId?: string; // Session ID if available + featureId?: string; // Feature ID if running a feature + startedAt: number; // When metrics collection started + lastUpdatedAt: number; // When metrics were last updated + duration: number; // Duration of agent execution (ms) + isRunning: boolean; // Whether the agent is still running + memory: AgentMemoryMetrics; + fileIO: FileIOMetrics; + tools: ToolUsageMetrics; + bash: BashMetrics; + api: APIMetrics; +} + +interface AgentMemoryMetrics { + startHeapUsed: number; // Memory at agent start (bytes) + currentHeapUsed: number; // Current memory (bytes) + peakHeapUsed: number; // Peak memory during execution (bytes) + deltaHeapUsed: number; // Memory change since start + samples: Array<{ timestamp: number; heapUsed: number }>; +} + +interface FileIOMetrics { + reads: number; // Number of file reads + bytesRead: number; // Total bytes read + writes: number; // Number of file writes + bytesWritten: number; // Total bytes written + edits: number; // Number of file edits + globs: number; // Number of glob operations + greps: number; // Number of grep operations + filesAccessed: string[]; // Unique files accessed (max 100) +} + +interface ToolUsageMetrics { + totalInvocations: number; + byTool: Record; // Invocations per tool name + avgExecutionTime: number; // Average tool execution time (ms) + totalExecutionTime: number; // Total tool execution time (ms) + failedInvocations: number; +} + +interface BashMetrics { + commandCount: number; + totalExecutionTime: number; + failedCommands: number; + commands: Array<{ + command: string; + exitCode: number | null; + duration: number; + timestamp: number; + }>; +} + +interface APIMetrics { + turns: number; // Number of API turns/iterations + inputTokens?: number; // Input tokens used + outputTokens?: number; // Output tokens generated + thinkingTokens?: number; // Thinking tokens used + totalDuration: number; // Total API call duration (ms) + errors: number; // Number of API errors +} +``` + +### ProcessStatus + +- `starting` - Process is starting up +- `running` - Process is actively running +- `idle` - Process is idle/waiting +- `stopping` - Process is shutting down +- `stopped` - Process has stopped normally +- `error` - Process encountered an error + +### MemoryTrend + +```typescript +interface MemoryTrend { + growthRate: number; // Bytes per second + isLeaking: boolean; // Leak detected flag + confidence: number; // R² value (0-1) + sampleCount: number; // Data points analyzed + windowDuration: number; // Analysis window (ms) +} +``` + +--- + +## Events + +The debug system emits the following WebSocket events: + +| Event | Description | +| -------------------------- | --------------------------------------------------- | +| `debug:metrics` | Periodic metrics snapshot (at `collectionInterval`) | +| `debug:memory-warning` | Memory usage exceeds 70% of heap limit | +| `debug:memory-critical` | Memory usage exceeds 90% of heap limit | +| `debug:leak-detected` | Memory leak pattern detected | +| `debug:process-spawned` | New process registered | +| `debug:process-updated` | Process status changed | +| `debug:process-stopped` | Process stopped normally | +| `debug:process-error` | Process encountered an error | +| `debug:high-cpu` | CPU usage exceeds 80% | +| `debug:event-loop-blocked` | Event loop lag exceeds 100ms | + +--- + +## Usage Example + +### Starting metrics collection with custom config + +```typescript +// Start with 500ms interval and 120 data points +await fetch('/api/debug/metrics/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + config: { + collectionInterval: 500, + maxDataPoints: 120, + }, + }), +}); + +// Poll for metrics +const response = await fetch('/api/debug/metrics'); +const { snapshot } = await response.json(); + +console.log(`Heap used: ${(snapshot.memory.server.heapUsed / 1024 / 1024).toFixed(1)} MB`); +console.log(`CPU: ${snapshot.cpu.server.percentage.toFixed(1)}%`); +``` + +### Monitoring for memory leaks + +```typescript +const response = await fetch('/api/debug/metrics'); +const { snapshot } = await response.json(); + +if (snapshot.memoryTrend?.isLeaking) { + console.warn(`Memory leak detected!`); + console.warn(`Growth rate: ${snapshot.memoryTrend.growthRate} bytes/s`); + console.warn(`Confidence: ${(snapshot.memoryTrend.confidence * 100).toFixed(0)}%`); +} +``` diff --git a/libs/types/package.json b/libs/types/package.json index 3a5c2a83..c94e0286 100644 --- a/libs/types/package.json +++ b/libs/types/package.json @@ -7,7 +7,9 @@ "types": "dist/index.d.ts", "scripts": { "build": "tsc", - "watch": "tsc --watch" + "watch": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest" }, "keywords": [ "automaker", @@ -20,6 +22,7 @@ }, "devDependencies": { "@types/node": "22.19.3", - "typescript": "5.9.3" + "typescript": "5.9.3", + "vitest": "4.0.16" } } diff --git a/libs/types/src/debug.ts b/libs/types/src/debug.ts new file mode 100644 index 00000000..e1745b5d --- /dev/null +++ b/libs/types/src/debug.ts @@ -0,0 +1,711 @@ +/** + * Debug types for AutoMaker performance monitoring and debugging + * + * This module defines types for: + * - Memory metrics and monitoring + * - CPU metrics and monitoring + * - Process tracking (agents, CLIs, terminals) + * - Component render tracking + * - Debug event streaming + */ + +// ============================================================================ +// Memory Metrics +// ============================================================================ + +/** + * Memory metrics from the server (Node.js process) + */ +export interface ServerMemoryMetrics { + /** Total heap size allocated (bytes) */ + heapTotal: number; + /** Heap actually used (bytes) */ + heapUsed: number; + /** V8 external memory (bytes) - memory used by C++ objects bound to JS */ + external: number; + /** Resident Set Size - total memory allocated for the process (bytes) */ + rss: number; + /** Array buffers memory (bytes) */ + arrayBuffers: number; +} + +/** + * Memory metrics from the browser (performance.memory API) + * Note: Only available in Chromium-based browsers with --enable-precise-memory-info flag + */ +export interface BrowserMemoryMetrics { + /** Total JS heap size limit (bytes) */ + jsHeapSizeLimit: number; + /** Total allocated heap size (bytes) */ + totalJSHeapSize: number; + /** Currently used heap size (bytes) */ + usedJSHeapSize: number; +} + +/** + * Combined memory metrics snapshot + */ +export interface MemoryMetrics { + /** Timestamp of the measurement */ + timestamp: number; + /** Server-side memory metrics (Node.js) */ + server?: ServerMemoryMetrics; + /** Browser-side memory metrics */ + browser?: BrowserMemoryMetrics; +} + +/** + * Memory trend analysis for leak detection + */ +export interface MemoryTrend { + /** Average memory growth rate (bytes/second) */ + growthRate: number; + /** Indicates potential memory leak if growth is sustained */ + isLeaking: boolean; + /** Confidence level of leak detection (0-1) */ + confidence: number; + /** Number of samples used for trend analysis */ + sampleCount: number; + /** Duration of trend analysis window (ms) */ + windowDuration: number; +} + +// ============================================================================ +// CPU Metrics +// ============================================================================ + +/** + * CPU usage metrics from the server + */ +export interface ServerCPUMetrics { + /** CPU usage percentage (0-100) */ + percentage: number; + /** User CPU time (microseconds) */ + user: number; + /** System CPU time (microseconds) */ + system: number; +} + +/** + * Combined CPU metrics snapshot + */ +export interface CPUMetrics { + /** Timestamp of the measurement */ + timestamp: number; + /** Server CPU metrics */ + server?: ServerCPUMetrics; + /** Event loop lag in milliseconds (indicates event loop blocking) */ + eventLoopLag?: number; +} + +// ============================================================================ +// Agent Resource Metrics +// ============================================================================ + +/** + * File I/O operation type + */ +export type FileIOOperation = 'read' | 'write' | 'edit' | 'delete' | 'glob' | 'grep'; + +/** + * File I/O metrics for tracking agent file operations + */ +export interface FileIOMetrics { + /** Number of file read operations */ + reads: number; + /** Total bytes read */ + bytesRead: number; + /** Number of file write operations */ + writes: number; + /** Total bytes written */ + bytesWritten: number; + /** Number of file edit operations */ + edits: number; + /** Number of glob/search operations */ + globs: number; + /** Number of grep/content search operations */ + greps: number; + /** Files accessed (unique paths) */ + filesAccessed: string[]; +} + +/** + * Tool usage metrics for tracking agent tool invocations + */ +export interface ToolUsageMetrics { + /** Total tool invocations */ + totalInvocations: number; + /** Invocations per tool name */ + byTool: Record; + /** Average tool execution time (ms) */ + avgExecutionTime: number; + /** Total tool execution time (ms) */ + totalExecutionTime: number; + /** Failed tool invocations */ + failedInvocations: number; +} + +/** + * Bash command execution metrics + */ +export interface BashMetrics { + /** Number of bash commands executed */ + commandCount: number; + /** Total execution time (ms) */ + totalExecutionTime: number; + /** Number of failed commands (non-zero exit) */ + failedCommands: number; + /** Commands executed (for debugging) */ + commands: Array<{ + command: string; + exitCode: number | null; + duration: number; + timestamp: number; + }>; +} + +/** + * API call metrics for tracking Anthropic API usage + */ +export interface APIMetrics { + /** Number of API turns/iterations */ + turns: number; + /** Input tokens used (if available) */ + inputTokens?: number; + /** Output tokens generated (if available) */ + outputTokens?: number; + /** Thinking tokens used (if available) */ + thinkingTokens?: number; + /** Total API call duration (ms) */ + totalDuration: number; + /** Number of API errors */ + errors: number; +} + +/** + * Memory delta tracking for an agent execution + */ +export interface AgentMemoryMetrics { + /** Memory at agent start (bytes) */ + startHeapUsed: number; + /** Current/latest memory (bytes) */ + currentHeapUsed: number; + /** Peak memory during execution (bytes) */ + peakHeapUsed: number; + /** Memory change since start (can be negative) */ + deltaHeapUsed: number; + /** Memory samples over time for trend analysis */ + samples: Array<{ timestamp: number; heapUsed: number }>; +} + +/** + * Comprehensive agent resource metrics + */ +export interface AgentResourceMetrics { + /** Agent/process ID */ + agentId: string; + /** Session ID if available */ + sessionId?: string; + /** Feature ID if running a feature */ + featureId?: string; + /** When metrics collection started */ + startedAt: number; + /** When metrics were last updated */ + lastUpdatedAt: number; + /** Duration of agent execution (ms) */ + duration: number; + /** Memory metrics */ + memory: AgentMemoryMetrics; + /** File I/O metrics */ + fileIO: FileIOMetrics; + /** Tool usage metrics */ + tools: ToolUsageMetrics; + /** Bash command metrics */ + bash: BashMetrics; + /** API call metrics */ + api: APIMetrics; + /** Whether the agent is still running */ + isRunning: boolean; +} + +/** + * Create empty agent resource metrics + */ +export function createEmptyAgentResourceMetrics( + agentId: string, + options?: { sessionId?: string; featureId?: string } +): AgentResourceMetrics { + const now = Date.now(); + const heapUsed = typeof process !== 'undefined' ? process.memoryUsage().heapUsed : 0; + + return { + agentId, + sessionId: options?.sessionId, + featureId: options?.featureId, + startedAt: now, + lastUpdatedAt: now, + duration: 0, + isRunning: true, + memory: { + startHeapUsed: heapUsed, + currentHeapUsed: heapUsed, + peakHeapUsed: heapUsed, + deltaHeapUsed: 0, + samples: [{ timestamp: now, heapUsed }], + }, + fileIO: { + reads: 0, + bytesRead: 0, + writes: 0, + bytesWritten: 0, + edits: 0, + globs: 0, + greps: 0, + filesAccessed: [], + }, + tools: { + totalInvocations: 0, + byTool: {}, + avgExecutionTime: 0, + totalExecutionTime: 0, + failedInvocations: 0, + }, + bash: { + commandCount: 0, + totalExecutionTime: 0, + failedCommands: 0, + commands: [], + }, + api: { + turns: 0, + totalDuration: 0, + errors: 0, + }, + }; +} + +// ============================================================================ +// Process Tracking +// ============================================================================ + +/** + * Process type enumeration + */ +export type ProcessType = 'agent' | 'cli' | 'terminal' | 'worker'; + +/** + * Process status enumeration + */ +export type ProcessStatus = 'starting' | 'running' | 'idle' | 'stopping' | 'stopped' | 'error'; + +/** + * Information about a tracked process + */ +export interface TrackedProcess { + /** Unique identifier for the process */ + id: string; + /** Process ID from the operating system */ + pid: number; + /** Type of process */ + type: ProcessType; + /** Human-readable name/label */ + name: string; + /** Current status */ + status: ProcessStatus; + /** Timestamp when process was spawned */ + startedAt: number; + /** Timestamp when process stopped (if applicable) */ + stoppedAt?: number; + /** Memory usage in bytes (if available) */ + memoryUsage?: number; + /** CPU usage percentage (if available) */ + cpuUsage?: number; + /** Associated feature ID (for agent processes) */ + featureId?: string; + /** Associated session ID (for agent processes) */ + sessionId?: string; + /** Command that was executed */ + command?: string; + /** Working directory */ + cwd?: string; + /** Exit code (if process has stopped) */ + exitCode?: number; + /** Error message (if process failed) */ + error?: string; + /** Detailed resource metrics for agent processes */ + resourceMetrics?: AgentResourceMetrics; +} + +/** + * Summary of all tracked processes + */ +export interface ProcessSummary { + /** Total number of tracked processes */ + total: number; + /** Number of currently running processes */ + running: number; + /** Number of idle processes */ + idle: number; + /** Number of stopped processes */ + stopped: number; + /** Number of errored processes */ + errored: number; + /** Breakdown by process type */ + byType: Record; +} + +// ============================================================================ +// Render Tracking +// ============================================================================ + +/** + * Render phase from React Profiler + */ +export type RenderPhase = 'mount' | 'update' | 'nested-update'; + +/** + * Information about a component render + */ +export interface ComponentRender { + /** Component name/identifier */ + componentName: string; + /** Render phase */ + phase: RenderPhase; + /** Actual render duration (ms) */ + actualDuration: number; + /** Base render duration (ms) - time to render without memoization */ + baseDuration: number; + /** Start time of the render */ + startTime: number; + /** Commit time */ + commitTime: number; +} + +/** + * Aggregated render statistics for a component + */ +export interface ComponentRenderStats { + /** Component name */ + componentName: string; + /** Total number of renders in the tracking window */ + renderCount: number; + /** Renders per second */ + rendersPerSecond: number; + /** Average render duration (ms) */ + avgDuration: number; + /** Maximum render duration (ms) */ + maxDuration: number; + /** Minimum render duration (ms) */ + minDuration: number; + /** Total time spent rendering (ms) */ + totalDuration: number; + /** Whether this component exceeds the render threshold */ + isHighRender: boolean; + /** Last render timestamp */ + lastRenderAt: number; +} + +/** + * Render tracking summary + */ +export interface RenderTrackingSummary { + /** Total renders tracked */ + totalRenders: number; + /** Number of unique components tracked */ + uniqueComponents: number; + /** Components exceeding render threshold */ + highRenderComponents: string[]; + /** Top 5 most frequently rendered components */ + topRenderers: ComponentRenderStats[]; + /** Tracking window start time */ + windowStart: number; + /** Tracking window duration (ms) */ + windowDuration: number; +} + +// ============================================================================ +// Combined Metrics +// ============================================================================ + +/** + * Complete debug metrics snapshot + */ +export interface DebugMetricsSnapshot { + /** Timestamp of the snapshot */ + timestamp: number; + /** Memory metrics */ + memory: MemoryMetrics; + /** CPU metrics */ + cpu: CPUMetrics; + /** List of tracked processes */ + processes: TrackedProcess[]; + /** Process summary */ + processSummary: ProcessSummary; + /** Memory trend analysis */ + memoryTrend?: MemoryTrend; +} + +/** + * Debug metrics configuration + */ +export interface DebugMetricsConfig { + /** Enable memory monitoring */ + memoryEnabled: boolean; + /** Enable CPU monitoring */ + cpuEnabled: boolean; + /** Enable process tracking */ + processTrackingEnabled: boolean; + /** Metrics collection interval (ms) */ + collectionInterval: number; + /** Number of data points to retain */ + maxDataPoints: number; + /** Memory leak detection threshold (bytes/second sustained growth) */ + leakThreshold: number; +} + +/** + * Default debug metrics configuration + */ +export const DEFAULT_DEBUG_METRICS_CONFIG: DebugMetricsConfig = { + memoryEnabled: true, + cpuEnabled: true, + processTrackingEnabled: true, + collectionInterval: 1000, + maxDataPoints: 60, + leakThreshold: 1024 * 1024, // 1MB/second sustained growth indicates potential leak +}; + +// ============================================================================ +// Debug Events +// ============================================================================ + +/** + * Debug event types for real-time streaming + */ +export type DebugEventType = + | 'debug:metrics' + | 'debug:memory-warning' + | 'debug:memory-critical' + | 'debug:leak-detected' + | 'debug:process-spawned' + | 'debug:process-updated' + | 'debug:process-stopped' + | 'debug:process-error' + | 'debug:high-cpu' + | 'debug:event-loop-blocked'; + +/** + * Base debug event interface + */ +export interface DebugEventBase { + /** Event type */ + type: DebugEventType; + /** Event timestamp */ + timestamp: number; +} + +/** + * Metrics update event + */ +export interface DebugMetricsEvent extends DebugEventBase { + type: 'debug:metrics'; + /** The metrics snapshot */ + metrics: DebugMetricsSnapshot; +} + +/** + * Memory warning event (heap usage exceeds threshold) + */ +export interface DebugMemoryWarningEvent extends DebugEventBase { + type: 'debug:memory-warning' | 'debug:memory-critical'; + /** Current memory usage */ + memory: MemoryMetrics; + /** Usage percentage */ + usagePercent: number; + /** Threshold that was exceeded */ + threshold: number; + /** Warning message */ + message: string; +} + +/** + * Memory leak detected event + */ +export interface DebugLeakDetectedEvent extends DebugEventBase { + type: 'debug:leak-detected'; + /** Memory trend analysis */ + trend: MemoryTrend; + /** Warning message */ + message: string; +} + +/** + * Process lifecycle events + */ +export interface DebugProcessEvent extends DebugEventBase { + type: + | 'debug:process-spawned' + | 'debug:process-updated' + | 'debug:process-stopped' + | 'debug:process-error'; + /** Process information */ + process: TrackedProcess; + /** Additional message */ + message?: string; +} + +/** + * High CPU usage event + */ +export interface DebugHighCPUEvent extends DebugEventBase { + type: 'debug:high-cpu'; + /** CPU metrics */ + cpu: CPUMetrics; + /** Usage percentage */ + usagePercent: number; + /** Threshold that was exceeded */ + threshold: number; + /** Warning message */ + message: string; +} + +/** + * Event loop blocked event + */ +export interface DebugEventLoopBlockedEvent extends DebugEventBase { + type: 'debug:event-loop-blocked'; + /** Event loop lag in milliseconds */ + lag: number; + /** Threshold that was exceeded */ + threshold: number; + /** Warning message */ + message: string; +} + +/** + * Union type of all debug events + */ +export type DebugEvent = + | DebugMetricsEvent + | DebugMemoryWarningEvent + | DebugLeakDetectedEvent + | DebugProcessEvent + | DebugHighCPUEvent + | DebugEventLoopBlockedEvent; + +// ============================================================================ +// API Types +// ============================================================================ + +/** + * Request to start debug metrics collection + */ +export interface StartDebugMetricsRequest { + /** Configuration overrides */ + config?: Partial; +} + +/** + * Response from debug metrics endpoint + */ +export interface DebugMetricsResponse { + /** Whether metrics collection is active */ + active: boolean; + /** Current configuration */ + config: DebugMetricsConfig; + /** Latest metrics snapshot */ + snapshot?: DebugMetricsSnapshot; +} + +/** + * Request to get process list + */ +export interface GetProcessesRequest { + /** Filter by process type */ + type?: ProcessType; + /** Filter by status */ + status?: ProcessStatus; + /** Include stopped processes */ + includeStoppedProcesses?: boolean; +} + +/** + * Response from process list endpoint + */ +export interface GetProcessesResponse { + /** List of processes */ + processes: TrackedProcess[]; + /** Summary statistics */ + summary: ProcessSummary; +} + +// ============================================================================ +// Utility Types +// ============================================================================ + +/** + * Circular buffer entry for time-series data + */ +export interface TimeSeriesDataPoint { + /** Timestamp */ + timestamp: number; + /** Data value */ + value: T; +} + +/** + * Memory data point for charts + */ +export interface MemoryDataPoint { + timestamp: number; + heapUsed: number; + heapTotal: number; + rss?: number; +} + +/** + * CPU data point for charts + */ +export interface CPUDataPoint { + timestamp: number; + percentage: number; + eventLoopLag?: number; +} + +/** + * Format bytes to human-readable string + * @param bytes - Number of bytes (can be negative for rate display) + * @returns Formatted string (e.g., "1.5 MB") + */ +export function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const absBytes = Math.abs(bytes); + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(absBytes) / Math.log(k)); + const sign = bytes < 0 ? '-' : ''; + return `${sign}${parseFloat((absBytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; +} + +/** + * Format duration to human-readable string + * @param ms - Duration in milliseconds + * @returns Formatted string (e.g., "1.5s", "150ms") + */ +export function formatDuration(ms: number): string { + if (ms < 1) return `${(ms * 1000).toFixed(0)}µs`; + if (ms < 1000) return `${ms.toFixed(1)}ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; + return `${(ms / 60000).toFixed(1)}m`; +} + +/** + * Calculate percentage with bounds + * @param value - Current value + * @param total - Total/max value + * @returns Percentage (0-100) + */ +export function calculatePercentage(value: number, total: number): number { + if (total === 0) return 0; + return Math.min(100, Math.max(0, (value / total) * 100)); +} diff --git a/libs/types/src/event.ts b/libs/types/src/event.ts index 6692f0f0..0d0c73f6 100644 --- a/libs/types/src/event.ts +++ b/libs/types/src/event.ts @@ -39,6 +39,17 @@ export type EventType = | 'ideation:idea-created' | 'ideation:idea-updated' | 'ideation:idea-deleted' - | 'ideation:idea-converted'; + | 'ideation:idea-converted' + // Debug events + | 'debug:metrics' + | 'debug:memory-warning' + | 'debug:memory-critical' + | 'debug:leak-detected' + | 'debug:process-spawned' + | 'debug:process-updated' + | 'debug:process-stopped' + | 'debug:process-error' + | 'debug:high-cpu' + | 'debug:event-loop-blocked'; export type EventCallback = (type: EventType, payload: unknown) => void; diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index 57784b2a..4e059d97 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -194,3 +194,62 @@ export type { IdeationStreamEvent, IdeationAnalysisEvent, } from './ideation.js'; + +// Debug types +export type { + // Memory metrics + ServerMemoryMetrics, + BrowserMemoryMetrics, + MemoryMetrics, + MemoryTrend, + // CPU metrics + ServerCPUMetrics, + CPUMetrics, + // Agent resource metrics + FileIOOperation, + FileIOMetrics, + ToolUsageMetrics, + BashMetrics, + APIMetrics, + AgentMemoryMetrics, + AgentResourceMetrics, + // Process tracking + ProcessType, + ProcessStatus, + TrackedProcess, + ProcessSummary, + // Render tracking + RenderPhase, + ComponentRender, + ComponentRenderStats, + RenderTrackingSummary, + // Combined metrics + DebugMetricsSnapshot, + DebugMetricsConfig, + // Events + DebugEventType, + DebugEventBase, + DebugMetricsEvent, + DebugMemoryWarningEvent, + DebugLeakDetectedEvent, + DebugProcessEvent, + DebugHighCPUEvent, + DebugEventLoopBlockedEvent, + DebugEvent, + // API types + StartDebugMetricsRequest, + DebugMetricsResponse, + GetProcessesRequest, + GetProcessesResponse, + // Utility types + TimeSeriesDataPoint, + MemoryDataPoint, + CPUDataPoint, +} from './debug.js'; +export { + DEFAULT_DEBUG_METRICS_CONFIG, + formatBytes, + formatDuration, + calculatePercentage, + createEmptyAgentResourceMetrics, +} from './debug.js'; diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 0220da3a..f727fcde 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -212,6 +212,8 @@ export interface KeyboardShortcuts { splitTerminalDown: string; /** Close current terminal */ closeTerminal: string; + /** Toggle debug panel (dev only) */ + toggleDebugPanel: string; } /** @@ -638,6 +640,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = { splitTerminalRight: 'Alt+D', splitTerminalDown: 'Alt+S', closeTerminal: 'Alt+W', + toggleDebugPanel: 'Cmd+Shift+D', }; /** Default global settings used when no settings file exists */ diff --git a/libs/types/tests/debug.test.ts b/libs/types/tests/debug.test.ts new file mode 100644 index 00000000..61706010 --- /dev/null +++ b/libs/types/tests/debug.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from 'vitest'; +import { formatBytes, formatDuration, calculatePercentage } from '../src/debug'; + +describe('debug.ts utility functions', () => { + describe('formatBytes', () => { + it('should return "0 B" for zero bytes', () => { + expect(formatBytes(0)).toBe('0 B'); + }); + + it('should format bytes correctly', () => { + expect(formatBytes(1)).toBe('1 B'); + expect(formatBytes(500)).toBe('500 B'); + expect(formatBytes(1023)).toBe('1023 B'); + }); + + it('should format kilobytes correctly', () => { + expect(formatBytes(1024)).toBe('1 KB'); + expect(formatBytes(1536)).toBe('1.5 KB'); + expect(formatBytes(10240)).toBe('10 KB'); + }); + + it('should format megabytes correctly', () => { + expect(formatBytes(1024 * 1024)).toBe('1 MB'); + expect(formatBytes(1.5 * 1024 * 1024)).toBe('1.5 MB'); + expect(formatBytes(100 * 1024 * 1024)).toBe('100 MB'); + }); + + it('should format gigabytes correctly', () => { + expect(formatBytes(1024 * 1024 * 1024)).toBe('1 GB'); + expect(formatBytes(2.5 * 1024 * 1024 * 1024)).toBe('2.5 GB'); + }); + + it('should format terabytes correctly', () => { + expect(formatBytes(1024 * 1024 * 1024 * 1024)).toBe('1 TB'); + }); + + it('should handle negative values for rate display', () => { + expect(formatBytes(-1024)).toBe('-1 KB'); + expect(formatBytes(-1.5 * 1024 * 1024)).toBe('-1.5 MB'); + }); + + it('should round to 2 decimal places', () => { + expect(formatBytes(1536)).toBe('1.5 KB'); + expect(formatBytes(1537)).toBe('1.5 KB'); + expect(formatBytes(1024 + 512 + 256)).toBe('1.75 KB'); + }); + }); + + describe('formatDuration', () => { + it('should format microseconds for sub-millisecond values', () => { + expect(formatDuration(0.001)).toBe('1µs'); + expect(formatDuration(0.5)).toBe('500µs'); + expect(formatDuration(0.999)).toBe('999µs'); + }); + + it('should format milliseconds for values under 1 second', () => { + expect(formatDuration(1)).toBe('1.0ms'); + expect(formatDuration(100)).toBe('100.0ms'); + expect(formatDuration(999)).toBe('999.0ms'); + expect(formatDuration(500.5)).toBe('500.5ms'); + }); + + it('should format seconds for values under 1 minute', () => { + expect(formatDuration(1000)).toBe('1.0s'); + expect(formatDuration(1500)).toBe('1.5s'); + expect(formatDuration(59999)).toBe('60.0s'); + }); + + it('should format minutes for values >= 1 minute', () => { + expect(formatDuration(60000)).toBe('1.0m'); + expect(formatDuration(90000)).toBe('1.5m'); + expect(formatDuration(120000)).toBe('2.0m'); + }); + + it('should handle edge case of exactly 1 millisecond', () => { + expect(formatDuration(1)).toBe('1.0ms'); + }); + + it('should handle zero duration', () => { + expect(formatDuration(0)).toBe('0µs'); + }); + }); + + describe('calculatePercentage', () => { + it('should return 0 when total is 0', () => { + expect(calculatePercentage(50, 0)).toBe(0); + expect(calculatePercentage(0, 0)).toBe(0); + }); + + it('should calculate correct percentage', () => { + expect(calculatePercentage(50, 100)).toBe(50); + expect(calculatePercentage(25, 100)).toBe(25); + expect(calculatePercentage(75, 100)).toBe(75); + }); + + it('should handle decimal percentages', () => { + expect(calculatePercentage(1, 3)).toBeCloseTo(33.33, 1); + expect(calculatePercentage(1, 7)).toBeCloseTo(14.29, 1); + }); + + it('should cap at 100%', () => { + expect(calculatePercentage(150, 100)).toBe(100); + expect(calculatePercentage(200, 100)).toBe(100); + }); + + it('should floor at 0%', () => { + expect(calculatePercentage(-50, 100)).toBe(0); + expect(calculatePercentage(-100, 100)).toBe(0); + }); + + it('should handle very small values', () => { + expect(calculatePercentage(0.001, 100)).toBeCloseTo(0.001, 3); + }); + + it('should handle negative totals correctly', () => { + // With negative total, the result can be unexpected but should be bounded + const result = calculatePercentage(50, -100); + expect(result).toBeGreaterThanOrEqual(0); + expect(result).toBeLessThanOrEqual(100); + }); + }); +}); diff --git a/libs/types/vitest.config.ts b/libs/types/vitest.config.ts new file mode 100644 index 00000000..ca77238a --- /dev/null +++ b/libs/types/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + name: 'types', + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['src/**/*.ts'], + exclude: ['src/**/*.d.ts', 'src/index.ts'], + }, + }, +}); diff --git a/package-lock.json b/package-lock.json index 98ca8545..65226914 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,8 @@ "devDependencies": { "husky": "9.1.7", "lint-staged": "16.2.7", - "prettier": "3.7.4" + "prettier": "3.7.4", + "vitest": "4.0.16" }, "engines": { "node": ">=22.0.0 <23.0.0"