mirror of
https://github.com/different-ai/openwork
synced 2026-04-26 01:25:10 +02:00
* feat(desktop): electron 1:1 port alongside Tauri, fix workspace-create visibility Adds an Electron shell that mirrors the Tauri desktop runtime (bridge, dialogs, deep links, runtime supervision for openwork-server / opencode / opencode-router / orchestrator, packaging via electron-builder). Tauri dev/build scripts remain the default; Electron runs via dev:electron and package:electron. Also fixes the "workspace I just created is invisible until I restart the app" bug: the React routes only wrote to desktop-side state, so the running openwork-server never learned about the new workspace and the sidebar (which is populated from the server list) dropped it. The create flow now also calls openworkClient.createLocalWorkspace so POST /workspaces/local registers the workspace at runtime. Other small fixes included: - Clears the "OpenWork server Disconnected" flash caused by React 18 StrictMode double-invoking the connection stores' start/dispose pair. - Real app icon wired into Electron (dock + BrowserWindow + builder). - Fix a latent rm-import bug in runtime.mjs that silently skipped orchestrator auth cleanup. - Locale copy updated to say "OpenWork desktop app" instead of "Tauri app". - Adds description/author to apps/desktop/package.json to silence electron-builder warnings. * docs(prds): Tauri → Electron migration plan Describes how we'll cut every current Tauri user over to the Electron build via the existing Tauri updater (one last migration release that downloads + launches the Electron installer), how we unify app identity so Electron reads the same userData Tauri wrote (zero-copy data migration), and how ongoing auto-updates switch to electron-updater publishing to the same GitHub releases. --------- Co-authored-by: Benjamin Shafii <benjamin@openworklabs.com>
1551 lines
49 KiB
JavaScript
1551 lines
49 KiB
JavaScript
import { randomUUID } from "node:crypto";
|
|
import { spawn, spawnSync } from "node:child_process";
|
|
import { existsSync } from "node:fs";
|
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
import net from "node:net";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
const DIRECT_RUNTIME = "direct";
|
|
const ORCHESTRATOR_RUNTIME = "openwork-orchestrator";
|
|
const OPENWORK_SERVER_PORT_RANGE_START = 48_000;
|
|
const OPENWORK_SERVER_PORT_RANGE_END = 51_000;
|
|
|
|
function truncateOutput(value, limit = 8000) {
|
|
const text = String(value ?? "");
|
|
return text.length <= limit ? text : text.slice(text.length - limit);
|
|
}
|
|
|
|
function appendOutput(state, key, chunk) {
|
|
const next = `${state[key] ?? ""}${String(chunk ?? "")}`;
|
|
state[key] = truncateOutput(next);
|
|
}
|
|
|
|
function normalizeWorkspaceKey(value) {
|
|
const trimmed = String(value ?? "").trim();
|
|
if (!trimmed) return "";
|
|
return path.resolve(trimmed).replace(/\\/g, "/").toLowerCase();
|
|
}
|
|
|
|
function nowMs() {
|
|
return Date.now();
|
|
}
|
|
|
|
function createEngineState() {
|
|
return {
|
|
child: null,
|
|
childExited: true,
|
|
runtime: DIRECT_RUNTIME,
|
|
projectDir: null,
|
|
hostname: null,
|
|
port: null,
|
|
baseUrl: null,
|
|
opencodeUsername: null,
|
|
opencodePassword: null,
|
|
lastStdout: null,
|
|
lastStderr: null,
|
|
};
|
|
}
|
|
|
|
function snapshotEngineState(state) {
|
|
const child = state.childExited ? null : state.child;
|
|
return {
|
|
running: Boolean(child && child.exitCode === null && !child.killed),
|
|
runtime: state.runtime,
|
|
baseUrl: state.baseUrl,
|
|
projectDir: state.projectDir,
|
|
hostname: state.hostname,
|
|
port: state.port,
|
|
opencodeUsername: state.opencodeUsername,
|
|
opencodePassword: state.opencodePassword,
|
|
pid: child?.pid ?? null,
|
|
lastStdout: state.lastStdout,
|
|
lastStderr: state.lastStderr,
|
|
};
|
|
}
|
|
|
|
function createOpenworkServerState() {
|
|
return {
|
|
child: null,
|
|
childExited: true,
|
|
remoteAccessEnabled: false,
|
|
host: null,
|
|
port: null,
|
|
baseUrl: null,
|
|
connectUrl: null,
|
|
mdnsUrl: null,
|
|
lanUrl: null,
|
|
clientToken: null,
|
|
ownerToken: null,
|
|
hostToken: null,
|
|
lastStdout: null,
|
|
lastStderr: null,
|
|
};
|
|
}
|
|
|
|
function snapshotOpenworkServerState(state) {
|
|
const child = state.childExited ? null : state.child;
|
|
return {
|
|
running: Boolean(child && child.exitCode === null && !child.killed),
|
|
remoteAccessEnabled: state.remoteAccessEnabled,
|
|
host: state.host,
|
|
port: state.port,
|
|
baseUrl: state.baseUrl,
|
|
connectUrl: state.connectUrl,
|
|
mdnsUrl: state.mdnsUrl,
|
|
lanUrl: state.lanUrl,
|
|
clientToken: state.clientToken,
|
|
ownerToken: state.ownerToken,
|
|
hostToken: state.hostToken,
|
|
pid: child?.pid ?? null,
|
|
lastStdout: state.lastStdout,
|
|
lastStderr: state.lastStderr,
|
|
};
|
|
}
|
|
|
|
function createOrchestratorState() {
|
|
return {
|
|
child: null,
|
|
childExited: true,
|
|
dataDir: null,
|
|
baseUrl: null,
|
|
daemonPort: null,
|
|
lastStdout: null,
|
|
lastStderr: null,
|
|
};
|
|
}
|
|
|
|
function createRouterState() {
|
|
return {
|
|
child: null,
|
|
childExited: true,
|
|
version: null,
|
|
workspacePath: null,
|
|
opencodeUrl: null,
|
|
healthPort: null,
|
|
lastStdout: null,
|
|
lastStderr: null,
|
|
};
|
|
}
|
|
|
|
function snapshotRouterState(state) {
|
|
const child = state.childExited ? null : state.child;
|
|
return {
|
|
running: Boolean(child && child.exitCode === null && !child.killed),
|
|
version: state.version,
|
|
workspacePath: state.workspacePath,
|
|
opencodeUrl: state.opencodeUrl,
|
|
healthPort: state.healthPort,
|
|
pid: child?.pid ?? null,
|
|
lastStdout: state.lastStdout,
|
|
lastStderr: state.lastStderr,
|
|
};
|
|
}
|
|
|
|
async function fileExists(targetPath) {
|
|
try {
|
|
await readFile(targetPath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function readJsonFile(targetPath, fallback) {
|
|
try {
|
|
const raw = await readFile(targetPath, "utf8");
|
|
return JSON.parse(raw);
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function selectLanAddress() {
|
|
const interfaces = os.networkInterfaces();
|
|
for (const entries of Object.values(interfaces)) {
|
|
for (const entry of entries ?? []) {
|
|
if (entry && entry.family === "IPv4" && entry.internal === false) {
|
|
return entry.address;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function buildConnectUrls(port) {
|
|
const hostname = os.hostname().trim();
|
|
const mdnsUrl = hostname ? `http://${hostname.replace(/\.local$/i, "")}.local:${port}` : null;
|
|
const lan = selectLanAddress();
|
|
const lanUrl = lan ? `http://${lan}:${port}` : null;
|
|
return {
|
|
connectUrl: lanUrl ?? mdnsUrl,
|
|
mdnsUrl,
|
|
lanUrl,
|
|
};
|
|
}
|
|
|
|
function targetTriple() {
|
|
if (process.platform === "darwin") {
|
|
return process.arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin";
|
|
}
|
|
if (process.platform === "linux") {
|
|
return process.arch === "arm64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu";
|
|
}
|
|
if (process.platform === "win32") {
|
|
return process.arch === "arm64" ? "aarch64-pc-windows-msvc" : "x86_64-pc-windows-msvc";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function binaryFileNames(baseName) {
|
|
const ext = process.platform === "win32" ? ".exe" : "";
|
|
const triple = targetTriple();
|
|
return [
|
|
`${baseName}${ext}`,
|
|
triple ? `${baseName}-${triple}${ext}` : null,
|
|
].filter(Boolean);
|
|
}
|
|
|
|
function prependedPath(sidecarDirs) {
|
|
const filtered = sidecarDirs.filter((dir) => existsSync(dir));
|
|
if (filtered.length === 0) return null;
|
|
return `${filtered.join(path.delimiter)}${path.delimiter}${process.env.PATH ?? ""}`;
|
|
}
|
|
|
|
async function portAvailable(host, port) {
|
|
return new Promise((resolve) => {
|
|
const server = net.createServer();
|
|
server.unref();
|
|
server.once("error", () => resolve(false));
|
|
server.listen({ host, port }, () => {
|
|
server.close(() => resolve(true));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function findFreePort(host = "127.0.0.1") {
|
|
return new Promise((resolve, reject) => {
|
|
const server = net.createServer();
|
|
server.unref();
|
|
server.once("error", reject);
|
|
server.listen({ host, port: 0 }, () => {
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
server.close(() => reject(new Error("Failed to allocate a free port.")));
|
|
return;
|
|
}
|
|
const { port } = address;
|
|
server.close(() => resolve(port));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function waitForHttpOk(url, timeoutMs) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
let lastError = "Request did not succeed.";
|
|
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const response = await fetch(url);
|
|
if (response.ok) {
|
|
return response;
|
|
}
|
|
lastError = `HTTP ${response.status}`;
|
|
} catch (error) {
|
|
lastError = error instanceof Error ? error.message : String(error);
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
}
|
|
|
|
throw new Error(lastError);
|
|
}
|
|
|
|
async function fetchJson(url, options = {}, timeoutMs = 3000) {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
try {
|
|
const response = await fetch(url, {
|
|
...options,
|
|
signal: controller.signal,
|
|
headers: {
|
|
Accept: "application/json",
|
|
...(options.headers ?? {}),
|
|
},
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
return await response.json();
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
export function createRuntimeManager({ app, desktopRoot, listLocalWorkspacePaths }) {
|
|
const engineState = createEngineState();
|
|
const openworkServerState = createOpenworkServerState();
|
|
const orchestratorState = createOrchestratorState();
|
|
const routerState = createRouterState();
|
|
|
|
const userDataDir = app.getPath("userData");
|
|
const sidecarDirs = [
|
|
path.join(desktopRoot, "src-tauri", "sidecars"),
|
|
process.resourcesPath ? path.join(process.resourcesPath, "sidecars") : null,
|
|
path.join(path.dirname(app.getPath("exe")), "sidecars"),
|
|
].filter(Boolean);
|
|
|
|
function openworkServerTokenStorePath() {
|
|
return path.join(userDataDir, "openwork-server-tokens.json");
|
|
}
|
|
|
|
function openworkServerStatePath() {
|
|
return path.join(userDataDir, "openwork-server-state.json");
|
|
}
|
|
|
|
function orchestratorDataDir() {
|
|
const envDir = process.env.OPENWORK_DATA_DIR?.trim();
|
|
if (envDir) return envDir;
|
|
return path.join(app.getPath("home"), ".openwork", "openwork-orchestrator");
|
|
}
|
|
|
|
function orchestratorStatePath(dataDir) {
|
|
return path.join(dataDir, "openwork-orchestrator-state.json");
|
|
}
|
|
|
|
function orchestratorAuthPath(dataDir) {
|
|
return path.join(dataDir, "openwork-orchestrator-auth.json");
|
|
}
|
|
|
|
async function readOrchestratorStateFile(dataDir) {
|
|
return readJsonFile(orchestratorStatePath(dataDir), null);
|
|
}
|
|
|
|
async function readOrchestratorAuthFile(dataDir) {
|
|
return readJsonFile(orchestratorAuthPath(dataDir), null);
|
|
}
|
|
|
|
async function writeOrchestratorAuthFile(dataDir, auth) {
|
|
const filePath = orchestratorAuthPath(dataDir);
|
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
await writeFile(filePath, `${JSON.stringify({ ...auth, updatedAt: nowMs() }, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
async function clearOrchestratorAuthFile(dataDir) {
|
|
await rm(orchestratorAuthPath(dataDir), { force: true });
|
|
}
|
|
|
|
async function requestOrchestratorShutdown(dataDir) {
|
|
const state = await readOrchestratorStateFile(dataDir);
|
|
const baseUrl = state?.daemon?.baseUrl?.trim();
|
|
if (!baseUrl) return false;
|
|
try {
|
|
await fetch(`${baseUrl.replace(/\/+$/, "")}/shutdown`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function loadTokenStore() {
|
|
return readJsonFile(openworkServerTokenStorePath(), { version: 1, workspaces: {} });
|
|
}
|
|
|
|
async function saveTokenStore(store) {
|
|
const filePath = openworkServerTokenStorePath();
|
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
await writeFile(filePath, `${JSON.stringify(store, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
async function loadPortState() {
|
|
return readJsonFile(openworkServerStatePath(), {
|
|
version: 3,
|
|
workspacePorts: {},
|
|
preferredPort: null,
|
|
});
|
|
}
|
|
|
|
async function savePortState(state) {
|
|
const filePath = openworkServerStatePath();
|
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
await writeFile(filePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
async function loadOrCreateWorkspaceTokens(workspaceKey) {
|
|
const store = await loadTokenStore();
|
|
const normalized = normalizeWorkspaceKey(workspaceKey);
|
|
if (store.workspaces?.[normalized]) {
|
|
return store.workspaces[normalized];
|
|
}
|
|
const next = {
|
|
clientToken: randomUUID(),
|
|
hostToken: randomUUID(),
|
|
ownerToken: null,
|
|
updatedAt: nowMs(),
|
|
};
|
|
store.workspaces ??= {};
|
|
store.workspaces[normalized] = next;
|
|
await saveTokenStore(store);
|
|
return next;
|
|
}
|
|
|
|
async function persistWorkspaceOwnerToken(workspaceKey, ownerToken) {
|
|
const store = await loadTokenStore();
|
|
const normalized = normalizeWorkspaceKey(workspaceKey);
|
|
if (!store.workspaces?.[normalized]) return;
|
|
store.workspaces[normalized].ownerToken = ownerToken;
|
|
store.workspaces[normalized].updatedAt = nowMs();
|
|
await saveTokenStore(store);
|
|
}
|
|
|
|
async function readPreferredOpenworkPort(workspaceKey) {
|
|
const state = await loadPortState();
|
|
const normalized = normalizeWorkspaceKey(workspaceKey);
|
|
if (normalized && state.workspacePorts?.[normalized]) {
|
|
return state.workspacePorts[normalized];
|
|
}
|
|
return state.preferredPort ?? null;
|
|
}
|
|
|
|
async function persistPreferredOpenworkPort(workspaceKey, port) {
|
|
const state = await loadPortState();
|
|
const normalized = normalizeWorkspaceKey(workspaceKey);
|
|
state.version = 3;
|
|
state.workspacePorts ??= {};
|
|
if (normalized) {
|
|
state.workspacePorts[normalized] = port;
|
|
state.preferredPort = null;
|
|
} else {
|
|
state.preferredPort = port;
|
|
}
|
|
await savePortState(state);
|
|
}
|
|
|
|
async function resolveOpenworkPort(host, workspaceKey) {
|
|
const preferred = await readPreferredOpenworkPort(workspaceKey);
|
|
if (preferred && (await portAvailable(host, preferred))) {
|
|
return preferred;
|
|
}
|
|
|
|
for (let port = OPENWORK_SERVER_PORT_RANGE_START; port <= OPENWORK_SERVER_PORT_RANGE_END; port += 1) {
|
|
if (await portAvailable(host, port)) {
|
|
return port;
|
|
}
|
|
}
|
|
|
|
return findFreePort(host);
|
|
}
|
|
|
|
async function ensureDevModePaths() {
|
|
const root = path.join(userDataDir, "openwork-dev-data");
|
|
const paths = {
|
|
homeDir: path.join(root, "home"),
|
|
xdgConfigHome: path.join(root, "xdg", "config"),
|
|
xdgDataHome: path.join(root, "xdg", "data"),
|
|
xdgCacheHome: path.join(root, "xdg", "cache"),
|
|
xdgStateHome: path.join(root, "xdg", "state"),
|
|
opencodeConfigDir: path.join(root, "config", "opencode"),
|
|
};
|
|
|
|
for (const dir of Object.values(paths)) {
|
|
await mkdir(dir, { recursive: true });
|
|
}
|
|
await mkdir(path.join(paths.xdgDataHome, "opencode"), { recursive: true });
|
|
return paths;
|
|
}
|
|
|
|
async function buildChildEnv(extra = {}) {
|
|
const env = { ...process.env, BUN_CONFIG_DNS_RESULT_ORDER: "verbatim", ...extra };
|
|
const pathEnv = prependedPath(sidecarDirs);
|
|
if (pathEnv) {
|
|
env.PATH = pathEnv;
|
|
}
|
|
if (process.env.OPENWORK_DEV_MODE === "1") {
|
|
const devPaths = await ensureDevModePaths();
|
|
env.OPENWORK_DEV_MODE = "1";
|
|
env.HOME = devPaths.homeDir;
|
|
env.USERPROFILE = devPaths.homeDir;
|
|
env.XDG_CONFIG_HOME = devPaths.xdgConfigHome;
|
|
env.XDG_DATA_HOME = devPaths.xdgDataHome;
|
|
env.XDG_CACHE_HOME = devPaths.xdgCacheHome;
|
|
env.XDG_STATE_HOME = devPaths.xdgStateHome;
|
|
env.OPENCODE_CONFIG_DIR = devPaths.opencodeConfigDir;
|
|
env.OPENCODE_TEST_HOME = devPaths.homeDir;
|
|
}
|
|
return env;
|
|
}
|
|
|
|
function resolveBinary(baseName, extraPaths = []) {
|
|
for (const directory of [...sidecarDirs, ...extraPaths]) {
|
|
for (const fileName of binaryFileNames(baseName)) {
|
|
const candidate = path.join(directory, fileName);
|
|
if (existsSync(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
}
|
|
|
|
const pathEntries = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean);
|
|
for (const entry of pathEntries) {
|
|
for (const fileName of binaryFileNames(baseName)) {
|
|
const candidate = path.join(entry, fileName);
|
|
if (existsSync(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (baseName === "opencode") {
|
|
for (const candidate of [
|
|
path.join(app.getPath("home"), ".opencode", "bin", process.platform === "win32" ? "opencode.exe" : "opencode"),
|
|
path.join("/opt/homebrew/bin", process.platform === "win32" ? "opencode.exe" : "opencode"),
|
|
path.join("/usr/local/bin", process.platform === "win32" ? "opencode.exe" : "opencode"),
|
|
path.join("/usr/bin", process.platform === "win32" ? "opencode.exe" : "opencode"),
|
|
]) {
|
|
if (existsSync(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function resolveDockerCandidates() {
|
|
const candidates = [];
|
|
const seen = new Set();
|
|
|
|
for (const key of ["OPENWORK_DOCKER_BIN", "OPENWRK_DOCKER_BIN", "DOCKER_BIN"]) {
|
|
const value = process.env[key]?.trim();
|
|
if (value && !seen.has(value)) {
|
|
seen.add(value);
|
|
candidates.push(value);
|
|
}
|
|
}
|
|
|
|
for (const entry of (process.env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
|
|
const candidate = path.join(entry, process.platform === "win32" ? "docker.exe" : "docker");
|
|
if (!seen.has(candidate)) {
|
|
seen.add(candidate);
|
|
candidates.push(candidate);
|
|
}
|
|
}
|
|
|
|
for (const candidate of [
|
|
"/opt/homebrew/bin/docker",
|
|
"/usr/local/bin/docker",
|
|
"/Applications/Docker.app/Contents/Resources/bin/docker",
|
|
]) {
|
|
if (!seen.has(candidate)) {
|
|
seen.add(candidate);
|
|
candidates.push(candidate);
|
|
}
|
|
}
|
|
|
|
return candidates.filter((candidate) => existsSync(candidate));
|
|
}
|
|
|
|
function runDockerCommandDetailed(args, timeoutMs = 8000) {
|
|
const tried = [...resolveDockerCandidates(), process.platform === "win32" ? "docker.exe" : "docker"];
|
|
const errors = [];
|
|
|
|
for (const program of tried) {
|
|
try {
|
|
const result = spawnSync(program, args, {
|
|
encoding: "utf8",
|
|
timeout: timeoutMs,
|
|
windowsHide: true,
|
|
});
|
|
return {
|
|
program,
|
|
status: typeof result.status === "number" ? result.status : -1,
|
|
stdout: result.stdout ?? "",
|
|
stderr: result.stderr ?? "",
|
|
};
|
|
} catch (error) {
|
|
errors.push(error instanceof Error ? error.message : String(error));
|
|
}
|
|
}
|
|
|
|
throw new Error(
|
|
`Failed to run docker: ${errors.join("; ")} (Set OPENWORK_DOCKER_BIN to your docker binary if needed)`,
|
|
);
|
|
}
|
|
|
|
function parseDockerClientVersion(stdout) {
|
|
const line = String(stdout ?? "").split(/\r?\n/)[0]?.trim() ?? "";
|
|
return line.toLowerCase().startsWith("docker version") ? line : null;
|
|
}
|
|
|
|
function parseDockerServerVersion(stdout) {
|
|
for (const line of String(stdout ?? "").split(/\r?\n/)) {
|
|
const trimmed = line.trim();
|
|
if (trimmed.startsWith("Server Version:")) {
|
|
return trimmed.slice("Server Version:".length).trim() || null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function deriveOrchestratorContainerName(runId) {
|
|
const sanitized = String(runId ?? "")
|
|
.replace(/[^a-zA-Z0-9_.-]+/g, "-")
|
|
.slice(0, 24);
|
|
return `openwork-orchestrator-${sanitized}`;
|
|
}
|
|
|
|
async function listOpenworkManagedContainers() {
|
|
const result = runDockerCommandDetailed(["ps", "-a", "--format", "{{.Names}}"], 8000);
|
|
if (result.status !== 0) {
|
|
const combined = `${result.stdout.trim()}\n${result.stderr.trim()}`.trim();
|
|
throw new Error(combined || `docker ps -a failed (status ${result.status})`);
|
|
}
|
|
return result.stdout
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter((name) => name && (name.startsWith("openwork-orchestrator-") || name.startsWith("openwork-dev-") || name.startsWith("openwrk-")))
|
|
.sort();
|
|
}
|
|
|
|
async function runShellCommand(program, args, options = {}) {
|
|
const result = spawnSync(program, args, {
|
|
encoding: "utf8",
|
|
cwd: options.cwd,
|
|
env: options.env,
|
|
shell: false,
|
|
windowsHide: true,
|
|
timeout: options.timeoutMs,
|
|
});
|
|
return {
|
|
status: typeof result.status === "number" ? result.status : -1,
|
|
stdout: result.stdout ?? "",
|
|
stderr: result.stderr ?? "",
|
|
};
|
|
}
|
|
|
|
async function pinnedOpencodeInstallCommand() {
|
|
const constantsPath = path.resolve(desktopRoot, "../../constants.json");
|
|
const payload = JSON.parse(await readFile(constantsPath, "utf8"));
|
|
const version = String(payload?.opencodeVersion ?? "").trim().replace(/^v/, "");
|
|
if (!version) {
|
|
throw new Error("constants.json is missing opencodeVersion");
|
|
}
|
|
return `curl -fsSL https://opencode.ai/install | bash -s -- --version ${version} --no-modify-path`;
|
|
}
|
|
|
|
function spawnManagedChild(state, program, args, options = {}) {
|
|
const child = spawn(program, args, {
|
|
cwd: options.cwd,
|
|
env: options.env,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
windowsHide: true,
|
|
});
|
|
|
|
state.child = child;
|
|
state.childExited = false;
|
|
state.lastStdout = null;
|
|
state.lastStderr = null;
|
|
|
|
child.stdout?.on("data", (chunk) => appendOutput(state, "lastStdout", chunk.toString()));
|
|
child.stderr?.on("data", (chunk) => appendOutput(state, "lastStderr", chunk.toString()));
|
|
child.on("exit", (code) => {
|
|
state.childExited = true;
|
|
if (code != null && code !== 0) {
|
|
appendOutput(state, "lastStderr", `Process exited with code ${code}.\n`);
|
|
}
|
|
options.onExit?.(code);
|
|
});
|
|
child.on("error", (error) => {
|
|
state.childExited = true;
|
|
appendOutput(state, "lastStderr", `${error instanceof Error ? error.message : String(error)}\n`);
|
|
});
|
|
|
|
return child;
|
|
}
|
|
|
|
async function stopChild(state, options = {}) {
|
|
const child = state.child;
|
|
state.child = null;
|
|
state.childExited = true;
|
|
if (!child || child.exitCode != null || child.killed) return;
|
|
|
|
if (options.requestShutdown) {
|
|
try {
|
|
const shutdownRequested = await options.requestShutdown();
|
|
if (shutdownRequested) {
|
|
await new Promise((resolve) => setTimeout(resolve, 750));
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
if (child.exitCode == null && !child.killed) {
|
|
child.kill("SIGTERM");
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
if (child.exitCode == null && !child.killed) {
|
|
child.kill("SIGKILL");
|
|
}
|
|
}
|
|
}
|
|
|
|
async function ensureOpencodeConfig(projectDir) {
|
|
const configPath = path.join(projectDir, "opencode.json");
|
|
if (await fileExists(configPath)) return;
|
|
await mkdir(projectDir, { recursive: true });
|
|
await writeFile(
|
|
configPath,
|
|
`${JSON.stringify({ $schema: "https://opencode.ai/config.json" }, null, 2)}\n`,
|
|
"utf8",
|
|
);
|
|
}
|
|
|
|
function generateManagedCredentials() {
|
|
return [randomUUID().replace(/-/g, "") + randomUUID().replace(/-/g, ""), randomUUID().replace(/-/g, "") + randomUUID().replace(/-/g, "")];
|
|
}
|
|
|
|
async function issueOwnerToken(baseUrl, hostToken) {
|
|
const payload = await fetchJson(
|
|
`${baseUrl.replace(/\/+$/, "")}/tokens`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"X-OpenWork-Host-Token": hostToken,
|
|
},
|
|
body: JSON.stringify({ scope: "owner", label: "OpenWork desktop owner token" }),
|
|
},
|
|
5000,
|
|
);
|
|
const token = typeof payload?.token === "string" ? payload.token.trim() : "";
|
|
return token || null;
|
|
}
|
|
|
|
async function startOpenworkServer(options) {
|
|
await stopChild(openworkServerState);
|
|
|
|
const workspacePaths = options.workspacePaths.filter((value) => value.trim().length > 0);
|
|
const activeWorkspace = workspacePaths[0] ?? "";
|
|
const host = options.remoteAccessEnabled ? "0.0.0.0" : "127.0.0.1";
|
|
const port = await resolveOpenworkPort(host, activeWorkspace);
|
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
const tokens = await loadOrCreateWorkspaceTokens(activeWorkspace);
|
|
const program = resolveBinary("openwork-server");
|
|
if (!program) {
|
|
throw new Error("Failed to locate openwork-server.");
|
|
}
|
|
|
|
const args = [
|
|
"--host",
|
|
host,
|
|
"--port",
|
|
String(port),
|
|
"--cors",
|
|
"*",
|
|
"--approval",
|
|
"auto",
|
|
...workspacePaths.flatMap((workspacePath) => ["--workspace", workspacePath]),
|
|
...(options.opencodeBaseUrl ? ["--opencode-base-url", options.opencodeBaseUrl] : []),
|
|
...(activeWorkspace ? ["--opencode-directory", activeWorkspace] : []),
|
|
];
|
|
|
|
const env = await buildChildEnv({
|
|
OPENWORK_TOKEN: tokens.clientToken,
|
|
OPENWORK_HOST_TOKEN: tokens.hostToken,
|
|
...(options.routerHealthPort ? { OPENCODE_ROUTER_HEALTH_PORT: String(options.routerHealthPort) } : {}),
|
|
...(options.opencodeUsername ? { OPENWORK_OPENCODE_USERNAME: options.opencodeUsername } : {}),
|
|
...(options.opencodePassword ? { OPENWORK_OPENCODE_PASSWORD: options.opencodePassword } : {}),
|
|
});
|
|
|
|
spawnManagedChild(openworkServerState, program, args, {
|
|
cwd: activeWorkspace || desktopRoot,
|
|
env,
|
|
});
|
|
|
|
openworkServerState.remoteAccessEnabled = options.remoteAccessEnabled;
|
|
openworkServerState.host = host;
|
|
openworkServerState.port = port;
|
|
openworkServerState.baseUrl = baseUrl;
|
|
openworkServerState.clientToken = tokens.clientToken;
|
|
openworkServerState.hostToken = tokens.hostToken;
|
|
|
|
const connectUrls = options.remoteAccessEnabled ? buildConnectUrls(port) : { connectUrl: null, mdnsUrl: null, lanUrl: null };
|
|
openworkServerState.connectUrl = connectUrls.connectUrl;
|
|
openworkServerState.mdnsUrl = connectUrls.mdnsUrl;
|
|
openworkServerState.lanUrl = connectUrls.lanUrl;
|
|
|
|
await waitForHttpOk(`${baseUrl}/health`, 10_000);
|
|
const ownerToken = tokens.ownerToken || (await issueOwnerToken(baseUrl, tokens.hostToken));
|
|
openworkServerState.ownerToken = ownerToken;
|
|
if (ownerToken) {
|
|
await persistWorkspaceOwnerToken(activeWorkspace, ownerToken);
|
|
}
|
|
await persistPreferredOpenworkPort(activeWorkspace, port);
|
|
return snapshotOpenworkServerState(openworkServerState);
|
|
}
|
|
|
|
async function resolveRouterHealthPort() {
|
|
return findFreePort("127.0.0.1");
|
|
}
|
|
|
|
async function startRouter(options) {
|
|
await stopChild(routerState);
|
|
const healthPort = options.healthPort ?? (await resolveRouterHealthPort());
|
|
const program = resolveBinary("opencode-router");
|
|
if (!program) {
|
|
throw new Error("Failed to locate opencode-router.");
|
|
}
|
|
|
|
const env = await buildChildEnv({
|
|
OPENCODE_ROUTER_HEALTH_PORT: String(healthPort),
|
|
...(options.opencodeUsername ? { OPENCODE_SERVER_USERNAME: options.opencodeUsername } : {}),
|
|
...(options.opencodePassword ? { OPENCODE_SERVER_PASSWORD: options.opencodePassword } : {}),
|
|
});
|
|
|
|
const args = [
|
|
"serve",
|
|
options.workspacePath,
|
|
...(options.opencodeUrl ? ["--opencode-url", options.opencodeUrl] : []),
|
|
];
|
|
|
|
spawnManagedChild(routerState, program, args, {
|
|
cwd: options.workspacePath,
|
|
env,
|
|
});
|
|
|
|
routerState.workspacePath = options.workspacePath;
|
|
routerState.opencodeUrl = options.opencodeUrl ?? null;
|
|
routerState.healthPort = healthPort;
|
|
|
|
try {
|
|
const version = runProgram(program, ["--version"]);
|
|
routerState.version = version.stdout?.trim() || version.stderr?.trim() || null;
|
|
} catch {
|
|
routerState.version = null;
|
|
}
|
|
|
|
await waitForHttpOk(`http://127.0.0.1:${healthPort}/health`, 5000).catch(() => undefined);
|
|
return snapshotRouterState(routerState);
|
|
}
|
|
|
|
async function resolveOrchestratorBaseUrl() {
|
|
if (orchestratorState.baseUrl) {
|
|
return orchestratorState.baseUrl;
|
|
}
|
|
const stateFile = await readOrchestratorStateFile(orchestratorState.dataDir || orchestratorDataDir());
|
|
const baseUrl = stateFile?.daemon?.baseUrl?.trim();
|
|
if (!baseUrl) {
|
|
throw new Error("orchestrator daemon is not running");
|
|
}
|
|
return baseUrl;
|
|
}
|
|
|
|
async function startOrchestratorRuntime(projectDir, options = {}) {
|
|
const dataDir = orchestratorDataDir();
|
|
await mkdir(dataDir, { recursive: true });
|
|
const daemonPort = await findFreePort("127.0.0.1");
|
|
const opencodePort = await findFreePort("127.0.0.1");
|
|
const [username, password] = generateManagedCredentials();
|
|
|
|
const orchestratorProgram = resolveBinary("openwork-orchestrator") ?? resolveBinary("openwork");
|
|
if (!orchestratorProgram) {
|
|
throw new Error("Failed to locate openwork-orchestrator.");
|
|
}
|
|
|
|
const opencodeProgram = (typeof options.opencodeBinPath === "string" && options.opencodeBinPath.trim())
|
|
? options.opencodeBinPath.trim()
|
|
: resolveBinary("opencode");
|
|
if (!opencodeProgram) {
|
|
throw new Error("Failed to locate opencode.");
|
|
}
|
|
|
|
const env = await buildChildEnv({
|
|
OPENWORK_INTERNAL_ALLOW_OPENCODE_CREDENTIALS: "1",
|
|
OPENWORK_OPENCODE_USERNAME: username,
|
|
OPENWORK_OPENCODE_PASSWORD: password,
|
|
...(options.opencodeEnableExa === true ? { OPENCODE_ENABLE_EXA: "1" } : {}),
|
|
});
|
|
|
|
const args = [
|
|
"daemon",
|
|
"run",
|
|
"--data-dir",
|
|
dataDir,
|
|
"--daemon-host",
|
|
"127.0.0.1",
|
|
"--daemon-port",
|
|
String(daemonPort),
|
|
"--opencode-bin",
|
|
opencodeProgram,
|
|
"--opencode-host",
|
|
"127.0.0.1",
|
|
"--opencode-workdir",
|
|
projectDir,
|
|
"--opencode-port",
|
|
String(opencodePort),
|
|
"--allow-external",
|
|
"--cors",
|
|
"*",
|
|
];
|
|
|
|
spawnManagedChild(orchestratorState, orchestratorProgram, args, { env });
|
|
orchestratorState.dataDir = dataDir;
|
|
orchestratorState.daemonPort = daemonPort;
|
|
orchestratorState.baseUrl = `http://127.0.0.1:${daemonPort}`;
|
|
|
|
await writeOrchestratorAuthFile(dataDir, {
|
|
opencodeUsername: username,
|
|
opencodePassword: password,
|
|
projectDir,
|
|
});
|
|
|
|
const health = await waitForHttpOk(`${orchestratorState.baseUrl}/health`, 180_000).then((response) => response.json());
|
|
const opencode = health?.opencode;
|
|
if (!opencode?.port) {
|
|
throw new Error("Orchestrator did not report OpenCode status.");
|
|
}
|
|
|
|
engineState.runtime = ORCHESTRATOR_RUNTIME;
|
|
engineState.projectDir = projectDir;
|
|
engineState.hostname = "127.0.0.1";
|
|
engineState.port = opencode.port;
|
|
engineState.baseUrl = `http://127.0.0.1:${opencode.port}`;
|
|
engineState.opencodeUsername = username;
|
|
engineState.opencodePassword = password;
|
|
|
|
return snapshotEngineState(engineState);
|
|
}
|
|
|
|
async function startDirectRuntime(projectDir, options = {}) {
|
|
const opencodeProgram = (typeof options.opencodeBinPath === "string" && options.opencodeBinPath.trim())
|
|
? options.opencodeBinPath.trim()
|
|
: resolveBinary("opencode");
|
|
if (!opencodeProgram) {
|
|
throw new Error("Failed to locate opencode.");
|
|
}
|
|
|
|
const port = await findFreePort("127.0.0.1");
|
|
const [username, password] = generateManagedCredentials();
|
|
const env = await buildChildEnv({
|
|
OPENCODE_SERVER_USERNAME: username,
|
|
OPENCODE_SERVER_PASSWORD: password,
|
|
});
|
|
|
|
spawnManagedChild(
|
|
engineState,
|
|
opencodeProgram,
|
|
["serve", "--hostname", "127.0.0.1", "--port", String(port), "--cors", "*"],
|
|
{
|
|
cwd: projectDir,
|
|
env,
|
|
},
|
|
);
|
|
|
|
engineState.runtime = DIRECT_RUNTIME;
|
|
engineState.projectDir = projectDir;
|
|
engineState.hostname = "127.0.0.1";
|
|
engineState.port = port;
|
|
engineState.baseUrl = `http://127.0.0.1:${port}`;
|
|
engineState.opencodeUsername = username;
|
|
engineState.opencodePassword = password;
|
|
|
|
await waitForHttpOk(`${engineState.baseUrl}/health`, 10_000).catch(() => undefined);
|
|
return snapshotEngineState(engineState);
|
|
}
|
|
|
|
async function stopAllRuntimeChildren() {
|
|
await stopChild(routerState);
|
|
await stopChild(openworkServerState);
|
|
await stopChild(orchestratorState, {
|
|
requestShutdown: () => requestOrchestratorShutdown(orchestratorState.dataDir || orchestratorDataDir()),
|
|
});
|
|
await clearOrchestratorAuthFile(orchestratorState.dataDir || orchestratorDataDir()).catch(() => undefined);
|
|
await stopChild(engineState);
|
|
|
|
Object.assign(engineState, createEngineState());
|
|
Object.assign(openworkServerState, createOpenworkServerState());
|
|
Object.assign(orchestratorState, createOrchestratorState());
|
|
Object.assign(routerState, createRouterState());
|
|
}
|
|
|
|
async function ensureRouterAndOpenwork(options) {
|
|
const routerHealthPort = await resolveRouterHealthPort().catch(() => null);
|
|
try {
|
|
await startOpenworkServer({
|
|
workspacePaths: options.workspacePaths,
|
|
opencodeBaseUrl: engineState.baseUrl,
|
|
opencodeUsername: engineState.opencodeUsername,
|
|
opencodePassword: engineState.opencodePassword,
|
|
routerHealthPort,
|
|
remoteAccessEnabled: options.remoteAccessEnabled,
|
|
});
|
|
} catch (error) {
|
|
appendOutput(engineState, "lastStderr", `OpenWork server: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
}
|
|
|
|
if (options.projectDir && engineState.baseUrl) {
|
|
try {
|
|
await startRouter({
|
|
workspacePath: options.projectDir,
|
|
opencodeUrl: engineState.baseUrl,
|
|
opencodeUsername: engineState.opencodeUsername,
|
|
opencodePassword: engineState.opencodePassword,
|
|
healthPort: routerHealthPort,
|
|
});
|
|
} catch (error) {
|
|
appendOutput(engineState, "lastStderr", `OpenCodeRouter: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function engineStart(projectDir, options = {}) {
|
|
const safeProjectDir = String(projectDir ?? "").trim();
|
|
if (!safeProjectDir) {
|
|
throw new Error("projectDir is required");
|
|
}
|
|
await mkdir(safeProjectDir, { recursive: true });
|
|
await ensureOpencodeConfig(safeProjectDir);
|
|
await stopAllRuntimeChildren();
|
|
|
|
const workspacePaths = [safeProjectDir, ...((options.workspacePaths ?? []).filter(Boolean))].filter(
|
|
(value, index, list) => list.indexOf(value) === index,
|
|
);
|
|
const runtime = options.runtime ?? ORCHESTRATOR_RUNTIME;
|
|
|
|
const snapshot = runtime === ORCHESTRATOR_RUNTIME
|
|
? await startOrchestratorRuntime(safeProjectDir, options)
|
|
: await startDirectRuntime(safeProjectDir, options);
|
|
|
|
await ensureRouterAndOpenwork({
|
|
projectDir: safeProjectDir,
|
|
workspacePaths,
|
|
remoteAccessEnabled: options.openworkRemoteAccess === true,
|
|
});
|
|
|
|
return snapshot;
|
|
}
|
|
|
|
async function engineStop() {
|
|
await stopAllRuntimeChildren();
|
|
return snapshotEngineState(engineState);
|
|
}
|
|
|
|
async function engineRestart(options = {}) {
|
|
const projectDir = engineState.projectDir;
|
|
if (!projectDir) {
|
|
throw new Error("OpenCode is not configured for a local workspace");
|
|
}
|
|
return engineStart(projectDir, {
|
|
runtime: engineState.runtime,
|
|
workspacePaths: [projectDir],
|
|
opencodeEnableExa: options.opencodeEnableExa,
|
|
openworkRemoteAccess: options.openworkRemoteAccess,
|
|
});
|
|
}
|
|
|
|
async function engineInfo() {
|
|
if (engineState.runtime === ORCHESTRATOR_RUNTIME && !engineState.child && !engineState.childExited) {
|
|
return snapshotEngineState(engineState);
|
|
}
|
|
|
|
if (engineState.runtime === ORCHESTRATOR_RUNTIME && !snapshotEngineState(engineState).running) {
|
|
const dataDir = orchestratorState.dataDir || orchestratorDataDir();
|
|
const stateFile = await readOrchestratorStateFile(dataDir);
|
|
const auth = await readOrchestratorAuthFile(dataDir);
|
|
const opencode = stateFile?.opencode;
|
|
return {
|
|
running: Boolean(stateFile?.daemon && opencode),
|
|
runtime: ORCHESTRATOR_RUNTIME,
|
|
baseUrl: opencode?.port ? `http://127.0.0.1:${opencode.port}` : null,
|
|
projectDir: auth?.projectDir ?? engineState.projectDir,
|
|
hostname: opencode ? "127.0.0.1" : null,
|
|
port: opencode?.port ?? null,
|
|
opencodeUsername: auth?.opencodeUsername ?? engineState.opencodeUsername,
|
|
opencodePassword: auth?.opencodePassword ?? engineState.opencodePassword,
|
|
pid: opencode?.pid ?? null,
|
|
lastStdout: orchestratorState.lastStdout,
|
|
lastStderr: orchestratorState.lastStderr,
|
|
};
|
|
}
|
|
|
|
return snapshotEngineState(engineState);
|
|
}
|
|
|
|
async function openworkServerInfo() {
|
|
return snapshotOpenworkServerState(openworkServerState);
|
|
}
|
|
|
|
async function openworkServerRestart(options = {}) {
|
|
const workspacePaths = (await listLocalWorkspacePaths()).filter(Boolean);
|
|
return startOpenworkServer({
|
|
workspacePaths,
|
|
opencodeBaseUrl: engineState.baseUrl,
|
|
opencodeUsername: engineState.opencodeUsername,
|
|
opencodePassword: engineState.opencodePassword,
|
|
routerHealthPort: routerState.healthPort,
|
|
remoteAccessEnabled: options.remoteAccessEnabled === true,
|
|
});
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
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,
|
|
workspaces,
|
|
lastError: orchestratorState.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.");
|
|
}
|
|
await fetch(`${baseUrl.replace(/\/+$/, "")}/workspaces/${id}/activate`, { method: "POST" });
|
|
return added.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.");
|
|
}
|
|
const response = await fetchJson(`${baseUrl.replace(/\/+$/, "")}/instances/${id}/dispose`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: "",
|
|
}, 5000);
|
|
return response?.disposed === true;
|
|
}
|
|
|
|
async function opencodeRouterInfo() {
|
|
return snapshotRouterState(routerState);
|
|
}
|
|
|
|
async function opencodeRouterStart(options) {
|
|
return startRouter(options);
|
|
}
|
|
|
|
async function opencodeRouterStop() {
|
|
await stopChild(routerState);
|
|
Object.assign(routerState, createRouterState());
|
|
return snapshotRouterState(routerState);
|
|
}
|
|
|
|
async function opencodeRouterRestart(options) {
|
|
return opencodeRouterStart(options);
|
|
}
|
|
|
|
async function engineInstall() {
|
|
if (process.platform === "win32") {
|
|
return {
|
|
ok: false,
|
|
status: -1,
|
|
stdout: "",
|
|
stderr:
|
|
"Guided install is not supported on Windows yet. Install the OpenWork-pinned OpenCode version manually, then restart OpenWork.",
|
|
};
|
|
}
|
|
|
|
const installDir = path.join(app.getPath("home"), ".opencode", "bin");
|
|
const command = await pinnedOpencodeInstallCommand();
|
|
const result = await runShellCommand("bash", ["-lc", command], {
|
|
env: { ...(await buildChildEnv()), OPENCODE_INSTALL_DIR: installDir },
|
|
timeoutMs: 180_000,
|
|
});
|
|
return {
|
|
ok: result.status === 0,
|
|
status: result.status,
|
|
stdout: result.stdout,
|
|
stderr: result.stderr,
|
|
};
|
|
}
|
|
|
|
async function opencodeMcpAuth(projectDir, serverName) {
|
|
const safeProjectDir = String(projectDir ?? "").trim();
|
|
const safeServerName = String(serverName ?? "").trim();
|
|
if (!safeProjectDir) {
|
|
throw new Error("project_dir is required");
|
|
}
|
|
if (!safeServerName) {
|
|
throw new Error("server_name is required");
|
|
}
|
|
|
|
const program = resolveBinary("opencode");
|
|
if (!program) {
|
|
throw new Error("Failed to locate opencode.");
|
|
}
|
|
|
|
const result = await runShellCommand(program, ["mcp", "auth", safeServerName], {
|
|
cwd: safeProjectDir,
|
|
env: await buildChildEnv(),
|
|
timeoutMs: 120_000,
|
|
});
|
|
return {
|
|
ok: result.status === 0,
|
|
status: result.status,
|
|
stdout: result.stdout,
|
|
stderr: result.stderr,
|
|
};
|
|
}
|
|
|
|
async function sandboxDoctor() {
|
|
const candidates = resolveDockerCandidates();
|
|
const debug = {
|
|
candidates,
|
|
selectedBin: null,
|
|
versionCommand: null,
|
|
infoCommand: null,
|
|
};
|
|
|
|
let version;
|
|
try {
|
|
version = runDockerCommandDetailed(["--version"], 2000);
|
|
} catch (error) {
|
|
return {
|
|
installed: false,
|
|
daemonRunning: false,
|
|
permissionOk: false,
|
|
ready: false,
|
|
clientVersion: null,
|
|
serverVersion: null,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
debug,
|
|
};
|
|
}
|
|
|
|
debug.selectedBin = version.program;
|
|
debug.versionCommand = {
|
|
status: version.status,
|
|
stdout: truncateOutput(version.stdout, 1200),
|
|
stderr: truncateOutput(version.stderr, 1200),
|
|
};
|
|
|
|
const clientVersion = parseDockerClientVersion(version.stdout);
|
|
if (version.status !== 0) {
|
|
return {
|
|
installed: false,
|
|
daemonRunning: false,
|
|
permissionOk: false,
|
|
ready: false,
|
|
clientVersion: null,
|
|
serverVersion: null,
|
|
error: `docker --version failed (status ${version.status}): ${version.stderr.trim()}`,
|
|
debug,
|
|
};
|
|
}
|
|
|
|
let info;
|
|
try {
|
|
info = runDockerCommandDetailed(["info"], 8000);
|
|
} catch (error) {
|
|
return {
|
|
installed: true,
|
|
daemonRunning: false,
|
|
permissionOk: false,
|
|
ready: false,
|
|
clientVersion,
|
|
serverVersion: null,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
debug,
|
|
};
|
|
}
|
|
|
|
debug.infoCommand = {
|
|
status: info.status,
|
|
stdout: truncateOutput(info.stdout, 1200),
|
|
stderr: truncateOutput(info.stderr, 1200),
|
|
};
|
|
|
|
if (info.status === 0) {
|
|
return {
|
|
installed: true,
|
|
daemonRunning: true,
|
|
permissionOk: true,
|
|
ready: true,
|
|
clientVersion,
|
|
serverVersion: parseDockerServerVersion(info.stdout),
|
|
error: null,
|
|
debug,
|
|
};
|
|
}
|
|
|
|
const combined = `${info.stdout.trim()}\n${info.stderr.trim()}`.trim().toLowerCase();
|
|
const permissionOk = !combined.includes("permission denied") && !combined.includes("access is denied");
|
|
const daemonRunning = !combined.includes("cannot connect to the docker daemon") && !combined.includes("is the docker daemon running") && !combined.includes("connection refused") && !combined.includes("no such file or directory");
|
|
|
|
return {
|
|
installed: true,
|
|
daemonRunning,
|
|
permissionOk,
|
|
ready: false,
|
|
clientVersion,
|
|
serverVersion: null,
|
|
error: `${info.stdout.trim()}\n${info.stderr.trim()}`.trim() || `docker info failed (status ${info.status})`,
|
|
debug,
|
|
};
|
|
}
|
|
|
|
async function sandboxStop(containerName) {
|
|
const name = String(containerName ?? "").trim();
|
|
if (!name) {
|
|
throw new Error("containerName is required");
|
|
}
|
|
if (!name.startsWith("openwork-orchestrator-")) {
|
|
throw new Error("Refusing to stop container: expected name starting with 'openwork-orchestrator-'");
|
|
}
|
|
if (!/^[A-Za-z0-9_.-]+$/.test(name)) {
|
|
throw new Error("containerName contains invalid characters");
|
|
}
|
|
const result = runDockerCommandDetailed(["stop", name], 15_000);
|
|
return {
|
|
ok: result.status === 0,
|
|
status: result.status,
|
|
stdout: result.stdout,
|
|
stderr: result.stderr,
|
|
};
|
|
}
|
|
|
|
async function sandboxCleanupOpenworkContainers() {
|
|
const candidates = await listOpenworkManagedContainers().catch((error) => {
|
|
throw error;
|
|
});
|
|
const removed = [];
|
|
const errors = [];
|
|
|
|
for (const name of candidates) {
|
|
try {
|
|
const result = runDockerCommandDetailed(["rm", "-f", name], 20_000);
|
|
if (result.status === 0) {
|
|
removed.push(name);
|
|
} else {
|
|
errors.push(`${name}: exit ${result.status}: ${(result.stdout + "\n" + result.stderr).trim()}`);
|
|
}
|
|
} catch (error) {
|
|
errors.push(`${name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
return { candidates, removed, errors };
|
|
}
|
|
|
|
async function orchestratorStartDetached(options = {}) {
|
|
const workspacePath = String(options.workspacePath ?? "").trim();
|
|
if (!workspacePath) {
|
|
throw new Error("workspacePath is required");
|
|
}
|
|
|
|
const sandboxBackend = String(options.sandboxBackend ?? "none").trim().toLowerCase();
|
|
if (!["none", "docker", "microsandbox"].includes(sandboxBackend)) {
|
|
throw new Error("sandboxBackend must be one of: none, docker, microsandbox");
|
|
}
|
|
|
|
const wantsDockerSandbox = sandboxBackend === "docker" || sandboxBackend === "microsandbox";
|
|
const runId = String(options.runId ?? randomUUID()).trim();
|
|
const containerName = wantsDockerSandbox ? deriveOrchestratorContainerName(runId) : null;
|
|
const port = await findFreePort("127.0.0.1");
|
|
const token = String(options.openworkToken ?? randomUUID()).trim();
|
|
const hostToken = String(options.openworkHostToken ?? randomUUID()).trim();
|
|
const openworkUrl = `http://127.0.0.1:${port}`;
|
|
const program = resolveBinary("openwork-orchestrator") ?? resolveBinary("openwork");
|
|
if (!program) {
|
|
throw new Error("Failed to locate openwork orchestrator.");
|
|
}
|
|
|
|
const args = [
|
|
"start",
|
|
"--workspace",
|
|
workspacePath,
|
|
"--approval",
|
|
"auto",
|
|
"--opencode-router",
|
|
"true",
|
|
"--detach",
|
|
"--openwork-port",
|
|
String(port),
|
|
"--run-id",
|
|
runId,
|
|
...(wantsDockerSandbox ? ["--sandbox", "docker"] : []),
|
|
...(options.sandboxImageRef ? ["--sandbox-image", String(options.sandboxImageRef)] : []),
|
|
];
|
|
|
|
const child = spawn(program, args, {
|
|
env: { ...(await buildChildEnv()), OPENWORK_TOKEN: token, OPENWORK_HOST_TOKEN: hostToken },
|
|
detached: true,
|
|
stdio: "ignore",
|
|
windowsHide: true,
|
|
});
|
|
child.unref();
|
|
|
|
await waitForHttpOk(`${openworkUrl}/health`, wantsDockerSandbox ? 90_000 : 12_000);
|
|
const ownerToken = await issueOwnerToken(openworkUrl, hostToken).catch(() => null);
|
|
|
|
return {
|
|
openworkUrl,
|
|
token,
|
|
ownerToken,
|
|
hostToken,
|
|
port,
|
|
sandboxBackend: wantsDockerSandbox ? sandboxBackend : null,
|
|
sandboxRunId: wantsDockerSandbox ? runId : null,
|
|
sandboxContainerName: containerName,
|
|
};
|
|
}
|
|
|
|
async function sandboxDebugProbe() {
|
|
const startedAt = nowMs();
|
|
const runId = `probe-${randomUUID()}`;
|
|
const workspacePath = path.join(os.tmpdir(), `openwork-sandbox-probe-${randomUUID()}`);
|
|
await mkdir(workspacePath, { recursive: true });
|
|
|
|
const doctor = await sandboxDoctor();
|
|
let detachedHost = null;
|
|
let dockerInspect = null;
|
|
let dockerLogs = null;
|
|
let error = null;
|
|
const cleanupErrors = [];
|
|
let containerRemoved = false;
|
|
let workspaceRemoved = false;
|
|
let removeResult = null;
|
|
|
|
if (doctor.ready) {
|
|
try {
|
|
detachedHost = await orchestratorStartDetached({
|
|
workspacePath,
|
|
sandboxBackend: "docker",
|
|
runId,
|
|
});
|
|
const containerName = detachedHost.sandboxContainerName ?? deriveOrchestratorContainerName(runId);
|
|
try {
|
|
const inspectResult = runDockerCommandDetailed(["inspect", containerName], 6000);
|
|
dockerInspect = {
|
|
status: inspectResult.status,
|
|
stdout: truncateOutput(inspectResult.stdout, 48000),
|
|
stderr: truncateOutput(inspectResult.stderr, 48000),
|
|
};
|
|
} catch (inspectError) {
|
|
cleanupErrors.push(`docker inspect failed: ${inspectError instanceof Error ? inspectError.message : String(inspectError)}`);
|
|
}
|
|
try {
|
|
const logsResult = runDockerCommandDetailed(["logs", "--timestamps", "--tail", "400", containerName], 8000);
|
|
dockerLogs = {
|
|
status: logsResult.status,
|
|
stdout: truncateOutput(logsResult.stdout, 48000),
|
|
stderr: truncateOutput(logsResult.stderr, 48000),
|
|
};
|
|
} catch (logsError) {
|
|
cleanupErrors.push(`docker logs failed: ${logsError instanceof Error ? logsError.message : String(logsError)}`);
|
|
}
|
|
|
|
try {
|
|
const rmResult = runDockerCommandDetailed(["rm", "-f", containerName], 20_000);
|
|
containerRemoved = rmResult.status === 0;
|
|
removeResult = {
|
|
status: rmResult.status,
|
|
stdout: truncateOutput(rmResult.stdout, 48000),
|
|
stderr: truncateOutput(rmResult.stderr, 48000),
|
|
};
|
|
} catch (removeError) {
|
|
cleanupErrors.push(`docker rm -f ${containerName} failed: ${removeError instanceof Error ? removeError.message : String(removeError)}`);
|
|
}
|
|
} catch (probeError) {
|
|
error = `Sandbox probe failed to start: ${probeError instanceof Error ? probeError.message : String(probeError)}`;
|
|
}
|
|
} else {
|
|
error = doctor.error ?? "Docker is not ready for sandbox creation";
|
|
}
|
|
|
|
try {
|
|
await rm(workspacePath, { recursive: true, force: true });
|
|
workspaceRemoved = true;
|
|
} catch (workspaceError) {
|
|
cleanupErrors.push(`Failed to remove probe workspace: ${workspaceError instanceof Error ? workspaceError.message : String(workspaceError)}`);
|
|
}
|
|
|
|
return {
|
|
startedAt,
|
|
finishedAt: nowMs(),
|
|
runId,
|
|
workspacePath,
|
|
ready: doctor.ready && !error,
|
|
doctor,
|
|
detachedHost,
|
|
dockerInspect,
|
|
dockerLogs,
|
|
cleanup: {
|
|
containerName: detachedHost?.sandboxContainerName ?? null,
|
|
containerRemoved,
|
|
removeResult,
|
|
workspaceRemoved,
|
|
errors: cleanupErrors,
|
|
},
|
|
error,
|
|
};
|
|
}
|
|
|
|
return {
|
|
engineStart,
|
|
engineStop,
|
|
engineRestart,
|
|
engineInfo,
|
|
engineInstall,
|
|
openworkServerInfo,
|
|
openworkServerRestart,
|
|
orchestratorStatus,
|
|
orchestratorWorkspaceActivate,
|
|
orchestratorInstanceDispose,
|
|
orchestratorStartDetached,
|
|
opencodeRouterInfo,
|
|
opencodeRouterStart,
|
|
opencodeRouterStop,
|
|
opencodeRouterRestart,
|
|
opencodeMcpAuth,
|
|
sandboxDoctor,
|
|
sandboxStop,
|
|
sandboxCleanupOpenworkContainers,
|
|
sandboxDebugProbe,
|
|
};
|
|
}
|