Files
next-tjwater-agent/scripts/start-isolated-integration.ts
T

151 lines
5.2 KiB
TypeScript

import { randomBytes } from "node:crypto";
import { mkdir } from "node:fs/promises";
import { createServer } from "node:net";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import dotenv from "dotenv";
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
dotenv.config({ path: join(projectRoot, ".local.env"), override: false, quiet: true });
const agentPort = readPort("INTEGRATION_AGENT_PORT", 18_787);
const opencodePort = readPort("INTEGRATION_OPENCODE_PORT", 14_096);
const dataRoot = resolve(
process.env.INTEGRATION_DATA_DIR ?? join(projectRoot, "data", `integration-${agentPort}`),
);
const bridgeToken = process.env.AGENT_INTERNAL_TOKEN?.trim() || randomBytes(32).toString("hex");
const agentBaseUrl = `http://127.0.0.1:${agentPort}`;
const opencodeBaseUrl = `http://127.0.0.1:${opencodePort}`;
await assertPortAvailable(agentPort);
await assertPortAvailable(opencodePort);
await mkdir(dataRoot, { recursive: true });
const sharedEnvironment = {
...process.env,
AGENT_INTERNAL_TOKEN: bridgeToken,
TJWATER_AGENT_INTERNAL_BASE_URL: agentBaseUrl,
// Retained for tools loaded by an older compatible OpenCode process.
TJWATER_AGENT_INTERNAL_TOKEN: bridgeToken,
};
const opencode = Bun.spawn([
"opencode",
"serve",
"--hostname=127.0.0.1",
`--port=${opencodePort}`,
], {
cwd: projectRoot,
env: {
...sharedEnvironment,
XDG_CACHE_HOME: join(dataRoot, "xdg-cache"),
XDG_DATA_HOME: join(dataRoot, "xdg-data"),
XDG_STATE_HOME: join(dataRoot, "xdg-state"),
},
stderr: "inherit",
stdout: "inherit",
});
let agent: ReturnType<typeof Bun.spawn> | undefined;
let shuttingDown = false;
const shutdown = async () => {
if (shuttingDown) return;
shuttingDown = true;
agent?.kill("SIGTERM");
opencode.kill("SIGTERM");
await Promise.allSettled([
...(agent ? [agent.exited] : []),
opencode.exited,
]);
};
process.once("SIGINT", () => void shutdown());
process.once("SIGTERM", () => void shutdown());
try {
await waitUntilHealthy(`${opencodeBaseUrl}/global/health`, "OpenCode", opencode);
agent = Bun.spawn([process.execPath, "src/server.ts"], {
cwd: projectRoot,
env: {
...sharedEnvironment,
HOST: "127.0.0.1",
PORT: String(agentPort),
OPENCODE_MODE: "client",
OPENCODE_CLIENT_BASE_URL: opencodeBaseUrl,
LLM_REQUEST_AUDIT_LOG_PATH: join(dataRoot, "logs", "llm-request-audit.log"),
LEARNING_AUDIT_LOG_PATH: join(dataRoot, "logs", "learning-audit.log"),
MEMORY_STORAGE_DIR: join(dataRoot, "memory"),
OPENCODE_SKILLS_ROOT_DIR: join(dataRoot, "skills"),
PERSISTENCE_BACKUP_DIR: join(dataRoot, "backup"),
RESULT_REF_STORAGE_DIR: join(dataRoot, "result-refs"),
SESSION_LEARNING_STATE_STORAGE_DIR: join(dataRoot, "session-learning-state"),
SESSION_METADATA_STORAGE_DIR: join(dataRoot, "session-metadata"),
SESSION_TRANSCRIPT_STORAGE_DIR: join(dataRoot, "session-transcripts"),
SESSION_UI_STATE_STORAGE_DIR: join(dataRoot, "session-ui-states"),
},
stderr: "inherit",
stdout: "inherit",
});
await waitUntilHealthy(`${agentBaseUrl}/health`, "Agent", agent);
console.log(`isolated integration pair ready: Agent ${agentBaseUrl}, OpenCode ${opencodeBaseUrl}`);
console.log(`isolated runtime data: ${dataRoot}`);
const result = await Promise.race([
agent.exited.then((exitCode) => ({ exitCode, process: "Agent" })),
opencode.exited.then((exitCode) => ({ exitCode, process: "OpenCode" })),
]);
if (!shuttingDown) {
console.error(`${result.process} exited with code ${result.exitCode}; stopping its paired process`);
process.exitCode = result.exitCode || 1;
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
} finally {
await shutdown();
}
function readPort(name: string, fallback: number) {
const raw = process.env[name]?.trim();
const value = raw ? Number(raw) : fallback;
if (!Number.isInteger(value) || value < 1 || value > 65_535) {
throw new Error(`${name} must be a valid TCP port`);
}
return value;
}
function assertPortAvailable(port: number) {
return new Promise<void>((resolvePromise, reject) => {
const server = createServer();
server.unref();
server.once("error", (error: NodeJS.ErrnoException) => {
reject(new Error(`port ${port} is unavailable: ${error.code ?? error.message}`));
});
server.listen({ host: "127.0.0.1", port, exclusive: true }, () => {
server.close((error) => (error ? reject(error) : resolvePromise()));
});
});
}
async function waitUntilHealthy(
url: string,
label: string,
child: ReturnType<typeof Bun.spawn>,
) {
const deadline = Date.now() + 30_000;
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, { signal: AbortSignal.timeout(1_000) });
if (response.ok) return;
} catch {
// The child is still starting; readiness is decided only by its health endpoint.
}
await new Promise((resolvePromise) => setTimeout(resolvePromise, 100));
}
throw new Error(`${label} did not become healthy within 30 seconds`);
}