mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-16 17:36:07 +00:00
201 lines
6.3 KiB
JavaScript
201 lines
6.3 KiB
JavaScript
import { execFile } from "node:child_process";
|
|
import { closeSync, openSync } from "node:fs";
|
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
import { createServer } from "node:net";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { promisify } from "node:util";
|
|
import { spawn } from "node:child_process";
|
|
import { pathExists, resolveLangBotRepo } from "./langbot-e2e.mjs";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const proxyKeys = [
|
|
"ALL_PROXY",
|
|
"all_proxy",
|
|
"HTTP_PROXY",
|
|
"http_proxy",
|
|
"HTTPS_PROXY",
|
|
"https_proxy",
|
|
];
|
|
|
|
function withoutProxy(source = process.env) {
|
|
const next = { ...source };
|
|
for (const key of proxyKeys) delete next[key];
|
|
next.NO_PROXY = "127.0.0.1,localhost";
|
|
next.no_proxy = "127.0.0.1,localhost";
|
|
return next;
|
|
}
|
|
|
|
async function getFreePort() {
|
|
const server = createServer();
|
|
await new Promise((resolvePromise, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", resolvePromise);
|
|
});
|
|
const address = server.address();
|
|
const port = typeof address === "object" && address ? address.port : 0;
|
|
await new Promise((resolvePromise) => server.close(resolvePromise));
|
|
if (!port) throw new Error("Could not allocate an isolated LangBot port.");
|
|
return port;
|
|
}
|
|
|
|
async function waitForHttp(url, child, timeoutMs, label) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (child.exitCode !== null) {
|
|
throw new Error(`${label} exited before becoming ready (code ${child.exitCode}).`);
|
|
}
|
|
try {
|
|
const response = await fetch(url, { redirect: "manual" });
|
|
if (response.status < 500) return response;
|
|
} catch {
|
|
// The service is still starting.
|
|
}
|
|
await new Promise((resolvePromise) => setTimeout(resolvePromise, 500));
|
|
}
|
|
throw new Error(`${label} did not become ready within ${timeoutMs}ms.`);
|
|
}
|
|
|
|
function spawnLogged(command, args, { cwd, env, logPath }) {
|
|
const logFd = openSync(logPath, "a");
|
|
try {
|
|
return spawn(command, args, {
|
|
cwd,
|
|
env,
|
|
detached: true,
|
|
stdio: ["ignore", logFd, logFd],
|
|
});
|
|
} finally {
|
|
closeSync(logFd);
|
|
}
|
|
}
|
|
|
|
async function stopProcess(child) {
|
|
if (!child || child.exitCode !== null) return;
|
|
const closed = new Promise((resolvePromise) => child.once("close", resolvePromise));
|
|
try {
|
|
process.kill(-child.pid, "SIGTERM");
|
|
} catch {
|
|
child.kill("SIGTERM");
|
|
}
|
|
const graceful = await Promise.race([
|
|
closed.then(() => true),
|
|
new Promise((resolvePromise) => setTimeout(() => resolvePromise(false), 5_000)),
|
|
]);
|
|
if (graceful) return;
|
|
try {
|
|
process.kill(-child.pid, "SIGKILL");
|
|
} catch {
|
|
child.kill("SIGKILL");
|
|
}
|
|
await closed.catch(() => {});
|
|
}
|
|
|
|
async function initializeUser(backendUrl, user, password) {
|
|
const response = await fetch(`${backendUrl}/api/v1/user/init`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ user, password }),
|
|
});
|
|
const payload = await response.json().catch(() => ({}));
|
|
if (response.status >= 400 || ![0, 1].includes(payload.code)) {
|
|
throw new Error(payload.msg || `Could not initialize isolated user (HTTP ${response.status}).`);
|
|
}
|
|
}
|
|
|
|
export async function startIsolatedLangBotInstance({ evidenceDir }) {
|
|
const repo = await resolveLangBotRepo();
|
|
const webRepo = process.env.LANGBOT_WEB_REPO || join(repo, "web");
|
|
const python = join(repo, ".venv", "bin", "python");
|
|
const configScript = resolve(
|
|
dirname(fileURLToPath(import.meta.url)),
|
|
"../prepare-isolated-langbot-config.py",
|
|
);
|
|
if (!(await pathExists(python))) throw new Error(`LangBot virtualenv Python is missing: ${python}`);
|
|
if (!(await pathExists(join(webRepo, "node_modules")))) {
|
|
throw new Error(`LangBot frontend dependencies are missing: ${join(webRepo, "node_modules")}`);
|
|
}
|
|
|
|
const [backendPort, frontendPort, pluginDebugPort] = await Promise.all([
|
|
getFreePort(),
|
|
getFreePort(),
|
|
getFreePort(),
|
|
]);
|
|
const instanceRoot = await mkdtemp(join(tmpdir(), "langbot-clean-catalog-"));
|
|
const backendUrl = `http://127.0.0.1:${backendPort}`;
|
|
const frontendUrl = `http://127.0.0.1:${frontendPort}`;
|
|
const backendLog = join(evidenceDir, "isolated-backend.log");
|
|
const frontendLog = join(evidenceDir, "isolated-frontend.log");
|
|
const user = "langbot-e2e@example.invalid";
|
|
const password = "LangBotIsolatedE2E!2026";
|
|
let backend;
|
|
let frontend;
|
|
|
|
const stop = async () => {
|
|
await stopProcess(frontend);
|
|
await stopProcess(backend);
|
|
await rm(instanceRoot, { recursive: true, force: true });
|
|
};
|
|
|
|
try {
|
|
await execFileAsync(
|
|
python,
|
|
[
|
|
configScript,
|
|
"--instance-root",
|
|
instanceRoot,
|
|
"--port",
|
|
String(backendPort),
|
|
"--plugin-debug-port",
|
|
String(pluginDebugPort),
|
|
],
|
|
{ cwd: repo, env: withoutProxy(), timeout: 30_000 },
|
|
);
|
|
|
|
backend = spawnLogged(python, ["-m", "langbot"], {
|
|
cwd: instanceRoot,
|
|
env: {
|
|
...withoutProxy(),
|
|
PYTHONPATH: join(repo, "src"),
|
|
LANGBOT_DATA_ROOT: join(instanceRoot, "data"),
|
|
API__PORT: String(backendPort),
|
|
API__WEBHOOK_PREFIX: backendUrl,
|
|
SPACE__DISABLE_TELEMETRY: "true",
|
|
SPACE__DISABLE_MODELS_SERVICE: "true",
|
|
},
|
|
logPath: backendLog,
|
|
});
|
|
await waitForHttp(`${backendUrl}/api/v1/system/info`, backend, 180_000, "Isolated LangBot backend");
|
|
|
|
const configText = await readFile(join(instanceRoot, "data", "config.yaml"), "utf8");
|
|
const recoveryKey = configText.match(/^\s*recovery_key:\s*['"]?([^'"\s#]+)['"]?\s*$/m)?.[1] || "";
|
|
if (!recoveryKey) throw new Error("Isolated LangBot did not generate a recovery key.");
|
|
await initializeUser(backendUrl, user, password);
|
|
|
|
frontend = spawnLogged(
|
|
"pnpm",
|
|
["exec", "vite", "--host", "127.0.0.1", "--port", String(frontendPort), "--strictPort"],
|
|
{
|
|
cwd: webRepo,
|
|
env: { ...withoutProxy(), VITE_API_BASE_URL: backendUrl },
|
|
logPath: frontendLog,
|
|
},
|
|
);
|
|
await waitForHttp(frontendUrl, frontend, 60_000, "Isolated LangBot frontend");
|
|
|
|
return {
|
|
backendUrl,
|
|
frontendUrl,
|
|
recoveryKey,
|
|
user,
|
|
password,
|
|
logs: { backend: backendLog, frontend: frontendLog },
|
|
stop,
|
|
};
|
|
} catch (error) {
|
|
await stop();
|
|
throw error;
|
|
}
|
|
}
|