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:
279
packages/engine/src/ipc/ipc-host.ts
Normal file
279
packages/engine/src/ipc/ipc-host.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
175
packages/engine/src/ipc/ipc-protocol.test.ts
Normal file
175
packages/engine/src/ipc/ipc-protocol.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
277
packages/engine/src/ipc/ipc-protocol.ts
Normal file
277
packages/engine/src/ipc/ipc-protocol.ts
Normal 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)}`;
|
||||
}
|
||||
325
packages/engine/src/ipc/ipc-worker.ts
Normal file
325
packages/engine/src/ipc/ipc-worker.ts
Normal 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";
|
||||
Reference in New Issue
Block a user