mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 14:22:02 +00:00
Compare commits
1 Commits
c848306e4c
...
feat/debug
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78d08c2b5b |
41
CLAUDE.md
41
CLAUDE.md
@@ -170,3 +170,44 @@ Use `resolveModelString()` from `@automaker/model-resolver` to convert model ali
|
||||
- `DATA_DIR` - Data storage directory (default: ./data)
|
||||
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
|
||||
- `AUTOMAKER_MOCK_AGENT=true` - Enable mock agent mode for CI testing
|
||||
- `ENABLE_DEBUG_PANEL=true` - Enable debug panel in non-development builds
|
||||
|
||||
## Debug System (Development Only)
|
||||
|
||||
The debug system provides real-time monitoring of server performance. Toggle with `Cmd/Ctrl+Shift+D`.
|
||||
|
||||
### Debug Services (`apps/server/src/services/`)
|
||||
|
||||
- `PerformanceMonitorService` - Collects memory/CPU metrics, detects leaks via linear regression
|
||||
- `ProcessRegistryService` - Tracks spawned agents, terminals, CLIs with lifecycle events
|
||||
|
||||
### Debug API (`apps/server/src/routes/debug/`)
|
||||
|
||||
- `GET /api/debug/metrics` - Current metrics snapshot
|
||||
- `POST /api/debug/metrics/start` - Start collection with optional config
|
||||
- `POST /api/debug/metrics/stop` - Stop collection
|
||||
- `GET /api/debug/processes` - List tracked processes with filters
|
||||
|
||||
### Debug Types (`libs/types/src/debug.ts`)
|
||||
|
||||
All debug types are exported from `@automaker/types`:
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
DebugMetricsSnapshot,
|
||||
TrackedProcess,
|
||||
MemoryTrend,
|
||||
ProcessSummary,
|
||||
} from '@automaker/types';
|
||||
import { formatBytes, formatDuration } from '@automaker/types';
|
||||
```
|
||||
|
||||
### UI Components (`apps/ui/src/components/debug/`)
|
||||
|
||||
- `DebugPanel` - Main draggable container with tabs
|
||||
- `MemoryMonitor` - Heap usage charts and leak indicators
|
||||
- `CPUMonitor` - CPU gauge and event loop lag display
|
||||
- `ProcessKanban` - Visual board of tracked processes
|
||||
- `RenderTracker` - React component render statistics
|
||||
|
||||
See `docs/server/debug-api.md` for full API documentation.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
332
apps/server/src/routes/debug/index.ts
Normal file
332
apps/server/src/routes/debug/index.ts
Normal 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';
|
||||
152
apps/server/src/routes/debug/routes/metrics.ts
Normal file
152
apps/server/src/routes/debug/routes/metrics.ts
Normal 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',
|
||||
});
|
||||
};
|
||||
}
|
||||
170
apps/server/src/routes/debug/routes/processes.ts
Normal file
170
apps/server/src/routes/debug/routes/processes.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
673
apps/server/src/services/performance-monitor-service.ts
Normal file
673
apps/server/src/services/performance-monitor-service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
982
apps/server/src/services/process-registry-service.ts
Normal file
982
apps/server/src/services/process-registry-service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
318
apps/server/tests/unit/routes/debug/metrics.test.ts
Normal file
318
apps/server/tests/unit/routes/debug/metrics.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
293
apps/server/tests/unit/routes/debug/processes.test.ts
Normal file
293
apps/server/tests/unit/routes/debug/processes.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
538
apps/server/tests/unit/services/process-registry-service.test.ts
Normal file
538
apps/server/tests/unit/services/process-registry-service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
175
apps/ui/src/components/debug/cpu-monitor.tsx
Normal file
175
apps/ui/src/components/debug/cpu-monitor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
229
apps/ui/src/components/debug/debug-docked-panel.tsx
Normal file
229
apps/ui/src/components/debug/debug-docked-panel.tsx
Normal 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} />;
|
||||
}
|
||||
427
apps/ui/src/components/debug/debug-panel.tsx
Normal file
427
apps/ui/src/components/debug/debug-panel.tsx
Normal 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 />;
|
||||
}
|
||||
171
apps/ui/src/components/debug/debug-status-bar.tsx
Normal file
171
apps/ui/src/components/debug/debug-status-bar.tsx
Normal 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} />;
|
||||
}
|
||||
26
apps/ui/src/components/debug/index.ts
Normal file
26
apps/ui/src/components/debug/index.ts
Normal 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';
|
||||
102
apps/ui/src/components/debug/leak-indicator.tsx
Normal file
102
apps/ui/src/components/debug/leak-indicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
276
apps/ui/src/components/debug/memory-monitor.tsx
Normal file
276
apps/ui/src/components/debug/memory-monitor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
364
apps/ui/src/components/debug/process-kanban.tsx
Normal file
364
apps/ui/src/components/debug/process-kanban.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
apps/ui/src/components/debug/render-profiler.tsx
Normal file
110
apps/ui/src/components/debug/render-profiler.tsx
Normal 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;
|
||||
}
|
||||
145
apps/ui/src/components/debug/render-tracker.tsx
Normal file
145
apps/ui/src/components/debug/render-tracker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
317
apps/ui/src/hooks/use-debug-metrics.ts
Normal file
317
apps/ui/src/hooks/use-debug-metrics.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
237
apps/ui/src/hooks/use-render-tracking.ts
Normal file
237
apps/ui/src/hooks/use-render-tracking.ts
Normal 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>;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
312
apps/ui/src/store/debug-store.ts
Normal file
312
apps/ui/src/store/debug-store.ts
Normal 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
726
docs/server/debug-api.md
Normal 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)}%`);
|
||||
}
|
||||
```
|
||||
@@ -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
711
libs/types/src/debug.ts
Normal 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));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 */
|
||||
|
||||
122
libs/types/tests/debug.test.ts
Normal file
122
libs/types/tests/debug.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
16
libs/types/vitest.config.ts
Normal file
16
libs/types/vitest.config.ts
Normal 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
3
package-lock.json
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user