Files
OpenHands/scripts/dev-safe.mjs
T
2899c7b383 Fix static automation auth and switch LLM tool (#474)
* Fix static automation auth and switch LLM tool

* Allow automation SDK release lag in sync check

* chore: update baseline snapshots [skip ci]

* chore: trigger CI after snapshot update

---------

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-15 15:30:29 +00:00

923 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
statSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import net from "node:net";
import { homedir, tmpdir } from "node:os";
import path from "node:path";
import process from "node:process";
import { setTimeout as delay } from "node:timers/promises";
import { pathToFileURL } from "node:url";
import {
getProcessTreeSpawnOptions,
isProcessRunning,
signalProcessTree,
} from "./dev-process-utils.mjs";
const DEFAULT_BACKEND_PORT = 18000;
const DEFAULT_VITE_PORT = 3001;
const DEFAULT_WAIT_TIMEOUT_MS = 30_000;
const DEFAULT_AGENT_SERVER_PACKAGE = "openhands-agent-server";
const AGENT_SERVER_GIT_REPO = "https://github.com/OpenHands/software-agent-sdk";
const LOCAL_AGENT_SERVER_SUBDIRS = [
"openhands-agent-server",
"openhands-sdk",
"openhands-tools",
"openhands-workspace",
];
// Default secret key for local development (DO NOT use in production)
// This is kept static because it's used for encrypting/decrypting persisted settings
const DEFAULT_SECRET_KEY = "openhands-dev-secret-key-change-in-prod";
// Default agent-server version (released PyPI version)
// Set OH_AGENT_SERVER_GIT_REF to use a git branch/SHA instead
const DEFAULT_AGENT_SERVER_VERSION = "1.22.1";
const FRONTEND_REQUIRED_BINS = ["cross-env", "react-router"];
/**
* Generate a cryptographically secure random API key.
* Returns a 64-character hex string (256-bit).
*/
export function generateRandomApiKey() {
return randomBytes(32).toString("hex");
}
// Where the auto-generated default session API key is persisted so it stays
// stable across `npm run dev` / `npm run dev:dangerously-dockerless` /
// `npm run dev:docker` restarts. Keeping the key stable means the value
// baked into the frontend (VITE_SESSION_API_KEY) and the persisted
// backend-registry entry (`openhands-backends` localStorage) stay in sync
// without users needing to set anything in `.env`.
//
// To rotate the key, delete this file. To pin a key explicitly, export
// SESSION_API_KEY (or OH_SESSION_API_KEYS_0 / VITE_SESSION_API_KEY) -- those
// take precedence over the persisted file.
export const DEFAULT_SESSION_API_KEY_PATH = path.join(
homedir(),
".openhands",
"agent-canvas",
"session-api-key.txt",
);
// Cache so repeated lookups within a single process return the same key,
// keyed by file path so tests can use temp paths in isolation.
const persistedApiKeyCache = new Map();
/**
* Load the persisted default session API key, generating + persisting one if
* the file doesn't exist yet.
*
* Best-effort: if the file can't be written (e.g. read-only home dir), we
* fall back to an in-memory key for this process so dev still works -- the
* key just won't survive a restart.
*
* @param {string} filePath - Where to read/write the key.
* @returns {string} The (hex) session API key.
*/
export function getOrCreatePersistedSessionApiKey(
filePath = DEFAULT_SESSION_API_KEY_PATH,
) {
return getOrCreatePersistedApiKey(filePath, "session");
}
/**
* Load a persisted default API key, generating + persisting one if the file
* doesn't exist yet.
*
* Best-effort: if the file can't be written (e.g. read-only home dir), we
* fall back to an in-memory key for this process so dev still works -- the
* key just won't survive a restart.
*
* @param {string} filePath - Where to read/write the key.
* @param {string} label - Human-readable key label for warning messages.
* @returns {string} The (hex) API key.
*/
export function getOrCreatePersistedApiKey(filePath, label = "API") {
const cached = persistedApiKeyCache.get(filePath);
if (cached) return cached;
// Try to read an existing key.
try {
const existing = readFileSync(filePath, "utf8").trim();
if (existing) {
persistedApiKeyCache.set(filePath, existing);
return existing;
}
// File exists but is empty -- treat as if missing and regenerate.
} catch (error) {
if (!isEnoentError(error)) {
console.warn(
`Could not read persisted ${label} API key from ${filePath}: ${error.message}. Regenerating.`,
);
}
}
// Generate and persist a new key.
const newKey = generateRandomApiKey();
try {
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, `${newKey}\n`, { mode: 0o600 });
} catch (error) {
console.warn(
`Could not persist ${label} API key to ${filePath}: ${error.message}. Falling back to in-memory key (will not survive restarts).`,
);
}
persistedApiKeyCache.set(filePath, newKey);
return newKey;
}
/**
* Clear the in-memory cache used by {@link getOrCreatePersistedSessionApiKey}.
* Intended for tests that swap the persisted file path between cases.
*/
export function resetPersistedSessionApiKeyCache() {
persistedApiKeyCache.clear();
}
function isEnoentError(error) {
return Boolean(
(error &&
typeof error === "object" &&
"code" in error &&
error.code === "ENOENT") ||
/ENOENT/.test(String(error)),
);
}
/**
* Find a free port, preferring the specified port if available.
*
* Tries the preferred port first; if it's busy, falls back to letting
* the OS assign any available port. This preserves predictable defaults
* while gracefully handling port conflicts.
*
* **Note on race conditions:** There is a small window between when this
* function checks port availability and when the calling service actually
* binds to the port. During this window, another process could theoretically
* grab the port. This is an accepted limitation of the "check-then-use"
* approach. Callers (like agent-server) should handle EADDRINUSE gracefully.
* For Vite, `strictPort: true` ensures a fast failure if this occurs.
*
* @param {number} preferredPort - The port to try first
* @param {string} host - The host to bind to (default: "127.0.0.1")
* @returns {Promise<number>} The actual port that was acquired
*/
export async function findFreePort(preferredPort, host = "127.0.0.1") {
// If preferredPort is 0, skip the check and go straight to OS assignment
if (preferredPort > 0) {
const preferredAvailable = await tryPort(preferredPort, host);
if (preferredAvailable) {
return preferredPort;
}
}
// Fall back to OS-assigned port
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, host, () => {
const { port } = server.address();
server.close(() => resolve(port));
});
});
}
/**
* Check if a port is available by attempting to bind to it.
*
* @param {number} port - The port to check
* @param {string} host - The host to bind to
* @returns {Promise<boolean>} True if the port is available
*/
function tryPort(port, host = "127.0.0.1") {
return new Promise((resolve) => {
const server = net.createServer();
server.once("error", () => resolve(false));
server.listen(port, host, () => {
server.close(() => resolve(true));
});
});
}
/**
* Find multiple free ports at once, each preferring its specified default.
*
* Allocates ports sequentially to avoid race conditions between checks.
*
* @param {Array<{name: string, preferred: number}>} portConfigs - Port configurations
* @param {string} host - The host to bind to (default: "127.0.0.1")
* @returns {Promise<Record<string, number>>} Map of name to actual port
*/
export async function findFreePorts(portConfigs, host = "127.0.0.1") {
const result = {};
const usedPorts = new Set();
for (const { name, preferred } of portConfigs) {
// Try preferred if not already taken by a previous allocation
// Skip if preferred is 0 (means "any port") or already used
if (preferred > 0 && !usedPorts.has(preferred)) {
const available = await tryPort(preferred, host);
if (available) {
result[name] = preferred;
usedPorts.add(preferred);
continue;
}
}
// Fall back to OS-assigned port, retrying if we get a collision
let port;
let attempts = 0;
const maxAttempts = 100;
do {
port = await findFreePort(0, host);
if (++attempts > maxAttempts) {
throw new Error(
`Could not allocate unique port for "${name}" after ${maxAttempts} attempts`,
);
}
} while (usedPorts.has(port));
result[name] = port;
usedPorts.add(port);
}
return result;
}
export function formatMissingUvxGuidance(cwd = process.cwd()) {
const readmePath = path.join(cwd, "README.md");
return [
"Failed to start uvx. Make sure uv is installed and on your PATH.",
"",
"To fix this:",
"1. Install uv:",
" curl -LsSf https://astral.sh/uv/install.sh | sh",
"2. Make sure the uv bin dir is on your PATH:",
' export PATH="$HOME/.local/bin:$PATH"',
" command -v uvx",
"",
"Need Windows or another install method? https://docs.astral.sh/uv/getting-started/installation/",
`See the local Quickstart for details: ${readmePath}`,
"",
"Other options:",
"- npm run dev:frontend # use an already running backend",
"- npm run dev:mock # run the frontend with mock APIs",
].join("\n");
}
function npmBinCandidates(binName, platform = process.platform) {
const candidates = [binName];
if (platform === "win32") {
candidates.push(`${binName}.cmd`, `${binName}.ps1`);
}
return candidates;
}
export function getMissingFrontendDependencyBins(
cwd = process.cwd(),
platform = process.platform,
) {
const binDir = path.join(cwd, "node_modules", ".bin");
return FRONTEND_REQUIRED_BINS.filter(
(binName) =>
!npmBinCandidates(binName, platform).some((candidate) =>
existsSync(path.join(binDir, candidate)),
),
);
}
export function formatMissingFrontendDependenciesGuidance(
missingBins,
cwd = process.cwd(),
) {
const missingList = missingBins.join(", ");
return [
"Frontend dependencies are not installed or are incomplete.",
"",
`Missing npm binaries: ${missingList}`,
"",
"Run this from the repository root:",
" npm ci",
"",
`Repository root: ${cwd}`,
].join("\n");
}
export function validateFrontendDependencies(
cwd = process.cwd(),
platform = process.platform,
) {
const missingBins = getMissingFrontendDependencyBins(cwd, platform);
if (missingBins.length > 0) {
throw new Error(
formatMissingFrontendDependenciesGuidance(missingBins, cwd),
);
}
}
/**
* Build the uvx command and arguments for running agent-server.
*
* Environment variables (highest precedence first):
* - OH_AGENT_SERVER_LOCAL_PATH: Absolute path to a software-agent-sdk checkout.
* Runs the local checkout via uvx with editable installs of the workspace
* packages (openhands-sdk, openhands-tools, openhands-workspace) so source
* edits are picked up without a manual reinstall. The agent-server itself
* is rebuilt from local source on each invocation (--reinstall).
* - OH_AGENT_SERVER_GIT_REF: Git commit SHA or branch name
* - OH_AGENT_SERVER_VERSION: Specific PyPI version (e.g., "1.22.1")
*
* If none are set, defaults to the released version specified by
* DEFAULT_AGENT_SERVER_VERSION. Set OH_AGENT_SERVER_GIT_REF to use a
* git branch or commit instead.
*
* @param {Record<string, string | undefined>} env
* @returns {{ command: string, args: string[], source: string }}
*/
export function buildAgentServerCommand(env = process.env) {
const localPath = env.OH_AGENT_SERVER_LOCAL_PATH;
const gitRef = env.OH_AGENT_SERVER_GIT_REF;
const version = env.OH_AGENT_SERVER_VERSION;
const uvxArgs = [];
let source = "";
if (localPath) {
if (!path.isAbsolute(localPath)) {
throw new Error(
`OH_AGENT_SERVER_LOCAL_PATH must be an absolute path, got: ${localPath}`,
);
}
uvxArgs.push(
"--reinstall",
"--from",
path.join(localPath, "openhands-agent-server"),
"--with-editable",
path.join(localPath, "openhands-sdk"),
"--with-editable",
path.join(localPath, "openhands-tools"),
"--with-editable",
path.join(localPath, "openhands-workspace"),
"agent-server",
);
source = `local (${localPath})`;
} else if (gitRef) {
// Use git ref with subdirectory syntax for uv workspace monorepo
// The software-agent-sdk repo has packages in subdirectories:
// openhands-agent-server/, openhands-tools/, openhands-workspace/
const baseGitUrl = `git+${AGENT_SERVER_GIT_REPO}@${gitRef}`;
uvxArgs.push(
"--from",
`${baseGitUrl}#subdirectory=openhands-agent-server`,
"--with",
`${baseGitUrl}#subdirectory=openhands-tools`,
"--with",
`${baseGitUrl}#subdirectory=openhands-workspace`,
"agent-server",
);
source = `git (${gitRef})`;
} else if (version) {
// Use specific PyPI version: uvx --from openhands-agent-server==version agent-server
// The package name differs from the executable name, so we need --from syntax
// Pin all SDK packages to the same version for consistency
uvxArgs.push(
"--from",
`${DEFAULT_AGENT_SERVER_PACKAGE}==${version}`,
"--with",
`openhands-tools==${version}`,
"--with",
`openhands-workspace==${version}`,
"agent-server",
);
source = `PyPI (${version})`;
} else {
// Default to released PyPI version
// Pin all SDK packages to the same version for consistency
uvxArgs.push(
"--from",
`${DEFAULT_AGENT_SERVER_PACKAGE}==${DEFAULT_AGENT_SERVER_VERSION}`,
"--with",
`openhands-tools==${DEFAULT_AGENT_SERVER_VERSION}`,
"--with",
`openhands-workspace==${DEFAULT_AGENT_SERVER_VERSION}`,
"agent-server",
);
source = `PyPI (${DEFAULT_AGENT_SERVER_VERSION}, default)`;
}
return {
command: "uvx",
args: uvxArgs,
source,
};
}
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;
}
/**
* Build safe dev configuration (synchronous version).
*
* Uses the port values from environment variables or defaults WITHOUT checking
* port availability. Use this when:
* - You need synchronous config (e.g., for test setup, config inspection)
* - Ports are already known to be available (e.g., specified via env vars)
* - You're building config objects for downstream use, not starting services
*
* For scripts that actually start services (dev-safe.mjs main, dev-with-automation.mjs),
* use {@link buildSafeDevConfigAsync} instead to handle port conflicts gracefully.
*
* @param {string} cwd - Current working directory
* @param {Record<string, string | undefined>} env - Environment variables
* @returns {SafeDevConfig} Configuration object
*/
export function buildSafeDevConfig(cwd = process.cwd(), env = process.env) {
const backendPort = parsePort(
env.OH_CANVAS_SAFE_BACKEND_PORT,
DEFAULT_BACKEND_PORT,
);
const vscodePort = parsePort(
env.OH_CANVAS_SAFE_VSCODE_PORT,
backendPort + 1,
);
return buildConfigFromPorts({ backendPort, vscodePort }, cwd, env);
}
/**
* Build safe dev configuration with dynamic port allocation.
*
* Tries preferred ports first; if busy, finds available alternatives.
* This is the recommended entry point for scripts that start services.
*
* @param {string} cwd - Current working directory
* @param {Record<string, string | undefined>} env - Environment variables
* @returns {Promise<SafeDevConfig>} Configuration object with allocated ports
*/
export async function buildSafeDevConfigAsync(
cwd = process.cwd(),
env = process.env,
) {
// Get preferred ports from env or defaults
const preferredBackendPort = parsePort(
env.OH_CANVAS_SAFE_BACKEND_PORT,
DEFAULT_BACKEND_PORT,
);
const preferredVscodePort = parsePort(
env.OH_CANVAS_SAFE_VSCODE_PORT,
preferredBackendPort + 1,
);
// Find available ports, preferring the defaults
const ports = await findFreePorts([
{ name: "backend", preferred: preferredBackendPort },
{ name: "vscode", preferred: preferredVscodePort },
]);
// Log if we're using non-default ports
if (ports.backend !== preferredBackendPort) {
console.log(
` ℹ Port ${preferredBackendPort} busy, using ${ports.backend} for agent-server`,
);
}
if (ports.vscode !== preferredVscodePort) {
console.log(
` ℹ Port ${preferredVscodePort} busy, using ${ports.vscode} for vscode`,
);
}
return buildConfigFromPorts(
{ backendPort: ports.backend, vscodePort: ports.vscode },
cwd,
env,
);
}
/**
* @typedef {object} SafeDevConfig
* @property {string} cwd
* @property {number} backendPort
* @property {number} vscodePort
* @property {string} stateDir
* @property {string} tmuxTmpDir
* @property {string} conversationsPath
* @property {string} workspacesPath
* @property {string} bashEventsDir
* @property {string} backendBaseUrl
* @property {string} backendHost
* @property {string} workingDir
* @property {string} secretKey
* @property {string} sessionApiKey
*/
/**
* Internal helper to build config from already-resolved ports.
* @param {{backendPort: number, vscodePort: number}} ports
* @param {string} cwd
* @param {Record<string, string | undefined>} env
* @returns {SafeDevConfig}
*/
function buildConfigFromPorts(ports, cwd, env) {
const { backendPort, vscodePort } = ports;
const stateDir = path.resolve(
cwd,
env.OH_CANVAS_SAFE_STATE_DIR ||
path.join(homedir(), ".openhands", "agent-canvas"),
);
const conversationsPath = path.join(stateDir, "conversations");
const workspacesPath = path.join(stateDir, "workspaces");
// Use provided secret key or default for local development
const secretKey = env.OH_SECRET_KEY || DEFAULT_SECRET_KEY;
// Use provided session API key or fall back to a key persisted to
// ~/.openhands/agent-canvas/session-api-key.txt. Persisting on disk keeps
// the agent-server, the Vite-baked VITE_SESSION_API_KEY, and any
// `openhands-backends` localStorage entries the frontend has cached all
// pointing at the same value across dev restarts.
//
// Check multiple env vars that may be used:
// - SESSION_API_KEY: Common name
// - OH_SESSION_API_KEYS_0: Used by agent-server V1 config
// - VITE_SESSION_API_KEY: Used by frontend config
// OH_SESSION_API_KEY_PATH overrides the persisted file path (used by tests).
const persistedKeyPath =
env.OH_SESSION_API_KEY_PATH || DEFAULT_SESSION_API_KEY_PATH;
const sessionApiKey =
env.SESSION_API_KEY ||
env.OH_SESSION_API_KEYS_0 ||
env.VITE_SESSION_API_KEY ||
getOrCreatePersistedSessionApiKey(persistedKeyPath);
return {
cwd,
backendPort,
vscodePort,
stateDir,
tmuxTmpDir: path.join(tmpdir(), "openhands-agent-canvas-tmux"),
conversationsPath,
workspacesPath,
bashEventsDir: path.join(stateDir, "bash_events"),
backendBaseUrl: `http://127.0.0.1:${backendPort}`,
backendHost: `127.0.0.1:${backendPort}`,
workingDir: env.VITE_WORKING_DIR || workspacesPath,
secretKey,
sessionApiKey,
};
}
/**
* Build the environment variables object for spawning the agent-server process.
*
* This is exported so downstream consumers (e.g., automation service) can use
* the same env vars without duplicating the mapping logic.
*
* @param {ReturnType<typeof buildSafeDevConfig>} config - Config from buildSafeDevConfig
* @returns {Record<string, string>} Environment variables for agent-server
*/
export function buildAgentServerEnv(config) {
return {
TMUX_TMPDIR: config.tmuxTmpDir,
OH_CONVERSATIONS_PATH: config.conversationsPath,
OH_BASH_EVENTS_DIR: config.bashEventsDir,
OH_VSCODE_PORT: String(config.vscodePort),
OH_SECRET_KEY: config.secretKey,
// Use OH_SESSION_API_KEYS_0 for agent-server V1 config format
OH_SESSION_API_KEYS_0: config.sessionApiKey,
};
}
export function buildNpmScriptCommand(
scriptName,
platform = process.platform,
env = process.env,
nodeExecPath = process.execPath,
) {
if (env.npm_execpath) {
return {
command: env.npm_node_execpath || nodeExecPath,
args: [env.npm_execpath, "run", scriptName],
};
}
if (platform === "win32") {
return {
command: env.ComSpec || "cmd.exe",
args: ["/d", "/s", "/c", "npm", "run", scriptName],
};
}
return {
command: "npm",
args: ["run", scriptName],
};
}
export function validateLocalAgentServerPath(localPath) {
if (!path.isAbsolute(localPath)) {
throw new Error(
`OH_AGENT_SERVER_LOCAL_PATH must be an absolute path, got: ${localPath}`,
);
}
if (!existsSync(localPath)) {
throw new Error(
`OH_AGENT_SERVER_LOCAL_PATH does not exist: ${localPath}`,
);
}
for (const subdir of LOCAL_AGENT_SERVER_SUBDIRS) {
const subdirPath = path.join(localPath, subdir);
if (!existsSync(subdirPath)) {
throw new Error(
`OH_AGENT_SERVER_LOCAL_PATH is missing expected workspace package '${subdir}': ${subdirPath}`,
);
}
}
}
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, getProcessTreeSpawnOptions({
stdio: "inherit",
...options,
}));
child.once("error", (error) => {
if (isEnoentError(error) && command === "uvx") {
console.error(formatMissingUvxGuidance(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() {
console.log("Starting isolated agent-server + frontend dev stack...");
validateFrontendDependencies();
console.log("Frontend dependencies found.");
console.log("Allocating ports...");
// Use async config builder with dynamic port allocation
const config = await buildSafeDevConfigAsync();
if (process.env.OH_AGENT_SERVER_LOCAL_PATH) {
validateLocalAgentServerPath(process.env.OH_AGENT_SERVER_LOCAL_PATH);
}
for (const dir of [
config.stateDir,
config.tmuxTmpDir,
config.conversationsPath,
config.workspacesPath,
config.bashEventsDir,
]) {
mkdirSync(dir, { recursive: true });
}
const agentServerCmd = buildAgentServerCommand();
const secretKeySource = process.env.OH_SECRET_KEY
? "custom (from OH_SECRET_KEY)"
: "default (for local development)";
const sessionKeySource =
process.env.SESSION_API_KEY ||
process.env.OH_SESSION_API_KEYS_0 ||
process.env.VITE_SESSION_API_KEY
? "custom (from env)"
: `persisted (${
process.env.OH_SESSION_API_KEY_PATH || DEFAULT_SESSION_API_KEY_PATH
})`;
console.log(`- agent-server: ${agentServerCmd.source}`);
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(`- secret key: ${secretKeySource}`);
console.log(`- session API key: ${sessionKeySource}`);
console.log("");
const backend = spawnProcess(
agentServerCmd.command,
[
...agentServerCmd.args,
"--host",
"127.0.0.1",
"--port",
String(config.backendPort),
],
{
cwd: config.cwd,
env: {
...process.env,
...buildAgentServerEnv(config),
},
},
);
let shuttingDown = false;
let frontend = null;
const shutdown = (signal = "SIGTERM") => {
if (shuttingDown) {
return;
}
shuttingDown = true;
if (frontend) {
signalProcessTree(frontend, signal);
}
signalProcessTree(backend, signal);
setTimeout(() => {
if (frontend && isProcessRunning(frontend)) {
signalProcessTree(frontend, "SIGKILL");
}
if (isProcessRunning(backend)) {
signalProcessTree(backend, "SIGKILL");
}
process.exit(process.exitCode ?? 0);
}, 3000);
};
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 frontendCommand = buildNpmScriptCommand("dev:frontend");
frontend = spawnProcess(frontendCommand.command, frontendCommand.args, {
cwd: config.cwd,
env: {
...process.env,
VITE_BACKEND_HOST: config.backendHost,
VITE_BACKEND_BASE_URL: config.backendBaseUrl,
VITE_WORKING_DIR: config.workingDir,
// Pass session API key so frontend can authenticate with agent-server
VITE_SESSION_API_KEY: config.sessionApiKey,
},
});
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;
}
});
}
// ─────────────────────────────────────────────────────────────────────────────
// Conversation lease cleanup
// ─────────────────────────────────────────────────────────────────────────────
/**
* Returns true if `host:port` accepts a TCP connection within `timeoutMs`.
* Used to detect a live agent-server we shouldn't disturb.
*/
export function isPortBusy(port, host = "127.0.0.1", timeoutMs = 500) {
return new Promise((resolve) => {
const socket = new net.Socket();
let settled = false;
const finish = (busy) => {
if (settled) return;
settled = true;
socket.destroy();
resolve(busy);
};
socket.setTimeout(timeoutMs);
socket.once("connect", () => finish(true));
socket.once("timeout", () => finish(false));
socket.once("error", () => finish(false));
socket.connect(port, host);
});
}
/**
* Remove stale `owner_lease.json` files under `conversationsDir` so a
* freshly spawned agent-server can claim ownership and re-load every
* existing conversation.
*
* Why this is needed: each conversation directory carries an
* `owner_lease.json` that locks it to a single agent-server's
* `owner_instance_id` for a 45 s TTL refreshed by heartbeat. On
* graceful shutdown the agent-server unlinks its leases; on a hard
* kill (or a fast restart, well under 45 s) the leases linger. A new
* agent-server with a fresh `owner_instance_id` will then raise
* `ConversationLeaseHeldError` for each conversation at startup load
* and skip it entirely — `/api/conversations/search` returns `[]`
* even though the meta files are right there on disk.
*
* The caller MUST verify (e.g. with `isPortBusy`) that no agent-server
* is currently bound to the backend port before calling this — there
* is no other reliable way to tell a stale lease from an actively
* renewed one.
*
* Returns the number of lease files unlinked.
*/
export function releaseStaleConversationLeases(conversationsDir) {
if (!existsSync(conversationsDir)) return 0;
let removed = 0;
for (const name of readdirSync(conversationsDir)) {
const convDir = path.join(conversationsDir, name);
let isDir = false;
try {
isDir = statSync(convDir).isDirectory();
} catch {
continue;
}
if (!isDir) continue;
const leasePath = path.join(convDir, "owner_lease.json");
if (!existsSync(leasePath)) continue;
try {
unlinkSync(leasePath);
removed += 1;
} catch {
// Best-effort: the new agent-server will simply skip this
// conversation as before. Don't fail the whole start.
}
}
return removed;
}
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);
});
}