agent-ecosystem/scripts/lib/opencode-live-preflight.mjs
2026-05-20 01:32:39 +03:00

319 lines
8.7 KiB
JavaScript

import { spawn, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
const CHILD_CLOSE_GRACE_MS = 3_000;
const CHILD_FORCE_CLOSE_GRACE_MS = 1_000;
const TASKKILL_TIMEOUT_MS = 5_000;
const OPENCODE_HEALTH_FETCH_TIMEOUT_MS = 1_000;
export async function preflightOpenCodeLiveEnvironment(input) {
const repoRoot = input.repoRoot;
const requiredModels = Array.isArray(input.requiredModels)
? input.requiredModels.map((model) => String(model).trim()).filter(Boolean)
: [];
const opencodeBin = process.env.OPENCODE_BIN?.trim() || '/opt/homebrew/bin/opencode';
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-live-preflight-'));
const xdgDataHome = path.join(tempRoot, 'xdg-data');
const env = {
...process.env,
XDG_DATA_HOME: xdgDataHome,
OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1',
};
try {
if (!fs.existsSync(opencodeBin)) {
return skip(`OpenCode binary not found at ${opencodeBin}`);
}
const models = runOpenCodeCommand(opencodeBin, ['models'], repoRoot, env);
if (!models.ok) {
return skip(`opencode models failed: ${models.output}`);
}
const missingModels = findMissingOpenCodeModels(models.output, requiredModels);
if (missingModels.length > 0) {
return skip(
`opencode models missing selected model(s): ${missingModels.join(', ')}. Available: ${compactOutput(
parseOpenCodeModels(models.output).join(', ') || 'none'
)}`
);
}
const agents = runOpenCodeCommand(opencodeBin, ['agent', 'list'], repoRoot, env);
if (!agents.ok) {
return skip(`opencode agent list failed: ${agents.output}`);
}
const loopback = await canBindLoopback();
if (!loopback.ok) {
return skip(`127.0.0.1 loopback bind failed: ${loopback.reason}`);
}
const host = await canStartOpenCodeHost(opencodeBin, repoRoot, env);
if (!host.ok) {
return skip(`opencode serve health check failed: ${host.reason}`);
}
return { ok: true };
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}
export function exitForSkippedPreflight(result) {
if (result.ok) {
return false;
}
console.warn(`SKIPPED: ${result.reason}`);
process.exit(process.env.OPENCODE_E2E_STRICT === '1' ? 1 : 0);
}
function runOpenCodeCommand(opencodeBin, args, cwd, env) {
const result = spawnSync(opencodeBin, args, {
cwd,
env,
encoding: 'utf8',
timeout: 20_000,
maxBuffer: 256_000,
});
if (result.status === 0) {
return { ok: true, output: result.stdout || '' };
}
return {
ok: false,
output: compactOutput(result.stderr || result.stdout || result.error?.message || 'unknown'),
};
}
function parseOpenCodeModels(output) {
return output
.split(/\s+/)
.map((model) => model.trim())
.filter(Boolean);
}
function findMissingOpenCodeModels(output, requiredModels) {
if (requiredModels.length === 0) return [];
const available = new Set(parseOpenCodeModels(output));
return requiredModels.filter((model) => !available.has(model));
}
function canBindLoopback() {
return new Promise((resolve) => {
const server = net.createServer();
const timeout = setTimeout(() => {
server.close(() => undefined);
resolve({ ok: false, reason: 'timed out allocating loopback port' });
}, 5_000);
server.once('error', (error) => {
clearTimeout(timeout);
resolve({ ok: false, reason: error.message });
});
server.listen(0, '127.0.0.1', () => {
clearTimeout(timeout);
server.close((error) => {
resolve(error ? { ok: false, reason: error.message } : { ok: true });
});
});
});
}
async function canStartOpenCodeHost(opencodeBin, cwd, env) {
const port = await allocateLoopbackPort();
const child = spawn(opencodeBin, ['serve', '--hostname', '127.0.0.1', '--port', String(port)], {
cwd,
env,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let output = '';
let spawnError = '';
const append = (chunk) => {
output = compactOutput(`${output}\n${chunk.toString('utf8')}`);
};
child.stdout?.on('data', append);
child.stderr?.on('data', append);
child.once('error', (error) => {
spawnError = error.message;
append(error.message);
});
try {
const deadline = Date.now() + 15_000;
while (Date.now() < deadline) {
if (spawnError) {
return { ok: false, reason: spawnError };
}
if (child.exitCode != null) {
return { ok: false, reason: output || `process exited with code ${child.exitCode}` };
}
try {
const response = await fetchOpenCodeHealth(port);
if (isHealthyOpenCodeHostResponse(response)) {
response.body?.cancel().catch(() => undefined);
return { ok: true };
}
response.body?.cancel().catch(() => undefined);
} catch {
// Host is still starting.
}
await sleep(250);
}
return { ok: false, reason: output || 'timed out waiting for /global/health' };
} finally {
await stopChild(child);
}
}
async function fetchOpenCodeHealth(port) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), OPENCODE_HEALTH_FETCH_TIMEOUT_MS);
try {
return await fetch(`http://127.0.0.1:${port}/global/health`, {
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
}
function isHealthyOpenCodeHostResponse(response) {
return response.ok;
}
async function stopChild(child, options = {}) {
const platform = options.platform ?? process.platform;
const killProcessTree = options.killProcessTree ?? taskkillProcessTree;
const closeGraceMs = options.closeGraceMs ?? CHILD_CLOSE_GRACE_MS;
const forceCloseGraceMs = options.forceCloseGraceMs ?? CHILD_FORCE_CLOSE_GRACE_MS;
if (hasChildExited(child)) {
return;
}
if (platform === 'win32' && child.pid) {
await killProcessTree(child.pid);
} else if (!child.killed) {
sendChildSignal(child, 'SIGTERM');
}
if (await waitForChildClose(child, closeGraceMs)) {
return;
}
if (!hasChildExited(child)) {
sendChildSignal(child, 'SIGKILL');
if (!(await waitForChildClose(child, forceCloseGraceMs))) {
child.stdout?.destroy();
child.stderr?.destroy();
child.unref?.();
}
}
}
function taskkillProcessTree(pid) {
return new Promise((resolve) => {
let done = false;
let taskkill = null;
const finish = () => {
if (done) return;
done = true;
clearTimeout(timeout);
resolve();
};
const timeout = setTimeout(() => {
if (taskkill) {
sendChildSignal(taskkill, 'SIGTERM');
}
finish();
}, TASKKILL_TIMEOUT_MS);
try {
taskkill = spawn(
path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'taskkill.exe'),
['/T', '/F', '/PID', String(pid)],
{
stdio: 'ignore',
windowsHide: true,
}
);
taskkill.unref?.();
taskkill.once('error', finish);
taskkill.once('close', finish);
} catch {
finish();
}
});
}
function waitForChildClose(child, timeoutMs) {
if (hasChildExited(child)) {
return Promise.resolve(true);
}
return new Promise((resolve) => {
let done = false;
const finish = (closed) => {
if (done) return;
done = true;
clearTimeout(timeout);
resolve(closed);
};
const timeout = setTimeout(() => finish(false), timeoutMs);
child.once('close', () => finish(true));
});
}
function hasChildExited(child) {
return child.exitCode != null || child.signalCode != null;
}
function sendChildSignal(child, signal) {
try {
child.kill(signal);
} catch {
// Process may already be gone between liveness checks and the kill call.
}
}
function allocateLoopbackPort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (!address || typeof address === 'string') {
server.close(() => reject(new Error('failed to allocate loopback port')));
return;
}
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve(address.port);
});
});
});
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function skip(reason) {
return { ok: false, reason };
}
function compactOutput(value) {
return value.replace(/\s+/g, ' ').trim().slice(0, 1_200);
}
export const __opencodeLivePreflightTestHooks = {
findMissingOpenCodeModels,
isHealthyOpenCodeHostResponse,
parseOpenCodeModels,
stopChild,
taskkillProcessTree,
};