Files
OpenHands/scripts/dev-safe.mjs
T
Graham Neubigandopenhands 3b754b5703 Improve missing agent-server setup guidance (#42)
Add actionable dev-safe output when agent-server is missing, including README and uv installation guidance, and update tests plus quickstart docs.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-27 10:40:06 -04:00

218 lines
6.0 KiB
JavaScript

import { spawn } from "node:child_process";
import { mkdirSync } from "node:fs";
import path from "node:path";
import process from "node:process";
import { setTimeout as delay } from "node:timers/promises";
import { pathToFileURL } from "node:url";
const DEFAULT_BACKEND_PORT = 18000;
const DEFAULT_WAIT_TIMEOUT_MS = 30_000;
function isEnoentError(error) {
return Boolean(
(error && typeof error === "object" && "code" in error && error.code === "ENOENT") ||
/ENOENT/.test(String(error)),
);
}
export function formatMissingAgentServerGuidance(cwd = process.cwd()) {
const readmePath = path.join(cwd, "README.md");
return [
"Failed to start agent-server. Make sure it is installed and on your PATH.",
"",
"To fix this:",
"1. Install the backend CLI:",
" uv tool install -U --with openhands-tools --with openhands-workspace openhands-agent-server",
"2. Make sure the uv tool bin dir is on your PATH:",
' export PATH="$HOME/.local/bin:$PATH"',
" command -v agent-server",
"",
"Need Windows or another install method? https://docs.astral.sh/uv/getting-started/installation/",
`See the local Quickstart for details: ${readmePath}`,
"- README > Quickstart > 2. Install OpenHands Agent Server",
"",
"Other options:",
"- npm run dev:frontend # use an already running backend",
"- npm run dev:mock # run the frontend with mock APIs",
].join("\n");
}
function parsePort(value, fallback) {
if (value == null || value === "") {
return fallback;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`Invalid port: ${value}`);
}
return parsed;
}
export function buildSafeDevConfig(cwd = process.cwd(), env = process.env) {
const backendPort = parsePort(env.OH_GUI_SAFE_BACKEND_PORT, DEFAULT_BACKEND_PORT);
const vscodePort = parsePort(env.OH_GUI_SAFE_VSCODE_PORT, backendPort + 1);
const stateDir = path.resolve(
cwd,
env.OH_GUI_SAFE_STATE_DIR || path.join(".openhands-dev", `safe-dev-${backendPort}`),
);
return {
cwd,
backendPort,
vscodePort,
stateDir,
tmuxTmpDir: path.join(stateDir, "tmux"),
conversationsPath: path.join(stateDir, "conversations"),
bashEventsDir: path.join(stateDir, "bash_events"),
backendBaseUrl: `http://127.0.0.1:${backendPort}`,
backendHost: `127.0.0.1:${backendPort}`,
workingDir: env.VITE_WORKING_DIR || cwd,
};
}
async function waitForServer(url, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
try {
const response = await fetch(url);
if (response.ok) {
return;
}
} catch {
// Keep polling until timeout.
}
await delay(500);
}
throw new Error(`Timed out waiting for agent-server at ${url}`);
}
function spawnProcess(command, args, options) {
const child = spawn(command, args, { stdio: "inherit", ...options });
child.once("error", (error) => {
if (isEnoentError(error) && command === "agent-server") {
console.error(formatMissingAgentServerGuidance(options?.cwd));
} else if (isEnoentError(error)) {
console.error(`Failed to start ${command}. Make sure it is installed and on your PATH.`);
} else {
console.error(`Failed to start ${command}:`, error);
}
});
return child;
}
async function main() {
const config = buildSafeDevConfig();
for (const dir of [
config.stateDir,
config.tmuxTmpDir,
config.conversationsPath,
config.bashEventsDir,
]) {
mkdirSync(dir, { recursive: true });
}
console.log("Starting isolated agent-server + frontend dev stack...");
console.log(`- backend: ${config.backendBaseUrl}`);
console.log(`- vscode port: ${config.vscodePort}`);
console.log(`- working dir: ${config.workingDir}`);
console.log(`- isolated state dir: ${config.stateDir}`);
console.log("");
const backend = spawnProcess(
"agent-server",
["--host", "127.0.0.1", "--port", String(config.backendPort)],
{
cwd: config.cwd,
env: {
...process.env,
TMUX_TMPDIR: config.tmuxTmpDir,
OH_CONVERSATIONS_PATH: config.conversationsPath,
OH_BASH_EVENTS_DIR: config.bashEventsDir,
OH_VSCODE_PORT: String(config.vscodePort),
},
},
);
let shuttingDown = false;
let frontend = null;
const shutdown = (signal = "SIGTERM") => {
if (shuttingDown) {
return;
}
shuttingDown = true;
frontend?.kill(signal);
backend.kill(signal);
};
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
const backendErrored = new Promise((_, reject) => {
backend.once("error", (error) => reject(error));
});
const backendExited = new Promise((_, reject) => {
backend.once("exit", (code, signal) => {
if (!shuttingDown) {
reject(
new Error(
`agent-server exited before startup completed (code=${code ?? "null"}, signal=${signal ?? "null"})`,
),
);
}
});
});
try {
await Promise.race([
waitForServer(`${config.backendBaseUrl}/server_info`),
backendErrored,
backendExited,
]);
} catch (error) {
shutdown();
throw error;
}
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
frontend = spawnProcess(npmCommand, ["run", "dev:frontend"], {
cwd: config.cwd,
env: {
...process.env,
VITE_BACKEND_HOST: config.backendHost,
VITE_BACKEND_BASE_URL: config.backendBaseUrl,
VITE_WORKING_DIR: config.workingDir,
},
});
frontend.once("exit", (code) => {
shutdown();
process.exitCode = code ?? 0;
});
backend.once("exit", (code) => {
if (!shuttingDown) {
console.error(`agent-server exited unexpectedly with code ${code ?? 0}`);
shutdown();
process.exitCode = code ?? 1;
}
});
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});
}