agent-ecosystem/scripts/lib/opencode-live-preflight.mjs
2026-04-23 18:27:03 +03:00

192 lines
5.2 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';
export async function preflightOpenCodeLiveEnvironment(input) {
const repoRoot = input.repoRoot;
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 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: '' };
}
return {
ok: false,
output: compactOutput(result.stderr || result.stdout || result.error?.message || 'unknown'),
};
}
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'],
});
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 fetch(`http://127.0.0.1:${port}/global/health`);
if (response.ok) {
const data = await response.json().catch(() => ({}));
if (data?.healthy === true) {
return { ok: true };
}
}
} catch {
// Host is still starting.
}
await sleep(250);
}
return { ok: false, reason: output || 'timed out waiting for /global/health' };
} finally {
await stopChild(child);
}
}
function stopChild(child) {
return new Promise((resolve) => {
if (child.exitCode != null || child.killed) {
resolve();
return;
}
const timeout = setTimeout(() => {
if (child.exitCode == null) {
child.kill('SIGKILL');
}
resolve();
}, 3_000);
child.once('close', () => {
clearTimeout(timeout);
resolve();
});
child.kill('SIGTERM');
});
}
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);
}