diff --git a/src/runtime.ts b/src/runtime.ts new file mode 100644 index 0000000..e203fc1 --- /dev/null +++ b/src/runtime.ts @@ -0,0 +1,150 @@ +/** + * Runtime auto-detection and lifecycle management for the `aasm` sidecar + * (F115 / AAASM-1205). + * + * The `initAssembly()` exported here is intentionally NOT re-exported from + * `@agent-assembly/sdk` at the top level: the existing gateway-based + * `initAssembly(config)` keeps its meaning. Opt in to the runtime-managed + * flow with `import { initAssembly } from "@agent-assembly/sdk/runtime"`. + */ + +import { type ChildProcess, spawn } from "node:child_process"; +import { existsSync, openSync } from "node:fs"; +import { createRequire } from "node:module"; +import { createConnection } from "node:net"; +import { arch, homedir, platform } from "node:os"; +import { delimiter as PATH_DELIM, dirname, join } from "node:path"; +import { cwd, env } from "node:process"; + +export const BINARY_NAME = "aasm"; +export const DEFAULT_PORT = 7878; +export const DEFAULT_RUNTIME_HOST = "127.0.0.1"; + +export const USER_LOCAL_BIN: string = join(homedir(), ".local", "bin"); +export const DOCKER_BASE_BIN = "/usr/local/bin"; +export const RUNTIME_LOG_FILENAME = ".aasm-runtime.log"; + +/** npm sub-package name for the bundled platform binary (esbuild pattern). */ +export const RUNTIME_SUBPACKAGE: string = `runtime-${platform()}-${arch()}`; + +export const INSTALL_HINT: string = [ + "agent-assembly runtime not found.", + " Install with: pnpm add agent-assembly", + " Or manually: brew install agent-assembly/tap/aasm", + " curl -fsSL https://get.agent-assembly.io | sh", +].join("\n"); + +/** + * Path to the platform-specific `aasm` binary bundled as an optional npm + * dependency, or `null` when the consumer hasn't installed that platform's + * `@agent-assembly/runtime-{platform}-{arch}` sub-package. + * + * Uses `createRequire(/package.json)` — the same pattern used by + * `src/native/client.ts` and the framework-detection modules — so this + * module compiles cleanly under both the ESM and CJS build targets + * (`import.meta.url` is ESM-only and breaks `tsconfig.cjs.json`). + */ +function bundledRuntimeBinaryPath(): string | null { + try { + const requireFromCwd = createRequire(`${cwd()}/package.json`); + const pkgJson = requireFromCwd.resolve(`@agent-assembly/${RUNTIME_SUBPACKAGE}/package.json`); + return join(dirname(pkgJson), "bin", BINARY_NAME); + } catch { + return null; + } +} + +/** + * Locate the `aasm` binary across the 4 supported install paths. + * + * Search order: `$PATH` (Homebrew, cargo install) → `~/.local/bin/aasm` + * (curl installer) → `node_modules/@agent-assembly/runtime-{platform}-{arch}/bin/aasm` + * (npm optionalDependency) → `/usr/local/bin/aasm` (Docker base image). + * Returns the first existing match, or `null` when none exist. + */ +export function findAasmBinary(): string | null { + for (const dir of (env.PATH ?? "").split(PATH_DELIM)) { + if (!dir) continue; + const candidate = join(dir, BINARY_NAME); + if (existsSync(candidate)) return candidate; + } + const userLocal = join(USER_LOCAL_BIN, BINARY_NAME); + if (existsSync(userLocal)) return userLocal; + const bundled = bundledRuntimeBinaryPath(); + if (bundled !== null && existsSync(bundled)) return bundled; + const docker = join(DOCKER_BASE_BIN, BINARY_NAME); + if (existsSync(docker)) return docker; + return null; +} + +/** + * Resolve to `true` iff a local TCP listener accepts a connect on + * `host:port` within 100 ms. Any socket error (refused, timeout, + * unreachable) resolves to `false` and is treated as no sidecar. + */ +export function isRunning( + port: number = DEFAULT_PORT, + host: string = DEFAULT_RUNTIME_HOST, +): Promise { + return new Promise((resolveResult) => { + const socket = createConnection({ port, host, timeout: 100 }); + const settle = (value: boolean): void => { + socket.removeAllListeners(); + socket.destroy(); + resolveResult(value); + }; + socket.once("connect", () => settle(true)); + socket.once("timeout", () => settle(false)); + socket.once("error", () => settle(false)); + }); +} + +/** + * Spawn `aasm serve --port ` as a detached background subprocess. + * + * Stdout/stderr are appended to `/.aasm-runtime.log` (default + * `process.cwd()`) so the sidecar outlives the parent. `detached: true` + * + `child.unref()` releases the event loop so the Node process can + * exit independently of the sidecar. + */ +export function startRuntime( + binaryPath: string, + port: number = DEFAULT_PORT, + logDir: string = cwd(), +): ChildProcess { + const logPath = join(logDir, RUNTIME_LOG_FILENAME); + const fd = openSync(logPath, "a"); + const child = spawn(binaryPath, ["serve", "--port", String(port)], { + detached: true, + stdio: ["ignore", fd, fd], + }); + child.unref(); + return child; +} + +/** + * Ensure the local `aasm` sidecar is running, starting it if necessary. + * + * Lifecycle per F115 / AAASM-1205: + * 1. Probe `host:port` via {@link isRunning}; return early if already up. + * 2. Resolve the binary via {@link findAasmBinary}. + * 3. Spawn the sidecar via {@link startRuntime}. + * + * `agentId` is accepted to keep the ticket-specified signature stable; + * actual register-and-connect is performed by the existing gateway-aware + * `@agent-assembly/sdk` `initAssembly` once the sidecar is reachable. + * + * Throws `Error` with {@link INSTALL_HINT} when no binary is found. + */ +export async function initAssembly( + agentId?: string, + port: number = DEFAULT_PORT, +): Promise { + void agentId; // not consumed at the lifecycle layer; see jsdoc + if (await isRunning(port)) return; + const binary = findAasmBinary(); + if (binary === null) { + throw new Error(INSTALL_HINT); + } + startRuntime(binary, port); +} diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts new file mode 100644 index 0000000..31bc53e --- /dev/null +++ b/tests/runtime.test.ts @@ -0,0 +1,123 @@ +// Unit tests for src/runtime.ts (AAASM-1228 / F115). +// +// Covers the four scenarios from the AAASM-1230 AC checklist: +// - binary-in-PATH +// - binary-bundled (under node_modules/@agent-assembly/runtime-{platform}-{arch}) +// - binary-not-found +// - already-running (initAssembly skips spawn when sidecar reachable) + +import { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:net"; +import { arch, platform, tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { BINARY_NAME, INSTALL_HINT, findAasmBinary, initAssembly } from "../src/runtime.js"; + +function makeFakeAasm(dir: string): string { + const path = join(dir, BINARY_NAME); + writeFileSync(path, "#!/bin/sh\nexit 0\n"); + chmodSync(path, 0o755); + return path; +} + +function makeBundledRuntimePackage(root: string): string { + const pkgDir = join(root, "node_modules", "@agent-assembly", `runtime-${platform()}-${arch()}`); + mkdirSync(join(pkgDir, "bin"), { recursive: true }); + writeFileSync(join(pkgDir, "package.json"), JSON.stringify({ name: `@agent-assembly/runtime-${platform()}-${arch()}` })); + writeFileSync(join(root, "package.json"), JSON.stringify({ name: "test-bundled-runtime" })); + return makeFakeAasm(join(pkgDir, "bin")); +} + +describe("runtime — F115 lifecycle", () => { + it("findAasmBinary returns the resolved path when binary is on $PATH", () => { + const tmp = mkdtempSync(join(tmpdir(), "aasm-path-")); + const originalPath = process.env.PATH; + const originalHome = process.env.HOME; + try { + const fake = makeFakeAasm(tmp); + process.env.PATH = tmp; + process.env.HOME = "/var/empty-aasm-no-home"; + + expect(findAasmBinary()).toBe(fake); + } finally { + process.env.PATH = originalPath; + process.env.HOME = originalHome; + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("findAasmBinary returns the bundled-runtime path when the npm optional sub-package is installed", () => { + const tmp = mkdtempSync(join(tmpdir(), "aasm-bundled-")); + const originalCwd = process.cwd(); + const originalPath = process.env.PATH; + const originalHome = process.env.HOME; + try { + const fake = makeBundledRuntimePackage(tmp); + process.chdir(tmp); + process.env.PATH = join(tmp, "no-such-path"); + process.env.HOME = join(tmp, "no-such-home"); + + // createRequire.resolve canonicalises symlinks (on macOS, /var/folders + // → /private/var/folders); compare against the realpath form. + expect(findAasmBinary()).toBe(realpathSync(fake)); + } finally { + process.chdir(originalCwd); + process.env.PATH = originalPath; + process.env.HOME = originalHome; + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("initAssembly throws Error with INSTALL_HINT when binary not found", async () => { + const tmp = mkdtempSync(join(tmpdir(), "aasm-missing-")); + const originalCwd = process.cwd(); + const originalPath = process.env.PATH; + const originalHome = process.env.HOME; + try { + // Empty cwd (no node_modules), empty PATH/HOME → every search path misses. + writeFileSync(join(tmp, "package.json"), JSON.stringify({ name: "test-missing" })); + process.chdir(tmp); + process.env.PATH = join(tmp, "no-such-path"); + process.env.HOME = join(tmp, "no-such-home"); + + await expect(initAssembly()).rejects.toThrowError(INSTALL_HINT); + await expect(initAssembly()).rejects.toThrowError(/agent-assembly runtime not found/); + } finally { + process.chdir(originalCwd); + process.env.PATH = originalPath; + process.env.HOME = originalHome; + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("initAssembly is idempotent when a sidecar is already running on the target port", async () => { + // Bind an ephemeral port and pass it explicitly; this avoids a fixed-port + // collision with whatever might be on 7878 on the host machine. + const server = createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (typeof address === "string" || address === null) { + throw new Error("expected AddressInfo from createServer().address()"); + } + const port = address.port; + + const originalPath = process.env.PATH; + const originalHome = process.env.HOME; + try { + // Pre-load every search path with a guaranteed miss; the orchestrator + // should still succeed because is_running short-circuits ahead of + // findAasmBinary. + process.env.PATH = "/var/empty-aasm-no-path"; + process.env.HOME = "/var/empty-aasm-no-home"; + + await expect(initAssembly(undefined, port)).resolves.toBeUndefined(); + } finally { + process.env.PATH = originalPath; + process.env.HOME = originalHome; + await new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())) + ); + } + }); +});