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:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
275
src/server/index.ts
Normal 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
59
src/server/types.ts
Normal 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;
|
||||
}>;
|
||||
}
|
||||
Reference in New Issue
Block a user