mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 20:03:37 +00:00
Compare commits
1 Commits
v0.13.0
...
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)
|
- `DATA_DIR` - Data storage directory (default: ./data)
|
||||||
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
|
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
|
||||||
- `AUTOMAKER_MOCK_AGENT=true` - Enable mock agent mode for CI testing
|
- `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
|
- `VITE_SKIP_ELECTRON` - Skip Electron in dev mode
|
||||||
- `OPEN_DEVTOOLS` - Auto-open DevTools in Electron
|
- `OPEN_DEVTOOLS` - Auto-open DevTools in Electron
|
||||||
|
- `ENABLE_DEBUG_PANEL` - Enable the debug panel in non-development builds (for staging environments)
|
||||||
|
|
||||||
### Authentication Setup
|
### 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
|
- 🎨 **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)
|
- 🖥️ **Cross-Platform** - Desktop app for macOS (x64, arm64), Windows (x64), and Linux (x64)
|
||||||
- 🌐 **Web Mode** - Run in browser or as Electron desktop app
|
- 🌐 **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
|
### Advanced Features
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,12 @@ import { createPipelineRoutes } from './routes/pipeline/index.js';
|
|||||||
import { pipelineService } from './services/pipeline-service.js';
|
import { pipelineService } from './services/pipeline-service.js';
|
||||||
import { createIdeationRoutes } from './routes/ideation/index.js';
|
import { createIdeationRoutes } from './routes/ideation/index.js';
|
||||||
import { IdeationService } from './services/ideation-service.js';
|
import { IdeationService } from './services/ideation-service.js';
|
||||||
|
import {
|
||||||
|
createDebugRoutes,
|
||||||
|
createDebugServices,
|
||||||
|
stopDebugServices,
|
||||||
|
type DebugServices,
|
||||||
|
} from './routes/debug/index.js';
|
||||||
|
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
@@ -70,6 +76,8 @@ dotenv.config();
|
|||||||
const PORT = parseInt(process.env.PORT || '3008', 10);
|
const PORT = parseInt(process.env.PORT || '3008', 10);
|
||||||
const DATA_DIR = process.env.DATA_DIR || './data';
|
const DATA_DIR = process.env.DATA_DIR || './data';
|
||||||
const ENABLE_REQUEST_LOGGING = process.env.ENABLE_REQUEST_LOGGING !== 'false'; // Default to true
|
const ENABLE_REQUEST_LOGGING = 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
|
// Check for required environment variables
|
||||||
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
|
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
|
||||||
@@ -169,6 +177,13 @@ const claudeUsageService = new ClaudeUsageService();
|
|||||||
const mcpTestService = new MCPTestService(settingsService);
|
const mcpTestService = new MCPTestService(settingsService);
|
||||||
const ideationService = new IdeationService(events, settingsService, featureLoader);
|
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
|
// Initialize services
|
||||||
(async () => {
|
(async () => {
|
||||||
await agentService.initialize();
|
await agentService.initialize();
|
||||||
@@ -223,6 +238,12 @@ app.use('/api/mcp', createMCPRoutes(mcpTestService));
|
|||||||
app.use('/api/pipeline', createPipelineRoutes(pipelineService));
|
app.use('/api/pipeline', createPipelineRoutes(pipelineService));
|
||||||
app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader));
|
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
|
// Create HTTP server
|
||||||
const server = createServer(app);
|
const server = createServer(app);
|
||||||
|
|
||||||
@@ -588,6 +609,9 @@ startServer(PORT);
|
|||||||
process.on('SIGTERM', () => {
|
process.on('SIGTERM', () => {
|
||||||
logger.info('SIGTERM received, shutting down...');
|
logger.info('SIGTERM received, shutting down...');
|
||||||
terminalService.cleanup();
|
terminalService.cleanup();
|
||||||
|
if (debugServices) {
|
||||||
|
stopDebugServices(debugServices);
|
||||||
|
}
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
logger.info('Server closed');
|
logger.info('Server closed');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@@ -597,6 +621,9 @@ process.on('SIGTERM', () => {
|
|||||||
process.on('SIGINT', () => {
|
process.on('SIGINT', () => {
|
||||||
logger.info('SIGINT received, shutting down...');
|
logger.info('SIGINT received, shutting down...');
|
||||||
terminalService.cleanup();
|
terminalService.cleanup();
|
||||||
|
if (debugServices) {
|
||||||
|
stopDebugServices(debugServices);
|
||||||
|
}
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
logger.info('Server closed');
|
logger.info('Server closed');
|
||||||
process.exit(0);
|
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 { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog';
|
||||||
import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen';
|
import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen';
|
||||||
import { LoadingState } from '@/components/ui/loading-state';
|
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');
|
const logger = createLogger('RootLayout');
|
||||||
|
|
||||||
@@ -46,6 +54,7 @@ function RootLayoutContent() {
|
|||||||
const authChecked = useAuthStore((s) => s.authChecked);
|
const authChecked = useAuthStore((s) => s.authChecked);
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
const { openFileBrowser } = useFileBrowser();
|
const { openFileBrowser } = useFileBrowser();
|
||||||
|
const toggleDebugPanel = useDebugStore((s) => s.togglePanel);
|
||||||
|
|
||||||
const isSetupRoute = location.pathname === '/setup';
|
const isSetupRoute = location.pathname === '/setup';
|
||||||
const isLoginRoute = location.pathname === '/login';
|
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(() => {
|
useEffect(() => {
|
||||||
window.addEventListener('keydown', handleStreamerPanelShortcut);
|
window.addEventListener('keydown', handleStreamerPanelShortcut);
|
||||||
|
window.addEventListener('keydown', handleDebugPanelShortcut);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', handleStreamerPanelShortcut);
|
window.removeEventListener('keydown', handleStreamerPanelShortcut);
|
||||||
|
window.removeEventListener('keydown', handleDebugPanelShortcut);
|
||||||
};
|
};
|
||||||
}, [handleStreamerPanelShortcut]);
|
}, [handleStreamerPanelShortcut, handleDebugPanelShortcut]);
|
||||||
|
|
||||||
const effectiveTheme = getEffectiveTheme();
|
const effectiveTheme = getEffectiveTheme();
|
||||||
// Defer the theme value to keep UI responsive during rapid hover changes
|
// Defer the theme value to keep UI responsive during rapid hover changes
|
||||||
@@ -394,12 +422,25 @@ function RootLayoutContent() {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Sidebar />
|
<RenderProfiler name="Sidebar">
|
||||||
|
<Sidebar />
|
||||||
|
</RenderProfiler>
|
||||||
<div
|
<div
|
||||||
className="flex-1 flex flex-col overflow-hidden transition-all duration-300"
|
className="flex-1 flex flex-col overflow-hidden transition-all duration-300"
|
||||||
style={{ marginRight: streamerPanelOpen ? '250px' : '0' }}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Hidden streamer panel - opens with "\" key, pushes content */}
|
{/* Hidden streamer panel - opens with "\" key, pushes content */}
|
||||||
@@ -410,6 +451,9 @@ function RootLayoutContent() {
|
|||||||
/>
|
/>
|
||||||
<Toaster richColors position="bottom-right" />
|
<Toaster richColors position="bottom-right" />
|
||||||
|
|
||||||
|
{/* Floating Debug Panel - alternative mode */}
|
||||||
|
<DebugPanelWrapper />
|
||||||
|
|
||||||
{/* Show sandbox dialog if needed */}
|
{/* Show sandbox dialog if needed */}
|
||||||
<SandboxRiskDialog
|
<SandboxRiskDialog
|
||||||
open={sandboxStatus === 'needs-confirmation'}
|
open={sandboxStatus === 'needs-confirmation'}
|
||||||
@@ -421,9 +465,18 @@ function RootLayoutContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
|
// Check if dev mode for render tracking
|
||||||
|
const isDev = import.meta.env.DEV || import.meta.env.VITE_ENABLE_DEBUG_PANEL === 'true';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FileBrowserProvider>
|
<FileBrowserProvider>
|
||||||
<RootLayoutContent />
|
{isDev ? (
|
||||||
|
<RenderTrackingProvider>
|
||||||
|
<RootLayoutContent />
|
||||||
|
</RenderTrackingProvider>
|
||||||
|
) : (
|
||||||
|
<RootLayoutContent />
|
||||||
|
)}
|
||||||
</FileBrowserProvider>
|
</FileBrowserProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { BoardView } from '@/components/views/board-view';
|
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')({
|
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",
|
"types": "dist/index.d.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"watch": "tsc --watch"
|
"watch": "tsc --watch",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"automaker",
|
"automaker",
|
||||||
@@ -20,6 +22,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "22.19.3",
|
"@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-created'
|
||||||
| 'ideation:idea-updated'
|
| 'ideation:idea-updated'
|
||||||
| 'ideation:idea-deleted'
|
| '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;
|
export type EventCallback = (type: EventType, payload: unknown) => void;
|
||||||
|
|||||||
@@ -194,3 +194,62 @@ export type {
|
|||||||
IdeationStreamEvent,
|
IdeationStreamEvent,
|
||||||
IdeationAnalysisEvent,
|
IdeationAnalysisEvent,
|
||||||
} from './ideation.js';
|
} 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;
|
splitTerminalDown: string;
|
||||||
/** Close current terminal */
|
/** Close current terminal */
|
||||||
closeTerminal: string;
|
closeTerminal: string;
|
||||||
|
/** Toggle debug panel (dev only) */
|
||||||
|
toggleDebugPanel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -638,6 +640,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
|
|||||||
splitTerminalRight: 'Alt+D',
|
splitTerminalRight: 'Alt+D',
|
||||||
splitTerminalDown: 'Alt+S',
|
splitTerminalDown: 'Alt+S',
|
||||||
closeTerminal: 'Alt+W',
|
closeTerminal: 'Alt+W',
|
||||||
|
toggleDebugPanel: 'Cmd+Shift+D',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Default global settings used when no settings file exists */
|
/** 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": {
|
"devDependencies": {
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "16.2.7",
|
"lint-staged": "16.2.7",
|
||||||
"prettier": "3.7.4"
|
"prettier": "3.7.4",
|
||||||
|
"vitest": "4.0.16"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.0.0 <23.0.0"
|
"node": ">=22.0.0 <23.0.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user