Compare commits

...

1 Commits

Author SHA1 Message Date
Kacper
78d08c2b5b feat: introduce debug panel for performance monitoring
- Added a debug panel to monitor server performance, including memory and CPU metrics.
- Implemented debug services for real-time tracking of processes and performance metrics.
- Created API endpoints for metrics collection and process management.
- Enhanced UI components for displaying metrics and process statuses.
- Updated documentation to include new debug API details.

This feature is intended for development use and can be toggled with the `ENABLE_DEBUG_PANEL` environment variable.
2026-01-05 18:59:09 +01:00
36 changed files with 8560 additions and 9 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<import('@automaker/types').DebugMetricsConfig>
): Partial<import('@automaker/types').DebugMetricsConfig> {
const sanitized: Partial<import('@automaker/types').DebugMetricsConfig> = {};
// 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',
});
};
}

View File

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

View File

@@ -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<T> {
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<MemoryDataPoint>;
private cpuHistory: CircularBuffer<CPUDataPoint>;
// 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<DebugMetricsConfig>) {
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<DebugMetricsConfig>): 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');
}
}

View File

@@ -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<string, TrackedProcess> = new Map();
private cleanupIntervalId: NodeJS.Timeout | null = null;
constructor(events: EventEmitter, config?: Partial<ProcessRegistryConfig>) {
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<ProcessRegistryConfig>): 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<ProcessRegistryConfig>
): 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;
}
}

View File

@@ -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<PerformanceMonitorService>;
let mockReq: Partial<Request>;
let mockRes: Partial<Response>;
let jsonFn: ReturnType<typeof vi.fn>;
let statusFn: ReturnType<typeof vi.fn>;
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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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',
});
});
});
});

View File

@@ -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<ProcessRegistryService>;
let mockReq: Partial<Request>;
let mockRes: Partial<Response>;
let jsonFn: ReturnType<typeof vi.fn>;
let statusFn: ReturnType<typeof vi.fn>;
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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(
customSummary
);
const handler = createGetSummaryHandler(mockProcessRegistry as ProcessRegistryService);
handler(mockReq as Request, mockRes as Response);
expect(jsonFn).toHaveBeenCalledWith(customSummary);
});
});
});

View File

@@ -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<DebugMetricsConfig> = {
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);
});
});
});

View File

@@ -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<typeof vi.fn>;
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);
});
});
});

View File

@@ -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 (
<div
className={cn(
'h-10 flex items-center justify-center text-muted-foreground text-xs',
className
)}
>
Collecting data...
</div>
);
}
return (
<svg viewBox="0 0 200 40" className={cn('w-full', className)} preserveAspectRatio="none">
<path
d={pathD}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-green-500"
/>
</svg>
);
}
/**
* CPU usage gauge
*/
function CPUGauge({ percentage }: { percentage: number }) {
const isHigh = percentage > 60;
const isCritical = percentage > 80;
return (
<div className="relative w-16 h-16">
{/* Background circle */}
<svg className="w-full h-full -rotate-90" viewBox="0 0 36 36">
<circle cx="18" cy="18" r="16" fill="none" strokeWidth="3" className="stroke-muted" />
<circle
cx="18"
cy="18"
r="16"
fill="none"
strokeWidth="3"
strokeDasharray={`${percentage} 100`}
strokeLinecap="round"
className={cn(
'transition-all duration-300',
isCritical ? 'stroke-red-500' : isHigh ? 'stroke-yellow-500' : 'stroke-green-500'
)}
/>
</svg>
{/* Center text */}
<div className="absolute inset-0 flex items-center justify-center">
<span
className={cn(
'text-sm font-mono font-bold',
isCritical ? 'text-red-400' : isHigh ? 'text-yellow-400' : 'text-green-400'
)}
>
{percentage.toFixed(0)}%
</span>
</div>
</div>
);
}
/**
* Event loop lag indicator
*/
function EventLoopLag({ lag }: { lag?: number }) {
if (lag === undefined) {
return null;
}
const isBlocked = lag > 50;
const isSevere = lag > 100;
return (
<div
className={cn(
'flex items-center gap-1.5 text-xs px-2 py-1 rounded',
isSevere && 'bg-red-500/20 text-red-400',
isBlocked && !isSevere && 'bg-yellow-500/20 text-yellow-400',
!isBlocked && 'bg-muted text-muted-foreground'
)}
>
{isSevere ? <AlertTriangle className="w-3 h-3" /> : <Activity className="w-3 h-3" />}
<span>Event Loop: {lag.toFixed(0)}ms</span>
</div>
);
}
export function CPUMonitor({ history, current, eventLoopLag, className }: CPUMonitorProps) {
const percentage = current?.percentage ?? 0;
return (
<div className={cn('space-y-3', className)}>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Cpu className="w-4 h-4 text-green-500" />
<span className="text-sm font-medium">CPU</span>
</div>
<EventLoopLag lag={eventLoopLag} />
</div>
{/* Main content */}
<div className="flex items-center gap-4">
{/* Gauge */}
<CPUGauge percentage={percentage} />
{/* Sparkline */}
<div className="flex-1 h-10">
<CPUSparkline data={history} />
</div>
</div>
{/* Details */}
{current && (
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<span className="text-muted-foreground">User: </span>
<span>{(current.user / 1000).toFixed(1)}ms</span>
</div>
<div>
<span className="text-muted-foreground">System: </span>
<span>{(current.system / 1000).toFixed(1)}ms</span>
</div>
</div>
)}
</div>
);
}

View File

@@ -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: <HardDrive className="w-3.5 h-3.5" /> },
{ id: 'cpu', label: 'CPU', icon: <Cpu className="w-3.5 h-3.5" /> },
{ id: 'processes', label: 'Processes', icon: <Bot className="w-3.5 h-3.5" /> },
{ id: 'renders', label: 'Renders', icon: <RefreshCw className="w-3.5 h-3.5" /> },
];
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<HTMLDivElement>(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 (
<div
ref={panelRef}
className={cn(
'flex flex-col bg-background border-t border-border',
isResizing && 'select-none',
className
)}
style={{ height: dockedHeight }}
>
{/* Resize handle - top edge */}
<div
className="h-1 cursor-ns-resize hover:bg-primary/20 transition-colors flex items-center justify-center group"
onMouseDown={handleResizeStart}
>
<GripHorizontal className="w-8 h-3 text-muted-foreground/30 group-hover:text-muted-foreground/60" />
</div>
{/* Tabs */}
<div className="flex items-center border-b bg-muted/30">
<div className="flex">
{TAB_CONFIG.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 text-xs border-b-2 -mb-px transition-colors',
activeTab === tab.id
? 'border-primary text-primary bg-background'
: 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50'
)}
>
{tab.icon}
<span>{tab.label}</span>
</button>
))}
</div>
{/* Right side controls */}
<div className="ml-auto flex items-center gap-1 px-2">
<button
onClick={() => (metrics.isActive ? metrics.stop() : metrics.start())}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
title={metrics.isActive ? 'Stop collecting' : 'Start collecting'}
>
{metrics.isActive ? (
<Pause className="w-3.5 h-3.5" />
) : (
<Play className="w-3.5 h-3.5" />
)}
</button>
<button
onClick={metrics.clearHistory}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
title="Clear history"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
<button
onClick={metrics.refresh}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
title="Refresh now"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-3">
{activeTab === 'memory' && (
<div className="space-y-4">
<MemoryMonitor
history={metrics.memoryHistory}
current={metrics.latestSnapshot?.memory.server ?? null}
trend={metrics.memoryTrend}
/>
<LeakIndicator trend={metrics.memoryTrend} onForceGC={metrics.forceGC} />
</div>
)}
{activeTab === 'cpu' && (
<CPUMonitor
history={metrics.cpuHistory}
current={metrics.latestSnapshot?.cpu.server ?? null}
eventLoopLag={metrics.latestSnapshot?.cpu.eventLoopLag}
/>
)}
{activeTab === 'processes' && (
<ProcessKanban
processes={metrics.processes}
summary={metrics.processSummary}
panelWidth={window.innerWidth} // Full width in docked mode
/>
)}
{activeTab === 'renders' && (
<RenderTracker
summary={renderTracking.summary}
stats={renderTracking.getAllStats()}
onClear={renderTracking.clearRecords}
/>
)}
</div>
</div>
);
}
/**
* 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 <DebugDockedPanel className={className} />;
}

View File

@@ -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: <HardDrive className="w-4 h-4" /> },
{ id: 'cpu', label: 'CPU', icon: <Cpu className="w-4 h-4" /> },
{ id: 'processes', label: 'Processes', icon: <Bot className="w-4 h-4" /> },
{ id: 'renders', label: 'Renders', icon: <RefreshCw className="w-4 h-4" /> },
];
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<HTMLDivElement>(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 (
<div
ref={panelRef}
className={cn(
'fixed z-[9999] bg-background/95 backdrop-blur-sm border rounded-lg shadow-xl',
'flex flex-col overflow-hidden',
isDragging && 'cursor-grabbing select-none',
isResizing && 'cursor-nwse-resize select-none',
className
)}
style={{
left: pos.x,
top: pos.y,
width: isMinimized ? 200 : size.width,
height: isMinimized ? 'auto' : size.height,
}}
>
{/* Header - Draggable */}
<div
className={cn(
'flex items-center justify-between px-3 py-2 border-b bg-muted/50',
'cursor-grab select-none',
isDragging && 'cursor-grabbing'
)}
onMouseDown={handleDragStart}
>
<div className="flex items-center gap-2">
<Bug className="w-4 h-4 text-purple-500" />
<span className="text-sm font-medium">Debug</span>
{metrics.isActive && (
<span
className="w-2 h-2 rounded-full bg-green-500 animate-pulse"
title="Collecting metrics"
/>
)}
{/* Dock to bottom */}
<button
onClick={togglePanelMode}
className="p-1 rounded hover:bg-muted"
title="Dock to bottom"
>
<PanelBottom className="w-4 h-4" />
</button>
</div>
<div className="flex items-center gap-1">
{/* Toggle collection */}
<button
onClick={() => (metrics.isActive ? metrics.stop() : metrics.start())}
className="p-1 rounded hover:bg-muted"
title={metrics.isActive ? 'Stop collecting' : 'Start collecting'}
>
{metrics.isActive ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
</button>
{/* Minimize */}
<button
onClick={toggleMinimized}
className="p-1 rounded hover:bg-muted"
title={isMinimized ? 'Expand' : 'Minimize'}
>
{isMinimized ? <Maximize2 className="w-4 h-4" /> : <Minimize2 className="w-4 h-4" />}
</button>
{/* Close */}
<button
onClick={() => setOpen(false)}
className="p-1 rounded hover:bg-muted hover:text-red-400"
title="Close"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* Minimized state - just show quick stats */}
{isMinimized ? (
<div className="p-2 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">Heap:</span>
<span>
{metrics.latestSnapshot?.memory.server
? `${(metrics.latestSnapshot.memory.server.heapUsed / 1024 / 1024).toFixed(0)}MB`
: '-'}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">CPU:</span>
<span>
{metrics.latestSnapshot?.cpu.server
? `${metrics.latestSnapshot.cpu.server.percentage.toFixed(0)}%`
: '-'}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Processes:</span>
<span>{metrics.processSummary?.running ?? 0}</span>
</div>
</div>
) : (
<>
{/* Tabs */}
<div className="flex border-b">
{TAB_CONFIG.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
'flex items-center gap-1.5 px-3 py-2 text-xs border-b-2 -mb-px transition-colors',
activeTab === tab.id
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted'
)}
>
{tab.icon}
<span>{tab.label}</span>
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-3">
{activeTab === 'memory' && (
<div className="space-y-4">
<MemoryMonitor
history={metrics.memoryHistory}
current={metrics.latestSnapshot?.memory.server ?? null}
trend={metrics.memoryTrend}
/>
<LeakIndicator trend={metrics.memoryTrend} onForceGC={metrics.forceGC} />
</div>
)}
{activeTab === 'cpu' && (
<CPUMonitor
history={metrics.cpuHistory}
current={metrics.latestSnapshot?.cpu.server ?? null}
eventLoopLag={metrics.latestSnapshot?.cpu.eventLoopLag}
/>
)}
{activeTab === 'processes' && (
<ProcessKanban
processes={metrics.processes}
summary={metrics.processSummary}
panelWidth={size.width}
/>
)}
{activeTab === 'renders' && (
<RenderTracker
summary={renderTracking.summary}
stats={renderTracking.getAllStats()}
onClear={renderTracking.clearRecords}
/>
)}
</div>
{/* Footer with actions */}
<div className="flex items-center justify-between px-3 py-1.5 border-t bg-muted/30 text-xs text-muted-foreground">
<span>
{metrics.isLoading
? 'Loading...'
: metrics.error
? `Error: ${metrics.error}`
: `Updated ${new Date().toLocaleTimeString()}`}
</span>
<div className="flex items-center gap-2">
<button
onClick={metrics.clearHistory}
className="flex items-center gap-1 hover:text-foreground"
title="Clear history"
>
<Trash2 className="w-3 h-3" />
</button>
<button
onClick={metrics.refresh}
className="flex items-center gap-1 hover:text-foreground"
title="Refresh now"
>
<RefreshCw className="w-3 h-3" />
</button>
</div>
</div>
{/* Resize handle - bottom right corner */}
<div
className="absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize flex items-center justify-center hover:bg-muted/50 rounded-tl"
onMouseDown={handleResizeStart}
title="Drag to resize"
>
<GripHorizontal className="w-3 h-3 rotate-[-45deg] text-muted-foreground/50" />
</div>
</>
)}
</div>
);
}
/**
* 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 <DebugPanel />;
}

View File

@@ -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 (
<button
onClick={onClick}
className={cn(
'flex items-center gap-1.5 px-2 py-0.5 text-xs hover:bg-muted/50 rounded transition-colors',
className
)}
>
{icon}
<span className="text-muted-foreground">{label}:</span>
<span className="font-medium">{value}</span>
</button>
);
});
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 (
<div
className={cn(
'flex items-center justify-between h-6 px-2 bg-muted/50 border-t border-border text-xs',
'select-none',
className
)}
>
{/* Left side - Debug label and quick stats */}
<div className="flex items-center gap-1">
{/* Debug label with status indicator */}
<button
onClick={toggleDockedExpanded}
className="flex items-center gap-1.5 px-2 py-0.5 hover:bg-muted rounded transition-colors"
>
<Bug className="w-3.5 h-3.5 text-purple-500" />
<span className="font-medium">Debug</span>
{metrics.isActive && (
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
)}
<ChevronUp
className={cn(
'w-3 h-3 text-muted-foreground transition-transform',
isDockedExpanded && 'rotate-180'
)}
/>
</button>
<div className="w-px h-4 bg-border mx-1" />
{/* Quick stats */}
<QuickStat
icon={<HardDrive className="w-3 h-3 text-blue-400" />}
label="Heap"
value={formatBytes(heapUsed)}
onClick={() => {
setActiveTab('memory');
if (!isDockedExpanded) toggleDockedExpanded();
}}
/>
<QuickStat
icon={<Cpu className="w-3 h-3 text-yellow-400" />}
label="CPU"
value={`${cpuPercent.toFixed(0)}%`}
onClick={() => {
setActiveTab('cpu');
if (!isDockedExpanded) toggleDockedExpanded();
}}
/>
<QuickStat
icon={<Bot className="w-3 h-3 text-purple-400" />}
label="Processes"
value={String(processCount)}
onClick={() => {
setActiveTab('processes');
if (!isDockedExpanded) toggleDockedExpanded();
}}
/>
</div>
{/* Right side - Actions */}
<div className="flex items-center gap-1">
{/* Toggle to floating mode */}
<button
onClick={togglePanelMode}
className="p-1 hover:bg-muted rounded transition-colors"
title="Switch to floating mode"
>
<Maximize2 className="w-3 h-3 text-muted-foreground" />
</button>
{/* Close debug panel */}
<button
onClick={() => setOpen(false)}
className="p-1 hover:bg-muted hover:text-red-400 rounded transition-colors"
title="Close debug panel (Ctrl+Shift+D)"
>
<X className="w-3 h-3" />
</button>
</div>
</div>
);
}
/**
* 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 <DebugStatusBar className={className} />;
}

View File

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

View File

@@ -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 (
<div className={cn('p-3 bg-muted/30 rounded-lg text-xs text-muted-foreground', className)}>
<div className="flex items-center gap-2">
<Info className="w-4 h-4" />
<span>Collecting memory data for leak analysis...</span>
</div>
</div>
);
}
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 (
<div className={cn('p-3 bg-red-500/10 border border-red-500/30 rounded-lg', className)}>
<div className="flex items-start gap-2">
<AlertTriangle className="w-5 h-5 text-red-400 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="font-medium text-red-400 text-sm">Memory Leak Detected</div>
<div className="text-xs text-muted-foreground mt-1 space-y-1">
<div className="flex items-center gap-1">
<TrendingUp className="w-3 h-3" />
<span>Growing at {growthPerSecond}/s</span>
</div>
<div>Confidence: {confidencePercent}%</div>
<div>Samples: {trend.sampleCount}</div>
</div>
<div className="mt-2 text-xs text-red-300">
Memory is consistently growing without garbage collection. This may indicate detached
DOM nodes, event listener leaks, or objects held in closures.
</div>
{onForceGC && (
<button
onClick={onForceGC}
className="mt-2 px-3 py-1 text-xs bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded transition-colors"
>
Force GC
</button>
)}
</div>
</div>
</div>
);
}
if (isGrowing) {
return (
<div className={cn('p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg', className)}>
<div className="flex items-start gap-2">
<TrendingUp className="w-5 h-5 text-yellow-400 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="font-medium text-yellow-400 text-sm">Memory Growing</div>
<div className="text-xs text-muted-foreground mt-1 space-y-1">
<div>Rate: {growthPerSecond}/s</div>
<div>Confidence: {confidencePercent}%</div>
</div>
<div className="mt-2 text-xs text-yellow-300">
Memory is growing but not yet at leak threshold. Monitor for sustained growth.
</div>
</div>
</div>
</div>
);
}
// Healthy state
return (
<div className={cn('p-3 bg-green-500/10 border border-green-500/30 rounded-lg', className)}>
<div className="flex items-center gap-2">
<Info className="w-4 h-4 text-green-400" />
<div>
<div className="font-medium text-green-400 text-sm">Memory Stable</div>
<div className="text-xs text-muted-foreground mt-0.5">
No memory leak patterns detected ({trend.sampleCount} samples)
</div>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<div
className={cn(
'h-10 flex items-center justify-center text-muted-foreground text-xs',
className
)}
>
Collecting data...
</div>
);
}
return (
<svg
viewBox={`0 0 ${width} ${height}`}
className={cn('w-full', className)}
preserveAspectRatio="none"
>
<path
d={pathD}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-blue-500"
/>
</svg>
);
});
/**
* Label with optional tooltip - Memoized
*/
const MetricLabel = memo(function MetricLabel({
label,
tooltip,
}: {
label: string;
tooltip?: string;
}) {
if (!tooltip) {
return <span className="text-muted-foreground">{label}</span>;
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground cursor-help inline-flex items-center gap-1">
{label}
<HelpCircle className="w-3 h-3 opacity-50" />
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[250px] z-[10000]">
{tooltip}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
});
/**
* 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 (
<div className="space-y-1">
<div className="flex justify-between text-xs">
<MetricLabel label={label} tooltip={tooltip} />
<span
className={cn(isCritical && 'text-red-400', isHigh && !isCritical && 'text-yellow-400')}
>
{formatBytes(used)} / {formatBytes(total)}
</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn(
'h-full transition-all duration-300 rounded-full',
isCritical ? 'bg-red-500' : isHigh ? 'bg-yellow-500' : 'bg-blue-500'
)}
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
</div>
</div>
);
});
/**
* 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 (
<div
className={cn(
'flex items-center gap-1 text-xs px-2 py-0.5 rounded-full',
trend.isLeaking && 'bg-red-500/20 text-red-400',
isGrowing && !trend.isLeaking && 'bg-yellow-500/20 text-yellow-400',
isShrinking && 'bg-green-500/20 text-green-400',
isStable && !trend.isLeaking && 'bg-muted text-muted-foreground'
)}
>
{trend.isLeaking ? (
<>
<TrendingUp className="w-3 h-3" />
<span>Leak detected</span>
</>
) : isGrowing ? (
<>
<TrendingUp className="w-3 h-3" />
<span>{formatBytes(Math.abs(trend.growthRate))}/s</span>
</>
) : isShrinking ? (
<>
<TrendingDown className="w-3 h-3" />
<span>{formatBytes(Math.abs(trend.growthRate))}/s</span>
</>
) : (
<>
<Minus className="w-3 h-3" />
<span>Stable</span>
</>
)}
</div>
);
});
export function MemoryMonitor({ history, current, trend, className }: MemoryMonitorProps) {
return (
<div className={cn('space-y-3', className)}>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<HardDrive className="w-4 h-4 text-blue-500" />
<span className="text-sm font-medium">Memory</span>
</div>
<TrendIndicator trend={trend} />
</div>
{/* Current values */}
{current ? (
<div className="space-y-3">
{/* Heap with integrated sparkline */}
<div className="space-y-1">
<div className="flex justify-between text-xs">
<MetricLabel label="Heap" tooltip={METRIC_TOOLTIPS.heap} />
<span
className={cn(
(current.heapUsed / current.heapTotal) * 100 > 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)}
</span>
</div>
{/* Sparkline chart for heap history */}
<div className="h-8 bg-muted/30 rounded overflow-hidden">
<MemorySparkline data={history} className="h-full" />
</div>
</div>
{/* RSS bar */}
<MemoryBar
used={current.rss}
total={current.heapTotal * 1.5}
label="RSS"
tooltip={METRIC_TOOLTIPS.rss}
/>
{/* Additional metrics with tooltips */}
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center gap-1">
<MetricLabel label="External:" tooltip={METRIC_TOOLTIPS.external} />
<span className="ml-auto">{formatBytes(current.external)}</span>
</div>
<div className="flex items-center gap-1">
<MetricLabel label="Buffers:" tooltip={METRIC_TOOLTIPS.arrayBuffers} />
<span className="ml-auto">{formatBytes(current.arrayBuffers)}</span>
</div>
</div>
</div>
) : (
<div className="text-center text-xs text-muted-foreground py-2">No data available</div>
)}
</div>
);
}

View File

@@ -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 <Bot className="w-3.5 h-3.5" />;
case 'terminal':
return <Terminal className="w-3.5 h-3.5" />;
case 'cli':
return <Terminal className="w-3.5 h-3.5" />;
case 'worker':
return <Cpu className="w-3.5 h-3.5" />;
default:
return <Circle className="w-3.5 h-3.5" />;
}
}
/**
* Get status indicator
*/
function getStatusIndicator(status: ProcessStatus) {
switch (status) {
case 'running':
return <Play className="w-3 h-3 text-green-400" />;
case 'starting':
return <Circle className="w-3 h-3 text-blue-400 animate-pulse" />;
case 'idle':
return <Pause className="w-3 h-3 text-yellow-400" />;
case 'stopping':
return <Circle className="w-3 h-3 text-orange-400 animate-pulse" />;
case 'stopped':
return <CheckCircle2 className="w-3 h-3 text-muted-foreground" />;
case 'error':
return <AlertCircle className="w-3 h-3 text-red-400" />;
default:
return <Circle className="w-3 h-3" />;
}
}
/**
* Resource metrics display component for agent processes
*/
const ResourceMetrics = memo(function ResourceMetrics({
metrics,
}: {
metrics: AgentResourceMetrics;
}) {
return (
<div className="mt-1.5 pt-1.5 border-t border-border/50 space-y-1">
{/* File I/O */}
<div className="flex items-center gap-1 text-muted-foreground">
<FileText className="w-3 h-3" />
<span>Files:</span>
<span className="ml-auto">
{metrics.fileIO.reads}R / {metrics.fileIO.writes}W / {metrics.fileIO.edits}E
</span>
</div>
{/* Bytes transferred */}
{(metrics.fileIO.bytesRead > 0 || metrics.fileIO.bytesWritten > 0) && (
<div className="flex items-center gap-1 text-muted-foreground">
<HardDrive className="w-3 h-3" />
<span>I/O:</span>
<span className="ml-auto">
{formatBytes(metrics.fileIO.bytesRead)} read /{' '}
{formatBytes(metrics.fileIO.bytesWritten)} written
</span>
</div>
)}
{/* Tool usage */}
<div className="flex items-center gap-1 text-muted-foreground">
<Hammer className="w-3 h-3" />
<span>Tools:</span>
<span className="ml-auto">{metrics.tools.totalInvocations} calls</span>
</div>
{/* API turns */}
{metrics.api.turns > 0 && (
<div className="flex items-center gap-1 text-muted-foreground">
<Activity className="w-3 h-3" />
<span>API:</span>
<span className="ml-auto">{metrics.api.turns} turns</span>
</div>
)}
{/* Bash commands */}
{metrics.bash.commandCount > 0 && (
<div className="flex items-center gap-1 text-muted-foreground">
<Terminal className="w-3 h-3" />
<span>Bash:</span>
<span className="ml-auto">
{metrics.bash.commandCount} cmds
{metrics.bash.failedCommands > 0 && (
<span className="text-red-400 ml-1">({metrics.bash.failedCommands} failed)</span>
)}
</span>
</div>
)}
{/* Memory delta */}
{metrics.memory.deltaHeapUsed !== 0 && (
<div className="flex items-center gap-1 text-muted-foreground">
<Cpu className="w-3 h-3" />
<span>Mem delta:</span>
<span
className={cn(
'ml-auto',
metrics.memory.deltaHeapUsed > 0 ? 'text-orange-400' : 'text-green-400'
)}
>
{metrics.memory.deltaHeapUsed > 0 ? '+' : ''}
{formatBytes(metrics.memory.deltaHeapUsed)}
</span>
</div>
)}
</div>
);
});
/**
* 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 (
<div
className={cn(
'p-2 rounded-md border text-xs',
isError && 'border-red-500/50 bg-red-500/10',
isActive && !isError && 'border-green-500/50 bg-green-500/10',
!isActive && !isError && 'border-border bg-muted/30'
)}
>
{/* Header */}
<div
className={cn('flex items-center gap-1.5 mb-1', hasMetrics && 'cursor-pointer')}
onClick={() => hasMetrics && setExpanded(!expanded)}
>
{hasMetrics &&
(expanded ? (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
))}
{getProcessIcon(process.type)}
<span className="font-medium truncate flex-1">{process.name}</span>
{getStatusIndicator(process.status)}
</div>
{/* Basic Details */}
<div className="space-y-0.5 text-muted-foreground">
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>{formatDuration(runtime)}</span>
{hasMetrics && (
<span className="ml-auto text-purple-400">
{process.resourceMetrics!.tools.totalInvocations} tools
</span>
)}
</div>
{process.memoryUsage !== undefined && (
<div className="flex justify-between">
<span>Memory:</span>
<span>{formatBytes(process.memoryUsage)}</span>
</div>
)}
{process.cpuUsage !== undefined && (
<div className="flex justify-between">
<span>CPU:</span>
<span>{process.cpuUsage.toFixed(1)}%</span>
</div>
)}
{process.error && (
<div className="text-red-400 mt-1 truncate" title={process.error}>
{process.error}
</div>
)}
</div>
{/* Expanded resource metrics */}
{hasMetrics && expanded && <ResourceMetrics metrics={process.resourceMetrics!} />}
</div>
);
});
/**
* 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 (
<div className="flex-1 min-w-0">
{/* Column header */}
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium">{title}</span>
<span className={cn('text-xs px-1.5 py-0.5 rounded', colorClass)}>{count}</span>
</div>
{/* Cards */}
<div className="space-y-1.5">
{processes.length > 0 ? (
processes.map((process) => <ProcessCard key={process.id} process={process} />)
) : (
<div className="text-xs text-muted-foreground text-center py-2">No processes</div>
)}
</div>
</div>
);
});
/** 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 (
<div className={cn('space-y-3', className)}>
{/* Header with summary */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Bot className="w-4 h-4 text-purple-500" />
<span className="text-sm font-medium">Processes</span>
</div>
{summary && (
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">Total: {summary.total}</span>
{summary.byType.agent > 0 && (
<span className="text-purple-400">{summary.byType.agent} agents</span>
)}
{summary.byType.terminal > 0 && (
<span className="text-blue-400">{summary.byType.terminal} terminals</span>
)}
</div>
)}
</div>
{/* Kanban board - 2x2 grid when narrow, 4-column when wide */}
<div className={cn('grid gap-2', isNarrow ? 'grid-cols-2' : 'grid-cols-4')}>
<ProcessColumn
title="Active"
processes={grouped.active}
count={summary?.running ?? grouped.active.length}
colorClass="bg-green-500/20 text-green-400"
/>
<ProcessColumn
title="Idle"
processes={grouped.idle}
count={summary?.idle ?? grouped.idle.length}
colorClass="bg-yellow-500/20 text-yellow-400"
/>
<ProcessColumn
title="Stopped"
processes={grouped.stopped}
count={summary?.stopped ?? grouped.stopped.length}
colorClass="bg-muted text-muted-foreground"
/>
<ProcessColumn
title="Error"
processes={grouped.errored}
count={summary?.errored ?? grouped.errored.length}
colorClass="bg-red-500/20 text-red-400"
/>
</div>
</div>
);
}

View File

@@ -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
* <RenderProfiler name="BoardView">
* <BoardView />
* </RenderProfiler>
* ```
*/
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<RenderTrackingContextType | null>(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 (
<RenderTrackingContext.Provider value={renderTracking}>
{children}
</RenderTrackingContext.Provider>
);
}
/**
* 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 (
<Profiler id={name} onRender={onRender}>
{children}
</Profiler>
);
}
/**
* Higher-order component version of RenderProfiler
*
* Usage:
* ```tsx
* const ProfiledComponent = withRenderProfiler(MyComponent, 'MyComponent');
* ```
*/
export function withRenderProfiler<P extends object>(
WrappedComponent: React.ComponentType<P>,
name: string
): React.FC<P> {
const ProfiledComponent: React.FC<P> = (props) => (
<RenderProfiler name={name}>
<WrappedComponent {...props} />
</RenderProfiler>
);
ProfiledComponent.displayName = `RenderProfiler(${name})`;
return ProfiledComponent;
}

View File

@@ -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 (
<div
className={cn(
'flex items-center gap-2 p-2 rounded text-xs',
stats.isHighRender ? 'bg-red-500/10 border border-red-500/30' : 'bg-muted/30'
)}
>
{/* Component name */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
{stats.isHighRender && <AlertTriangle className="w-3 h-3 text-red-400 shrink-0" />}
<span className={cn('font-medium truncate', stats.isHighRender && 'text-red-400')}>
{stats.componentName}
</span>
</div>
</div>
{/* Stats */}
<div className="flex items-center gap-3 text-muted-foreground shrink-0">
<div className="flex items-center gap-1" title="Render count">
<RefreshCw className="w-3 h-3" />
<span>{stats.renderCount}</span>
</div>
<div className="flex items-center gap-1" title="Renders per second">
<TrendingUp className="w-3 h-3" />
<span className={cn(stats.isHighRender && 'text-red-400')}>
{stats.rendersPerSecond.toFixed(1)}/s
</span>
</div>
<div className="flex items-center gap-1" title="Average duration">
<Clock className="w-3 h-3" />
<span>{formatDuration(stats.avgDuration)}</span>
</div>
</div>
</div>
);
}
/**
* Summary stats
*/
function SummaryStats({ summary }: { summary: RenderTrackingSummary }) {
return (
<div className="grid grid-cols-3 gap-2 text-center">
<div className="p-2 bg-muted/30 rounded">
<div className="text-lg font-bold">{summary.totalRenders}</div>
<div className="text-xs text-muted-foreground">Total Renders</div>
</div>
<div className="p-2 bg-muted/30 rounded">
<div className="text-lg font-bold">{summary.uniqueComponents}</div>
<div className="text-xs text-muted-foreground">Components</div>
</div>
<div
className={cn(
'p-2 rounded',
summary.highRenderComponents.length > 0 ? 'bg-red-500/20' : 'bg-muted/30'
)}
>
<div
className={cn(
'text-lg font-bold',
summary.highRenderComponents.length > 0 && 'text-red-400'
)}
>
{summary.highRenderComponents.length}
</div>
<div className="text-xs text-muted-foreground">High Render</div>
</div>
</div>
);
}
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 (
<div className={cn('space-y-3', className)}>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<RefreshCw className="w-4 h-4 text-orange-500" />
<span className="text-sm font-medium">Render Tracker</span>
</div>
{onClear && (
<button
onClick={onClear}
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1 rounded hover:bg-muted"
>
Clear
</button>
)}
</div>
{/* Summary */}
<SummaryStats summary={summary} />
{/* High render warnings */}
{summary.highRenderComponents.length > 0 && (
<div className="p-2 bg-red-500/10 border border-red-500/30 rounded text-xs">
<div className="flex items-center gap-1 text-red-400 font-medium mb-1">
<AlertTriangle className="w-3 h-3" />
<span>High render rate detected</span>
</div>
<div className="text-muted-foreground">{summary.highRenderComponents.join(', ')}</div>
</div>
)}
{/* Component list */}
<div className="space-y-1 max-h-[200px] overflow-y-auto">
{topStats.length > 0 ? (
topStats.map((s) => <ComponentStatsRow key={s.componentName} stats={s} />)
) : (
<div className="text-center text-xs text-muted-foreground py-4">
<p>No render data yet.</p>
<p className="mt-1">Wrap components with RenderProfiler to track renders.</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -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<void>;
/** Stop metrics collection */
stop: () => Promise<void>;
/** Force garbage collection (if available) */
forceGC: () => Promise<{ success: boolean; message: string }>;
/** Clear history */
clearHistory: () => Promise<void>;
/** Refresh metrics immediately */
refresh: () => Promise<void>;
}
/**
* 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<DebugMetricsState>({
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<MemoryDataPoint[]>([]);
const cpuHistoryRef = useRef<CPUDataPoint[]>([]);
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
/**
* Fetch metrics from server
*/
const fetchMetrics = useCallback(async () => {
try {
const response = await apiGet<DebugMetricsResponse>('/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<DebugMetricsResponse>('/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<DebugMetricsResponse>('/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,
};
}

View File

@@ -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<Map<string, ComponentRender[]>>(new Map());
// Store computed stats
const [stats, setStats] = useState<Map<string, ComponentRenderStats>>(new Map());
const [summary, setSummary] = useState<RenderTrackingSummary>({
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<string, ComponentRenderStats>();
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<typeof useRenderTracking>;

View File

@@ -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"
/>
)}
<Sidebar />
<RenderProfiler name="Sidebar">
<Sidebar />
</RenderProfiler>
<div
className="flex-1 flex flex-col overflow-hidden transition-all duration-300"
style={{ marginRight: streamerPanelOpen ? '250px' : '0' }}
>
<Outlet />
{/* Main content area */}
<div className="flex-1 flex flex-col min-h-0">
<RenderProfiler name="MainContent">
<Outlet />
</RenderProfiler>
</div>
{/* Docked Debug Panel - expands above status bar */}
<DebugDockedPanelWrapper />
{/* Docked Debug Status Bar - VS Code style footer */}
<DebugStatusBarWrapper />
</div>
{/* Hidden streamer panel - opens with "\" key, pushes content */}
@@ -410,6 +451,9 @@ function RootLayoutContent() {
/>
<Toaster richColors position="bottom-right" />
{/* Floating Debug Panel - alternative mode */}
<DebugPanelWrapper />
{/* Show sandbox dialog if needed */}
<SandboxRiskDialog
open={sandboxStatus === 'needs-confirmation'}
@@ -421,9 +465,18 @@ function RootLayoutContent() {
}
function RootLayout() {
// Check if dev mode for render tracking
const isDev = import.meta.env.DEV || import.meta.env.VITE_ENABLE_DEBUG_PANEL === 'true';
return (
<FileBrowserProvider>
<RootLayoutContent />
{isDev ? (
<RenderTrackingProvider>
<RootLayoutContent />
</RenderTrackingProvider>
) : (
<RootLayoutContent />
)}
</FileBrowserProvider>
);
}

View File

@@ -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 (
<RenderProfiler name="BoardView">
<BoardView />
</RenderProfiler>
);
}
export const Route = createFileRoute('/board')({
component: BoardView,
component: ProfiledBoardView,
});

View File

@@ -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<DebugPanelPreferences>) => 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<DebugState & DebugActions>()(
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;

726
docs/server/debug-api.md Normal file
View File

@@ -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<string, number>; // 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)}%`);
}
```

View File

@@ -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"
}
}

711
libs/types/src/debug.ts Normal file
View File

@@ -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<string, number>;
/** 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<ProcessType, number>;
}
// ============================================================================
// 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<DebugMetricsConfig>;
}
/**
* 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<T> {
/** 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));
}

View File

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

View File

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

View File

@@ -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 */

View File

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

View File

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

3
package-lock.json generated
View File

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