From debcff2b6b10bd191d68a427f19bcd3d0d6eaf59 Mon Sep 17 00:00:00 2001 From: James Long Date: Mon, 20 Apr 2026 14:27:58 -0400 Subject: [PATCH] feat(core): add debug workspace server (#23590) --- packages/opencode/script/run-workspace-server | 108 ++++++++++++++++++ .../dev/debug-workspace-plugin.ts | 73 ++++++++++++ 2 files changed, 181 insertions(+) create mode 100755 packages/opencode/script/run-workspace-server create mode 100644 packages/opencode/src/control-plane/dev/debug-workspace-plugin.ts diff --git a/packages/opencode/script/run-workspace-server b/packages/opencode/script/run-workspace-server new file mode 100755 index 0000000000..0087fba10d --- /dev/null +++ b/packages/opencode/script/run-workspace-server @@ -0,0 +1,108 @@ +#!/usr/bin/env bun + +// This script runs a separate OpenCode server to be used as a remote +// workspace, simulating a remote environment but all local to make +// debugger easier +// +// *Important*: make sure you add the debug workspace plugin first. +// In `.opencode/opencode.jsonc` in the root of this project add: +// +// "plugin": ["../packages/opencode/src/control-plane/dev/debug-workspace-plugin.ts"] +// +// Afterwards, run `./packages/opencode/script/run-workspace-server` + +import { stat } from "node:fs/promises" +import { setTimeout as sleep } from "node:timers/promises" + +const DEV_DATA_FILE = "/tmp/opencode-workspace-dev-data.json" +const RESTART_POLL_INTERVAL = 250 + +async function readData() { + return await Bun.file(DEV_DATA_FILE).json() +} + +async function readDataMtime() { + return await stat(DEV_DATA_FILE) + .then((info) => info.mtimeMs) + .catch((error) => { + if (typeof error === "object" && error && "code" in error && error.code === "ENOENT") { + return undefined + } + + throw error + }) +} + +async function readSnapshot() { + while (true) { + try { + const before = await readDataMtime() + if (before === undefined) { + await sleep(RESTART_POLL_INTERVAL) + continue + } + + const data = await readData() + const after = await readDataMtime() + + if (before === after) { + return { data, mtime: after } + } + } catch (error) { + if (typeof error === "object" && error && "code" in error && error.code === "ENOENT") { + await sleep(RESTART_POLL_INTERVAL) + continue + } + + throw error + } + } +} + +function startDevServer(data: any) { + const env = Object.fromEntries( + Object.entries(data.env ?? {}).filter(([, value]) => value !== undefined), + ) + + return Bun.spawn(["bun", "run", "dev", "serve", "--port", String(data.port), "--print-logs"], { + env: { + ...process.env, + ...env, + XDG_DATA_HOME: "/tmp/data", + }, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }) +} + +async function waitForRestartSignal(mtime: number, signal: AbortSignal) { + while (!signal.aborted) { + await sleep(RESTART_POLL_INTERVAL) + if (signal.aborted) return false + if ((await readDataMtime()) !== mtime) return true + } + + return false +} + +while (true) { + const { data, mtime } = await readSnapshot() + const proc = startDevServer(data) + const restartAbort = new AbortController() + + const result = await Promise.race([ + proc.exited.then((code) => ({ type: "exit" as const, code })), + waitForRestartSignal(mtime, restartAbort.signal).then((restart) => ({ type: "restart" as const, restart })), + ]) + + restartAbort.abort() + + if (result.type === "restart" && result.restart) { + proc.kill() + await proc.exited + continue + } + + process.exit(result.code) +} diff --git a/packages/opencode/src/control-plane/dev/debug-workspace-plugin.ts b/packages/opencode/src/control-plane/dev/debug-workspace-plugin.ts new file mode 100644 index 0000000000..efc9d0c651 --- /dev/null +++ b/packages/opencode/src/control-plane/dev/debug-workspace-plugin.ts @@ -0,0 +1,73 @@ +import type { Plugin } from "@opencode-ai/plugin" +import { rename, writeFile } from "node:fs/promises" +import { randomInt } from "node:crypto" +import { setTimeout as sleep } from "node:timers/promises" + +const DEV_DATA_FILE = "/tmp/opencode-workspace-dev-data.json" +const DEV_DATA_TEMP_FILE = `${DEV_DATA_FILE}.tmp` + +async function waitForHealth(port: number) { + const url = `http://127.0.0.1:${port}/global/health` + const started = Date.now() + + while (Date.now() - started < 30_000) { + try { + const response = await fetch(url) + if (response.ok) { + return + } + } catch {} + + await sleep(250) + } + + throw new Error(`Timed out waiting for debug server health check at ${url}`) +} + +let PORT: number | undefined + +async function writeDebugData(port: number, id: string, env: Record) { + await writeFile( + DEV_DATA_TEMP_FILE, + JSON.stringify( + { + port, + id, + env, + }, + null, + 2, + ), + ) + + await rename(DEV_DATA_TEMP_FILE, DEV_DATA_FILE) +} + +export const DebugWorkspacePlugin: Plugin = async ({ experimental_workspace }) => { + experimental_workspace.register("debug", { + name: "Debug", + description: "Create a debugging server", + configure(config) { + return config + }, + async create(config, env) { + const port = randomInt(5000, 9001) + PORT = port + + await writeDebugData(port, config.id, env) + + await waitForHealth(port) + }, + async remove(_config) {}, + target(_config) { + return { + type: "remote", + url: `http://localhost:${PORT!}/`, + } + }, + }) + + return {} +} + +export default DebugWorkspacePlugin