aps-agent/scripts/build-sidecar.mjs

168 lines
5.8 KiB
JavaScript

import { spawn, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import http from 'node:http';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
const { buildChildEnv } = require('../apps/desktop/sidecar.cjs');
const PYTHON_VERSION = '3.13.12';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const venvDir = path.join(repoRoot, '.sidecar-venv');
const python = path.join(venvDir, 'Scripts', 'python.exe');
const lockFile = path.join(repoRoot, 'packaging', 'requirements-sidecar.lock');
const specFile = path.join(repoRoot, 'packaging', 'aps-sidecar.spec');
const executable = path.join(repoRoot, 'dist', 'sidecar', 'aps-sidecar', 'aps-sidecar.exe');
function run(command, args, options = {}) {
const completed = spawnSync(command, args, {
cwd: repoRoot,
env: buildChildEnv(process.env, { UV_NO_CONFIG: '1' }),
stdio: 'inherit',
windowsHide: true,
...options,
});
if (completed.error) throw completed.error;
if (completed.status !== 0) {
throw new Error(`${command} ${args.join(' ')} exited with code ${completed.status}`);
}
}
function readRuntimeIdentity() {
const script = [
'import json, platform, sys',
'print(json.dumps({',
' "implementation": platform.python_implementation(),',
' "version": platform.python_version(),',
' "machine": platform.machine(),',
' "base_executable": getattr(sys, "_base_executable", sys.executable),',
'}))',
].join('\n');
const completed = spawnSync(python, ['-c', script], {
cwd: repoRoot,
env: buildChildEnv(process.env, {}),
encoding: 'utf8',
windowsHide: true,
});
if (completed.status !== 0) throw new Error(completed.stderr || 'cannot inspect sidecar Python');
return JSON.parse(completed.stdout.trim());
}
function validateRuntime() {
const identity = readRuntimeIdentity();
if (identity.implementation !== 'CPython' || identity.version !== PYTHON_VERSION) {
throw new Error(`Sidecar runtime must be CPython ${PYTHON_VERSION}; got ${JSON.stringify(identity)}`);
}
if (!/amd64|x86_64/i.test(identity.machine)) {
throw new Error(`Sidecar runtime must be x86-64; got ${identity.machine}`);
}
if (/anaconda|conda/i.test(identity.base_executable)) {
throw new Error(`Conda runtime is forbidden for release sidecars: ${identity.base_executable}`);
}
console.log(`sidecar runtime: ${identity.implementation} ${identity.version} ${identity.machine}`);
}
function reservePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen({ host: '127.0.0.1', port: 0, exclusive: true }, () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close((error) => error ? reject(error) : resolve(port));
});
});
}
function healthProbe(url, expectedNonce) {
return new Promise((resolve, reject) => {
const request = http.get(url, {
timeout: 1_500,
headers: { 'x-aps-sidecar-nonce': expectedNonce },
}, (response) => {
response.resume();
if (response.statusCode !== 200) {
reject(new Error(`health returned HTTP ${response.statusCode}`));
} else if (response.headers['x-aps-sidecar-nonce'] !== expectedNonce) {
reject(new Error('health returned an unexpected sidecar identity'));
} else {
resolve();
}
});
request.once('timeout', () => request.destroy(new Error('health probe timed out')));
request.once('error', reject);
});
}
function sidecarEnv(overrides) {
return buildChildEnv(process.env, overrides);
}
async function smokeFrozenSidecar() {
const port = await reservePort();
const smokeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'aps-frozen-smoke-'));
const uiDir = path.join(repoRoot, 'apps', 'web', 'dist');
const nonce = 'f'.repeat(64);
const child = spawn(executable, [], {
cwd: path.dirname(executable),
env: sidecarEnv({
APS_MODE: 'desktop',
APS_HOME: smokeHome,
APS_API_HOST: '127.0.0.1',
APS_API_PORT: String(port),
APS_UI_DIR: uiDir,
APS_SIDECAR_NONCE: nonce,
}),
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let output = '';
child.stdout.on('data', (chunk) => { output += String(chunk); });
child.stderr.on('data', (chunk) => { output += String(chunk); });
try {
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
if (child.exitCode !== null) {
throw new Error(`frozen sidecar exited with ${child.exitCode}:\n${output}`);
}
try {
await healthProbe(`http://127.0.0.1:${port}/api/health`, nonce);
console.log(`frozen sidecar smoke passed on port ${port}`);
return;
} catch {
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
throw new Error(`frozen sidecar health timeout:\n${output}`);
} finally {
child.kill();
fs.rmSync(smokeHome, { recursive: true, force: true });
}
}
if (process.platform !== 'win32') {
throw new Error('The current Sidecar release target is Windows x86-64.');
}
run('uv', ['venv', '--managed-python', '--python', PYTHON_VERSION, '--clear', venvDir]);
validateRuntime();
run('uv', [
'pip', 'install', '--python', python, '--require-hashes', '--only-binary', ':all:',
'--requirements', lockFile,
]);
run(python, ['scripts/check_solver_runtime.py', '--iterations', '5']);
run(python, [
'-m', 'PyInstaller', '--clean', '--noconfirm',
'--distpath', path.join(repoRoot, 'dist', 'sidecar'),
'--workpath', path.join(repoRoot, 'build', 'sidecar'),
specFile,
]);
run(executable, ['--probe-child']);
await smokeFrozenSidecar();