import { ChildProcess, spawn } from "child_process" import { existsSync, statSync } from "fs" import path from "path" import { EventBus } from "../events/bus" import { LogLevel, WorkspaceLogEntry } from "../api-types" import { Logger } from "../logger" const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i function redactEnvironment(env: Record): Record { const redacted: Record = {} for (const [key, value] of Object.entries(env)) { if (value === undefined) { redacted[key] = value continue } redacted[key] = SENSITIVE_ENV_KEY.test(key) ? "[REDACTED]" : value } return redacted } interface LaunchOptions { workspaceId: string folder: string binaryPath: string environment?: Record onExit?: (info: ProcessExitInfo) => void } export interface ProcessExitInfo { workspaceId: string code: number | null signal: NodeJS.Signals | null requested: boolean } interface ManagedProcess { child: ChildProcess requestedStop: boolean } export class WorkspaceRuntime { private processes = new Map() constructor(private readonly eventBus: EventBus, private readonly logger: Logger) {} async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise; getLastOutput: () => string }> { this.validateFolder(options.folder) const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"] const env = { ...process.env, ...(options.environment ?? {}) } let exitResolve: ((info: ProcessExitInfo) => void) | null = null const exitPromise = new Promise((resolveExit) => { exitResolve = resolveExit }) // Store recent output for debugging - keep last 50 lines from each stream const MAX_OUTPUT_LINES = 50 const recentStdout: string[] = [] const recentStderr: string[] = [] const getLastOutput = () => { const combined: string[] = [] if (recentStderr.length > 0) { combined.push("Error Stream") combined.push(...recentStderr.slice(-10)) } if (recentStdout.length > 0) { combined.push("Output Stream") combined.push(...recentStdout.slice(-10)) } return combined.join("\n") } return new Promise((resolve, reject) => { const commandLine = [options.binaryPath, ...args].join(" ") this.logger.info( { workspaceId: options.workspaceId, folder: options.folder, binary: options.binaryPath, args, commandLine, env: redactEnvironment(env), }, "Launching OpenCode process", ) const child = spawn(options.binaryPath, args, { cwd: options.folder, env, stdio: ["ignore", "pipe", "pipe"], }) const managed: ManagedProcess = { child, requestedStop: false } this.processes.set(options.workspaceId, managed) let stdoutBuffer = "" let stderrBuffer = "" let portFound = false let warningTimer: NodeJS.Timeout | null = null const startWarningTimer = () => { warningTimer = setInterval(() => { this.logger.warn({ workspaceId: options.workspaceId }, "Workspace runtime has not reported a port yet") }, 10000) } const stopWarningTimer = () => { if (warningTimer) { clearInterval(warningTimer) warningTimer = null } } startWarningTimer() const cleanupStreams = () => { stopWarningTimer() child.stdout?.removeAllListeners() child.stderr?.removeAllListeners() } const handleExit = (code: number | null, signal: NodeJS.Signals | null) => { this.logger.info({ workspaceId: options.workspaceId, code, signal }, "OpenCode process exited") this.processes.delete(options.workspaceId) cleanupStreams() child.removeListener("error", handleError) child.removeListener("exit", handleExit) const exitInfo: ProcessExitInfo = { workspaceId: options.workspaceId, code, signal, requested: managed.requestedStop, } if (exitResolve) { exitResolve(exitInfo) exitResolve = null } if (!portFound) { const recentOutput = getLastOutput().trim() const reason = recentOutput || stderrBuffer || `Process exited with code ${code}` reject(new Error(reason)) } else { options.onExit?.(exitInfo) } } const handleError = (error: Error) => { cleanupStreams() child.removeListener("exit", handleExit) this.processes.delete(options.workspaceId) this.logger.error({ workspaceId: options.workspaceId, err: error }, "Workspace runtime error") if (exitResolve) { exitResolve({ workspaceId: options.workspaceId, code: null, signal: null, requested: managed.requestedStop }) exitResolve = null } reject(error) } child.on("error", handleError) child.on("exit", handleExit) child.stdout?.on("data", (data: Buffer) => { const text = data.toString() stdoutBuffer += text const lines = stdoutBuffer.split("\n") stdoutBuffer = lines.pop() ?? "" for (const line of lines) { const trimmed = line.trim() if (!trimmed) continue recentStdout.push(trimmed) if (recentStdout.length > MAX_OUTPUT_LINES) { recentStdout.shift() } this.emitLog(options.workspaceId, "info", line) if (!portFound) { const portMatch = line.match(/opencode server listening on http:\/\/.+:(\d+)/i) if (portMatch) { portFound = true stopWarningTimer() child.removeListener("error", handleError) const port = parseInt(portMatch[1], 10) this.logger.info({ workspaceId: options.workspaceId, port }, "Workspace runtime allocated port") resolve({ pid: child.pid!, port, exitPromise, getLastOutput }) } } } }) child.stderr?.on("data", (data: Buffer) => { const text = data.toString() stderrBuffer += text const lines = stderrBuffer.split("\n") stderrBuffer = lines.pop() ?? "" for (const line of lines) { const trimmed = line.trim() if (!trimmed) continue recentStderr.push(trimmed) if (recentStderr.length > MAX_OUTPUT_LINES) { recentStderr.shift() } this.emitLog(options.workspaceId, "error", line) } }) }) } async stop(workspaceId: string): Promise { const managed = this.processes.get(workspaceId) if (!managed) return managed.requestedStop = true const child = managed.child this.logger.info({ workspaceId }, "Stopping OpenCode process") await new Promise((resolve, reject) => { const cleanup = () => { child.removeListener("exit", onExit) child.removeListener("error", onError) } const onExit = () => { cleanup() resolve() } const onError = (error: Error) => { cleanup() reject(error) } const resolveIfAlreadyExited = () => { if (child.exitCode !== null || child.signalCode !== null) { this.logger.debug({ workspaceId, exitCode: child.exitCode, signal: child.signalCode }, "Process already exited") cleanup() resolve() return true } return false } child.once("exit", onExit) child.once("error", onError) if (resolveIfAlreadyExited()) { return } this.logger.debug({ workspaceId }, "Sending SIGTERM to workspace process") child.kill("SIGTERM") setTimeout(() => { if (!child.killed) { this.logger.warn({ workspaceId }, "Process did not stop after SIGTERM, force killing") child.kill("SIGKILL") } else { this.logger.debug({ workspaceId }, "Workspace process stopped gracefully before SIGKILL timeout") } }, 2000) }) } private emitLog(workspaceId: string, level: LogLevel, message: string) { const entry: WorkspaceLogEntry = { workspaceId, timestamp: new Date().toISOString(), level, message: message.trim(), } this.eventBus.publish({ type: "workspace.log", entry }) } private validateFolder(folder: string) { const resolved = path.resolve(folder) if (!existsSync(resolved)) { throw new Error(`Folder does not exist: ${resolved}`) } const stats = statSync(resolved) if (!stats.isDirectory()) { throw new Error(`Path is not a directory: ${resolved}`) } } }