mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
move OpenCode ownership into server
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
91
apps/server/src/managed-opencode.ts
Normal file
91
apps/server/src/managed-opencode.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user