feat: add dev server log panel with real-time streaming

Add the ability to view dev server logs in a dedicated panel with:
- Real-time log streaming via WebSocket events
- ANSI color support using xterm.js
- Scrollback buffer (50KB) for log history on reconnect
- Output throttling to prevent UI flooding
- "View Logs" option in worktree dropdown menu

Server changes:
- Add scrollback buffer and event emission to DevServerService
- Add GET /api/worktree/dev-server-logs endpoint
- Add dev-server:started, dev-server:output, dev-server:stopped events

UI changes:
- Add reusable XtermLogViewer component
- Add DevServerLogsPanel dialog component
- Add useDevServerLogs hook for WebSocket subscription

Closes #462

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Shirone
2026-01-13 20:42:57 +01:00
parent 7ef525effa
commit 073f6d5793
17 changed files with 1277 additions and 30 deletions

View File

@@ -67,6 +67,7 @@ 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 { getDevServerService } from './services/dev-server-service.js';
// Load environment variables
dotenv.config();
@@ -176,6 +177,10 @@ const codexUsageService = new CodexUsageService(codexAppServerService);
const mcpTestService = new MCPTestService(settingsService);
const ideationService = new IdeationService(events, settingsService, featureLoader);
// Initialize DevServerService with event emitter for real-time log streaming
const devServerService = getDevServerService();
devServerService.setEventEmitter(events);
// Initialize services
(async () => {
await agentService.initialize();

View File

@@ -8,12 +8,28 @@ import type { Request, Response, NextFunction } from 'express';
import { validatePath, PathNotAllowedError } from '@automaker/platform';
/**
* Creates a middleware that validates specified path parameters in req.body
* Helper to get parameter value from request (checks body first, then query)
*/
function getParamValue(req: Request, paramName: string): unknown {
// Check body first (for POST/PUT/PATCH requests)
if (req.body && req.body[paramName] !== undefined) {
return req.body[paramName];
}
// Fall back to query params (for GET requests)
if (req.query && req.query[paramName] !== undefined) {
return req.query[paramName];
}
return undefined;
}
/**
* Creates a middleware that validates specified path parameters in req.body or req.query
* @param paramNames - Names of parameters to validate (e.g., 'projectPath', 'worktreePath')
* @example
* router.post('/create', validatePathParams('projectPath'), handler);
* router.post('/delete', validatePathParams('projectPath', 'worktreePath'), handler);
* router.post('/send', validatePathParams('workingDirectory?', 'imagePaths[]'), handler);
* router.get('/logs', validatePathParams('worktreePath'), handler); // Works with query params too
*
* Special syntax:
* - 'paramName?' - Optional parameter (only validated if present)
@@ -26,8 +42,8 @@ export function validatePathParams(...paramNames: string[]) {
// Handle optional parameters (paramName?)
if (paramName.endsWith('?')) {
const actualName = paramName.slice(0, -1);
const value = req.body[actualName];
if (value) {
const value = getParamValue(req, actualName);
if (value && typeof value === 'string') {
validatePath(value);
}
continue;
@@ -36,18 +52,20 @@ export function validatePathParams(...paramNames: string[]) {
// Handle array parameters (paramName[])
if (paramName.endsWith('[]')) {
const actualName = paramName.slice(0, -2);
const values = req.body[actualName];
const values = getParamValue(req, actualName);
if (Array.isArray(values) && values.length > 0) {
for (const value of values) {
validatePath(value);
if (typeof value === 'string') {
validatePath(value);
}
}
}
continue;
}
// Handle regular parameters
const value = req.body[paramName];
if (value) {
const value = getParamValue(req, paramName);
if (value && typeof value === 'string') {
validatePath(value);
}
}

View File

@@ -33,6 +33,7 @@ import { createMigrateHandler } from './routes/migrate.js';
import { createStartDevHandler } from './routes/start-dev.js';
import { createStopDevHandler } from './routes/stop-dev.js';
import { createListDevServersHandler } from './routes/list-dev-servers.js';
import { createGetDevServerLogsHandler } from './routes/dev-server-logs.js';
import {
createGetInitScriptHandler,
createPutInitScriptHandler,
@@ -97,6 +98,11 @@ export function createWorktreeRoutes(events: EventEmitter): Router {
);
router.post('/stop-dev', createStopDevHandler());
router.post('/list-dev-servers', createListDevServersHandler());
router.get(
'/dev-server-logs',
validatePathParams('worktreePath'),
createGetDevServerLogsHandler()
);
// Init script routes
router.get('/init-script', createGetInitScriptHandler());

View File

@@ -0,0 +1,52 @@
/**
* GET /dev-server-logs endpoint - Get buffered logs for a worktree's dev server
*
* Returns the scrollback buffer containing historical log output for a running
* dev server. Used by clients to populate the log panel on initial connection
* before subscribing to real-time updates via WebSocket.
*/
import type { Request, Response } from 'express';
import { getDevServerService } from '../../../services/dev-server-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createGetDevServerLogsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath } = req.query as {
worktreePath?: string;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath query parameter is required',
});
return;
}
const devServerService = getDevServerService();
const result = devServerService.getServerLogs(worktreePath);
if (result.success && result.result) {
res.json({
success: true,
result: {
worktreePath: result.result.worktreePath,
port: result.result.port,
logs: result.result.logs,
startedAt: result.result.startedAt,
},
});
} else {
res.status(404).json({
success: false,
error: result.error || 'Failed to get dev server logs',
});
}
} catch (error) {
logError(error, 'Get dev server logs failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -12,24 +12,123 @@ import * as secureFs from '../lib/secure-fs.js';
import path from 'path';
import net from 'net';
import { createLogger } from '@automaker/utils';
import type { EventEmitter } from '../lib/events.js';
const logger = createLogger('DevServerService');
// Maximum scrollback buffer size (characters) - matches TerminalService pattern
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per dev server
// Throttle output to prevent overwhelming WebSocket under heavy load
const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive feedback
const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency
export interface DevServerInfo {
worktreePath: string;
port: number;
url: string;
process: ChildProcess | null;
startedAt: Date;
// Scrollback buffer for log history (replay on reconnect)
scrollbackBuffer: string;
// Pending output to be flushed to subscribers
outputBuffer: string;
// Throttle timer for batching output
flushTimeout: NodeJS.Timeout | null;
// Flag to indicate server is stopping (prevents output after stop)
stopping: boolean;
}
// Port allocation starts at 3001 to avoid conflicts with common dev ports
const BASE_PORT = 3001;
const MAX_PORT = 3099; // Safety limit
// Common livereload ports that may need cleanup when stopping dev servers
const LIVERELOAD_PORTS = [35729, 35730, 35731] as const;
class DevServerService {
private runningServers: Map<string, DevServerInfo> = new Map();
private allocatedPorts: Set<number> = new Set();
private emitter: EventEmitter | null = null;
/**
* Set the event emitter for streaming log events
* Called during service initialization with the global event emitter
*/
setEventEmitter(emitter: EventEmitter): void {
this.emitter = emitter;
}
/**
* Append data to scrollback buffer with size limit enforcement
* Evicts oldest data when buffer exceeds MAX_SCROLLBACK_SIZE
*/
private appendToScrollback(server: DevServerInfo, data: string): void {
server.scrollbackBuffer += data;
if (server.scrollbackBuffer.length > MAX_SCROLLBACK_SIZE) {
server.scrollbackBuffer = server.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE);
}
}
/**
* Flush buffered output to WebSocket subscribers
* Sends batched output to prevent overwhelming clients under heavy load
*/
private flushOutput(server: DevServerInfo): void {
// Skip flush if server is stopping or buffer is empty
if (server.stopping || server.outputBuffer.length === 0) {
server.flushTimeout = null;
return;
}
let dataToSend = server.outputBuffer;
if (dataToSend.length > OUTPUT_BATCH_SIZE) {
// Send in batches if buffer is large
dataToSend = server.outputBuffer.slice(0, OUTPUT_BATCH_SIZE);
server.outputBuffer = server.outputBuffer.slice(OUTPUT_BATCH_SIZE);
// Schedule another flush for remaining data
server.flushTimeout = setTimeout(() => this.flushOutput(server), OUTPUT_THROTTLE_MS);
} else {
server.outputBuffer = '';
server.flushTimeout = null;
}
// Emit output event for WebSocket streaming
if (this.emitter) {
this.emitter.emit('dev-server:output', {
worktreePath: server.worktreePath,
content: dataToSend,
timestamp: new Date().toISOString(),
});
}
}
/**
* Handle incoming stdout/stderr data from dev server process
* Buffers data for scrollback replay and schedules throttled emission
*/
private handleProcessOutput(server: DevServerInfo, data: Buffer): void {
// Skip output if server is stopping
if (server.stopping) {
return;
}
const content = data.toString();
// Append to scrollback buffer for replay on reconnect
this.appendToScrollback(server, content);
// Buffer output for throttled live delivery
server.outputBuffer += content;
// Schedule flush if not already scheduled
if (!server.flushTimeout) {
server.flushTimeout = setTimeout(() => this.flushOutput(server), OUTPUT_THROTTLE_MS);
}
// Also log for debugging (existing behavior)
logger.debug(`[Port${server.port}] ${content.trim()}`);
}
/**
* Check if a port is available (not in use by system or by us)
@@ -244,10 +343,9 @@ class DevServerService {
// Reserve the port (port was already force-killed in findAvailablePort)
this.allocatedPorts.add(port);
// Also kill common related ports (livereload uses 35729 by default)
// Also kill common related ports (livereload, etc.)
// Some dev servers use fixed ports for HMR/livereload regardless of main port
const commonRelatedPorts = [35729, 35730, 35731];
for (const relatedPort of commonRelatedPorts) {
for (const relatedPort of LIVERELOAD_PORTS) {
this.killProcessOnPort(relatedPort);
}
@@ -259,9 +357,14 @@ class DevServerService {
logger.debug(`Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`);
// Spawn the dev process with PORT environment variable
// FORCE_COLOR enables colored output even when not running in a TTY
const env = {
...process.env,
PORT: String(port),
FORCE_COLOR: '1',
// Some tools use these additional env vars for color detection
COLORTERM: 'truecolor',
TERM: 'xterm-256color',
};
const devProcess = spawn(devCommand.cmd, devCommand.args, {
@@ -274,32 +377,66 @@ class DevServerService {
// Track if process failed early using object to work around TypeScript narrowing
const status = { error: null as string | null, exited: false };
// Log output for debugging
// Create server info early so we can reference it in handlers
// We'll add it to runningServers after verifying the process started successfully
const serverInfo: DevServerInfo = {
worktreePath,
port,
url: `http://localhost:${port}`,
process: devProcess,
startedAt: new Date(),
scrollbackBuffer: '',
outputBuffer: '',
flushTimeout: null,
stopping: false,
};
// Capture stdout with buffer management and event emission
if (devProcess.stdout) {
devProcess.stdout.on('data', (data: Buffer) => {
logger.debug(`[Port${port}] ${data.toString().trim()}`);
this.handleProcessOutput(serverInfo, data);
});
}
// Capture stderr with buffer management and event emission
if (devProcess.stderr) {
devProcess.stderr.on('data', (data: Buffer) => {
const msg = data.toString().trim();
logger.debug(`[Port${port}] ${msg}`);
this.handleProcessOutput(serverInfo, data);
});
}
// Helper to clean up resources and emit stop event
const cleanupAndEmitStop = (exitCode: number | null, errorMessage?: string) => {
if (serverInfo.flushTimeout) {
clearTimeout(serverInfo.flushTimeout);
serverInfo.flushTimeout = null;
}
// Emit stopped event (only if not already stopping - prevents duplicate events)
if (this.emitter && !serverInfo.stopping) {
this.emitter.emit('dev-server:stopped', {
worktreePath,
port,
exitCode,
error: errorMessage,
timestamp: new Date().toISOString(),
});
}
this.allocatedPorts.delete(port);
this.runningServers.delete(worktreePath);
};
devProcess.on('error', (error) => {
logger.error(`Process error:`, error);
status.error = error.message;
this.allocatedPorts.delete(port);
this.runningServers.delete(worktreePath);
cleanupAndEmitStop(null, error.message);
});
devProcess.on('exit', (code) => {
logger.info(`Process for ${worktreePath} exited with code ${code}`);
status.exited = true;
this.allocatedPorts.delete(port);
this.runningServers.delete(worktreePath);
cleanupAndEmitStop(code);
});
// Wait a moment to see if the process fails immediately
@@ -319,16 +456,19 @@ class DevServerService {
};
}
const serverInfo: DevServerInfo = {
worktreePath,
port,
url: `http://localhost:${port}`,
process: devProcess,
startedAt: new Date(),
};
// Server started successfully - add to running servers map
this.runningServers.set(worktreePath, serverInfo);
// Emit started event for WebSocket subscribers
if (this.emitter) {
this.emitter.emit('dev-server:started', {
worktreePath,
port,
url: serverInfo.url,
timestamp: new Date().toISOString(),
});
}
return {
success: true,
result: {
@@ -365,6 +505,28 @@ class DevServerService {
logger.info(`Stopping dev server for ${worktreePath}`);
// Mark as stopping to prevent further output events
server.stopping = true;
// Clean up flush timeout to prevent memory leaks
if (server.flushTimeout) {
clearTimeout(server.flushTimeout);
server.flushTimeout = null;
}
// Clear any pending output buffer
server.outputBuffer = '';
// Emit stopped event immediately so UI updates right away
if (this.emitter) {
this.emitter.emit('dev-server:stopped', {
worktreePath,
port: server.port,
exitCode: null, // Will be populated by exit handler if process exits normally
timestamp: new Date().toISOString(),
});
}
// Kill the process
if (server.process && !server.process.killed) {
server.process.kill('SIGTERM');
@@ -422,6 +584,41 @@ class DevServerService {
return this.runningServers.get(worktreePath);
}
/**
* Get buffered logs for a worktree's dev server
* Returns the scrollback buffer containing historical log output
* Used by the API to serve logs to clients on initial connection
*/
getServerLogs(worktreePath: string): {
success: boolean;
result?: {
worktreePath: string;
port: number;
logs: string;
startedAt: string;
};
error?: string;
} {
const server = this.runningServers.get(worktreePath);
if (!server) {
return {
success: false,
error: `No dev server running for worktree: ${worktreePath}`,
};
}
return {
success: true,
result: {
worktreePath: server.worktreePath,
port: server.port,
logs: server.scrollbackBuffer,
startedAt: server.startedAt.toISOString(),
},
};
}
/**
* Get all allocated ports
*/