feat(KB-616): add ProjectRuntime abstraction for task execution isolation

- Define ProjectRuntime interface with unified task execution contract\n- Implement IPC protocol for host-worker communication (messages, streaming, heartbeats)\n- Add InProcessRuntime for synchronous in-process task execution\n- Add ChildProcessRuntime with isolated worker processes for sandboxed execution\n- Implement ProjectManager to coordinate runtime selection and task lifecycle\n- Update engine exports to expose runtime APIs\n- Add comprehensive tests for all runtime implementations and IPC protocol
This commit is contained in:
gsxdsm
2026-03-31 20:12:28 -07:00
parent 45b5fbbbe8
commit 879a941b9a
15 changed files with 3454 additions and 0 deletions

View File

@@ -0,0 +1,279 @@
import { EventEmitter } from "node:events";
import type { ChildProcess } from "node:child_process";
import type { IpcMessage, IpcCommandType, IpcResponseType } from "./ipc-protocol.js";
import { OK, ERROR, PONG, generateCorrelationId } from "./ipc-protocol.js";
import { ipcLog } from "../logger.js";
/**
* Pending command waiting for a response.
*/
interface PendingCommand {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timeout: ReturnType<typeof setTimeout>;
type: IpcCommandType;
}
/**
* IPC Host handler for managing communication with a child process worker.
*
* Handles:
* - Sending commands and correlating responses
* - Forwarding events from worker to host listeners
* - Detecting and handling IPC disconnections
* - Timeout handling for commands
*
* @example
* ```typescript
* const child = fork(workerPath);
* const ipcHost = new IpcHost(child);
*
* // Send command and await response
* const response = await ipcHost.sendCommand("GET_STATUS", {});
*
* // Listen for events
* ipcHost.on("TASK_CREATED", (payload) => {
* console.log("Task created:", payload.task.id);
* });
* ```
*/
export class IpcHost extends EventEmitter {
private pendingCommands = new Map<string, PendingCommand>();
private commandTimeoutMs = 30000; // 30 second default timeout
private disconnected = false;
/**
* @param childProcess - The forked child process to communicate with
* @param options - Optional configuration
*/
constructor(
private childProcess: ChildProcess,
options?: { commandTimeoutMs?: number }
) {
super();
this.setMaxListeners(100);
this.commandTimeoutMs = options?.commandTimeoutMs ?? 30000;
this.setupListeners();
}
/**
* Set up message and error listeners on the child process.
*/
private setupListeners(): void {
// Handle incoming messages from child
this.childProcess.on("message", (message: IpcMessage) => {
this.handleMessage(message);
});
// Handle child process errors
this.childProcess.on("error", (error) => {
ipcLog.error(`Child process error: ${error.message}`);
this.handleDisconnection(error);
});
// Handle child process exit
this.childProcess.on("exit", (code, signal) => {
const reason = signal
? `Child process exited with signal ${signal}`
: `Child process exited with code ${code}`;
ipcLog.warn(reason);
this.handleDisconnection(new Error(reason));
});
// Handle IPC channel disconnection
this.childProcess.on("disconnect", () => {
ipcLog.warn("Child process IPC channel disconnected");
this.handleDisconnection(new Error("IPC channel disconnected"));
});
}
/**
* Handle an incoming message from the child process.
*/
private handleMessage(message: IpcMessage): void {
// Validate message structure
if (!this.isValidMessage(message)) {
ipcLog.warn(`Received malformed IPC message: ${JSON.stringify(message)}`);
return;
}
// Handle responses to pending commands
if (message.type === OK || message.type === ERROR || message.type === PONG) {
const pending = this.pendingCommands.get(message.id);
if (pending) {
this.handleResponse(message, pending);
} else {
ipcLog.warn(`Received response for unknown command ID: ${message.id}`);
}
return;
}
// Handle events (forward to listeners)
this.emit(message.type, message.payload);
this.emit("message", message); // Generic message event
}
/**
* Handle a response message for a pending command.
*/
private handleResponse(
message: IpcMessage,
pending: PendingCommand
): void {
// Clear the timeout
clearTimeout(pending.timeout);
this.pendingCommands.delete(message.id);
if (message.type === OK) {
pending.resolve((message.payload as { data?: unknown }).data);
} else if (message.type === ERROR) {
const errorPayload = message.payload as {
message: string;
code?: string;
stack?: string;
};
const error = new Error(errorPayload.message);
if (errorPayload.code) {
(error as Error & { code: string }).code = errorPayload.code;
}
pending.reject(error);
} else if (message.type === PONG) {
pending.resolve(message.payload);
}
}
/**
* Handle disconnection from the child process.
* Rejects all pending commands.
*/
private handleDisconnection(error: Error): void {
if (this.disconnected) return;
this.disconnected = true;
// Reject all pending commands
for (const [id, pending] of this.pendingCommands) {
clearTimeout(pending.timeout);
pending.reject(new Error(`IPC disconnected: ${error.message}`));
}
this.pendingCommands.clear();
this.emit("disconnect", error);
}
/**
* Send a command to the child process and await a response.
*
* @param type - Command type
* @param payload - Command payload
* @param timeoutMs - Optional timeout override
* @returns Promise that resolves with the response data
* @throws Error if the command times out or IPC disconnects
*/
async sendCommand<T = unknown>(
type: IpcCommandType,
payload: unknown,
timeoutMs?: number
): Promise<T> {
if (this.disconnected) {
throw new Error("Cannot send command: IPC channel disconnected");
}
const id = generateCorrelationId();
const message: IpcMessage = { type, id, payload };
return new Promise<T>((resolve, reject) => {
// Set up timeout
const timeout = setTimeout(() => {
this.pendingCommands.delete(id);
reject(new Error(`Command ${type} timed out after ${timeoutMs ?? this.commandTimeoutMs}ms`));
}, timeoutMs ?? this.commandTimeoutMs);
// Store pending command
this.pendingCommands.set(id, {
resolve: resolve as (value: unknown) => void,
reject,
timeout,
type,
});
// Send message to child
try {
if (!this.childProcess.send) {
throw new Error("Child process does not have IPC channel");
}
this.childProcess.send(message, (err) => {
if (err) {
clearTimeout(timeout);
this.pendingCommands.delete(id);
reject(new Error(`Failed to send command: ${err.message}`));
}
});
} catch (err) {
clearTimeout(timeout);
this.pendingCommands.delete(id);
reject(err);
}
});
}
/**
* Send a ping to check if the child process is responsive.
*
* @param timeoutMs - Timeout for pong response (default: 5000)
* @returns Promise that resolves with pong payload or rejects on timeout
*/
async ping(timeoutMs = 5000): Promise<{ timestamp: string }> {
return this.sendCommand("PING", {}, timeoutMs);
}
/**
* Check if the IPC channel is connected.
*/
isConnected(): boolean {
return !this.disconnected && this.childProcess.connected;
}
/**
* Get the underlying child process.
*/
getChildProcess(): ChildProcess {
return this.childProcess;
}
/**
* Disconnect the IPC channel and clean up.
*/
disconnect(): void {
this.handleDisconnection(new Error("Host initiated disconnect"));
if (this.childProcess.connected) {
this.childProcess.disconnect();
}
this.removeAllListeners();
}
/**
* Validate that a message has the expected structure.
*/
private isValidMessage(message: unknown): message is IpcMessage {
if (typeof message !== "object" || message === null) {
return false;
}
const msg = message as Record<string, unknown>;
return (
typeof msg.type === "string" &&
typeof msg.id === "string" &&
"payload" in msg
);
}
/**
* Get the number of pending commands.
*/
getPendingCommandCount(): number {
return this.pendingCommands.size;
}
}

View File

@@ -0,0 +1,175 @@
import { describe, it, expect } from "vitest";
import {
START_RUNTIME,
STOP_RUNTIME,
GET_STATUS,
GET_METRICS,
GET_TASK_STORE,
GET_SCHEDULER,
PING,
OK,
ERROR,
PONG,
TASK_CREATED,
TASK_MOVED,
TASK_UPDATED,
ERROR_EVENT,
HEALTH_CHANGED,
isIpcCommand,
isIpcResponse,
isIpcEvent,
createCommand,
createResponse,
createEvent,
generateCorrelationId,
} from "./ipc-protocol.js";
describe("IPC Protocol", () => {
describe("constants", () => {
it("should export all command types", () => {
expect(START_RUNTIME).toBe("START_RUNTIME");
expect(STOP_RUNTIME).toBe("STOP_RUNTIME");
expect(GET_STATUS).toBe("GET_STATUS");
expect(GET_METRICS).toBe("GET_METRICS");
expect(GET_TASK_STORE).toBe("GET_TASK_STORE");
expect(GET_SCHEDULER).toBe("GET_SCHEDULER");
expect(PING).toBe("PING");
});
it("should export all response types", () => {
expect(OK).toBe("OK");
expect(ERROR).toBe("ERROR");
expect(PONG).toBe("PONG");
});
it("should export all event types", () => {
expect(TASK_CREATED).toBe("TASK_CREATED");
expect(TASK_MOVED).toBe("TASK_MOVED");
expect(TASK_UPDATED).toBe("TASK_UPDATED");
expect(ERROR_EVENT).toBe("ERROR_EVENT");
expect(HEALTH_CHANGED).toBe("HEALTH_CHANGED");
});
it("should have distinct ERROR and ERROR_EVENT values", () => {
expect(ERROR).toBe("ERROR");
expect(ERROR_EVENT).toBe("ERROR_EVENT");
expect(ERROR).not.toBe(ERROR_EVENT);
});
});
describe("isIpcCommand", () => {
it("should return true for command types", () => {
expect(isIpcCommand({ type: START_RUNTIME, id: "1", payload: {} })).toBe(true);
expect(isIpcCommand({ type: STOP_RUNTIME, id: "1", payload: {} })).toBe(true);
expect(isIpcCommand({ type: GET_STATUS, id: "1", payload: {} })).toBe(true);
expect(isIpcCommand({ type: PING, id: "1", payload: {} })).toBe(true);
});
it("should return false for response types", () => {
expect(isIpcCommand({ type: OK, id: "1", payload: {} })).toBe(false);
expect(isIpcCommand({ type: ERROR, id: "1", payload: {} })).toBe(false);
expect(isIpcCommand({ type: PONG, id: "1", payload: {} })).toBe(false);
});
it("should return false for event types", () => {
expect(isIpcCommand({ type: TASK_CREATED, id: "1", payload: {} })).toBe(false);
expect(isIpcCommand({ type: HEALTH_CHANGED, id: "1", payload: {} })).toBe(false);
});
});
describe("isIpcResponse", () => {
it("should return true for response types", () => {
expect(isIpcResponse({ type: OK, id: "1", payload: {} })).toBe(true);
expect(isIpcResponse({ type: ERROR, id: "1", payload: {} })).toBe(true);
expect(isIpcResponse({ type: PONG, id: "1", payload: {} })).toBe(true);
});
it("should return false for command types", () => {
expect(isIpcResponse({ type: START_RUNTIME, id: "1", payload: {} })).toBe(false);
expect(isIpcResponse({ type: PING, id: "1", payload: {} })).toBe(false);
});
it("should return false for event types", () => {
expect(isIpcResponse({ type: TASK_CREATED, id: "1", payload: {} })).toBe(false);
});
});
describe("isIpcEvent", () => {
it("should return true for event types", () => {
expect(isIpcEvent({ type: TASK_CREATED, id: "1", payload: {} })).toBe(true);
expect(isIpcEvent({ type: TASK_MOVED, id: "1", payload: {} })).toBe(true);
expect(isIpcEvent({ type: TASK_UPDATED, id: "1", payload: {} })).toBe(true);
expect(isIpcEvent({ type: ERROR_EVENT, id: "1", payload: {} })).toBe(true);
expect(isIpcEvent({ type: HEALTH_CHANGED, id: "1", payload: {} })).toBe(true);
});
it("should return false for command types", () => {
expect(isIpcEvent({ type: START_RUNTIME, id: "1", payload: {} })).toBe(false);
expect(isIpcEvent({ type: PING, id: "1", payload: {} })).toBe(false);
});
it("should return false for response types", () => {
expect(isIpcEvent({ type: OK, id: "1", payload: {} })).toBe(false);
expect(isIpcEvent({ type: ERROR, id: "1", payload: {} })).toBe(false);
});
});
describe("createCommand", () => {
it("should create a command message", () => {
const payload = { config: { projectId: "test" } };
const message = createCommand(START_RUNTIME, "cmd-1", payload);
expect(message).toEqual({
type: START_RUNTIME,
id: "cmd-1",
payload,
});
});
});
describe("createResponse", () => {
it("should create a response message", () => {
const payload = { data: { status: "active" } };
const message = createResponse(OK, "cmd-1", payload);
expect(message).toEqual({
type: OK,
id: "cmd-1",
payload,
});
});
});
describe("createEvent", () => {
it("should create an event message", () => {
const payload = { task: { id: "KB-001" } };
const message = createEvent(TASK_CREATED, "evt-1", payload);
expect(message).toEqual({
type: TASK_CREATED,
id: "evt-1",
payload,
});
});
});
describe("generateCorrelationId", () => {
it("should generate unique IDs", () => {
const id1 = generateCorrelationId();
const id2 = generateCorrelationId();
expect(id1).toBeDefined();
expect(id2).toBeDefined();
expect(id1).not.toBe(id2);
});
it("should generate string IDs with timestamp and random parts", () => {
const id = generateCorrelationId();
const parts = id.split("-");
expect(parts.length).toBeGreaterThanOrEqual(2);
// First part should be a timestamp (number)
expect(Number.parseInt(parts[0], 10)).not.toBeNaN();
});
});
});

View File

@@ -0,0 +1,277 @@
/**
* IPC Protocol for child-process isolation mode.
*
* Defines message types for communication between the host (ProjectManager)
* and worker (child process running InProcessRuntime internally).
*
* Message Flow:
* 1. Host sends commands with unique correlation IDs
* 2. Worker processes commands and sends responses with matching IDs
* 3. Worker can also send events unsolicited (task events, health changes)
*/
import type { RuntimeStatus, RuntimeMetrics, ProjectRuntimeConfig } from "../project-runtime.js";
import type { Task, TaskStore } from "@fusion/core";
import type { Scheduler } from "../scheduler.js";
// ── Base Message Types ────────────────────────────────────────────────────
/**
* Base interface for all IPC messages.
*/
export interface IpcMessage {
/** Message type discriminator */
type: string;
/** Unique correlation ID for request/response matching */
id: string;
/** Message payload */
payload: unknown;
}
// ── Command Types (Host → Worker) ───────────────────────────────────────────
/** Command type: Start the runtime */
export const START_RUNTIME = "START_RUNTIME" as const;
/** Command type: Stop the runtime */
export const STOP_RUNTIME = "STOP_RUNTIME" as const;
/** Command type: Get current status */
export const GET_STATUS = "GET_STATUS" as const;
/** Command type: Get runtime metrics */
export const GET_METRICS = "GET_METRICS" as const;
/** Command type: Get TaskStore reference (returns serialized state) */
export const GET_TASK_STORE = "GET_TASK_STORE" as const;
/** Command type: Get Scheduler reference (returns serialized state) */
export const GET_SCHEDULER = "GET_SCHEDULER" as const;
/** Command type: Ping for health check */
export const PING = "PING" as const;
/**
* Union of all command types.
*/
export type IpcCommandType =
| typeof START_RUNTIME
| typeof STOP_RUNTIME
| typeof GET_STATUS
| typeof GET_METRICS
| typeof GET_TASK_STORE
| typeof GET_SCHEDULER
| typeof PING;
/**
* Payload for START_RUNTIME command.
*/
export interface StartRuntimePayload {
config: ProjectRuntimeConfig;
}
/**
* Payload for STOP_RUNTIME command.
*/
export interface StopRuntimePayload {
/** Timeout in milliseconds for graceful shutdown (default: 30000) */
timeoutMs?: number;
}
// ── Response Types (Worker → Host) ──────────────────────────────────────
/** Response type: Success */
export const OK = "OK" as const;
/** Response type: Error */
export const ERROR = "ERROR" as const;
/** Response type: Pong (ping reply) */
export const PONG = "PONG" as const;
/**
* Union of all response types.
*/
export type IpcResponseType = typeof OK | typeof ERROR | typeof PONG;
/**
* Successful response payload.
*/
export interface OkPayload {
/** Response data (type depends on the command) */
data?: unknown;
}
/**
* Error response payload.
*/
export interface ErrorPayload {
/** Error message */
message: string;
/** Error code for programmatic handling */
code?: string;
/** Stack trace (only in development) */
stack?: string;
}
/**
* Pong response payload for health checks.
*/
export interface PongPayload {
/** ISO-8601 timestamp from the worker */
timestamp: string;
}
// ── Event Types (Worker → Host, unsolicited) ─────────────────────────────
/** Event type: Task created */
export const TASK_CREATED = "TASK_CREATED" as const;
/** Event type: Task moved */
export const TASK_MOVED = "TASK_MOVED" as const;
/** Event type: Task updated */
export const TASK_UPDATED = "TASK_UPDATED" as const;
/** Event type: Runtime error */
export const ERROR_EVENT = "ERROR_EVENT" as const;
/** Event type: Health status changed */
export const HEALTH_CHANGED = "HEALTH_CHANGED" as const;
/**
* Union of all event types.
*/
export type IpcEventType =
| typeof TASK_CREATED
| typeof TASK_MOVED
| typeof TASK_UPDATED
| typeof ERROR_EVENT
| typeof HEALTH_CHANGED;
/**
* Payload for TASK_CREATED event.
*/
export interface TaskCreatedPayload {
task: Task;
}
/**
* Payload for TASK_MOVED event.
*/
export interface TaskMovedPayload {
task: Task;
from: string;
to: string;
}
/**
* Payload for TASK_UPDATED event.
*/
export interface TaskUpdatedPayload {
task: Task;
}
/**
* Payload for ERROR event.
*/
export interface ErrorEventPayload {
message: string;
code?: string;
}
/**
* Payload for HEALTH_CHANGED event.
*/
export interface HealthChangedPayload {
status: RuntimeStatus;
previous: RuntimeStatus;
}
// ── Type Guards ───────────────────────────────────────────────────────────
/**
* Check if a message is a command.
*/
export function isIpcCommand(message: IpcMessage): boolean {
const commandTypes: IpcCommandType[] = [
START_RUNTIME,
STOP_RUNTIME,
GET_STATUS,
GET_METRICS,
GET_TASK_STORE,
GET_SCHEDULER,
PING,
];
return commandTypes.includes(message.type as IpcCommandType);
}
/**
* Check if a message is a response.
*/
export function isIpcResponse(message: IpcMessage): boolean {
const responseTypes: IpcResponseType[] = [OK, ERROR, PONG];
return responseTypes.includes(message.type as IpcResponseType);
}
/**
* Check if a message is an event.
*/
export function isIpcEvent(message: IpcMessage): boolean {
const eventTypes: IpcEventType[] = [
TASK_CREATED,
TASK_MOVED,
TASK_UPDATED,
ERROR_EVENT,
HEALTH_CHANGED,
];
return eventTypes.includes(message.type as IpcEventType);
}
// ── Serialized State Types ────────────────────────────────────────────────
/**
* Serialized TaskStore state (for IPC transfer).
* Full TaskStore objects cannot be passed across process boundaries.
*/
export interface SerializedTaskStore {
rootDir: string;
taskCount: number;
}
/**
* Serialized Scheduler state (for IPC transfer).
*/
export interface SerializedScheduler {
running: boolean;
}
// ── Helper Functions ────────────────────────────────────────────────────
/**
* Create a command message.
*/
export function createCommand<T>(
type: IpcCommandType,
id: string,
payload: T
): IpcMessage {
return { type, id, payload };
}
/**
* Create a response message.
*/
export function createResponse<T>(
type: IpcResponseType,
id: string,
payload: T
): IpcMessage {
return { type, id, payload };
}
/**
* Create an event message.
*/
export function createEvent<T>(
type: IpcEventType,
id: string,
payload: T
): IpcMessage {
return { type, id, payload };
}
/**
* Generate a unique correlation ID.
*/
export function generateCorrelationId(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}

View File

@@ -0,0 +1,325 @@
import { EventEmitter } from "node:events";
import type { IpcMessage, IpcCommandType, IpcEventType } from "./ipc-protocol.js";
import {
OK,
ERROR,
PONG,
START_RUNTIME,
STOP_RUNTIME,
GET_STATUS,
GET_METRICS,
GET_TASK_STORE,
GET_SCHEDULER,
PING,
ERROR_EVENT,
isIpcCommand,
generateCorrelationId,
} from "./ipc-protocol.js";
import { ipcLog } from "../logger.js";
type CommandHandler = (payload: unknown) => Promise<unknown> | unknown;
/**
* IPC Worker handler for managing communication from a child process to the host.
*
* Handles:
* - Receiving commands from the host
* - Sending responses back to the host
* - Sending events to the host
* - Graceful shutdown signal handling
*
* This class is designed to run inside a forked child process.
*
* @example
* ```typescript
* // In child-process-worker.ts
* if (process.send) {
* const ipcWorker = new IpcWorker();
*
* // Register command handlers
* ipcWorker.onCommand("START_RUNTIME", async (payload) => {
* // ... start runtime
* return { success: true };
* });
*
* // Send events to host
* ipcWorker.sendEvent("TASK_CREATED", { task });
*
* // Handle graceful shutdown
* process.on("SIGTERM", () => {
* ipcWorker.shutdown();
* });
* }
* ```
*/
export class IpcWorker extends EventEmitter {
private commandHandlers = new Map<IpcCommandType, CommandHandler>();
private shuttingDown = false;
/**
* Create an IpcWorker instance.
* Throws if not running in a forked child process (process.send unavailable).
*/
constructor() {
super();
this.setMaxListeners(100);
// Verify we're running in an IPC context
if (!process.send) {
throw new Error(
"IpcWorker can only be instantiated in a forked child process (process.send unavailable)"
);
}
this.setupListeners();
this.setupSignalHandlers();
}
/**
* Set up message listeners on the process.
*/
private setupListeners(): void {
process.on("message", (message: IpcMessage) => {
this.handleMessage(message);
});
// Handle disconnect from parent
process.on("disconnect", () => {
ipcLog.warn("IPC channel disconnected from parent");
this.emit("disconnect");
});
}
/**
* Set up signal handlers for graceful shutdown.
*/
private setupSignalHandlers(): void {
const handleSignal = (signal: string) => {
ipcLog.log(`Received ${signal}, initiating graceful shutdown...`);
this.emit("shutdown", signal);
// Give time for cleanup before exiting
setTimeout(() => {
process.exit(0);
}, 5000);
};
process.on("SIGTERM", () => handleSignal("SIGTERM"));
process.on("SIGINT", () => handleSignal("SIGINT"));
// Handle uncaught errors
process.on("uncaughtException", (error) => {
ipcLog.error(`Uncaught exception: ${error.message}`);
this.sendErrorEvent(error);
this.emit("error", error);
// Give time for error to be sent before exiting
setTimeout(() => {
process.exit(1);
}, 1000);
});
process.on("unhandledRejection", (reason) => {
const error = reason instanceof Error ? reason : new Error(String(reason));
ipcLog.error(`Unhandled rejection: ${error.message}`);
this.sendErrorEvent(error);
this.emit("error", error);
});
}
/**
* Handle an incoming message from the parent process.
*/
private async handleMessage(message: unknown): Promise<void> {
// Validate message structure first
if (!this.isValidMessage(message)) {
ipcLog.warn(`Received malformed IPC message: ${JSON.stringify(message)}`);
// Use a generated ID since we can't trust the message structure
this.sendResponse(ERROR, generateCorrelationId(), {
message: "Malformed message received",
code: "MALFORMED_MESSAGE",
});
return;
}
// Now TypeScript knows message is IpcMessage
// Check if this is a command
if (!isIpcCommand(message)) {
// Unknown message type
this.sendResponse(ERROR, message.id, {
message: `Unknown command type: ${message.type}`,
code: "UNKNOWN_COMMAND",
});
return;
}
// Handle PING specially (no handler registration needed)
if (message.type === PING) {
this.sendResponse(PONG, message.id, {
timestamp: new Date().toISOString(),
});
return;
}
// Look up command handler
const handler = this.commandHandlers.get(message.type as IpcCommandType);
if (!handler) {
this.sendResponse(ERROR, message.id, {
message: `No handler registered for command: ${message.type}`,
code: "NO_HANDLER",
});
return;
}
// Execute handler and send response
try {
const result = await handler(message.payload);
this.sendResponse(OK, message.id, { data: result });
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
ipcLog.error(`Error handling command ${message.type}: ${err.message}`);
this.sendResponse(ERROR, message.id, {
message: err.message,
code: (err as Error & { code?: string }).code ?? "HANDLER_ERROR",
stack: process.env.NODE_ENV === "development" ? err.stack : undefined,
});
}
}
/**
* Register a command handler.
*
* @param type - Command type to handle
* @param handler - Handler function that receives the payload and returns result
*/
onCommand(type: IpcCommandType, handler: CommandHandler): void {
this.commandHandlers.set(type, handler);
ipcLog.log(`Registered handler for command: ${type}`);
}
/**
* Unregister a command handler.
*
* @param type - Command type to unregister
*/
offCommand(type: IpcCommandType): void {
this.commandHandlers.delete(type);
ipcLog.log(`Unregistered handler for command: ${type}`);
}
/**
* Send a response to the parent process.
*
* @param type - Response type (OK, ERROR, or PONG)
* @param id - Correlation ID matching the command
* @param payload - Response payload
*/
sendResponse(type: IpcResponseType, id: string, payload: unknown): void {
if (this.shuttingDown) return;
const message: IpcMessage = { type, id, payload };
this.send(message);
}
/**
* Send an event to the parent process.
*
* @param type - Event type
* @param payload - Event payload
*/
sendEvent(type: IpcEventType, payload: unknown): void {
if (this.shuttingDown) return;
const message: IpcMessage = {
type,
id: generateCorrelationId(),
payload,
};
this.send(message);
}
/**
* Send an error event to the parent process.
*
* @param error - Error to send
*/
sendErrorEvent(error: Error): void {
this.sendEvent(ERROR_EVENT, {
message: error.message,
code: (error as Error & { code?: string }).code,
});
}
/**
* Send a message to the parent process.
*/
private send(message: IpcMessage): void {
if (!process.send) {
ipcLog.error("Cannot send message: process.send unavailable");
return;
}
try {
process.send(message, (err) => {
if (err) {
ipcLog.error(`Failed to send message: ${err.message}`);
}
});
} catch (err) {
ipcLog.error(`Error sending message: ${(err as Error).message}`);
}
}
/**
* Initiate graceful shutdown.
* Notifies the parent and prevents further message sending.
*/
shutdown(): void {
if (this.shuttingDown) return;
this.shuttingDown = true;
ipcLog.log("IPC worker shutting down...");
this.emit("shutdown");
// Notify parent we're shutting down
try {
process.send?.({ type: "SHUTDOWN", id: generateCorrelationId(), payload: {} });
} catch {
// Ignore errors during shutdown
}
}
/**
* Check if the worker is in shutdown mode.
*/
isShuttingDown(): boolean {
return this.shuttingDown;
}
/**
* Get the number of registered command handlers.
*/
getHandlerCount(): number {
return this.commandHandlers.size;
}
/**
* Validate that a message has the expected structure.
*/
private isValidMessage(message: unknown): message is IpcMessage {
if (typeof message !== "object" || message === null) {
return false;
}
const msg = message as Record<string, unknown>;
return (
typeof msg.type === "string" &&
typeof msg.id === "string" &&
"payload" in msg
);
}
}
// Import types for type checking
import type { IpcResponseType } from "./ipc-protocol.js";