Files
OpenHands/scripts/dev-with-automation.mjs
T
Graham Neubig ffd19e977f Fix Windows dev script startup (#199)
* Fix Windows dev script startup

* Run CI on Windows

* Disable npm cache on Windows CI
2026-05-08 21:36:20 -04:00

731 lines
26 KiB
JavaScript

/**
* Development Stack with Automation Service
*
* Extends agent-canvas's dev-safe.mjs to additionally run the OpenHands Automation
* backend via uvx. No cloning required - runs directly from git reference.
*
* Uses a standalone ingress proxy to route traffic to multiple backends.
*
* Architecture:
* ┌──────────────────────────────────────────────────────────────────────────┐
* │ http://localhost:8000 (Ingress Proxy) │
* │ /api/automation/* → Automation Backend │
* │ /api/*, /sockets → Agent Server │
* │ /* → Vite Dev Server │
* └──────────────────────────────────────────────────────────────────────────┘
* │ │ │
* ▼ ▼ ▼
* ┌─────────────┐ ┌───────────────┐ ┌──────────────────┐
* │ Vite │ │ Agent Server │ │ Automation │
* │ :3001 │ │ (uvx) :18000 │ │ Backend (uvx) │
* │ │ │ │ │ :18001 │
* └─────────────┘ └───────────────┘ └──────────────────┘
*
* Usage:
* node scripts/dev-with-automation.mjs
* node scripts/dev-with-automation.mjs --automation-ref feat/my-branch
* node scripts/dev-with-automation.mjs --port 12000
*
* Environment variables:
* - PORT: Ingress port (default: 8000)
* - OH_AUTOMATION_GIT_REF: Git ref for automation (default: main)
* - OH_AGENT_SERVER_GIT_REF: Git ref for agent-server
* - AUTOMATION_LOCAL_API_KEY: Custom API key for automation backend auth
*
* Secrets:
* The automation API key is automatically seeded into agent-server secrets
* as OPENHANDS_AUTOMATION_API_KEY, making it available to agents in conversations.
*/
import { spawn, spawnSync } from "node:child_process";
import { mkdirSync } from "node:fs";
import { join, resolve, dirname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { homedir } from "node:os";
import { setTimeout as delay } from "node:timers/promises";
import process from "node:process";
import {
buildAgentServerCommand,
buildSafeDevConfig,
buildAgentServerEnv,
buildNpmScriptCommand,
formatMissingUvxGuidance,
} from "./dev-safe.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = resolve(__dirname, "..");
const DEFAULT_AUTOMATION_REPO = "https://github.com/OpenHands/automation";
const DEFAULT_AUTOMATION_PACKAGE = "openhands-automation";
// Default automation version (released PyPI version)
// Set OH_AUTOMATION_GIT_REF to use a git branch/SHA instead
const DEFAULT_AUTOMATION_VERSION = "1.0.0a1";
const DEFAULT_BACKEND_PORT = 18000;
const DEFAULT_AUTOMATION_PORT = 18001;
// Default local API key for automation backend auth (matches agent-server pattern)
const DEFAULT_LOCAL_API_KEY = "openhands-local-api-key";
// ═══════════════════════════════════════════════════════════════════════════
// Terminal Styling
// ═══════════════════════════════════════════════════════════════════════════
const c = {
reset: "\x1b[0m",
bold: "\x1b[1m",
dim: "\x1b[2m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
magenta: "\x1b[35m",
cyan: "\x1b[36m",
};
function logService(name, message, color = c.reset) {
const ts = new Date().toISOString().split("T")[1].split(".")[0];
console.log(`${c.dim}${ts}${c.reset} ${color}[${name}]${c.reset} ${message}`);
}
function logStep(step, message) {
console.log(`${c.cyan}[${step}]${c.reset} ${message}`);
}
function logSuccess(message) {
console.log(`${c.green}✓${c.reset} ${message}`);
}
function logError(message) {
console.error(`${c.red}✗${c.reset} ${message}`);
}
// ═══════════════════════════════════════════════════════════════════════════
// Configuration
// ═══════════════════════════════════════════════════════════════════════════
function parseArgs() {
const args = process.argv.slice(2);
const config = {
port: null,
automationGitRef: null,
automationRepo: null,
verbose: false,
};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case "-p":
case "--port":
config.port = parseInt(args[++i], 10);
break;
case "--automation-ref":
config.automationGitRef = args[++i];
break;
case "--automation-repo":
config.automationRepo = args[++i];
break;
case "-v":
case "--verbose":
config.verbose = true;
break;
case "-h":
case "--help":
showHelp();
process.exit(0);
}
}
return config;
}
function showHelp() {
console.log(`
Agent Canvas + Automation Development Stack
Runs agent-canvas with the automation backend (via uvx, no clone needed).
Uses a standalone ingress proxy to route traffic.
USAGE:
node scripts/dev-with-automation.mjs [options]
OPTIONS:
-p, --port <port> Ingress port (default: 8000)
--automation-ref <ref> Git ref for automation (branch/tag/SHA)
--automation-repo <url> Git repo URL (default: ${DEFAULT_AUTOMATION_REPO})
-v, --verbose Show detailed output
-h, --help Show this help
ENVIRONMENT VARIABLES:
PORT Alternative to --port
OH_AUTOMATION_GIT_REF Git ref for automation (overrides default version)
OH_AUTOMATION_VERSION Specific PyPI version for automation (default: ${DEFAULT_AUTOMATION_VERSION})
OH_AGENT_SERVER_GIT_REF Git ref for agent-server SDK (overrides default version)
OH_AGENT_SERVER_VERSION Specific PyPI version for agent-server
OH_SECRET_KEY Secret key for sessions
AUTOMATION_LOCAL_API_KEY Custom API key for automation backend auth
SECRETS:
The automation API key is automatically seeded into agent-server secrets
as OPENHANDS_AUTOMATION_API_KEY, making it available to agents in conversations.
ACCESS POINTS:
Main UI: http://localhost:PORT/
API Docs: http://localhost:PORT/api/automation/docs
`);
}
/**
* Build the uvx command for running automation backend.
*
* Environment variables (highest precedence first):
* - OH_AUTOMATION_GIT_REF: Git commit SHA or branch name
* - OH_AUTOMATION_VERSION: Specific PyPI version (e.g., "1.0.0a1")
*
* If none are set, defaults to the released version specified by
* DEFAULT_AUTOMATION_VERSION. Set OH_AUTOMATION_GIT_REF to use a
* git branch or commit instead.
*/
function buildAutomationCommand(env = process.env) {
const gitRef = env.OH_AUTOMATION_GIT_REF;
const version = env.OH_AUTOMATION_VERSION;
const repoUrl = env.OH_AUTOMATION_REPO || DEFAULT_AUTOMATION_REPO;
const uvxArgs = [];
let source = "";
if (gitRef) {
// Use git ref - refresh to ensure latest commit is fetched
const gitUrl = `git+${repoUrl}@${gitRef}`;
uvxArgs.push("--refresh", "--from", gitUrl, "uvicorn", "openhands.automation.app:app");
source = `git (${gitRef})`;
} else if (version) {
// Use specific PyPI version
uvxArgs.push("--from", `${DEFAULT_AUTOMATION_PACKAGE}==${version}`, "uvicorn", "openhands.automation.app:app");
source = `PyPI (${version})`;
} else {
// Default to released PyPI version
uvxArgs.push("--from", `${DEFAULT_AUTOMATION_PACKAGE}==${DEFAULT_AUTOMATION_VERSION}`, "uvicorn", "openhands.automation.app:app");
source = `PyPI (${DEFAULT_AUTOMATION_VERSION}, default)`;
}
return {
command: "uvx",
args: uvxArgs,
source,
};
}
function buildConfig(args, env = process.env) {
// Apply args to env for buildAutomationCommand
if (args.automationGitRef) {
env.OH_AUTOMATION_GIT_REF = args.automationGitRef;
}
if (args.automationRepo) {
env.OH_AUTOMATION_REPO = args.automationRepo;
}
const ingressPort = args.port || parseInt(env.PORT, 10) || 8000;
const backendPort = DEFAULT_BACKEND_PORT;
const automationPort = DEFAULT_AUTOMATION_PORT;
const vitePort = 3001;
const vscodePort = backendPort + 1000;
// Local API key for automation backend auth
const localApiKey = env.AUTOMATION_LOCAL_API_KEY || DEFAULT_LOCAL_API_KEY;
// Session API key for agent-server auth (optional)
// Check multiple env vars that the agent-server may use:
// - SESSION_API_KEY: Used by agent-server default config (V0)
// - OH_SESSION_API_KEYS_0: Used by agent-server V1 config
// - OH_SESSION_API_KEY: Common alias
// - VITE_SESSION_API_KEY: Used by frontend config
const sessionApiKey = env.SESSION_API_KEY || env.OH_SESSION_API_KEYS_0 || env.OH_SESSION_API_KEY || env.VITE_SESSION_API_KEY || null;
return {
// Ingress port (main entry point)
ingressPort,
// Service ports (internal)
agentServerPort: backendPort,
autoBackendPort: automationPort,
vitePort,
vscodePort,
// Paths
canvasPath: projectRoot,
// Data directories (same as dev-safe.mjs)
stateDir: join(homedir(), ".openhands", "agent-canvas"),
// Auth
localApiKey,
sessionApiKey,
verbose: args.verbose,
};
}
// ═══════════════════════════════════════════════════════════════════════════
// Prerequisites & Setup
// ═══════════════════════════════════════════════════════════════════════════
function commandExists(cmd) {
const result =
process.platform === "win32"
? spawnSync("where.exe", [cmd], { stdio: "pipe" })
: spawnSync("sh", ["-c", `command -v ${cmd}`], { stdio: "pipe" });
return result.status === 0;
}
function checkPrerequisites() {
logStep("1/2", "Checking prerequisites...");
if (!commandExists("uvx")) {
console.error(formatMissingUvxGuidance(projectRoot));
process.exit(1);
}
logSuccess("uvx found");
if (!commandExists("npm")) {
logError("npm is required but not found");
process.exit(1);
}
logSuccess("npm found");
}
function ensureDirectories(config) {
const dirs = [
config.stateDir,
join(config.stateDir, "tmux"),
join(config.stateDir, "conversations"),
join(config.stateDir, "workspaces"),
join(config.stateDir, "bash_events"),
join(config.stateDir, "storage"),
];
for (const dir of dirs) {
mkdirSync(dir, { recursive: true });
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Process Management
// ═══════════════════════════════════════════════════════════════════════════
const processes = new Map();
function spawnService(name, command, args, options = {}) {
const proc = spawn(command, args, {
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, ...options.env },
cwd: options.cwd,
shell: process.platform === "win32",
});
const color = options.color || c.reset;
proc.stdout.on("data", (data) => {
data
.toString()
.split("\n")
.filter(Boolean)
.forEach((line) => {
logService(name, line.trim(), color);
});
});
proc.stderr.on("data", (data) => {
data
.toString()
.split("\n")
.filter(Boolean)
.forEach((line) => {
logService(name, line.trim(), c.yellow);
});
});
proc.on("error", (error) => {
logError(`${name} failed to start: ${error.message}`);
});
proc.on("exit", (code, signal) => {
if (code !== 0 && code !== null && !shuttingDown) {
logService(name, `Exited with code ${code}`, c.red);
}
processes.delete(name);
});
processes.set(name, proc);
return proc;
}
async function waitForService(name, url, timeoutMs = 30000) {
const start = Date.now();
let lastError = null;
while (Date.now() - start < timeoutMs) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
if (res.ok) {
logService(name, `Ready at ${url}`, c.green);
return true;
}
} catch (err) {
lastError = err;
// Keep trying
}
await delay(500);
}
const elapsed = Math.round((Date.now() - start) / 1000);
logService(name, `Timeout waiting for ${url} after ${elapsed}s`, c.red);
if (lastError) {
logService(name, `Last error: ${lastError.message}`, c.dim);
}
return false;
}
// ═══════════════════════════════════════════════════════════════════════════
// Service Starters
// ═══════════════════════════════════════════════════════════════════════════
function startAgentServer(config) {
logService("agent-server", `Starting on port ${config.agentServerPort}...`, c.blue);
const agentServerCmd = buildAgentServerCommand(process.env);
logService("agent-server", `Using ${agentServerCmd.source}`, c.dim);
// Build safe config for agent-server env vars
const safeConfig = buildSafeDevConfig(config.canvasPath, {
...process.env,
OH_CANVAS_SAFE_STATE_DIR: config.stateDir,
OH_CANVAS_SAFE_BACKEND_PORT: config.agentServerPort.toString(),
OH_CANVAS_SAFE_VSCODE_PORT: config.vscodePort.toString(),
});
const agentServerEnv = buildAgentServerEnv(safeConfig);
spawnService(
"agent-server",
agentServerCmd.command,
[...agentServerCmd.args, "--host", "127.0.0.1", "--port", String(config.agentServerPort)],
{
cwd: safeConfig.workspacesPath,
env: agentServerEnv,
color: c.blue,
}
);
}
function startAutomationBackend(config) {
logService("automation", `Starting on port ${config.autoBackendPort}...`, c.green);
const automationCmd = buildAutomationCommand(process.env);
logService("automation", `Using ${automationCmd.source}`, c.dim);
spawnService(
"automation",
automationCmd.command,
[
...automationCmd.args,
"--host", "127.0.0.1",
"--port", config.autoBackendPort.toString(),
],
{
cwd: config.stateDir,
env: {
AUTOMATION_AGENT_SERVER_URL: `http://localhost:${config.agentServerPort}`,
AUTOMATION_DB_URL: `sqlite+aiosqlite:///${join(config.stateDir, "automations.db")}`,
AUTOMATION_BASE_URL: `http://localhost:${config.ingressPort}`,
AUTOMATION_WORKSPACE_BASE: join(config.stateDir, "workspaces"),
// Local API key for self-hosted auth (no cloud API needed)
AUTOMATION_LOCAL_API_KEY: config.localApiKey,
// CORS: allow localhost origins for dev
AUTOMATION_CORS_ORIGINS: `http://localhost:${config.ingressPort},http://127.0.0.1:${config.ingressPort},http://localhost:3001,http://127.0.0.1:3001`,
FILE_STORE: "local",
LOCAL_STORAGE_PATH: join(config.stateDir, "storage"),
OPENHANDS_SUPPRESS_BANNER: "1",
},
color: c.green,
}
);
}
// ═══════════════════════════════════════════════════════════════════════════
// Main
// ═══════════════════════════════════════════════════════════════════════════
let shuttingDown = false;
function shutdown() {
if (shuttingDown) return;
shuttingDown = true;
console.log("");
console.log(`${c.yellow}Shutting down...${c.reset}`);
for (const [name, proc] of processes) {
logService(name, "Stopping...", c.dim);
proc.kill("SIGTERM");
}
setTimeout(() => {
for (const [, proc] of processes) {
if (!proc.killed) {
proc.kill("SIGKILL");
}
}
process.exit(0);
}, 3000);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
function startIngress(config) {
logService("ingress", `Starting on port ${config.ingressPort}...`, c.yellow);
const ingressScript = join(projectRoot, "scripts", "ingress.mjs");
spawnService(
"ingress",
"node",
[
ingressScript,
"--port", config.ingressPort.toString(),
"--route", `/api/automation=http://localhost:${config.autoBackendPort}`,
"--route", `/api=http://localhost:${config.agentServerPort}`,
"--route", `/sockets=http://localhost:${config.agentServerPort}`,
"--route", `/server_info=http://localhost:${config.agentServerPort}`,
"--route", `/health=http://localhost:${config.agentServerPort}`,
"--route", `/ready=http://localhost:${config.agentServerPort}`,
"--route", `/alive=http://localhost:${config.agentServerPort}`,
"--default", `http://localhost:${config.vitePort}`,
],
{
cwd: projectRoot,
color: c.yellow,
}
);
}
function startVite(config) {
logService("vite", `Starting on port ${config.vitePort}...`, c.magenta);
const frontendCommand = buildNpmScriptCommand("dev:frontend");
spawnService("vite", frontendCommand.command, frontendCommand.args, {
cwd: config.canvasPath,
env: {
// Point Vite at the ingress (so client-side fetches work)
VITE_BACKEND_HOST: `127.0.0.1:${config.ingressPort}`,
VITE_BACKEND_BASE_URL: `http://127.0.0.1:${config.ingressPort}`,
VITE_WORKING_DIR: join(config.stateDir, "workspaces"),
VITE_FRONTEND_PORT: config.vitePort.toString(),
// Automation API key for frontend to authenticate with automation backend
VITE_AUTOMATION_API_KEY: config.localApiKey,
},
color: c.magenta,
});
}
/**
* Seed the automation API key into agent-server's secrets store.
* This makes the key available to agents during conversations.
*
* Includes retry logic to handle slow server startup or transient failures.
*
* @param {object} config - Configuration object with agentServerPort, localApiKey, sessionApiKey
* @param {object} options - Options for retry behavior
* @param {number} options.maxRetries - Maximum number of retry attempts (default: 5)
* @param {number} options.retryDelayMs - Delay between retries in ms (default: 2000)
* @param {number} options.timeoutMs - Request timeout in ms (default: 10000)
* @returns {Promise<boolean>} True if seeding succeeded, false otherwise
*/
async function seedAutomationSecret(config, options = {}) {
const {
maxRetries = 5,
retryDelayMs = 2000,
timeoutMs = 10000,
} = options;
const secretName = "OPENHANDS_AUTOMATION_API_KEY";
const secretDescription = "API key for authenticating with the automation backend";
logService("secrets", `Seeding ${secretName} into agent-server...`, c.dim);
const url = `http://localhost:${config.agentServerPort}/api/settings/secrets`;
const body = JSON.stringify({
name: secretName,
value: config.localApiKey,
description: secretDescription,
});
const headers = {
"Content-Type": "application/json",
// Include session API key if configured
...(config.sessionApiKey && { "X-Session-API-Key": config.sessionApiKey }),
};
let lastError = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url, {
method: "PUT",
headers,
body,
signal: AbortSignal.timeout(timeoutMs),
});
if (response.ok) {
logService("secrets", `${secretName} seeded successfully`, c.green);
return true;
}
const text = await response.text();
lastError = `HTTP ${response.status}: ${text}`;
// Don't retry on authentication errors - they won't resolve with retries
if (response.status === 401 || response.status === 403) {
logService("secrets", `Warning: Failed to seed secret (${response.status}): ${text}`, c.yellow);
return false;
}
// Retry on server errors or service unavailable
if (attempt < maxRetries) {
logService("secrets", `Retry ${attempt}/${maxRetries} after ${response.status}...`, c.dim);
await delay(retryDelayMs);
}
} catch (err) {
lastError = err.message;
// Connection errors likely mean server isn't ready - wait and retry
if (attempt < maxRetries) {
logService("secrets", `Retry ${attempt}/${maxRetries}: ${err.message}`, c.dim);
await delay(retryDelayMs);
}
}
}
logService("secrets", `Warning: Failed to seed secret after ${maxRetries} attempts: ${lastError}`, c.yellow);
return false;
}
function printBanner(config) {
console.log("");
console.log(
`${c.green}${c.bold}╔══════════════════════════════════════════════════════════════╗${c.reset}`
);
console.log(
`${c.green}${c.bold}║${c.reset} ${c.bold}Agent Canvas + Automation Stack${c.reset} ${c.green}${c.bold}║${c.reset}`
);
console.log(
`${c.green}${c.bold}╠══════════════════════════════════════════════════════════════╣${c.reset}`
);
console.log(
`${c.green}${c.bold}║${c.reset} ${c.green}${c.bold}║${c.reset}`
);
console.log(
`${c.green}${c.bold}║${c.reset} Main UI: ${c.cyan}http://localhost:${config.ingressPort}/${c.reset}`.padEnd(75) + `${c.green}${c.bold}║${c.reset}`
);
console.log(
`${c.green}${c.bold}║${c.reset} API Docs: ${c.cyan}http://localhost:${config.ingressPort}/api/automation/docs${c.reset}`.padEnd(75) + `${c.green}${c.bold}║${c.reset}`
);
console.log(
`${c.green}${c.bold}║${c.reset} ${c.green}${c.bold}║${c.reset}`
);
console.log(
`${c.green}${c.bold}╚══════════════════════════════════════════════════════════════╝${c.reset}`
);
console.log("");
console.log(`${c.dim}State directory: ${config.stateDir}${c.reset}`);
console.log(`${c.dim}Press Ctrl+C to stop${c.reset}`);
console.log("");
}
async function main() {
const args = parseArgs();
const config = buildConfig(args);
console.log("");
console.log(`${c.cyan}${c.bold}Agent Canvas + Automation Development Stack${c.reset}`);
console.log("");
// Setup phase
checkPrerequisites();
ensureDirectories(config);
// Start services phase
logStep("2/2", "Starting services...");
// 1. Start agent-server first (other services depend on it)
startAgentServer(config);
// Wait for agent-server to be ready (60s timeout for slow systems)
const agentServerReady = await waitForService(
"agent-server",
`http://localhost:${config.agentServerPort}/server_info`,
60000 // 60 second timeout for initial startup
);
// 2. Seed automation API key into agent-server secrets
// This makes the key available to agents during conversations
// Note: seedAutomationSecret has its own retry logic if server is still warming up
if (agentServerReady) {
await seedAutomationSecret(config);
} else {
logService("secrets", "Skipping secret seeding - agent-server not ready", c.yellow);
}
// 3. Start automation backend
startAutomationBackend(config);
// 4. Start Vite dev server (no proxy config needed - ingress handles routing)
startVite(config);
// 5. Wait for services to be ready
await delay(2000);
// 6. Start ingress proxy (routes traffic to all backends)
startIngress(config);
// Wait for ingress to start
await delay(1000);
printBanner(config);
}
// ═══════════════════════════════════════════════════════════════════════════
// Exports for testing
// ═══════════════════════════════════════════════════════════════════════════
export {
buildAutomationCommand,
buildConfig,
DEFAULT_AUTOMATION_REPO,
DEFAULT_AUTOMATION_PACKAGE,
DEFAULT_AUTOMATION_VERSION,
DEFAULT_BACKEND_PORT,
DEFAULT_AUTOMATION_PORT,
};
// ═══════════════════════════════════════════════════════════════════════════
// Main entry point (only when run directly, not when imported)
// ═══════════════════════════════════════════════════════════════════════════
// Check if this module is the main entry point
const isMainModule =
process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isMainModule) {
main().catch((err) => {
logError(`Fatal error: ${err.message}`);
if (err.stack) {
console.error(c.dim + err.stack + c.reset);
}
process.exit(1);
});
}