diff --git a/apps/desktop/electron/main.mjs b/apps/desktop/electron/main.mjs index 24d2c7cc..4257e57b 100644 --- a/apps/desktop/electron/main.mjs +++ b/apps/desktop/electron/main.mjs @@ -90,7 +90,7 @@ const EMPTY_WORKSPACE_LIST = Object.freeze({ const IDLE_ENGINE_INFO = Object.freeze({ running: false, - runtime: "openwork-orchestrator", + runtime: "direct", baseUrl: null, projectDir: null, hostname: null, @@ -412,7 +412,7 @@ async function bootRuntimeForSelectedWorkspace() { if (!workspacePaths.includes(workspaceRoot)) workspacePaths.unshift(workspaceRoot); const engine = await runtimeManager.engineStart(workspaceRoot, { - runtime: "openwork-orchestrator", + runtime: "direct", workspacePaths, }); await runtimeManager.orchestratorWorkspaceActivate({ diff --git a/apps/desktop/electron/runtime.mjs b/apps/desktop/electron/runtime.mjs index 0566379a..647a88b5 100644 --- a/apps/desktop/electron/runtime.mjs +++ b/apps/desktop/electron/runtime.mjs @@ -807,6 +807,8 @@ export function createRuntimeManager({ app, desktopRoot, listLocalWorkspacePaths const env = await buildChildEnv({ OPENWORK_TOKEN: tokens.clientToken, OPENWORK_HOST_TOKEN: tokens.hostToken, + ...(options.manageOpencode ? { OPENWORK_MANAGE_OPENCODE: "1" } : {}), + ...(options.manageOpencode ? { OPENWORK_OPENCODE_BIN: options.opencodeBinPath || resolveBinary("opencode") || "" } : {}), ...(options.routerHealthPort ? { OPENCODE_ROUTER_HEALTH_PORT: String(options.routerHealthPort) } : {}), ...(options.opencodeUsername ? { OPENWORK_OPENCODE_USERNAME: options.opencodeUsername } : {}), ...(options.opencodePassword ? { OPENWORK_OPENCODE_PASSWORD: options.opencodePassword } : {}), @@ -835,6 +837,28 @@ export function createRuntimeManager({ app, desktopRoot, listLocalWorkspacePaths if (ownerToken) { await persistWorkspaceOwnerToken(activeWorkspace, ownerToken); } + if (ownerToken) { + try { + const list = await fetchJson(`${baseUrl}/workspaces`, { + headers: { Authorization: `Bearer ${ownerToken}` }, + }, 5000); + const first = Array.isArray(list?.items) ? list.items[0] : undefined; + const opencode = first?.opencode; + if (opencode?.baseUrl) { + engineState.runtime = DIRECT_RUNTIME; + engineState.projectDir = opencode.directory ?? activeWorkspace ?? null; + engineState.hostname = new URL(opencode.baseUrl).hostname; + engineState.port = Number(new URL(opencode.baseUrl).port) || null; + engineState.baseUrl = opencode.baseUrl; + engineState.opencodeUsername = opencode.username ?? null; + engineState.opencodePassword = opencode.password ?? null; + engineState.child = null; + engineState.childExited = false; + } + } catch (error) { + appendOutput(openworkServerState, "lastStderr", `OpenWork server workspace probe: ${error instanceof Error ? error.message : String(error)}\n`); + } + } await persistPreferredOpenworkPort(activeWorkspace, port); return snapshotOpenworkServerState(openworkServerState); } @@ -1040,6 +1064,8 @@ export function createRuntimeManager({ app, desktopRoot, listLocalWorkspacePaths opencodePassword: engineState.opencodePassword, routerHealthPort, remoteAccessEnabled: options.remoteAccessEnabled, + manageOpencode: options.manageOpencode === true, + opencodeBinPath: options.opencodeBinPath, }); } catch (error) { appendOutput(engineState, "lastStderr", `OpenWork server: ${error instanceof Error ? error.message : String(error)}\n`); @@ -1072,22 +1098,25 @@ export function createRuntimeManager({ app, desktopRoot, listLocalWorkspacePaths const workspacePaths = [safeProjectDir, ...((options.workspacePaths ?? []).filter(Boolean))].filter( (value, index, list) => list.indexOf(value) === index, ); - const runtime = options.runtime ?? ORCHESTRATOR_RUNTIME; + const runtime = DIRECT_RUNTIME; try { lifecycleState = "starting"; - const snapshot = runtime === ORCHESTRATOR_RUNTIME - ? await startOrchestratorRuntime(safeProjectDir, options) - : await startDirectRuntime(safeProjectDir, options); + engineState.runtime = runtime; + engineState.projectDir = safeProjectDir; + engineState.child = null; + engineState.childExited = true; await ensureRouterAndOpenwork({ projectDir: safeProjectDir, workspacePaths, remoteAccessEnabled: options.openworkRemoteAccess === true, + manageOpencode: true, + opencodeBinPath: options.opencodeBinPath, }); lifecycleState = "healthy"; - return snapshot; + return snapshotEngineState(engineState); } catch (error) { lifecycleState = "error"; throw error; @@ -1144,74 +1173,54 @@ export function createRuntimeManager({ app, desktopRoot, listLocalWorkspacePaths } async function orchestratorStatus() { - const dataDir = orchestratorState.dataDir || orchestratorDataDir(); - const stateFile = await readOrchestratorStateFile(dataDir); - const baseUrl = stateFile?.daemon?.baseUrl?.trim(); - let health = null; - let workspaces = stateFile?.workspaces ?? []; - if (baseUrl) { - try { - health = await fetchJson(`${baseUrl}/health`, {}, 250); - } catch { - health = null; - } - try { - const list = await fetchJson(`${baseUrl}/workspaces`, {}, 250); - if (Array.isArray(list?.workspaces)) { - workspaces = list.workspaces; - } - } catch { - // ignore - } - } + const engine = snapshotEngineState(engineState); + const openworkServer = snapshotOpenworkServerState(openworkServerState); + const workspaces = engine.projectDir + ? [{ id: normalizeWorkspaceKey(engine.projectDir), path: engine.projectDir, name: path.basename(engine.projectDir) || "Workspace" }] + : []; return { - running: Boolean(health?.ok || stateFile?.daemon), - dataDir, - daemon: health?.daemon ?? stateFile?.daemon ?? null, - opencode: health?.opencode ?? stateFile?.opencode ?? null, - cliVersion: health?.cliVersion ?? stateFile?.cliVersion ?? null, - sidecar: health?.sidecar ?? stateFile?.sidecar ?? null, - binaries: health?.binaries ?? stateFile?.binaries ?? null, - activeId: health?.activeId ?? stateFile?.activeId ?? null, - workspaceCount: typeof health?.workspaceCount === "number" ? health.workspaceCount : workspaces.length, + running: engine.running, + dataDir: null, + daemon: openworkServer.running + ? { baseUrl: openworkServer.baseUrl, port: openworkServer.port, pid: openworkServer.pid, runtime: "direct" } + : null, + opencode: engine.running + ? { baseUrl: engine.baseUrl, port: engine.port, pid: engine.pid, projectDir: engine.projectDir, runtime: "direct" } + : null, + cliVersion: null, + sidecar: null, + binaries: null, + activeId: workspaces[0]?.id ?? null, + workspaceCount: workspaces.length, workspaces, - lastError: orchestratorState.lastStderr, + lastError: engine.lastStderr, }; } async function orchestratorWorkspaceActivate(input) { - const baseUrl = await resolveOrchestratorBaseUrl(); - const payload = { path: input.workspacePath, name: input.name ?? null }; - const added = await fetchJson(`${baseUrl.replace(/\/+$/, "")}/workspaces`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }, 5000); - const id = added?.workspace?.id; - if (!id) { - throw new Error("Failed to add workspace."); + const workspacePath = String(input?.workspacePath ?? "").trim(); + if (!workspacePath) { + throw new Error("workspacePath is required"); } - await fetch(`${baseUrl.replace(/\/+$/, "")}/workspaces/${id}/activate`, { method: "POST" }); - return added.workspace; + const resolved = path.resolve(workspacePath); + if (normalizeWorkspaceKey(engineState.projectDir) !== normalizeWorkspaceKey(resolved)) { + await engineStart(resolved, { + runtime: DIRECT_RUNTIME, + workspacePaths: [resolved], + }); + } + return { + id: normalizeWorkspaceKey(resolved), + path: resolved, + name: input?.name ?? (path.basename(resolved) || "Workspace"), + }; } async function orchestratorInstanceDispose(workspacePath) { - const baseUrl = await resolveOrchestratorBaseUrl(); - const added = await fetchJson(`${baseUrl.replace(/\/+$/, "")}/workspaces`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ path: workspacePath }), - }, 5000); - const id = added?.workspace?.id; - if (!id) { - throw new Error("Failed to resolve workspace."); + if (normalizeWorkspaceKey(engineState.projectDir) === normalizeWorkspaceKey(workspacePath)) { + return true; } - const response = await fetchJson(`${baseUrl.replace(/\/+$/, "")}/instances/${id}/dispose`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: "", - }, 5000); - return response?.disposed === true; + return true; } async function opencodeRouterInfo() { diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts index 1a5aefa7..9357322e 100644 --- a/apps/server/src/cli.ts +++ b/apps/server/src/cli.ts @@ -1,6 +1,7 @@ #!/usr/bin/env bun import { parseCliArgs, printHelp, resolveServerConfig } from "./config.js"; +import { createManagedOpencodeServer, type ManagedOpencodeServer } from "./managed-opencode.js"; import { createServerLogger, startServer } from "./server.js"; import pkg from "../package.json" with { type: "json" }; @@ -18,6 +19,31 @@ if (args.version) { const config = await resolveServerConfig(args); const logger = createServerLogger(config); +let managedOpencode: ManagedOpencodeServer | null = null; + +if (!config.opencodeBaseUrl && process.env.OPENWORK_MANAGE_OPENCODE === "1") { + const workspace = config.workspaces[0]; + if (workspace?.path) { + managedOpencode = await createManagedOpencodeServer({ + bin: process.env.OPENWORK_OPENCODE_BIN, + cwd: workspace.path, + env: { + ...(process.env.OPENWORK_DEV_MODE ? { OPENWORK_DEV_MODE: process.env.OPENWORK_DEV_MODE } : {}), + }, + }); + config.opencodeBaseUrl = managedOpencode.url; + config.opencodeUsername = managedOpencode.username; + config.opencodePassword = managedOpencode.password; + for (const entry of config.workspaces) { + entry.baseUrl ??= managedOpencode.url; + entry.opencodeUsername ??= managedOpencode.username; + entry.opencodePassword ??= managedOpencode.password; + entry.directory ??= entry.path; + } + logger.log("info", `Managed OpenCode listening on ${managedOpencode.url}`); + } +} + const server = startServer(config); const url = `http://${config.host}:${server.port}`; @@ -46,3 +72,17 @@ if (args.verbose) { logger.log("info", `Token source: ${config.tokenSource}`); logger.log("info", `Host token source: ${config.hostTokenSource}`); } + +const shutdown = () => { + managedOpencode?.close(); + (server as { stop?: (closeActiveConnections?: boolean) => void }).stop?.(true); +}; + +process.once("SIGINT", () => { + shutdown(); + process.exit(0); +}); +process.once("SIGTERM", () => { + shutdown(); + process.exit(0); +}); diff --git a/apps/server/src/managed-opencode.ts b/apps/server/src/managed-opencode.ts new file mode 100644 index 00000000..a99c5d42 --- /dev/null +++ b/apps/server/src/managed-opencode.ts @@ -0,0 +1,91 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import net from "node:net"; +import { randomUUID } from "node:crypto"; + +export type ManagedOpencodeServer = { + url: string; + username: string; + password: string; + pid: number | null; + close: () => void; +}; + +function randomSecret(): string { + return randomUUID().replace(/-/g, "") + randomUUID().replace(/-/g, ""); +} + +async function findFreePort(hostname: string): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, hostname, () => { + const address = server.address(); + server.close(() => { + if (address && typeof address === "object") resolve(address.port); + else reject(new Error("Failed to resolve free port")); + }); + }); + }); +} + +export async function createManagedOpencodeServer(options: { + bin?: string; + cwd: string; + hostname?: string; + port?: number; + timeoutMs?: number; + env?: Record; +}): Promise { + const hostname = options.hostname ?? "127.0.0.1"; + const port = options.port ?? await findFreePort(hostname); + const username = randomSecret(); + const password = randomSecret(); + const args = ["serve", "--hostname", hostname, "--port", String(port), "--cors", "*"]; + const child: ChildProcess = spawn(options.bin?.trim() || "opencode", args, { + cwd: options.cwd, + env: { + ...process.env, + ...options.env, + OPENCODE_SERVER_USERNAME: username, + OPENCODE_SERVER_PASSWORD: password, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + const url = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(`Timeout waiting for OpenCode server after ${options.timeoutMs ?? 15000}ms`)), options.timeoutMs ?? 15000); + let output = ""; + const done = (value: string) => { + clearTimeout(timeout); + resolve(value); + }; + const fail = (error: Error) => { + clearTimeout(timeout); + reject(error); + }; + child.stdout?.on("data", (chunk) => { + output += chunk.toString(); + for (const line of output.split("\n")) { + if (!line.startsWith("opencode server listening")) continue; + const match = line.match(/on\s+(https?:\/\/[^\s]+)/); + if (!match?.[1]) return fail(new Error(`Failed to parse OpenCode server URL from: ${line}`)); + done(match[1]); + } + }); + child.stderr?.on("data", (chunk) => { + output += chunk.toString(); + }); + child.once("error", fail); + child.once("exit", (code) => fail(new Error(`OpenCode server exited with code ${code}${output.trim() ? `\n${output}` : ""}`))); + }); + + return { + url, + username, + password, + pid: child.pid ?? null, + close() { + if (!child.killed) child.kill(); + }, + }; +}