diff --git a/src/extension/extension.ts b/src/extension/extension.ts index 885e24d0..158bb6ab 100644 --- a/src/extension/extension.ts +++ b/src/extension/extension.ts @@ -10,6 +10,7 @@ import * as vscode from 'vscode'; import { registerOpenEditorCommand } from './commands/open-editor'; import { handleConnectSlackManual } from './commands/slack-connect-manual'; import { WorkflowPreviewEditorProvider } from './editors/workflow-preview-editor-provider'; +import { HealthServerService } from './services/health-server-service'; import { SlackApiService } from './services/slack-api-service'; import { SlackTokenManager } from './utils/slack-token-manager'; @@ -18,6 +19,11 @@ import { SlackTokenManager } from './utils/slack-token-manager'; */ let outputChannel: vscode.OutputChannel | null = null; +/** + * Health HTTP server instance (started on activation, stopped on deactivation) + */ +let healthServer: HealthServerService | null = null; + /** * Get the global output channel instance */ @@ -221,6 +227,14 @@ export function activate(context: vscode.ExtensionContext): void { ); log('INFO', 'Claude Code Workflow Studio: All commands and handlers registered'); + + // Start health server (fire-and-forget; non-fatal if port is in use) + const extensionPackage = context.extension.packageJSON as { name: string; version: string }; + healthServer = new HealthServerService(extensionPackage.name, extensionPackage.version); + healthServer.start().catch((err: Error) => { + log('WARN', 'Health server failed to start', { error: err.message }); + healthServer = null; + }); } /** @@ -229,6 +243,13 @@ export function activate(context: vscode.ExtensionContext): void { */ export function deactivate(): void { log('INFO', 'Claude Code Workflow Studio is now deactivated'); + + // Stop health server if running + healthServer?.stop().catch((err: Error) => { + log('WARN', 'Health server failed to stop cleanly', { error: err.message }); + }); + healthServer = null; + outputChannel?.dispose(); outputChannel = null; } diff --git a/src/extension/services/health-server-service.ts b/src/extension/services/health-server-service.ts new file mode 100644 index 00000000..99247229 --- /dev/null +++ b/src/extension/services/health-server-service.ts @@ -0,0 +1,142 @@ +/** + * Claude Code Workflow Studio - Health Server Service + * + * Provides a simple HTTP server exposing a /health endpoint for monitoring + * and integration testing. The server starts on extension activation and + * shuts down on deactivation. + * + * Endpoint: + * GET /health → 200 OK + * { + * "status": "ok", + * "name": "cc-wf-studio", + * "version": "", + * "timestamp": "" + * } + * + * Default port: 3456 (configurable via constructor) + */ + +import * as http from 'node:http'; +import { log } from '../extension'; + +/** + * Response body returned by the /health endpoint + */ +export interface HealthResponse { + /** Overall health status */ + status: 'ok'; + /** Extension name */ + name: string; + /** Extension version */ + version: string; + /** ISO 8601 timestamp of the response */ + timestamp: string; +} + +/** + * Health Server Service + * + * Starts a lightweight HTTP server that exposes a /health endpoint. + * Intended for use by monitoring tools, CI pipelines, and integration tests. + */ +export class HealthServerService { + private server: http.Server | null = null; + private readonly port: number; + private readonly name: string; + private readonly version: string; + + /** + * @param name - Extension name (from package.json `name` field) + * @param version - Extension version (from package.json `version` field) + * @param port - Port to listen on (default: 3456) + */ + constructor(name: string, version: string, port = 3456) { + this.name = name; + this.version = version; + this.port = port; + } + + /** + * Starts the health HTTP server. + * + * Resolves when the server is listening. + * Rejects if the port is already in use or the server fails to start. + */ + start(): Promise { + return new Promise((resolve, reject) => { + this.server = http.createServer((req, res) => { + this.handleRequest(req, res); + }); + + this.server.on('error', (err) => { + log('ERROR', 'Health server error', { error: err.message }); + reject(err); + }); + + this.server.listen(this.port, '127.0.0.1', () => { + log('INFO', `Health server listening on http://127.0.0.1:${this.port}/health`); + resolve(); + }); + }); + } + + /** + * Stops the health HTTP server. + * + * Resolves when the server has closed all connections. + */ + stop(): Promise { + return new Promise((resolve) => { + if (!this.server) { + resolve(); + return; + } + + this.server.close((err) => { + if (err) { + log('WARN', 'Health server stop error', { error: err.message }); + } else { + log('INFO', 'Health server stopped'); + } + this.server = null; + resolve(); + }); + }); + } + + /** + * Routes an incoming HTTP request. + * + * Only GET /health is handled; all other requests receive 404. + */ + private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void { + if (req.method === 'GET' && req.url === '/health') { + this.handleHealth(res); + } else { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not Found' })); + } + } + + /** + * Responds to GET /health with a 200 OK and a HealthResponse JSON body. + */ + private handleHealth(res: http.ServerResponse): void { + const body: HealthResponse = { + status: 'ok', + name: this.name, + version: this.version, + timestamp: new Date().toISOString(), + }; + + const json = JSON.stringify(body); + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(json), + }); + res.end(json); + + log('INFO', 'Health check request served'); + } +}