move OpenCode ownership into server

This commit is contained in:
Benjamin Shafii
2026-04-24 14:56:22 -07:00
parent f243f2e0ed
commit e64eb9366f
4 changed files with 204 additions and 64 deletions

View File

@@ -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({

View File

@@ -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() {

View File

@@ -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);
});

View File

@@ -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<number> {
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<string, string | undefined>;
}): Promise<ManagedOpencodeServer> {
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<string>((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();
},
};
}