mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +00:00
feat: introduce debug panel for performance monitoring
- Added a debug panel to monitor server performance, including memory and CPU metrics. - Implemented debug services for real-time tracking of processes and performance metrics. - Created API endpoints for metrics collection and process management. - Enhanced UI components for displaying metrics and process statuses. - Updated documentation to include new debug API details. This feature is intended for development use and can be toggled with the `ENABLE_DEBUG_PANEL` environment variable.
This commit is contained in:
@@ -63,6 +63,12 @@ import { createPipelineRoutes } from './routes/pipeline/index.js';
|
||||
import { pipelineService } from './services/pipeline-service.js';
|
||||
import { createIdeationRoutes } from './routes/ideation/index.js';
|
||||
import { IdeationService } from './services/ideation-service.js';
|
||||
import {
|
||||
createDebugRoutes,
|
||||
createDebugServices,
|
||||
stopDebugServices,
|
||||
type DebugServices,
|
||||
} from './routes/debug/index.js';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
@@ -70,6 +76,8 @@ dotenv.config();
|
||||
const PORT = parseInt(process.env.PORT || '3008', 10);
|
||||
const DATA_DIR = process.env.DATA_DIR || './data';
|
||||
const ENABLE_REQUEST_LOGGING = process.env.ENABLE_REQUEST_LOGGING !== 'false'; // Default to true
|
||||
const ENABLE_DEBUG_PANEL =
|
||||
process.env.NODE_ENV !== 'production' || process.env.ENABLE_DEBUG_PANEL === 'true';
|
||||
|
||||
// Check for required environment variables
|
||||
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
|
||||
@@ -169,6 +177,13 @@ const claudeUsageService = new ClaudeUsageService();
|
||||
const mcpTestService = new MCPTestService(settingsService);
|
||||
const ideationService = new IdeationService(events, settingsService, featureLoader);
|
||||
|
||||
// Create debug services (dev mode only)
|
||||
let debugServices: DebugServices | null = null;
|
||||
if (ENABLE_DEBUG_PANEL) {
|
||||
debugServices = createDebugServices(events);
|
||||
logger.info('Debug services enabled');
|
||||
}
|
||||
|
||||
// Initialize services
|
||||
(async () => {
|
||||
await agentService.initialize();
|
||||
@@ -223,6 +238,12 @@ app.use('/api/mcp', createMCPRoutes(mcpTestService));
|
||||
app.use('/api/pipeline', createPipelineRoutes(pipelineService));
|
||||
app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader));
|
||||
|
||||
// Debug routes (dev mode only)
|
||||
if (debugServices) {
|
||||
app.use('/api/debug', createDebugRoutes(debugServices));
|
||||
logger.info('Debug API routes mounted at /api/debug');
|
||||
}
|
||||
|
||||
// Create HTTP server
|
||||
const server = createServer(app);
|
||||
|
||||
@@ -588,6 +609,9 @@ startServer(PORT);
|
||||
process.on('SIGTERM', () => {
|
||||
logger.info('SIGTERM received, shutting down...');
|
||||
terminalService.cleanup();
|
||||
if (debugServices) {
|
||||
stopDebugServices(debugServices);
|
||||
}
|
||||
server.close(() => {
|
||||
logger.info('Server closed');
|
||||
process.exit(0);
|
||||
@@ -597,6 +621,9 @@ process.on('SIGTERM', () => {
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('SIGINT received, shutting down...');
|
||||
terminalService.cleanup();
|
||||
if (debugServices) {
|
||||
stopDebugServices(debugServices);
|
||||
}
|
||||
server.close(() => {
|
||||
logger.info('Server closed');
|
||||
process.exit(0);
|
||||
|
||||
332
apps/server/src/routes/debug/index.ts
Normal file
332
apps/server/src/routes/debug/index.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* Debug routes - HTTP API for debug panel and performance monitoring
|
||||
*
|
||||
* These routes are only enabled in development mode.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { PerformanceMonitorService } from '../../services/performance-monitor-service.js';
|
||||
import { ProcessRegistryService } from '../../services/process-registry-service.js';
|
||||
import {
|
||||
createGetMetricsHandler,
|
||||
createStartMetricsHandler,
|
||||
createStopMetricsHandler,
|
||||
createForceGCHandler,
|
||||
createClearHistoryHandler,
|
||||
} from './routes/metrics.js';
|
||||
import {
|
||||
createGetProcessesHandler,
|
||||
createGetProcessHandler,
|
||||
createGetSummaryHandler,
|
||||
createGetAgentsHandler,
|
||||
createGetAgentMetricsHandler,
|
||||
createGetAgentSummaryHandler,
|
||||
} from './routes/processes.js';
|
||||
|
||||
export interface DebugServices {
|
||||
performanceMonitor: PerformanceMonitorService;
|
||||
processRegistry: ProcessRegistryService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and initialize debug services
|
||||
*/
|
||||
export function createDebugServices(events: EventEmitter): DebugServices {
|
||||
// Create services
|
||||
const processRegistry = new ProcessRegistryService(events);
|
||||
const performanceMonitor = new PerformanceMonitorService(events);
|
||||
|
||||
// Wire them together - performance monitor gets processes from registry
|
||||
performanceMonitor.setProcessProvider(processRegistry.getProcessProvider());
|
||||
|
||||
// Subscribe to AutoMode events to track feature execution as processes
|
||||
// Events are wrapped in 'auto-mode:event' with the actual type in data.type
|
||||
events.subscribe((eventType, data) => {
|
||||
// Handle auto-mode:event
|
||||
if (eventType === 'auto-mode:event') {
|
||||
handleAutoModeEvent(processRegistry, data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle agent:stream events for chat sessions
|
||||
if (eventType === 'agent:stream') {
|
||||
handleAgentStreamEvent(processRegistry, data);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Handle AutoMode events for feature execution tracking
|
||||
*/
|
||||
function handleAutoModeEvent(registry: ProcessRegistryService, data: unknown): void {
|
||||
const eventData = data as { type?: string; [key: string]: unknown };
|
||||
const innerType = eventData.type;
|
||||
|
||||
if (innerType === 'auto_mode_feature_start') {
|
||||
const { featureId, projectPath, feature, model } = eventData as {
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
feature?: { id: string; title: string; description?: string };
|
||||
model?: string;
|
||||
};
|
||||
|
||||
// Register the feature as a tracked process
|
||||
// Use -1 for pid since this isn't a real OS process
|
||||
registry.registerProcess({
|
||||
id: `agent-${featureId}`,
|
||||
pid: -1,
|
||||
type: 'agent',
|
||||
name: feature?.title || `Feature ${featureId}`,
|
||||
featureId,
|
||||
cwd: projectPath,
|
||||
command: model ? `claude ${model}` : 'claude agent',
|
||||
});
|
||||
|
||||
// Initialize resource metrics
|
||||
registry.initializeAgentMetrics(`agent-${featureId}`, { featureId });
|
||||
|
||||
// Mark it as running
|
||||
registry.markRunning(`agent-${featureId}`);
|
||||
} else if (innerType === 'auto_mode_feature_complete') {
|
||||
const { featureId, passes, message } = eventData as {
|
||||
featureId: string;
|
||||
passes: boolean;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
const processId = `agent-${featureId}`;
|
||||
if (registry.hasProcess(processId)) {
|
||||
// Finalize the metrics before marking as stopped
|
||||
registry.finalizeAgentMetrics(processId);
|
||||
|
||||
if (passes) {
|
||||
registry.markStopped(processId, 0);
|
||||
} else {
|
||||
registry.markError(processId, message || 'Feature failed');
|
||||
}
|
||||
}
|
||||
} else if (innerType === 'auto_mode_error') {
|
||||
const { featureId, error } = eventData as {
|
||||
featureId?: string;
|
||||
error: string;
|
||||
};
|
||||
|
||||
if (featureId) {
|
||||
const processId = `agent-${featureId}`;
|
||||
if (registry.hasProcess(processId)) {
|
||||
registry.finalizeAgentMetrics(processId);
|
||||
registry.markError(processId, error);
|
||||
}
|
||||
}
|
||||
} else if (innerType === 'auto_mode_tool_use') {
|
||||
// Track tool usage for the feature
|
||||
const { featureId, tool } = eventData as {
|
||||
featureId: string;
|
||||
tool: { name: string; input?: unknown };
|
||||
};
|
||||
|
||||
const processId = `agent-${featureId}`;
|
||||
if (registry.hasProcess(processId)) {
|
||||
registry.recordToolUse(processId, { toolName: tool.name });
|
||||
|
||||
// Record file operations based on tool type
|
||||
if (tool.name === 'Read' && tool.input) {
|
||||
const input = tool.input as { file_path?: string };
|
||||
if (input.file_path) {
|
||||
registry.recordFileOperation(processId, {
|
||||
operation: 'read',
|
||||
filePath: input.file_path,
|
||||
});
|
||||
}
|
||||
} else if (tool.name === 'Write' && tool.input) {
|
||||
const input = tool.input as { file_path?: string; content?: string };
|
||||
if (input.file_path) {
|
||||
registry.recordFileOperation(processId, {
|
||||
operation: 'write',
|
||||
filePath: input.file_path,
|
||||
bytes: input.content?.length,
|
||||
});
|
||||
}
|
||||
} else if (tool.name === 'Edit' && tool.input) {
|
||||
const input = tool.input as { file_path?: string; new_string?: string };
|
||||
if (input.file_path) {
|
||||
registry.recordFileOperation(processId, {
|
||||
operation: 'edit',
|
||||
filePath: input.file_path,
|
||||
bytes: input.new_string?.length,
|
||||
});
|
||||
}
|
||||
} else if (tool.name === 'Glob') {
|
||||
const input = tool.input as { path?: string };
|
||||
registry.recordFileOperation(processId, {
|
||||
operation: 'glob',
|
||||
filePath: input?.path || '.',
|
||||
});
|
||||
} else if (tool.name === 'Grep') {
|
||||
const input = tool.input as { path?: string };
|
||||
registry.recordFileOperation(processId, {
|
||||
operation: 'grep',
|
||||
filePath: input?.path || '.',
|
||||
});
|
||||
} else if (tool.name === 'Bash' && tool.input) {
|
||||
const input = tool.input as { command?: string };
|
||||
if (input.command) {
|
||||
registry.recordBashCommand(processId, {
|
||||
command: input.command,
|
||||
executionTime: 0, // Will be updated on completion
|
||||
exitCode: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle agent:stream events for chat session tracking
|
||||
*/
|
||||
function handleAgentStreamEvent(registry: ProcessRegistryService, data: unknown): void {
|
||||
const eventData = data as {
|
||||
sessionId?: string;
|
||||
type?: string;
|
||||
tool?: { name: string; input?: unknown };
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
const { sessionId, type } = eventData;
|
||||
if (!sessionId) return;
|
||||
|
||||
const processId = `chat-${sessionId}`;
|
||||
|
||||
// Register chat session as a process if not already tracked
|
||||
if (!registry.hasProcess(processId) && type !== 'complete' && type !== 'error') {
|
||||
registry.registerProcess({
|
||||
id: processId,
|
||||
pid: -1,
|
||||
type: 'agent',
|
||||
name: `Chat Session`,
|
||||
sessionId,
|
||||
command: 'claude chat',
|
||||
});
|
||||
registry.initializeAgentMetrics(processId, { sessionId });
|
||||
registry.markRunning(processId);
|
||||
}
|
||||
|
||||
// Handle different event types
|
||||
if (type === 'tool_use' && eventData.tool) {
|
||||
const tool = eventData.tool;
|
||||
registry.recordToolUse(processId, { toolName: tool.name });
|
||||
|
||||
// Record file operations based on tool type
|
||||
if (tool.name === 'Read' && tool.input) {
|
||||
const input = tool.input as { file_path?: string };
|
||||
if (input.file_path) {
|
||||
registry.recordFileOperation(processId, {
|
||||
operation: 'read',
|
||||
filePath: input.file_path,
|
||||
});
|
||||
}
|
||||
} else if (tool.name === 'Write' && tool.input) {
|
||||
const input = tool.input as { file_path?: string; content?: string };
|
||||
if (input.file_path) {
|
||||
registry.recordFileOperation(processId, {
|
||||
operation: 'write',
|
||||
filePath: input.file_path,
|
||||
bytes: input.content?.length,
|
||||
});
|
||||
}
|
||||
} else if (tool.name === 'Edit' && tool.input) {
|
||||
const input = tool.input as { file_path?: string; new_string?: string };
|
||||
if (input.file_path) {
|
||||
registry.recordFileOperation(processId, {
|
||||
operation: 'edit',
|
||||
filePath: input.file_path,
|
||||
bytes: input.new_string?.length,
|
||||
});
|
||||
}
|
||||
} else if (tool.name === 'Glob') {
|
||||
const input = tool.input as { path?: string };
|
||||
registry.recordFileOperation(processId, {
|
||||
operation: 'glob',
|
||||
filePath: input?.path || '.',
|
||||
});
|
||||
} else if (tool.name === 'Grep') {
|
||||
const input = tool.input as { path?: string };
|
||||
registry.recordFileOperation(processId, {
|
||||
operation: 'grep',
|
||||
filePath: input?.path || '.',
|
||||
});
|
||||
} else if (tool.name === 'Bash' && tool.input) {
|
||||
const input = tool.input as { command?: string };
|
||||
if (input.command) {
|
||||
registry.recordBashCommand(processId, {
|
||||
command: input.command,
|
||||
executionTime: 0,
|
||||
exitCode: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (type === 'complete') {
|
||||
if (registry.hasProcess(processId)) {
|
||||
registry.finalizeAgentMetrics(processId);
|
||||
// Keep the session as "idle" rather than "stopped" since it can receive more messages
|
||||
registry.markIdle(processId);
|
||||
}
|
||||
} else if (type === 'error') {
|
||||
if (registry.hasProcess(processId)) {
|
||||
registry.finalizeAgentMetrics(processId);
|
||||
const errorMsg = (eventData.error as string) || 'Unknown error';
|
||||
registry.markError(processId, errorMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start services
|
||||
processRegistry.start();
|
||||
performanceMonitor.start();
|
||||
|
||||
return {
|
||||
performanceMonitor,
|
||||
processRegistry,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop debug services
|
||||
*/
|
||||
export function stopDebugServices(services: DebugServices): void {
|
||||
services.performanceMonitor.stop();
|
||||
services.processRegistry.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create debug routes
|
||||
*/
|
||||
export function createDebugRoutes(services: DebugServices): Router {
|
||||
const router = Router();
|
||||
const { performanceMonitor, processRegistry } = services;
|
||||
|
||||
// Metrics routes
|
||||
router.get('/metrics', createGetMetricsHandler(performanceMonitor));
|
||||
router.post('/metrics/start', createStartMetricsHandler(performanceMonitor));
|
||||
router.post('/metrics/stop', createStopMetricsHandler(performanceMonitor));
|
||||
router.post('/metrics/gc', createForceGCHandler(performanceMonitor));
|
||||
router.post('/metrics/clear', createClearHistoryHandler(performanceMonitor));
|
||||
|
||||
// Process routes
|
||||
router.get('/processes', createGetProcessesHandler(processRegistry));
|
||||
router.get('/processes/summary', createGetSummaryHandler(processRegistry));
|
||||
router.get('/processes/:id', createGetProcessHandler(processRegistry));
|
||||
|
||||
// Agent resource metrics routes
|
||||
router.get('/agents', createGetAgentsHandler(processRegistry));
|
||||
router.get('/agents/summary', createGetAgentSummaryHandler(processRegistry));
|
||||
router.get('/agents/:id/metrics', createGetAgentMetricsHandler(processRegistry));
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
// Re-export services for use elsewhere
|
||||
export { PerformanceMonitorService } from '../../services/performance-monitor-service.js';
|
||||
export { ProcessRegistryService } from '../../services/process-registry-service.js';
|
||||
152
apps/server/src/routes/debug/routes/metrics.ts
Normal file
152
apps/server/src/routes/debug/routes/metrics.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Debug metrics route handler
|
||||
*
|
||||
* GET /api/debug/metrics - Get current metrics snapshot
|
||||
* POST /api/debug/metrics/start - Start metrics collection
|
||||
* POST /api/debug/metrics/stop - Stop metrics collection
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { PerformanceMonitorService } from '../../../services/performance-monitor-service.js';
|
||||
import type { StartDebugMetricsRequest, DebugMetricsResponse } from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Create handler for GET /api/debug/metrics
|
||||
* Returns current metrics snapshot
|
||||
*/
|
||||
export function createGetMetricsHandler(performanceMonitor: PerformanceMonitorService) {
|
||||
return (_req: Request, res: Response) => {
|
||||
const snapshot = performanceMonitor.getLatestSnapshot();
|
||||
const config = performanceMonitor.getConfig();
|
||||
const active = performanceMonitor.isActive();
|
||||
|
||||
const response: DebugMetricsResponse = {
|
||||
active,
|
||||
config,
|
||||
snapshot: snapshot ?? undefined,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and sanitize debug metrics config values
|
||||
* Prevents DoS via extreme configuration values
|
||||
*/
|
||||
function sanitizeConfig(
|
||||
config: Partial<import('@automaker/types').DebugMetricsConfig>
|
||||
): Partial<import('@automaker/types').DebugMetricsConfig> {
|
||||
const sanitized: Partial<import('@automaker/types').DebugMetricsConfig> = {};
|
||||
|
||||
// Collection interval: min 100ms, max 60s (prevents CPU exhaustion)
|
||||
if (typeof config.collectionInterval === 'number') {
|
||||
sanitized.collectionInterval = Math.min(
|
||||
60000,
|
||||
Math.max(100, Math.floor(config.collectionInterval))
|
||||
);
|
||||
}
|
||||
|
||||
// Max data points: min 10, max 10000 (prevents memory exhaustion)
|
||||
if (typeof config.maxDataPoints === 'number') {
|
||||
sanitized.maxDataPoints = Math.min(10000, Math.max(10, Math.floor(config.maxDataPoints)));
|
||||
}
|
||||
|
||||
// Leak threshold: min 1KB, max 100MB (reasonable bounds)
|
||||
if (typeof config.leakThreshold === 'number') {
|
||||
sanitized.leakThreshold = Math.min(
|
||||
100 * 1024 * 1024,
|
||||
Math.max(1024, Math.floor(config.leakThreshold))
|
||||
);
|
||||
}
|
||||
|
||||
// Boolean flags - only accept actual booleans
|
||||
if (typeof config.memoryEnabled === 'boolean') {
|
||||
sanitized.memoryEnabled = config.memoryEnabled;
|
||||
}
|
||||
if (typeof config.cpuEnabled === 'boolean') {
|
||||
sanitized.cpuEnabled = config.cpuEnabled;
|
||||
}
|
||||
if (typeof config.processTrackingEnabled === 'boolean') {
|
||||
sanitized.processTrackingEnabled = config.processTrackingEnabled;
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create handler for POST /api/debug/metrics/start
|
||||
* Starts metrics collection with optional config overrides
|
||||
*/
|
||||
export function createStartMetricsHandler(performanceMonitor: PerformanceMonitorService) {
|
||||
return (req: Request, res: Response) => {
|
||||
const body = req.body as StartDebugMetricsRequest | undefined;
|
||||
|
||||
// Update config if provided (with validation)
|
||||
if (body?.config && typeof body.config === 'object') {
|
||||
const sanitizedConfig = sanitizeConfig(body.config);
|
||||
if (Object.keys(sanitizedConfig).length > 0) {
|
||||
performanceMonitor.updateConfig(sanitizedConfig);
|
||||
}
|
||||
}
|
||||
|
||||
// Start collection
|
||||
performanceMonitor.start();
|
||||
|
||||
const response: DebugMetricsResponse = {
|
||||
active: true,
|
||||
config: performanceMonitor.getConfig(),
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create handler for POST /api/debug/metrics/stop
|
||||
* Stops metrics collection
|
||||
*/
|
||||
export function createStopMetricsHandler(performanceMonitor: PerformanceMonitorService) {
|
||||
return (_req: Request, res: Response) => {
|
||||
performanceMonitor.stop();
|
||||
|
||||
const response: DebugMetricsResponse = {
|
||||
active: false,
|
||||
config: performanceMonitor.getConfig(),
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create handler for POST /api/debug/metrics/gc
|
||||
* Forces garbage collection if available
|
||||
*/
|
||||
export function createForceGCHandler(performanceMonitor: PerformanceMonitorService) {
|
||||
return (_req: Request, res: Response) => {
|
||||
const success = performanceMonitor.forceGC();
|
||||
|
||||
res.json({
|
||||
success,
|
||||
message: success
|
||||
? 'Garbage collection triggered'
|
||||
: 'Garbage collection not available (start Node.js with --expose-gc flag)',
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create handler for POST /api/debug/metrics/clear
|
||||
* Clears metrics history
|
||||
*/
|
||||
export function createClearHistoryHandler(performanceMonitor: PerformanceMonitorService) {
|
||||
return (_req: Request, res: Response) => {
|
||||
performanceMonitor.clearHistory();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Metrics history cleared',
|
||||
});
|
||||
};
|
||||
}
|
||||
170
apps/server/src/routes/debug/routes/processes.ts
Normal file
170
apps/server/src/routes/debug/routes/processes.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Debug processes route handler
|
||||
*
|
||||
* GET /api/debug/processes - Get list of tracked processes
|
||||
* GET /api/debug/processes/:id - Get specific process by ID
|
||||
* POST /api/debug/processes/:id/terminate - Terminate a process
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { ProcessRegistryService } from '../../../services/process-registry-service.js';
|
||||
import type {
|
||||
GetProcessesRequest,
|
||||
GetProcessesResponse,
|
||||
ProcessType,
|
||||
ProcessStatus,
|
||||
} from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Create handler for GET /api/debug/processes
|
||||
* Returns list of tracked processes with optional filtering
|
||||
*/
|
||||
export function createGetProcessesHandler(processRegistry: ProcessRegistryService) {
|
||||
return (req: Request, res: Response) => {
|
||||
const query = req.query as {
|
||||
type?: string;
|
||||
status?: string;
|
||||
includeStopped?: string;
|
||||
sessionId?: string;
|
||||
featureId?: string;
|
||||
};
|
||||
|
||||
// Build query options
|
||||
const options: GetProcessesRequest = {};
|
||||
|
||||
if (query.type) {
|
||||
options.type = query.type as ProcessType;
|
||||
}
|
||||
|
||||
if (query.status) {
|
||||
options.status = query.status as ProcessStatus;
|
||||
}
|
||||
|
||||
if (query.includeStopped === 'true') {
|
||||
options.includeStoppedProcesses = true;
|
||||
}
|
||||
|
||||
const processes = processRegistry.getProcesses({
|
||||
type: options.type,
|
||||
status: options.status,
|
||||
includeStopped: options.includeStoppedProcesses,
|
||||
sessionId: query.sessionId,
|
||||
featureId: query.featureId,
|
||||
});
|
||||
|
||||
const summary = processRegistry.getProcessSummary();
|
||||
|
||||
const response: GetProcessesResponse = {
|
||||
processes,
|
||||
summary,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate process ID format
|
||||
* Process IDs should be non-empty strings with reasonable length
|
||||
*/
|
||||
function isValidProcessId(id: unknown): id is string {
|
||||
return typeof id === 'string' && id.length > 0 && id.length <= 256;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create handler for GET /api/debug/processes/:id
|
||||
* Returns a specific process by ID
|
||||
*/
|
||||
export function createGetProcessHandler(processRegistry: ProcessRegistryService) {
|
||||
return (req: Request, res: Response) => {
|
||||
const { id } = req.params;
|
||||
|
||||
// Validate process ID format
|
||||
if (!isValidProcessId(id)) {
|
||||
res.status(400).json({
|
||||
error: 'Invalid process ID format',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const process = processRegistry.getProcess(id);
|
||||
|
||||
if (!process) {
|
||||
res.status(404).json({
|
||||
error: 'Process not found',
|
||||
id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(process);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create handler for GET /api/debug/processes/summary
|
||||
* Returns summary statistics
|
||||
*/
|
||||
export function createGetSummaryHandler(processRegistry: ProcessRegistryService) {
|
||||
return (_req: Request, res: Response) => {
|
||||
const summary = processRegistry.getProcessSummary();
|
||||
res.json(summary);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create handler for GET /api/debug/agents
|
||||
* Returns all agent processes with their resource metrics
|
||||
*/
|
||||
export function createGetAgentsHandler(processRegistry: ProcessRegistryService) {
|
||||
return (_req: Request, res: Response) => {
|
||||
const agents = processRegistry.getAgentProcessesWithMetrics();
|
||||
const summary = processRegistry.getAgentResourceSummary();
|
||||
|
||||
res.json({
|
||||
agents,
|
||||
summary,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create handler for GET /api/debug/agents/:id/metrics
|
||||
* Returns detailed resource metrics for a specific agent
|
||||
*/
|
||||
export function createGetAgentMetricsHandler(processRegistry: ProcessRegistryService) {
|
||||
return (req: Request, res: Response) => {
|
||||
const { id } = req.params;
|
||||
|
||||
// Validate process ID format
|
||||
if (!isValidProcessId(id)) {
|
||||
res.status(400).json({
|
||||
error: 'Invalid agent ID format',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const metrics = processRegistry.getAgentMetrics(id);
|
||||
|
||||
if (!metrics) {
|
||||
res.status(404).json({
|
||||
error: 'Agent metrics not found',
|
||||
id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(metrics);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create handler for GET /api/debug/agents/summary
|
||||
* Returns summary of resource usage across all agents
|
||||
*/
|
||||
export function createGetAgentSummaryHandler(processRegistry: ProcessRegistryService) {
|
||||
return (_req: Request, res: Response) => {
|
||||
const summary = processRegistry.getAgentResourceSummary();
|
||||
res.json(summary);
|
||||
};
|
||||
}
|
||||
673
apps/server/src/services/performance-monitor-service.ts
Normal file
673
apps/server/src/services/performance-monitor-service.ts
Normal file
@@ -0,0 +1,673 @@
|
||||
/**
|
||||
* Performance Monitor Service
|
||||
*
|
||||
* Collects and streams server-side performance metrics including:
|
||||
* - Memory usage (heap, rss, external)
|
||||
* - CPU usage (user, system, percentage)
|
||||
* - Event loop lag detection
|
||||
* - Memory leak trend analysis
|
||||
*
|
||||
* Emits debug events for real-time streaming to connected clients.
|
||||
*/
|
||||
|
||||
import v8 from 'v8';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { EventEmitter } from '../lib/events.js';
|
||||
import type {
|
||||
ServerMemoryMetrics,
|
||||
ServerCPUMetrics,
|
||||
MemoryMetrics,
|
||||
CPUMetrics,
|
||||
MemoryTrend,
|
||||
DebugMetricsConfig,
|
||||
DebugMetricsSnapshot,
|
||||
ProcessSummary,
|
||||
TrackedProcess,
|
||||
} from '@automaker/types';
|
||||
import { DEFAULT_DEBUG_METRICS_CONFIG, formatBytes } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('PerformanceMonitor');
|
||||
|
||||
/**
|
||||
* Circular buffer for time-series data storage
|
||||
* Uses index-based ring buffer for O(1) push operations instead of O(n) shift().
|
||||
* Efficiently stores a fixed number of data points, automatically discarding old ones.
|
||||
*/
|
||||
class CircularBuffer<T> {
|
||||
private buffer: (T | undefined)[];
|
||||
private maxSize: number;
|
||||
private head = 0; // Write position
|
||||
private count = 0; // Number of items
|
||||
|
||||
constructor(maxSize: number) {
|
||||
this.maxSize = maxSize;
|
||||
this.buffer = new Array(maxSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add item to buffer - O(1) operation
|
||||
*/
|
||||
push(item: T): void {
|
||||
this.buffer[this.head] = item;
|
||||
this.head = (this.head + 1) % this.maxSize;
|
||||
if (this.count < this.maxSize) {
|
||||
this.count++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all items in chronological order - O(n) but only called when needed
|
||||
*/
|
||||
getAll(): T[] {
|
||||
if (this.count === 0) return [];
|
||||
|
||||
const result: T[] = new Array(this.count);
|
||||
const start = this.count < this.maxSize ? 0 : this.head;
|
||||
|
||||
for (let i = 0; i < this.count; i++) {
|
||||
const idx = (start + i) % this.maxSize;
|
||||
result[i] = this.buffer[idx] as T;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get most recent item - O(1)
|
||||
*/
|
||||
getLast(): T | undefined {
|
||||
if (this.count === 0) return undefined;
|
||||
const idx = (this.head - 1 + this.maxSize) % this.maxSize;
|
||||
return this.buffer[idx];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get oldest item - O(1)
|
||||
*/
|
||||
getFirst(): T | undefined {
|
||||
if (this.count === 0) return undefined;
|
||||
const start = this.count < this.maxSize ? 0 : this.head;
|
||||
return this.buffer[start];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current count - O(1)
|
||||
*/
|
||||
size(): number {
|
||||
return this.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all items - O(1)
|
||||
*/
|
||||
clear(): void {
|
||||
this.head = 0;
|
||||
this.count = 0;
|
||||
// Don't reallocate array, just reset indices
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize buffer, preserving existing data
|
||||
*/
|
||||
resize(newSize: number): void {
|
||||
const oldData = this.getAll();
|
||||
this.maxSize = newSize;
|
||||
this.buffer = new Array(newSize);
|
||||
this.head = 0;
|
||||
this.count = 0;
|
||||
|
||||
// Copy over data (trim if necessary, keep most recent)
|
||||
const startIdx = Math.max(0, oldData.length - newSize);
|
||||
for (let i = startIdx; i < oldData.length; i++) {
|
||||
this.push(oldData[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Memory data point for trend analysis
|
||||
*/
|
||||
interface MemoryDataPoint {
|
||||
timestamp: number;
|
||||
heapUsed: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* CPU data point for tracking
|
||||
*/
|
||||
interface CPUDataPoint {
|
||||
timestamp: number;
|
||||
user: number;
|
||||
system: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* PerformanceMonitorService - Collects server-side performance metrics
|
||||
*
|
||||
* This service runs in the Node.js server process and periodically collects:
|
||||
* - Memory metrics from process.memoryUsage()
|
||||
* - CPU metrics from process.cpuUsage()
|
||||
* - Event loop lag using setTimeout deviation
|
||||
*
|
||||
* It streams metrics to connected clients via the event emitter and
|
||||
* analyzes memory trends to detect potential leaks.
|
||||
*/
|
||||
export class PerformanceMonitorService {
|
||||
private events: EventEmitter;
|
||||
private config: DebugMetricsConfig;
|
||||
private isRunning = false;
|
||||
private collectionInterval: NodeJS.Timeout | null = null;
|
||||
private eventLoopCheckInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
// Data storage
|
||||
private memoryHistory: CircularBuffer<MemoryDataPoint>;
|
||||
private cpuHistory: CircularBuffer<CPUDataPoint>;
|
||||
|
||||
// CPU tracking state
|
||||
private lastCpuUsage: NodeJS.CpuUsage | null = null;
|
||||
private lastCpuTime: number = 0;
|
||||
|
||||
// Event loop lag tracking
|
||||
private lastEventLoopLag = 0;
|
||||
private eventLoopLagThreshold = 100; // ms - threshold for warning
|
||||
|
||||
// Memory warning thresholds (percentage of heap limit)
|
||||
private memoryWarningThreshold = 70; // 70% of heap limit
|
||||
private memoryCriticalThreshold = 90; // 90% of heap limit
|
||||
private lastMemoryWarningTime = 0;
|
||||
private memoryWarningCooldown = 30000; // 30 seconds between warnings
|
||||
|
||||
// Process tracking (will be populated by ProcessRegistryService)
|
||||
private getProcesses: () => TrackedProcess[] = () => [];
|
||||
|
||||
constructor(events: EventEmitter, config?: Partial<DebugMetricsConfig>) {
|
||||
this.events = events;
|
||||
this.config = { ...DEFAULT_DEBUG_METRICS_CONFIG, ...config };
|
||||
this.memoryHistory = new CircularBuffer(this.config.maxDataPoints);
|
||||
this.cpuHistory = new CircularBuffer(this.config.maxDataPoints);
|
||||
|
||||
logger.info('PerformanceMonitorService initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the process provider function (called by ProcessRegistryService)
|
||||
*/
|
||||
setProcessProvider(provider: () => TrackedProcess[]): void {
|
||||
this.getProcesses = provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start metrics collection
|
||||
*/
|
||||
start(): void {
|
||||
if (this.isRunning) {
|
||||
logger.warn('PerformanceMonitorService is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRunning = true;
|
||||
this.lastCpuUsage = process.cpuUsage();
|
||||
this.lastCpuTime = Date.now();
|
||||
|
||||
// Start periodic metrics collection
|
||||
this.collectionInterval = setInterval(() => {
|
||||
this.collectAndEmitMetrics();
|
||||
}, this.config.collectionInterval);
|
||||
|
||||
// Start event loop lag monitoring (more frequent for accurate detection)
|
||||
this.startEventLoopMonitoring();
|
||||
|
||||
logger.info('PerformanceMonitorService started', {
|
||||
interval: this.config.collectionInterval,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop metrics collection
|
||||
*/
|
||||
stop(): void {
|
||||
if (!this.isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRunning = false;
|
||||
|
||||
if (this.collectionInterval) {
|
||||
clearInterval(this.collectionInterval);
|
||||
this.collectionInterval = null;
|
||||
}
|
||||
|
||||
if (this.eventLoopCheckInterval) {
|
||||
clearInterval(this.eventLoopCheckInterval);
|
||||
this.eventLoopCheckInterval = null;
|
||||
}
|
||||
|
||||
logger.info('PerformanceMonitorService stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration
|
||||
*/
|
||||
updateConfig(config: Partial<DebugMetricsConfig>): void {
|
||||
const wasRunning = this.isRunning;
|
||||
|
||||
if (wasRunning) {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
this.config = { ...this.config, ...config };
|
||||
|
||||
// Resize buffers if maxDataPoints changed
|
||||
if (config.maxDataPoints) {
|
||||
this.memoryHistory.resize(config.maxDataPoints);
|
||||
this.cpuHistory.resize(config.maxDataPoints);
|
||||
}
|
||||
|
||||
if (wasRunning) {
|
||||
this.start();
|
||||
}
|
||||
|
||||
logger.info('PerformanceMonitorService configuration updated', config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current configuration
|
||||
*/
|
||||
getConfig(): DebugMetricsConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get whether monitoring is active
|
||||
*/
|
||||
isActive(): boolean {
|
||||
return this.isRunning;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect and emit current metrics
|
||||
*/
|
||||
private collectAndEmitMetrics(): void {
|
||||
const timestamp = Date.now();
|
||||
const memoryMetrics = this.collectMemoryMetrics(timestamp);
|
||||
const cpuMetrics = this.collectCPUMetrics(timestamp);
|
||||
|
||||
// Store in history
|
||||
if (this.config.memoryEnabled && memoryMetrics.server) {
|
||||
this.memoryHistory.push({
|
||||
timestamp,
|
||||
heapUsed: memoryMetrics.server.heapUsed,
|
||||
});
|
||||
}
|
||||
|
||||
// Analyze memory trend
|
||||
const memoryTrend = this.analyzeMemoryTrend();
|
||||
|
||||
// Get process information
|
||||
const processes = this.getProcesses();
|
||||
const processSummary = this.calculateProcessSummary(processes);
|
||||
|
||||
// Build snapshot
|
||||
const snapshot: DebugMetricsSnapshot = {
|
||||
timestamp,
|
||||
memory: memoryMetrics,
|
||||
cpu: cpuMetrics,
|
||||
processes,
|
||||
processSummary,
|
||||
memoryTrend,
|
||||
};
|
||||
|
||||
// Emit metrics event
|
||||
this.events.emit('debug:metrics', {
|
||||
type: 'debug:metrics',
|
||||
timestamp,
|
||||
metrics: snapshot,
|
||||
});
|
||||
|
||||
// Check for memory warnings
|
||||
this.checkMemoryThresholds(memoryMetrics);
|
||||
|
||||
// Check for memory leak
|
||||
if (memoryTrend && memoryTrend.isLeaking) {
|
||||
this.events.emit('debug:leak-detected', {
|
||||
type: 'debug:leak-detected',
|
||||
timestamp,
|
||||
trend: memoryTrend,
|
||||
message: `Potential memory leak detected: ${formatBytes(memoryTrend.growthRate)}/s sustained growth`,
|
||||
});
|
||||
}
|
||||
|
||||
// Check for high CPU
|
||||
if (cpuMetrics.server && cpuMetrics.server.percentage > 80) {
|
||||
this.events.emit('debug:high-cpu', {
|
||||
type: 'debug:high-cpu',
|
||||
timestamp,
|
||||
cpu: cpuMetrics,
|
||||
usagePercent: cpuMetrics.server.percentage,
|
||||
threshold: 80,
|
||||
message: `High CPU usage: ${cpuMetrics.server.percentage.toFixed(1)}%`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect memory metrics from Node.js process
|
||||
*/
|
||||
private collectMemoryMetrics(timestamp: number): MemoryMetrics {
|
||||
if (!this.config.memoryEnabled) {
|
||||
return { timestamp };
|
||||
}
|
||||
|
||||
const usage = process.memoryUsage();
|
||||
const serverMetrics: ServerMemoryMetrics = {
|
||||
heapTotal: usage.heapTotal,
|
||||
heapUsed: usage.heapUsed,
|
||||
external: usage.external,
|
||||
rss: usage.rss,
|
||||
arrayBuffers: usage.arrayBuffers,
|
||||
};
|
||||
|
||||
return {
|
||||
timestamp,
|
||||
server: serverMetrics,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect CPU metrics from Node.js process
|
||||
*/
|
||||
private collectCPUMetrics(timestamp: number): CPUMetrics {
|
||||
if (!this.config.cpuEnabled) {
|
||||
return { timestamp };
|
||||
}
|
||||
|
||||
const currentCpuUsage = process.cpuUsage();
|
||||
const currentTime = Date.now();
|
||||
|
||||
let serverMetrics: ServerCPUMetrics | undefined;
|
||||
|
||||
if (this.lastCpuUsage) {
|
||||
// Calculate CPU usage since last measurement
|
||||
const userDiff = currentCpuUsage.user - this.lastCpuUsage.user;
|
||||
const systemDiff = currentCpuUsage.system - this.lastCpuUsage.system;
|
||||
const timeDiff = (currentTime - this.lastCpuTime) * 1000; // Convert to microseconds
|
||||
|
||||
// Calculate percentage (CPU usage is in microseconds)
|
||||
// For multi-core systems, this can exceed 100%
|
||||
const percentage = timeDiff > 0 ? ((userDiff + systemDiff) / timeDiff) * 100 : 0;
|
||||
|
||||
serverMetrics = {
|
||||
percentage: Math.min(100, percentage), // Cap at 100% for single-core representation
|
||||
user: userDiff,
|
||||
system: systemDiff,
|
||||
};
|
||||
|
||||
// Store in history
|
||||
this.cpuHistory.push({
|
||||
timestamp,
|
||||
user: userDiff,
|
||||
system: systemDiff,
|
||||
});
|
||||
}
|
||||
|
||||
this.lastCpuUsage = currentCpuUsage;
|
||||
this.lastCpuTime = currentTime;
|
||||
|
||||
return {
|
||||
timestamp,
|
||||
server: serverMetrics,
|
||||
eventLoopLag: this.lastEventLoopLag,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start event loop lag monitoring
|
||||
* Uses setTimeout deviation to detect when the event loop is blocked
|
||||
*/
|
||||
private startEventLoopMonitoring(): void {
|
||||
const checkInterval = 100; // Check every 100ms
|
||||
|
||||
const measureLag = () => {
|
||||
if (!this.isRunning) return;
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
// setImmediate runs after I/O events, giving us event loop lag
|
||||
setImmediate(() => {
|
||||
const lag = Date.now() - start;
|
||||
this.lastEventLoopLag = lag;
|
||||
|
||||
// Emit warning if lag exceeds threshold
|
||||
if (lag > this.eventLoopLagThreshold) {
|
||||
this.events.emit('debug:event-loop-blocked', {
|
||||
type: 'debug:event-loop-blocked',
|
||||
timestamp: Date.now(),
|
||||
lag,
|
||||
threshold: this.eventLoopLagThreshold,
|
||||
message: `Event loop blocked for ${lag}ms`,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
this.eventLoopCheckInterval = setInterval(measureLag, checkInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze memory trend for leak detection
|
||||
*/
|
||||
private analyzeMemoryTrend(): MemoryTrend | undefined {
|
||||
const history = this.memoryHistory.getAll();
|
||||
if (history.length < 10) {
|
||||
return undefined; // Need at least 10 samples for meaningful analysis
|
||||
}
|
||||
|
||||
const first = history[0];
|
||||
const last = history[history.length - 1];
|
||||
const windowDuration = last.timestamp - first.timestamp;
|
||||
|
||||
if (windowDuration === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Calculate linear regression for growth rate
|
||||
const n = history.length;
|
||||
let sumX = 0;
|
||||
let sumY = 0;
|
||||
let sumXY = 0;
|
||||
let sumXX = 0;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = history[i].timestamp - first.timestamp;
|
||||
const y = history[i].heapUsed;
|
||||
sumX += x;
|
||||
sumY += y;
|
||||
sumXY += x * y;
|
||||
sumXX += x * x;
|
||||
}
|
||||
|
||||
// Slope of linear regression (bytes per millisecond)
|
||||
const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);
|
||||
const growthRate = slope * 1000; // Convert to bytes per second
|
||||
|
||||
// Calculate R² for confidence
|
||||
const meanY = sumY / n;
|
||||
let ssRes = 0;
|
||||
let ssTot = 0;
|
||||
const intercept = (sumY - slope * sumX) / n;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = history[i].timestamp - first.timestamp;
|
||||
const y = history[i].heapUsed;
|
||||
const yPred = slope * x + intercept;
|
||||
ssRes += (y - yPred) ** 2;
|
||||
ssTot += (y - meanY) ** 2;
|
||||
}
|
||||
|
||||
const rSquared = ssTot > 0 ? 1 - ssRes / ssTot : 0;
|
||||
const confidence = Math.max(0, Math.min(1, rSquared));
|
||||
|
||||
// Consider it a leak if:
|
||||
// 1. Growth rate exceeds threshold
|
||||
// 2. R² is high (indicating consistent growth, not just fluctuation)
|
||||
const isLeaking =
|
||||
growthRate > this.config.leakThreshold && confidence > 0.7 && windowDuration > 30000; // At least 30 seconds of data
|
||||
|
||||
return {
|
||||
growthRate,
|
||||
isLeaking,
|
||||
confidence,
|
||||
sampleCount: n,
|
||||
windowDuration,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check memory thresholds and emit warnings
|
||||
*/
|
||||
private checkMemoryThresholds(memory: MemoryMetrics): void {
|
||||
if (!memory.server) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - this.lastMemoryWarningTime < this.memoryWarningCooldown) {
|
||||
return; // Don't spam warnings
|
||||
}
|
||||
|
||||
// Get V8 heap statistics for limit
|
||||
const heapStats = v8.getHeapStatistics();
|
||||
const heapLimit = heapStats.heap_size_limit;
|
||||
const usagePercent = (memory.server.heapUsed / heapLimit) * 100;
|
||||
|
||||
if (usagePercent >= this.memoryCriticalThreshold) {
|
||||
this.lastMemoryWarningTime = now;
|
||||
this.events.emit('debug:memory-critical', {
|
||||
type: 'debug:memory-critical',
|
||||
timestamp: now,
|
||||
memory,
|
||||
usagePercent,
|
||||
threshold: this.memoryCriticalThreshold,
|
||||
message: `Critical memory usage: ${usagePercent.toFixed(1)}% of heap limit`,
|
||||
});
|
||||
} else if (usagePercent >= this.memoryWarningThreshold) {
|
||||
this.lastMemoryWarningTime = now;
|
||||
this.events.emit('debug:memory-warning', {
|
||||
type: 'debug:memory-warning',
|
||||
timestamp: now,
|
||||
memory,
|
||||
usagePercent,
|
||||
threshold: this.memoryWarningThreshold,
|
||||
message: `High memory usage: ${usagePercent.toFixed(1)}% of heap limit`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate process summary from tracked processes
|
||||
*/
|
||||
private calculateProcessSummary(processes: TrackedProcess[]): ProcessSummary {
|
||||
const summary: ProcessSummary = {
|
||||
total: processes.length,
|
||||
running: 0,
|
||||
idle: 0,
|
||||
stopped: 0,
|
||||
errored: 0,
|
||||
byType: {
|
||||
agent: 0,
|
||||
cli: 0,
|
||||
terminal: 0,
|
||||
worker: 0,
|
||||
},
|
||||
};
|
||||
|
||||
for (const process of processes) {
|
||||
// Count by status
|
||||
switch (process.status) {
|
||||
case 'running':
|
||||
case 'starting':
|
||||
summary.running++;
|
||||
break;
|
||||
case 'idle':
|
||||
summary.idle++;
|
||||
break;
|
||||
case 'stopped':
|
||||
case 'stopping':
|
||||
summary.stopped++;
|
||||
break;
|
||||
case 'error':
|
||||
summary.errored++;
|
||||
break;
|
||||
}
|
||||
|
||||
// Count by type
|
||||
if (process.type in summary.byType) {
|
||||
summary.byType[process.type]++;
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest metrics snapshot
|
||||
*/
|
||||
getLatestSnapshot(): DebugMetricsSnapshot | null {
|
||||
const timestamp = Date.now();
|
||||
const lastMemory = this.memoryHistory.getLast();
|
||||
|
||||
if (!lastMemory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const memoryMetrics = this.collectMemoryMetrics(timestamp);
|
||||
const cpuMetrics = this.collectCPUMetrics(timestamp);
|
||||
const memoryTrend = this.analyzeMemoryTrend();
|
||||
const processes = this.getProcesses();
|
||||
const processSummary = this.calculateProcessSummary(processes);
|
||||
|
||||
return {
|
||||
timestamp,
|
||||
memory: memoryMetrics,
|
||||
cpu: cpuMetrics,
|
||||
processes,
|
||||
processSummary,
|
||||
memoryTrend,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get memory history for charting
|
||||
*/
|
||||
getMemoryHistory(): MemoryDataPoint[] {
|
||||
return this.memoryHistory.getAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CPU history for charting
|
||||
*/
|
||||
getCPUHistory(): CPUDataPoint[] {
|
||||
return this.cpuHistory.getAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a garbage collection (if --expose-gc flag is used)
|
||||
* Returns true if GC was triggered, false if not available
|
||||
*/
|
||||
forceGC(): boolean {
|
||||
if (global.gc) {
|
||||
global.gc();
|
||||
logger.info('Forced garbage collection');
|
||||
return true;
|
||||
}
|
||||
logger.warn('Garbage collection not available (start with --expose-gc flag)');
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear collected history
|
||||
*/
|
||||
clearHistory(): void {
|
||||
this.memoryHistory.clear();
|
||||
this.cpuHistory.clear();
|
||||
logger.info('Performance history cleared');
|
||||
}
|
||||
}
|
||||
982
apps/server/src/services/process-registry-service.ts
Normal file
982
apps/server/src/services/process-registry-service.ts
Normal file
@@ -0,0 +1,982 @@
|
||||
/**
|
||||
* Process Registry Service
|
||||
*
|
||||
* Tracks spawned agents, CLIs, and terminal processes for debugging and monitoring.
|
||||
* Emits debug events for real-time updates to connected clients.
|
||||
*
|
||||
* This service provides:
|
||||
* - Process registration and unregistration
|
||||
* - Status updates for tracked processes
|
||||
* - Integration with PerformanceMonitorService for metrics snapshots
|
||||
* - Filtering and querying of tracked processes
|
||||
* - Automatic cleanup of stopped processes after a retention period
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { EventEmitter } from '../lib/events.js';
|
||||
import type {
|
||||
TrackedProcess,
|
||||
ProcessType,
|
||||
ProcessStatus,
|
||||
ProcessSummary,
|
||||
AgentResourceMetrics,
|
||||
FileIOOperation,
|
||||
} from '@automaker/types';
|
||||
import { createEmptyAgentResourceMetrics } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('ProcessRegistry');
|
||||
|
||||
/**
|
||||
* Options for recording a tool invocation
|
||||
*/
|
||||
export interface RecordToolUseOptions {
|
||||
/** Tool name */
|
||||
toolName: string;
|
||||
/** Execution time in milliseconds */
|
||||
executionTime?: number;
|
||||
/** Whether the tool invocation failed */
|
||||
failed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for recording a file operation
|
||||
*/
|
||||
export interface RecordFileOperationOptions {
|
||||
/** Type of file operation */
|
||||
operation: FileIOOperation;
|
||||
/** File path accessed */
|
||||
filePath: string;
|
||||
/** Bytes read or written */
|
||||
bytes?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for recording a bash command
|
||||
*/
|
||||
export interface RecordBashCommandOptions {
|
||||
/** Command executed */
|
||||
command: string;
|
||||
/** Execution time in milliseconds */
|
||||
executionTime: number;
|
||||
/** Exit code (null if still running or killed) */
|
||||
exitCode: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for registering a new process
|
||||
*/
|
||||
export interface RegisterProcessOptions {
|
||||
/** Unique identifier for the process */
|
||||
id: string;
|
||||
/** Process ID from the operating system (-1 if not applicable) */
|
||||
pid: number;
|
||||
/** Type of process */
|
||||
type: ProcessType;
|
||||
/** Human-readable name/label */
|
||||
name: string;
|
||||
/** Associated feature ID (for agent processes) */
|
||||
featureId?: string;
|
||||
/** Associated session ID (for agent/terminal processes) */
|
||||
sessionId?: string;
|
||||
/** Command that was executed */
|
||||
command?: string;
|
||||
/** Working directory */
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for updating a process
|
||||
*/
|
||||
export interface UpdateProcessOptions {
|
||||
/** New status */
|
||||
status?: ProcessStatus;
|
||||
/** Memory usage in bytes */
|
||||
memoryUsage?: number;
|
||||
/** CPU usage percentage */
|
||||
cpuUsage?: number;
|
||||
/** Exit code (when stopping) */
|
||||
exitCode?: number;
|
||||
/** Error message */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for querying processes
|
||||
*/
|
||||
export interface QueryProcessOptions {
|
||||
/** Filter by process type */
|
||||
type?: ProcessType;
|
||||
/** Filter by status */
|
||||
status?: ProcessStatus;
|
||||
/** Include stopped processes (default: false) */
|
||||
includeStopped?: boolean;
|
||||
/** Filter by session ID */
|
||||
sessionId?: string;
|
||||
/** Filter by feature ID */
|
||||
featureId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for the ProcessRegistryService
|
||||
*/
|
||||
export interface ProcessRegistryConfig {
|
||||
/** How long to keep stopped processes in the registry (ms) */
|
||||
stoppedProcessRetention: number;
|
||||
/** Interval for cleanup of old stopped processes (ms) */
|
||||
cleanupInterval: number;
|
||||
/** Maximum number of stopped processes to retain */
|
||||
maxStoppedProcesses: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: ProcessRegistryConfig = {
|
||||
stoppedProcessRetention: 5 * 60 * 1000, // 5 minutes
|
||||
cleanupInterval: 60 * 1000, // 1 minute
|
||||
maxStoppedProcesses: 100,
|
||||
};
|
||||
|
||||
/**
|
||||
* ProcessRegistryService - Tracks spawned processes for debugging
|
||||
*
|
||||
* This service maintains a registry of all tracked processes including:
|
||||
* - Agent sessions (AI conversations)
|
||||
* - CLI processes (one-off commands)
|
||||
* - Terminal sessions (persistent PTY sessions)
|
||||
* - Worker processes (background tasks)
|
||||
*
|
||||
* It emits events when processes are spawned, updated, or stopped,
|
||||
* allowing real-time monitoring in the debug panel.
|
||||
*/
|
||||
export class ProcessRegistryService {
|
||||
private events: EventEmitter;
|
||||
private config: ProcessRegistryConfig;
|
||||
private processes: Map<string, TrackedProcess> = new Map();
|
||||
private cleanupIntervalId: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(events: EventEmitter, config?: Partial<ProcessRegistryConfig>) {
|
||||
this.events = events;
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
|
||||
logger.info('ProcessRegistryService initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the process registry service
|
||||
* Begins periodic cleanup of old stopped processes
|
||||
*/
|
||||
start(): void {
|
||||
if (this.cleanupIntervalId) {
|
||||
logger.warn('ProcessRegistryService is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
this.cleanupIntervalId = setInterval(() => {
|
||||
this.cleanupStoppedProcesses();
|
||||
}, this.config.cleanupInterval);
|
||||
|
||||
logger.info('ProcessRegistryService started');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the process registry service
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.cleanupIntervalId) {
|
||||
clearInterval(this.cleanupIntervalId);
|
||||
this.cleanupIntervalId = null;
|
||||
}
|
||||
|
||||
logger.info('ProcessRegistryService stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new process
|
||||
*/
|
||||
registerProcess(options: RegisterProcessOptions): TrackedProcess {
|
||||
const now = Date.now();
|
||||
|
||||
const process: TrackedProcess = {
|
||||
id: options.id,
|
||||
pid: options.pid,
|
||||
type: options.type,
|
||||
name: options.name,
|
||||
status: 'starting',
|
||||
startedAt: now,
|
||||
featureId: options.featureId,
|
||||
sessionId: options.sessionId,
|
||||
command: options.command,
|
||||
cwd: options.cwd,
|
||||
};
|
||||
|
||||
this.processes.set(options.id, process);
|
||||
|
||||
logger.info('Process registered', {
|
||||
id: process.id,
|
||||
type: process.type,
|
||||
name: process.name,
|
||||
pid: process.pid,
|
||||
});
|
||||
|
||||
// Emit process spawned event
|
||||
this.events.emit('debug:process-spawned', {
|
||||
type: 'debug:process-spawned',
|
||||
timestamp: now,
|
||||
process,
|
||||
message: `Process ${process.name} (${process.type}) started`,
|
||||
});
|
||||
|
||||
return process;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing process
|
||||
*/
|
||||
updateProcess(id: string, updates: UpdateProcessOptions): TrackedProcess | null {
|
||||
const process = this.processes.get(id);
|
||||
if (!process) {
|
||||
logger.warn('Attempted to update non-existent process', { id });
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Apply updates
|
||||
if (updates.status !== undefined) {
|
||||
process.status = updates.status;
|
||||
|
||||
// Set stoppedAt timestamp when process stops
|
||||
if (updates.status === 'stopped' || updates.status === 'error') {
|
||||
process.stoppedAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.memoryUsage !== undefined) {
|
||||
process.memoryUsage = updates.memoryUsage;
|
||||
}
|
||||
|
||||
if (updates.cpuUsage !== undefined) {
|
||||
process.cpuUsage = updates.cpuUsage;
|
||||
}
|
||||
|
||||
if (updates.exitCode !== undefined) {
|
||||
process.exitCode = updates.exitCode;
|
||||
}
|
||||
|
||||
if (updates.error !== undefined) {
|
||||
process.error = updates.error;
|
||||
}
|
||||
|
||||
logger.debug('Process updated', {
|
||||
id,
|
||||
updates,
|
||||
});
|
||||
|
||||
// Emit appropriate event based on status
|
||||
if (updates.status === 'stopped') {
|
||||
this.events.emit('debug:process-stopped', {
|
||||
type: 'debug:process-stopped',
|
||||
timestamp: now,
|
||||
process,
|
||||
message: `Process ${process.name} stopped${updates.exitCode !== undefined ? ` (exit code: ${updates.exitCode})` : ''}`,
|
||||
});
|
||||
} else if (updates.status === 'error') {
|
||||
this.events.emit('debug:process-error', {
|
||||
type: 'debug:process-error',
|
||||
timestamp: now,
|
||||
process,
|
||||
message: `Process ${process.name} encountered an error: ${updates.error || 'Unknown error'}`,
|
||||
});
|
||||
} else {
|
||||
this.events.emit('debug:process-updated', {
|
||||
type: 'debug:process-updated',
|
||||
timestamp: now,
|
||||
process,
|
||||
});
|
||||
}
|
||||
|
||||
return process;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a process as running
|
||||
*/
|
||||
markRunning(id: string): TrackedProcess | null {
|
||||
return this.updateProcess(id, { status: 'running' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a process as idle
|
||||
*/
|
||||
markIdle(id: string): TrackedProcess | null {
|
||||
return this.updateProcess(id, { status: 'idle' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a process as stopping
|
||||
*/
|
||||
markStopping(id: string): TrackedProcess | null {
|
||||
return this.updateProcess(id, { status: 'stopping' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a process as stopped
|
||||
*/
|
||||
markStopped(id: string, exitCode?: number): TrackedProcess | null {
|
||||
return this.updateProcess(id, { status: 'stopped', exitCode });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a process as errored
|
||||
*/
|
||||
markError(id: string, error: string): TrackedProcess | null {
|
||||
return this.updateProcess(id, { status: 'error', error });
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a process (remove immediately without retention)
|
||||
*/
|
||||
unregisterProcess(id: string): boolean {
|
||||
const process = this.processes.get(id);
|
||||
if (!process) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.processes.delete(id);
|
||||
|
||||
logger.info('Process unregistered', {
|
||||
id,
|
||||
type: process.type,
|
||||
name: process.name,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a process by ID
|
||||
*/
|
||||
getProcess(id: string): TrackedProcess | undefined {
|
||||
return this.processes.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tracked processes, optionally filtered
|
||||
* Optimized single-pass filtering to avoid multiple array allocations
|
||||
*/
|
||||
getProcesses(options?: QueryProcessOptions): TrackedProcess[] {
|
||||
// Pre-allocate array with estimated capacity
|
||||
const result: TrackedProcess[] = [];
|
||||
|
||||
// Single-pass filtering
|
||||
for (const process of this.processes.values()) {
|
||||
// Filter by type
|
||||
if (options?.type && process.type !== options.type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter by status
|
||||
if (options?.status && process.status !== options.status) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter out stopped processes by default
|
||||
if (!options?.includeStopped) {
|
||||
if (process.status === 'stopped' || process.status === 'error') {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by session ID
|
||||
if (options?.sessionId && process.sessionId !== options.sessionId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter by feature ID
|
||||
if (options?.featureId && process.featureId !== options.featureId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push(process);
|
||||
}
|
||||
|
||||
// Sort by start time (most recent first)
|
||||
result.sort((a, b) => b.startedAt - a.startedAt);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all processes (for PerformanceMonitorService integration)
|
||||
* This is used as the process provider function
|
||||
*/
|
||||
getAllProcesses(): TrackedProcess[] {
|
||||
return Array.from(this.processes.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get process provider function for PerformanceMonitorService
|
||||
*/
|
||||
getProcessProvider(): () => TrackedProcess[] {
|
||||
return () => this.getAllProcesses();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate summary statistics for tracked processes
|
||||
*/
|
||||
getProcessSummary(): ProcessSummary {
|
||||
const processes = this.getAllProcesses();
|
||||
|
||||
const summary: ProcessSummary = {
|
||||
total: processes.length,
|
||||
running: 0,
|
||||
idle: 0,
|
||||
stopped: 0,
|
||||
errored: 0,
|
||||
byType: {
|
||||
agent: 0,
|
||||
cli: 0,
|
||||
terminal: 0,
|
||||
worker: 0,
|
||||
},
|
||||
};
|
||||
|
||||
for (const process of processes) {
|
||||
// Count by status
|
||||
switch (process.status) {
|
||||
case 'running':
|
||||
case 'starting':
|
||||
summary.running++;
|
||||
break;
|
||||
case 'idle':
|
||||
summary.idle++;
|
||||
break;
|
||||
case 'stopped':
|
||||
case 'stopping':
|
||||
summary.stopped++;
|
||||
break;
|
||||
case 'error':
|
||||
summary.errored++;
|
||||
break;
|
||||
}
|
||||
|
||||
// Count by type
|
||||
if (process.type in summary.byType) {
|
||||
summary.byType[process.type]++;
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of active (non-stopped) processes
|
||||
*/
|
||||
getActiveCount(): number {
|
||||
let count = 0;
|
||||
for (const process of this.processes.values()) {
|
||||
if (process.status !== 'stopped' && process.status !== 'error') {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of processes by type
|
||||
*/
|
||||
getCountByType(type: ProcessType): number {
|
||||
let count = 0;
|
||||
for (const process of this.processes.values()) {
|
||||
if (process.type === type) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a process exists
|
||||
*/
|
||||
hasProcess(id: string): boolean {
|
||||
return this.processes.has(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration
|
||||
*/
|
||||
updateConfig(config: Partial<ProcessRegistryConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
logger.info('ProcessRegistryService configuration updated', config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current configuration
|
||||
*/
|
||||
getConfig(): ProcessRegistryConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old stopped processes
|
||||
*/
|
||||
private cleanupStoppedProcesses(): void {
|
||||
const now = Date.now();
|
||||
const stoppedProcesses: Array<{ id: string; stoppedAt: number }> = [];
|
||||
|
||||
// Find all stopped processes
|
||||
for (const [id, process] of this.processes.entries()) {
|
||||
if ((process.status === 'stopped' || process.status === 'error') && process.stoppedAt) {
|
||||
stoppedProcesses.push({ id, stoppedAt: process.stoppedAt });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by stoppedAt (oldest first)
|
||||
stoppedProcesses.sort((a, b) => a.stoppedAt - b.stoppedAt);
|
||||
|
||||
let removedCount = 0;
|
||||
|
||||
// Remove processes that exceed retention time
|
||||
for (const { id, stoppedAt } of stoppedProcesses) {
|
||||
const age = now - stoppedAt;
|
||||
if (age > this.config.stoppedProcessRetention) {
|
||||
this.processes.delete(id);
|
||||
removedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// If still over max, remove oldest stopped processes
|
||||
const remainingStoppedCount = stoppedProcesses.length - removedCount;
|
||||
if (remainingStoppedCount > this.config.maxStoppedProcesses) {
|
||||
const toRemove = remainingStoppedCount - this.config.maxStoppedProcesses;
|
||||
let removed = 0;
|
||||
|
||||
for (const { id } of stoppedProcesses) {
|
||||
if (this.processes.has(id) && removed < toRemove) {
|
||||
this.processes.delete(id);
|
||||
removedCount++;
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (removedCount > 0) {
|
||||
logger.debug('Cleaned up stopped processes', { removedCount });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all tracked processes
|
||||
*/
|
||||
clear(): void {
|
||||
this.processes.clear();
|
||||
logger.info('All tracked processes cleared');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Agent Resource Metrics Tracking
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Initialize resource metrics for an agent process
|
||||
* Call this when an agent starts to begin tracking its resource usage
|
||||
*/
|
||||
initializeAgentMetrics(
|
||||
processId: string,
|
||||
options?: { sessionId?: string; featureId?: string }
|
||||
): AgentResourceMetrics | null {
|
||||
const process = this.processes.get(processId);
|
||||
if (!process) {
|
||||
logger.warn('Cannot initialize metrics for non-existent process', { processId });
|
||||
return null;
|
||||
}
|
||||
|
||||
if (process.type !== 'agent') {
|
||||
logger.warn('Cannot initialize agent metrics for non-agent process', {
|
||||
processId,
|
||||
type: process.type,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const metrics = createEmptyAgentResourceMetrics(processId, {
|
||||
sessionId: options?.sessionId || process.sessionId,
|
||||
featureId: options?.featureId || process.featureId,
|
||||
});
|
||||
|
||||
process.resourceMetrics = metrics;
|
||||
|
||||
logger.debug('Agent metrics initialized', { processId });
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resource metrics for an agent process
|
||||
*/
|
||||
getAgentMetrics(processId: string): AgentResourceMetrics | undefined {
|
||||
const process = this.processes.get(processId);
|
||||
return process?.resourceMetrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a tool invocation for an agent
|
||||
*/
|
||||
recordToolUse(processId: string, options: RecordToolUseOptions): void {
|
||||
const process = this.processes.get(processId);
|
||||
if (!process?.resourceMetrics) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metrics = process.resourceMetrics;
|
||||
const now = Date.now();
|
||||
|
||||
// Update tool metrics
|
||||
metrics.tools.totalInvocations++;
|
||||
metrics.tools.byTool[options.toolName] = (metrics.tools.byTool[options.toolName] || 0) + 1;
|
||||
|
||||
if (options.executionTime !== undefined) {
|
||||
metrics.tools.totalExecutionTime += options.executionTime;
|
||||
metrics.tools.avgExecutionTime =
|
||||
metrics.tools.totalExecutionTime / metrics.tools.totalInvocations;
|
||||
}
|
||||
|
||||
if (options.failed) {
|
||||
metrics.tools.failedInvocations++;
|
||||
}
|
||||
|
||||
// Update memory snapshot
|
||||
this.updateMemorySnapshot(processId);
|
||||
|
||||
metrics.lastUpdatedAt = now;
|
||||
metrics.duration = now - metrics.startedAt;
|
||||
|
||||
logger.debug('Tool use recorded', {
|
||||
processId,
|
||||
tool: options.toolName,
|
||||
totalInvocations: metrics.tools.totalInvocations,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a file operation for an agent
|
||||
*/
|
||||
recordFileOperation(processId: string, options: RecordFileOperationOptions): void {
|
||||
const process = this.processes.get(processId);
|
||||
if (!process?.resourceMetrics) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metrics = process.resourceMetrics;
|
||||
const now = Date.now();
|
||||
|
||||
// Update file I/O metrics based on operation type
|
||||
switch (options.operation) {
|
||||
case 'read':
|
||||
metrics.fileIO.reads++;
|
||||
if (options.bytes) {
|
||||
metrics.fileIO.bytesRead += options.bytes;
|
||||
}
|
||||
break;
|
||||
case 'write':
|
||||
metrics.fileIO.writes++;
|
||||
if (options.bytes) {
|
||||
metrics.fileIO.bytesWritten += options.bytes;
|
||||
}
|
||||
break;
|
||||
case 'edit':
|
||||
metrics.fileIO.edits++;
|
||||
if (options.bytes) {
|
||||
metrics.fileIO.bytesWritten += options.bytes;
|
||||
}
|
||||
break;
|
||||
case 'glob':
|
||||
metrics.fileIO.globs++;
|
||||
break;
|
||||
case 'grep':
|
||||
metrics.fileIO.greps++;
|
||||
break;
|
||||
}
|
||||
|
||||
// Track unique files accessed
|
||||
if (!metrics.fileIO.filesAccessed.includes(options.filePath)) {
|
||||
// Limit to 100 files to prevent memory bloat
|
||||
if (metrics.fileIO.filesAccessed.length < 100) {
|
||||
metrics.fileIO.filesAccessed.push(options.filePath);
|
||||
}
|
||||
}
|
||||
|
||||
metrics.lastUpdatedAt = now;
|
||||
metrics.duration = now - metrics.startedAt;
|
||||
|
||||
logger.debug('File operation recorded', {
|
||||
processId,
|
||||
operation: options.operation,
|
||||
filePath: options.filePath,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a bash command execution for an agent
|
||||
*/
|
||||
recordBashCommand(processId: string, options: RecordBashCommandOptions): void {
|
||||
const process = this.processes.get(processId);
|
||||
if (!process?.resourceMetrics) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metrics = process.resourceMetrics;
|
||||
const now = Date.now();
|
||||
|
||||
metrics.bash.commandCount++;
|
||||
metrics.bash.totalExecutionTime += options.executionTime;
|
||||
|
||||
if (options.exitCode !== null && options.exitCode !== 0) {
|
||||
metrics.bash.failedCommands++;
|
||||
}
|
||||
|
||||
// Keep only last 20 commands to prevent memory bloat
|
||||
if (metrics.bash.commands.length >= 20) {
|
||||
metrics.bash.commands.shift();
|
||||
}
|
||||
|
||||
metrics.bash.commands.push({
|
||||
command: options.command.substring(0, 200), // Truncate long commands
|
||||
exitCode: options.exitCode,
|
||||
duration: options.executionTime,
|
||||
timestamp: now,
|
||||
});
|
||||
|
||||
// Update memory snapshot
|
||||
this.updateMemorySnapshot(processId);
|
||||
|
||||
metrics.lastUpdatedAt = now;
|
||||
metrics.duration = now - metrics.startedAt;
|
||||
|
||||
logger.debug('Bash command recorded', {
|
||||
processId,
|
||||
command: options.command.substring(0, 50),
|
||||
exitCode: options.exitCode,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an API turn/iteration for an agent
|
||||
*/
|
||||
recordAPITurn(
|
||||
processId: string,
|
||||
options?: {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
thinkingTokens?: number;
|
||||
duration?: number;
|
||||
error?: boolean;
|
||||
}
|
||||
): void {
|
||||
const process = this.processes.get(processId);
|
||||
if (!process?.resourceMetrics) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metrics = process.resourceMetrics;
|
||||
const now = Date.now();
|
||||
|
||||
metrics.api.turns++;
|
||||
|
||||
if (options?.inputTokens !== undefined) {
|
||||
metrics.api.inputTokens = (metrics.api.inputTokens || 0) + options.inputTokens;
|
||||
}
|
||||
|
||||
if (options?.outputTokens !== undefined) {
|
||||
metrics.api.outputTokens = (metrics.api.outputTokens || 0) + options.outputTokens;
|
||||
}
|
||||
|
||||
if (options?.thinkingTokens !== undefined) {
|
||||
metrics.api.thinkingTokens = (metrics.api.thinkingTokens || 0) + options.thinkingTokens;
|
||||
}
|
||||
|
||||
if (options?.duration !== undefined) {
|
||||
metrics.api.totalDuration += options.duration;
|
||||
}
|
||||
|
||||
if (options?.error) {
|
||||
metrics.api.errors++;
|
||||
}
|
||||
|
||||
// Update memory snapshot
|
||||
this.updateMemorySnapshot(processId);
|
||||
|
||||
metrics.lastUpdatedAt = now;
|
||||
metrics.duration = now - metrics.startedAt;
|
||||
|
||||
logger.debug('API turn recorded', {
|
||||
processId,
|
||||
turns: metrics.api.turns,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update memory snapshot for an agent process
|
||||
* Takes a memory sample and updates peak/delta values
|
||||
*/
|
||||
updateMemorySnapshot(processId: string): void {
|
||||
const process = this.processes.get(processId);
|
||||
if (!process?.resourceMetrics) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metrics = process.resourceMetrics;
|
||||
const now = Date.now();
|
||||
const heapUsed = process.memoryUsage || 0;
|
||||
|
||||
// Update current heap
|
||||
metrics.memory.currentHeapUsed = heapUsed;
|
||||
|
||||
// Update peak if higher
|
||||
if (heapUsed > metrics.memory.peakHeapUsed) {
|
||||
metrics.memory.peakHeapUsed = heapUsed;
|
||||
}
|
||||
|
||||
// Calculate delta from start
|
||||
metrics.memory.deltaHeapUsed = heapUsed - metrics.memory.startHeapUsed;
|
||||
|
||||
// Add sample (keep max 60 samples = 1 minute at 1 sample/second)
|
||||
if (metrics.memory.samples.length >= 60) {
|
||||
metrics.memory.samples.shift();
|
||||
}
|
||||
metrics.memory.samples.push({ timestamp: now, heapUsed });
|
||||
|
||||
metrics.lastUpdatedAt = now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark agent metrics as completed (agent finished running)
|
||||
*/
|
||||
finalizeAgentMetrics(processId: string): void {
|
||||
const process = this.processes.get(processId);
|
||||
if (!process?.resourceMetrics) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metrics = process.resourceMetrics;
|
||||
const now = Date.now();
|
||||
|
||||
metrics.isRunning = false;
|
||||
metrics.lastUpdatedAt = now;
|
||||
metrics.duration = now - metrics.startedAt;
|
||||
|
||||
// Final memory snapshot
|
||||
this.updateMemorySnapshot(processId);
|
||||
|
||||
logger.debug('Agent metrics finalized', {
|
||||
processId,
|
||||
duration: metrics.duration,
|
||||
toolInvocations: metrics.tools.totalInvocations,
|
||||
fileReads: metrics.fileIO.reads,
|
||||
fileWrites: metrics.fileIO.writes,
|
||||
bashCommands: metrics.bash.commandCount,
|
||||
apiTurns: metrics.api.turns,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all agent processes with their resource metrics
|
||||
*/
|
||||
getAgentProcessesWithMetrics(): TrackedProcess[] {
|
||||
const result: TrackedProcess[] = [];
|
||||
|
||||
for (const process of this.processes.values()) {
|
||||
if (process.type === 'agent' && process.resourceMetrics) {
|
||||
result.push(process);
|
||||
}
|
||||
}
|
||||
|
||||
return result.sort((a, b) => b.startedAt - a.startedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary of resource usage across all running agents
|
||||
*/
|
||||
getAgentResourceSummary(): {
|
||||
totalAgents: number;
|
||||
runningAgents: number;
|
||||
totalFileReads: number;
|
||||
totalFileWrites: number;
|
||||
totalBytesRead: number;
|
||||
totalBytesWritten: number;
|
||||
totalToolInvocations: number;
|
||||
totalBashCommands: number;
|
||||
totalAPITurns: number;
|
||||
peakMemoryUsage: number;
|
||||
totalDuration: number;
|
||||
} {
|
||||
const summary = {
|
||||
totalAgents: 0,
|
||||
runningAgents: 0,
|
||||
totalFileReads: 0,
|
||||
totalFileWrites: 0,
|
||||
totalBytesRead: 0,
|
||||
totalBytesWritten: 0,
|
||||
totalToolInvocations: 0,
|
||||
totalBashCommands: 0,
|
||||
totalAPITurns: 0,
|
||||
peakMemoryUsage: 0,
|
||||
totalDuration: 0,
|
||||
};
|
||||
|
||||
for (const process of this.processes.values()) {
|
||||
if (process.type !== 'agent' || !process.resourceMetrics) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const metrics = process.resourceMetrics;
|
||||
summary.totalAgents++;
|
||||
|
||||
if (metrics.isRunning) {
|
||||
summary.runningAgents++;
|
||||
}
|
||||
|
||||
summary.totalFileReads += metrics.fileIO.reads;
|
||||
summary.totalFileWrites += metrics.fileIO.writes;
|
||||
summary.totalBytesRead += metrics.fileIO.bytesRead;
|
||||
summary.totalBytesWritten += metrics.fileIO.bytesWritten;
|
||||
summary.totalToolInvocations += metrics.tools.totalInvocations;
|
||||
summary.totalBashCommands += metrics.bash.commandCount;
|
||||
summary.totalAPITurns += metrics.api.turns;
|
||||
summary.totalDuration += metrics.duration;
|
||||
|
||||
if (metrics.memory.peakHeapUsed > summary.peakMemoryUsage) {
|
||||
summary.peakMemoryUsage = metrics.memory.peakHeapUsed;
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let processRegistryService: ProcessRegistryService | null = null;
|
||||
|
||||
/**
|
||||
* Get or create the ProcessRegistryService singleton
|
||||
*/
|
||||
export function getProcessRegistryService(
|
||||
events?: EventEmitter,
|
||||
config?: Partial<ProcessRegistryConfig>
|
||||
): ProcessRegistryService {
|
||||
if (!processRegistryService) {
|
||||
if (!events) {
|
||||
throw new Error('EventEmitter is required to initialize ProcessRegistryService');
|
||||
}
|
||||
processRegistryService = new ProcessRegistryService(events, config);
|
||||
}
|
||||
return processRegistryService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the singleton (for testing)
|
||||
*/
|
||||
export function resetProcessRegistryService(): void {
|
||||
if (processRegistryService) {
|
||||
processRegistryService.stop();
|
||||
processRegistryService = null;
|
||||
}
|
||||
}
|
||||
318
apps/server/tests/unit/routes/debug/metrics.test.ts
Normal file
318
apps/server/tests/unit/routes/debug/metrics.test.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Request, Response } from 'express';
|
||||
import {
|
||||
createGetMetricsHandler,
|
||||
createStartMetricsHandler,
|
||||
createStopMetricsHandler,
|
||||
createForceGCHandler,
|
||||
createClearHistoryHandler,
|
||||
} from '@/routes/debug/routes/metrics.js';
|
||||
import type { PerformanceMonitorService } from '@/services/performance-monitor-service.js';
|
||||
import type { DebugMetricsConfig, DebugMetricsSnapshot } from '@automaker/types';
|
||||
import { DEFAULT_DEBUG_METRICS_CONFIG } from '@automaker/types';
|
||||
|
||||
describe('Debug Metrics Routes', () => {
|
||||
let mockPerformanceMonitor: Partial<PerformanceMonitorService>;
|
||||
let mockReq: Partial<Request>;
|
||||
let mockRes: Partial<Response>;
|
||||
let jsonFn: ReturnType<typeof vi.fn>;
|
||||
let statusFn: ReturnType<typeof vi.fn>;
|
||||
|
||||
const mockConfig: DebugMetricsConfig = { ...DEFAULT_DEBUG_METRICS_CONFIG };
|
||||
const mockSnapshot: DebugMetricsSnapshot = {
|
||||
timestamp: Date.now(),
|
||||
memory: {
|
||||
timestamp: Date.now(),
|
||||
server: {
|
||||
heapTotal: 100 * 1024 * 1024,
|
||||
heapUsed: 50 * 1024 * 1024,
|
||||
external: 5 * 1024 * 1024,
|
||||
rss: 150 * 1024 * 1024,
|
||||
arrayBuffers: 1 * 1024 * 1024,
|
||||
},
|
||||
},
|
||||
cpu: {
|
||||
timestamp: Date.now(),
|
||||
server: {
|
||||
percentage: 25.5,
|
||||
user: 1000,
|
||||
system: 500,
|
||||
},
|
||||
eventLoopLag: 5,
|
||||
},
|
||||
processes: [],
|
||||
processSummary: {
|
||||
total: 0,
|
||||
running: 0,
|
||||
idle: 0,
|
||||
stopped: 0,
|
||||
errored: 0,
|
||||
byType: { agent: 0, cli: 0, terminal: 0, worker: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jsonFn = vi.fn();
|
||||
statusFn = vi.fn(() => ({ json: jsonFn }));
|
||||
|
||||
mockPerformanceMonitor = {
|
||||
getLatestSnapshot: vi.fn(() => mockSnapshot),
|
||||
getConfig: vi.fn(() => mockConfig),
|
||||
isActive: vi.fn(() => true),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
updateConfig: vi.fn(),
|
||||
forceGC: vi.fn(() => true),
|
||||
clearHistory: vi.fn(),
|
||||
};
|
||||
|
||||
mockReq = {
|
||||
body: {},
|
||||
query: {},
|
||||
params: {},
|
||||
};
|
||||
|
||||
mockRes = {
|
||||
json: jsonFn,
|
||||
status: statusFn,
|
||||
};
|
||||
});
|
||||
|
||||
describe('GET /api/debug/metrics', () => {
|
||||
it('should return current metrics snapshot', () => {
|
||||
const handler = createGetMetricsHandler(mockPerformanceMonitor as PerformanceMonitorService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(jsonFn).toHaveBeenCalledWith({
|
||||
active: true,
|
||||
config: mockConfig,
|
||||
snapshot: mockSnapshot,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined snapshot when no data available', () => {
|
||||
(mockPerformanceMonitor.getLatestSnapshot as ReturnType<typeof vi.fn>).mockReturnValue(null);
|
||||
|
||||
const handler = createGetMetricsHandler(mockPerformanceMonitor as PerformanceMonitorService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(jsonFn).toHaveBeenCalledWith({
|
||||
active: true,
|
||||
config: mockConfig,
|
||||
snapshot: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return active status correctly', () => {
|
||||
(mockPerformanceMonitor.isActive as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
||||
|
||||
const handler = createGetMetricsHandler(mockPerformanceMonitor as PerformanceMonitorService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(jsonFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
active: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/debug/metrics/start', () => {
|
||||
it('should start metrics collection', () => {
|
||||
const handler = createStartMetricsHandler(
|
||||
mockPerformanceMonitor as PerformanceMonitorService
|
||||
);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.start).toHaveBeenCalled();
|
||||
expect(jsonFn).toHaveBeenCalledWith({
|
||||
active: true,
|
||||
config: mockConfig,
|
||||
});
|
||||
});
|
||||
|
||||
it('should apply config overrides when provided', () => {
|
||||
mockReq.body = {
|
||||
config: {
|
||||
collectionInterval: 5000,
|
||||
maxDataPoints: 500,
|
||||
},
|
||||
};
|
||||
|
||||
const handler = createStartMetricsHandler(
|
||||
mockPerformanceMonitor as PerformanceMonitorService
|
||||
);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({
|
||||
collectionInterval: 5000,
|
||||
maxDataPoints: 500,
|
||||
});
|
||||
});
|
||||
|
||||
it('should sanitize config values - clamp collectionInterval to min 100ms', () => {
|
||||
mockReq.body = {
|
||||
config: {
|
||||
collectionInterval: 10, // Below minimum of 100ms
|
||||
},
|
||||
};
|
||||
|
||||
const handler = createStartMetricsHandler(
|
||||
mockPerformanceMonitor as PerformanceMonitorService
|
||||
);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({
|
||||
collectionInterval: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('should sanitize config values - clamp collectionInterval to max 60000ms', () => {
|
||||
mockReq.body = {
|
||||
config: {
|
||||
collectionInterval: 100000, // Above maximum of 60000ms
|
||||
},
|
||||
};
|
||||
|
||||
const handler = createStartMetricsHandler(
|
||||
mockPerformanceMonitor as PerformanceMonitorService
|
||||
);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({
|
||||
collectionInterval: 60000,
|
||||
});
|
||||
});
|
||||
|
||||
it('should sanitize config values - clamp maxDataPoints to bounds', () => {
|
||||
mockReq.body = {
|
||||
config: {
|
||||
maxDataPoints: 5, // Below minimum of 10
|
||||
},
|
||||
};
|
||||
|
||||
const handler = createStartMetricsHandler(
|
||||
mockPerformanceMonitor as PerformanceMonitorService
|
||||
);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({
|
||||
maxDataPoints: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('should sanitize config values - clamp maxDataPoints to max', () => {
|
||||
mockReq.body = {
|
||||
config: {
|
||||
maxDataPoints: 50000, // Above maximum of 10000
|
||||
},
|
||||
};
|
||||
|
||||
const handler = createStartMetricsHandler(
|
||||
mockPerformanceMonitor as PerformanceMonitorService
|
||||
);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({
|
||||
maxDataPoints: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore non-object config', () => {
|
||||
mockReq.body = {
|
||||
config: 'not-an-object',
|
||||
};
|
||||
|
||||
const handler = createStartMetricsHandler(
|
||||
mockPerformanceMonitor as PerformanceMonitorService
|
||||
);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.updateConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ignore empty config object', () => {
|
||||
mockReq.body = {
|
||||
config: {},
|
||||
};
|
||||
|
||||
const handler = createStartMetricsHandler(
|
||||
mockPerformanceMonitor as PerformanceMonitorService
|
||||
);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.updateConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should only accept boolean flags as actual booleans', () => {
|
||||
mockReq.body = {
|
||||
config: {
|
||||
memoryEnabled: 'true', // String, not boolean - should be ignored
|
||||
cpuEnabled: true, // Boolean - should be accepted
|
||||
},
|
||||
};
|
||||
|
||||
const handler = createStartMetricsHandler(
|
||||
mockPerformanceMonitor as PerformanceMonitorService
|
||||
);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({
|
||||
cpuEnabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/debug/metrics/stop', () => {
|
||||
it('should stop metrics collection', () => {
|
||||
const handler = createStopMetricsHandler(mockPerformanceMonitor as PerformanceMonitorService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.stop).toHaveBeenCalled();
|
||||
expect(jsonFn).toHaveBeenCalledWith({
|
||||
active: false,
|
||||
config: mockConfig,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/debug/metrics/gc', () => {
|
||||
it('should trigger garbage collection when available', () => {
|
||||
const handler = createForceGCHandler(mockPerformanceMonitor as PerformanceMonitorService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.forceGC).toHaveBeenCalled();
|
||||
expect(jsonFn).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
message: 'Garbage collection triggered',
|
||||
});
|
||||
});
|
||||
|
||||
it('should report when garbage collection is not available', () => {
|
||||
(mockPerformanceMonitor.forceGC as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
||||
|
||||
const handler = createForceGCHandler(mockPerformanceMonitor as PerformanceMonitorService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(jsonFn).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
message: 'Garbage collection not available (start Node.js with --expose-gc flag)',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/debug/metrics/clear', () => {
|
||||
it('should clear metrics history', () => {
|
||||
const handler = createClearHistoryHandler(
|
||||
mockPerformanceMonitor as PerformanceMonitorService
|
||||
);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.clearHistory).toHaveBeenCalled();
|
||||
expect(jsonFn).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
message: 'Metrics history cleared',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
293
apps/server/tests/unit/routes/debug/processes.test.ts
Normal file
293
apps/server/tests/unit/routes/debug/processes.test.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Request, Response } from 'express';
|
||||
import {
|
||||
createGetProcessesHandler,
|
||||
createGetProcessHandler,
|
||||
createGetSummaryHandler,
|
||||
} from '@/routes/debug/routes/processes.js';
|
||||
import type { ProcessRegistryService } from '@/services/process-registry-service.js';
|
||||
import type { TrackedProcess, ProcessSummary } from '@automaker/types';
|
||||
|
||||
describe('Debug Processes Routes', () => {
|
||||
let mockProcessRegistry: Partial<ProcessRegistryService>;
|
||||
let mockReq: Partial<Request>;
|
||||
let mockRes: Partial<Response>;
|
||||
let jsonFn: ReturnType<typeof vi.fn>;
|
||||
let statusFn: ReturnType<typeof vi.fn>;
|
||||
|
||||
const mockProcesses: TrackedProcess[] = [
|
||||
{
|
||||
id: 'process-1',
|
||||
pid: 1234,
|
||||
type: 'agent',
|
||||
name: 'Agent 1',
|
||||
status: 'running',
|
||||
startedAt: Date.now() - 60000,
|
||||
featureId: 'feature-1',
|
||||
sessionId: 'session-1',
|
||||
},
|
||||
{
|
||||
id: 'process-2',
|
||||
pid: 5678,
|
||||
type: 'terminal',
|
||||
name: 'Terminal 1',
|
||||
status: 'idle',
|
||||
startedAt: Date.now() - 30000,
|
||||
sessionId: 'session-1',
|
||||
},
|
||||
{
|
||||
id: 'process-3',
|
||||
pid: 9012,
|
||||
type: 'cli',
|
||||
name: 'CLI 1',
|
||||
status: 'stopped',
|
||||
startedAt: Date.now() - 120000,
|
||||
stoppedAt: Date.now() - 60000,
|
||||
exitCode: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const mockSummary: ProcessSummary = {
|
||||
total: 3,
|
||||
running: 1,
|
||||
idle: 1,
|
||||
stopped: 1,
|
||||
errored: 0,
|
||||
byType: {
|
||||
agent: 1,
|
||||
cli: 1,
|
||||
terminal: 1,
|
||||
worker: 0,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jsonFn = vi.fn();
|
||||
statusFn = vi.fn(() => ({ json: jsonFn }));
|
||||
|
||||
mockProcessRegistry = {
|
||||
getProcesses: vi.fn(() => mockProcesses),
|
||||
getProcess: vi.fn((id: string) => mockProcesses.find((p) => p.id === id)),
|
||||
getProcessSummary: vi.fn(() => mockSummary),
|
||||
};
|
||||
|
||||
mockReq = {
|
||||
body: {},
|
||||
query: {},
|
||||
params: {},
|
||||
};
|
||||
|
||||
mockRes = {
|
||||
json: jsonFn,
|
||||
status: statusFn,
|
||||
};
|
||||
});
|
||||
|
||||
describe('GET /api/debug/processes', () => {
|
||||
it('should return list of processes with summary', () => {
|
||||
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockProcessRegistry.getProcesses).toHaveBeenCalled();
|
||||
expect(mockProcessRegistry.getProcessSummary).toHaveBeenCalled();
|
||||
expect(jsonFn).toHaveBeenCalledWith({
|
||||
processes: mockProcesses,
|
||||
summary: mockSummary,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass type filter to service', () => {
|
||||
mockReq.query = { type: 'agent' };
|
||||
|
||||
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'agent',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass status filter to service', () => {
|
||||
mockReq.query = { status: 'running' };
|
||||
|
||||
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: 'running',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass includeStopped flag when set to "true"', () => {
|
||||
mockReq.query = { includeStopped: 'true' };
|
||||
|
||||
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
includeStopped: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should not pass includeStopped when not "true"', () => {
|
||||
mockReq.query = { includeStopped: 'false' };
|
||||
|
||||
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
includeStopped: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass sessionId filter to service', () => {
|
||||
mockReq.query = { sessionId: 'session-1' };
|
||||
|
||||
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: 'session-1',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass featureId filter to service', () => {
|
||||
mockReq.query = { featureId: 'feature-1' };
|
||||
|
||||
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
featureId: 'feature-1',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multiple filters', () => {
|
||||
mockReq.query = {
|
||||
type: 'agent',
|
||||
status: 'running',
|
||||
sessionId: 'session-1',
|
||||
includeStopped: 'true',
|
||||
};
|
||||
|
||||
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith({
|
||||
type: 'agent',
|
||||
status: 'running',
|
||||
sessionId: 'session-1',
|
||||
includeStopped: true,
|
||||
featureId: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/debug/processes/:id', () => {
|
||||
it('should return a specific process by ID', () => {
|
||||
mockReq.params = { id: 'process-1' };
|
||||
|
||||
const handler = createGetProcessHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockProcessRegistry.getProcess).toHaveBeenCalledWith('process-1');
|
||||
expect(jsonFn).toHaveBeenCalledWith(mockProcesses[0]);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent process', () => {
|
||||
mockReq.params = { id: 'non-existent' };
|
||||
(mockProcessRegistry.getProcess as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
|
||||
|
||||
const handler = createGetProcessHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(statusFn).toHaveBeenCalledWith(404);
|
||||
expect(jsonFn).toHaveBeenCalledWith({
|
||||
error: 'Process not found',
|
||||
id: 'non-existent',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 for empty process ID', () => {
|
||||
mockReq.params = { id: '' };
|
||||
|
||||
const handler = createGetProcessHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(statusFn).toHaveBeenCalledWith(400);
|
||||
expect(jsonFn).toHaveBeenCalledWith({
|
||||
error: 'Invalid process ID format',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 for process ID exceeding max length', () => {
|
||||
mockReq.params = { id: 'a'.repeat(257) };
|
||||
|
||||
const handler = createGetProcessHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(statusFn).toHaveBeenCalledWith(400);
|
||||
expect(jsonFn).toHaveBeenCalledWith({
|
||||
error: 'Invalid process ID format',
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept process ID at max length', () => {
|
||||
mockReq.params = { id: 'a'.repeat(256) };
|
||||
(mockProcessRegistry.getProcess as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
|
||||
|
||||
const handler = createGetProcessHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
// Should pass validation but process not found
|
||||
expect(statusFn).toHaveBeenCalledWith(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/debug/processes/summary', () => {
|
||||
it('should return process summary', () => {
|
||||
const handler = createGetSummaryHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockProcessRegistry.getProcessSummary).toHaveBeenCalled();
|
||||
expect(jsonFn).toHaveBeenCalledWith(mockSummary);
|
||||
});
|
||||
|
||||
it('should return correct counts', () => {
|
||||
const customSummary: ProcessSummary = {
|
||||
total: 10,
|
||||
running: 5,
|
||||
idle: 2,
|
||||
stopped: 2,
|
||||
errored: 1,
|
||||
byType: {
|
||||
agent: 4,
|
||||
cli: 3,
|
||||
terminal: 2,
|
||||
worker: 1,
|
||||
},
|
||||
};
|
||||
|
||||
(mockProcessRegistry.getProcessSummary as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
customSummary
|
||||
);
|
||||
|
||||
const handler = createGetSummaryHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(jsonFn).toHaveBeenCalledWith(customSummary);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,418 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { PerformanceMonitorService } from '@/services/performance-monitor-service.js';
|
||||
import { createEventEmitter } from '@/lib/events.js';
|
||||
import type { EventEmitter } from '@/lib/events.js';
|
||||
import type { TrackedProcess, DebugMetricsConfig } from '@automaker/types';
|
||||
import { DEFAULT_DEBUG_METRICS_CONFIG } from '@automaker/types';
|
||||
|
||||
// Mock the logger to prevent console output during tests
|
||||
vi.mock('@automaker/utils', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('PerformanceMonitorService', () => {
|
||||
let service: PerformanceMonitorService;
|
||||
let events: EventEmitter;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
events = createEventEmitter();
|
||||
service = new PerformanceMonitorService(events);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service.stop();
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize with default configuration', () => {
|
||||
const config = service.getConfig();
|
||||
expect(config.collectionInterval).toBe(DEFAULT_DEBUG_METRICS_CONFIG.collectionInterval);
|
||||
expect(config.maxDataPoints).toBe(DEFAULT_DEBUG_METRICS_CONFIG.maxDataPoints);
|
||||
expect(config.memoryEnabled).toBe(DEFAULT_DEBUG_METRICS_CONFIG.memoryEnabled);
|
||||
expect(config.cpuEnabled).toBe(DEFAULT_DEBUG_METRICS_CONFIG.cpuEnabled);
|
||||
});
|
||||
|
||||
it('should accept custom configuration on initialization', () => {
|
||||
const customConfig: Partial<DebugMetricsConfig> = {
|
||||
collectionInterval: 5000,
|
||||
maxDataPoints: 500,
|
||||
memoryEnabled: false,
|
||||
};
|
||||
|
||||
const customService = new PerformanceMonitorService(events, customConfig);
|
||||
const config = customService.getConfig();
|
||||
|
||||
expect(config.collectionInterval).toBe(5000);
|
||||
expect(config.maxDataPoints).toBe(500);
|
||||
expect(config.memoryEnabled).toBe(false);
|
||||
expect(config.cpuEnabled).toBe(DEFAULT_DEBUG_METRICS_CONFIG.cpuEnabled);
|
||||
|
||||
customService.stop();
|
||||
});
|
||||
|
||||
it('should not be running initially', () => {
|
||||
expect(service.isActive()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('start/stop', () => {
|
||||
it('should start metrics collection', () => {
|
||||
service.start();
|
||||
expect(service.isActive()).toBe(true);
|
||||
});
|
||||
|
||||
it('should stop metrics collection', () => {
|
||||
service.start();
|
||||
expect(service.isActive()).toBe(true);
|
||||
|
||||
service.stop();
|
||||
expect(service.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
it('should not start again if already running', () => {
|
||||
service.start();
|
||||
const isActive1 = service.isActive();
|
||||
|
||||
service.start(); // Should log warning but not throw
|
||||
const isActive2 = service.isActive();
|
||||
|
||||
expect(isActive1).toBe(true);
|
||||
expect(isActive2).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle stop when not running', () => {
|
||||
// Should not throw
|
||||
expect(() => service.stop()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('configuration updates', () => {
|
||||
it('should update configuration', () => {
|
||||
service.updateConfig({ collectionInterval: 2000 });
|
||||
expect(service.getConfig().collectionInterval).toBe(2000);
|
||||
});
|
||||
|
||||
it('should restart collection if running when config is updated', () => {
|
||||
service.start();
|
||||
expect(service.isActive()).toBe(true);
|
||||
|
||||
service.updateConfig({ collectionInterval: 5000 });
|
||||
|
||||
// Should still be running after config update
|
||||
expect(service.isActive()).toBe(true);
|
||||
expect(service.getConfig().collectionInterval).toBe(5000);
|
||||
});
|
||||
|
||||
it('should resize data buffers when maxDataPoints changes', () => {
|
||||
// Start and collect some data
|
||||
service.start();
|
||||
|
||||
// Collect multiple data points
|
||||
for (let i = 0; i < 50; i++) {
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
}
|
||||
|
||||
// Reduce max data points
|
||||
service.updateConfig({ maxDataPoints: 10 });
|
||||
|
||||
const history = service.getMemoryHistory();
|
||||
expect(history.length).toBeLessThanOrEqual(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('metrics collection', () => {
|
||||
it('should emit debug:metrics event on collection', () => {
|
||||
const callback = vi.fn();
|
||||
events.subscribe(callback);
|
||||
|
||||
service.start();
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
const [eventType, eventData] = callback.mock.calls[0];
|
||||
expect(eventType).toBe('debug:metrics');
|
||||
expect(eventData).toHaveProperty('timestamp');
|
||||
expect(eventData).toHaveProperty('metrics');
|
||||
});
|
||||
|
||||
it('should collect memory metrics when memoryEnabled is true', () => {
|
||||
const callback = vi.fn();
|
||||
events.subscribe(callback);
|
||||
|
||||
service.start();
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
|
||||
const [, eventData] = callback.mock.calls[0];
|
||||
expect(eventData.metrics.memory.server).toBeDefined();
|
||||
expect(eventData.metrics.memory.server.heapUsed).toBeGreaterThan(0);
|
||||
expect(eventData.metrics.memory.server.heapTotal).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should not collect memory metrics when memoryEnabled is false', () => {
|
||||
const customService = new PerformanceMonitorService(events, { memoryEnabled: false });
|
||||
const callback = vi.fn();
|
||||
events.subscribe(callback);
|
||||
|
||||
customService.start();
|
||||
vi.advanceTimersByTime(customService.getConfig().collectionInterval);
|
||||
|
||||
const [, eventData] = callback.mock.calls[0];
|
||||
expect(eventData.metrics.memory.server).toBeUndefined();
|
||||
|
||||
customService.stop();
|
||||
});
|
||||
|
||||
it('should collect CPU metrics when cpuEnabled is true', () => {
|
||||
const callback = vi.fn();
|
||||
events.subscribe(callback);
|
||||
|
||||
service.start();
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
|
||||
// Need at least 2 collections for CPU diff
|
||||
const lastCall = callback.mock.calls[callback.mock.calls.length - 1];
|
||||
const [, eventData] = lastCall;
|
||||
expect(eventData.metrics.cpu.server).toBeDefined();
|
||||
});
|
||||
|
||||
it('should track event loop lag', () => {
|
||||
const callback = vi.fn();
|
||||
events.subscribe(callback);
|
||||
|
||||
service.start();
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
|
||||
const [, eventData] = callback.mock.calls[0];
|
||||
expect(eventData.metrics.cpu.eventLoopLag).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('memory history', () => {
|
||||
it('should return empty history initially', () => {
|
||||
const history = service.getMemoryHistory();
|
||||
expect(history).toEqual([]);
|
||||
});
|
||||
|
||||
it('should accumulate memory history over time', () => {
|
||||
service.start();
|
||||
|
||||
// Collect multiple data points
|
||||
for (let i = 0; i < 5; i++) {
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
}
|
||||
|
||||
const history = service.getMemoryHistory();
|
||||
expect(history.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should limit history to maxDataPoints', () => {
|
||||
const maxPoints = 10;
|
||||
const customService = new PerformanceMonitorService(events, { maxDataPoints: maxPoints });
|
||||
customService.start();
|
||||
|
||||
// Collect more data points than max
|
||||
for (let i = 0; i < maxPoints + 10; i++) {
|
||||
vi.advanceTimersByTime(customService.getConfig().collectionInterval);
|
||||
}
|
||||
|
||||
const history = customService.getMemoryHistory();
|
||||
expect(history.length).toBeLessThanOrEqual(maxPoints);
|
||||
|
||||
customService.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CPU history', () => {
|
||||
it('should return empty CPU history initially', () => {
|
||||
const history = service.getCPUHistory();
|
||||
expect(history).toEqual([]);
|
||||
});
|
||||
|
||||
it('should accumulate CPU history over time', () => {
|
||||
service.start();
|
||||
|
||||
// Collect multiple data points (need at least 2 for CPU diff)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
}
|
||||
|
||||
const history = service.getCPUHistory();
|
||||
expect(history.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('process provider', () => {
|
||||
it('should use provided process provider', () => {
|
||||
const mockProcesses: TrackedProcess[] = [
|
||||
{
|
||||
id: 'test-1',
|
||||
type: 'agent',
|
||||
name: 'TestAgent',
|
||||
status: 'running',
|
||||
startedAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: 'test-2',
|
||||
type: 'terminal',
|
||||
name: 'TestTerminal',
|
||||
status: 'idle',
|
||||
startedAt: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const provider = vi.fn(() => mockProcesses);
|
||||
service.setProcessProvider(provider);
|
||||
|
||||
const callback = vi.fn();
|
||||
events.subscribe(callback);
|
||||
|
||||
service.start();
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
|
||||
const [, eventData] = callback.mock.calls[0];
|
||||
expect(eventData.metrics.processes).toEqual(mockProcesses);
|
||||
expect(eventData.metrics.processSummary.total).toBe(2);
|
||||
expect(eventData.metrics.processSummary.running).toBe(1);
|
||||
expect(eventData.metrics.processSummary.idle).toBe(1);
|
||||
expect(eventData.metrics.processSummary.byType.agent).toBe(1);
|
||||
expect(eventData.metrics.processSummary.byType.terminal).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLatestSnapshot', () => {
|
||||
it('should return null when no data collected', () => {
|
||||
const snapshot = service.getLatestSnapshot();
|
||||
expect(snapshot).toBeNull();
|
||||
});
|
||||
|
||||
it('should return snapshot after data collection', () => {
|
||||
service.start();
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
|
||||
const snapshot = service.getLatestSnapshot();
|
||||
expect(snapshot).not.toBeNull();
|
||||
expect(snapshot).toHaveProperty('timestamp');
|
||||
expect(snapshot).toHaveProperty('memory');
|
||||
expect(snapshot).toHaveProperty('cpu');
|
||||
expect(snapshot).toHaveProperty('processes');
|
||||
expect(snapshot).toHaveProperty('processSummary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearHistory', () => {
|
||||
it('should clear all history', () => {
|
||||
service.start();
|
||||
|
||||
// Collect some data
|
||||
for (let i = 0; i < 5; i++) {
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
}
|
||||
|
||||
expect(service.getMemoryHistory().length).toBeGreaterThan(0);
|
||||
|
||||
service.clearHistory();
|
||||
|
||||
expect(service.getMemoryHistory().length).toBe(0);
|
||||
expect(service.getCPUHistory().length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('forceGC', () => {
|
||||
it('should return false when gc is not available', () => {
|
||||
const originalGc = global.gc;
|
||||
global.gc = undefined;
|
||||
|
||||
const result = service.forceGC();
|
||||
expect(result).toBe(false);
|
||||
|
||||
// Restore
|
||||
global.gc = originalGc;
|
||||
});
|
||||
|
||||
it('should return true and call gc when available', () => {
|
||||
const mockGc = vi.fn();
|
||||
global.gc = mockGc;
|
||||
|
||||
const result = service.forceGC();
|
||||
expect(result).toBe(true);
|
||||
expect(mockGc).toHaveBeenCalled();
|
||||
|
||||
// Cleanup
|
||||
global.gc = undefined;
|
||||
});
|
||||
});
|
||||
|
||||
describe('memory trend analysis', () => {
|
||||
it('should not calculate trend with insufficient data', () => {
|
||||
service.start();
|
||||
|
||||
// Collect only a few data points
|
||||
for (let i = 0; i < 5; i++) {
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
}
|
||||
|
||||
const snapshot = service.getLatestSnapshot();
|
||||
// Trend requires at least 10 samples
|
||||
expect(snapshot?.memoryTrend).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should calculate trend with sufficient data', () => {
|
||||
service.start();
|
||||
|
||||
// Collect enough data points for trend analysis
|
||||
for (let i = 0; i < 15; i++) {
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
}
|
||||
|
||||
const snapshot = service.getLatestSnapshot();
|
||||
expect(snapshot?.memoryTrend).toBeDefined();
|
||||
expect(snapshot?.memoryTrend).toHaveProperty('growthRate');
|
||||
expect(snapshot?.memoryTrend).toHaveProperty('isLeaking');
|
||||
expect(snapshot?.memoryTrend).toHaveProperty('confidence');
|
||||
expect(snapshot?.memoryTrend).toHaveProperty('sampleCount');
|
||||
});
|
||||
});
|
||||
|
||||
describe('process summary calculation', () => {
|
||||
it('should correctly categorize processes by status', () => {
|
||||
const mockProcesses: TrackedProcess[] = [
|
||||
{ id: '1', type: 'agent', name: 'A1', status: 'running', startedAt: Date.now() },
|
||||
{ id: '2', type: 'agent', name: 'A2', status: 'starting', startedAt: Date.now() },
|
||||
{ id: '3', type: 'terminal', name: 'T1', status: 'idle', startedAt: Date.now() },
|
||||
{ id: '4', type: 'terminal', name: 'T2', status: 'stopped', startedAt: Date.now() },
|
||||
{ id: '5', type: 'cli', name: 'C1', status: 'stopping', startedAt: Date.now() },
|
||||
{ id: '6', type: 'worker', name: 'W1', status: 'error', startedAt: Date.now() },
|
||||
];
|
||||
|
||||
service.setProcessProvider(() => mockProcesses);
|
||||
|
||||
const callback = vi.fn();
|
||||
events.subscribe(callback);
|
||||
|
||||
service.start();
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
|
||||
const [, eventData] = callback.mock.calls[0];
|
||||
const summary = eventData.metrics.processSummary;
|
||||
|
||||
expect(summary.total).toBe(6);
|
||||
expect(summary.running).toBe(2); // running + starting
|
||||
expect(summary.idle).toBe(1);
|
||||
expect(summary.stopped).toBe(2); // stopped + stopping
|
||||
expect(summary.errored).toBe(1);
|
||||
expect(summary.byType.agent).toBe(2);
|
||||
expect(summary.byType.terminal).toBe(2);
|
||||
expect(summary.byType.cli).toBe(1);
|
||||
expect(summary.byType.worker).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
538
apps/server/tests/unit/services/process-registry-service.test.ts
Normal file
538
apps/server/tests/unit/services/process-registry-service.test.ts
Normal file
@@ -0,0 +1,538 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
ProcessRegistryService,
|
||||
getProcessRegistryService,
|
||||
resetProcessRegistryService,
|
||||
} from '@/services/process-registry-service.js';
|
||||
import { createEventEmitter } from '@/lib/events.js';
|
||||
import type { EventEmitter } from '@/lib/events.js';
|
||||
import type { TrackedProcess, ProcessType, ProcessStatus } from '@automaker/types';
|
||||
|
||||
// Mock the logger to prevent console output during tests
|
||||
vi.mock('@automaker/utils', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ProcessRegistryService', () => {
|
||||
let service: ProcessRegistryService;
|
||||
let events: EventEmitter;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
events = createEventEmitter();
|
||||
service = new ProcessRegistryService(events);
|
||||
resetProcessRegistryService();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service.stop();
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize with default configuration', () => {
|
||||
const config = service.getConfig();
|
||||
expect(config.stoppedProcessRetention).toBe(5 * 60 * 1000);
|
||||
expect(config.cleanupInterval).toBe(60 * 1000);
|
||||
expect(config.maxStoppedProcesses).toBe(100);
|
||||
});
|
||||
|
||||
it('should accept custom configuration', () => {
|
||||
const customService = new ProcessRegistryService(events, {
|
||||
stoppedProcessRetention: 10000,
|
||||
maxStoppedProcesses: 50,
|
||||
});
|
||||
|
||||
const config = customService.getConfig();
|
||||
expect(config.stoppedProcessRetention).toBe(10000);
|
||||
expect(config.maxStoppedProcesses).toBe(50);
|
||||
expect(config.cleanupInterval).toBe(60 * 1000);
|
||||
|
||||
customService.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe('start/stop', () => {
|
||||
it('should start the service', () => {
|
||||
expect(() => service.start()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should stop the service', () => {
|
||||
service.start();
|
||||
expect(() => service.stop()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not start again if already running', () => {
|
||||
service.start();
|
||||
// Should log warning but not throw
|
||||
expect(() => service.start()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('process registration', () => {
|
||||
it('should register a new process', () => {
|
||||
const process = service.registerProcess({
|
||||
id: 'test-1',
|
||||
pid: 1234,
|
||||
type: 'agent',
|
||||
name: 'TestAgent',
|
||||
});
|
||||
|
||||
expect(process.id).toBe('test-1');
|
||||
expect(process.pid).toBe(1234);
|
||||
expect(process.type).toBe('agent');
|
||||
expect(process.name).toBe('TestAgent');
|
||||
expect(process.status).toBe('starting');
|
||||
expect(process.startedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should register a process with all optional fields', () => {
|
||||
const process = service.registerProcess({
|
||||
id: 'test-2',
|
||||
pid: 5678,
|
||||
type: 'terminal',
|
||||
name: 'TestTerminal',
|
||||
featureId: 'feature-123',
|
||||
sessionId: 'session-456',
|
||||
command: 'bash',
|
||||
cwd: '/home/user',
|
||||
});
|
||||
|
||||
expect(process.featureId).toBe('feature-123');
|
||||
expect(process.sessionId).toBe('session-456');
|
||||
expect(process.command).toBe('bash');
|
||||
expect(process.cwd).toBe('/home/user');
|
||||
});
|
||||
|
||||
it('should emit debug:process-spawned event on registration', () => {
|
||||
const callback = vi.fn();
|
||||
events.subscribe(callback);
|
||||
|
||||
service.registerProcess({
|
||||
id: 'test-3',
|
||||
pid: 111,
|
||||
type: 'cli',
|
||||
name: 'TestCLI',
|
||||
});
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
const [eventType, eventData] = callback.mock.calls[0];
|
||||
expect(eventType).toBe('debug:process-spawned');
|
||||
expect(eventData.process.id).toBe('test-3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('process retrieval', () => {
|
||||
beforeEach(() => {
|
||||
// Register test processes
|
||||
service.registerProcess({
|
||||
id: 'p1',
|
||||
pid: 1,
|
||||
type: 'agent',
|
||||
name: 'Agent1',
|
||||
featureId: 'f1',
|
||||
sessionId: 's1',
|
||||
});
|
||||
service.registerProcess({
|
||||
id: 'p2',
|
||||
pid: 2,
|
||||
type: 'terminal',
|
||||
name: 'Terminal1',
|
||||
sessionId: 's1',
|
||||
});
|
||||
service.registerProcess({ id: 'p3', pid: 3, type: 'cli', name: 'CLI1', featureId: 'f2' });
|
||||
});
|
||||
|
||||
it('should get a process by ID', () => {
|
||||
const process = service.getProcess('p1');
|
||||
expect(process).toBeDefined();
|
||||
expect(process?.name).toBe('Agent1');
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent process', () => {
|
||||
const process = service.getProcess('non-existent');
|
||||
expect(process).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should check if process exists', () => {
|
||||
expect(service.hasProcess('p1')).toBe(true);
|
||||
expect(service.hasProcess('non-existent')).toBe(false);
|
||||
});
|
||||
|
||||
it('should get all processes without filters', () => {
|
||||
const processes = service.getProcesses({ includeStopped: true });
|
||||
expect(processes.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should filter by type', () => {
|
||||
const agents = service.getProcesses({ type: 'agent', includeStopped: true });
|
||||
expect(agents.length).toBe(1);
|
||||
expect(agents[0].type).toBe('agent');
|
||||
});
|
||||
|
||||
it('should filter by session ID', () => {
|
||||
const sessionProcesses = service.getProcesses({ sessionId: 's1', includeStopped: true });
|
||||
expect(sessionProcesses.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should filter by feature ID', () => {
|
||||
const featureProcesses = service.getProcesses({ featureId: 'f1', includeStopped: true });
|
||||
expect(featureProcesses.length).toBe(1);
|
||||
expect(featureProcesses[0].id).toBe('p1');
|
||||
});
|
||||
|
||||
it('should exclude stopped processes by default', () => {
|
||||
service.markStopped('p1');
|
||||
const processes = service.getProcesses();
|
||||
expect(processes.length).toBe(2);
|
||||
expect(processes.find((p) => p.id === 'p1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include stopped processes when requested', () => {
|
||||
service.markStopped('p1');
|
||||
const processes = service.getProcesses({ includeStopped: true });
|
||||
expect(processes.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should sort processes by start time (most recent first)', () => {
|
||||
// Re-register processes with different timestamps
|
||||
service.clear();
|
||||
|
||||
// Register p1 at time 0
|
||||
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'Agent1' });
|
||||
|
||||
// Advance time and register p2
|
||||
vi.advanceTimersByTime(1000);
|
||||
service.registerProcess({ id: 'p2', pid: 2, type: 'terminal', name: 'Terminal1' });
|
||||
|
||||
// Advance time and register p3
|
||||
vi.advanceTimersByTime(1000);
|
||||
service.registerProcess({ id: 'p3', pid: 3, type: 'cli', name: 'CLI1' });
|
||||
|
||||
const processes = service.getProcesses({ includeStopped: true });
|
||||
// p3 was registered last (most recent), so it should be first
|
||||
expect(processes[0].id).toBe('p3');
|
||||
expect(processes[1].id).toBe('p2');
|
||||
expect(processes[2].id).toBe('p1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('process status updates', () => {
|
||||
let process: TrackedProcess;
|
||||
|
||||
beforeEach(() => {
|
||||
process = service.registerProcess({
|
||||
id: 'test-proc',
|
||||
pid: 100,
|
||||
type: 'agent',
|
||||
name: 'TestProcess',
|
||||
});
|
||||
});
|
||||
|
||||
it('should update process status', () => {
|
||||
const updated = service.updateProcess('test-proc', { status: 'running' });
|
||||
expect(updated?.status).toBe('running');
|
||||
});
|
||||
|
||||
it('should update memory usage', () => {
|
||||
const updated = service.updateProcess('test-proc', { memoryUsage: 1024 * 1024 });
|
||||
expect(updated?.memoryUsage).toBe(1024 * 1024);
|
||||
});
|
||||
|
||||
it('should update CPU usage', () => {
|
||||
const updated = service.updateProcess('test-proc', { cpuUsage: 45.5 });
|
||||
expect(updated?.cpuUsage).toBe(45.5);
|
||||
});
|
||||
|
||||
it('should return null for non-existent process', () => {
|
||||
const updated = service.updateProcess('non-existent', { status: 'running' });
|
||||
expect(updated).toBeNull();
|
||||
});
|
||||
|
||||
it('should set stoppedAt when status is stopped', () => {
|
||||
const updated = service.markStopped('test-proc');
|
||||
expect(updated?.stoppedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should set stoppedAt when status is error', () => {
|
||||
const updated = service.markError('test-proc', 'Something went wrong');
|
||||
expect(updated?.stoppedAt).toBeDefined();
|
||||
expect(updated?.error).toBe('Something went wrong');
|
||||
});
|
||||
});
|
||||
|
||||
describe('status shortcut methods', () => {
|
||||
beforeEach(() => {
|
||||
service.registerProcess({
|
||||
id: 'test-proc',
|
||||
pid: 100,
|
||||
type: 'agent',
|
||||
name: 'TestProcess',
|
||||
});
|
||||
});
|
||||
|
||||
it('should mark process as running', () => {
|
||||
const updated = service.markRunning('test-proc');
|
||||
expect(updated?.status).toBe('running');
|
||||
});
|
||||
|
||||
it('should mark process as idle', () => {
|
||||
const updated = service.markIdle('test-proc');
|
||||
expect(updated?.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('should mark process as stopping', () => {
|
||||
const updated = service.markStopping('test-proc');
|
||||
expect(updated?.status).toBe('stopping');
|
||||
});
|
||||
|
||||
it('should mark process as stopped with exit code', () => {
|
||||
const updated = service.markStopped('test-proc', 0);
|
||||
expect(updated?.status).toBe('stopped');
|
||||
expect(updated?.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('should mark process as error with message', () => {
|
||||
const updated = service.markError('test-proc', 'Process crashed');
|
||||
expect(updated?.status).toBe('error');
|
||||
expect(updated?.error).toBe('Process crashed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('event emissions', () => {
|
||||
let callback: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
callback = vi.fn();
|
||||
events.subscribe(callback);
|
||||
service.registerProcess({
|
||||
id: 'test-proc',
|
||||
pid: 100,
|
||||
type: 'agent',
|
||||
name: 'TestProcess',
|
||||
});
|
||||
callback.mockClear();
|
||||
});
|
||||
|
||||
it('should emit debug:process-stopped when stopped', () => {
|
||||
service.markStopped('test-proc', 0);
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
const [eventType] = callback.mock.calls[0];
|
||||
expect(eventType).toBe('debug:process-stopped');
|
||||
});
|
||||
|
||||
it('should emit debug:process-error when errored', () => {
|
||||
service.markError('test-proc', 'Error message');
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
const [eventType, eventData] = callback.mock.calls[0];
|
||||
expect(eventType).toBe('debug:process-error');
|
||||
expect(eventData.message).toContain('Error message');
|
||||
});
|
||||
|
||||
it('should emit debug:process-updated for other status changes', () => {
|
||||
service.markRunning('test-proc');
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
const [eventType] = callback.mock.calls[0];
|
||||
expect(eventType).toBe('debug:process-updated');
|
||||
});
|
||||
});
|
||||
|
||||
describe('process unregistration', () => {
|
||||
it('should unregister an existing process', () => {
|
||||
service.registerProcess({
|
||||
id: 'test-proc',
|
||||
pid: 100,
|
||||
type: 'agent',
|
||||
name: 'TestProcess',
|
||||
});
|
||||
|
||||
const result = service.unregisterProcess('test-proc');
|
||||
expect(result).toBe(true);
|
||||
expect(service.getProcess('test-proc')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return false for non-existent process', () => {
|
||||
const result = service.unregisterProcess('non-existent');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('process summary', () => {
|
||||
beforeEach(() => {
|
||||
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' });
|
||||
service.registerProcess({ id: 'p2', pid: 2, type: 'agent', name: 'A2' });
|
||||
service.registerProcess({ id: 'p3', pid: 3, type: 'terminal', name: 'T1' });
|
||||
service.registerProcess({ id: 'p4', pid: 4, type: 'cli', name: 'C1' });
|
||||
service.registerProcess({ id: 'p5', pid: 5, type: 'worker', name: 'W1' });
|
||||
|
||||
// Update statuses
|
||||
service.markRunning('p1');
|
||||
service.markIdle('p2');
|
||||
service.markStopped('p3');
|
||||
service.markError('p4', 'error');
|
||||
service.markRunning('p5');
|
||||
});
|
||||
|
||||
it('should calculate correct summary statistics', () => {
|
||||
const summary = service.getProcessSummary();
|
||||
|
||||
expect(summary.total).toBe(5);
|
||||
expect(summary.running).toBe(2); // p1 running, p5 running
|
||||
expect(summary.idle).toBe(1); // p2 idle
|
||||
expect(summary.stopped).toBe(1); // p3 stopped
|
||||
expect(summary.errored).toBe(1); // p4 error
|
||||
});
|
||||
|
||||
it('should count processes by type', () => {
|
||||
const summary = service.getProcessSummary();
|
||||
|
||||
expect(summary.byType.agent).toBe(2);
|
||||
expect(summary.byType.terminal).toBe(1);
|
||||
expect(summary.byType.cli).toBe(1);
|
||||
expect(summary.byType.worker).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('active count', () => {
|
||||
beforeEach(() => {
|
||||
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' });
|
||||
service.registerProcess({ id: 'p2', pid: 2, type: 'agent', name: 'A2' });
|
||||
service.registerProcess({ id: 'p3', pid: 3, type: 'terminal', name: 'T1' });
|
||||
|
||||
service.markRunning('p1');
|
||||
service.markStopped('p2');
|
||||
service.markIdle('p3');
|
||||
});
|
||||
|
||||
it('should return count of active processes', () => {
|
||||
expect(service.getActiveCount()).toBe(2); // p1 running, p3 idle
|
||||
});
|
||||
|
||||
it('should return count by type', () => {
|
||||
expect(service.getCountByType('agent')).toBe(2);
|
||||
expect(service.getCountByType('terminal')).toBe(1);
|
||||
expect(service.getCountByType('cli')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('process provider', () => {
|
||||
it('should return a process provider function', () => {
|
||||
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' });
|
||||
|
||||
const provider = service.getProcessProvider();
|
||||
expect(typeof provider).toBe('function');
|
||||
|
||||
const processes = provider();
|
||||
expect(processes.length).toBe(1);
|
||||
expect(processes[0].id).toBe('p1');
|
||||
});
|
||||
|
||||
it('should return all processes including stopped', () => {
|
||||
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' });
|
||||
service.registerProcess({ id: 'p2', pid: 2, type: 'agent', name: 'A2' });
|
||||
service.markStopped('p2');
|
||||
|
||||
const provider = service.getProcessProvider();
|
||||
const processes = provider();
|
||||
|
||||
expect(processes.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should clean up old stopped processes', () => {
|
||||
// Register and stop a process
|
||||
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' });
|
||||
service.markStopped('p1');
|
||||
|
||||
// Start service to enable cleanup
|
||||
service.start();
|
||||
|
||||
// Advance time past retention period
|
||||
vi.advanceTimersByTime(6 * 60 * 1000); // 6 minutes (past default 5 min retention)
|
||||
|
||||
// Process should be cleaned up
|
||||
expect(service.getProcess('p1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should enforce max stopped processes limit', () => {
|
||||
const customService = new ProcessRegistryService(events, {
|
||||
maxStoppedProcesses: 3,
|
||||
cleanupInterval: 1000,
|
||||
});
|
||||
|
||||
// Register and stop more processes than max
|
||||
for (let i = 0; i < 5; i++) {
|
||||
customService.registerProcess({ id: `p${i}`, pid: i, type: 'agent', name: `A${i}` });
|
||||
customService.markStopped(`p${i}`);
|
||||
}
|
||||
|
||||
customService.start();
|
||||
|
||||
// Trigger cleanup
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
// Should only have max stopped processes
|
||||
const allProcesses = customService.getAllProcesses();
|
||||
expect(allProcesses.length).toBeLessThanOrEqual(3);
|
||||
|
||||
customService.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe('configuration update', () => {
|
||||
it('should update configuration', () => {
|
||||
service.updateConfig({ maxStoppedProcesses: 200 });
|
||||
expect(service.getConfig().maxStoppedProcesses).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('should clear all tracked processes', () => {
|
||||
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' });
|
||||
service.registerProcess({ id: 'p2', pid: 2, type: 'terminal', name: 'T1' });
|
||||
|
||||
service.clear();
|
||||
|
||||
expect(service.getAllProcesses().length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('singleton pattern', () => {
|
||||
beforeEach(() => {
|
||||
resetProcessRegistryService();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetProcessRegistryService();
|
||||
});
|
||||
|
||||
it('should create singleton instance', () => {
|
||||
const instance1 = getProcessRegistryService(events);
|
||||
const instance2 = getProcessRegistryService();
|
||||
|
||||
expect(instance1).toBe(instance2);
|
||||
});
|
||||
|
||||
it('should throw if no events provided on first call', () => {
|
||||
expect(() => getProcessRegistryService()).toThrow();
|
||||
});
|
||||
|
||||
it('should reset singleton', () => {
|
||||
const instance1 = getProcessRegistryService(events);
|
||||
resetProcessRegistryService();
|
||||
const instance2 = getProcessRegistryService(events);
|
||||
|
||||
expect(instance1).not.toBe(instance2);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user