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({
|
const IDLE_ENGINE_INFO = Object.freeze({
|
||||||
running: false,
|
running: false,
|
||||||
runtime: "openwork-orchestrator",
|
runtime: "direct",
|
||||||
baseUrl: null,
|
baseUrl: null,
|
||||||
projectDir: null,
|
projectDir: null,
|
||||||
hostname: null,
|
hostname: null,
|
||||||
@@ -412,7 +412,7 @@ async function bootRuntimeForSelectedWorkspace() {
|
|||||||
if (!workspacePaths.includes(workspaceRoot)) workspacePaths.unshift(workspaceRoot);
|
if (!workspacePaths.includes(workspaceRoot)) workspacePaths.unshift(workspaceRoot);
|
||||||
|
|
||||||
const engine = await runtimeManager.engineStart(workspaceRoot, {
|
const engine = await runtimeManager.engineStart(workspaceRoot, {
|
||||||
runtime: "openwork-orchestrator",
|
runtime: "direct",
|
||||||
workspacePaths,
|
workspacePaths,
|
||||||
});
|
});
|
||||||
await runtimeManager.orchestratorWorkspaceActivate({
|
await runtimeManager.orchestratorWorkspaceActivate({
|
||||||
|
|||||||
@@ -807,6 +807,8 @@ export function createRuntimeManager({ app, desktopRoot, listLocalWorkspacePaths
|
|||||||
const env = await buildChildEnv({
|
const env = await buildChildEnv({
|
||||||
OPENWORK_TOKEN: tokens.clientToken,
|
OPENWORK_TOKEN: tokens.clientToken,
|
||||||
OPENWORK_HOST_TOKEN: tokens.hostToken,
|
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.routerHealthPort ? { OPENCODE_ROUTER_HEALTH_PORT: String(options.routerHealthPort) } : {}),
|
||||||
...(options.opencodeUsername ? { OPENWORK_OPENCODE_USERNAME: options.opencodeUsername } : {}),
|
...(options.opencodeUsername ? { OPENWORK_OPENCODE_USERNAME: options.opencodeUsername } : {}),
|
||||||
...(options.opencodePassword ? { OPENWORK_OPENCODE_PASSWORD: options.opencodePassword } : {}),
|
...(options.opencodePassword ? { OPENWORK_OPENCODE_PASSWORD: options.opencodePassword } : {}),
|
||||||
@@ -835,6 +837,28 @@ export function createRuntimeManager({ app, desktopRoot, listLocalWorkspacePaths
|
|||||||
if (ownerToken) {
|
if (ownerToken) {
|
||||||
await persistWorkspaceOwnerToken(activeWorkspace, 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);
|
await persistPreferredOpenworkPort(activeWorkspace, port);
|
||||||
return snapshotOpenworkServerState(openworkServerState);
|
return snapshotOpenworkServerState(openworkServerState);
|
||||||
}
|
}
|
||||||
@@ -1040,6 +1064,8 @@ export function createRuntimeManager({ app, desktopRoot, listLocalWorkspacePaths
|
|||||||
opencodePassword: engineState.opencodePassword,
|
opencodePassword: engineState.opencodePassword,
|
||||||
routerHealthPort,
|
routerHealthPort,
|
||||||
remoteAccessEnabled: options.remoteAccessEnabled,
|
remoteAccessEnabled: options.remoteAccessEnabled,
|
||||||
|
manageOpencode: options.manageOpencode === true,
|
||||||
|
opencodeBinPath: options.opencodeBinPath,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
appendOutput(engineState, "lastStderr", `OpenWork server: ${error instanceof Error ? error.message : String(error)}\n`);
|
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(
|
const workspacePaths = [safeProjectDir, ...((options.workspacePaths ?? []).filter(Boolean))].filter(
|
||||||
(value, index, list) => list.indexOf(value) === index,
|
(value, index, list) => list.indexOf(value) === index,
|
||||||
);
|
);
|
||||||
const runtime = options.runtime ?? ORCHESTRATOR_RUNTIME;
|
const runtime = DIRECT_RUNTIME;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
lifecycleState = "starting";
|
lifecycleState = "starting";
|
||||||
const snapshot = runtime === ORCHESTRATOR_RUNTIME
|
engineState.runtime = runtime;
|
||||||
? await startOrchestratorRuntime(safeProjectDir, options)
|
engineState.projectDir = safeProjectDir;
|
||||||
: await startDirectRuntime(safeProjectDir, options);
|
engineState.child = null;
|
||||||
|
engineState.childExited = true;
|
||||||
|
|
||||||
await ensureRouterAndOpenwork({
|
await ensureRouterAndOpenwork({
|
||||||
projectDir: safeProjectDir,
|
projectDir: safeProjectDir,
|
||||||
workspacePaths,
|
workspacePaths,
|
||||||
remoteAccessEnabled: options.openworkRemoteAccess === true,
|
remoteAccessEnabled: options.openworkRemoteAccess === true,
|
||||||
|
manageOpencode: true,
|
||||||
|
opencodeBinPath: options.opencodeBinPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
lifecycleState = "healthy";
|
lifecycleState = "healthy";
|
||||||
return snapshot;
|
return snapshotEngineState(engineState);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lifecycleState = "error";
|
lifecycleState = "error";
|
||||||
throw error;
|
throw error;
|
||||||
@@ -1144,74 +1173,54 @@ export function createRuntimeManager({ app, desktopRoot, listLocalWorkspacePaths
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function orchestratorStatus() {
|
async function orchestratorStatus() {
|
||||||
const dataDir = orchestratorState.dataDir || orchestratorDataDir();
|
const engine = snapshotEngineState(engineState);
|
||||||
const stateFile = await readOrchestratorStateFile(dataDir);
|
const openworkServer = snapshotOpenworkServerState(openworkServerState);
|
||||||
const baseUrl = stateFile?.daemon?.baseUrl?.trim();
|
const workspaces = engine.projectDir
|
||||||
let health = null;
|
? [{ id: normalizeWorkspaceKey(engine.projectDir), path: engine.projectDir, name: path.basename(engine.projectDir) || "Workspace" }]
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
running: Boolean(health?.ok || stateFile?.daemon),
|
running: engine.running,
|
||||||
dataDir,
|
dataDir: null,
|
||||||
daemon: health?.daemon ?? stateFile?.daemon ?? null,
|
daemon: openworkServer.running
|
||||||
opencode: health?.opencode ?? stateFile?.opencode ?? null,
|
? { baseUrl: openworkServer.baseUrl, port: openworkServer.port, pid: openworkServer.pid, runtime: "direct" }
|
||||||
cliVersion: health?.cliVersion ?? stateFile?.cliVersion ?? null,
|
: null,
|
||||||
sidecar: health?.sidecar ?? stateFile?.sidecar ?? null,
|
opencode: engine.running
|
||||||
binaries: health?.binaries ?? stateFile?.binaries ?? null,
|
? { baseUrl: engine.baseUrl, port: engine.port, pid: engine.pid, projectDir: engine.projectDir, runtime: "direct" }
|
||||||
activeId: health?.activeId ?? stateFile?.activeId ?? null,
|
: null,
|
||||||
workspaceCount: typeof health?.workspaceCount === "number" ? health.workspaceCount : workspaces.length,
|
cliVersion: null,
|
||||||
|
sidecar: null,
|
||||||
|
binaries: null,
|
||||||
|
activeId: workspaces[0]?.id ?? null,
|
||||||
|
workspaceCount: workspaces.length,
|
||||||
workspaces,
|
workspaces,
|
||||||
lastError: orchestratorState.lastStderr,
|
lastError: engine.lastStderr,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function orchestratorWorkspaceActivate(input) {
|
async function orchestratorWorkspaceActivate(input) {
|
||||||
const baseUrl = await resolveOrchestratorBaseUrl();
|
const workspacePath = String(input?.workspacePath ?? "").trim();
|
||||||
const payload = { path: input.workspacePath, name: input.name ?? null };
|
if (!workspacePath) {
|
||||||
const added = await fetchJson(`${baseUrl.replace(/\/+$/, "")}/workspaces`, {
|
throw new Error("workspacePath is required");
|
||||||
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.");
|
|
||||||
}
|
}
|
||||||
await fetch(`${baseUrl.replace(/\/+$/, "")}/workspaces/${id}/activate`, { method: "POST" });
|
const resolved = path.resolve(workspacePath);
|
||||||
return added.workspace;
|
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) {
|
async function orchestratorInstanceDispose(workspacePath) {
|
||||||
const baseUrl = await resolveOrchestratorBaseUrl();
|
if (normalizeWorkspaceKey(engineState.projectDir) === normalizeWorkspaceKey(workspacePath)) {
|
||||||
const added = await fetchJson(`${baseUrl.replace(/\/+$/, "")}/workspaces`, {
|
return true;
|
||||||
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.");
|
|
||||||
}
|
}
|
||||||
const response = await fetchJson(`${baseUrl.replace(/\/+$/, "")}/instances/${id}/dispose`, {
|
return true;
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: "",
|
|
||||||
}, 5000);
|
|
||||||
return response?.disposed === true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function opencodeRouterInfo() {
|
async function opencodeRouterInfo() {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
import { parseCliArgs, printHelp, resolveServerConfig } from "./config.js";
|
import { parseCliArgs, printHelp, resolveServerConfig } from "./config.js";
|
||||||
|
import { createManagedOpencodeServer, type ManagedOpencodeServer } from "./managed-opencode.js";
|
||||||
import { createServerLogger, startServer } from "./server.js";
|
import { createServerLogger, startServer } from "./server.js";
|
||||||
import pkg from "../package.json" with { type: "json" };
|
import pkg from "../package.json" with { type: "json" };
|
||||||
|
|
||||||
@@ -18,6 +19,31 @@ if (args.version) {
|
|||||||
|
|
||||||
const config = await resolveServerConfig(args);
|
const config = await resolveServerConfig(args);
|
||||||
const logger = createServerLogger(config);
|
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 server = startServer(config);
|
||||||
|
|
||||||
const url = `http://${config.host}:${server.port}`;
|
const url = `http://${config.host}:${server.port}`;
|
||||||
@@ -46,3 +72,17 @@ if (args.verbose) {
|
|||||||
logger.log("info", `Token source: ${config.tokenSource}`);
|
logger.log("info", `Token source: ${config.tokenSource}`);
|
||||||
logger.log("info", `Host token source: ${config.hostTokenSource}`);
|
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