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)
|
* - npm i -g codewalk-district (for release)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createCli } from '../cli/index.js';
|
import { runCli } from '../cli/index.js';
|
||||||
|
|
||||||
const program = createCli();
|
|
||||||
|
|
||||||
// Handle uncaught errors gracefully
|
// Handle uncaught errors gracefully
|
||||||
process.on('uncaughtException', (error) => {
|
process.on('uncaughtException', (error) => {
|
||||||
@@ -22,5 +20,8 @@ process.on('unhandledRejection', (reason) => {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse command line arguments
|
// Run the CLI
|
||||||
program.parse(process.argv);
|
runCli().catch((error) => {
|
||||||
|
console.error('CLI error:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,17 +2,66 @@
|
|||||||
* Codewalk District CLI
|
* Codewalk District CLI
|
||||||
*
|
*
|
||||||
* Commander-based CLI with help system and version display.
|
* Commander-based CLI with help system and version display.
|
||||||
|
* Supports server mode via --server flag.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import { VERSION } from '../index.js';
|
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.
|
* Creates and configures the CLI program.
|
||||||
*
|
*
|
||||||
|
* @param serverHandler - Optional handler to be called for server mode
|
||||||
* @returns Configured Commander program ready for parsing
|
* @returns Configured Commander program ready for parsing
|
||||||
*/
|
*/
|
||||||
export function createCli(): Command {
|
export function createCli(serverHandler?: (port?: number) => Promise<void>): Command {
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
|
||||||
program
|
program
|
||||||
@@ -20,6 +69,21 @@ export function createCli(): Command {
|
|||||||
.description('Multi-agent workspace for orchestrating multiple Claude Code agents')
|
.description('Multi-agent workspace for orchestrating multiple Claude Code agents')
|
||||||
.version(VERSION, '-v, --version', 'Display version number');
|
.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
|
// Placeholder commands - will be implemented in later phases
|
||||||
program
|
program
|
||||||
.command('status')
|
.command('status')
|
||||||
@@ -44,3 +108,27 @@ export function createCli(): Command {
|
|||||||
|
|
||||||
return program;
|
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