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
This commit is contained in:
Lukas May
2026-01-30 13:22:35 +01:00
parent f117227fed
commit bec46aa234
4 changed files with 429 additions and 6 deletions

View File

@@ -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);
});

View File

@@ -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<void> {
// 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<void>): 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 <number>', '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<void> {
// 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);
}

275
src/server/index.ts Normal file
View File

@@ -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<ServerConfig>,
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<void> {
// 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<void>((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<void> {
if (!this.server) {
return;
}
// Close server
await new Promise<void>((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<number | null> {
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<void> {
// 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<void> {
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';

59
src/server/types.ts Normal file
View File

@@ -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;
}>;
}