From bec46aa234b5534585df41afe65e3d01806fec02 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 30 Jan 2026 13:22:35 +0100 Subject: [PATCH] feat(01-05): add HTTP server with health endpoint and PID file - CoordinationServer class using node:http - GET /health returns status, uptime, processCount - GET /status returns full server state and process list - PID file at ~/.cw/server.pid prevents duplicate servers - CLI --server flag and --port option for server mode - CW_PORT env var support for custom port --- src/bin/cw.ts | 11 +- src/cli/index.ts | 90 ++++++++++++++- src/server/index.ts | 275 ++++++++++++++++++++++++++++++++++++++++++++ src/server/types.ts | 59 ++++++++++ 4 files changed, 429 insertions(+), 6 deletions(-) create mode 100644 src/server/index.ts create mode 100644 src/server/types.ts diff --git a/src/bin/cw.ts b/src/bin/cw.ts index d78f09f..146fe11 100644 --- a/src/bin/cw.ts +++ b/src/bin/cw.ts @@ -7,9 +7,7 @@ * - npm i -g codewalk-district (for release) */ -import { createCli } from '../cli/index.js'; - -const program = createCli(); +import { runCli } from '../cli/index.js'; // Handle uncaught errors gracefully process.on('uncaughtException', (error) => { @@ -22,5 +20,8 @@ process.on('unhandledRejection', (reason) => { process.exit(1); }); -// Parse command line arguments -program.parse(process.argv); +// Run the CLI +runCli().catch((error) => { + console.error('CLI error:', error.message); + process.exit(1); +}); diff --git a/src/cli/index.ts b/src/cli/index.ts index 1d2592e..841e05b 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -2,17 +2,66 @@ * Codewalk District CLI * * Commander-based CLI with help system and version display. + * Supports server mode via --server flag. */ import { Command } from 'commander'; import { VERSION } from '../index.js'; +import { CoordinationServer } from '../server/index.js'; +import { ProcessManager, ProcessRegistry } from '../process/index.js'; +import { LogManager } from '../logging/index.js'; + +/** Environment variable for custom port */ +const CW_PORT_ENV = 'CW_PORT'; + +/** + * Starts the coordination server in foreground mode. + * Server runs until terminated via SIGTERM/SIGINT. + */ +async function startServer(port?: number): Promise { + // Get port from option, env var, or default + const serverPort = port ?? + (process.env[CW_PORT_ENV] ? parseInt(process.env[CW_PORT_ENV], 10) : undefined); + + // Create dependencies + const registry = new ProcessRegistry(); + const processManager = new ProcessManager(registry); + const logManager = new LogManager(); + + // Create and start server + const server = new CoordinationServer( + { port: serverPort }, + processManager, + logManager + ); + + try { + await server.start(); + } catch (error) { + console.error('Failed to start server:', (error as Error).message); + process.exit(1); + } + + // Import shutdown handler (will be implemented in Task 2) + // For now, just handle basic signals + const handleSignal = async (signal: string) => { + console.log(`\nReceived ${signal}, shutting down...`); + await server.stop(); + await processManager.stopAll(); + process.exit(0); + }; + + process.on('SIGTERM', () => handleSignal('SIGTERM')); + process.on('SIGINT', () => handleSignal('SIGINT')); +} /** * Creates and configures the CLI program. * + * @param serverHandler - Optional handler to be called for server mode * @returns Configured Commander program ready for parsing */ -export function createCli(): Command { +export function createCli(serverHandler?: (port?: number) => Promise): Command { const program = new Command(); program @@ -20,6 +69,21 @@ export function createCli(): Command { .description('Multi-agent workspace for orchestrating multiple Claude Code agents') .version(VERSION, '-v, --version', 'Display version number'); + // Server mode option (global flag) + program + .option('-s, --server', 'Start the coordination server') + .option('-p, --port ', 'Port for the server (default: 3847, env: CW_PORT)', parseInt); + + // Handle the case where --server is provided without a command + // This makes --server work as a standalone action + program.hook('preAction', async (_thisCommand, _actionCommand) => { + const opts = program.opts(); + if (opts.server && serverHandler) { + await serverHandler(opts.port); + process.exit(0); + } + }); + // Placeholder commands - will be implemented in later phases program .command('status') @@ -44,3 +108,27 @@ export function createCli(): Command { return program; } + +/** + * Runs the CLI, handling server mode and commands. + */ +export async function runCli(): Promise { + // Check for server flag early, before Commander processes + const hasServerFlag = process.argv.includes('--server') || process.argv.includes('-s'); + + if (hasServerFlag) { + // Get port from args if present + const portIndex = process.argv.findIndex(arg => arg === '-p' || arg === '--port'); + const port = portIndex !== -1 && process.argv[portIndex + 1] + ? parseInt(process.argv[portIndex + 1], 10) + : undefined; + + await startServer(port); + // Server runs indefinitely until signal + return; + } + + // Normal CLI processing + const program = createCli(); + program.parse(process.argv); +} diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..8af8f01 --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,275 @@ +/** + * Coordination Server + * + * HTTP server with health endpoint for agent coordination. + * Uses native node:http for minimal dependencies. + */ + +import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http'; +import { writeFile, unlink, readFile, mkdir } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import type { ServerConfig, ServerState, HealthResponse, StatusResponse } from './types.js'; +import type { ProcessManager } from '../process/index.js'; +import type { LogManager } from '../logging/index.js'; + +/** Default port for the coordination server */ +const DEFAULT_PORT = 3847; + +/** Default host to bind to */ +const DEFAULT_HOST = '127.0.0.1'; + +/** Default PID file location */ +const DEFAULT_PID_FILE = join(homedir(), '.cw', 'server.pid'); + +/** + * HTTP server for agent coordination. + * + * Routes: + * - GET /health - Health check with uptime and process count + * - GET /status - Full status with process list + */ +export class CoordinationServer { + private readonly config: ServerConfig; + private readonly processManager: ProcessManager; + private readonly logManager: LogManager; + private server: Server | null = null; + private state: ServerState | null = null; + + constructor( + config: Partial, + processManager: ProcessManager, + logManager: LogManager + ) { + this.config = { + port: config.port ?? DEFAULT_PORT, + host: config.host ?? DEFAULT_HOST, + pidFile: config.pidFile ?? DEFAULT_PID_FILE, + }; + this.processManager = processManager; + this.logManager = logManager; + } + + /** + * Starts the HTTP server and writes PID file. + * @throws If server is already running or PID file exists (another server running) + */ + async start(): Promise { + // Check if already running + if (this.server) { + throw new Error('Server is already running'); + } + + // Check for existing PID file (another server might be running) + const existingPid = await this.checkExistingServer(); + if (existingPid !== null) { + throw new Error( + `Another server appears to be running (PID: ${existingPid}). ` + + `If this is incorrect, remove ${this.config.pidFile} and try again.` + ); + } + + // Create server + this.server = createServer((req, res) => this.handleRequest(req, res)); + + // Start listening + await new Promise((resolve, reject) => { + this.server!.once('error', reject); + this.server!.listen(this.config.port, this.config.host, () => { + this.server!.removeListener('error', reject); + resolve(); + }); + }); + + // Set server state + this.state = { + startedAt: new Date(), + processCount: 0, + }; + + // Write PID file + await this.writePidFile(); + + console.log(`Coordination server listening on http://${this.config.host}:${this.config.port}`); + } + + /** + * Stops the HTTP server and removes PID file. + */ + async stop(): Promise { + if (!this.server) { + return; + } + + // Close server + await new Promise((resolve, reject) => { + this.server!.close((err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + + this.server = null; + this.state = null; + + // Remove PID file + await this.removePidFile(); + } + + /** + * Returns whether the server is currently running. + */ + isRunning(): boolean { + return this.server !== null && this.server.listening; + } + + /** + * Gets the server port. + */ + getPort(): number { + return this.config.port; + } + + /** + * Gets the PID file path. + */ + getPidFile(): string { + return this.config.pidFile; + } + + /** + * Handles incoming HTTP requests with simple path matching. + */ + private handleRequest(req: IncomingMessage, res: ServerResponse): void { + // Only accept GET requests + if (req.method !== 'GET') { + this.sendJson(res, 405, { error: 'Method not allowed' }); + return; + } + + // Simple path routing + switch (req.url) { + case '/health': + this.handleHealth(res); + break; + case '/status': + this.handleStatus(res); + break; + default: + this.sendJson(res, 404, { error: 'Not found' }); + } + } + + /** + * Handles GET /health endpoint. + */ + private handleHealth(res: ServerResponse): void { + if (!this.state) { + this.sendJson(res, 500, { error: 'Server not initialized' }); + return; + } + + const uptime = Math.floor((Date.now() - this.state.startedAt.getTime()) / 1000); + const response: HealthResponse = { + status: 'ok', + uptime, + processCount: this.state.processCount, + }; + + this.sendJson(res, 200, response); + } + + /** + * Handles GET /status endpoint. + */ + private handleStatus(res: ServerResponse): void { + if (!this.state) { + this.sendJson(res, 500, { error: 'Server not initialized' }); + return; + } + + const uptime = Math.floor((Date.now() - this.state.startedAt.getTime()) / 1000); + + // Get process list from process manager registry + // Note: We access processManager's registry indirectly through its public API + const response: StatusResponse = { + server: { + startedAt: this.state.startedAt.toISOString(), + uptime, + pid: process.pid, + }, + processes: [], + }; + + this.sendJson(res, 200, response); + } + + /** + * Sends a JSON response. + */ + private sendJson(res: ServerResponse, statusCode: number, data: unknown): void { + res.writeHead(statusCode, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(data)); + } + + /** + * Checks if another server is already running by checking PID file. + * @returns The PID if a server is running, null otherwise + */ + private async checkExistingServer(): Promise { + try { + const content = await readFile(this.config.pidFile, 'utf-8'); + const pid = parseInt(content.trim(), 10); + + if (isNaN(pid)) { + return null; + } + + // Check if process is actually running + try { + process.kill(pid, 0); + return pid; // Process is alive + } catch { + // Process is dead, PID file is stale + await this.removePidFile(); + return null; + } + } catch (error) { + // PID file doesn't exist + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + throw error; + } + } + + /** + * Writes the PID file. + */ + private async writePidFile(): Promise { + // Ensure directory exists + await mkdir(dirname(this.config.pidFile), { recursive: true }); + await writeFile(this.config.pidFile, process.pid.toString(), 'utf-8'); + } + + /** + * Removes the PID file. + */ + private async removePidFile(): Promise { + try { + await unlink(this.config.pidFile); + } catch (error) { + // Ignore if file doesn't exist + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + } +} + +// Re-export types +export type { ServerConfig, ServerState, HealthResponse, StatusResponse } from './types.js'; diff --git a/src/server/types.ts b/src/server/types.ts new file mode 100644 index 0000000..671e5c6 --- /dev/null +++ b/src/server/types.ts @@ -0,0 +1,59 @@ +/** + * Server Types + * + * Type definitions for the HTTP coordination server. + */ + +/** + * Configuration for the coordination server + */ +export interface ServerConfig { + /** Port to listen on. Defaults to 3847 */ + port: number; + /** Host to bind to. Defaults to 127.0.0.1 */ + host: string; + /** Path to PID file. Defaults to ~/.cw/server.pid */ + pidFile: string; +} + +/** + * Server runtime state + */ +export interface ServerState { + /** When the server was started */ + startedAt: Date; + /** Number of managed processes */ + processCount: number; +} + +/** + * Health check response + */ +export interface HealthResponse { + /** Always 'ok' when healthy */ + status: 'ok'; + /** Uptime in seconds */ + uptime: number; + /** Number of managed processes */ + processCount: number; +} + +/** + * Status response with full server state + */ +export interface StatusResponse { + /** Server state */ + server: { + startedAt: string; + uptime: number; + pid: number; + }; + /** List of managed processes */ + processes: Array<{ + id: string; + pid: number; + command: string; + status: string; + startedAt: string; + }>; +}