Compare commits

..

20 Commits

Author SHA1 Message Date
webdevcody
329e429841 fix: downgrade @modelcontextprotocol/sdk to version 1.25.0 in package.json 2026-01-06 21:34:42 -05:00
antdev
46636cf385 fix background image and create test in case background image failed again 2026-01-07 02:04:22 +08:00
antdev
e24a6894a5 Merge remote-tracking branch 'refs/remotes/origin/main'
# Conflicts:
#	apps/ui/src/routes/__root.tsx
2026-01-07 01:48:34 +08:00
antdev
cf9289e21a fix background image and create test incase background image failed again 2026-01-07 01:21:46 +08:00
webdevcody
fe7bc954ba chore: add OpenSSH client to Dockerfile for enhanced SSH capabilities
- Updated the Dockerfile to include the OpenSSH client, improving the container's ability to handle SSH connections and operations.
2026-01-06 00:36:45 -05:00
Web Dev Cody
8a6a83bf52 Merge pull request #366 from AutoMaker-Org/cursor-docker-oauth
feat: add Cursor CLI installation attempts documentation and enhance …
2026-01-05 21:53:31 -05:00
webdevcody
84b582ffa7 refactor: streamline Docker container management and enhance utility functions
- Removed redundant Docker image rebuilding logic from `dev.mjs` and `start.mjs`, centralizing it in the new `launchDockerContainers` function within `launcher-utils.mjs`.
- Introduced `sanitizeProjectName` and `shouldRebuildDockerImages` functions to improve project name handling and Docker image management.
- Updated the Docker launch process to provide clearer logging and ensure proper handling of environment variables, enhancing the overall development experience.
2026-01-05 21:50:12 -05:00
webdevcody
bd5176165d refactor: remove duplicate logger initialization in useCliStatus hook
- Eliminated redundant logger declaration within the useCliStatus hook to improve code clarity and prevent potential performance issues.
- This change enhances the maintainability of the code by ensuring the logger is created only once outside the hook.
2026-01-05 21:38:18 -05:00
webdevcody
49f32c4d59 Merge branch 'main' of github.com:AutoMaker-Org/automaker into cursor-docker-oauth 2026-01-05 21:29:20 -05:00
webdevcody
0af5bc86f4 Merge branch 'cursor-docker-oauth' of github.com:AutoMaker-Org/automaker into cursor-docker-oauth 2026-01-05 21:29:01 -05:00
webdevcody
bc5a36c5f4 feat: enhance project name sanitization and improve Docker image naming
- Added a `sanitizeProjectName` function to ensure project names are safe for shell commands and Docker image names by converting them to lowercase and removing non-alphanumeric characters.
- Updated `dev.mjs` and `start.mjs` to utilize the new sanitization function when determining Docker image names, enhancing security and consistency.
- Refactored the Docker entrypoint script to ensure proper permissions for the Cursor CLI config directory, improving setup reliability.
- Clarified documentation regarding the storage location of OAuth tokens for the Cursor CLI on Linux.

These changes improve the robustness of the Docker setup and enhance the overall development workflow.
2026-01-05 21:28:42 -05:00
Web Dev Cody
2934d73db2 Merge pull request #368 from AutoMaker-Org/fix/small-bugs
fix: small bugs
2026-01-05 20:23:42 -05:00
Shirone
a4968f7235 fix: show success toast only during project creation flow
- Updated the useSpecRegeneration hook to conditionally display the success toast message only when the user is in the active project creation flow, preventing unnecessary notifications during regular spec regeneration.
2026-01-06 02:04:08 +01:00
Shirone
b8e0c18c53 fix: theme switch bug
- when user had set up theme on the project lvl i and went trought the setup wizard again and changed theme its was not updating because its was only updating global theme and app was reverting back to show current project theme
2026-01-06 02:00:41 +01:00
Shirone
d0b3e0d9bb refactor: move logger initialization outside of useCliStatus function
- Moved the logger initialization to the top of the file for better readability and to avoid re-initialization on each function call.
- This change enhances the performance and clarity of the code in the useCliStatus hook.
- fix infinite loop calling caused by rerender because of logger
2026-01-06 01:53:08 +01:00
Kacper
2a0719e00c refactor: move logger initialization outside of useCliStatus hook
- Moved the logger creation outside the hook to prevent infinite re-renders.
- Updated dependencies in the checkStatus function to remove logger from the dependency array.

These changes enhance performance and maintainability of the useCliStatus hook.
2026-01-06 00:58:31 +01:00
webdevcody
af394183e6 feat: add Cursor CLI installation attempts documentation and enhance Docker setup
- Introduced a new markdown file summarizing various attempts to install the Cursor CLI in Docker, detailing approaches, results, and key learnings.
- Updated Dockerfile to ensure proper installation of Cursor CLI for the non-root user, including necessary PATH adjustments for interactive shells.
- Enhanced entrypoint script to manage OAuth tokens for both Claude and Cursor CLIs, ensuring correct permissions and directory setups.
- Added scripts for extracting OAuth tokens from macOS Keychain and Linux JSON files for seamless integration with Docker.
- Updated docker-compose files to support persistent storage for CLI configurations and authentication tokens.

These changes improve the development workflow and provide clear guidance on CLI installation and authentication processes.
2026-01-05 18:13:14 -05:00
webdevcody
5d675561ba chore: release v0.8.0
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 16:31:11 -05:00
Web Dev Cody
27fb3f2777 Merge pull request #364 from AutoMaker-Org/v0.8.0rc
V0.8.0rc
2026-01-05 16:28:25 -05:00
webdevcody
aca84fe16a chore: update Docker configuration and entrypoint script
- Enhanced .dockerignore to exclude additional build outputs and dependencies.
- Modified dev.mjs and start.mjs to change Docker container startup behavior, removing the --build flag to preserve volumes.
- Updated docker-compose.yml to add a new volume for persisting Claude CLI OAuth session keys.
- Introduced docker-entrypoint.sh to fix permissions on the Claude CLI config directory.
- Adjusted Dockerfile to include the entrypoint script and ensure proper user permissions.

These changes improve the Docker setup and streamline the development workflow.
2026-01-05 10:44:47 -05:00
59 changed files with 1090 additions and 8719 deletions

View File

@@ -1 +1,19 @@
node_modules/
# Dependencies
node_modules/
**/node_modules/
# Build outputs
dist/
**/dist/
dist-electron/
**/dist-electron/
build/
**/build/
.next/
**/.next/
.nuxt/
**/.nuxt/
out/
**/out/
.cache/
**/.cache/

View File

@@ -170,44 +170,3 @@ Use `resolveModelString()` from `@automaker/model-resolver` to convert model ali
- `DATA_DIR` - Data storage directory (default: ./data)
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
- `AUTOMAKER_MOCK_AGENT=true` - Enable mock agent mode for CI testing
- `ENABLE_DEBUG_PANEL=true` - Enable debug panel in non-development builds
## Debug System (Development Only)
The debug system provides real-time monitoring of server performance. Toggle with `Cmd/Ctrl+Shift+D`.
### Debug Services (`apps/server/src/services/`)
- `PerformanceMonitorService` - Collects memory/CPU metrics, detects leaks via linear regression
- `ProcessRegistryService` - Tracks spawned agents, terminals, CLIs with lifecycle events
### Debug API (`apps/server/src/routes/debug/`)
- `GET /api/debug/metrics` - Current metrics snapshot
- `POST /api/debug/metrics/start` - Start collection with optional config
- `POST /api/debug/metrics/stop` - Stop collection
- `GET /api/debug/processes` - List tracked processes with filters
### Debug Types (`libs/types/src/debug.ts`)
All debug types are exported from `@automaker/types`:
```typescript
import type {
DebugMetricsSnapshot,
TrackedProcess,
MemoryTrend,
ProcessSummary,
} from '@automaker/types';
import { formatBytes, formatDuration } from '@automaker/types';
```
### UI Components (`apps/ui/src/components/debug/`)
- `DebugPanel` - Main draggable container with tabs
- `MemoryMonitor` - Heap usage charts and leak indicators
- `CPUMonitor` - CPU gauge and event loop lag display
- `ProcessKanban` - Visual board of tracked processes
- `RenderTracker` - React component render statistics
See `docs/server/debug-api.md` for full API documentation.

View File

@@ -8,10 +8,12 @@
# =============================================================================
# BASE STAGE - Common setup for all builds (DRY: defined once, used by all)
# =============================================================================
FROM node:22-alpine AS base
FROM node:22-slim AS base
# Install build dependencies for native modules (node-pty)
RUN apk add --no-cache python3 make g++
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
@@ -51,30 +53,59 @@ RUN npm run build:packages && npm run build --workspace=apps/server
# =============================================================================
# SERVER PRODUCTION STAGE
# =============================================================================
FROM node:22-alpine AS server
FROM node:22-slim AS server
# Install git, curl, bash (for terminal), and GitHub CLI (pinned version, multi-arch)
RUN apk add --no-cache git curl bash && \
GH_VERSION="2.63.2" && \
ARCH=$(uname -m) && \
case "$ARCH" in \
# Install git, curl, bash (for terminal), gosu (for user switching), and GitHub CLI (pinned version, multi-arch)
RUN apt-get update && apt-get install -y --no-install-recommends \
git curl bash gosu ca-certificates openssh-client \
&& GH_VERSION="2.63.2" \
&& ARCH=$(uname -m) \
&& case "$ARCH" in \
x86_64) GH_ARCH="amd64" ;; \
aarch64|arm64) GH_ARCH="arm64" ;; \
*) echo "Unsupported architecture: $ARCH" && exit 1 ;; \
esac && \
curl -L "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${GH_ARCH}.tar.gz" -o gh.tar.gz && \
tar -xzf gh.tar.gz && \
mv gh_${GH_VERSION}_linux_${GH_ARCH}/bin/gh /usr/local/bin/gh && \
rm -rf gh.tar.gz gh_${GH_VERSION}_linux_${GH_ARCH}
esac \
&& curl -L "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${GH_ARCH}.tar.gz" -o gh.tar.gz \
&& tar -xzf gh.tar.gz \
&& mv gh_${GH_VERSION}_linux_${GH_ARCH}/bin/gh /usr/local/bin/gh \
&& rm -rf gh.tar.gz gh_${GH_VERSION}_linux_${GH_ARCH} \
&& rm -rf /var/lib/apt/lists/*
# Install Claude CLI globally
# Install Claude CLI globally (available to all users via npm global bin)
RUN npm install -g @anthropic-ai/claude-code
WORKDIR /app
# Create non-root user with home directory BEFORE installing Cursor CLI
RUN groupadd -g 1001 automaker && \
useradd -u 1001 -g automaker -m -d /home/automaker -s /bin/bash automaker && \
mkdir -p /home/automaker/.local/bin && \
mkdir -p /home/automaker/.cursor && \
chown -R automaker:automaker /home/automaker && \
chmod 700 /home/automaker/.cursor
# Create non-root user
RUN addgroup -g 1001 -S automaker && \
adduser -S automaker -u 1001
# Install Cursor CLI as the automaker user
# Set HOME explicitly and install to /home/automaker/.local/bin/
USER automaker
ENV HOME=/home/automaker
RUN curl https://cursor.com/install -fsS | bash && \
echo "=== Checking Cursor CLI installation ===" && \
ls -la /home/automaker/.local/bin/ && \
echo "=== PATH is: $PATH ===" && \
(which cursor-agent && cursor-agent --version) || echo "cursor-agent installed (may need auth setup)"
USER root
# Add PATH to profile so it's available in all interactive shells (for login shells)
RUN mkdir -p /etc/profile.d && \
echo 'export PATH="/home/automaker/.local/bin:$PATH"' > /etc/profile.d/cursor-cli.sh && \
chmod +x /etc/profile.d/cursor-cli.sh
# Add to automaker's .bashrc for bash interactive shells
RUN echo 'export PATH="/home/automaker/.local/bin:$PATH"' >> /home/automaker/.bashrc && \
chown automaker:automaker /home/automaker/.bashrc
# Also add to root's .bashrc since docker exec defaults to root
RUN echo 'export PATH="/home/automaker/.local/bin:$PATH"' >> /root/.bashrc
WORKDIR /app
# Copy root package.json (needed for workspace resolution)
COPY --from=server-builder /app/package*.json ./
@@ -98,12 +129,19 @@ RUN git config --system --add safe.directory '*' && \
# Use gh as credential helper (works with GH_TOKEN env var)
git config --system credential.helper '!gh auth git-credential'
# Switch to non-root user
USER automaker
# Copy entrypoint script for fixing permissions on mounted volumes
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Note: We stay as root here so entrypoint can fix permissions
# The entrypoint script will switch to automaker user before running the command
# Environment variables
ENV PORT=3008
ENV DATA_DIR=/data
ENV HOME=/home/automaker
# Add user's local bin to PATH for cursor-agent
ENV PATH="/home/automaker/.local/bin:${PATH}"
# Expose port
EXPOSE 3008
@@ -112,6 +150,9 @@ EXPOSE 3008
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3008/api/health || exit 1
# Use entrypoint to fix permissions before starting
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
# Start server
CMD ["node", "apps/server/dist/index.js"]

View File

@@ -374,7 +374,6 @@ npm run lint
- `VITE_SKIP_ELECTRON` - Skip Electron in dev mode
- `OPEN_DEVTOOLS` - Auto-open DevTools in Electron
- `ENABLE_DEBUG_PANEL` - Enable the debug panel in non-development builds (for staging environments)
### Authentication Setup
@@ -456,7 +455,6 @@ The application can store your API key securely in the settings UI. The key is p
- 🎨 **Theme System** - 25+ themes including Dark, Light, Dracula, Nord, Catppuccin, and more
- 🖥️ **Cross-Platform** - Desktop app for macOS (x64, arm64), Windows (x64), and Linux (x64)
- 🌐 **Web Mode** - Run in browser or as Electron desktop app
- 🐛 **Debug Panel** - Floating debug overlay for monitoring memory, CPU, and process performance (dev mode only, toggle with `Cmd/Ctrl+Shift+D`)
### Advanced Features

View File

@@ -1,6 +1,6 @@
{
"name": "@automaker/server",
"version": "0.7.3",
"version": "0.8.0",
"description": "Backend server for Automaker - provides API for both web and Electron modes",
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",

View File

@@ -63,12 +63,6 @@ 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();
@@ -76,8 +70,6 @@ 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;
@@ -177,13 +169,6 @@ 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();
@@ -238,12 +223,6 @@ 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);
@@ -609,9 +588,6 @@ 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);
@@ -621,9 +597,6 @@ 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

@@ -1,332 +0,0 @@
/**
* 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

@@ -1,152 +0,0 @@
/**
* 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

@@ -1,170 +0,0 @@
/**
* 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

@@ -1,673 +0,0 @@
/**
* 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

@@ -1,982 +0,0 @@
/**
* 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

@@ -1,318 +0,0 @@
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

@@ -1,293 +0,0 @@
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

@@ -1,418 +0,0 @@
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

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

View File

@@ -1,6 +1,6 @@
{
"name": "@automaker/ui",
"version": "0.7.3",
"version": "0.8.0",
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
"homepage": "https://github.com/AutoMaker-Org/automaker",
"repository": {

View File

@@ -1,175 +0,0 @@
/**
* CPU Monitor Component
*
* Displays CPU usage percentage with historical chart and event loop lag indicator.
*/
import { useMemo } from 'react';
import { Cpu, Activity, AlertTriangle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CPUDataPoint, ServerCPUMetrics } from '@automaker/types';
interface CPUMonitorProps {
history: CPUDataPoint[];
current: ServerCPUMetrics | null;
eventLoopLag?: number;
className?: string;
}
/**
* Simple sparkline chart for CPU data
*/
function CPUSparkline({ data, className }: { data: CPUDataPoint[]; className?: string }) {
const pathD = useMemo(() => {
if (data.length < 2) {
return '';
}
const w = 200;
const h = 40;
const padding = 2;
// CPU percentage is 0-100, but we'll use 0-100 as our range
const points = data.map((d, i) => {
const x = (i / (data.length - 1)) * (w - padding * 2) + padding;
const y = h - padding - (d.percentage / 100) * (h - padding * 2);
return `${x},${y}`;
});
return `M ${points.join(' L ')}`;
}, [data]);
if (data.length < 2) {
return (
<div
className={cn(
'h-10 flex items-center justify-center text-muted-foreground text-xs',
className
)}
>
Collecting data...
</div>
);
}
return (
<svg viewBox="0 0 200 40" className={cn('w-full', className)} preserveAspectRatio="none">
<path
d={pathD}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-green-500"
/>
</svg>
);
}
/**
* CPU usage gauge
*/
function CPUGauge({ percentage }: { percentage: number }) {
const isHigh = percentage > 60;
const isCritical = percentage > 80;
return (
<div className="relative w-16 h-16">
{/* Background circle */}
<svg className="w-full h-full -rotate-90" viewBox="0 0 36 36">
<circle cx="18" cy="18" r="16" fill="none" strokeWidth="3" className="stroke-muted" />
<circle
cx="18"
cy="18"
r="16"
fill="none"
strokeWidth="3"
strokeDasharray={`${percentage} 100`}
strokeLinecap="round"
className={cn(
'transition-all duration-300',
isCritical ? 'stroke-red-500' : isHigh ? 'stroke-yellow-500' : 'stroke-green-500'
)}
/>
</svg>
{/* Center text */}
<div className="absolute inset-0 flex items-center justify-center">
<span
className={cn(
'text-sm font-mono font-bold',
isCritical ? 'text-red-400' : isHigh ? 'text-yellow-400' : 'text-green-400'
)}
>
{percentage.toFixed(0)}%
</span>
</div>
</div>
);
}
/**
* Event loop lag indicator
*/
function EventLoopLag({ lag }: { lag?: number }) {
if (lag === undefined) {
return null;
}
const isBlocked = lag > 50;
const isSevere = lag > 100;
return (
<div
className={cn(
'flex items-center gap-1.5 text-xs px-2 py-1 rounded',
isSevere && 'bg-red-500/20 text-red-400',
isBlocked && !isSevere && 'bg-yellow-500/20 text-yellow-400',
!isBlocked && 'bg-muted text-muted-foreground'
)}
>
{isSevere ? <AlertTriangle className="w-3 h-3" /> : <Activity className="w-3 h-3" />}
<span>Event Loop: {lag.toFixed(0)}ms</span>
</div>
);
}
export function CPUMonitor({ history, current, eventLoopLag, className }: CPUMonitorProps) {
const percentage = current?.percentage ?? 0;
return (
<div className={cn('space-y-3', className)}>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Cpu className="w-4 h-4 text-green-500" />
<span className="text-sm font-medium">CPU</span>
</div>
<EventLoopLag lag={eventLoopLag} />
</div>
{/* Main content */}
<div className="flex items-center gap-4">
{/* Gauge */}
<CPUGauge percentage={percentage} />
{/* Sparkline */}
<div className="flex-1 h-10">
<CPUSparkline data={history} />
</div>
</div>
{/* Details */}
{current && (
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<span className="text-muted-foreground">User: </span>
<span>{(current.user / 1000).toFixed(1)}ms</span>
</div>
<div>
<span className="text-muted-foreground">System: </span>
<span>{(current.system / 1000).toFixed(1)}ms</span>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,229 +0,0 @@
/**
* Debug Docked Panel Component
*
* Expandable panel that appears above the status bar when expanded.
* Contains the full debug interface with tabs.
*/
import { useRef, useCallback, useEffect } from 'react';
import { HardDrive, Cpu, Bot, RefreshCw, Trash2, Play, Pause, GripHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
useDebugStore,
MIN_DOCKED_HEIGHT,
MAX_DOCKED_HEIGHT_RATIO,
type DebugTab,
} from '@/store/debug-store';
import { useDebugMetrics } from '@/hooks/use-debug-metrics';
import { useRenderTracking } from '@/hooks/use-render-tracking';
import { MemoryMonitor } from './memory-monitor';
import { CPUMonitor } from './cpu-monitor';
import { ProcessKanban } from './process-kanban';
import { RenderTracker } from './render-tracker';
import { LeakIndicator } from './leak-indicator';
import { useRenderTrackingContext } from './render-profiler';
const TAB_CONFIG: { id: DebugTab; label: string; icon: React.ReactNode }[] = [
{ id: 'memory', label: 'Memory', icon: <HardDrive className="w-3.5 h-3.5" /> },
{ id: 'cpu', label: 'CPU', icon: <Cpu className="w-3.5 h-3.5" /> },
{ id: 'processes', label: 'Processes', icon: <Bot className="w-3.5 h-3.5" /> },
{ id: 'renders', label: 'Renders', icon: <RefreshCw className="w-3.5 h-3.5" /> },
];
interface DebugDockedPanelProps {
className?: string;
}
export function DebugDockedPanel({ className }: DebugDockedPanelProps) {
const {
isOpen,
isDockedExpanded,
panelMode,
dockedHeight,
activeTab,
setActiveTab,
setDockedHeight,
isResizing,
setIsResizing,
} = useDebugStore();
const metrics = useDebugMetrics();
const renderTrackingFromContext = useRenderTrackingContext();
const localRenderTracking = useRenderTracking();
const renderTracking = renderTrackingFromContext ?? localRenderTracking;
// Ref for resize handling
const panelRef = useRef<HTMLDivElement>(null);
const resizeStartRef = useRef<{ y: number; height: number } | null>(null);
// Handle resize start (drag from top edge)
const handleResizeStart = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
resizeStartRef.current = {
y: e.clientY,
height: dockedHeight,
};
},
[setIsResizing, dockedHeight]
);
// Handle resize move
useEffect(() => {
if (!isResizing) return;
const handleMouseMove = (e: MouseEvent) => {
if (!resizeStartRef.current) return;
// Dragging up increases height, dragging down decreases
const deltaY = resizeStartRef.current.y - e.clientY;
const newHeight = resizeStartRef.current.height + deltaY;
// Clamp to min/max bounds
const maxHeight = window.innerHeight * MAX_DOCKED_HEIGHT_RATIO;
const clampedHeight = Math.max(MIN_DOCKED_HEIGHT, Math.min(maxHeight, newHeight));
setDockedHeight(clampedHeight);
};
const handleMouseUp = () => {
setIsResizing(false);
resizeStartRef.current = null;
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [isResizing, setIsResizing, setDockedHeight]);
// Only show in docked mode when expanded
if (panelMode !== 'docked' || !isDockedExpanded || !isOpen) {
return null;
}
return (
<div
ref={panelRef}
className={cn(
'flex flex-col bg-background border-t border-border',
isResizing && 'select-none',
className
)}
style={{ height: dockedHeight }}
>
{/* Resize handle - top edge */}
<div
className="h-1 cursor-ns-resize hover:bg-primary/20 transition-colors flex items-center justify-center group"
onMouseDown={handleResizeStart}
>
<GripHorizontal className="w-8 h-3 text-muted-foreground/30 group-hover:text-muted-foreground/60" />
</div>
{/* Tabs */}
<div className="flex items-center border-b bg-muted/30">
<div className="flex">
{TAB_CONFIG.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 text-xs border-b-2 -mb-px transition-colors',
activeTab === tab.id
? 'border-primary text-primary bg-background'
: 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50'
)}
>
{tab.icon}
<span>{tab.label}</span>
</button>
))}
</div>
{/* Right side controls */}
<div className="ml-auto flex items-center gap-1 px-2">
<button
onClick={() => (metrics.isActive ? metrics.stop() : metrics.start())}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
title={metrics.isActive ? 'Stop collecting' : 'Start collecting'}
>
{metrics.isActive ? (
<Pause className="w-3.5 h-3.5" />
) : (
<Play className="w-3.5 h-3.5" />
)}
</button>
<button
onClick={metrics.clearHistory}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
title="Clear history"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
<button
onClick={metrics.refresh}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
title="Refresh now"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-3">
{activeTab === 'memory' && (
<div className="space-y-4">
<MemoryMonitor
history={metrics.memoryHistory}
current={metrics.latestSnapshot?.memory.server ?? null}
trend={metrics.memoryTrend}
/>
<LeakIndicator trend={metrics.memoryTrend} onForceGC={metrics.forceGC} />
</div>
)}
{activeTab === 'cpu' && (
<CPUMonitor
history={metrics.cpuHistory}
current={metrics.latestSnapshot?.cpu.server ?? null}
eventLoopLag={metrics.latestSnapshot?.cpu.eventLoopLag}
/>
)}
{activeTab === 'processes' && (
<ProcessKanban
processes={metrics.processes}
summary={metrics.processSummary}
panelWidth={window.innerWidth} // Full width in docked mode
/>
)}
{activeTab === 'renders' && (
<RenderTracker
summary={renderTracking.summary}
stats={renderTracking.getAllStats()}
onClear={renderTracking.clearRecords}
/>
)}
</div>
</div>
);
}
/**
* Debug Docked Panel Wrapper - Only renders in development mode
*/
export function DebugDockedPanelWrapper({ className }: DebugDockedPanelProps) {
const isDev = import.meta.env.DEV || import.meta.env.VITE_ENABLE_DEBUG_PANEL === 'true';
if (!isDev) {
return null;
}
return <DebugDockedPanel className={className} />;
}

View File

@@ -1,427 +0,0 @@
/**
* Debug Panel Component
*
* Main container for the floating debug overlay with:
* - Draggable positioning
* - Resizable panels
* - Tab-based navigation
* - Minimize/maximize states
*/
import { useRef, useCallback, useEffect } from 'react';
import {
Bug,
X,
Minimize2,
Maximize2,
HardDrive,
Cpu,
Bot,
RefreshCw,
Trash2,
Play,
Pause,
GripHorizontal,
PanelBottom,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import {
useDebugStore,
MIN_PANEL_SIZE,
MAX_PANEL_SIZE_RATIO,
type DebugTab,
} from '@/store/debug-store';
import { useDebugMetrics } from '@/hooks/use-debug-metrics';
import { useRenderTracking } from '@/hooks/use-render-tracking';
import { MemoryMonitor } from './memory-monitor';
import { CPUMonitor } from './cpu-monitor';
import { ProcessKanban } from './process-kanban';
import { RenderTracker } from './render-tracker';
import { LeakIndicator } from './leak-indicator';
import { useRenderTrackingContext } from './render-profiler';
const TAB_CONFIG: { id: DebugTab; label: string; icon: React.ReactNode }[] = [
{ id: 'memory', label: 'Memory', icon: <HardDrive className="w-4 h-4" /> },
{ id: 'cpu', label: 'CPU', icon: <Cpu className="w-4 h-4" /> },
{ id: 'processes', label: 'Processes', icon: <Bot className="w-4 h-4" /> },
{ id: 'renders', label: 'Renders', icon: <RefreshCw className="w-4 h-4" /> },
];
interface DebugPanelProps {
className?: string;
}
export function DebugPanel({ className }: DebugPanelProps) {
const {
isOpen,
isMinimized,
position,
size,
activeTab,
setOpen,
toggleMinimized,
setPosition,
setSize,
togglePanelMode,
setActiveTab,
isDragging,
setIsDragging,
isResizing,
setIsResizing,
} = useDebugStore();
const metrics = useDebugMetrics();
const renderTrackingFromContext = useRenderTrackingContext();
const localRenderTracking = useRenderTracking();
// Use context if available (when wrapped in RenderTrackingProvider), otherwise use local
const renderTracking = renderTrackingFromContext ?? localRenderTracking;
// Refs for drag handling
const panelRef = useRef<HTMLDivElement>(null);
const dragStartRef = useRef<{ x: number; y: number; posX: number; posY: number } | null>(null);
const resizeStartRef = useRef<{ x: number; y: number; width: number; height: number } | null>(
null
);
// Calculate actual position (handle negative values for right-edge positioning)
const actualPosition = useCallback(() => {
if (!panelRef.current) return { x: position.x, y: position.y };
const rect = panelRef.current.getBoundingClientRect();
const windowWidth = window.innerWidth;
// If x is negative, position from right edge
const x = position.x < 0 ? windowWidth + position.x - rect.width : position.x;
return { x, y: position.y };
}, [position]);
// Handle drag start
const handleDragStart = useCallback(
(e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest('button')) return;
e.preventDefault();
setIsDragging(true);
const rect = panelRef.current?.getBoundingClientRect();
if (!rect) return;
dragStartRef.current = {
x: e.clientX,
y: e.clientY,
posX: rect.left,
posY: rect.top,
};
},
[setIsDragging]
);
// Handle resize start
const handleResizeStart = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsResizing(true);
resizeStartRef.current = {
x: e.clientX,
y: e.clientY,
width: size.width,
height: size.height,
};
},
[setIsResizing, size]
);
// Handle drag move
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
if (!dragStartRef.current || !panelRef.current) return;
const deltaX = e.clientX - dragStartRef.current.x;
const deltaY = e.clientY - dragStartRef.current.y;
const newX = dragStartRef.current.posX + deltaX;
const newY = dragStartRef.current.posY + deltaY;
// Clamp to window bounds
const rect = panelRef.current.getBoundingClientRect();
const clampedX = Math.max(0, Math.min(window.innerWidth - rect.width, newX));
const clampedY = Math.max(0, Math.min(window.innerHeight - rect.height, newY));
setPosition({ x: clampedX, y: clampedY });
};
const handleMouseUp = () => {
setIsDragging(false);
dragStartRef.current = null;
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, setIsDragging, setPosition]);
// Handle resize move
useEffect(() => {
if (!isResizing) return;
const handleMouseMove = (e: MouseEvent) => {
if (!resizeStartRef.current) return;
const deltaX = e.clientX - resizeStartRef.current.x;
const deltaY = e.clientY - resizeStartRef.current.y;
const newWidth = resizeStartRef.current.width + deltaX;
const newHeight = resizeStartRef.current.height + deltaY;
// Clamp to min/max bounds
const maxWidth = window.innerWidth * MAX_PANEL_SIZE_RATIO.width;
const maxHeight = window.innerHeight * MAX_PANEL_SIZE_RATIO.height;
const clampedWidth = Math.max(MIN_PANEL_SIZE.width, Math.min(maxWidth, newWidth));
const clampedHeight = Math.max(MIN_PANEL_SIZE.height, Math.min(maxHeight, newHeight));
setSize({ width: clampedWidth, height: clampedHeight });
};
const handleMouseUp = () => {
setIsResizing(false);
resizeStartRef.current = null;
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [isResizing, setIsResizing, setSize]);
// Don't render if not open
if (!isOpen) {
return null;
}
const pos = actualPosition();
return (
<div
ref={panelRef}
className={cn(
'fixed z-[9999] bg-background/95 backdrop-blur-sm border rounded-lg shadow-xl',
'flex flex-col overflow-hidden',
isDragging && 'cursor-grabbing select-none',
isResizing && 'cursor-nwse-resize select-none',
className
)}
style={{
left: pos.x,
top: pos.y,
width: isMinimized ? 200 : size.width,
height: isMinimized ? 'auto' : size.height,
}}
>
{/* Header - Draggable */}
<div
className={cn(
'flex items-center justify-between px-3 py-2 border-b bg-muted/50',
'cursor-grab select-none',
isDragging && 'cursor-grabbing'
)}
onMouseDown={handleDragStart}
>
<div className="flex items-center gap-2">
<Bug className="w-4 h-4 text-purple-500" />
<span className="text-sm font-medium">Debug</span>
{metrics.isActive && (
<span
className="w-2 h-2 rounded-full bg-green-500 animate-pulse"
title="Collecting metrics"
/>
)}
{/* Dock to bottom */}
<button
onClick={togglePanelMode}
className="p-1 rounded hover:bg-muted"
title="Dock to bottom"
>
<PanelBottom className="w-4 h-4" />
</button>
</div>
<div className="flex items-center gap-1">
{/* Toggle collection */}
<button
onClick={() => (metrics.isActive ? metrics.stop() : metrics.start())}
className="p-1 rounded hover:bg-muted"
title={metrics.isActive ? 'Stop collecting' : 'Start collecting'}
>
{metrics.isActive ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
</button>
{/* Minimize */}
<button
onClick={toggleMinimized}
className="p-1 rounded hover:bg-muted"
title={isMinimized ? 'Expand' : 'Minimize'}
>
{isMinimized ? <Maximize2 className="w-4 h-4" /> : <Minimize2 className="w-4 h-4" />}
</button>
{/* Close */}
<button
onClick={() => setOpen(false)}
className="p-1 rounded hover:bg-muted hover:text-red-400"
title="Close"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* Minimized state - just show quick stats */}
{isMinimized ? (
<div className="p-2 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">Heap:</span>
<span>
{metrics.latestSnapshot?.memory.server
? `${(metrics.latestSnapshot.memory.server.heapUsed / 1024 / 1024).toFixed(0)}MB`
: '-'}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">CPU:</span>
<span>
{metrics.latestSnapshot?.cpu.server
? `${metrics.latestSnapshot.cpu.server.percentage.toFixed(0)}%`
: '-'}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Processes:</span>
<span>{metrics.processSummary?.running ?? 0}</span>
</div>
</div>
) : (
<>
{/* Tabs */}
<div className="flex border-b">
{TAB_CONFIG.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
'flex items-center gap-1.5 px-3 py-2 text-xs border-b-2 -mb-px transition-colors',
activeTab === tab.id
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted'
)}
>
{tab.icon}
<span>{tab.label}</span>
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-3">
{activeTab === 'memory' && (
<div className="space-y-4">
<MemoryMonitor
history={metrics.memoryHistory}
current={metrics.latestSnapshot?.memory.server ?? null}
trend={metrics.memoryTrend}
/>
<LeakIndicator trend={metrics.memoryTrend} onForceGC={metrics.forceGC} />
</div>
)}
{activeTab === 'cpu' && (
<CPUMonitor
history={metrics.cpuHistory}
current={metrics.latestSnapshot?.cpu.server ?? null}
eventLoopLag={metrics.latestSnapshot?.cpu.eventLoopLag}
/>
)}
{activeTab === 'processes' && (
<ProcessKanban
processes={metrics.processes}
summary={metrics.processSummary}
panelWidth={size.width}
/>
)}
{activeTab === 'renders' && (
<RenderTracker
summary={renderTracking.summary}
stats={renderTracking.getAllStats()}
onClear={renderTracking.clearRecords}
/>
)}
</div>
{/* Footer with actions */}
<div className="flex items-center justify-between px-3 py-1.5 border-t bg-muted/30 text-xs text-muted-foreground">
<span>
{metrics.isLoading
? 'Loading...'
: metrics.error
? `Error: ${metrics.error}`
: `Updated ${new Date().toLocaleTimeString()}`}
</span>
<div className="flex items-center gap-2">
<button
onClick={metrics.clearHistory}
className="flex items-center gap-1 hover:text-foreground"
title="Clear history"
>
<Trash2 className="w-3 h-3" />
</button>
<button
onClick={metrics.refresh}
className="flex items-center gap-1 hover:text-foreground"
title="Refresh now"
>
<RefreshCw className="w-3 h-3" />
</button>
</div>
</div>
{/* Resize handle - bottom right corner */}
<div
className="absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize flex items-center justify-center hover:bg-muted/50 rounded-tl"
onMouseDown={handleResizeStart}
title="Drag to resize"
>
<GripHorizontal className="w-3 h-3 rotate-[-45deg] text-muted-foreground/50" />
</div>
</>
)}
</div>
);
}
/**
* Debug Panel Wrapper - Only renders in development mode and floating mode
*/
export function DebugPanelWrapper() {
const panelMode = useDebugStore((s) => s.panelMode);
// Only show in development mode
const isDev = import.meta.env.DEV || import.meta.env.VITE_ENABLE_DEBUG_PANEL === 'true';
// Only show in floating mode
if (!isDev || panelMode !== 'floating') {
return null;
}
return <DebugPanel />;
}

View File

@@ -1,171 +0,0 @@
/**
* Debug Status Bar Component
*
* VS Code-style status bar at the bottom of the screen showing quick debug stats.
* Clicking expands to show the full debug panel.
*/
import { memo } from 'react';
import { Bug, HardDrive, Cpu, Bot, ChevronUp, X, Maximize2, Minimize2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useDebugStore } from '@/store/debug-store';
import { useDebugMetrics } from '@/hooks/use-debug-metrics';
import { formatBytes } from '@automaker/types';
interface DebugStatusBarProps {
className?: string;
}
/**
* Quick stat display component
*/
const QuickStat = memo(function QuickStat({
icon,
label,
value,
onClick,
className,
}: {
icon: React.ReactNode;
label: string;
value: string;
onClick?: () => void;
className?: string;
}) {
return (
<button
onClick={onClick}
className={cn(
'flex items-center gap-1.5 px-2 py-0.5 text-xs hover:bg-muted/50 rounded transition-colors',
className
)}
>
{icon}
<span className="text-muted-foreground">{label}:</span>
<span className="font-medium">{value}</span>
</button>
);
});
export function DebugStatusBar({ className }: DebugStatusBarProps) {
const {
isOpen,
isDockedExpanded,
panelMode,
setOpen,
toggleDockedExpanded,
setActiveTab,
togglePanelMode,
} = useDebugStore();
const metrics = useDebugMetrics();
// Only show in docked mode when debug is enabled
if (panelMode !== 'docked') {
return null;
}
// Don't render if debug panel is not open (toggled off with Ctrl+Shift+D)
if (!isOpen) {
return null;
}
const heapUsed = metrics.latestSnapshot?.memory.server?.heapUsed ?? 0;
const cpuPercent = metrics.latestSnapshot?.cpu.server?.percentage ?? 0;
const processCount = metrics.processSummary?.running ?? 0;
return (
<div
className={cn(
'flex items-center justify-between h-6 px-2 bg-muted/50 border-t border-border text-xs',
'select-none',
className
)}
>
{/* Left side - Debug label and quick stats */}
<div className="flex items-center gap-1">
{/* Debug label with status indicator */}
<button
onClick={toggleDockedExpanded}
className="flex items-center gap-1.5 px-2 py-0.5 hover:bg-muted rounded transition-colors"
>
<Bug className="w-3.5 h-3.5 text-purple-500" />
<span className="font-medium">Debug</span>
{metrics.isActive && (
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
)}
<ChevronUp
className={cn(
'w-3 h-3 text-muted-foreground transition-transform',
isDockedExpanded && 'rotate-180'
)}
/>
</button>
<div className="w-px h-4 bg-border mx-1" />
{/* Quick stats */}
<QuickStat
icon={<HardDrive className="w-3 h-3 text-blue-400" />}
label="Heap"
value={formatBytes(heapUsed)}
onClick={() => {
setActiveTab('memory');
if (!isDockedExpanded) toggleDockedExpanded();
}}
/>
<QuickStat
icon={<Cpu className="w-3 h-3 text-yellow-400" />}
label="CPU"
value={`${cpuPercent.toFixed(0)}%`}
onClick={() => {
setActiveTab('cpu');
if (!isDockedExpanded) toggleDockedExpanded();
}}
/>
<QuickStat
icon={<Bot className="w-3 h-3 text-purple-400" />}
label="Processes"
value={String(processCount)}
onClick={() => {
setActiveTab('processes');
if (!isDockedExpanded) toggleDockedExpanded();
}}
/>
</div>
{/* Right side - Actions */}
<div className="flex items-center gap-1">
{/* Toggle to floating mode */}
<button
onClick={togglePanelMode}
className="p-1 hover:bg-muted rounded transition-colors"
title="Switch to floating mode"
>
<Maximize2 className="w-3 h-3 text-muted-foreground" />
</button>
{/* Close debug panel */}
<button
onClick={() => setOpen(false)}
className="p-1 hover:bg-muted hover:text-red-400 rounded transition-colors"
title="Close debug panel (Ctrl+Shift+D)"
>
<X className="w-3 h-3" />
</button>
</div>
</div>
);
}
/**
* Debug Status Bar Wrapper - Only renders in development mode
*/
export function DebugStatusBarWrapper({ className }: DebugStatusBarProps) {
const isDev = import.meta.env.DEV || import.meta.env.VITE_ENABLE_DEBUG_PANEL === 'true';
if (!isDev) {
return null;
}
return <DebugStatusBar className={className} />;
}

View File

@@ -1,26 +0,0 @@
/**
* Debug Components
*
* Exports all debug-related UI components for the debug panel.
* Supports both floating overlay and docked (VS Code-style) modes.
*/
// Floating mode panel
export { DebugPanel, DebugPanelWrapper } from './debug-panel';
// Docked mode components (VS Code-style)
export { DebugStatusBar, DebugStatusBarWrapper } from './debug-status-bar';
export { DebugDockedPanel, DebugDockedPanelWrapper } from './debug-docked-panel';
// Shared components
export { MemoryMonitor } from './memory-monitor';
export { CPUMonitor } from './cpu-monitor';
export { ProcessKanban } from './process-kanban';
export { RenderTracker } from './render-tracker';
export { LeakIndicator } from './leak-indicator';
export {
RenderProfiler,
RenderTrackingProvider,
useRenderTrackingContext,
withRenderProfiler,
} from './render-profiler';

View File

@@ -1,102 +0,0 @@
/**
* Leak Indicator Component
*
* Alerts when memory growth patterns exceed threshold.
*/
import { AlertTriangle, TrendingUp, Info } from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatBytes } from '@automaker/types';
import type { MemoryTrend } from '@automaker/types';
interface LeakIndicatorProps {
trend: MemoryTrend | null;
onForceGC?: () => void;
className?: string;
}
export function LeakIndicator({ trend, onForceGC, className }: LeakIndicatorProps) {
if (!trend) {
return (
<div className={cn('p-3 bg-muted/30 rounded-lg text-xs text-muted-foreground', className)}>
<div className="flex items-center gap-2">
<Info className="w-4 h-4" />
<span>Collecting memory data for leak analysis...</span>
</div>
</div>
);
}
const isLeaking = trend.isLeaking;
const isGrowing = trend.growthRate > 1024 * 100; // > 100KB/s
const growthPerSecond = formatBytes(Math.abs(trend.growthRate));
const confidencePercent = (trend.confidence * 100).toFixed(0);
if (isLeaking) {
return (
<div className={cn('p-3 bg-red-500/10 border border-red-500/30 rounded-lg', className)}>
<div className="flex items-start gap-2">
<AlertTriangle className="w-5 h-5 text-red-400 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="font-medium text-red-400 text-sm">Memory Leak Detected</div>
<div className="text-xs text-muted-foreground mt-1 space-y-1">
<div className="flex items-center gap-1">
<TrendingUp className="w-3 h-3" />
<span>Growing at {growthPerSecond}/s</span>
</div>
<div>Confidence: {confidencePercent}%</div>
<div>Samples: {trend.sampleCount}</div>
</div>
<div className="mt-2 text-xs text-red-300">
Memory is consistently growing without garbage collection. This may indicate detached
DOM nodes, event listener leaks, or objects held in closures.
</div>
{onForceGC && (
<button
onClick={onForceGC}
className="mt-2 px-3 py-1 text-xs bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded transition-colors"
>
Force GC
</button>
)}
</div>
</div>
</div>
);
}
if (isGrowing) {
return (
<div className={cn('p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg', className)}>
<div className="flex items-start gap-2">
<TrendingUp className="w-5 h-5 text-yellow-400 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="font-medium text-yellow-400 text-sm">Memory Growing</div>
<div className="text-xs text-muted-foreground mt-1 space-y-1">
<div>Rate: {growthPerSecond}/s</div>
<div>Confidence: {confidencePercent}%</div>
</div>
<div className="mt-2 text-xs text-yellow-300">
Memory is growing but not yet at leak threshold. Monitor for sustained growth.
</div>
</div>
</div>
</div>
);
}
// Healthy state
return (
<div className={cn('p-3 bg-green-500/10 border border-green-500/30 rounded-lg', className)}>
<div className="flex items-center gap-2">
<Info className="w-4 h-4 text-green-400" />
<div>
<div className="font-medium text-green-400 text-sm">Memory Stable</div>
<div className="text-xs text-muted-foreground mt-0.5">
No memory leak patterns detected ({trend.sampleCount} samples)
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,276 +0,0 @@
/**
* Memory Monitor Component
*
* Displays real-time heap usage with a line chart showing historical data.
*/
import { useMemo, memo } from 'react';
import { HardDrive, TrendingUp, TrendingDown, Minus, HelpCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatBytes } from '@automaker/types';
import type { MemoryDataPoint, MemoryTrend, ServerMemoryMetrics } from '@automaker/types';
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from '@/components/ui/tooltip';
/** Tooltip explanations for memory metrics */
const METRIC_TOOLTIPS = {
heap: 'JavaScript heap memory - memory used by V8 engine for JavaScript objects and data',
rss: 'Resident Set Size - total memory allocated for the process including code, stack, and heap',
external: 'Memory used by C++ objects bound to JavaScript objects (e.g., Buffers)',
arrayBuffers: 'Memory allocated for ArrayBuffer and SharedArrayBuffer objects',
} as const;
interface MemoryMonitorProps {
history: MemoryDataPoint[];
current: ServerMemoryMetrics | null;
trend: MemoryTrend | null;
className?: string;
}
/**
* Simple sparkline chart for memory data - Memoized to prevent unnecessary re-renders
*/
const MemorySparkline = memo(function MemorySparkline({
data,
className,
}: {
data: MemoryDataPoint[];
className?: string;
}) {
const { pathD, width, height } = useMemo(() => {
if (data.length < 2) {
return { pathD: '', width: 200, height: 40 };
}
const w = 200;
const h = 40;
const padding = 2;
const values = data.map((d) => d.heapUsed);
const max = Math.max(...values) * 1.1; // Add 10% headroom
const min = Math.min(...values) * 0.9;
const range = max - min || 1;
const points = data.map((d, i) => {
const x = (i / (data.length - 1)) * (w - padding * 2) + padding;
const y = h - padding - ((d.heapUsed - min) / range) * (h - padding * 2);
return `${x},${y}`;
});
return {
pathD: `M ${points.join(' L ')}`,
width: w,
height: h,
};
}, [data]);
if (data.length < 2) {
return (
<div
className={cn(
'h-10 flex items-center justify-center text-muted-foreground text-xs',
className
)}
>
Collecting data...
</div>
);
}
return (
<svg
viewBox={`0 0 ${width} ${height}`}
className={cn('w-full', className)}
preserveAspectRatio="none"
>
<path
d={pathD}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-blue-500"
/>
</svg>
);
});
/**
* Label with optional tooltip - Memoized
*/
const MetricLabel = memo(function MetricLabel({
label,
tooltip,
}: {
label: string;
tooltip?: string;
}) {
if (!tooltip) {
return <span className="text-muted-foreground">{label}</span>;
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground cursor-help inline-flex items-center gap-1">
{label}
<HelpCircle className="w-3 h-3 opacity-50" />
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[250px] z-[10000]">
{tooltip}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
});
/**
* Memory usage bar - Memoized to prevent unnecessary re-renders
*/
const MemoryBar = memo(function MemoryBar({
used,
total,
label,
tooltip,
}: {
used: number;
total: number;
label: string;
tooltip?: string;
}) {
const percentage = total > 0 ? (used / total) * 100 : 0;
const isHigh = percentage > 70;
const isCritical = percentage > 90;
return (
<div className="space-y-1">
<div className="flex justify-between text-xs">
<MetricLabel label={label} tooltip={tooltip} />
<span
className={cn(isCritical && 'text-red-400', isHigh && !isCritical && 'text-yellow-400')}
>
{formatBytes(used)} / {formatBytes(total)}
</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn(
'h-full transition-all duration-300 rounded-full',
isCritical ? 'bg-red-500' : isHigh ? 'bg-yellow-500' : 'bg-blue-500'
)}
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
</div>
</div>
);
});
/**
* Trend indicator - Memoized to prevent unnecessary re-renders
*/
const TrendIndicator = memo(function TrendIndicator({ trend }: { trend: MemoryTrend | null }) {
if (!trend) {
return null;
}
const isGrowing = trend.growthRate > 1024 * 100; // > 100KB/s
const isShrinking = trend.growthRate < -1024 * 100; // < -100KB/s
const isStable = !isGrowing && !isShrinking;
return (
<div
className={cn(
'flex items-center gap-1 text-xs px-2 py-0.5 rounded-full',
trend.isLeaking && 'bg-red-500/20 text-red-400',
isGrowing && !trend.isLeaking && 'bg-yellow-500/20 text-yellow-400',
isShrinking && 'bg-green-500/20 text-green-400',
isStable && !trend.isLeaking && 'bg-muted text-muted-foreground'
)}
>
{trend.isLeaking ? (
<>
<TrendingUp className="w-3 h-3" />
<span>Leak detected</span>
</>
) : isGrowing ? (
<>
<TrendingUp className="w-3 h-3" />
<span>{formatBytes(Math.abs(trend.growthRate))}/s</span>
</>
) : isShrinking ? (
<>
<TrendingDown className="w-3 h-3" />
<span>{formatBytes(Math.abs(trend.growthRate))}/s</span>
</>
) : (
<>
<Minus className="w-3 h-3" />
<span>Stable</span>
</>
)}
</div>
);
});
export function MemoryMonitor({ history, current, trend, className }: MemoryMonitorProps) {
return (
<div className={cn('space-y-3', className)}>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<HardDrive className="w-4 h-4 text-blue-500" />
<span className="text-sm font-medium">Memory</span>
</div>
<TrendIndicator trend={trend} />
</div>
{/* Current values */}
{current ? (
<div className="space-y-3">
{/* Heap with integrated sparkline */}
<div className="space-y-1">
<div className="flex justify-between text-xs">
<MetricLabel label="Heap" tooltip={METRIC_TOOLTIPS.heap} />
<span
className={cn(
(current.heapUsed / current.heapTotal) * 100 > 90 && 'text-red-400',
(current.heapUsed / current.heapTotal) * 100 > 70 &&
(current.heapUsed / current.heapTotal) * 100 <= 90 &&
'text-yellow-400'
)}
>
{formatBytes(current.heapUsed)} / {formatBytes(current.heapTotal)}
</span>
</div>
{/* Sparkline chart for heap history */}
<div className="h-8 bg-muted/30 rounded overflow-hidden">
<MemorySparkline data={history} className="h-full" />
</div>
</div>
{/* RSS bar */}
<MemoryBar
used={current.rss}
total={current.heapTotal * 1.5}
label="RSS"
tooltip={METRIC_TOOLTIPS.rss}
/>
{/* Additional metrics with tooltips */}
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center gap-1">
<MetricLabel label="External:" tooltip={METRIC_TOOLTIPS.external} />
<span className="ml-auto">{formatBytes(current.external)}</span>
</div>
<div className="flex items-center gap-1">
<MetricLabel label="Buffers:" tooltip={METRIC_TOOLTIPS.arrayBuffers} />
<span className="ml-auto">{formatBytes(current.arrayBuffers)}</span>
</div>
</div>
</div>
) : (
<div className="text-center text-xs text-muted-foreground py-2">No data available</div>
)}
</div>
);
}

View File

@@ -1,364 +0,0 @@
/**
* Process Kanban Component
*
* Visual board showing active agents/CLIs with status indicators.
* Columns: Active | Idle | Stopped | Error
*/
import { useMemo, memo, useState } from 'react';
import {
Bot,
Terminal,
Cpu,
Circle,
Clock,
AlertCircle,
CheckCircle2,
Pause,
Play,
FileText,
Hammer,
ChevronDown,
ChevronRight,
HardDrive,
Activity,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatBytes, formatDuration } from '@automaker/types';
import type {
TrackedProcess,
ProcessType,
ProcessStatus,
ProcessSummary,
AgentResourceMetrics,
} from '@automaker/types';
interface ProcessKanbanProps {
processes: TrackedProcess[];
summary: ProcessSummary | null;
className?: string;
/** Panel width for responsive layout - uses 2x2 grid when narrow */
panelWidth?: number;
}
/**
* Get icon for process type
*/
function getProcessIcon(type: ProcessType) {
switch (type) {
case 'agent':
return <Bot className="w-3.5 h-3.5" />;
case 'terminal':
return <Terminal className="w-3.5 h-3.5" />;
case 'cli':
return <Terminal className="w-3.5 h-3.5" />;
case 'worker':
return <Cpu className="w-3.5 h-3.5" />;
default:
return <Circle className="w-3.5 h-3.5" />;
}
}
/**
* Get status indicator
*/
function getStatusIndicator(status: ProcessStatus) {
switch (status) {
case 'running':
return <Play className="w-3 h-3 text-green-400" />;
case 'starting':
return <Circle className="w-3 h-3 text-blue-400 animate-pulse" />;
case 'idle':
return <Pause className="w-3 h-3 text-yellow-400" />;
case 'stopping':
return <Circle className="w-3 h-3 text-orange-400 animate-pulse" />;
case 'stopped':
return <CheckCircle2 className="w-3 h-3 text-muted-foreground" />;
case 'error':
return <AlertCircle className="w-3 h-3 text-red-400" />;
default:
return <Circle className="w-3 h-3" />;
}
}
/**
* Resource metrics display component for agent processes
*/
const ResourceMetrics = memo(function ResourceMetrics({
metrics,
}: {
metrics: AgentResourceMetrics;
}) {
return (
<div className="mt-1.5 pt-1.5 border-t border-border/50 space-y-1">
{/* File I/O */}
<div className="flex items-center gap-1 text-muted-foreground">
<FileText className="w-3 h-3" />
<span>Files:</span>
<span className="ml-auto">
{metrics.fileIO.reads}R / {metrics.fileIO.writes}W / {metrics.fileIO.edits}E
</span>
</div>
{/* Bytes transferred */}
{(metrics.fileIO.bytesRead > 0 || metrics.fileIO.bytesWritten > 0) && (
<div className="flex items-center gap-1 text-muted-foreground">
<HardDrive className="w-3 h-3" />
<span>I/O:</span>
<span className="ml-auto">
{formatBytes(metrics.fileIO.bytesRead)} read /{' '}
{formatBytes(metrics.fileIO.bytesWritten)} written
</span>
</div>
)}
{/* Tool usage */}
<div className="flex items-center gap-1 text-muted-foreground">
<Hammer className="w-3 h-3" />
<span>Tools:</span>
<span className="ml-auto">{metrics.tools.totalInvocations} calls</span>
</div>
{/* API turns */}
{metrics.api.turns > 0 && (
<div className="flex items-center gap-1 text-muted-foreground">
<Activity className="w-3 h-3" />
<span>API:</span>
<span className="ml-auto">{metrics.api.turns} turns</span>
</div>
)}
{/* Bash commands */}
{metrics.bash.commandCount > 0 && (
<div className="flex items-center gap-1 text-muted-foreground">
<Terminal className="w-3 h-3" />
<span>Bash:</span>
<span className="ml-auto">
{metrics.bash.commandCount} cmds
{metrics.bash.failedCommands > 0 && (
<span className="text-red-400 ml-1">({metrics.bash.failedCommands} failed)</span>
)}
</span>
</div>
)}
{/* Memory delta */}
{metrics.memory.deltaHeapUsed !== 0 && (
<div className="flex items-center gap-1 text-muted-foreground">
<Cpu className="w-3 h-3" />
<span>Mem delta:</span>
<span
className={cn(
'ml-auto',
metrics.memory.deltaHeapUsed > 0 ? 'text-orange-400' : 'text-green-400'
)}
>
{metrics.memory.deltaHeapUsed > 0 ? '+' : ''}
{formatBytes(metrics.memory.deltaHeapUsed)}
</span>
</div>
)}
</div>
);
});
/**
* Process card component - Memoized to prevent unnecessary re-renders
*/
const ProcessCard = memo(function ProcessCard({ process }: { process: TrackedProcess }) {
const [expanded, setExpanded] = useState(false);
const runtime = useMemo(() => {
const end = process.stoppedAt || Date.now();
return end - process.startedAt;
}, [process.startedAt, process.stoppedAt]);
const isActive = process.status === 'running' || process.status === 'starting';
const isError = process.status === 'error';
const hasMetrics = process.type === 'agent' && process.resourceMetrics;
return (
<div
className={cn(
'p-2 rounded-md border text-xs',
isError && 'border-red-500/50 bg-red-500/10',
isActive && !isError && 'border-green-500/50 bg-green-500/10',
!isActive && !isError && 'border-border bg-muted/30'
)}
>
{/* Header */}
<div
className={cn('flex items-center gap-1.5 mb-1', hasMetrics && 'cursor-pointer')}
onClick={() => hasMetrics && setExpanded(!expanded)}
>
{hasMetrics &&
(expanded ? (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
))}
{getProcessIcon(process.type)}
<span className="font-medium truncate flex-1">{process.name}</span>
{getStatusIndicator(process.status)}
</div>
{/* Basic Details */}
<div className="space-y-0.5 text-muted-foreground">
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>{formatDuration(runtime)}</span>
{hasMetrics && (
<span className="ml-auto text-purple-400">
{process.resourceMetrics!.tools.totalInvocations} tools
</span>
)}
</div>
{process.memoryUsage !== undefined && (
<div className="flex justify-between">
<span>Memory:</span>
<span>{formatBytes(process.memoryUsage)}</span>
</div>
)}
{process.cpuUsage !== undefined && (
<div className="flex justify-between">
<span>CPU:</span>
<span>{process.cpuUsage.toFixed(1)}%</span>
</div>
)}
{process.error && (
<div className="text-red-400 mt-1 truncate" title={process.error}>
{process.error}
</div>
)}
</div>
{/* Expanded resource metrics */}
{hasMetrics && expanded && <ResourceMetrics metrics={process.resourceMetrics!} />}
</div>
);
});
/**
* Column component - Memoized to prevent unnecessary re-renders
*/
const ProcessColumn = memo(function ProcessColumn({
title,
processes,
count,
colorClass,
}: {
title: string;
processes: TrackedProcess[];
count: number;
colorClass: string;
}) {
return (
<div className="flex-1 min-w-0">
{/* Column header */}
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium">{title}</span>
<span className={cn('text-xs px-1.5 py-0.5 rounded', colorClass)}>{count}</span>
</div>
{/* Cards */}
<div className="space-y-1.5">
{processes.length > 0 ? (
processes.map((process) => <ProcessCard key={process.id} process={process} />)
) : (
<div className="text-xs text-muted-foreground text-center py-2">No processes</div>
)}
</div>
</div>
);
});
/** Threshold width for switching to 2x2 grid layout */
const NARROW_THRESHOLD = 450;
export function ProcessKanban({ processes, summary, className, panelWidth }: ProcessKanbanProps) {
// Determine if we should use narrow (2x2) layout
const isNarrow = panelWidth !== undefined && panelWidth < NARROW_THRESHOLD;
// Group processes by status
const grouped = useMemo(() => {
const active: TrackedProcess[] = [];
const idle: TrackedProcess[] = [];
const stopped: TrackedProcess[] = [];
const errored: TrackedProcess[] = [];
for (const process of processes) {
switch (process.status) {
case 'running':
case 'starting':
active.push(process);
break;
case 'idle':
idle.push(process);
break;
case 'stopped':
case 'stopping':
stopped.push(process);
break;
case 'error':
errored.push(process);
break;
}
}
return { active, idle, stopped, errored };
}, [processes]);
return (
<div className={cn('space-y-3', className)}>
{/* Header with summary */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Bot className="w-4 h-4 text-purple-500" />
<span className="text-sm font-medium">Processes</span>
</div>
{summary && (
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">Total: {summary.total}</span>
{summary.byType.agent > 0 && (
<span className="text-purple-400">{summary.byType.agent} agents</span>
)}
{summary.byType.terminal > 0 && (
<span className="text-blue-400">{summary.byType.terminal} terminals</span>
)}
</div>
)}
</div>
{/* Kanban board - 2x2 grid when narrow, 4-column when wide */}
<div className={cn('grid gap-2', isNarrow ? 'grid-cols-2' : 'grid-cols-4')}>
<ProcessColumn
title="Active"
processes={grouped.active}
count={summary?.running ?? grouped.active.length}
colorClass="bg-green-500/20 text-green-400"
/>
<ProcessColumn
title="Idle"
processes={grouped.idle}
count={summary?.idle ?? grouped.idle.length}
colorClass="bg-yellow-500/20 text-yellow-400"
/>
<ProcessColumn
title="Stopped"
processes={grouped.stopped}
count={summary?.stopped ?? grouped.stopped.length}
colorClass="bg-muted text-muted-foreground"
/>
<ProcessColumn
title="Error"
processes={grouped.errored}
count={summary?.errored ?? grouped.errored.length}
colorClass="bg-red-500/20 text-red-400"
/>
</div>
</div>
);
}

View File

@@ -1,110 +0,0 @@
/**
* RenderProfiler Component
*
* A wrapper component that uses React.Profiler to track render performance
* of wrapped components. Data is collected and displayed in the Debug Panel's
* Render Tracker tab.
*
* Usage:
* ```tsx
* <RenderProfiler name="BoardView">
* <BoardView />
* </RenderProfiler>
* ```
*/
import {
Profiler,
createContext,
useContext,
type ReactNode,
type ProfilerOnRenderCallback,
} from 'react';
import { useRenderTracking, type RenderTrackingContextType } from '@/hooks/use-render-tracking';
/**
* Context for sharing render tracking across the app
*/
const RenderTrackingContext = createContext<RenderTrackingContextType | null>(null);
/**
* Hook to access render tracking context
*/
export function useRenderTrackingContext(): RenderTrackingContextType | null {
return useContext(RenderTrackingContext);
}
/**
* Provider component that enables render tracking throughout the app
*/
export function RenderTrackingProvider({ children }: { children: ReactNode }) {
const renderTracking = useRenderTracking();
return (
<RenderTrackingContext.Provider value={renderTracking}>
{children}
</RenderTrackingContext.Provider>
);
}
/**
* Props for RenderProfiler component
*/
interface RenderProfilerProps {
/** Name of the component being profiled (displayed in Render Tracker) */
name: string;
/** Children to render and profile */
children: ReactNode;
}
/**
* RenderProfiler wraps a component with React.Profiler to track render performance.
*
* When the Debug Panel is open and render tracking is enabled, this component
* records render data including:
* - Render count
* - Render duration (actual and base)
* - Render phase (mount/update/nested-update)
* - Render frequency (renders per second)
*
* The data appears in the Debug Panel's "Renders" tab.
*/
export function RenderProfiler({ name, children }: RenderProfilerProps) {
const renderTracking = useContext(RenderTrackingContext);
// If no context available, just render children without profiling
if (!renderTracking) {
return <>{children}</>;
}
const onRender: ProfilerOnRenderCallback = renderTracking.createProfilerCallback(name);
return (
<Profiler id={name} onRender={onRender}>
{children}
</Profiler>
);
}
/**
* Higher-order component version of RenderProfiler
*
* Usage:
* ```tsx
* const ProfiledComponent = withRenderProfiler(MyComponent, 'MyComponent');
* ```
*/
export function withRenderProfiler<P extends object>(
WrappedComponent: React.ComponentType<P>,
name: string
): React.FC<P> {
const ProfiledComponent: React.FC<P> = (props) => (
<RenderProfiler name={name}>
<WrappedComponent {...props} />
</RenderProfiler>
);
ProfiledComponent.displayName = `RenderProfiler(${name})`;
return ProfiledComponent;
}

View File

@@ -1,145 +0,0 @@
/**
* Render Tracker Component
*
* Displays component render statistics and highlights frequently re-rendering components.
*/
import { RefreshCw, AlertTriangle, TrendingUp, Clock } from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatDuration } from '@automaker/types';
import type { ComponentRenderStats, RenderTrackingSummary } from '@automaker/types';
interface RenderTrackerProps {
summary: RenderTrackingSummary;
stats: ComponentRenderStats[];
onClear?: () => void;
className?: string;
}
/**
* Component stats row
*/
function ComponentStatsRow({ stats }: { stats: ComponentRenderStats }) {
return (
<div
className={cn(
'flex items-center gap-2 p-2 rounded text-xs',
stats.isHighRender ? 'bg-red-500/10 border border-red-500/30' : 'bg-muted/30'
)}
>
{/* Component name */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
{stats.isHighRender && <AlertTriangle className="w-3 h-3 text-red-400 shrink-0" />}
<span className={cn('font-medium truncate', stats.isHighRender && 'text-red-400')}>
{stats.componentName}
</span>
</div>
</div>
{/* Stats */}
<div className="flex items-center gap-3 text-muted-foreground shrink-0">
<div className="flex items-center gap-1" title="Render count">
<RefreshCw className="w-3 h-3" />
<span>{stats.renderCount}</span>
</div>
<div className="flex items-center gap-1" title="Renders per second">
<TrendingUp className="w-3 h-3" />
<span className={cn(stats.isHighRender && 'text-red-400')}>
{stats.rendersPerSecond.toFixed(1)}/s
</span>
</div>
<div className="flex items-center gap-1" title="Average duration">
<Clock className="w-3 h-3" />
<span>{formatDuration(stats.avgDuration)}</span>
</div>
</div>
</div>
);
}
/**
* Summary stats
*/
function SummaryStats({ summary }: { summary: RenderTrackingSummary }) {
return (
<div className="grid grid-cols-3 gap-2 text-center">
<div className="p-2 bg-muted/30 rounded">
<div className="text-lg font-bold">{summary.totalRenders}</div>
<div className="text-xs text-muted-foreground">Total Renders</div>
</div>
<div className="p-2 bg-muted/30 rounded">
<div className="text-lg font-bold">{summary.uniqueComponents}</div>
<div className="text-xs text-muted-foreground">Components</div>
</div>
<div
className={cn(
'p-2 rounded',
summary.highRenderComponents.length > 0 ? 'bg-red-500/20' : 'bg-muted/30'
)}
>
<div
className={cn(
'text-lg font-bold',
summary.highRenderComponents.length > 0 && 'text-red-400'
)}
>
{summary.highRenderComponents.length}
</div>
<div className="text-xs text-muted-foreground">High Render</div>
</div>
</div>
);
}
export function RenderTracker({ summary, stats, onClear, className }: RenderTrackerProps) {
// Sort by render count (highest first)
const sortedStats = [...stats].sort((a, b) => b.renderCount - a.renderCount);
const topStats = sortedStats.slice(0, 10);
return (
<div className={cn('space-y-3', className)}>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<RefreshCw className="w-4 h-4 text-orange-500" />
<span className="text-sm font-medium">Render Tracker</span>
</div>
{onClear && (
<button
onClick={onClear}
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1 rounded hover:bg-muted"
>
Clear
</button>
)}
</div>
{/* Summary */}
<SummaryStats summary={summary} />
{/* High render warnings */}
{summary.highRenderComponents.length > 0 && (
<div className="p-2 bg-red-500/10 border border-red-500/30 rounded text-xs">
<div className="flex items-center gap-1 text-red-400 font-medium mb-1">
<AlertTriangle className="w-3 h-3" />
<span>High render rate detected</span>
</div>
<div className="text-muted-foreground">{summary.highRenderComponents.join(', ')}</div>
</div>
)}
{/* Component list */}
<div className="space-y-1 max-h-[200px] overflow-y-auto">
{topStats.length > 0 ? (
topStats.map((s) => <ComponentStatsRow key={s.componentName} stats={s} />)
) : (
<div className="text-center text-xs text-muted-foreground py-4">
<p>No render data yet.</p>
<p className="mt-1">Wrap components with RenderProfiler to track renders.</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -42,6 +42,9 @@ export function useSpecRegeneration({
}
if (event.type === 'spec_regeneration_complete') {
// Only show toast if we're in active creation flow (not regular regeneration)
const isCreationFlow = creatingSpecProjectPath !== null;
setSpecCreatingForProject(null);
setShowSetupDialog(false);
setProjectOverview('');
@@ -49,9 +52,12 @@ export function useSpecRegeneration({
// Clear onboarding state if we came from onboarding
setNewProjectName('');
setNewProjectPath('');
toast.success('App specification created', {
description: 'Your project is now set up and ready to go!',
});
if (isCreationFlow) {
toast.success('App specification created', {
description: 'Your project is now set up and ready to go!',
});
}
} else if (event.type === 'spec_regeneration_error') {
setSpecCreatingForProject(null);
toast.error('Failed to create specification', {

View File

@@ -32,6 +32,53 @@ export function useCliStatus() {
const [isCheckingClaudeCli, setIsCheckingClaudeCli] = useState(false);
// Refresh Claude auth status from the server
const refreshAuthStatus = useCallback(async () => {
const api = getElectronAPI();
if (!api?.setup?.getClaudeStatus) return;
try {
const result = await api.setup.getClaudeStatus();
if (result.success && result.auth) {
// Cast to extended type that includes server-added fields
const auth = result.auth as typeof result.auth & {
oauthTokenValid?: boolean;
apiKeyValid?: boolean;
};
// Map server method names to client method types
// Server returns: oauth_token_env, oauth_token, api_key_env, api_key, credentials_file, cli_authenticated, none
const validMethods = [
'oauth_token_env',
'oauth_token',
'api_key',
'api_key_env',
'credentials_file',
'cli_authenticated',
'none',
] as const;
type AuthMethod = (typeof validMethods)[number];
const method: AuthMethod = validMethods.includes(auth.method as AuthMethod)
? (auth.method as AuthMethod)
: auth.authenticated
? 'api_key'
: 'none'; // Default authenticated to api_key, not none
const authStatus = {
authenticated: auth.authenticated,
method,
hasCredentialsFile: auth.hasCredentialsFile ?? false,
oauthTokenValid:
auth.oauthTokenValid || auth.hasStoredOAuthToken || auth.hasEnvOAuthToken,
apiKeyValid: auth.apiKeyValid || auth.hasStoredApiKey || auth.hasEnvApiKey,
hasEnvOAuthToken: auth.hasEnvOAuthToken,
hasEnvApiKey: auth.hasEnvApiKey,
};
setClaudeAuthStatus(authStatus);
}
} catch (error) {
logger.error('Failed to refresh Claude auth status:', error);
}
}, [setClaudeAuthStatus]);
// Check CLI status on mount
useEffect(() => {
const checkCliStatus = async () => {
@@ -48,54 +95,13 @@ export function useCliStatus() {
}
// Check Claude auth status (re-fetch on mount to ensure persistence)
if (api?.setup?.getClaudeStatus) {
try {
const result = await api.setup.getClaudeStatus();
if (result.success && result.auth) {
// Cast to extended type that includes server-added fields
const auth = result.auth as typeof result.auth & {
oauthTokenValid?: boolean;
apiKeyValid?: boolean;
};
// Map server method names to client method types
// Server returns: oauth_token_env, oauth_token, api_key_env, api_key, credentials_file, cli_authenticated, none
const validMethods = [
'oauth_token_env',
'oauth_token',
'api_key',
'api_key_env',
'credentials_file',
'cli_authenticated',
'none',
] as const;
type AuthMethod = (typeof validMethods)[number];
const method: AuthMethod = validMethods.includes(auth.method as AuthMethod)
? (auth.method as AuthMethod)
: auth.authenticated
? 'api_key'
: 'none'; // Default authenticated to api_key, not none
const authStatus = {
authenticated: auth.authenticated,
method,
hasCredentialsFile: auth.hasCredentialsFile ?? false,
oauthTokenValid:
auth.oauthTokenValid || auth.hasStoredOAuthToken || auth.hasEnvOAuthToken,
apiKeyValid: auth.apiKeyValid || auth.hasStoredApiKey || auth.hasEnvApiKey,
hasEnvOAuthToken: auth.hasEnvOAuthToken,
hasEnvApiKey: auth.hasEnvApiKey,
};
setClaudeAuthStatus(authStatus);
}
} catch (error) {
logger.error('Failed to check Claude auth status:', error);
}
}
await refreshAuthStatus();
};
checkCliStatus();
}, [setClaudeAuthStatus]);
}, [refreshAuthStatus]);
// Refresh Claude CLI status
// Refresh Claude CLI status and auth status
const handleRefreshClaudeCli = useCallback(async () => {
setIsCheckingClaudeCli(true);
try {
@@ -104,12 +110,14 @@ export function useCliStatus() {
const status = await api.checkClaudeCli();
setClaudeCliStatus(status);
}
// Also refresh auth status
await refreshAuthStatus();
} catch (error) {
logger.error('Failed to refresh Claude CLI status:', error);
} finally {
setIsCheckingClaudeCli(false);
}
}, []);
}, [refreshAuthStatus]);
return {
claudeCliStatus,

View File

@@ -8,6 +8,9 @@ interface UseCliStatusOptions {
setAuthStatus: (status: any) => void;
}
// Create logger once outside the hook to prevent infinite re-renders
const logger = createLogger('CliStatus');
export function useCliStatus({
cliType,
statusApi,
@@ -15,7 +18,6 @@ export function useCliStatus({
setAuthStatus,
}: UseCliStatusOptions) {
const [isChecking, setIsChecking] = useState(false);
const logger = createLogger('CliStatus');
const checkStatus = useCallback(async () => {
logger.info(`Starting status check for ${cliType}...`);
@@ -66,7 +68,7 @@ export function useCliStatus({
} finally {
setIsChecking(false);
}
}, [cliType, statusApi, setCliStatus, setAuthStatus, logger]);
}, [cliType, statusApi, setCliStatus, setAuthStatus]);
return { isChecking, checkStatus };
}

View File

@@ -11,7 +11,7 @@ interface ThemeStepProps {
}
export function ThemeStep({ onNext, onBack }: ThemeStepProps) {
const { theme, setTheme, setPreviewTheme } = useAppStore();
const { theme, setTheme, setPreviewTheme, currentProject, setProjectTheme } = useAppStore();
const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark');
const handleThemeHover = (themeValue: string) => {
@@ -24,6 +24,11 @@ export function ThemeStep({ onNext, onBack }: ThemeStepProps) {
const handleThemeClick = (themeValue: string) => {
setTheme(themeValue as typeof theme);
// Also update the current project's theme if one exists
// This ensures the selected theme is visible since getEffectiveTheme() prioritizes project theme
if (currentProject) {
setProjectTheme(currentProject.id, themeValue as typeof theme);
}
setPreviewTheme(null);
};

View File

@@ -1,317 +0,0 @@
/**
* Hook for consuming debug metrics from the server
*
* Provides real-time metrics data including:
* - Memory usage (server-side)
* - CPU usage (server-side)
* - Tracked processes
* - Memory leak detection
*
* Uses polling for metrics data with configurable interval.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { apiGet, apiPost } from '@/lib/api-fetch';
import { useDebugStore } from '@/store/debug-store';
import type {
DebugMetricsSnapshot,
DebugMetricsResponse,
MemoryDataPoint,
CPUDataPoint,
TrackedProcess,
ProcessSummary,
MemoryTrend,
BrowserMemoryMetrics,
} from '@automaker/types';
/**
* Maximum data points to store in history buffers
*/
const MAX_HISTORY_POINTS = 60;
/**
* Browser memory metrics (from Chrome's performance.memory API)
*/
interface BrowserMetrics {
memory?: BrowserMemoryMetrics;
available: boolean;
}
/**
* Get browser memory metrics (Chrome only)
*/
function getBrowserMemoryMetrics(): BrowserMetrics {
// performance.memory is Chrome-specific
const perf = performance as Performance & {
memory?: {
jsHeapSizeLimit: number;
totalJSHeapSize: number;
usedJSHeapSize: number;
};
};
if (!perf.memory) {
return { available: false };
}
return {
available: true,
memory: {
jsHeapSizeLimit: perf.memory.jsHeapSizeLimit,
totalJSHeapSize: perf.memory.totalJSHeapSize,
usedJSHeapSize: perf.memory.usedJSHeapSize,
},
};
}
/**
* Debug metrics state
*/
export interface DebugMetricsState {
/** Whether metrics collection is active */
isActive: boolean;
/** Whether data is currently loading */
isLoading: boolean;
/** Error message if any */
error: string | null;
/** Latest metrics snapshot from server */
latestSnapshot: DebugMetricsSnapshot | null;
/** Memory history for charting */
memoryHistory: MemoryDataPoint[];
/** CPU history for charting */
cpuHistory: CPUDataPoint[];
/** Tracked processes */
processes: TrackedProcess[];
/** Process summary */
processSummary: ProcessSummary | null;
/** Memory trend analysis */
memoryTrend: MemoryTrend | null;
/** Browser-side memory metrics */
browserMetrics: BrowserMetrics;
}
/**
* Debug metrics actions
*/
export interface DebugMetricsActions {
/** Start metrics collection */
start: () => Promise<void>;
/** Stop metrics collection */
stop: () => Promise<void>;
/** Force garbage collection (if available) */
forceGC: () => Promise<{ success: boolean; message: string }>;
/** Clear history */
clearHistory: () => Promise<void>;
/** Refresh metrics immediately */
refresh: () => Promise<void>;
}
/**
* Hook for consuming debug metrics
*/
export function useDebugMetrics(): DebugMetricsState & DebugMetricsActions {
const preferences = useDebugStore((state) => state.preferences);
const isOpen = useDebugStore((state) => state.isOpen);
const [state, setState] = useState<DebugMetricsState>({
isActive: false,
isLoading: true,
error: null,
latestSnapshot: null,
memoryHistory: [],
cpuHistory: [],
processes: [],
processSummary: null,
memoryTrend: null,
browserMetrics: { available: false },
});
// Use ref to store history to avoid re-renders during updates
const memoryHistoryRef = useRef<MemoryDataPoint[]>([]);
const cpuHistoryRef = useRef<CPUDataPoint[]>([]);
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
/**
* Fetch metrics from server
*/
const fetchMetrics = useCallback(async () => {
try {
const response = await apiGet<DebugMetricsResponse>('/api/debug/metrics');
// Get browser metrics
const browserMetrics = getBrowserMemoryMetrics();
if (response.snapshot) {
const snapshot = response.snapshot;
// Add to history buffers
if (snapshot.memory.server) {
const memoryPoint: MemoryDataPoint = {
timestamp: snapshot.timestamp,
heapUsed: snapshot.memory.server.heapUsed,
heapTotal: snapshot.memory.server.heapTotal,
rss: snapshot.memory.server.rss,
};
memoryHistoryRef.current.push(memoryPoint);
if (memoryHistoryRef.current.length > MAX_HISTORY_POINTS) {
memoryHistoryRef.current.shift();
}
}
if (snapshot.cpu.server) {
const cpuPoint: CPUDataPoint = {
timestamp: snapshot.timestamp,
percentage: snapshot.cpu.server.percentage,
eventLoopLag: snapshot.cpu.eventLoopLag,
};
cpuHistoryRef.current.push(cpuPoint);
if (cpuHistoryRef.current.length > MAX_HISTORY_POINTS) {
cpuHistoryRef.current.shift();
}
}
setState((prev) => ({
...prev,
isActive: response.active,
isLoading: false,
error: null,
latestSnapshot: snapshot,
memoryHistory: [...memoryHistoryRef.current],
cpuHistory: [...cpuHistoryRef.current],
processes: snapshot.processes,
processSummary: snapshot.processSummary,
memoryTrend: snapshot.memoryTrend || null,
browserMetrics,
}));
} else {
setState((prev) => ({
...prev,
isActive: response.active,
isLoading: false,
browserMetrics,
}));
}
} catch (error) {
setState((prev) => ({
...prev,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch metrics',
}));
}
}, []);
/**
* Start metrics collection
*/
const start = useCallback(async () => {
try {
await apiPost<DebugMetricsResponse>('/api/debug/metrics/start');
await fetchMetrics();
} catch (error) {
setState((prev) => ({
...prev,
error: error instanceof Error ? error.message : 'Failed to start metrics',
}));
}
}, [fetchMetrics]);
/**
* Stop metrics collection
*/
const stop = useCallback(async () => {
try {
await apiPost<DebugMetricsResponse>('/api/debug/metrics/stop');
setState((prev) => ({
...prev,
isActive: false,
}));
} catch (error) {
setState((prev) => ({
...prev,
error: error instanceof Error ? error.message : 'Failed to stop metrics',
}));
}
}, []);
/**
* Force garbage collection
*/
const forceGC = useCallback(async () => {
try {
const response = await apiPost<{ success: boolean; message: string }>(
'/api/debug/metrics/gc'
);
return response;
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to trigger GC',
};
}
}, []);
/**
* Clear metrics history
*/
const clearHistory = useCallback(async () => {
try {
await apiPost('/api/debug/metrics/clear');
memoryHistoryRef.current = [];
cpuHistoryRef.current = [];
setState((prev) => ({
...prev,
memoryHistory: [],
cpuHistory: [],
}));
} catch (error) {
setState((prev) => ({
...prev,
error: error instanceof Error ? error.message : 'Failed to clear history',
}));
}
}, []);
/**
* Refresh metrics immediately
*/
const refresh = useCallback(async () => {
setState((prev) => ({ ...prev, isLoading: true }));
await fetchMetrics();
}, [fetchMetrics]);
// Set up polling when debug panel is open and monitoring is enabled
useEffect(() => {
if (!isOpen || !preferences.memoryMonitorEnabled) {
// Clear polling when panel is closed or monitoring disabled
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
return;
}
// Initial fetch
fetchMetrics();
// Set up polling interval
pollingIntervalRef.current = setInterval(fetchMetrics, preferences.updateInterval);
return () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
};
}, [isOpen, preferences.memoryMonitorEnabled, preferences.updateInterval, fetchMetrics]);
return {
...state,
start,
stop,
forceGC,
clearHistory,
refresh,
};
}

View File

@@ -0,0 +1,82 @@
import { useEffect, useRef } from 'react';
import { useAppStore } from '@/store/app-store';
import { getHttpApiClient } from '@/lib/http-api-client';
/**
* Hook that loads project settings from the server when the current project changes.
* This ensures that settings like board backgrounds are properly restored when
* switching between projects or restarting the app.
*/
export function useProjectSettingsLoader() {
const currentProject = useAppStore((state) => state.currentProject);
const setBoardBackground = useAppStore((state) => state.setBoardBackground);
const setCardOpacity = useAppStore((state) => state.setCardOpacity);
const setColumnOpacity = useAppStore((state) => state.setColumnOpacity);
const setColumnBorderEnabled = useAppStore((state) => state.setColumnBorderEnabled);
const setCardGlassmorphism = useAppStore((state) => state.setCardGlassmorphism);
const setCardBorderEnabled = useAppStore((state) => state.setCardBorderEnabled);
const setCardBorderOpacity = useAppStore((state) => state.setCardBorderOpacity);
const setHideScrollbar = useAppStore((state) => state.setHideScrollbar);
const loadingRef = useRef<string | null>(null);
useEffect(() => {
if (!currentProject?.path) {
return;
}
// Prevent loading the same project multiple times
if (loadingRef.current === currentProject.path) {
return;
}
loadingRef.current = currentProject.path;
const loadProjectSettings = async () => {
try {
const httpClient = getHttpApiClient();
const result = await httpClient.settings.getProject(currentProject.path);
if (result.success && result.settings?.boardBackground) {
const bg = result.settings.boardBackground;
const projectPath = currentProject.path;
// Update store with loaded settings (without triggering server save)
setBoardBackground(projectPath, bg.imagePath);
const settingsMap = {
cardOpacity: setCardOpacity,
columnOpacity: setColumnOpacity,
columnBorderEnabled: setColumnBorderEnabled,
cardGlassmorphism: setCardGlassmorphism,
cardBorderEnabled: setCardBorderEnabled,
cardBorderOpacity: setCardBorderOpacity,
hideScrollbar: setHideScrollbar,
} as const;
for (const [key, setter] of Object.entries(settingsMap)) {
const value = bg[key as keyof typeof bg];
if (value !== undefined) {
(setter as (path: string, val: typeof value) => void)(projectPath, value);
}
}
}
} catch (error) {
console.error('Failed to load project settings:', error);
// Don't show error toast - just log it
}
};
loadProjectSettings();
}, [
currentProject?.path,
setBoardBackground,
setCardOpacity,
setColumnOpacity,
setColumnBorderEnabled,
setCardGlassmorphism,
setCardBorderEnabled,
setCardBorderOpacity,
setHideScrollbar,
]);
}

View File

@@ -1,237 +0,0 @@
/**
* Hook for tracking React component render performance
*
* Uses React Profiler API to track:
* - Component render counts
* - Render durations
* - Render frequency (renders per second)
* - High-render component detection
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import type { ProfilerOnRenderCallback } from 'react';
import type {
ComponentRender,
ComponentRenderStats,
RenderTrackingSummary,
} from '@automaker/types';
import { useDebugStore } from '@/store/debug-store';
/**
* Maximum render records to keep per component
*/
const MAX_RENDER_RECORDS = 100;
/**
* Time window for calculating renders per second (ms)
*/
const RENDER_RATE_WINDOW = 5000;
/**
* Hook for tracking render performance
*/
export function useRenderTracking() {
const isOpen = useDebugStore((state) => state.isOpen);
const preferences = useDebugStore((state) => state.preferences);
// Store render records per component
const renderRecordsRef = useRef<Map<string, ComponentRender[]>>(new Map());
// Store computed stats
const [stats, setStats] = useState<Map<string, ComponentRenderStats>>(new Map());
const [summary, setSummary] = useState<RenderTrackingSummary>({
totalRenders: 0,
uniqueComponents: 0,
highRenderComponents: [],
topRenderers: [],
windowStart: Date.now(),
windowDuration: 0,
});
/**
* Create a profiler callback for a specific component
*/
const createProfilerCallback = useCallback(
(componentName: string): ProfilerOnRenderCallback => {
return (
_id: string,
phase: 'mount' | 'update' | 'nested-update',
actualDuration: number,
baseDuration: number,
startTime: number,
commitTime: number
) => {
if (!isOpen || !preferences.renderTrackingEnabled) {
return;
}
const record: ComponentRender = {
componentName,
phase,
actualDuration,
baseDuration,
startTime,
commitTime,
};
// Add to records
let records = renderRecordsRef.current.get(componentName);
if (!records) {
records = [];
renderRecordsRef.current.set(componentName, records);
}
records.push(record);
// Trim old records
if (records.length > MAX_RENDER_RECORDS) {
records.shift();
}
};
},
[isOpen, preferences.renderTrackingEnabled]
);
/**
* Calculate stats for a component
*/
const calculateComponentStats = useCallback(
(componentName: string, records: ComponentRender[]): ComponentRenderStats => {
const now = Date.now();
const windowStart = now - RENDER_RATE_WINDOW;
// Filter records in the rate calculation window
const recentRecords = records.filter((r) => r.commitTime >= windowStart);
const rendersPerSecond = recentRecords.length / (RENDER_RATE_WINDOW / 1000);
// Calculate duration stats
let totalDuration = 0;
let maxDuration = 0;
let minDuration = Infinity;
for (const record of records) {
totalDuration += record.actualDuration;
maxDuration = Math.max(maxDuration, record.actualDuration);
minDuration = Math.min(minDuration, record.actualDuration);
}
const avgDuration = records.length > 0 ? totalDuration / records.length : 0;
const lastRender = records[records.length - 1];
return {
componentName,
renderCount: records.length,
rendersPerSecond,
avgDuration,
maxDuration,
minDuration: minDuration === Infinity ? 0 : minDuration,
totalDuration,
isHighRender: rendersPerSecond > preferences.renderAlertThreshold,
lastRenderAt: lastRender?.commitTime || 0,
};
},
[preferences.renderAlertThreshold]
);
/**
* Update all stats
*/
const updateStats = useCallback(() => {
const newStats = new Map<string, ComponentRenderStats>();
let totalRenders = 0;
const highRenderComponents: string[] = [];
const allStats: ComponentRenderStats[] = [];
let windowStart = Date.now();
for (const [componentName, records] of renderRecordsRef.current.entries()) {
if (records.length === 0) continue;
const componentStats = calculateComponentStats(componentName, records);
newStats.set(componentName, componentStats);
allStats.push(componentStats);
totalRenders += componentStats.renderCount;
if (componentStats.isHighRender) {
highRenderComponents.push(componentName);
}
// Track earliest record
const firstRecord = records[0];
if (firstRecord && firstRecord.commitTime < windowStart) {
windowStart = firstRecord.commitTime;
}
}
// Sort by render count to get top renderers
const topRenderers = allStats.sort((a, b) => b.renderCount - a.renderCount).slice(0, 5);
setStats(newStats);
setSummary({
totalRenders,
uniqueComponents: newStats.size,
highRenderComponents,
topRenderers,
windowStart,
windowDuration: Date.now() - windowStart,
});
}, [calculateComponentStats]);
/**
* Clear all render records
*/
const clearRecords = useCallback(() => {
renderRecordsRef.current.clear();
setStats(new Map());
setSummary({
totalRenders: 0,
uniqueComponents: 0,
highRenderComponents: [],
topRenderers: [],
windowStart: Date.now(),
windowDuration: 0,
});
}, []);
/**
* Get stats for a specific component
*/
const getComponentStats = useCallback(
(componentName: string): ComponentRenderStats | null => {
return stats.get(componentName) || null;
},
[stats]
);
/**
* Get all component stats as array
*/
const getAllStats = useCallback((): ComponentRenderStats[] => {
return Array.from(stats.values());
}, [stats]);
// Periodically update stats when panel is open
useEffect(() => {
if (!isOpen || !preferences.renderTrackingEnabled) {
return;
}
// Update stats every second
const interval = setInterval(updateStats, 1000);
return () => clearInterval(interval);
}, [isOpen, preferences.renderTrackingEnabled, updateStats]);
return {
stats,
summary,
createProfilerCallback,
updateStats,
clearRecords,
getComponentStats,
getAllStats,
};
}
/**
* Context for sharing render tracking across components
*/
export type RenderTrackingContextType = ReturnType<typeof useRenderTracking>;

View File

@@ -7,12 +7,28 @@ import { createLogger } from '@automaker/utils/logger';
import { getHttpApiClient } from './http-api-client';
import { getElectronAPI } from './electron';
import { getItem, setItem } from './storage';
import path from 'path';
const logger = createLogger('WorkspaceConfig');
const LAST_PROJECT_DIR_KEY = 'automaker:lastProjectDir';
/**
* Browser-compatible path join utility
* Works in both Node.js and browser environments
*/
function joinPath(...parts: string[]): string {
// Remove empty parts and normalize separators
const normalized = parts
.filter((p) => p)
.map((p) => p.replace(/\\/g, '/'))
.join('/')
.replace(/\/+/g, '/'); // Remove duplicate slashes
// Preserve leading slash if first part had it
const hasLeadingSlash = parts[0]?.startsWith('/');
return hasLeadingSlash ? '/' + normalized.replace(/^\//, '') : normalized;
}
/**
* Gets the default Documents/Automaker directory path
* @returns Promise resolving to Documents/Automaker path, or null if unavailable
@@ -21,7 +37,7 @@ async function getDefaultDocumentsPath(): Promise<string | null> {
try {
const api = getElectronAPI();
const documentsPath = await api.getPath('documents');
return path.join(documentsPath, 'Automaker');
return joinPath(documentsPath, 'Automaker');
} catch (error) {
logger.error('Failed to get documents path:', error);
return null;

View File

@@ -24,14 +24,7 @@ import { ThemeOption, themeOptions } from '@/config/theme-options';
import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog';
import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen';
import { LoadingState } from '@/components/ui/loading-state';
import {
DebugPanelWrapper,
DebugStatusBarWrapper,
DebugDockedPanelWrapper,
RenderTrackingProvider,
RenderProfiler,
} from '@/components/debug';
import { useDebugStore } from '@/store/debug-store';
import { useProjectSettingsLoader } from '@/hooks/use-project-settings-loader';
const logger = createLogger('RootLayout');
@@ -54,7 +47,9 @@ function RootLayoutContent() {
const authChecked = useAuthStore((s) => s.authChecked);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const { openFileBrowser } = useFileBrowser();
const toggleDebugPanel = useDebugStore((s) => s.togglePanel);
// Load project settings when switching projects
useProjectSettingsLoader();
const isSetupRoute = location.pathname === '/setup';
const isLoginRoute = location.pathname === '/login';
@@ -96,31 +91,12 @@ function RootLayoutContent() {
}
}, []);
// Debug panel shortcut - Cmd/Ctrl+Shift+D
const handleDebugPanelShortcut = useCallback(
(event: KeyboardEvent) => {
// Only in dev mode
if (!import.meta.env.DEV && import.meta.env.VITE_ENABLE_DEBUG_PANEL !== 'true') {
return;
}
const cmdCtrl = event.metaKey || event.ctrlKey;
if (cmdCtrl && event.shiftKey && event.key.toLowerCase() === 'd') {
event.preventDefault();
toggleDebugPanel();
}
},
[toggleDebugPanel]
);
useEffect(() => {
window.addEventListener('keydown', handleStreamerPanelShortcut);
window.addEventListener('keydown', handleDebugPanelShortcut);
return () => {
window.removeEventListener('keydown', handleStreamerPanelShortcut);
window.removeEventListener('keydown', handleDebugPanelShortcut);
};
}, [handleStreamerPanelShortcut, handleDebugPanelShortcut]);
}, [handleStreamerPanelShortcut]);
const effectiveTheme = getEffectiveTheme();
// Defer the theme value to keep UI responsive during rapid hover changes
@@ -422,25 +398,12 @@ function RootLayoutContent() {
aria-hidden="true"
/>
)}
<RenderProfiler name="Sidebar">
<Sidebar />
</RenderProfiler>
<Sidebar />
<div
className="flex-1 flex flex-col overflow-hidden transition-all duration-300"
style={{ marginRight: streamerPanelOpen ? '250px' : '0' }}
>
{/* Main content area */}
<div className="flex-1 flex flex-col min-h-0">
<RenderProfiler name="MainContent">
<Outlet />
</RenderProfiler>
</div>
{/* Docked Debug Panel - expands above status bar */}
<DebugDockedPanelWrapper />
{/* Docked Debug Status Bar - VS Code style footer */}
<DebugStatusBarWrapper />
<Outlet />
</div>
{/* Hidden streamer panel - opens with "\" key, pushes content */}
@@ -451,9 +414,6 @@ function RootLayoutContent() {
/>
<Toaster richColors position="bottom-right" />
{/* Floating Debug Panel - alternative mode */}
<DebugPanelWrapper />
{/* Show sandbox dialog if needed */}
<SandboxRiskDialog
open={sandboxStatus === 'needs-confirmation'}
@@ -465,18 +425,9 @@ function RootLayoutContent() {
}
function RootLayout() {
// Check if dev mode for render tracking
const isDev = import.meta.env.DEV || import.meta.env.VITE_ENABLE_DEBUG_PANEL === 'true';
return (
<FileBrowserProvider>
{isDev ? (
<RenderTrackingProvider>
<RootLayoutContent />
</RenderTrackingProvider>
) : (
<RootLayoutContent />
)}
<RootLayoutContent />
</FileBrowserProvider>
);
}

View File

@@ -1,15 +1,6 @@
import { createFileRoute } from '@tanstack/react-router';
import { BoardView } from '@/components/views/board-view';
import { RenderProfiler } from '@/components/debug';
function ProfiledBoardView() {
return (
<RenderProfiler name="BoardView">
<BoardView />
</RenderProfiler>
);
}
export const Route = createFileRoute('/board')({
component: ProfiledBoardView,
component: BoardView,
});

View File

@@ -1,312 +0,0 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
/**
* Debug Panel Position - coordinates for draggable panel
*/
export interface DebugPanelPosition {
x: number;
y: number;
}
/**
* Debug Panel Size - dimensions for resizable panel
*/
export interface DebugPanelSize {
width: number;
height: number;
}
/**
* Debug Tab - available tabs in the debug panel
*/
export type DebugTab = 'memory' | 'cpu' | 'processes' | 'renders';
/**
* Debug Panel Mode - floating overlay or docked to bottom
*/
export type DebugPanelMode = 'floating' | 'docked';
/**
* Debug Panel Preferences - user customization options
*/
export interface DebugPanelPreferences {
/** Update interval for metrics polling in milliseconds */
updateInterval: number;
/** Maximum data points to retain in charts (circular buffer) */
maxDataPoints: number;
/** Enable/disable memory monitoring */
memoryMonitorEnabled: boolean;
/** Enable/disable CPU monitoring */
cpuMonitorEnabled: boolean;
/** Enable/disable process tracking */
processTrackingEnabled: boolean;
/** Enable/disable render tracking */
renderTrackingEnabled: boolean;
/** Threshold for highlighting high-render components (renders/second) */
renderAlertThreshold: number;
/** Show mini chart in collapsed mode */
showMiniChart: boolean;
}
/**
* Default preferences for the debug panel
*/
export const DEFAULT_DEBUG_PREFERENCES: DebugPanelPreferences = {
updateInterval: 1000, // 1 second
maxDataPoints: 60, // 60 data points = 60 seconds of history
memoryMonitorEnabled: true,
cpuMonitorEnabled: true,
processTrackingEnabled: true,
renderTrackingEnabled: true,
renderAlertThreshold: 10, // 10 renders/second triggers alert
showMiniChart: true,
};
/**
* Debug Store State
*/
export interface DebugState {
/** Whether the debug panel is open/visible */
isOpen: boolean;
/** Whether the panel is minimized (collapsed view) */
isMinimized: boolean;
/** Panel mode: floating overlay or docked to bottom */
panelMode: DebugPanelMode;
/** Whether the docked panel detail view is expanded */
isDockedExpanded: boolean;
/** Height of the docked panel when expanded */
dockedHeight: number;
/** Current position of the panel (for dragging - floating mode only) */
position: DebugPanelPosition;
/** Current size of the panel (for resizing - floating mode only) */
size: DebugPanelSize;
/** Currently active tab */
activeTab: DebugTab;
/** User preferences */
preferences: DebugPanelPreferences;
/** Whether the panel is currently being dragged */
isDragging: boolean;
/** Whether the panel is currently being resized */
isResizing: boolean;
}
/**
* Debug Store Actions
*/
export interface DebugActions {
// Panel visibility
/** Toggle the debug panel open/closed */
togglePanel: () => void;
/** Set the panel open state directly */
setOpen: (open: boolean) => void;
/** Toggle minimized state */
toggleMinimized: () => void;
/** Set minimized state directly */
setMinimized: (minimized: boolean) => void;
// Panel mode (floating vs docked)
/** Set panel mode */
setPanelMode: (mode: DebugPanelMode) => void;
/** Toggle between floating and docked mode */
togglePanelMode: () => void;
/** Toggle docked panel expanded state */
toggleDockedExpanded: () => void;
/** Set docked expanded state */
setDockedExpanded: (expanded: boolean) => void;
/** Set docked panel height */
setDockedHeight: (height: number) => void;
// Position & Size
/** Update panel position (called during drag) */
setPosition: (position: DebugPanelPosition) => void;
/** Update panel size (called during resize) */
setSize: (size: DebugPanelSize) => void;
/** Reset position to default (top-right corner) */
resetPosition: () => void;
/** Reset size to default */
resetSize: () => void;
// Tab management
/** Set the active tab */
setActiveTab: (tab: DebugTab) => void;
// Preferences
/** Update preferences (partial update supported) */
setPreferences: (preferences: Partial<DebugPanelPreferences>) => void;
/** Reset preferences to defaults */
resetPreferences: () => void;
// Drag/Resize state (for UI feedback)
/** Set dragging state */
setIsDragging: (dragging: boolean) => void;
/** Set resizing state */
setIsResizing: (resizing: boolean) => void;
// Reset
/** Reset entire store to initial state */
reset: () => void;
}
/**
* Default position - top-right corner with offset
*/
const DEFAULT_POSITION: DebugPanelPosition = {
x: -20, // 20px from right edge (negative = from right)
y: 20, // 20px from top
};
/**
* Default size for the debug panel
*/
const DEFAULT_SIZE: DebugPanelSize = {
width: 450,
height: 350,
};
/**
* Minimum size constraints for resize
*/
export const MIN_PANEL_SIZE: DebugPanelSize = {
width: 350,
height: 250,
};
/**
* Maximum size constraints for resize (relative to viewport)
*/
export const MAX_PANEL_SIZE_RATIO = {
width: 0.9, // 90% of viewport width
height: 0.9, // 90% of viewport height
};
/**
* Default height for docked panel when expanded
*/
export const DEFAULT_DOCKED_HEIGHT = 250;
/**
* Minimum height for docked panel when expanded
*/
export const MIN_DOCKED_HEIGHT = 150;
/**
* Maximum height ratio for docked panel (relative to viewport)
*/
export const MAX_DOCKED_HEIGHT_RATIO = 0.5; // 50% of viewport height
/**
* Initial state for the debug store
*/
const initialState: DebugState = {
isOpen: false,
isMinimized: false,
panelMode: 'docked', // Default to docked mode (VS Code style)
isDockedExpanded: false,
dockedHeight: DEFAULT_DOCKED_HEIGHT,
position: DEFAULT_POSITION,
size: DEFAULT_SIZE,
activeTab: 'memory',
preferences: DEFAULT_DEBUG_PREFERENCES,
isDragging: false,
isResizing: false,
};
/**
* Debug Store
*
* Manages state for the floating debug panel including:
* - Panel visibility (open/closed, minimized/expanded)
* - Position and size (for dragging and resizing)
* - Active tab selection
* - User preferences for metrics collection
*
* Uses Zustand with persist middleware to save preferences across sessions.
* Only UI-related state is persisted; runtime metrics data is stored separately.
*/
export const useDebugStore = create<DebugState & DebugActions>()(
persist(
(set, get) => ({
...initialState,
// Panel visibility
togglePanel: () => set((state) => ({ isOpen: !state.isOpen })),
setOpen: (open) => set({ isOpen: open }),
toggleMinimized: () => set((state) => ({ isMinimized: !state.isMinimized })),
setMinimized: (minimized) => set({ isMinimized: minimized }),
// Panel mode (floating vs docked)
setPanelMode: (mode) => set({ panelMode: mode }),
togglePanelMode: () =>
set((state) => ({
panelMode: state.panelMode === 'floating' ? 'docked' : 'floating',
})),
toggleDockedExpanded: () => set((state) => ({ isDockedExpanded: !state.isDockedExpanded })),
setDockedExpanded: (expanded) => set({ isDockedExpanded: expanded }),
setDockedHeight: (height) => set({ dockedHeight: height }),
// Position & Size
setPosition: (position) => set({ position }),
setSize: (size) => set({ size }),
resetPosition: () => set({ position: DEFAULT_POSITION }),
resetSize: () => set({ size: DEFAULT_SIZE }),
// Tab management
setActiveTab: (tab) => set({ activeTab: tab }),
// Preferences
setPreferences: (preferences) =>
set((state) => ({
preferences: { ...state.preferences, ...preferences },
})),
resetPreferences: () => set({ preferences: DEFAULT_DEBUG_PREFERENCES }),
// Drag/Resize state
setIsDragging: (dragging) => set({ isDragging: dragging }),
setIsResizing: (resizing) => set({ isResizing: resizing }),
// Reset
reset: () => set(initialState),
}),
{
name: 'automaker-debug-panel',
version: 2, // Bumped for new fields
partialize: (state) => ({
// Only persist UI preferences, not runtime state
position: state.position,
size: state.size,
activeTab: state.activeTab,
preferences: state.preferences,
isMinimized: state.isMinimized,
panelMode: state.panelMode,
dockedHeight: state.dockedHeight,
// Don't persist: isOpen, isDragging, isResizing, isDockedExpanded (runtime state)
}),
}
)
);
/**
* Selector hooks for common patterns
*/
export const selectDebugPanelOpen = (state: DebugState) => state.isOpen;
export const selectDebugPanelMinimized = (state: DebugState) => state.isMinimized;
export const selectDebugPanelMode = (state: DebugState) => state.panelMode;
export const selectDebugDockedExpanded = (state: DebugState) => state.isDockedExpanded;
export const selectDebugDockedHeight = (state: DebugState) => state.dockedHeight;
export const selectDebugPosition = (state: DebugState) => state.position;
export const selectDebugSize = (state: DebugState) => state.size;
export const selectDebugActiveTab = (state: DebugState) => state.activeTab;
export const selectDebugPreferences = (state: DebugState) => state.preferences;

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@@ -0,0 +1,411 @@
/**
* Board Background Persistence End-to-End Test
*
* Tests that board background settings are properly saved and loaded when switching projects.
* This verifies that:
* 1. Background settings are saved to .automaker-local/settings.json
* 2. Settings are loaded when switching back to a project
* 3. Background image, opacity, and other settings are correctly restored
* 4. Settings persist across app restarts (new page loads)
*
* This test prevents regression of the board background loading bug where
* settings were saved but never loaded when switching projects.
*/
import { test, expect } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
import {
createTempDirPath,
cleanupTempDir,
authenticateForTests,
handleLoginScreenIfPresent,
} from '../utils';
// Create unique temp dirs for this test run
const TEST_TEMP_DIR = createTempDirPath('board-bg-test');
test.describe('Board Background Persistence', () => {
test.beforeAll(async () => {
// Create test temp directory
if (!fs.existsSync(TEST_TEMP_DIR)) {
fs.mkdirSync(TEST_TEMP_DIR, { recursive: true });
}
});
test.afterAll(async () => {
// Cleanup temp directory
cleanupTempDir(TEST_TEMP_DIR);
});
test('should load board background settings when switching projects', async ({ page }) => {
const projectAName = `project-a-${Date.now()}`;
const projectBName = `project-b-${Date.now()}`;
const projectAPath = path.join(TEST_TEMP_DIR, projectAName);
const projectBPath = path.join(TEST_TEMP_DIR, projectBName);
const projectAId = `project-a-${Date.now()}`;
const projectBId = `project-b-${Date.now()}`;
// Create both project directories
fs.mkdirSync(projectAPath, { recursive: true });
fs.mkdirSync(projectBPath, { recursive: true });
// Create basic files for both projects
for (const [name, projectPath] of [
[projectAName, projectAPath],
[projectBName, projectBPath],
]) {
fs.writeFileSync(
path.join(projectPath, 'package.json'),
JSON.stringify({ name, version: '1.0.0' }, null, 2)
);
fs.writeFileSync(path.join(projectPath, 'README.md'), `# ${name}\n`);
}
// Create .automaker-local directory for project A with background settings
const automakerDirA = path.join(projectAPath, '.automaker-local');
fs.mkdirSync(automakerDirA, { recursive: true });
fs.mkdirSync(path.join(automakerDirA, 'board'), { recursive: true });
fs.mkdirSync(path.join(automakerDirA, 'features'), { recursive: true });
fs.mkdirSync(path.join(automakerDirA, 'context'), { recursive: true });
// Copy actual background image from test fixtures
const backgroundPath = path.join(automakerDirA, 'board', 'background.jpg');
const testImagePath = path.join(__dirname, '..', 'img', 'background.jpg');
fs.copyFileSync(testImagePath, backgroundPath);
// Create settings.json with board background configuration
const settingsPath = path.join(automakerDirA, 'settings.json');
const backgroundSettings = {
version: 1,
boardBackground: {
imagePath: backgroundPath,
cardOpacity: 85,
columnOpacity: 60,
columnBorderEnabled: true,
cardGlassmorphism: true,
cardBorderEnabled: false,
cardBorderOpacity: 50,
hideScrollbar: true,
imageVersion: Date.now(),
},
};
fs.writeFileSync(settingsPath, JSON.stringify(backgroundSettings, null, 2));
// Create minimal automaker-local directory for project B (no background)
const automakerDirB = path.join(projectBPath, '.automaker-local');
fs.mkdirSync(automakerDirB, { recursive: true });
fs.mkdirSync(path.join(automakerDirB, 'features'), { recursive: true });
fs.mkdirSync(path.join(automakerDirB, 'context'), { recursive: true });
fs.writeFileSync(
path.join(automakerDirB, 'settings.json'),
JSON.stringify({ version: 1 }, null, 2)
);
// Set up app state with both projects in the list (not recent, but in projects list)
await page.addInitScript(
({ projects }: { projects: string[] }) => {
const appState = {
state: {
projects: [
{
id: projects[0],
name: projects[1],
path: projects[2],
lastOpened: new Date(Date.now() - 86400000).toISOString(),
theme: 'red',
},
{
id: projects[3],
name: projects[4],
path: projects[5],
lastOpened: new Date(Date.now() - 172800000).toISOString(),
theme: 'red',
},
],
currentProject: null,
currentView: 'welcome',
theme: 'red',
sidebarOpen: true,
apiKeys: { anthropic: '', google: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
boardBackgroundByProject: {},
},
version: 2,
};
localStorage.setItem('automaker-storage', JSON.stringify(appState));
// Setup complete
const setupState = {
state: {
setupComplete: true,
workspaceDir: '/tmp',
},
version: 0,
};
localStorage.setItem('setup-storage', JSON.stringify(setupState));
},
{ projects: [projectAId, projectAName, projectAPath, projectBId, projectBName, projectBPath] }
);
// Track API calls to /api/settings/project to verify settings are being loaded
const settingsApiCalls: Array<{ url: string; method: string; body: string }> = [];
page.on('request', (request) => {
if (request.url().includes('/api/settings/project') && request.method() === 'POST') {
settingsApiCalls.push({
url: request.url(),
method: request.method(),
body: request.postData() || '',
});
}
});
// Navigate to the app
await authenticateForTests(page);
await page.goto('/');
await page.waitForLoadState('load');
await handleLoginScreenIfPresent(page);
// Wait for welcome view
await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 10000 });
// Open project A (has background settings)
const projectACard = page.locator(`[data-testid="recent-project-${projectAId}"]`);
await expect(projectACard).toBeVisible();
await projectACard.click();
// Wait for board view
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 });
// Verify project A is current
await expect(
page.locator('[data-testid="project-selector"]').getByText(projectAName)
).toBeVisible({ timeout: 5000 });
// CRITICAL: Wait for settings to be loaded (useProjectSettingsLoader hook)
// This ensures the background settings are fetched from the server
await page.waitForResponse(
(resp) =>
resp.url().includes('/api/settings/project') &&
resp.request().postData()?.includes(projectAPath) === true
);
// Check if background settings were applied by checking the store
// We can't directly access React state, so we'll verify via DOM/CSS
const boardView = page.locator('[data-testid="board-view"]');
await expect(boardView).toBeVisible();
// Wait for initial project load to stabilize
await page.waitForTimeout(500);
// Switch to project B (no background)
const projectSelector = page.locator('[data-testid="project-selector"]');
await projectSelector.click();
// Wait for dropdown to be visible
await expect(page.locator('[data-testid="project-picker-dropdown"]')).toBeVisible({
timeout: 5000,
});
const projectPickerB = page.locator(`[data-testid="project-option-${projectBId}"]`);
await expect(projectPickerB).toBeVisible({ timeout: 5000 });
await projectPickerB.click();
// Wait for project B to load
await expect(
page.locator('[data-testid="project-selector"]').getByText(projectBName)
).toBeVisible({ timeout: 5000 });
// Wait a bit for project B to fully load before switching
await page.waitForTimeout(500);
// Switch back to project A
await projectSelector.click();
// Wait for dropdown to be visible
await expect(page.locator('[data-testid="project-picker-dropdown"]')).toBeVisible({
timeout: 5000,
});
const projectPickerA = page.locator(`[data-testid="project-option-${projectAId}"]`);
await expect(projectPickerA).toBeVisible({ timeout: 5000 });
await projectPickerA.click();
// Verify we're back on project A
await expect(
page.locator('[data-testid="project-selector"]').getByText(projectAName)
).toBeVisible({ timeout: 5000 });
// CRITICAL: Wait for settings to be loaded again
await page.waitForResponse(
(resp) =>
resp.url().includes('/api/settings/project') &&
resp.request().postData()?.includes(projectAPath) === true
);
// Verify that the settings API was called for project A (at least twice - initial load and switch back)
const projectASettingsCalls = settingsApiCalls.filter((call) =>
call.body.includes(projectAPath)
);
// Debug: log all API calls if test fails
if (projectASettingsCalls.length < 2) {
console.log('Total settings API calls:', settingsApiCalls.length);
console.log('API calls:', JSON.stringify(settingsApiCalls, null, 2));
console.log('Looking for path:', projectAPath);
}
expect(projectASettingsCalls.length).toBeGreaterThanOrEqual(2);
// Verify settings file still exists with correct data
const loadedSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
expect(loadedSettings.boardBackground).toBeDefined();
expect(loadedSettings.boardBackground.imagePath).toBe(backgroundPath);
expect(loadedSettings.boardBackground.cardOpacity).toBe(85);
expect(loadedSettings.boardBackground.columnOpacity).toBe(60);
expect(loadedSettings.boardBackground.hideScrollbar).toBe(true);
// The test passing means:
// 1. The useProjectSettingsLoader hook is working
// 2. Settings are loaded when switching projects
// 3. The API call to /api/settings/project is made correctly
});
test('should load background settings on app restart', async ({ page }) => {
const projectName = `restart-test-${Date.now()}`;
const projectPath = path.join(TEST_TEMP_DIR, projectName);
const projectId = `project-${Date.now()}`;
// Create project directory
fs.mkdirSync(projectPath, { recursive: true });
fs.writeFileSync(
path.join(projectPath, 'package.json'),
JSON.stringify({ name: projectName, version: '1.0.0' }, null, 2)
);
// Create .automaker-local with background settings
const automakerDir = path.join(projectPath, '.automaker-local');
fs.mkdirSync(automakerDir, { recursive: true });
fs.mkdirSync(path.join(automakerDir, 'board'), { recursive: true });
fs.mkdirSync(path.join(automakerDir, 'features'), { recursive: true });
fs.mkdirSync(path.join(automakerDir, 'context'), { recursive: true });
// Copy actual background image from test fixtures
const backgroundPath = path.join(automakerDir, 'board', 'background.jpg');
const testImagePath = path.join(__dirname, '..', 'img', 'background.jpg');
fs.copyFileSync(testImagePath, backgroundPath);
const settingsPath = path.join(automakerDir, 'settings.json');
fs.writeFileSync(
settingsPath,
JSON.stringify(
{
version: 1,
boardBackground: {
imagePath: backgroundPath,
cardOpacity: 90,
columnOpacity: 70,
imageVersion: Date.now(),
},
},
null,
2
)
);
// Set up with project as current using direct localStorage
await page.addInitScript(
({ project }: { project: string[] }) => {
const projectObj = {
id: project[0],
name: project[1],
path: project[2],
lastOpened: new Date().toISOString(),
theme: 'red',
};
const appState = {
state: {
projects: [projectObj],
currentProject: projectObj,
currentView: 'board',
theme: 'red',
sidebarOpen: true,
apiKeys: { anthropic: '', google: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
boardBackgroundByProject: {},
},
version: 2,
};
localStorage.setItem('automaker-storage', JSON.stringify(appState));
// Setup complete
const setupState = {
state: {
setupComplete: true,
workspaceDir: '/tmp',
},
version: 0,
};
localStorage.setItem('setup-storage', JSON.stringify(setupState));
},
{ project: [projectId, projectName, projectPath] }
);
// Track API calls to /api/settings/project to verify settings are being loaded
const settingsApiCalls: Array<{ url: string; method: string; body: string }> = [];
page.on('request', (request) => {
if (request.url().includes('/api/settings/project') && request.method() === 'POST') {
settingsApiCalls.push({
url: request.url(),
method: request.method(),
body: request.postData() || '',
});
}
});
// Navigate and authenticate
await authenticateForTests(page);
await page.goto('/');
await page.waitForLoadState('load');
await handleLoginScreenIfPresent(page);
// Should go straight to board view (not welcome) since we have currentProject
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 });
// Wait for settings to load
await page.waitForResponse(
(resp) =>
resp.url().includes('/api/settings/project') &&
resp.request().postData()?.includes(projectPath) === true
);
// Verify that the settings API was called for this project
const projectSettingsCalls = settingsApiCalls.filter((call) => call.body.includes(projectPath));
// Debug: log all API calls if test fails
if (projectSettingsCalls.length < 1) {
console.log('Total settings API calls:', settingsApiCalls.length);
console.log('API calls:', JSON.stringify(settingsApiCalls, null, 2));
console.log('Looking for path:', projectPath);
}
expect(projectSettingsCalls.length).toBeGreaterThanOrEqual(1);
// Verify settings file exists with correct data
const loadedSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
expect(loadedSettings.boardBackground).toBeDefined();
expect(loadedSettings.boardBackground.imagePath).toBe(backgroundPath);
expect(loadedSettings.boardBackground.cardOpacity).toBe(90);
expect(loadedSettings.boardBackground.columnOpacity).toBe(70);
// The test passing means:
// 1. The useProjectSettingsLoader hook is working
// 2. Settings are loaded when app starts with a currentProject
// 3. The API call to /api/settings/project is made correctly
});
});

47
dev.mjs
View File

@@ -11,13 +11,13 @@
import path from 'path';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';
import {
createRestrictedFs,
log,
runNpm,
runNpmAndWait,
runNpx,
printHeader,
printModeMenu,
resolvePortConfiguration,
@@ -26,11 +26,9 @@ import {
startServerAndWait,
ensureDependencies,
prompt,
launchDockerContainers,
} from './scripts/launcher-utils.mjs';
const require = createRequire(import.meta.url);
const crossSpawn = require('cross-spawn');
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -52,10 +50,11 @@ async function installPlaywrightBrowsers() {
log('Checking Playwright browsers...', 'yellow');
try {
const exitCode = await new Promise((resolve) => {
const playwright = crossSpawn('npx', ['playwright', 'install', 'chromium'], {
stdio: 'inherit',
cwd: path.join(__dirname, 'apps', 'ui'),
});
const playwright = runNpx(
['playwright', 'install', 'chromium'],
{ stdio: 'inherit' },
path.join(__dirname, 'apps', 'ui')
);
playwright.on('close', (code) => resolve(code));
playwright.on('error', () => resolve(1));
});
@@ -171,37 +170,7 @@ async function main() {
break;
} else if (choice === '3') {
console.log('');
log('Launching Docker Container (Isolated Mode)...', 'blue');
log('Building and starting Docker containers...', 'yellow');
console.log('');
// Check if ANTHROPIC_API_KEY is set
if (!process.env.ANTHROPIC_API_KEY) {
log('Warning: ANTHROPIC_API_KEY environment variable is not set.', 'yellow');
log('The server will require an API key to function.', 'yellow');
log('Set it with: export ANTHROPIC_API_KEY=your-key', 'yellow');
console.log('');
}
// Build and start containers with docker-compose
processes.docker = crossSpawn('docker', ['compose', 'up', '--build'], {
stdio: 'inherit',
cwd: __dirname,
env: {
...process.env,
},
});
log('Docker containers starting...', 'blue');
log('UI will be available at: http://localhost:3007', 'green');
log('API will be available at: http://localhost:3008', 'green');
console.log('');
log('Press Ctrl+C to stop the containers.', 'yellow');
await new Promise((resolve) => {
processes.docker.on('close', resolve);
});
await launchDockerContainers({ baseDir: __dirname, processes });
break;
} else {
log('Invalid choice. Please enter 1, 2, or 3.', 'red');

View File

@@ -4,8 +4,26 @@ services:
# Mount your workspace directory to /projects inside the container
# Example: mount your local /workspace to /projects inside the container
- /Users/webdevcody/Workspace/automaker-workspace:/projects:rw
# ===== CLI Authentication (Optional) =====
# Mount host CLI credentials to avoid re-authenticating in container
# Claude CLI - mount your ~/.claude directory (Linux/Windows)
# This shares your 'claude login' OAuth session with the container
# - ~/.claude:/home/automaker/.claude
# Cursor CLI - mount your ~/.cursor directory (Linux/Windows)
# This shares your 'cursor-agent login' OAuth session with the container
# - ~/.cursor:/home/automaker/.cursor
environment:
# Set root directory for all projects and file operations
# Users can only create/open projects within this directory
- ALLOWED_ROOT_DIRECTORY=/projects
- NODE_ENV=development
# ===== macOS Users =====
# On macOS, OAuth tokens are stored in SQLite databases, not plain files.
# Extract your Cursor token with: ./scripts/get-cursor-token.sh
# Then set it here or in your .env file:
# - CURSOR_API_KEY=${CURSOR_API_KEY:-}

View File

@@ -36,6 +36,17 @@ services:
# Required
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
# Optional - Claude CLI OAuth credentials (for macOS users)
# Extract with: ./scripts/get-claude-token.sh
# This writes the OAuth tokens to ~/.claude/.credentials.json in the container
- CLAUDE_OAUTH_CREDENTIALS=${CLAUDE_OAUTH_CREDENTIALS:-}
# Optional - Cursor CLI OAuth token (extract from host with the command shown below)
# macOS: ./scripts/get-cursor-token.sh (extracts from Keychain)
# Linux: jq -r '.accessToken' ~/.config/cursor/auth.json
# Note: cursor-agent stores its OAuth tokens separately from Cursor IDE
- CURSOR_AUTH_TOKEN=${CURSOR_AUTH_TOKEN:-}
# Optional - authentication, one will generate if left blank
- AUTOMAKER_API_KEY=${AUTOMAKER_API_KEY:-}
@@ -59,6 +70,14 @@ services:
# This volume persists data between restarts but is container-managed
- automaker-data:/data
# Persist Claude CLI OAuth session keys across container restarts
# This allows 'claude login' authentication to persist between restarts
- automaker-claude-config:/home/automaker/.claude
# Persist Cursor CLI configuration and authentication across container restarts
# This allows 'cursor-agent login' authentication to persist between restarts
- automaker-cursor-config:/home/automaker/.cursor
# NO host directory mounts - container cannot access your laptop files
# If you need to work on a project, create it INSIDE the container
# or use a separate docker-compose override file
@@ -72,3 +91,13 @@ volumes:
automaker-data:
name: automaker-data
# Named volume - completely isolated from host filesystem
automaker-claude-config:
name: automaker-claude-config
# Named volume for Claude CLI OAuth session keys and configuration
# Persists authentication across container restarts
automaker-cursor-config:
name: automaker-cursor-config
# Named volume for Cursor CLI configuration and authentication
# Persists cursor-agent login authentication across container restarts

45
docker-entrypoint.sh Executable file
View File

@@ -0,0 +1,45 @@
#!/bin/sh
set -e
# Ensure Claude CLI config directory exists with correct permissions
if [ ! -d "/home/automaker/.claude" ]; then
mkdir -p /home/automaker/.claude
fi
# If CLAUDE_OAUTH_CREDENTIALS is set, write it to the credentials file
# This allows passing OAuth tokens from host (especially macOS where they're in Keychain)
if [ -n "$CLAUDE_OAUTH_CREDENTIALS" ]; then
echo "$CLAUDE_OAUTH_CREDENTIALS" > /home/automaker/.claude/.credentials.json
chmod 600 /home/automaker/.claude/.credentials.json
fi
# Fix permissions on Claude CLI config directory
chown -R automaker:automaker /home/automaker/.claude
chmod 700 /home/automaker/.claude
# Ensure Cursor CLI config directory exists with correct permissions
# This handles both: mounted volumes (owned by root) and empty directories
if [ ! -d "/home/automaker/.cursor" ]; then
mkdir -p /home/automaker/.cursor
fi
chown -R automaker:automaker /home/automaker/.cursor
chmod -R 700 /home/automaker/.cursor
# If CURSOR_AUTH_TOKEN is set, write it to the cursor auth file
# On Linux, cursor-agent uses ~/.config/cursor/auth.json for file-based credential storage
# The env var CURSOR_AUTH_TOKEN is also checked directly by cursor-agent
if [ -n "$CURSOR_AUTH_TOKEN" ]; then
CURSOR_CONFIG_DIR="/home/automaker/.config/cursor"
mkdir -p "$CURSOR_CONFIG_DIR"
# Write auth.json with the access token
cat > "$CURSOR_CONFIG_DIR/auth.json" << EOF
{
"accessToken": "$CURSOR_AUTH_TOKEN"
}
EOF
chmod 600 "$CURSOR_CONFIG_DIR/auth.json"
chown -R automaker:automaker /home/automaker/.config
fi
# Switch to automaker user and execute the command
exec gosu automaker "$@"

View File

@@ -57,10 +57,63 @@ docker-compose -f docker-compose.yml -f docker-compose.project.yml up -d
**Tip**: Use `:ro` (read-only) when possible for extra safety.
## CLI Authentication (macOS)
On macOS, OAuth tokens are stored in Keychain (Claude) and SQLite (Cursor). Use these scripts to extract and pass them to the container:
### Claude CLI
```bash
# Extract and add to .env
echo "CLAUDE_OAUTH_CREDENTIALS=$(./scripts/get-claude-token.sh)" >> .env
```
### Cursor CLI
```bash
# Extract and add to .env (extracts from macOS Keychain)
echo "CURSOR_AUTH_TOKEN=$(./scripts/get-cursor-token.sh)" >> .env
```
**Note**: The cursor-agent CLI stores its OAuth tokens separately from the Cursor IDE:
- **macOS**: Tokens are stored in Keychain (service: `cursor-access-token`)
- **Linux**: Tokens are stored in `~/.config/cursor/auth.json` (not `~/.cursor`)
### Apply to container
```bash
# Restart with new credentials
docker-compose down && docker-compose up -d
```
**Note**: Tokens expire periodically. If you get authentication errors, re-run the extraction scripts.
## CLI Authentication (Linux/Windows)
On Linux/Windows, cursor-agent stores credentials in files, so you can either:
**Option 1: Extract tokens to environment variables (recommended)**
```bash
# Linux: Extract tokens to .env
echo "CURSOR_AUTH_TOKEN=$(jq -r '.accessToken' ~/.config/cursor/auth.json)" >> .env
```
**Option 2: Bind mount credential directories directly**
```yaml
# In docker-compose.override.yml
volumes:
- ~/.claude:/home/automaker/.claude
- ~/.config/cursor:/home/automaker/.config/cursor
```
## Troubleshooting
| Problem | Solution |
| --------------------- | -------------------------------------------------------------------------------------------- |
| Container won't start | Check `.env` has `ANTHROPIC_API_KEY` set. Run `docker-compose logs` for errors. |
| Can't access web UI | Verify container is running with `docker ps \| grep automaker` |
| Need a fresh start | Run `docker-compose down && docker volume rm automaker-data && docker-compose up -d --build` |
| Problem | Solution |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| Container won't start | Check `.env` has `ANTHROPIC_API_KEY` set. Run `docker-compose logs` for errors. |
| Can't access web UI | Verify container is running with `docker ps \| grep automaker` |
| Need a fresh start | Run `docker-compose down && docker volume rm automaker-data && docker-compose up -d --build` |
| Cursor auth fails | Re-extract token with `./scripts/get-cursor-token.sh` - tokens expire periodically. Make sure you've run `cursor-agent login` on your host first. |

View File

@@ -1,726 +0,0 @@
# Debug API Documentation
The Debug API provides endpoints for monitoring server performance, memory usage, CPU metrics, and process tracking. These endpoints are only available in development mode or when `ENABLE_DEBUG_PANEL=true`.
## Table of Contents
- [Overview](#overview)
- [Authentication](#authentication)
- [Metrics Endpoints](#metrics-endpoints)
- [GET /api/debug/metrics](#get-apidebugmetrics)
- [POST /api/debug/metrics/start](#post-apidebugmetricsstart)
- [POST /api/debug/metrics/stop](#post-apidebugmetricsstop)
- [POST /api/debug/metrics/gc](#post-apidebugmetricsgc)
- [POST /api/debug/metrics/clear](#post-apidebugmetricsclear)
- [Process Endpoints](#process-endpoints)
- [GET /api/debug/processes](#get-apidebugprocesses)
- [GET /api/debug/processes/summary](#get-apidebugprocessessummary)
- [GET /api/debug/processes/:id](#get-apidebugprocessesid)
- [Agent Resource Metrics Endpoints](#agent-resource-metrics-endpoints)
- [GET /api/debug/agents](#get-apidebugagents)
- [GET /api/debug/agents/summary](#get-apidebugagentssummary)
- [GET /api/debug/agents/:id/metrics](#get-apidebugagentsidmetrics)
- [Types](#types)
- [Events](#events)
---
## Overview
The Debug API is designed for development and debugging purposes. It provides:
- **Memory Monitoring**: Track heap usage, RSS, and detect memory leaks
- **CPU Monitoring**: Track CPU usage percentage and event loop lag
- **Process Tracking**: Monitor agents, terminals, CLIs, and worker processes
- **Trend Analysis**: Detect memory leaks using linear regression
### Enabling the Debug API
The Debug API is enabled when:
- `NODE_ENV !== 'production'` (development mode), OR
- `ENABLE_DEBUG_PANEL=true` environment variable is set
---
## Authentication
All debug endpoints require authentication. Requests must include a valid session token or use the standard Automaker authentication mechanism.
---
## Metrics Endpoints
### GET /api/debug/metrics
Returns the current metrics snapshot including memory, CPU, and process information.
**Response**
```json
{
"active": true,
"config": {
"memoryEnabled": true,
"cpuEnabled": true,
"processTrackingEnabled": true,
"collectionInterval": 1000,
"maxDataPoints": 60,
"leakThreshold": 1048576
},
"snapshot": {
"timestamp": 1704067200000,
"memory": {
"timestamp": 1704067200000,
"server": {
"heapTotal": 104857600,
"heapUsed": 52428800,
"external": 5242880,
"rss": 157286400,
"arrayBuffers": 1048576
}
},
"cpu": {
"timestamp": 1704067200000,
"server": {
"percentage": 25.5,
"user": 1000000,
"system": 500000
},
"eventLoopLag": 5
},
"processes": [],
"processSummary": {
"total": 0,
"running": 0,
"idle": 0,
"stopped": 0,
"errored": 0,
"byType": {
"agent": 0,
"cli": 0,
"terminal": 0,
"worker": 0
}
},
"memoryTrend": {
"growthRate": 1024,
"isLeaking": false,
"confidence": 0.85,
"sampleCount": 30,
"windowDuration": 30000
}
}
}
```
---
### POST /api/debug/metrics/start
Starts metrics collection with optional configuration overrides.
**Request Body** (optional)
```json
{
"config": {
"collectionInterval": 2000,
"maxDataPoints": 100,
"memoryEnabled": true,
"cpuEnabled": true,
"leakThreshold": 2097152
}
}
```
**Configuration Limits** (enforced server-side)
| Field | Min | Max | Default |
| -------------------- | ----- | ------- | ------- |
| `collectionInterval` | 100ms | 60000ms | 1000ms |
| `maxDataPoints` | 10 | 10000 | 60 |
| `leakThreshold` | 1KB | 100MB | 1MB |
**Response**
```json
{
"active": true,
"config": {
"memoryEnabled": true,
"cpuEnabled": true,
"processTrackingEnabled": true,
"collectionInterval": 2000,
"maxDataPoints": 100,
"leakThreshold": 2097152
}
}
```
---
### POST /api/debug/metrics/stop
Stops metrics collection.
**Response**
```json
{
"active": false,
"config": {
"memoryEnabled": true,
"cpuEnabled": true,
"processTrackingEnabled": true,
"collectionInterval": 1000,
"maxDataPoints": 60,
"leakThreshold": 1048576
}
}
```
---
### POST /api/debug/metrics/gc
Forces garbage collection if Node.js was started with `--expose-gc` flag.
**Response (success)**
```json
{
"success": true,
"message": "Garbage collection triggered"
}
```
**Response (not available)**
```json
{
"success": false,
"message": "Garbage collection not available (start Node.js with --expose-gc flag)"
}
```
---
### POST /api/debug/metrics/clear
Clears the metrics history buffer.
**Response**
```json
{
"success": true,
"message": "Metrics history cleared"
}
```
---
## Process Endpoints
### GET /api/debug/processes
Returns a list of tracked processes with optional filtering.
**Query Parameters**
| Parameter | Type | Description |
| ---------------- | ------ | ------------------------------------------------------------------------------- |
| `type` | string | Filter by process type: `agent`, `cli`, `terminal`, `worker` |
| `status` | string | Filter by status: `starting`, `running`, `idle`, `stopping`, `stopped`, `error` |
| `includeStopped` | string | Set to `"true"` to include stopped processes |
| `sessionId` | string | Filter by session ID |
| `featureId` | string | Filter by feature ID |
**Example Request**
```
GET /api/debug/processes?type=agent&status=running&includeStopped=true
```
**Response**
```json
{
"processes": [
{
"id": "agent-12345",
"pid": 1234,
"type": "agent",
"name": "Feature Agent",
"status": "running",
"startedAt": 1704067200000,
"memoryUsage": 52428800,
"cpuUsage": 15.5,
"featureId": "feature-123",
"sessionId": "session-456"
}
],
"summary": {
"total": 5,
"running": 2,
"idle": 1,
"stopped": 1,
"errored": 1,
"byType": {
"agent": 2,
"cli": 1,
"terminal": 2,
"worker": 0
}
}
}
```
---
### GET /api/debug/processes/summary
Returns summary statistics for all tracked processes.
**Response**
```json
{
"total": 5,
"running": 2,
"idle": 1,
"stopped": 1,
"errored": 1,
"byType": {
"agent": 2,
"cli": 1,
"terminal": 2,
"worker": 0
}
}
```
---
### GET /api/debug/processes/:id
Returns details for a specific process.
**Path Parameters**
| Parameter | Type | Description |
| --------- | ------ | ------------------------------- |
| `id` | string | Process ID (max 256 characters) |
**Response (success)**
```json
{
"id": "agent-12345",
"pid": 1234,
"type": "agent",
"name": "Feature Agent",
"status": "running",
"startedAt": 1704067200000,
"memoryUsage": 52428800,
"cpuUsage": 15.5,
"featureId": "feature-123",
"sessionId": "session-456",
"command": "node agent.js",
"cwd": "/path/to/project"
}
```
**Response (not found)**
```json
{
"error": "Process not found",
"id": "non-existent-id"
}
```
**Response (invalid ID)**
```json
{
"error": "Invalid process ID format"
}
```
---
## Agent Resource Metrics Endpoints
These endpoints provide detailed resource usage metrics for agent processes, including file I/O, tool usage, bash commands, and memory tracking.
### GET /api/debug/agents
Returns all agent processes with their detailed resource metrics.
**Response**
```json
{
"agents": [
{
"id": "agent-feature-123",
"pid": -1,
"type": "agent",
"name": "Feature Agent",
"status": "running",
"startedAt": 1704067200000,
"featureId": "feature-123",
"resourceMetrics": {
"agentId": "agent-feature-123",
"featureId": "feature-123",
"startedAt": 1704067200000,
"lastUpdatedAt": 1704067260000,
"duration": 60000,
"isRunning": true,
"memory": {
"startHeapUsed": 52428800,
"currentHeapUsed": 57671680,
"peakHeapUsed": 58720256,
"deltaHeapUsed": 5242880,
"samples": [...]
},
"fileIO": {
"reads": 25,
"bytesRead": 524288,
"writes": 5,
"bytesWritten": 10240,
"edits": 3,
"globs": 10,
"greps": 8,
"filesAccessed": ["src/index.ts", "src/utils.ts", ...]
},
"tools": {
"totalInvocations": 51,
"byTool": {
"Read": 25,
"Glob": 10,
"Grep": 8,
"Write": 5,
"Edit": 3
},
"avgExecutionTime": 150,
"totalExecutionTime": 7650,
"failedInvocations": 1
},
"bash": {
"commandCount": 5,
"totalExecutionTime": 2500,
"failedCommands": 0,
"commands": [...]
},
"api": {
"turns": 12,
"totalDuration": 45000,
"errors": 0
}
}
}
],
"summary": {
"totalAgents": 3,
"runningAgents": 1,
"totalFileReads": 75,
"totalFileWrites": 15,
"totalBytesRead": 1572864,
"totalBytesWritten": 30720,
"totalToolInvocations": 153,
"totalBashCommands": 12,
"totalAPITurns": 36,
"peakMemoryUsage": 58720256,
"totalDuration": 180000
}
}
```
---
### GET /api/debug/agents/summary
Returns aggregate resource usage statistics across all agent processes.
**Response**
```json
{
"totalAgents": 3,
"runningAgents": 1,
"totalFileReads": 75,
"totalFileWrites": 15,
"totalBytesRead": 1572864,
"totalBytesWritten": 30720,
"totalToolInvocations": 153,
"totalBashCommands": 12,
"totalAPITurns": 36,
"peakMemoryUsage": 58720256,
"totalDuration": 180000
}
```
---
### GET /api/debug/agents/:id/metrics
Returns detailed resource metrics for a specific agent.
**Path Parameters**
| Parameter | Type | Description |
| --------- | ------ | ------------------------------------------------------------------ |
| `id` | string | Agent process ID (e.g., `agent-feature-123` or `chat-session-456`) |
**Response (success)**
```json
{
"agentId": "agent-feature-123",
"featureId": "feature-123",
"startedAt": 1704067200000,
"lastUpdatedAt": 1704067260000,
"duration": 60000,
"isRunning": true,
"memory": {
"startHeapUsed": 52428800,
"currentHeapUsed": 57671680,
"peakHeapUsed": 58720256,
"deltaHeapUsed": 5242880,
"samples": [
{ "timestamp": 1704067200000, "heapUsed": 52428800 },
{ "timestamp": 1704067201000, "heapUsed": 53477376 }
]
},
"fileIO": {
"reads": 25,
"bytesRead": 524288,
"writes": 5,
"bytesWritten": 10240,
"edits": 3,
"globs": 10,
"greps": 8,
"filesAccessed": ["src/index.ts", "src/utils.ts", "package.json"]
},
"tools": {
"totalInvocations": 51,
"byTool": {
"Read": 25,
"Glob": 10,
"Grep": 8,
"Write": 5,
"Edit": 3
},
"avgExecutionTime": 150,
"totalExecutionTime": 7650,
"failedInvocations": 1
},
"bash": {
"commandCount": 5,
"totalExecutionTime": 2500,
"failedCommands": 0,
"commands": [
{
"command": "npm test",
"exitCode": 0,
"duration": 1500,
"timestamp": 1704067230000
}
]
},
"api": {
"turns": 12,
"inputTokens": 15000,
"outputTokens": 8000,
"thinkingTokens": 5000,
"totalDuration": 45000,
"errors": 0
}
}
```
**Response (not found)**
```json
{
"error": "Agent metrics not found",
"id": "non-existent-id"
}
```
---
## Types
### TrackedProcess
```typescript
interface TrackedProcess {
id: string; // Unique identifier
pid?: number; // OS process ID
type: ProcessType; // 'agent' | 'cli' | 'terminal' | 'worker'
name: string; // Human-readable name
status: ProcessStatus; // Current status
startedAt: number; // Start timestamp (ms)
stoppedAt?: number; // Stop timestamp (ms)
memoryUsage?: number; // Memory in bytes
cpuUsage?: number; // CPU percentage
featureId?: string; // Associated feature
sessionId?: string; // Associated session
command?: string; // Command executed
cwd?: string; // Working directory
exitCode?: number; // Exit code (if stopped)
error?: string; // Error message (if failed)
resourceMetrics?: AgentResourceMetrics; // Detailed metrics for agents
}
```
### AgentResourceMetrics
```typescript
interface AgentResourceMetrics {
agentId: string; // Agent/process ID
sessionId?: string; // Session ID if available
featureId?: string; // Feature ID if running a feature
startedAt: number; // When metrics collection started
lastUpdatedAt: number; // When metrics were last updated
duration: number; // Duration of agent execution (ms)
isRunning: boolean; // Whether the agent is still running
memory: AgentMemoryMetrics;
fileIO: FileIOMetrics;
tools: ToolUsageMetrics;
bash: BashMetrics;
api: APIMetrics;
}
interface AgentMemoryMetrics {
startHeapUsed: number; // Memory at agent start (bytes)
currentHeapUsed: number; // Current memory (bytes)
peakHeapUsed: number; // Peak memory during execution (bytes)
deltaHeapUsed: number; // Memory change since start
samples: Array<{ timestamp: number; heapUsed: number }>;
}
interface FileIOMetrics {
reads: number; // Number of file reads
bytesRead: number; // Total bytes read
writes: number; // Number of file writes
bytesWritten: number; // Total bytes written
edits: number; // Number of file edits
globs: number; // Number of glob operations
greps: number; // Number of grep operations
filesAccessed: string[]; // Unique files accessed (max 100)
}
interface ToolUsageMetrics {
totalInvocations: number;
byTool: Record<string, number>; // Invocations per tool name
avgExecutionTime: number; // Average tool execution time (ms)
totalExecutionTime: number; // Total tool execution time (ms)
failedInvocations: number;
}
interface BashMetrics {
commandCount: number;
totalExecutionTime: number;
failedCommands: number;
commands: Array<{
command: string;
exitCode: number | null;
duration: number;
timestamp: number;
}>;
}
interface APIMetrics {
turns: number; // Number of API turns/iterations
inputTokens?: number; // Input tokens used
outputTokens?: number; // Output tokens generated
thinkingTokens?: number; // Thinking tokens used
totalDuration: number; // Total API call duration (ms)
errors: number; // Number of API errors
}
```
### ProcessStatus
- `starting` - Process is starting up
- `running` - Process is actively running
- `idle` - Process is idle/waiting
- `stopping` - Process is shutting down
- `stopped` - Process has stopped normally
- `error` - Process encountered an error
### MemoryTrend
```typescript
interface MemoryTrend {
growthRate: number; // Bytes per second
isLeaking: boolean; // Leak detected flag
confidence: number; // R² value (0-1)
sampleCount: number; // Data points analyzed
windowDuration: number; // Analysis window (ms)
}
```
---
## Events
The debug system emits the following WebSocket events:
| Event | Description |
| -------------------------- | --------------------------------------------------- |
| `debug:metrics` | Periodic metrics snapshot (at `collectionInterval`) |
| `debug:memory-warning` | Memory usage exceeds 70% of heap limit |
| `debug:memory-critical` | Memory usage exceeds 90% of heap limit |
| `debug:leak-detected` | Memory leak pattern detected |
| `debug:process-spawned` | New process registered |
| `debug:process-updated` | Process status changed |
| `debug:process-stopped` | Process stopped normally |
| `debug:process-error` | Process encountered an error |
| `debug:high-cpu` | CPU usage exceeds 80% |
| `debug:event-loop-blocked` | Event loop lag exceeds 100ms |
---
## Usage Example
### Starting metrics collection with custom config
```typescript
// Start with 500ms interval and 120 data points
await fetch('/api/debug/metrics/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
config: {
collectionInterval: 500,
maxDataPoints: 120,
},
}),
});
// Poll for metrics
const response = await fetch('/api/debug/metrics');
const { snapshot } = await response.json();
console.log(`Heap used: ${(snapshot.memory.server.heapUsed / 1024 / 1024).toFixed(1)} MB`);
console.log(`CPU: ${snapshot.cpu.server.percentage.toFixed(1)}%`);
```
### Monitoring for memory leaks
```typescript
const response = await fetch('/api/debug/metrics');
const { snapshot } = await response.json();
if (snapshot.memoryTrend?.isLeaking) {
console.warn(`Memory leak detected!`);
console.warn(`Growth rate: ${snapshot.memoryTrend.growthRate} bytes/s`);
console.warn(`Confidence: ${(snapshot.memoryTrend.confidence * 100).toFixed(0)}%`);
}
```

View File

@@ -800,8 +800,14 @@ export async function getClaudeAuthIndicators(): Promise<ClaudeAuthIndicators> {
const content = await systemPathReadFile(credPath);
const credentials = JSON.parse(content);
result.hasCredentialsFile = true;
// Support multiple credential formats:
// 1. Claude Code CLI format: { claudeAiOauth: { accessToken, refreshToken } }
// 2. Legacy format: { oauth_token } or { access_token }
// 3. API key format: { api_key }
const hasClaudeOauth = !!credentials.claudeAiOauth?.accessToken;
const hasLegacyOauth = !!(credentials.oauth_token || credentials.access_token);
result.credentials = {
hasOAuthToken: !!(credentials.oauth_token || credentials.access_token),
hasOAuthToken: hasClaudeOauth || hasLegacyOauth,
hasApiKey: !!credentials.api_key,
};
break;

View File

@@ -7,9 +7,7 @@
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"watch": "tsc --watch",
"test": "vitest run",
"test:watch": "vitest"
"watch": "tsc --watch"
},
"keywords": [
"automaker",
@@ -22,7 +20,6 @@
},
"devDependencies": {
"@types/node": "22.19.3",
"typescript": "5.9.3",
"vitest": "4.0.16"
"typescript": "5.9.3"
}
}

View File

@@ -1,711 +0,0 @@
/**
* Debug types for AutoMaker performance monitoring and debugging
*
* This module defines types for:
* - Memory metrics and monitoring
* - CPU metrics and monitoring
* - Process tracking (agents, CLIs, terminals)
* - Component render tracking
* - Debug event streaming
*/
// ============================================================================
// Memory Metrics
// ============================================================================
/**
* Memory metrics from the server (Node.js process)
*/
export interface ServerMemoryMetrics {
/** Total heap size allocated (bytes) */
heapTotal: number;
/** Heap actually used (bytes) */
heapUsed: number;
/** V8 external memory (bytes) - memory used by C++ objects bound to JS */
external: number;
/** Resident Set Size - total memory allocated for the process (bytes) */
rss: number;
/** Array buffers memory (bytes) */
arrayBuffers: number;
}
/**
* Memory metrics from the browser (performance.memory API)
* Note: Only available in Chromium-based browsers with --enable-precise-memory-info flag
*/
export interface BrowserMemoryMetrics {
/** Total JS heap size limit (bytes) */
jsHeapSizeLimit: number;
/** Total allocated heap size (bytes) */
totalJSHeapSize: number;
/** Currently used heap size (bytes) */
usedJSHeapSize: number;
}
/**
* Combined memory metrics snapshot
*/
export interface MemoryMetrics {
/** Timestamp of the measurement */
timestamp: number;
/** Server-side memory metrics (Node.js) */
server?: ServerMemoryMetrics;
/** Browser-side memory metrics */
browser?: BrowserMemoryMetrics;
}
/**
* Memory trend analysis for leak detection
*/
export interface MemoryTrend {
/** Average memory growth rate (bytes/second) */
growthRate: number;
/** Indicates potential memory leak if growth is sustained */
isLeaking: boolean;
/** Confidence level of leak detection (0-1) */
confidence: number;
/** Number of samples used for trend analysis */
sampleCount: number;
/** Duration of trend analysis window (ms) */
windowDuration: number;
}
// ============================================================================
// CPU Metrics
// ============================================================================
/**
* CPU usage metrics from the server
*/
export interface ServerCPUMetrics {
/** CPU usage percentage (0-100) */
percentage: number;
/** User CPU time (microseconds) */
user: number;
/** System CPU time (microseconds) */
system: number;
}
/**
* Combined CPU metrics snapshot
*/
export interface CPUMetrics {
/** Timestamp of the measurement */
timestamp: number;
/** Server CPU metrics */
server?: ServerCPUMetrics;
/** Event loop lag in milliseconds (indicates event loop blocking) */
eventLoopLag?: number;
}
// ============================================================================
// Agent Resource Metrics
// ============================================================================
/**
* File I/O operation type
*/
export type FileIOOperation = 'read' | 'write' | 'edit' | 'delete' | 'glob' | 'grep';
/**
* File I/O metrics for tracking agent file operations
*/
export interface FileIOMetrics {
/** Number of file read operations */
reads: number;
/** Total bytes read */
bytesRead: number;
/** Number of file write operations */
writes: number;
/** Total bytes written */
bytesWritten: number;
/** Number of file edit operations */
edits: number;
/** Number of glob/search operations */
globs: number;
/** Number of grep/content search operations */
greps: number;
/** Files accessed (unique paths) */
filesAccessed: string[];
}
/**
* Tool usage metrics for tracking agent tool invocations
*/
export interface ToolUsageMetrics {
/** Total tool invocations */
totalInvocations: number;
/** Invocations per tool name */
byTool: Record<string, number>;
/** Average tool execution time (ms) */
avgExecutionTime: number;
/** Total tool execution time (ms) */
totalExecutionTime: number;
/** Failed tool invocations */
failedInvocations: number;
}
/**
* Bash command execution metrics
*/
export interface BashMetrics {
/** Number of bash commands executed */
commandCount: number;
/** Total execution time (ms) */
totalExecutionTime: number;
/** Number of failed commands (non-zero exit) */
failedCommands: number;
/** Commands executed (for debugging) */
commands: Array<{
command: string;
exitCode: number | null;
duration: number;
timestamp: number;
}>;
}
/**
* API call metrics for tracking Anthropic API usage
*/
export interface APIMetrics {
/** Number of API turns/iterations */
turns: number;
/** Input tokens used (if available) */
inputTokens?: number;
/** Output tokens generated (if available) */
outputTokens?: number;
/** Thinking tokens used (if available) */
thinkingTokens?: number;
/** Total API call duration (ms) */
totalDuration: number;
/** Number of API errors */
errors: number;
}
/**
* Memory delta tracking for an agent execution
*/
export interface AgentMemoryMetrics {
/** Memory at agent start (bytes) */
startHeapUsed: number;
/** Current/latest memory (bytes) */
currentHeapUsed: number;
/** Peak memory during execution (bytes) */
peakHeapUsed: number;
/** Memory change since start (can be negative) */
deltaHeapUsed: number;
/** Memory samples over time for trend analysis */
samples: Array<{ timestamp: number; heapUsed: number }>;
}
/**
* Comprehensive agent resource metrics
*/
export interface AgentResourceMetrics {
/** Agent/process ID */
agentId: string;
/** Session ID if available */
sessionId?: string;
/** Feature ID if running a feature */
featureId?: string;
/** When metrics collection started */
startedAt: number;
/** When metrics were last updated */
lastUpdatedAt: number;
/** Duration of agent execution (ms) */
duration: number;
/** Memory metrics */
memory: AgentMemoryMetrics;
/** File I/O metrics */
fileIO: FileIOMetrics;
/** Tool usage metrics */
tools: ToolUsageMetrics;
/** Bash command metrics */
bash: BashMetrics;
/** API call metrics */
api: APIMetrics;
/** Whether the agent is still running */
isRunning: boolean;
}
/**
* Create empty agent resource metrics
*/
export function createEmptyAgentResourceMetrics(
agentId: string,
options?: { sessionId?: string; featureId?: string }
): AgentResourceMetrics {
const now = Date.now();
const heapUsed = typeof process !== 'undefined' ? process.memoryUsage().heapUsed : 0;
return {
agentId,
sessionId: options?.sessionId,
featureId: options?.featureId,
startedAt: now,
lastUpdatedAt: now,
duration: 0,
isRunning: true,
memory: {
startHeapUsed: heapUsed,
currentHeapUsed: heapUsed,
peakHeapUsed: heapUsed,
deltaHeapUsed: 0,
samples: [{ timestamp: now, heapUsed }],
},
fileIO: {
reads: 0,
bytesRead: 0,
writes: 0,
bytesWritten: 0,
edits: 0,
globs: 0,
greps: 0,
filesAccessed: [],
},
tools: {
totalInvocations: 0,
byTool: {},
avgExecutionTime: 0,
totalExecutionTime: 0,
failedInvocations: 0,
},
bash: {
commandCount: 0,
totalExecutionTime: 0,
failedCommands: 0,
commands: [],
},
api: {
turns: 0,
totalDuration: 0,
errors: 0,
},
};
}
// ============================================================================
// Process Tracking
// ============================================================================
/**
* Process type enumeration
*/
export type ProcessType = 'agent' | 'cli' | 'terminal' | 'worker';
/**
* Process status enumeration
*/
export type ProcessStatus = 'starting' | 'running' | 'idle' | 'stopping' | 'stopped' | 'error';
/**
* Information about a tracked process
*/
export interface TrackedProcess {
/** Unique identifier for the process */
id: string;
/** Process ID from the operating system */
pid: number;
/** Type of process */
type: ProcessType;
/** Human-readable name/label */
name: string;
/** Current status */
status: ProcessStatus;
/** Timestamp when process was spawned */
startedAt: number;
/** Timestamp when process stopped (if applicable) */
stoppedAt?: number;
/** Memory usage in bytes (if available) */
memoryUsage?: number;
/** CPU usage percentage (if available) */
cpuUsage?: number;
/** Associated feature ID (for agent processes) */
featureId?: string;
/** Associated session ID (for agent processes) */
sessionId?: string;
/** Command that was executed */
command?: string;
/** Working directory */
cwd?: string;
/** Exit code (if process has stopped) */
exitCode?: number;
/** Error message (if process failed) */
error?: string;
/** Detailed resource metrics for agent processes */
resourceMetrics?: AgentResourceMetrics;
}
/**
* Summary of all tracked processes
*/
export interface ProcessSummary {
/** Total number of tracked processes */
total: number;
/** Number of currently running processes */
running: number;
/** Number of idle processes */
idle: number;
/** Number of stopped processes */
stopped: number;
/** Number of errored processes */
errored: number;
/** Breakdown by process type */
byType: Record<ProcessType, number>;
}
// ============================================================================
// Render Tracking
// ============================================================================
/**
* Render phase from React Profiler
*/
export type RenderPhase = 'mount' | 'update' | 'nested-update';
/**
* Information about a component render
*/
export interface ComponentRender {
/** Component name/identifier */
componentName: string;
/** Render phase */
phase: RenderPhase;
/** Actual render duration (ms) */
actualDuration: number;
/** Base render duration (ms) - time to render without memoization */
baseDuration: number;
/** Start time of the render */
startTime: number;
/** Commit time */
commitTime: number;
}
/**
* Aggregated render statistics for a component
*/
export interface ComponentRenderStats {
/** Component name */
componentName: string;
/** Total number of renders in the tracking window */
renderCount: number;
/** Renders per second */
rendersPerSecond: number;
/** Average render duration (ms) */
avgDuration: number;
/** Maximum render duration (ms) */
maxDuration: number;
/** Minimum render duration (ms) */
minDuration: number;
/** Total time spent rendering (ms) */
totalDuration: number;
/** Whether this component exceeds the render threshold */
isHighRender: boolean;
/** Last render timestamp */
lastRenderAt: number;
}
/**
* Render tracking summary
*/
export interface RenderTrackingSummary {
/** Total renders tracked */
totalRenders: number;
/** Number of unique components tracked */
uniqueComponents: number;
/** Components exceeding render threshold */
highRenderComponents: string[];
/** Top 5 most frequently rendered components */
topRenderers: ComponentRenderStats[];
/** Tracking window start time */
windowStart: number;
/** Tracking window duration (ms) */
windowDuration: number;
}
// ============================================================================
// Combined Metrics
// ============================================================================
/**
* Complete debug metrics snapshot
*/
export interface DebugMetricsSnapshot {
/** Timestamp of the snapshot */
timestamp: number;
/** Memory metrics */
memory: MemoryMetrics;
/** CPU metrics */
cpu: CPUMetrics;
/** List of tracked processes */
processes: TrackedProcess[];
/** Process summary */
processSummary: ProcessSummary;
/** Memory trend analysis */
memoryTrend?: MemoryTrend;
}
/**
* Debug metrics configuration
*/
export interface DebugMetricsConfig {
/** Enable memory monitoring */
memoryEnabled: boolean;
/** Enable CPU monitoring */
cpuEnabled: boolean;
/** Enable process tracking */
processTrackingEnabled: boolean;
/** Metrics collection interval (ms) */
collectionInterval: number;
/** Number of data points to retain */
maxDataPoints: number;
/** Memory leak detection threshold (bytes/second sustained growth) */
leakThreshold: number;
}
/**
* Default debug metrics configuration
*/
export const DEFAULT_DEBUG_METRICS_CONFIG: DebugMetricsConfig = {
memoryEnabled: true,
cpuEnabled: true,
processTrackingEnabled: true,
collectionInterval: 1000,
maxDataPoints: 60,
leakThreshold: 1024 * 1024, // 1MB/second sustained growth indicates potential leak
};
// ============================================================================
// Debug Events
// ============================================================================
/**
* Debug event types for real-time streaming
*/
export type DebugEventType =
| 'debug:metrics'
| 'debug:memory-warning'
| 'debug:memory-critical'
| 'debug:leak-detected'
| 'debug:process-spawned'
| 'debug:process-updated'
| 'debug:process-stopped'
| 'debug:process-error'
| 'debug:high-cpu'
| 'debug:event-loop-blocked';
/**
* Base debug event interface
*/
export interface DebugEventBase {
/** Event type */
type: DebugEventType;
/** Event timestamp */
timestamp: number;
}
/**
* Metrics update event
*/
export interface DebugMetricsEvent extends DebugEventBase {
type: 'debug:metrics';
/** The metrics snapshot */
metrics: DebugMetricsSnapshot;
}
/**
* Memory warning event (heap usage exceeds threshold)
*/
export interface DebugMemoryWarningEvent extends DebugEventBase {
type: 'debug:memory-warning' | 'debug:memory-critical';
/** Current memory usage */
memory: MemoryMetrics;
/** Usage percentage */
usagePercent: number;
/** Threshold that was exceeded */
threshold: number;
/** Warning message */
message: string;
}
/**
* Memory leak detected event
*/
export interface DebugLeakDetectedEvent extends DebugEventBase {
type: 'debug:leak-detected';
/** Memory trend analysis */
trend: MemoryTrend;
/** Warning message */
message: string;
}
/**
* Process lifecycle events
*/
export interface DebugProcessEvent extends DebugEventBase {
type:
| 'debug:process-spawned'
| 'debug:process-updated'
| 'debug:process-stopped'
| 'debug:process-error';
/** Process information */
process: TrackedProcess;
/** Additional message */
message?: string;
}
/**
* High CPU usage event
*/
export interface DebugHighCPUEvent extends DebugEventBase {
type: 'debug:high-cpu';
/** CPU metrics */
cpu: CPUMetrics;
/** Usage percentage */
usagePercent: number;
/** Threshold that was exceeded */
threshold: number;
/** Warning message */
message: string;
}
/**
* Event loop blocked event
*/
export interface DebugEventLoopBlockedEvent extends DebugEventBase {
type: 'debug:event-loop-blocked';
/** Event loop lag in milliseconds */
lag: number;
/** Threshold that was exceeded */
threshold: number;
/** Warning message */
message: string;
}
/**
* Union type of all debug events
*/
export type DebugEvent =
| DebugMetricsEvent
| DebugMemoryWarningEvent
| DebugLeakDetectedEvent
| DebugProcessEvent
| DebugHighCPUEvent
| DebugEventLoopBlockedEvent;
// ============================================================================
// API Types
// ============================================================================
/**
* Request to start debug metrics collection
*/
export interface StartDebugMetricsRequest {
/** Configuration overrides */
config?: Partial<DebugMetricsConfig>;
}
/**
* Response from debug metrics endpoint
*/
export interface DebugMetricsResponse {
/** Whether metrics collection is active */
active: boolean;
/** Current configuration */
config: DebugMetricsConfig;
/** Latest metrics snapshot */
snapshot?: DebugMetricsSnapshot;
}
/**
* Request to get process list
*/
export interface GetProcessesRequest {
/** Filter by process type */
type?: ProcessType;
/** Filter by status */
status?: ProcessStatus;
/** Include stopped processes */
includeStoppedProcesses?: boolean;
}
/**
* Response from process list endpoint
*/
export interface GetProcessesResponse {
/** List of processes */
processes: TrackedProcess[];
/** Summary statistics */
summary: ProcessSummary;
}
// ============================================================================
// Utility Types
// ============================================================================
/**
* Circular buffer entry for time-series data
*/
export interface TimeSeriesDataPoint<T> {
/** Timestamp */
timestamp: number;
/** Data value */
value: T;
}
/**
* Memory data point for charts
*/
export interface MemoryDataPoint {
timestamp: number;
heapUsed: number;
heapTotal: number;
rss?: number;
}
/**
* CPU data point for charts
*/
export interface CPUDataPoint {
timestamp: number;
percentage: number;
eventLoopLag?: number;
}
/**
* Format bytes to human-readable string
* @param bytes - Number of bytes (can be negative for rate display)
* @returns Formatted string (e.g., "1.5 MB")
*/
export function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const absBytes = Math.abs(bytes);
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(absBytes) / Math.log(k));
const sign = bytes < 0 ? '-' : '';
return `${sign}${parseFloat((absBytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
/**
* Format duration to human-readable string
* @param ms - Duration in milliseconds
* @returns Formatted string (e.g., "1.5s", "150ms")
*/
export function formatDuration(ms: number): string {
if (ms < 1) return `${(ms * 1000).toFixed(0)}µs`;
if (ms < 1000) return `${ms.toFixed(1)}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${(ms / 60000).toFixed(1)}m`;
}
/**
* Calculate percentage with bounds
* @param value - Current value
* @param total - Total/max value
* @returns Percentage (0-100)
*/
export function calculatePercentage(value: number, total: number): number {
if (total === 0) return 0;
return Math.min(100, Math.max(0, (value / total) * 100));
}

View File

@@ -39,17 +39,6 @@ export type EventType =
| 'ideation:idea-created'
| 'ideation:idea-updated'
| 'ideation:idea-deleted'
| 'ideation:idea-converted'
// Debug events
| 'debug:metrics'
| 'debug:memory-warning'
| 'debug:memory-critical'
| 'debug:leak-detected'
| 'debug:process-spawned'
| 'debug:process-updated'
| 'debug:process-stopped'
| 'debug:process-error'
| 'debug:high-cpu'
| 'debug:event-loop-blocked';
| 'ideation:idea-converted';
export type EventCallback = (type: EventType, payload: unknown) => void;

View File

@@ -194,62 +194,3 @@ export type {
IdeationStreamEvent,
IdeationAnalysisEvent,
} from './ideation.js';
// Debug types
export type {
// Memory metrics
ServerMemoryMetrics,
BrowserMemoryMetrics,
MemoryMetrics,
MemoryTrend,
// CPU metrics
ServerCPUMetrics,
CPUMetrics,
// Agent resource metrics
FileIOOperation,
FileIOMetrics,
ToolUsageMetrics,
BashMetrics,
APIMetrics,
AgentMemoryMetrics,
AgentResourceMetrics,
// Process tracking
ProcessType,
ProcessStatus,
TrackedProcess,
ProcessSummary,
// Render tracking
RenderPhase,
ComponentRender,
ComponentRenderStats,
RenderTrackingSummary,
// Combined metrics
DebugMetricsSnapshot,
DebugMetricsConfig,
// Events
DebugEventType,
DebugEventBase,
DebugMetricsEvent,
DebugMemoryWarningEvent,
DebugLeakDetectedEvent,
DebugProcessEvent,
DebugHighCPUEvent,
DebugEventLoopBlockedEvent,
DebugEvent,
// API types
StartDebugMetricsRequest,
DebugMetricsResponse,
GetProcessesRequest,
GetProcessesResponse,
// Utility types
TimeSeriesDataPoint,
MemoryDataPoint,
CPUDataPoint,
} from './debug.js';
export {
DEFAULT_DEBUG_METRICS_CONFIG,
formatBytes,
formatDuration,
calculatePercentage,
createEmptyAgentResourceMetrics,
} from './debug.js';

View File

@@ -212,8 +212,6 @@ export interface KeyboardShortcuts {
splitTerminalDown: string;
/** Close current terminal */
closeTerminal: string;
/** Toggle debug panel (dev only) */
toggleDebugPanel: string;
}
/**
@@ -640,7 +638,6 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
splitTerminalRight: 'Alt+D',
splitTerminalDown: 'Alt+S',
closeTerminal: 'Alt+W',
toggleDebugPanel: 'Cmd+Shift+D',
};
/** Default global settings used when no settings file exists */

View File

@@ -1,122 +0,0 @@
import { describe, it, expect } from 'vitest';
import { formatBytes, formatDuration, calculatePercentage } from '../src/debug';
describe('debug.ts utility functions', () => {
describe('formatBytes', () => {
it('should return "0 B" for zero bytes', () => {
expect(formatBytes(0)).toBe('0 B');
});
it('should format bytes correctly', () => {
expect(formatBytes(1)).toBe('1 B');
expect(formatBytes(500)).toBe('500 B');
expect(formatBytes(1023)).toBe('1023 B');
});
it('should format kilobytes correctly', () => {
expect(formatBytes(1024)).toBe('1 KB');
expect(formatBytes(1536)).toBe('1.5 KB');
expect(formatBytes(10240)).toBe('10 KB');
});
it('should format megabytes correctly', () => {
expect(formatBytes(1024 * 1024)).toBe('1 MB');
expect(formatBytes(1.5 * 1024 * 1024)).toBe('1.5 MB');
expect(formatBytes(100 * 1024 * 1024)).toBe('100 MB');
});
it('should format gigabytes correctly', () => {
expect(formatBytes(1024 * 1024 * 1024)).toBe('1 GB');
expect(formatBytes(2.5 * 1024 * 1024 * 1024)).toBe('2.5 GB');
});
it('should format terabytes correctly', () => {
expect(formatBytes(1024 * 1024 * 1024 * 1024)).toBe('1 TB');
});
it('should handle negative values for rate display', () => {
expect(formatBytes(-1024)).toBe('-1 KB');
expect(formatBytes(-1.5 * 1024 * 1024)).toBe('-1.5 MB');
});
it('should round to 2 decimal places', () => {
expect(formatBytes(1536)).toBe('1.5 KB');
expect(formatBytes(1537)).toBe('1.5 KB');
expect(formatBytes(1024 + 512 + 256)).toBe('1.75 KB');
});
});
describe('formatDuration', () => {
it('should format microseconds for sub-millisecond values', () => {
expect(formatDuration(0.001)).toBe('1µs');
expect(formatDuration(0.5)).toBe('500µs');
expect(formatDuration(0.999)).toBe('999µs');
});
it('should format milliseconds for values under 1 second', () => {
expect(formatDuration(1)).toBe('1.0ms');
expect(formatDuration(100)).toBe('100.0ms');
expect(formatDuration(999)).toBe('999.0ms');
expect(formatDuration(500.5)).toBe('500.5ms');
});
it('should format seconds for values under 1 minute', () => {
expect(formatDuration(1000)).toBe('1.0s');
expect(formatDuration(1500)).toBe('1.5s');
expect(formatDuration(59999)).toBe('60.0s');
});
it('should format minutes for values >= 1 minute', () => {
expect(formatDuration(60000)).toBe('1.0m');
expect(formatDuration(90000)).toBe('1.5m');
expect(formatDuration(120000)).toBe('2.0m');
});
it('should handle edge case of exactly 1 millisecond', () => {
expect(formatDuration(1)).toBe('1.0ms');
});
it('should handle zero duration', () => {
expect(formatDuration(0)).toBe('0µs');
});
});
describe('calculatePercentage', () => {
it('should return 0 when total is 0', () => {
expect(calculatePercentage(50, 0)).toBe(0);
expect(calculatePercentage(0, 0)).toBe(0);
});
it('should calculate correct percentage', () => {
expect(calculatePercentage(50, 100)).toBe(50);
expect(calculatePercentage(25, 100)).toBe(25);
expect(calculatePercentage(75, 100)).toBe(75);
});
it('should handle decimal percentages', () => {
expect(calculatePercentage(1, 3)).toBeCloseTo(33.33, 1);
expect(calculatePercentage(1, 7)).toBeCloseTo(14.29, 1);
});
it('should cap at 100%', () => {
expect(calculatePercentage(150, 100)).toBe(100);
expect(calculatePercentage(200, 100)).toBe(100);
});
it('should floor at 0%', () => {
expect(calculatePercentage(-50, 100)).toBe(0);
expect(calculatePercentage(-100, 100)).toBe(0);
});
it('should handle very small values', () => {
expect(calculatePercentage(0.001, 100)).toBeCloseTo(0.001, 3);
});
it('should handle negative totals correctly', () => {
// With negative total, the result can be unexpected but should be bounded
const result = calculatePercentage(50, -100);
expect(result).toBeGreaterThanOrEqual(0);
expect(result).toBeLessThanOrEqual(100);
});
});
});

View File

@@ -1,16 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
name: 'types',
globals: true,
environment: 'node',
include: ['tests/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.d.ts', 'src/index.ts'],
},
},
});

4
package-lock.json generated
View File

@@ -29,7 +29,7 @@
},
"apps/server": {
"name": "@automaker/server",
"version": "0.7.3",
"version": "0.8.0",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "0.1.76",
@@ -79,7 +79,7 @@
},
"apps/ui": {
"name": "@automaker/ui",
"version": "0.7.3",
"version": "0.8.0",
"hasInstallScript": true,
"license": "SEE LICENSE IN LICENSE",
"dependencies": {

View File

@@ -26,7 +26,7 @@
"dev:electron:wsl": "npm run build:packages && npm run _dev:electron:wsl",
"dev:electron:wsl:gpu": "npm run build:packages && npm run _dev:electron:wsl:gpu",
"dev:server": "npm run build:packages && npm run _dev:server",
"dev:docker": "docker compose up --build",
"dev:docker": "docker compose up",
"dev:full": "npm run build:packages && concurrently \"npm run _dev:server\" \"npm run _dev:web\"",
"build": "npm run build:packages && npm run build --workspace=apps/ui",
"build:packages": "npm run build -w @automaker/types && npm run build -w @automaker/platform && npm run build -w @automaker/utils && npm run build -w @automaker/prompts -w @automaker/model-resolver -w @automaker/dependency-resolver && npm run build -w @automaker/git-utils",

34
scripts/get-claude-token.sh Executable file
View File

@@ -0,0 +1,34 @@
#!/bin/bash
# Extract Claude OAuth token from macOS Keychain for use in Docker container
# Usage: ./scripts/get-claude-token.sh
# or: export CLAUDE_OAUTH_TOKEN=$(./scripts/get-claude-token.sh)
set -e
# Only works on macOS (uses security command for Keychain access)
if [[ "$OSTYPE" != "darwin"* ]]; then
echo "Error: This script only works on macOS." >&2
echo "On Linux, mount ~/.claude directory directly instead." >&2
exit 1
fi
# Check if security command exists
if ! command -v security &> /dev/null; then
echo "Error: 'security' command not found." >&2
exit 1
fi
# Get the current username
USERNAME=$(whoami)
# Extract credentials from Keychain
CREDS=$(security find-generic-password -s "Claude Code-credentials" -a "$USERNAME" -w 2>/dev/null)
if [ -z "$CREDS" ]; then
echo "Error: No Claude credentials found in Keychain." >&2
echo "Make sure you've logged in with 'claude login' first." >&2
exit 1
fi
# Output the full credentials JSON (contains accessToken and refreshToken)
echo "$CREDS"

69
scripts/get-cursor-token.sh Executable file
View File

@@ -0,0 +1,69 @@
#!/bin/bash
# Extract Cursor CLI OAuth token from host machine for use in Docker container
#
# IMPORTANT: This extracts the cursor-agent CLI OAuth token, NOT the Cursor IDE token.
# cursor-agent stores tokens in macOS Keychain (not SQLite like the IDE).
#
# Usage: ./scripts/get-cursor-token.sh
# or: export CURSOR_AUTH_TOKEN=$(./scripts/get-cursor-token.sh)
#
# For Docker: echo "CURSOR_AUTH_TOKEN=$(./scripts/get-cursor-token.sh)" >> .env
set -e
# Determine platform and extract token accordingly
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS: cursor-agent stores OAuth tokens in Keychain
# Service: cursor-access-token, Account: cursor-user
if ! command -v security &> /dev/null; then
echo "Error: 'security' command not found." >&2
exit 1
fi
# Extract access token from Keychain
TOKEN=$(security find-generic-password -a "cursor-user" -s "cursor-access-token" -w 2>/dev/null)
if [ -z "$TOKEN" ]; then
echo "Error: No Cursor CLI token found in Keychain." >&2
echo "Make sure you've logged in with 'cursor-agent login' first." >&2
exit 1
fi
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
# Linux: cursor-agent stores OAuth tokens in a JSON file
# Default location: ~/.config/cursor/auth.json
# Or: $XDG_CONFIG_HOME/cursor/auth.json
if [ -n "$XDG_CONFIG_HOME" ]; then
AUTH_FILE="$XDG_CONFIG_HOME/cursor/auth.json"
else
AUTH_FILE="$HOME/.config/cursor/auth.json"
fi
if [ ! -f "$AUTH_FILE" ]; then
echo "Error: Cursor auth file not found at: $AUTH_FILE" >&2
echo "Make sure you've logged in with 'cursor-agent login' first." >&2
exit 1
fi
# Check if jq is available
if ! command -v jq &> /dev/null; then
echo "Error: jq is required but not installed." >&2
echo "Install it with: apt install jq" >&2
exit 1
fi
TOKEN=$(jq -r '.accessToken // empty' "$AUTH_FILE" 2>/dev/null)
if [ -z "$TOKEN" ]; then
echo "Error: No access token found in $AUTH_FILE" >&2
exit 1
fi
else
echo "Error: Unsupported platform: $OSTYPE" >&2
exit 1
fi
# Output the token
echo "$TOKEN"

View File

@@ -13,7 +13,7 @@
*/
import { execSync } from 'child_process';
import fsNative from 'fs';
import fsNative, { statSync } from 'fs';
import http from 'http';
import path from 'path';
import readline from 'readline';
@@ -662,3 +662,142 @@ export async function ensureDependencies(fs, baseDir) {
});
}
}
// =============================================================================
// Docker Utilities
// =============================================================================
/**
* Sanitize a project name to be safe for use in shell commands and Docker image names.
* Converts to lowercase and removes any characters that aren't alphanumeric.
* @param {string} name - Project name to sanitize
* @returns {string} - Sanitized project name
*/
export function sanitizeProjectName(name) {
return name.toLowerCase().replace(/[^a-z0-9]/g, '');
}
/**
* Check if Docker images need to be rebuilt based on Dockerfile or package.json changes
* @param {string} baseDir - Base directory containing Dockerfile and package.json
* @returns {boolean} - Whether images need to be rebuilt
*/
export function shouldRebuildDockerImages(baseDir) {
try {
const dockerfilePath = path.join(baseDir, 'Dockerfile');
const packageJsonPath = path.join(baseDir, 'package.json');
// Get modification times of source files
const dockerfileMtime = statSync(dockerfilePath).mtimeMs;
const packageJsonMtime = statSync(packageJsonPath).mtimeMs;
const latestSourceMtime = Math.max(dockerfileMtime, packageJsonMtime);
// Get project name from docker-compose config, falling back to directory name
let projectName;
try {
const composeConfig = execSync('docker compose config --format json', {
encoding: 'utf-8',
cwd: baseDir,
});
const config = JSON.parse(composeConfig);
projectName = config.name;
} catch (error) {
// Fallback handled below
}
// Sanitize project name (whether from config or fallback)
// This prevents command injection and ensures valid Docker image names
const sanitizedProjectName = sanitizeProjectName(projectName || path.basename(baseDir));
const serverImageName = `${sanitizedProjectName}_server`;
const uiImageName = `${sanitizedProjectName}_ui`;
// Check if images exist and get their creation times
let needsRebuild = false;
try {
// Check server image
const serverImageInfo = execSync(
`docker image inspect ${serverImageName} --format "{{.Created}}" 2>/dev/null || echo ""`,
{ encoding: 'utf-8', cwd: baseDir }
).trim();
// Check UI image
const uiImageInfo = execSync(
`docker image inspect ${uiImageName} --format "{{.Created}}" 2>/dev/null || echo ""`,
{ encoding: 'utf-8', cwd: baseDir }
).trim();
// If either image doesn't exist, we need to rebuild
if (!serverImageInfo || !uiImageInfo) {
return true;
}
// Parse image creation times (ISO 8601 format)
const serverCreated = new Date(serverImageInfo).getTime();
const uiCreated = new Date(uiImageInfo).getTime();
const oldestImageTime = Math.min(serverCreated, uiCreated);
// If source files are newer than images, rebuild
needsRebuild = latestSourceMtime > oldestImageTime;
} catch (error) {
// If images don't exist or inspect fails, rebuild
needsRebuild = true;
}
return needsRebuild;
} catch (error) {
// If we can't check, err on the side of rebuilding
log('Could not check Docker image status, will rebuild to be safe', 'yellow');
return true;
}
}
/**
* Launch Docker containers with docker-compose
* @param {object} options - Configuration options
* @param {string} options.baseDir - Base directory containing docker-compose.yml
* @param {object} options.processes - Processes object to track docker process
* @returns {Promise<void>}
*/
export async function launchDockerContainers({ baseDir, processes }) {
log('Launching Docker Container (Isolated Mode)...', 'blue');
// Check if Dockerfile or package.json changed and rebuild if needed
const needsRebuild = shouldRebuildDockerImages(baseDir);
const buildFlag = needsRebuild ? ['--build'] : [];
if (needsRebuild) {
log('Dockerfile or package.json changed - rebuilding images...', 'yellow');
} else {
log('Starting Docker containers...', 'yellow');
}
console.log('');
// Check if ANTHROPIC_API_KEY is set
if (!process.env.ANTHROPIC_API_KEY) {
log('Warning: ANTHROPIC_API_KEY environment variable is not set.', 'yellow');
log('The server will require an API key to function.', 'yellow');
log('Set it with: export ANTHROPIC_API_KEY=your-key', 'yellow');
console.log('');
}
// Start containers with docker-compose
// Will rebuild if Dockerfile or package.json changed
processes.docker = crossSpawn('docker', ['compose', 'up', ...buildFlag], {
stdio: 'inherit',
cwd: baseDir,
env: {
...process.env,
},
});
log('Docker containers starting...', 'blue');
log('UI will be available at: http://localhost:3007', 'green');
log('API will be available at: http://localhost:3008', 'green');
console.log('');
log('Press Ctrl+C to stop the containers.', 'yellow');
await new Promise((resolve) => {
processes.docker.on('close', resolve);
});
}

View File

@@ -18,11 +18,9 @@
import path from 'path';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';
import {
createRestrictedFs,
log,
runNpm,
runNpmAndWait,
runNpx,
printHeader,
@@ -35,11 +33,9 @@ import {
prompt,
killProcessTree,
sleep,
launchDockerContainers,
} from './scripts/launcher-utils.mjs';
const require = createRequire(import.meta.url);
const crossSpawn = require('cross-spawn');
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -230,37 +226,7 @@ async function main() {
break;
} else if (choice === '3') {
console.log('');
log('Launching Docker Container (Isolated Mode)...', 'blue');
log('Building and starting Docker containers...', 'yellow');
console.log('');
// Check if ANTHROPIC_API_KEY is set
if (!process.env.ANTHROPIC_API_KEY) {
log('Warning: ANTHROPIC_API_KEY environment variable is not set.', 'yellow');
log('The server will require an API key to function.', 'yellow');
log('Set it with: export ANTHROPIC_API_KEY=your-key', 'yellow');
console.log('');
}
// Build and start containers with docker-compose
processes.docker = crossSpawn('docker', ['compose', 'up', '--build'], {
stdio: 'inherit',
cwd: __dirname,
env: {
...process.env,
},
});
log('Docker containers starting...', 'blue');
log('UI will be available at: http://localhost:3007', 'green');
log('API will be available at: http://localhost:3008', 'green');
console.log('');
log('Press Ctrl+C to stop the containers.', 'yellow');
await new Promise((resolve) => {
processes.docker.on('close', resolve);
});
await launchDockerContainers({ baseDir: __dirname, processes });
break;
} else {
log('Invalid choice. Please enter 1, 2, or 3.', 'red');