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:
Kacper
2026-01-05 18:55:44 +01:00
parent abab7be367
commit 78d08c2b5b
36 changed files with 8560 additions and 9 deletions

View File

@@ -63,6 +63,12 @@ import { createPipelineRoutes } from './routes/pipeline/index.js';
import { pipelineService } from './services/pipeline-service.js';
import { createIdeationRoutes } from './routes/ideation/index.js';
import { IdeationService } from './services/ideation-service.js';
import {
createDebugRoutes,
createDebugServices,
stopDebugServices,
type DebugServices,
} from './routes/debug/index.js';
// Load environment variables
dotenv.config();
@@ -70,6 +76,8 @@ dotenv.config();
const PORT = parseInt(process.env.PORT || '3008', 10);
const DATA_DIR = process.env.DATA_DIR || './data';
const ENABLE_REQUEST_LOGGING = process.env.ENABLE_REQUEST_LOGGING !== 'false'; // Default to true
const ENABLE_DEBUG_PANEL =
process.env.NODE_ENV !== 'production' || process.env.ENABLE_DEBUG_PANEL === 'true';
// Check for required environment variables
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
@@ -169,6 +177,13 @@ const claudeUsageService = new ClaudeUsageService();
const mcpTestService = new MCPTestService(settingsService);
const ideationService = new IdeationService(events, settingsService, featureLoader);
// Create debug services (dev mode only)
let debugServices: DebugServices | null = null;
if (ENABLE_DEBUG_PANEL) {
debugServices = createDebugServices(events);
logger.info('Debug services enabled');
}
// Initialize services
(async () => {
await agentService.initialize();
@@ -223,6 +238,12 @@ app.use('/api/mcp', createMCPRoutes(mcpTestService));
app.use('/api/pipeline', createPipelineRoutes(pipelineService));
app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader));
// Debug routes (dev mode only)
if (debugServices) {
app.use('/api/debug', createDebugRoutes(debugServices));
logger.info('Debug API routes mounted at /api/debug');
}
// Create HTTP server
const server = createServer(app);
@@ -588,6 +609,9 @@ startServer(PORT);
process.on('SIGTERM', () => {
logger.info('SIGTERM received, shutting down...');
terminalService.cleanup();
if (debugServices) {
stopDebugServices(debugServices);
}
server.close(() => {
logger.info('Server closed');
process.exit(0);
@@ -597,6 +621,9 @@ process.on('SIGTERM', () => {
process.on('SIGINT', () => {
logger.info('SIGINT received, shutting down...');
terminalService.cleanup();
if (debugServices) {
stopDebugServices(debugServices);
}
server.close(() => {
logger.info('Server closed');
process.exit(0);

View File

@@ -0,0 +1,332 @@
/**
* Debug routes - HTTP API for debug panel and performance monitoring
*
* These routes are only enabled in development mode.
*/
import { Router } from 'express';
import type { EventEmitter } from '../../lib/events.js';
import { PerformanceMonitorService } from '../../services/performance-monitor-service.js';
import { ProcessRegistryService } from '../../services/process-registry-service.js';
import {
createGetMetricsHandler,
createStartMetricsHandler,
createStopMetricsHandler,
createForceGCHandler,
createClearHistoryHandler,
} from './routes/metrics.js';
import {
createGetProcessesHandler,
createGetProcessHandler,
createGetSummaryHandler,
createGetAgentsHandler,
createGetAgentMetricsHandler,
createGetAgentSummaryHandler,
} from './routes/processes.js';
export interface DebugServices {
performanceMonitor: PerformanceMonitorService;
processRegistry: ProcessRegistryService;
}
/**
* Create and initialize debug services
*/
export function createDebugServices(events: EventEmitter): DebugServices {
// Create services
const processRegistry = new ProcessRegistryService(events);
const performanceMonitor = new PerformanceMonitorService(events);
// Wire them together - performance monitor gets processes from registry
performanceMonitor.setProcessProvider(processRegistry.getProcessProvider());
// Subscribe to AutoMode events to track feature execution as processes
// Events are wrapped in 'auto-mode:event' with the actual type in data.type
events.subscribe((eventType, data) => {
// Handle auto-mode:event
if (eventType === 'auto-mode:event') {
handleAutoModeEvent(processRegistry, data);
return;
}
// Handle agent:stream events for chat sessions
if (eventType === 'agent:stream') {
handleAgentStreamEvent(processRegistry, data);
return;
}
});
/**
* Handle AutoMode events for feature execution tracking
*/
function handleAutoModeEvent(registry: ProcessRegistryService, data: unknown): void {
const eventData = data as { type?: string; [key: string]: unknown };
const innerType = eventData.type;
if (innerType === 'auto_mode_feature_start') {
const { featureId, projectPath, feature, model } = eventData as {
featureId: string;
projectPath: string;
feature?: { id: string; title: string; description?: string };
model?: string;
};
// Register the feature as a tracked process
// Use -1 for pid since this isn't a real OS process
registry.registerProcess({
id: `agent-${featureId}`,
pid: -1,
type: 'agent',
name: feature?.title || `Feature ${featureId}`,
featureId,
cwd: projectPath,
command: model ? `claude ${model}` : 'claude agent',
});
// Initialize resource metrics
registry.initializeAgentMetrics(`agent-${featureId}`, { featureId });
// Mark it as running
registry.markRunning(`agent-${featureId}`);
} else if (innerType === 'auto_mode_feature_complete') {
const { featureId, passes, message } = eventData as {
featureId: string;
passes: boolean;
message?: string;
};
const processId = `agent-${featureId}`;
if (registry.hasProcess(processId)) {
// Finalize the metrics before marking as stopped
registry.finalizeAgentMetrics(processId);
if (passes) {
registry.markStopped(processId, 0);
} else {
registry.markError(processId, message || 'Feature failed');
}
}
} else if (innerType === 'auto_mode_error') {
const { featureId, error } = eventData as {
featureId?: string;
error: string;
};
if (featureId) {
const processId = `agent-${featureId}`;
if (registry.hasProcess(processId)) {
registry.finalizeAgentMetrics(processId);
registry.markError(processId, error);
}
}
} else if (innerType === 'auto_mode_tool_use') {
// Track tool usage for the feature
const { featureId, tool } = eventData as {
featureId: string;
tool: { name: string; input?: unknown };
};
const processId = `agent-${featureId}`;
if (registry.hasProcess(processId)) {
registry.recordToolUse(processId, { toolName: tool.name });
// Record file operations based on tool type
if (tool.name === 'Read' && tool.input) {
const input = tool.input as { file_path?: string };
if (input.file_path) {
registry.recordFileOperation(processId, {
operation: 'read',
filePath: input.file_path,
});
}
} else if (tool.name === 'Write' && tool.input) {
const input = tool.input as { file_path?: string; content?: string };
if (input.file_path) {
registry.recordFileOperation(processId, {
operation: 'write',
filePath: input.file_path,
bytes: input.content?.length,
});
}
} else if (tool.name === 'Edit' && tool.input) {
const input = tool.input as { file_path?: string; new_string?: string };
if (input.file_path) {
registry.recordFileOperation(processId, {
operation: 'edit',
filePath: input.file_path,
bytes: input.new_string?.length,
});
}
} else if (tool.name === 'Glob') {
const input = tool.input as { path?: string };
registry.recordFileOperation(processId, {
operation: 'glob',
filePath: input?.path || '.',
});
} else if (tool.name === 'Grep') {
const input = tool.input as { path?: string };
registry.recordFileOperation(processId, {
operation: 'grep',
filePath: input?.path || '.',
});
} else if (tool.name === 'Bash' && tool.input) {
const input = tool.input as { command?: string };
if (input.command) {
registry.recordBashCommand(processId, {
command: input.command,
executionTime: 0, // Will be updated on completion
exitCode: null,
});
}
}
}
}
}
/**
* Handle agent:stream events for chat session tracking
*/
function handleAgentStreamEvent(registry: ProcessRegistryService, data: unknown): void {
const eventData = data as {
sessionId?: string;
type?: string;
tool?: { name: string; input?: unknown };
[key: string]: unknown;
};
const { sessionId, type } = eventData;
if (!sessionId) return;
const processId = `chat-${sessionId}`;
// Register chat session as a process if not already tracked
if (!registry.hasProcess(processId) && type !== 'complete' && type !== 'error') {
registry.registerProcess({
id: processId,
pid: -1,
type: 'agent',
name: `Chat Session`,
sessionId,
command: 'claude chat',
});
registry.initializeAgentMetrics(processId, { sessionId });
registry.markRunning(processId);
}
// Handle different event types
if (type === 'tool_use' && eventData.tool) {
const tool = eventData.tool;
registry.recordToolUse(processId, { toolName: tool.name });
// Record file operations based on tool type
if (tool.name === 'Read' && tool.input) {
const input = tool.input as { file_path?: string };
if (input.file_path) {
registry.recordFileOperation(processId, {
operation: 'read',
filePath: input.file_path,
});
}
} else if (tool.name === 'Write' && tool.input) {
const input = tool.input as { file_path?: string; content?: string };
if (input.file_path) {
registry.recordFileOperation(processId, {
operation: 'write',
filePath: input.file_path,
bytes: input.content?.length,
});
}
} else if (tool.name === 'Edit' && tool.input) {
const input = tool.input as { file_path?: string; new_string?: string };
if (input.file_path) {
registry.recordFileOperation(processId, {
operation: 'edit',
filePath: input.file_path,
bytes: input.new_string?.length,
});
}
} else if (tool.name === 'Glob') {
const input = tool.input as { path?: string };
registry.recordFileOperation(processId, {
operation: 'glob',
filePath: input?.path || '.',
});
} else if (tool.name === 'Grep') {
const input = tool.input as { path?: string };
registry.recordFileOperation(processId, {
operation: 'grep',
filePath: input?.path || '.',
});
} else if (tool.name === 'Bash' && tool.input) {
const input = tool.input as { command?: string };
if (input.command) {
registry.recordBashCommand(processId, {
command: input.command,
executionTime: 0,
exitCode: null,
});
}
}
} else if (type === 'complete') {
if (registry.hasProcess(processId)) {
registry.finalizeAgentMetrics(processId);
// Keep the session as "idle" rather than "stopped" since it can receive more messages
registry.markIdle(processId);
}
} else if (type === 'error') {
if (registry.hasProcess(processId)) {
registry.finalizeAgentMetrics(processId);
const errorMsg = (eventData.error as string) || 'Unknown error';
registry.markError(processId, errorMsg);
}
}
}
// Start services
processRegistry.start();
performanceMonitor.start();
return {
performanceMonitor,
processRegistry,
};
}
/**
* Stop debug services
*/
export function stopDebugServices(services: DebugServices): void {
services.performanceMonitor.stop();
services.processRegistry.stop();
}
/**
* Create debug routes
*/
export function createDebugRoutes(services: DebugServices): Router {
const router = Router();
const { performanceMonitor, processRegistry } = services;
// Metrics routes
router.get('/metrics', createGetMetricsHandler(performanceMonitor));
router.post('/metrics/start', createStartMetricsHandler(performanceMonitor));
router.post('/metrics/stop', createStopMetricsHandler(performanceMonitor));
router.post('/metrics/gc', createForceGCHandler(performanceMonitor));
router.post('/metrics/clear', createClearHistoryHandler(performanceMonitor));
// Process routes
router.get('/processes', createGetProcessesHandler(processRegistry));
router.get('/processes/summary', createGetSummaryHandler(processRegistry));
router.get('/processes/:id', createGetProcessHandler(processRegistry));
// Agent resource metrics routes
router.get('/agents', createGetAgentsHandler(processRegistry));
router.get('/agents/summary', createGetAgentSummaryHandler(processRegistry));
router.get('/agents/:id/metrics', createGetAgentMetricsHandler(processRegistry));
return router;
}
// Re-export services for use elsewhere
export { PerformanceMonitorService } from '../../services/performance-monitor-service.js';
export { ProcessRegistryService } from '../../services/process-registry-service.js';

View File

@@ -0,0 +1,152 @@
/**
* Debug metrics route handler
*
* GET /api/debug/metrics - Get current metrics snapshot
* POST /api/debug/metrics/start - Start metrics collection
* POST /api/debug/metrics/stop - Stop metrics collection
*/
import type { Request, Response } from 'express';
import type { PerformanceMonitorService } from '../../../services/performance-monitor-service.js';
import type { StartDebugMetricsRequest, DebugMetricsResponse } from '@automaker/types';
/**
* Create handler for GET /api/debug/metrics
* Returns current metrics snapshot
*/
export function createGetMetricsHandler(performanceMonitor: PerformanceMonitorService) {
return (_req: Request, res: Response) => {
const snapshot = performanceMonitor.getLatestSnapshot();
const config = performanceMonitor.getConfig();
const active = performanceMonitor.isActive();
const response: DebugMetricsResponse = {
active,
config,
snapshot: snapshot ?? undefined,
};
res.json(response);
};
}
/**
* Validate and sanitize debug metrics config values
* Prevents DoS via extreme configuration values
*/
function sanitizeConfig(
config: Partial<import('@automaker/types').DebugMetricsConfig>
): Partial<import('@automaker/types').DebugMetricsConfig> {
const sanitized: Partial<import('@automaker/types').DebugMetricsConfig> = {};
// Collection interval: min 100ms, max 60s (prevents CPU exhaustion)
if (typeof config.collectionInterval === 'number') {
sanitized.collectionInterval = Math.min(
60000,
Math.max(100, Math.floor(config.collectionInterval))
);
}
// Max data points: min 10, max 10000 (prevents memory exhaustion)
if (typeof config.maxDataPoints === 'number') {
sanitized.maxDataPoints = Math.min(10000, Math.max(10, Math.floor(config.maxDataPoints)));
}
// Leak threshold: min 1KB, max 100MB (reasonable bounds)
if (typeof config.leakThreshold === 'number') {
sanitized.leakThreshold = Math.min(
100 * 1024 * 1024,
Math.max(1024, Math.floor(config.leakThreshold))
);
}
// Boolean flags - only accept actual booleans
if (typeof config.memoryEnabled === 'boolean') {
sanitized.memoryEnabled = config.memoryEnabled;
}
if (typeof config.cpuEnabled === 'boolean') {
sanitized.cpuEnabled = config.cpuEnabled;
}
if (typeof config.processTrackingEnabled === 'boolean') {
sanitized.processTrackingEnabled = config.processTrackingEnabled;
}
return sanitized;
}
/**
* Create handler for POST /api/debug/metrics/start
* Starts metrics collection with optional config overrides
*/
export function createStartMetricsHandler(performanceMonitor: PerformanceMonitorService) {
return (req: Request, res: Response) => {
const body = req.body as StartDebugMetricsRequest | undefined;
// Update config if provided (with validation)
if (body?.config && typeof body.config === 'object') {
const sanitizedConfig = sanitizeConfig(body.config);
if (Object.keys(sanitizedConfig).length > 0) {
performanceMonitor.updateConfig(sanitizedConfig);
}
}
// Start collection
performanceMonitor.start();
const response: DebugMetricsResponse = {
active: true,
config: performanceMonitor.getConfig(),
};
res.json(response);
};
}
/**
* Create handler for POST /api/debug/metrics/stop
* Stops metrics collection
*/
export function createStopMetricsHandler(performanceMonitor: PerformanceMonitorService) {
return (_req: Request, res: Response) => {
performanceMonitor.stop();
const response: DebugMetricsResponse = {
active: false,
config: performanceMonitor.getConfig(),
};
res.json(response);
};
}
/**
* Create handler for POST /api/debug/metrics/gc
* Forces garbage collection if available
*/
export function createForceGCHandler(performanceMonitor: PerformanceMonitorService) {
return (_req: Request, res: Response) => {
const success = performanceMonitor.forceGC();
res.json({
success,
message: success
? 'Garbage collection triggered'
: 'Garbage collection not available (start Node.js with --expose-gc flag)',
});
};
}
/**
* Create handler for POST /api/debug/metrics/clear
* Clears metrics history
*/
export function createClearHistoryHandler(performanceMonitor: PerformanceMonitorService) {
return (_req: Request, res: Response) => {
performanceMonitor.clearHistory();
res.json({
success: true,
message: 'Metrics history cleared',
});
};
}

View File

@@ -0,0 +1,170 @@
/**
* Debug processes route handler
*
* GET /api/debug/processes - Get list of tracked processes
* GET /api/debug/processes/:id - Get specific process by ID
* POST /api/debug/processes/:id/terminate - Terminate a process
*/
import type { Request, Response } from 'express';
import type { ProcessRegistryService } from '../../../services/process-registry-service.js';
import type {
GetProcessesRequest,
GetProcessesResponse,
ProcessType,
ProcessStatus,
} from '@automaker/types';
/**
* Create handler for GET /api/debug/processes
* Returns list of tracked processes with optional filtering
*/
export function createGetProcessesHandler(processRegistry: ProcessRegistryService) {
return (req: Request, res: Response) => {
const query = req.query as {
type?: string;
status?: string;
includeStopped?: string;
sessionId?: string;
featureId?: string;
};
// Build query options
const options: GetProcessesRequest = {};
if (query.type) {
options.type = query.type as ProcessType;
}
if (query.status) {
options.status = query.status as ProcessStatus;
}
if (query.includeStopped === 'true') {
options.includeStoppedProcesses = true;
}
const processes = processRegistry.getProcesses({
type: options.type,
status: options.status,
includeStopped: options.includeStoppedProcesses,
sessionId: query.sessionId,
featureId: query.featureId,
});
const summary = processRegistry.getProcessSummary();
const response: GetProcessesResponse = {
processes,
summary,
};
res.json(response);
};
}
/**
* Validate process ID format
* Process IDs should be non-empty strings with reasonable length
*/
function isValidProcessId(id: unknown): id is string {
return typeof id === 'string' && id.length > 0 && id.length <= 256;
}
/**
* Create handler for GET /api/debug/processes/:id
* Returns a specific process by ID
*/
export function createGetProcessHandler(processRegistry: ProcessRegistryService) {
return (req: Request, res: Response) => {
const { id } = req.params;
// Validate process ID format
if (!isValidProcessId(id)) {
res.status(400).json({
error: 'Invalid process ID format',
});
return;
}
const process = processRegistry.getProcess(id);
if (!process) {
res.status(404).json({
error: 'Process not found',
id,
});
return;
}
res.json(process);
};
}
/**
* Create handler for GET /api/debug/processes/summary
* Returns summary statistics
*/
export function createGetSummaryHandler(processRegistry: ProcessRegistryService) {
return (_req: Request, res: Response) => {
const summary = processRegistry.getProcessSummary();
res.json(summary);
};
}
/**
* Create handler for GET /api/debug/agents
* Returns all agent processes with their resource metrics
*/
export function createGetAgentsHandler(processRegistry: ProcessRegistryService) {
return (_req: Request, res: Response) => {
const agents = processRegistry.getAgentProcessesWithMetrics();
const summary = processRegistry.getAgentResourceSummary();
res.json({
agents,
summary,
});
};
}
/**
* Create handler for GET /api/debug/agents/:id/metrics
* Returns detailed resource metrics for a specific agent
*/
export function createGetAgentMetricsHandler(processRegistry: ProcessRegistryService) {
return (req: Request, res: Response) => {
const { id } = req.params;
// Validate process ID format
if (!isValidProcessId(id)) {
res.status(400).json({
error: 'Invalid agent ID format',
});
return;
}
const metrics = processRegistry.getAgentMetrics(id);
if (!metrics) {
res.status(404).json({
error: 'Agent metrics not found',
id,
});
return;
}
res.json(metrics);
};
}
/**
* Create handler for GET /api/debug/agents/summary
* Returns summary of resource usage across all agents
*/
export function createGetAgentSummaryHandler(processRegistry: ProcessRegistryService) {
return (_req: Request, res: Response) => {
const summary = processRegistry.getAgentResourceSummary();
res.json(summary);
};
}

View File

@@ -0,0 +1,673 @@
/**
* Performance Monitor Service
*
* Collects and streams server-side performance metrics including:
* - Memory usage (heap, rss, external)
* - CPU usage (user, system, percentage)
* - Event loop lag detection
* - Memory leak trend analysis
*
* Emits debug events for real-time streaming to connected clients.
*/
import v8 from 'v8';
import { createLogger } from '@automaker/utils';
import type { EventEmitter } from '../lib/events.js';
import type {
ServerMemoryMetrics,
ServerCPUMetrics,
MemoryMetrics,
CPUMetrics,
MemoryTrend,
DebugMetricsConfig,
DebugMetricsSnapshot,
ProcessSummary,
TrackedProcess,
} from '@automaker/types';
import { DEFAULT_DEBUG_METRICS_CONFIG, formatBytes } from '@automaker/types';
const logger = createLogger('PerformanceMonitor');
/**
* Circular buffer for time-series data storage
* Uses index-based ring buffer for O(1) push operations instead of O(n) shift().
* Efficiently stores a fixed number of data points, automatically discarding old ones.
*/
class CircularBuffer<T> {
private buffer: (T | undefined)[];
private maxSize: number;
private head = 0; // Write position
private count = 0; // Number of items
constructor(maxSize: number) {
this.maxSize = maxSize;
this.buffer = new Array(maxSize);
}
/**
* Add item to buffer - O(1) operation
*/
push(item: T): void {
this.buffer[this.head] = item;
this.head = (this.head + 1) % this.maxSize;
if (this.count < this.maxSize) {
this.count++;
}
}
/**
* Get all items in chronological order - O(n) but only called when needed
*/
getAll(): T[] {
if (this.count === 0) return [];
const result: T[] = new Array(this.count);
const start = this.count < this.maxSize ? 0 : this.head;
for (let i = 0; i < this.count; i++) {
const idx = (start + i) % this.maxSize;
result[i] = this.buffer[idx] as T;
}
return result;
}
/**
* Get most recent item - O(1)
*/
getLast(): T | undefined {
if (this.count === 0) return undefined;
const idx = (this.head - 1 + this.maxSize) % this.maxSize;
return this.buffer[idx];
}
/**
* Get oldest item - O(1)
*/
getFirst(): T | undefined {
if (this.count === 0) return undefined;
const start = this.count < this.maxSize ? 0 : this.head;
return this.buffer[start];
}
/**
* Get current count - O(1)
*/
size(): number {
return this.count;
}
/**
* Clear all items - O(1)
*/
clear(): void {
this.head = 0;
this.count = 0;
// Don't reallocate array, just reset indices
}
/**
* Resize buffer, preserving existing data
*/
resize(newSize: number): void {
const oldData = this.getAll();
this.maxSize = newSize;
this.buffer = new Array(newSize);
this.head = 0;
this.count = 0;
// Copy over data (trim if necessary, keep most recent)
const startIdx = Math.max(0, oldData.length - newSize);
for (let i = startIdx; i < oldData.length; i++) {
this.push(oldData[i]);
}
}
}
/**
* Memory data point for trend analysis
*/
interface MemoryDataPoint {
timestamp: number;
heapUsed: number;
}
/**
* CPU data point for tracking
*/
interface CPUDataPoint {
timestamp: number;
user: number;
system: number;
}
/**
* PerformanceMonitorService - Collects server-side performance metrics
*
* This service runs in the Node.js server process and periodically collects:
* - Memory metrics from process.memoryUsage()
* - CPU metrics from process.cpuUsage()
* - Event loop lag using setTimeout deviation
*
* It streams metrics to connected clients via the event emitter and
* analyzes memory trends to detect potential leaks.
*/
export class PerformanceMonitorService {
private events: EventEmitter;
private config: DebugMetricsConfig;
private isRunning = false;
private collectionInterval: NodeJS.Timeout | null = null;
private eventLoopCheckInterval: NodeJS.Timeout | null = null;
// Data storage
private memoryHistory: CircularBuffer<MemoryDataPoint>;
private cpuHistory: CircularBuffer<CPUDataPoint>;
// CPU tracking state
private lastCpuUsage: NodeJS.CpuUsage | null = null;
private lastCpuTime: number = 0;
// Event loop lag tracking
private lastEventLoopLag = 0;
private eventLoopLagThreshold = 100; // ms - threshold for warning
// Memory warning thresholds (percentage of heap limit)
private memoryWarningThreshold = 70; // 70% of heap limit
private memoryCriticalThreshold = 90; // 90% of heap limit
private lastMemoryWarningTime = 0;
private memoryWarningCooldown = 30000; // 30 seconds between warnings
// Process tracking (will be populated by ProcessRegistryService)
private getProcesses: () => TrackedProcess[] = () => [];
constructor(events: EventEmitter, config?: Partial<DebugMetricsConfig>) {
this.events = events;
this.config = { ...DEFAULT_DEBUG_METRICS_CONFIG, ...config };
this.memoryHistory = new CircularBuffer(this.config.maxDataPoints);
this.cpuHistory = new CircularBuffer(this.config.maxDataPoints);
logger.info('PerformanceMonitorService initialized');
}
/**
* Set the process provider function (called by ProcessRegistryService)
*/
setProcessProvider(provider: () => TrackedProcess[]): void {
this.getProcesses = provider;
}
/**
* Start metrics collection
*/
start(): void {
if (this.isRunning) {
logger.warn('PerformanceMonitorService is already running');
return;
}
this.isRunning = true;
this.lastCpuUsage = process.cpuUsage();
this.lastCpuTime = Date.now();
// Start periodic metrics collection
this.collectionInterval = setInterval(() => {
this.collectAndEmitMetrics();
}, this.config.collectionInterval);
// Start event loop lag monitoring (more frequent for accurate detection)
this.startEventLoopMonitoring();
logger.info('PerformanceMonitorService started', {
interval: this.config.collectionInterval,
});
}
/**
* Stop metrics collection
*/
stop(): void {
if (!this.isRunning) {
return;
}
this.isRunning = false;
if (this.collectionInterval) {
clearInterval(this.collectionInterval);
this.collectionInterval = null;
}
if (this.eventLoopCheckInterval) {
clearInterval(this.eventLoopCheckInterval);
this.eventLoopCheckInterval = null;
}
logger.info('PerformanceMonitorService stopped');
}
/**
* Update configuration
*/
updateConfig(config: Partial<DebugMetricsConfig>): void {
const wasRunning = this.isRunning;
if (wasRunning) {
this.stop();
}
this.config = { ...this.config, ...config };
// Resize buffers if maxDataPoints changed
if (config.maxDataPoints) {
this.memoryHistory.resize(config.maxDataPoints);
this.cpuHistory.resize(config.maxDataPoints);
}
if (wasRunning) {
this.start();
}
logger.info('PerformanceMonitorService configuration updated', config);
}
/**
* Get current configuration
*/
getConfig(): DebugMetricsConfig {
return { ...this.config };
}
/**
* Get whether monitoring is active
*/
isActive(): boolean {
return this.isRunning;
}
/**
* Collect and emit current metrics
*/
private collectAndEmitMetrics(): void {
const timestamp = Date.now();
const memoryMetrics = this.collectMemoryMetrics(timestamp);
const cpuMetrics = this.collectCPUMetrics(timestamp);
// Store in history
if (this.config.memoryEnabled && memoryMetrics.server) {
this.memoryHistory.push({
timestamp,
heapUsed: memoryMetrics.server.heapUsed,
});
}
// Analyze memory trend
const memoryTrend = this.analyzeMemoryTrend();
// Get process information
const processes = this.getProcesses();
const processSummary = this.calculateProcessSummary(processes);
// Build snapshot
const snapshot: DebugMetricsSnapshot = {
timestamp,
memory: memoryMetrics,
cpu: cpuMetrics,
processes,
processSummary,
memoryTrend,
};
// Emit metrics event
this.events.emit('debug:metrics', {
type: 'debug:metrics',
timestamp,
metrics: snapshot,
});
// Check for memory warnings
this.checkMemoryThresholds(memoryMetrics);
// Check for memory leak
if (memoryTrend && memoryTrend.isLeaking) {
this.events.emit('debug:leak-detected', {
type: 'debug:leak-detected',
timestamp,
trend: memoryTrend,
message: `Potential memory leak detected: ${formatBytes(memoryTrend.growthRate)}/s sustained growth`,
});
}
// Check for high CPU
if (cpuMetrics.server && cpuMetrics.server.percentage > 80) {
this.events.emit('debug:high-cpu', {
type: 'debug:high-cpu',
timestamp,
cpu: cpuMetrics,
usagePercent: cpuMetrics.server.percentage,
threshold: 80,
message: `High CPU usage: ${cpuMetrics.server.percentage.toFixed(1)}%`,
});
}
}
/**
* Collect memory metrics from Node.js process
*/
private collectMemoryMetrics(timestamp: number): MemoryMetrics {
if (!this.config.memoryEnabled) {
return { timestamp };
}
const usage = process.memoryUsage();
const serverMetrics: ServerMemoryMetrics = {
heapTotal: usage.heapTotal,
heapUsed: usage.heapUsed,
external: usage.external,
rss: usage.rss,
arrayBuffers: usage.arrayBuffers,
};
return {
timestamp,
server: serverMetrics,
};
}
/**
* Collect CPU metrics from Node.js process
*/
private collectCPUMetrics(timestamp: number): CPUMetrics {
if (!this.config.cpuEnabled) {
return { timestamp };
}
const currentCpuUsage = process.cpuUsage();
const currentTime = Date.now();
let serverMetrics: ServerCPUMetrics | undefined;
if (this.lastCpuUsage) {
// Calculate CPU usage since last measurement
const userDiff = currentCpuUsage.user - this.lastCpuUsage.user;
const systemDiff = currentCpuUsage.system - this.lastCpuUsage.system;
const timeDiff = (currentTime - this.lastCpuTime) * 1000; // Convert to microseconds
// Calculate percentage (CPU usage is in microseconds)
// For multi-core systems, this can exceed 100%
const percentage = timeDiff > 0 ? ((userDiff + systemDiff) / timeDiff) * 100 : 0;
serverMetrics = {
percentage: Math.min(100, percentage), // Cap at 100% for single-core representation
user: userDiff,
system: systemDiff,
};
// Store in history
this.cpuHistory.push({
timestamp,
user: userDiff,
system: systemDiff,
});
}
this.lastCpuUsage = currentCpuUsage;
this.lastCpuTime = currentTime;
return {
timestamp,
server: serverMetrics,
eventLoopLag: this.lastEventLoopLag,
};
}
/**
* Start event loop lag monitoring
* Uses setTimeout deviation to detect when the event loop is blocked
*/
private startEventLoopMonitoring(): void {
const checkInterval = 100; // Check every 100ms
const measureLag = () => {
if (!this.isRunning) return;
const start = Date.now();
// setImmediate runs after I/O events, giving us event loop lag
setImmediate(() => {
const lag = Date.now() - start;
this.lastEventLoopLag = lag;
// Emit warning if lag exceeds threshold
if (lag > this.eventLoopLagThreshold) {
this.events.emit('debug:event-loop-blocked', {
type: 'debug:event-loop-blocked',
timestamp: Date.now(),
lag,
threshold: this.eventLoopLagThreshold,
message: `Event loop blocked for ${lag}ms`,
});
}
});
};
this.eventLoopCheckInterval = setInterval(measureLag, checkInterval);
}
/**
* Analyze memory trend for leak detection
*/
private analyzeMemoryTrend(): MemoryTrend | undefined {
const history = this.memoryHistory.getAll();
if (history.length < 10) {
return undefined; // Need at least 10 samples for meaningful analysis
}
const first = history[0];
const last = history[history.length - 1];
const windowDuration = last.timestamp - first.timestamp;
if (windowDuration === 0) {
return undefined;
}
// Calculate linear regression for growth rate
const n = history.length;
let sumX = 0;
let sumY = 0;
let sumXY = 0;
let sumXX = 0;
for (let i = 0; i < n; i++) {
const x = history[i].timestamp - first.timestamp;
const y = history[i].heapUsed;
sumX += x;
sumY += y;
sumXY += x * y;
sumXX += x * x;
}
// Slope of linear regression (bytes per millisecond)
const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);
const growthRate = slope * 1000; // Convert to bytes per second
// Calculate R² for confidence
const meanY = sumY / n;
let ssRes = 0;
let ssTot = 0;
const intercept = (sumY - slope * sumX) / n;
for (let i = 0; i < n; i++) {
const x = history[i].timestamp - first.timestamp;
const y = history[i].heapUsed;
const yPred = slope * x + intercept;
ssRes += (y - yPred) ** 2;
ssTot += (y - meanY) ** 2;
}
const rSquared = ssTot > 0 ? 1 - ssRes / ssTot : 0;
const confidence = Math.max(0, Math.min(1, rSquared));
// Consider it a leak if:
// 1. Growth rate exceeds threshold
// 2. R² is high (indicating consistent growth, not just fluctuation)
const isLeaking =
growthRate > this.config.leakThreshold && confidence > 0.7 && windowDuration > 30000; // At least 30 seconds of data
return {
growthRate,
isLeaking,
confidence,
sampleCount: n,
windowDuration,
};
}
/**
* Check memory thresholds and emit warnings
*/
private checkMemoryThresholds(memory: MemoryMetrics): void {
if (!memory.server) return;
const now = Date.now();
if (now - this.lastMemoryWarningTime < this.memoryWarningCooldown) {
return; // Don't spam warnings
}
// Get V8 heap statistics for limit
const heapStats = v8.getHeapStatistics();
const heapLimit = heapStats.heap_size_limit;
const usagePercent = (memory.server.heapUsed / heapLimit) * 100;
if (usagePercent >= this.memoryCriticalThreshold) {
this.lastMemoryWarningTime = now;
this.events.emit('debug:memory-critical', {
type: 'debug:memory-critical',
timestamp: now,
memory,
usagePercent,
threshold: this.memoryCriticalThreshold,
message: `Critical memory usage: ${usagePercent.toFixed(1)}% of heap limit`,
});
} else if (usagePercent >= this.memoryWarningThreshold) {
this.lastMemoryWarningTime = now;
this.events.emit('debug:memory-warning', {
type: 'debug:memory-warning',
timestamp: now,
memory,
usagePercent,
threshold: this.memoryWarningThreshold,
message: `High memory usage: ${usagePercent.toFixed(1)}% of heap limit`,
});
}
}
/**
* Calculate process summary from tracked processes
*/
private calculateProcessSummary(processes: TrackedProcess[]): ProcessSummary {
const summary: ProcessSummary = {
total: processes.length,
running: 0,
idle: 0,
stopped: 0,
errored: 0,
byType: {
agent: 0,
cli: 0,
terminal: 0,
worker: 0,
},
};
for (const process of processes) {
// Count by status
switch (process.status) {
case 'running':
case 'starting':
summary.running++;
break;
case 'idle':
summary.idle++;
break;
case 'stopped':
case 'stopping':
summary.stopped++;
break;
case 'error':
summary.errored++;
break;
}
// Count by type
if (process.type in summary.byType) {
summary.byType[process.type]++;
}
}
return summary;
}
/**
* Get latest metrics snapshot
*/
getLatestSnapshot(): DebugMetricsSnapshot | null {
const timestamp = Date.now();
const lastMemory = this.memoryHistory.getLast();
if (!lastMemory) {
return null;
}
const memoryMetrics = this.collectMemoryMetrics(timestamp);
const cpuMetrics = this.collectCPUMetrics(timestamp);
const memoryTrend = this.analyzeMemoryTrend();
const processes = this.getProcesses();
const processSummary = this.calculateProcessSummary(processes);
return {
timestamp,
memory: memoryMetrics,
cpu: cpuMetrics,
processes,
processSummary,
memoryTrend,
};
}
/**
* Get memory history for charting
*/
getMemoryHistory(): MemoryDataPoint[] {
return this.memoryHistory.getAll();
}
/**
* Get CPU history for charting
*/
getCPUHistory(): CPUDataPoint[] {
return this.cpuHistory.getAll();
}
/**
* Force a garbage collection (if --expose-gc flag is used)
* Returns true if GC was triggered, false if not available
*/
forceGC(): boolean {
if (global.gc) {
global.gc();
logger.info('Forced garbage collection');
return true;
}
logger.warn('Garbage collection not available (start with --expose-gc flag)');
return false;
}
/**
* Clear collected history
*/
clearHistory(): void {
this.memoryHistory.clear();
this.cpuHistory.clear();
logger.info('Performance history cleared');
}
}

View File

@@ -0,0 +1,982 @@
/**
* Process Registry Service
*
* Tracks spawned agents, CLIs, and terminal processes for debugging and monitoring.
* Emits debug events for real-time updates to connected clients.
*
* This service provides:
* - Process registration and unregistration
* - Status updates for tracked processes
* - Integration with PerformanceMonitorService for metrics snapshots
* - Filtering and querying of tracked processes
* - Automatic cleanup of stopped processes after a retention period
*/
import { createLogger } from '@automaker/utils';
import type { EventEmitter } from '../lib/events.js';
import type {
TrackedProcess,
ProcessType,
ProcessStatus,
ProcessSummary,
AgentResourceMetrics,
FileIOOperation,
} from '@automaker/types';
import { createEmptyAgentResourceMetrics } from '@automaker/types';
const logger = createLogger('ProcessRegistry');
/**
* Options for recording a tool invocation
*/
export interface RecordToolUseOptions {
/** Tool name */
toolName: string;
/** Execution time in milliseconds */
executionTime?: number;
/** Whether the tool invocation failed */
failed?: boolean;
}
/**
* Options for recording a file operation
*/
export interface RecordFileOperationOptions {
/** Type of file operation */
operation: FileIOOperation;
/** File path accessed */
filePath: string;
/** Bytes read or written */
bytes?: number;
}
/**
* Options for recording a bash command
*/
export interface RecordBashCommandOptions {
/** Command executed */
command: string;
/** Execution time in milliseconds */
executionTime: number;
/** Exit code (null if still running or killed) */
exitCode: number | null;
}
/**
* Options for registering a new process
*/
export interface RegisterProcessOptions {
/** Unique identifier for the process */
id: string;
/** Process ID from the operating system (-1 if not applicable) */
pid: number;
/** Type of process */
type: ProcessType;
/** Human-readable name/label */
name: string;
/** Associated feature ID (for agent processes) */
featureId?: string;
/** Associated session ID (for agent/terminal processes) */
sessionId?: string;
/** Command that was executed */
command?: string;
/** Working directory */
cwd?: string;
}
/**
* Options for updating a process
*/
export interface UpdateProcessOptions {
/** New status */
status?: ProcessStatus;
/** Memory usage in bytes */
memoryUsage?: number;
/** CPU usage percentage */
cpuUsage?: number;
/** Exit code (when stopping) */
exitCode?: number;
/** Error message */
error?: string;
}
/**
* Options for querying processes
*/
export interface QueryProcessOptions {
/** Filter by process type */
type?: ProcessType;
/** Filter by status */
status?: ProcessStatus;
/** Include stopped processes (default: false) */
includeStopped?: boolean;
/** Filter by session ID */
sessionId?: string;
/** Filter by feature ID */
featureId?: string;
}
/**
* Configuration for the ProcessRegistryService
*/
export interface ProcessRegistryConfig {
/** How long to keep stopped processes in the registry (ms) */
stoppedProcessRetention: number;
/** Interval for cleanup of old stopped processes (ms) */
cleanupInterval: number;
/** Maximum number of stopped processes to retain */
maxStoppedProcesses: number;
}
const DEFAULT_CONFIG: ProcessRegistryConfig = {
stoppedProcessRetention: 5 * 60 * 1000, // 5 minutes
cleanupInterval: 60 * 1000, // 1 minute
maxStoppedProcesses: 100,
};
/**
* ProcessRegistryService - Tracks spawned processes for debugging
*
* This service maintains a registry of all tracked processes including:
* - Agent sessions (AI conversations)
* - CLI processes (one-off commands)
* - Terminal sessions (persistent PTY sessions)
* - Worker processes (background tasks)
*
* It emits events when processes are spawned, updated, or stopped,
* allowing real-time monitoring in the debug panel.
*/
export class ProcessRegistryService {
private events: EventEmitter;
private config: ProcessRegistryConfig;
private processes: Map<string, TrackedProcess> = new Map();
private cleanupIntervalId: NodeJS.Timeout | null = null;
constructor(events: EventEmitter, config?: Partial<ProcessRegistryConfig>) {
this.events = events;
this.config = { ...DEFAULT_CONFIG, ...config };
logger.info('ProcessRegistryService initialized');
}
/**
* Start the process registry service
* Begins periodic cleanup of old stopped processes
*/
start(): void {
if (this.cleanupIntervalId) {
logger.warn('ProcessRegistryService is already running');
return;
}
this.cleanupIntervalId = setInterval(() => {
this.cleanupStoppedProcesses();
}, this.config.cleanupInterval);
logger.info('ProcessRegistryService started');
}
/**
* Stop the process registry service
*/
stop(): void {
if (this.cleanupIntervalId) {
clearInterval(this.cleanupIntervalId);
this.cleanupIntervalId = null;
}
logger.info('ProcessRegistryService stopped');
}
/**
* Register a new process
*/
registerProcess(options: RegisterProcessOptions): TrackedProcess {
const now = Date.now();
const process: TrackedProcess = {
id: options.id,
pid: options.pid,
type: options.type,
name: options.name,
status: 'starting',
startedAt: now,
featureId: options.featureId,
sessionId: options.sessionId,
command: options.command,
cwd: options.cwd,
};
this.processes.set(options.id, process);
logger.info('Process registered', {
id: process.id,
type: process.type,
name: process.name,
pid: process.pid,
});
// Emit process spawned event
this.events.emit('debug:process-spawned', {
type: 'debug:process-spawned',
timestamp: now,
process,
message: `Process ${process.name} (${process.type}) started`,
});
return process;
}
/**
* Update an existing process
*/
updateProcess(id: string, updates: UpdateProcessOptions): TrackedProcess | null {
const process = this.processes.get(id);
if (!process) {
logger.warn('Attempted to update non-existent process', { id });
return null;
}
const now = Date.now();
// Apply updates
if (updates.status !== undefined) {
process.status = updates.status;
// Set stoppedAt timestamp when process stops
if (updates.status === 'stopped' || updates.status === 'error') {
process.stoppedAt = now;
}
}
if (updates.memoryUsage !== undefined) {
process.memoryUsage = updates.memoryUsage;
}
if (updates.cpuUsage !== undefined) {
process.cpuUsage = updates.cpuUsage;
}
if (updates.exitCode !== undefined) {
process.exitCode = updates.exitCode;
}
if (updates.error !== undefined) {
process.error = updates.error;
}
logger.debug('Process updated', {
id,
updates,
});
// Emit appropriate event based on status
if (updates.status === 'stopped') {
this.events.emit('debug:process-stopped', {
type: 'debug:process-stopped',
timestamp: now,
process,
message: `Process ${process.name} stopped${updates.exitCode !== undefined ? ` (exit code: ${updates.exitCode})` : ''}`,
});
} else if (updates.status === 'error') {
this.events.emit('debug:process-error', {
type: 'debug:process-error',
timestamp: now,
process,
message: `Process ${process.name} encountered an error: ${updates.error || 'Unknown error'}`,
});
} else {
this.events.emit('debug:process-updated', {
type: 'debug:process-updated',
timestamp: now,
process,
});
}
return process;
}
/**
* Mark a process as running
*/
markRunning(id: string): TrackedProcess | null {
return this.updateProcess(id, { status: 'running' });
}
/**
* Mark a process as idle
*/
markIdle(id: string): TrackedProcess | null {
return this.updateProcess(id, { status: 'idle' });
}
/**
* Mark a process as stopping
*/
markStopping(id: string): TrackedProcess | null {
return this.updateProcess(id, { status: 'stopping' });
}
/**
* Mark a process as stopped
*/
markStopped(id: string, exitCode?: number): TrackedProcess | null {
return this.updateProcess(id, { status: 'stopped', exitCode });
}
/**
* Mark a process as errored
*/
markError(id: string, error: string): TrackedProcess | null {
return this.updateProcess(id, { status: 'error', error });
}
/**
* Unregister a process (remove immediately without retention)
*/
unregisterProcess(id: string): boolean {
const process = this.processes.get(id);
if (!process) {
return false;
}
this.processes.delete(id);
logger.info('Process unregistered', {
id,
type: process.type,
name: process.name,
});
return true;
}
/**
* Get a process by ID
*/
getProcess(id: string): TrackedProcess | undefined {
return this.processes.get(id);
}
/**
* Get all tracked processes, optionally filtered
* Optimized single-pass filtering to avoid multiple array allocations
*/
getProcesses(options?: QueryProcessOptions): TrackedProcess[] {
// Pre-allocate array with estimated capacity
const result: TrackedProcess[] = [];
// Single-pass filtering
for (const process of this.processes.values()) {
// Filter by type
if (options?.type && process.type !== options.type) {
continue;
}
// Filter by status
if (options?.status && process.status !== options.status) {
continue;
}
// Filter out stopped processes by default
if (!options?.includeStopped) {
if (process.status === 'stopped' || process.status === 'error') {
continue;
}
}
// Filter by session ID
if (options?.sessionId && process.sessionId !== options.sessionId) {
continue;
}
// Filter by feature ID
if (options?.featureId && process.featureId !== options.featureId) {
continue;
}
result.push(process);
}
// Sort by start time (most recent first)
result.sort((a, b) => b.startedAt - a.startedAt);
return result;
}
/**
* Get all processes (for PerformanceMonitorService integration)
* This is used as the process provider function
*/
getAllProcesses(): TrackedProcess[] {
return Array.from(this.processes.values());
}
/**
* Get process provider function for PerformanceMonitorService
*/
getProcessProvider(): () => TrackedProcess[] {
return () => this.getAllProcesses();
}
/**
* Calculate summary statistics for tracked processes
*/
getProcessSummary(): ProcessSummary {
const processes = this.getAllProcesses();
const summary: ProcessSummary = {
total: processes.length,
running: 0,
idle: 0,
stopped: 0,
errored: 0,
byType: {
agent: 0,
cli: 0,
terminal: 0,
worker: 0,
},
};
for (const process of processes) {
// Count by status
switch (process.status) {
case 'running':
case 'starting':
summary.running++;
break;
case 'idle':
summary.idle++;
break;
case 'stopped':
case 'stopping':
summary.stopped++;
break;
case 'error':
summary.errored++;
break;
}
// Count by type
if (process.type in summary.byType) {
summary.byType[process.type]++;
}
}
return summary;
}
/**
* Get count of active (non-stopped) processes
*/
getActiveCount(): number {
let count = 0;
for (const process of this.processes.values()) {
if (process.status !== 'stopped' && process.status !== 'error') {
count++;
}
}
return count;
}
/**
* Get count of processes by type
*/
getCountByType(type: ProcessType): number {
let count = 0;
for (const process of this.processes.values()) {
if (process.type === type) {
count++;
}
}
return count;
}
/**
* Check if a process exists
*/
hasProcess(id: string): boolean {
return this.processes.has(id);
}
/**
* Update configuration
*/
updateConfig(config: Partial<ProcessRegistryConfig>): void {
this.config = { ...this.config, ...config };
logger.info('ProcessRegistryService configuration updated', config);
}
/**
* Get current configuration
*/
getConfig(): ProcessRegistryConfig {
return { ...this.config };
}
/**
* Clean up old stopped processes
*/
private cleanupStoppedProcesses(): void {
const now = Date.now();
const stoppedProcesses: Array<{ id: string; stoppedAt: number }> = [];
// Find all stopped processes
for (const [id, process] of this.processes.entries()) {
if ((process.status === 'stopped' || process.status === 'error') && process.stoppedAt) {
stoppedProcesses.push({ id, stoppedAt: process.stoppedAt });
}
}
// Sort by stoppedAt (oldest first)
stoppedProcesses.sort((a, b) => a.stoppedAt - b.stoppedAt);
let removedCount = 0;
// Remove processes that exceed retention time
for (const { id, stoppedAt } of stoppedProcesses) {
const age = now - stoppedAt;
if (age > this.config.stoppedProcessRetention) {
this.processes.delete(id);
removedCount++;
}
}
// If still over max, remove oldest stopped processes
const remainingStoppedCount = stoppedProcesses.length - removedCount;
if (remainingStoppedCount > this.config.maxStoppedProcesses) {
const toRemove = remainingStoppedCount - this.config.maxStoppedProcesses;
let removed = 0;
for (const { id } of stoppedProcesses) {
if (this.processes.has(id) && removed < toRemove) {
this.processes.delete(id);
removedCount++;
removed++;
}
}
}
if (removedCount > 0) {
logger.debug('Cleaned up stopped processes', { removedCount });
}
}
/**
* Clear all tracked processes
*/
clear(): void {
this.processes.clear();
logger.info('All tracked processes cleared');
}
// ============================================================================
// Agent Resource Metrics Tracking
// ============================================================================
/**
* Initialize resource metrics for an agent process
* Call this when an agent starts to begin tracking its resource usage
*/
initializeAgentMetrics(
processId: string,
options?: { sessionId?: string; featureId?: string }
): AgentResourceMetrics | null {
const process = this.processes.get(processId);
if (!process) {
logger.warn('Cannot initialize metrics for non-existent process', { processId });
return null;
}
if (process.type !== 'agent') {
logger.warn('Cannot initialize agent metrics for non-agent process', {
processId,
type: process.type,
});
return null;
}
const metrics = createEmptyAgentResourceMetrics(processId, {
sessionId: options?.sessionId || process.sessionId,
featureId: options?.featureId || process.featureId,
});
process.resourceMetrics = metrics;
logger.debug('Agent metrics initialized', { processId });
return metrics;
}
/**
* Get resource metrics for an agent process
*/
getAgentMetrics(processId: string): AgentResourceMetrics | undefined {
const process = this.processes.get(processId);
return process?.resourceMetrics;
}
/**
* Record a tool invocation for an agent
*/
recordToolUse(processId: string, options: RecordToolUseOptions): void {
const process = this.processes.get(processId);
if (!process?.resourceMetrics) {
return;
}
const metrics = process.resourceMetrics;
const now = Date.now();
// Update tool metrics
metrics.tools.totalInvocations++;
metrics.tools.byTool[options.toolName] = (metrics.tools.byTool[options.toolName] || 0) + 1;
if (options.executionTime !== undefined) {
metrics.tools.totalExecutionTime += options.executionTime;
metrics.tools.avgExecutionTime =
metrics.tools.totalExecutionTime / metrics.tools.totalInvocations;
}
if (options.failed) {
metrics.tools.failedInvocations++;
}
// Update memory snapshot
this.updateMemorySnapshot(processId);
metrics.lastUpdatedAt = now;
metrics.duration = now - metrics.startedAt;
logger.debug('Tool use recorded', {
processId,
tool: options.toolName,
totalInvocations: metrics.tools.totalInvocations,
});
}
/**
* Record a file operation for an agent
*/
recordFileOperation(processId: string, options: RecordFileOperationOptions): void {
const process = this.processes.get(processId);
if (!process?.resourceMetrics) {
return;
}
const metrics = process.resourceMetrics;
const now = Date.now();
// Update file I/O metrics based on operation type
switch (options.operation) {
case 'read':
metrics.fileIO.reads++;
if (options.bytes) {
metrics.fileIO.bytesRead += options.bytes;
}
break;
case 'write':
metrics.fileIO.writes++;
if (options.bytes) {
metrics.fileIO.bytesWritten += options.bytes;
}
break;
case 'edit':
metrics.fileIO.edits++;
if (options.bytes) {
metrics.fileIO.bytesWritten += options.bytes;
}
break;
case 'glob':
metrics.fileIO.globs++;
break;
case 'grep':
metrics.fileIO.greps++;
break;
}
// Track unique files accessed
if (!metrics.fileIO.filesAccessed.includes(options.filePath)) {
// Limit to 100 files to prevent memory bloat
if (metrics.fileIO.filesAccessed.length < 100) {
metrics.fileIO.filesAccessed.push(options.filePath);
}
}
metrics.lastUpdatedAt = now;
metrics.duration = now - metrics.startedAt;
logger.debug('File operation recorded', {
processId,
operation: options.operation,
filePath: options.filePath,
});
}
/**
* Record a bash command execution for an agent
*/
recordBashCommand(processId: string, options: RecordBashCommandOptions): void {
const process = this.processes.get(processId);
if (!process?.resourceMetrics) {
return;
}
const metrics = process.resourceMetrics;
const now = Date.now();
metrics.bash.commandCount++;
metrics.bash.totalExecutionTime += options.executionTime;
if (options.exitCode !== null && options.exitCode !== 0) {
metrics.bash.failedCommands++;
}
// Keep only last 20 commands to prevent memory bloat
if (metrics.bash.commands.length >= 20) {
metrics.bash.commands.shift();
}
metrics.bash.commands.push({
command: options.command.substring(0, 200), // Truncate long commands
exitCode: options.exitCode,
duration: options.executionTime,
timestamp: now,
});
// Update memory snapshot
this.updateMemorySnapshot(processId);
metrics.lastUpdatedAt = now;
metrics.duration = now - metrics.startedAt;
logger.debug('Bash command recorded', {
processId,
command: options.command.substring(0, 50),
exitCode: options.exitCode,
});
}
/**
* Record an API turn/iteration for an agent
*/
recordAPITurn(
processId: string,
options?: {
inputTokens?: number;
outputTokens?: number;
thinkingTokens?: number;
duration?: number;
error?: boolean;
}
): void {
const process = this.processes.get(processId);
if (!process?.resourceMetrics) {
return;
}
const metrics = process.resourceMetrics;
const now = Date.now();
metrics.api.turns++;
if (options?.inputTokens !== undefined) {
metrics.api.inputTokens = (metrics.api.inputTokens || 0) + options.inputTokens;
}
if (options?.outputTokens !== undefined) {
metrics.api.outputTokens = (metrics.api.outputTokens || 0) + options.outputTokens;
}
if (options?.thinkingTokens !== undefined) {
metrics.api.thinkingTokens = (metrics.api.thinkingTokens || 0) + options.thinkingTokens;
}
if (options?.duration !== undefined) {
metrics.api.totalDuration += options.duration;
}
if (options?.error) {
metrics.api.errors++;
}
// Update memory snapshot
this.updateMemorySnapshot(processId);
metrics.lastUpdatedAt = now;
metrics.duration = now - metrics.startedAt;
logger.debug('API turn recorded', {
processId,
turns: metrics.api.turns,
});
}
/**
* Update memory snapshot for an agent process
* Takes a memory sample and updates peak/delta values
*/
updateMemorySnapshot(processId: string): void {
const process = this.processes.get(processId);
if (!process?.resourceMetrics) {
return;
}
const metrics = process.resourceMetrics;
const now = Date.now();
const heapUsed = process.memoryUsage || 0;
// Update current heap
metrics.memory.currentHeapUsed = heapUsed;
// Update peak if higher
if (heapUsed > metrics.memory.peakHeapUsed) {
metrics.memory.peakHeapUsed = heapUsed;
}
// Calculate delta from start
metrics.memory.deltaHeapUsed = heapUsed - metrics.memory.startHeapUsed;
// Add sample (keep max 60 samples = 1 minute at 1 sample/second)
if (metrics.memory.samples.length >= 60) {
metrics.memory.samples.shift();
}
metrics.memory.samples.push({ timestamp: now, heapUsed });
metrics.lastUpdatedAt = now;
}
/**
* Mark agent metrics as completed (agent finished running)
*/
finalizeAgentMetrics(processId: string): void {
const process = this.processes.get(processId);
if (!process?.resourceMetrics) {
return;
}
const metrics = process.resourceMetrics;
const now = Date.now();
metrics.isRunning = false;
metrics.lastUpdatedAt = now;
metrics.duration = now - metrics.startedAt;
// Final memory snapshot
this.updateMemorySnapshot(processId);
logger.debug('Agent metrics finalized', {
processId,
duration: metrics.duration,
toolInvocations: metrics.tools.totalInvocations,
fileReads: metrics.fileIO.reads,
fileWrites: metrics.fileIO.writes,
bashCommands: metrics.bash.commandCount,
apiTurns: metrics.api.turns,
});
}
/**
* Get all agent processes with their resource metrics
*/
getAgentProcessesWithMetrics(): TrackedProcess[] {
const result: TrackedProcess[] = [];
for (const process of this.processes.values()) {
if (process.type === 'agent' && process.resourceMetrics) {
result.push(process);
}
}
return result.sort((a, b) => b.startedAt - a.startedAt);
}
/**
* Get summary of resource usage across all running agents
*/
getAgentResourceSummary(): {
totalAgents: number;
runningAgents: number;
totalFileReads: number;
totalFileWrites: number;
totalBytesRead: number;
totalBytesWritten: number;
totalToolInvocations: number;
totalBashCommands: number;
totalAPITurns: number;
peakMemoryUsage: number;
totalDuration: number;
} {
const summary = {
totalAgents: 0,
runningAgents: 0,
totalFileReads: 0,
totalFileWrites: 0,
totalBytesRead: 0,
totalBytesWritten: 0,
totalToolInvocations: 0,
totalBashCommands: 0,
totalAPITurns: 0,
peakMemoryUsage: 0,
totalDuration: 0,
};
for (const process of this.processes.values()) {
if (process.type !== 'agent' || !process.resourceMetrics) {
continue;
}
const metrics = process.resourceMetrics;
summary.totalAgents++;
if (metrics.isRunning) {
summary.runningAgents++;
}
summary.totalFileReads += metrics.fileIO.reads;
summary.totalFileWrites += metrics.fileIO.writes;
summary.totalBytesRead += metrics.fileIO.bytesRead;
summary.totalBytesWritten += metrics.fileIO.bytesWritten;
summary.totalToolInvocations += metrics.tools.totalInvocations;
summary.totalBashCommands += metrics.bash.commandCount;
summary.totalAPITurns += metrics.api.turns;
summary.totalDuration += metrics.duration;
if (metrics.memory.peakHeapUsed > summary.peakMemoryUsage) {
summary.peakMemoryUsage = metrics.memory.peakHeapUsed;
}
}
return summary;
}
}
// Singleton instance
let processRegistryService: ProcessRegistryService | null = null;
/**
* Get or create the ProcessRegistryService singleton
*/
export function getProcessRegistryService(
events?: EventEmitter,
config?: Partial<ProcessRegistryConfig>
): ProcessRegistryService {
if (!processRegistryService) {
if (!events) {
throw new Error('EventEmitter is required to initialize ProcessRegistryService');
}
processRegistryService = new ProcessRegistryService(events, config);
}
return processRegistryService;
}
/**
* Reset the singleton (for testing)
*/
export function resetProcessRegistryService(): void {
if (processRegistryService) {
processRegistryService.stop();
processRegistryService = null;
}
}

View File

@@ -0,0 +1,318 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Request, Response } from 'express';
import {
createGetMetricsHandler,
createStartMetricsHandler,
createStopMetricsHandler,
createForceGCHandler,
createClearHistoryHandler,
} from '@/routes/debug/routes/metrics.js';
import type { PerformanceMonitorService } from '@/services/performance-monitor-service.js';
import type { DebugMetricsConfig, DebugMetricsSnapshot } from '@automaker/types';
import { DEFAULT_DEBUG_METRICS_CONFIG } from '@automaker/types';
describe('Debug Metrics Routes', () => {
let mockPerformanceMonitor: Partial<PerformanceMonitorService>;
let mockReq: Partial<Request>;
let mockRes: Partial<Response>;
let jsonFn: ReturnType<typeof vi.fn>;
let statusFn: ReturnType<typeof vi.fn>;
const mockConfig: DebugMetricsConfig = { ...DEFAULT_DEBUG_METRICS_CONFIG };
const mockSnapshot: DebugMetricsSnapshot = {
timestamp: Date.now(),
memory: {
timestamp: Date.now(),
server: {
heapTotal: 100 * 1024 * 1024,
heapUsed: 50 * 1024 * 1024,
external: 5 * 1024 * 1024,
rss: 150 * 1024 * 1024,
arrayBuffers: 1 * 1024 * 1024,
},
},
cpu: {
timestamp: Date.now(),
server: {
percentage: 25.5,
user: 1000,
system: 500,
},
eventLoopLag: 5,
},
processes: [],
processSummary: {
total: 0,
running: 0,
idle: 0,
stopped: 0,
errored: 0,
byType: { agent: 0, cli: 0, terminal: 0, worker: 0 },
},
};
beforeEach(() => {
jsonFn = vi.fn();
statusFn = vi.fn(() => ({ json: jsonFn }));
mockPerformanceMonitor = {
getLatestSnapshot: vi.fn(() => mockSnapshot),
getConfig: vi.fn(() => mockConfig),
isActive: vi.fn(() => true),
start: vi.fn(),
stop: vi.fn(),
updateConfig: vi.fn(),
forceGC: vi.fn(() => true),
clearHistory: vi.fn(),
};
mockReq = {
body: {},
query: {},
params: {},
};
mockRes = {
json: jsonFn,
status: statusFn,
};
});
describe('GET /api/debug/metrics', () => {
it('should return current metrics snapshot', () => {
const handler = createGetMetricsHandler(mockPerformanceMonitor as PerformanceMonitorService);
handler(mockReq as Request, mockRes as Response);
expect(jsonFn).toHaveBeenCalledWith({
active: true,
config: mockConfig,
snapshot: mockSnapshot,
});
});
it('should return undefined snapshot when no data available', () => {
(mockPerformanceMonitor.getLatestSnapshot as ReturnType<typeof vi.fn>).mockReturnValue(null);
const handler = createGetMetricsHandler(mockPerformanceMonitor as PerformanceMonitorService);
handler(mockReq as Request, mockRes as Response);
expect(jsonFn).toHaveBeenCalledWith({
active: true,
config: mockConfig,
snapshot: undefined,
});
});
it('should return active status correctly', () => {
(mockPerformanceMonitor.isActive as ReturnType<typeof vi.fn>).mockReturnValue(false);
const handler = createGetMetricsHandler(mockPerformanceMonitor as PerformanceMonitorService);
handler(mockReq as Request, mockRes as Response);
expect(jsonFn).toHaveBeenCalledWith(
expect.objectContaining({
active: false,
})
);
});
});
describe('POST /api/debug/metrics/start', () => {
it('should start metrics collection', () => {
const handler = createStartMetricsHandler(
mockPerformanceMonitor as PerformanceMonitorService
);
handler(mockReq as Request, mockRes as Response);
expect(mockPerformanceMonitor.start).toHaveBeenCalled();
expect(jsonFn).toHaveBeenCalledWith({
active: true,
config: mockConfig,
});
});
it('should apply config overrides when provided', () => {
mockReq.body = {
config: {
collectionInterval: 5000,
maxDataPoints: 500,
},
};
const handler = createStartMetricsHandler(
mockPerformanceMonitor as PerformanceMonitorService
);
handler(mockReq as Request, mockRes as Response);
expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({
collectionInterval: 5000,
maxDataPoints: 500,
});
});
it('should sanitize config values - clamp collectionInterval to min 100ms', () => {
mockReq.body = {
config: {
collectionInterval: 10, // Below minimum of 100ms
},
};
const handler = createStartMetricsHandler(
mockPerformanceMonitor as PerformanceMonitorService
);
handler(mockReq as Request, mockRes as Response);
expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({
collectionInterval: 100,
});
});
it('should sanitize config values - clamp collectionInterval to max 60000ms', () => {
mockReq.body = {
config: {
collectionInterval: 100000, // Above maximum of 60000ms
},
};
const handler = createStartMetricsHandler(
mockPerformanceMonitor as PerformanceMonitorService
);
handler(mockReq as Request, mockRes as Response);
expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({
collectionInterval: 60000,
});
});
it('should sanitize config values - clamp maxDataPoints to bounds', () => {
mockReq.body = {
config: {
maxDataPoints: 5, // Below minimum of 10
},
};
const handler = createStartMetricsHandler(
mockPerformanceMonitor as PerformanceMonitorService
);
handler(mockReq as Request, mockRes as Response);
expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({
maxDataPoints: 10,
});
});
it('should sanitize config values - clamp maxDataPoints to max', () => {
mockReq.body = {
config: {
maxDataPoints: 50000, // Above maximum of 10000
},
};
const handler = createStartMetricsHandler(
mockPerformanceMonitor as PerformanceMonitorService
);
handler(mockReq as Request, mockRes as Response);
expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({
maxDataPoints: 10000,
});
});
it('should ignore non-object config', () => {
mockReq.body = {
config: 'not-an-object',
};
const handler = createStartMetricsHandler(
mockPerformanceMonitor as PerformanceMonitorService
);
handler(mockReq as Request, mockRes as Response);
expect(mockPerformanceMonitor.updateConfig).not.toHaveBeenCalled();
});
it('should ignore empty config object', () => {
mockReq.body = {
config: {},
};
const handler = createStartMetricsHandler(
mockPerformanceMonitor as PerformanceMonitorService
);
handler(mockReq as Request, mockRes as Response);
expect(mockPerformanceMonitor.updateConfig).not.toHaveBeenCalled();
});
it('should only accept boolean flags as actual booleans', () => {
mockReq.body = {
config: {
memoryEnabled: 'true', // String, not boolean - should be ignored
cpuEnabled: true, // Boolean - should be accepted
},
};
const handler = createStartMetricsHandler(
mockPerformanceMonitor as PerformanceMonitorService
);
handler(mockReq as Request, mockRes as Response);
expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({
cpuEnabled: true,
});
});
});
describe('POST /api/debug/metrics/stop', () => {
it('should stop metrics collection', () => {
const handler = createStopMetricsHandler(mockPerformanceMonitor as PerformanceMonitorService);
handler(mockReq as Request, mockRes as Response);
expect(mockPerformanceMonitor.stop).toHaveBeenCalled();
expect(jsonFn).toHaveBeenCalledWith({
active: false,
config: mockConfig,
});
});
});
describe('POST /api/debug/metrics/gc', () => {
it('should trigger garbage collection when available', () => {
const handler = createForceGCHandler(mockPerformanceMonitor as PerformanceMonitorService);
handler(mockReq as Request, mockRes as Response);
expect(mockPerformanceMonitor.forceGC).toHaveBeenCalled();
expect(jsonFn).toHaveBeenCalledWith({
success: true,
message: 'Garbage collection triggered',
});
});
it('should report when garbage collection is not available', () => {
(mockPerformanceMonitor.forceGC as ReturnType<typeof vi.fn>).mockReturnValue(false);
const handler = createForceGCHandler(mockPerformanceMonitor as PerformanceMonitorService);
handler(mockReq as Request, mockRes as Response);
expect(jsonFn).toHaveBeenCalledWith({
success: false,
message: 'Garbage collection not available (start Node.js with --expose-gc flag)',
});
});
});
describe('POST /api/debug/metrics/clear', () => {
it('should clear metrics history', () => {
const handler = createClearHistoryHandler(
mockPerformanceMonitor as PerformanceMonitorService
);
handler(mockReq as Request, mockRes as Response);
expect(mockPerformanceMonitor.clearHistory).toHaveBeenCalled();
expect(jsonFn).toHaveBeenCalledWith({
success: true,
message: 'Metrics history cleared',
});
});
});
});

View File

@@ -0,0 +1,293 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Request, Response } from 'express';
import {
createGetProcessesHandler,
createGetProcessHandler,
createGetSummaryHandler,
} from '@/routes/debug/routes/processes.js';
import type { ProcessRegistryService } from '@/services/process-registry-service.js';
import type { TrackedProcess, ProcessSummary } from '@automaker/types';
describe('Debug Processes Routes', () => {
let mockProcessRegistry: Partial<ProcessRegistryService>;
let mockReq: Partial<Request>;
let mockRes: Partial<Response>;
let jsonFn: ReturnType<typeof vi.fn>;
let statusFn: ReturnType<typeof vi.fn>;
const mockProcesses: TrackedProcess[] = [
{
id: 'process-1',
pid: 1234,
type: 'agent',
name: 'Agent 1',
status: 'running',
startedAt: Date.now() - 60000,
featureId: 'feature-1',
sessionId: 'session-1',
},
{
id: 'process-2',
pid: 5678,
type: 'terminal',
name: 'Terminal 1',
status: 'idle',
startedAt: Date.now() - 30000,
sessionId: 'session-1',
},
{
id: 'process-3',
pid: 9012,
type: 'cli',
name: 'CLI 1',
status: 'stopped',
startedAt: Date.now() - 120000,
stoppedAt: Date.now() - 60000,
exitCode: 0,
},
];
const mockSummary: ProcessSummary = {
total: 3,
running: 1,
idle: 1,
stopped: 1,
errored: 0,
byType: {
agent: 1,
cli: 1,
terminal: 1,
worker: 0,
},
};
beforeEach(() => {
jsonFn = vi.fn();
statusFn = vi.fn(() => ({ json: jsonFn }));
mockProcessRegistry = {
getProcesses: vi.fn(() => mockProcesses),
getProcess: vi.fn((id: string) => mockProcesses.find((p) => p.id === id)),
getProcessSummary: vi.fn(() => mockSummary),
};
mockReq = {
body: {},
query: {},
params: {},
};
mockRes = {
json: jsonFn,
status: statusFn,
};
});
describe('GET /api/debug/processes', () => {
it('should return list of processes with summary', () => {
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
handler(mockReq as Request, mockRes as Response);
expect(mockProcessRegistry.getProcesses).toHaveBeenCalled();
expect(mockProcessRegistry.getProcessSummary).toHaveBeenCalled();
expect(jsonFn).toHaveBeenCalledWith({
processes: mockProcesses,
summary: mockSummary,
});
});
it('should pass type filter to service', () => {
mockReq.query = { type: 'agent' };
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
handler(mockReq as Request, mockRes as Response);
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith(
expect.objectContaining({
type: 'agent',
})
);
});
it('should pass status filter to service', () => {
mockReq.query = { status: 'running' };
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
handler(mockReq as Request, mockRes as Response);
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith(
expect.objectContaining({
status: 'running',
})
);
});
it('should pass includeStopped flag when set to "true"', () => {
mockReq.query = { includeStopped: 'true' };
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
handler(mockReq as Request, mockRes as Response);
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith(
expect.objectContaining({
includeStopped: true,
})
);
});
it('should not pass includeStopped when not "true"', () => {
mockReq.query = { includeStopped: 'false' };
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
handler(mockReq as Request, mockRes as Response);
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith(
expect.objectContaining({
includeStopped: undefined,
})
);
});
it('should pass sessionId filter to service', () => {
mockReq.query = { sessionId: 'session-1' };
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
handler(mockReq as Request, mockRes as Response);
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: 'session-1',
})
);
});
it('should pass featureId filter to service', () => {
mockReq.query = { featureId: 'feature-1' };
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
handler(mockReq as Request, mockRes as Response);
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith(
expect.objectContaining({
featureId: 'feature-1',
})
);
});
it('should handle multiple filters', () => {
mockReq.query = {
type: 'agent',
status: 'running',
sessionId: 'session-1',
includeStopped: 'true',
};
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
handler(mockReq as Request, mockRes as Response);
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith({
type: 'agent',
status: 'running',
sessionId: 'session-1',
includeStopped: true,
featureId: undefined,
});
});
});
describe('GET /api/debug/processes/:id', () => {
it('should return a specific process by ID', () => {
mockReq.params = { id: 'process-1' };
const handler = createGetProcessHandler(mockProcessRegistry as ProcessRegistryService);
handler(mockReq as Request, mockRes as Response);
expect(mockProcessRegistry.getProcess).toHaveBeenCalledWith('process-1');
expect(jsonFn).toHaveBeenCalledWith(mockProcesses[0]);
});
it('should return 404 for non-existent process', () => {
mockReq.params = { id: 'non-existent' };
(mockProcessRegistry.getProcess as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
const handler = createGetProcessHandler(mockProcessRegistry as ProcessRegistryService);
handler(mockReq as Request, mockRes as Response);
expect(statusFn).toHaveBeenCalledWith(404);
expect(jsonFn).toHaveBeenCalledWith({
error: 'Process not found',
id: 'non-existent',
});
});
it('should return 400 for empty process ID', () => {
mockReq.params = { id: '' };
const handler = createGetProcessHandler(mockProcessRegistry as ProcessRegistryService);
handler(mockReq as Request, mockRes as Response);
expect(statusFn).toHaveBeenCalledWith(400);
expect(jsonFn).toHaveBeenCalledWith({
error: 'Invalid process ID format',
});
});
it('should return 400 for process ID exceeding max length', () => {
mockReq.params = { id: 'a'.repeat(257) };
const handler = createGetProcessHandler(mockProcessRegistry as ProcessRegistryService);
handler(mockReq as Request, mockRes as Response);
expect(statusFn).toHaveBeenCalledWith(400);
expect(jsonFn).toHaveBeenCalledWith({
error: 'Invalid process ID format',
});
});
it('should accept process ID at max length', () => {
mockReq.params = { id: 'a'.repeat(256) };
(mockProcessRegistry.getProcess as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
const handler = createGetProcessHandler(mockProcessRegistry as ProcessRegistryService);
handler(mockReq as Request, mockRes as Response);
// Should pass validation but process not found
expect(statusFn).toHaveBeenCalledWith(404);
});
});
describe('GET /api/debug/processes/summary', () => {
it('should return process summary', () => {
const handler = createGetSummaryHandler(mockProcessRegistry as ProcessRegistryService);
handler(mockReq as Request, mockRes as Response);
expect(mockProcessRegistry.getProcessSummary).toHaveBeenCalled();
expect(jsonFn).toHaveBeenCalledWith(mockSummary);
});
it('should return correct counts', () => {
const customSummary: ProcessSummary = {
total: 10,
running: 5,
idle: 2,
stopped: 2,
errored: 1,
byType: {
agent: 4,
cli: 3,
terminal: 2,
worker: 1,
},
};
(mockProcessRegistry.getProcessSummary as ReturnType<typeof vi.fn>).mockReturnValue(
customSummary
);
const handler = createGetSummaryHandler(mockProcessRegistry as ProcessRegistryService);
handler(mockReq as Request, mockRes as Response);
expect(jsonFn).toHaveBeenCalledWith(customSummary);
});
});
});

View File

@@ -0,0 +1,418 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { PerformanceMonitorService } from '@/services/performance-monitor-service.js';
import { createEventEmitter } from '@/lib/events.js';
import type { EventEmitter } from '@/lib/events.js';
import type { TrackedProcess, DebugMetricsConfig } from '@automaker/types';
import { DEFAULT_DEBUG_METRICS_CONFIG } from '@automaker/types';
// Mock the logger to prevent console output during tests
vi.mock('@automaker/utils', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}));
describe('PerformanceMonitorService', () => {
let service: PerformanceMonitorService;
let events: EventEmitter;
beforeEach(() => {
vi.useFakeTimers();
events = createEventEmitter();
service = new PerformanceMonitorService(events);
});
afterEach(() => {
service.stop();
vi.useRealTimers();
vi.clearAllMocks();
});
describe('initialization', () => {
it('should initialize with default configuration', () => {
const config = service.getConfig();
expect(config.collectionInterval).toBe(DEFAULT_DEBUG_METRICS_CONFIG.collectionInterval);
expect(config.maxDataPoints).toBe(DEFAULT_DEBUG_METRICS_CONFIG.maxDataPoints);
expect(config.memoryEnabled).toBe(DEFAULT_DEBUG_METRICS_CONFIG.memoryEnabled);
expect(config.cpuEnabled).toBe(DEFAULT_DEBUG_METRICS_CONFIG.cpuEnabled);
});
it('should accept custom configuration on initialization', () => {
const customConfig: Partial<DebugMetricsConfig> = {
collectionInterval: 5000,
maxDataPoints: 500,
memoryEnabled: false,
};
const customService = new PerformanceMonitorService(events, customConfig);
const config = customService.getConfig();
expect(config.collectionInterval).toBe(5000);
expect(config.maxDataPoints).toBe(500);
expect(config.memoryEnabled).toBe(false);
expect(config.cpuEnabled).toBe(DEFAULT_DEBUG_METRICS_CONFIG.cpuEnabled);
customService.stop();
});
it('should not be running initially', () => {
expect(service.isActive()).toBe(false);
});
});
describe('start/stop', () => {
it('should start metrics collection', () => {
service.start();
expect(service.isActive()).toBe(true);
});
it('should stop metrics collection', () => {
service.start();
expect(service.isActive()).toBe(true);
service.stop();
expect(service.isActive()).toBe(false);
});
it('should not start again if already running', () => {
service.start();
const isActive1 = service.isActive();
service.start(); // Should log warning but not throw
const isActive2 = service.isActive();
expect(isActive1).toBe(true);
expect(isActive2).toBe(true);
});
it('should handle stop when not running', () => {
// Should not throw
expect(() => service.stop()).not.toThrow();
});
});
describe('configuration updates', () => {
it('should update configuration', () => {
service.updateConfig({ collectionInterval: 2000 });
expect(service.getConfig().collectionInterval).toBe(2000);
});
it('should restart collection if running when config is updated', () => {
service.start();
expect(service.isActive()).toBe(true);
service.updateConfig({ collectionInterval: 5000 });
// Should still be running after config update
expect(service.isActive()).toBe(true);
expect(service.getConfig().collectionInterval).toBe(5000);
});
it('should resize data buffers when maxDataPoints changes', () => {
// Start and collect some data
service.start();
// Collect multiple data points
for (let i = 0; i < 50; i++) {
vi.advanceTimersByTime(service.getConfig().collectionInterval);
}
// Reduce max data points
service.updateConfig({ maxDataPoints: 10 });
const history = service.getMemoryHistory();
expect(history.length).toBeLessThanOrEqual(10);
});
});
describe('metrics collection', () => {
it('should emit debug:metrics event on collection', () => {
const callback = vi.fn();
events.subscribe(callback);
service.start();
vi.advanceTimersByTime(service.getConfig().collectionInterval);
expect(callback).toHaveBeenCalled();
const [eventType, eventData] = callback.mock.calls[0];
expect(eventType).toBe('debug:metrics');
expect(eventData).toHaveProperty('timestamp');
expect(eventData).toHaveProperty('metrics');
});
it('should collect memory metrics when memoryEnabled is true', () => {
const callback = vi.fn();
events.subscribe(callback);
service.start();
vi.advanceTimersByTime(service.getConfig().collectionInterval);
const [, eventData] = callback.mock.calls[0];
expect(eventData.metrics.memory.server).toBeDefined();
expect(eventData.metrics.memory.server.heapUsed).toBeGreaterThan(0);
expect(eventData.metrics.memory.server.heapTotal).toBeGreaterThan(0);
});
it('should not collect memory metrics when memoryEnabled is false', () => {
const customService = new PerformanceMonitorService(events, { memoryEnabled: false });
const callback = vi.fn();
events.subscribe(callback);
customService.start();
vi.advanceTimersByTime(customService.getConfig().collectionInterval);
const [, eventData] = callback.mock.calls[0];
expect(eventData.metrics.memory.server).toBeUndefined();
customService.stop();
});
it('should collect CPU metrics when cpuEnabled is true', () => {
const callback = vi.fn();
events.subscribe(callback);
service.start();
vi.advanceTimersByTime(service.getConfig().collectionInterval);
vi.advanceTimersByTime(service.getConfig().collectionInterval);
// Need at least 2 collections for CPU diff
const lastCall = callback.mock.calls[callback.mock.calls.length - 1];
const [, eventData] = lastCall;
expect(eventData.metrics.cpu.server).toBeDefined();
});
it('should track event loop lag', () => {
const callback = vi.fn();
events.subscribe(callback);
service.start();
vi.advanceTimersByTime(service.getConfig().collectionInterval);
const [, eventData] = callback.mock.calls[0];
expect(eventData.metrics.cpu.eventLoopLag).toBeDefined();
});
});
describe('memory history', () => {
it('should return empty history initially', () => {
const history = service.getMemoryHistory();
expect(history).toEqual([]);
});
it('should accumulate memory history over time', () => {
service.start();
// Collect multiple data points
for (let i = 0; i < 5; i++) {
vi.advanceTimersByTime(service.getConfig().collectionInterval);
}
const history = service.getMemoryHistory();
expect(history.length).toBeGreaterThan(0);
});
it('should limit history to maxDataPoints', () => {
const maxPoints = 10;
const customService = new PerformanceMonitorService(events, { maxDataPoints: maxPoints });
customService.start();
// Collect more data points than max
for (let i = 0; i < maxPoints + 10; i++) {
vi.advanceTimersByTime(customService.getConfig().collectionInterval);
}
const history = customService.getMemoryHistory();
expect(history.length).toBeLessThanOrEqual(maxPoints);
customService.stop();
});
});
describe('CPU history', () => {
it('should return empty CPU history initially', () => {
const history = service.getCPUHistory();
expect(history).toEqual([]);
});
it('should accumulate CPU history over time', () => {
service.start();
// Collect multiple data points (need at least 2 for CPU diff)
for (let i = 0; i < 5; i++) {
vi.advanceTimersByTime(service.getConfig().collectionInterval);
}
const history = service.getCPUHistory();
expect(history.length).toBeGreaterThan(0);
});
});
describe('process provider', () => {
it('should use provided process provider', () => {
const mockProcesses: TrackedProcess[] = [
{
id: 'test-1',
type: 'agent',
name: 'TestAgent',
status: 'running',
startedAt: Date.now(),
},
{
id: 'test-2',
type: 'terminal',
name: 'TestTerminal',
status: 'idle',
startedAt: Date.now(),
},
];
const provider = vi.fn(() => mockProcesses);
service.setProcessProvider(provider);
const callback = vi.fn();
events.subscribe(callback);
service.start();
vi.advanceTimersByTime(service.getConfig().collectionInterval);
const [, eventData] = callback.mock.calls[0];
expect(eventData.metrics.processes).toEqual(mockProcesses);
expect(eventData.metrics.processSummary.total).toBe(2);
expect(eventData.metrics.processSummary.running).toBe(1);
expect(eventData.metrics.processSummary.idle).toBe(1);
expect(eventData.metrics.processSummary.byType.agent).toBe(1);
expect(eventData.metrics.processSummary.byType.terminal).toBe(1);
});
});
describe('getLatestSnapshot', () => {
it('should return null when no data collected', () => {
const snapshot = service.getLatestSnapshot();
expect(snapshot).toBeNull();
});
it('should return snapshot after data collection', () => {
service.start();
vi.advanceTimersByTime(service.getConfig().collectionInterval);
const snapshot = service.getLatestSnapshot();
expect(snapshot).not.toBeNull();
expect(snapshot).toHaveProperty('timestamp');
expect(snapshot).toHaveProperty('memory');
expect(snapshot).toHaveProperty('cpu');
expect(snapshot).toHaveProperty('processes');
expect(snapshot).toHaveProperty('processSummary');
});
});
describe('clearHistory', () => {
it('should clear all history', () => {
service.start();
// Collect some data
for (let i = 0; i < 5; i++) {
vi.advanceTimersByTime(service.getConfig().collectionInterval);
}
expect(service.getMemoryHistory().length).toBeGreaterThan(0);
service.clearHistory();
expect(service.getMemoryHistory().length).toBe(0);
expect(service.getCPUHistory().length).toBe(0);
});
});
describe('forceGC', () => {
it('should return false when gc is not available', () => {
const originalGc = global.gc;
global.gc = undefined;
const result = service.forceGC();
expect(result).toBe(false);
// Restore
global.gc = originalGc;
});
it('should return true and call gc when available', () => {
const mockGc = vi.fn();
global.gc = mockGc;
const result = service.forceGC();
expect(result).toBe(true);
expect(mockGc).toHaveBeenCalled();
// Cleanup
global.gc = undefined;
});
});
describe('memory trend analysis', () => {
it('should not calculate trend with insufficient data', () => {
service.start();
// Collect only a few data points
for (let i = 0; i < 5; i++) {
vi.advanceTimersByTime(service.getConfig().collectionInterval);
}
const snapshot = service.getLatestSnapshot();
// Trend requires at least 10 samples
expect(snapshot?.memoryTrend).toBeUndefined();
});
it('should calculate trend with sufficient data', () => {
service.start();
// Collect enough data points for trend analysis
for (let i = 0; i < 15; i++) {
vi.advanceTimersByTime(service.getConfig().collectionInterval);
}
const snapshot = service.getLatestSnapshot();
expect(snapshot?.memoryTrend).toBeDefined();
expect(snapshot?.memoryTrend).toHaveProperty('growthRate');
expect(snapshot?.memoryTrend).toHaveProperty('isLeaking');
expect(snapshot?.memoryTrend).toHaveProperty('confidence');
expect(snapshot?.memoryTrend).toHaveProperty('sampleCount');
});
});
describe('process summary calculation', () => {
it('should correctly categorize processes by status', () => {
const mockProcesses: TrackedProcess[] = [
{ id: '1', type: 'agent', name: 'A1', status: 'running', startedAt: Date.now() },
{ id: '2', type: 'agent', name: 'A2', status: 'starting', startedAt: Date.now() },
{ id: '3', type: 'terminal', name: 'T1', status: 'idle', startedAt: Date.now() },
{ id: '4', type: 'terminal', name: 'T2', status: 'stopped', startedAt: Date.now() },
{ id: '5', type: 'cli', name: 'C1', status: 'stopping', startedAt: Date.now() },
{ id: '6', type: 'worker', name: 'W1', status: 'error', startedAt: Date.now() },
];
service.setProcessProvider(() => mockProcesses);
const callback = vi.fn();
events.subscribe(callback);
service.start();
vi.advanceTimersByTime(service.getConfig().collectionInterval);
const [, eventData] = callback.mock.calls[0];
const summary = eventData.metrics.processSummary;
expect(summary.total).toBe(6);
expect(summary.running).toBe(2); // running + starting
expect(summary.idle).toBe(1);
expect(summary.stopped).toBe(2); // stopped + stopping
expect(summary.errored).toBe(1);
expect(summary.byType.agent).toBe(2);
expect(summary.byType.terminal).toBe(2);
expect(summary.byType.cli).toBe(1);
expect(summary.byType.worker).toBe(1);
});
});
});

View File

@@ -0,0 +1,538 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
ProcessRegistryService,
getProcessRegistryService,
resetProcessRegistryService,
} from '@/services/process-registry-service.js';
import { createEventEmitter } from '@/lib/events.js';
import type { EventEmitter } from '@/lib/events.js';
import type { TrackedProcess, ProcessType, ProcessStatus } from '@automaker/types';
// Mock the logger to prevent console output during tests
vi.mock('@automaker/utils', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}));
describe('ProcessRegistryService', () => {
let service: ProcessRegistryService;
let events: EventEmitter;
beforeEach(() => {
vi.useFakeTimers();
events = createEventEmitter();
service = new ProcessRegistryService(events);
resetProcessRegistryService();
});
afterEach(() => {
service.stop();
vi.useRealTimers();
vi.clearAllMocks();
});
describe('initialization', () => {
it('should initialize with default configuration', () => {
const config = service.getConfig();
expect(config.stoppedProcessRetention).toBe(5 * 60 * 1000);
expect(config.cleanupInterval).toBe(60 * 1000);
expect(config.maxStoppedProcesses).toBe(100);
});
it('should accept custom configuration', () => {
const customService = new ProcessRegistryService(events, {
stoppedProcessRetention: 10000,
maxStoppedProcesses: 50,
});
const config = customService.getConfig();
expect(config.stoppedProcessRetention).toBe(10000);
expect(config.maxStoppedProcesses).toBe(50);
expect(config.cleanupInterval).toBe(60 * 1000);
customService.stop();
});
});
describe('start/stop', () => {
it('should start the service', () => {
expect(() => service.start()).not.toThrow();
});
it('should stop the service', () => {
service.start();
expect(() => service.stop()).not.toThrow();
});
it('should not start again if already running', () => {
service.start();
// Should log warning but not throw
expect(() => service.start()).not.toThrow();
});
});
describe('process registration', () => {
it('should register a new process', () => {
const process = service.registerProcess({
id: 'test-1',
pid: 1234,
type: 'agent',
name: 'TestAgent',
});
expect(process.id).toBe('test-1');
expect(process.pid).toBe(1234);
expect(process.type).toBe('agent');
expect(process.name).toBe('TestAgent');
expect(process.status).toBe('starting');
expect(process.startedAt).toBeDefined();
});
it('should register a process with all optional fields', () => {
const process = service.registerProcess({
id: 'test-2',
pid: 5678,
type: 'terminal',
name: 'TestTerminal',
featureId: 'feature-123',
sessionId: 'session-456',
command: 'bash',
cwd: '/home/user',
});
expect(process.featureId).toBe('feature-123');
expect(process.sessionId).toBe('session-456');
expect(process.command).toBe('bash');
expect(process.cwd).toBe('/home/user');
});
it('should emit debug:process-spawned event on registration', () => {
const callback = vi.fn();
events.subscribe(callback);
service.registerProcess({
id: 'test-3',
pid: 111,
type: 'cli',
name: 'TestCLI',
});
expect(callback).toHaveBeenCalled();
const [eventType, eventData] = callback.mock.calls[0];
expect(eventType).toBe('debug:process-spawned');
expect(eventData.process.id).toBe('test-3');
});
});
describe('process retrieval', () => {
beforeEach(() => {
// Register test processes
service.registerProcess({
id: 'p1',
pid: 1,
type: 'agent',
name: 'Agent1',
featureId: 'f1',
sessionId: 's1',
});
service.registerProcess({
id: 'p2',
pid: 2,
type: 'terminal',
name: 'Terminal1',
sessionId: 's1',
});
service.registerProcess({ id: 'p3', pid: 3, type: 'cli', name: 'CLI1', featureId: 'f2' });
});
it('should get a process by ID', () => {
const process = service.getProcess('p1');
expect(process).toBeDefined();
expect(process?.name).toBe('Agent1');
});
it('should return undefined for non-existent process', () => {
const process = service.getProcess('non-existent');
expect(process).toBeUndefined();
});
it('should check if process exists', () => {
expect(service.hasProcess('p1')).toBe(true);
expect(service.hasProcess('non-existent')).toBe(false);
});
it('should get all processes without filters', () => {
const processes = service.getProcesses({ includeStopped: true });
expect(processes.length).toBe(3);
});
it('should filter by type', () => {
const agents = service.getProcesses({ type: 'agent', includeStopped: true });
expect(agents.length).toBe(1);
expect(agents[0].type).toBe('agent');
});
it('should filter by session ID', () => {
const sessionProcesses = service.getProcesses({ sessionId: 's1', includeStopped: true });
expect(sessionProcesses.length).toBe(2);
});
it('should filter by feature ID', () => {
const featureProcesses = service.getProcesses({ featureId: 'f1', includeStopped: true });
expect(featureProcesses.length).toBe(1);
expect(featureProcesses[0].id).toBe('p1');
});
it('should exclude stopped processes by default', () => {
service.markStopped('p1');
const processes = service.getProcesses();
expect(processes.length).toBe(2);
expect(processes.find((p) => p.id === 'p1')).toBeUndefined();
});
it('should include stopped processes when requested', () => {
service.markStopped('p1');
const processes = service.getProcesses({ includeStopped: true });
expect(processes.length).toBe(3);
});
it('should sort processes by start time (most recent first)', () => {
// Re-register processes with different timestamps
service.clear();
// Register p1 at time 0
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'Agent1' });
// Advance time and register p2
vi.advanceTimersByTime(1000);
service.registerProcess({ id: 'p2', pid: 2, type: 'terminal', name: 'Terminal1' });
// Advance time and register p3
vi.advanceTimersByTime(1000);
service.registerProcess({ id: 'p3', pid: 3, type: 'cli', name: 'CLI1' });
const processes = service.getProcesses({ includeStopped: true });
// p3 was registered last (most recent), so it should be first
expect(processes[0].id).toBe('p3');
expect(processes[1].id).toBe('p2');
expect(processes[2].id).toBe('p1');
});
});
describe('process status updates', () => {
let process: TrackedProcess;
beforeEach(() => {
process = service.registerProcess({
id: 'test-proc',
pid: 100,
type: 'agent',
name: 'TestProcess',
});
});
it('should update process status', () => {
const updated = service.updateProcess('test-proc', { status: 'running' });
expect(updated?.status).toBe('running');
});
it('should update memory usage', () => {
const updated = service.updateProcess('test-proc', { memoryUsage: 1024 * 1024 });
expect(updated?.memoryUsage).toBe(1024 * 1024);
});
it('should update CPU usage', () => {
const updated = service.updateProcess('test-proc', { cpuUsage: 45.5 });
expect(updated?.cpuUsage).toBe(45.5);
});
it('should return null for non-existent process', () => {
const updated = service.updateProcess('non-existent', { status: 'running' });
expect(updated).toBeNull();
});
it('should set stoppedAt when status is stopped', () => {
const updated = service.markStopped('test-proc');
expect(updated?.stoppedAt).toBeDefined();
});
it('should set stoppedAt when status is error', () => {
const updated = service.markError('test-proc', 'Something went wrong');
expect(updated?.stoppedAt).toBeDefined();
expect(updated?.error).toBe('Something went wrong');
});
});
describe('status shortcut methods', () => {
beforeEach(() => {
service.registerProcess({
id: 'test-proc',
pid: 100,
type: 'agent',
name: 'TestProcess',
});
});
it('should mark process as running', () => {
const updated = service.markRunning('test-proc');
expect(updated?.status).toBe('running');
});
it('should mark process as idle', () => {
const updated = service.markIdle('test-proc');
expect(updated?.status).toBe('idle');
});
it('should mark process as stopping', () => {
const updated = service.markStopping('test-proc');
expect(updated?.status).toBe('stopping');
});
it('should mark process as stopped with exit code', () => {
const updated = service.markStopped('test-proc', 0);
expect(updated?.status).toBe('stopped');
expect(updated?.exitCode).toBe(0);
});
it('should mark process as error with message', () => {
const updated = service.markError('test-proc', 'Process crashed');
expect(updated?.status).toBe('error');
expect(updated?.error).toBe('Process crashed');
});
});
describe('event emissions', () => {
let callback: ReturnType<typeof vi.fn>;
beforeEach(() => {
callback = vi.fn();
events.subscribe(callback);
service.registerProcess({
id: 'test-proc',
pid: 100,
type: 'agent',
name: 'TestProcess',
});
callback.mockClear();
});
it('should emit debug:process-stopped when stopped', () => {
service.markStopped('test-proc', 0);
expect(callback).toHaveBeenCalled();
const [eventType] = callback.mock.calls[0];
expect(eventType).toBe('debug:process-stopped');
});
it('should emit debug:process-error when errored', () => {
service.markError('test-proc', 'Error message');
expect(callback).toHaveBeenCalled();
const [eventType, eventData] = callback.mock.calls[0];
expect(eventType).toBe('debug:process-error');
expect(eventData.message).toContain('Error message');
});
it('should emit debug:process-updated for other status changes', () => {
service.markRunning('test-proc');
expect(callback).toHaveBeenCalled();
const [eventType] = callback.mock.calls[0];
expect(eventType).toBe('debug:process-updated');
});
});
describe('process unregistration', () => {
it('should unregister an existing process', () => {
service.registerProcess({
id: 'test-proc',
pid: 100,
type: 'agent',
name: 'TestProcess',
});
const result = service.unregisterProcess('test-proc');
expect(result).toBe(true);
expect(service.getProcess('test-proc')).toBeUndefined();
});
it('should return false for non-existent process', () => {
const result = service.unregisterProcess('non-existent');
expect(result).toBe(false);
});
});
describe('process summary', () => {
beforeEach(() => {
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' });
service.registerProcess({ id: 'p2', pid: 2, type: 'agent', name: 'A2' });
service.registerProcess({ id: 'p3', pid: 3, type: 'terminal', name: 'T1' });
service.registerProcess({ id: 'p4', pid: 4, type: 'cli', name: 'C1' });
service.registerProcess({ id: 'p5', pid: 5, type: 'worker', name: 'W1' });
// Update statuses
service.markRunning('p1');
service.markIdle('p2');
service.markStopped('p3');
service.markError('p4', 'error');
service.markRunning('p5');
});
it('should calculate correct summary statistics', () => {
const summary = service.getProcessSummary();
expect(summary.total).toBe(5);
expect(summary.running).toBe(2); // p1 running, p5 running
expect(summary.idle).toBe(1); // p2 idle
expect(summary.stopped).toBe(1); // p3 stopped
expect(summary.errored).toBe(1); // p4 error
});
it('should count processes by type', () => {
const summary = service.getProcessSummary();
expect(summary.byType.agent).toBe(2);
expect(summary.byType.terminal).toBe(1);
expect(summary.byType.cli).toBe(1);
expect(summary.byType.worker).toBe(1);
});
});
describe('active count', () => {
beforeEach(() => {
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' });
service.registerProcess({ id: 'p2', pid: 2, type: 'agent', name: 'A2' });
service.registerProcess({ id: 'p3', pid: 3, type: 'terminal', name: 'T1' });
service.markRunning('p1');
service.markStopped('p2');
service.markIdle('p3');
});
it('should return count of active processes', () => {
expect(service.getActiveCount()).toBe(2); // p1 running, p3 idle
});
it('should return count by type', () => {
expect(service.getCountByType('agent')).toBe(2);
expect(service.getCountByType('terminal')).toBe(1);
expect(service.getCountByType('cli')).toBe(0);
});
});
describe('process provider', () => {
it('should return a process provider function', () => {
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' });
const provider = service.getProcessProvider();
expect(typeof provider).toBe('function');
const processes = provider();
expect(processes.length).toBe(1);
expect(processes[0].id).toBe('p1');
});
it('should return all processes including stopped', () => {
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' });
service.registerProcess({ id: 'p2', pid: 2, type: 'agent', name: 'A2' });
service.markStopped('p2');
const provider = service.getProcessProvider();
const processes = provider();
expect(processes.length).toBe(2);
});
});
describe('cleanup', () => {
it('should clean up old stopped processes', () => {
// Register and stop a process
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' });
service.markStopped('p1');
// Start service to enable cleanup
service.start();
// Advance time past retention period
vi.advanceTimersByTime(6 * 60 * 1000); // 6 minutes (past default 5 min retention)
// Process should be cleaned up
expect(service.getProcess('p1')).toBeUndefined();
});
it('should enforce max stopped processes limit', () => {
const customService = new ProcessRegistryService(events, {
maxStoppedProcesses: 3,
cleanupInterval: 1000,
});
// Register and stop more processes than max
for (let i = 0; i < 5; i++) {
customService.registerProcess({ id: `p${i}`, pid: i, type: 'agent', name: `A${i}` });
customService.markStopped(`p${i}`);
}
customService.start();
// Trigger cleanup
vi.advanceTimersByTime(1000);
// Should only have max stopped processes
const allProcesses = customService.getAllProcesses();
expect(allProcesses.length).toBeLessThanOrEqual(3);
customService.stop();
});
});
describe('configuration update', () => {
it('should update configuration', () => {
service.updateConfig({ maxStoppedProcesses: 200 });
expect(service.getConfig().maxStoppedProcesses).toBe(200);
});
});
describe('clear', () => {
it('should clear all tracked processes', () => {
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' });
service.registerProcess({ id: 'p2', pid: 2, type: 'terminal', name: 'T1' });
service.clear();
expect(service.getAllProcesses().length).toBe(0);
});
});
describe('singleton pattern', () => {
beforeEach(() => {
resetProcessRegistryService();
});
afterEach(() => {
resetProcessRegistryService();
});
it('should create singleton instance', () => {
const instance1 = getProcessRegistryService(events);
const instance2 = getProcessRegistryService();
expect(instance1).toBe(instance2);
});
it('should throw if no events provided on first call', () => {
expect(() => getProcessRegistryService()).toThrow();
});
it('should reset singleton', () => {
const instance1 = getProcessRegistryService(events);
resetProcessRegistryService();
const instance2 = getProcessRegistryService(events);
expect(instance1).not.toBe(instance2);
});
});
});