Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions src/runtime.ts
Original file line number Diff line number Diff line change
@@ -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(<cwd>/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<boolean> {
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 <port>` as a detached background subprocess.
*
* Stdout/stderr are appended to `<logDir>/.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> {
void agentId; // not consumed at the lifecycle layer; see jsdoc

Check failure on line 143 in src/runtime.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this use of the "void" operator.

See more on https://sonarcloud.io/project/issues?id=AI-agent-assembly_node-sdk&issues=AZ5IN_SIED7WQmOOpO1e&open=AZ5IN_SIED7WQmOOpO1e&pullRequest=41
if (await isRunning(port)) return;
const binary = findAasmBinary();
if (binary === null) {
throw new Error(INSTALL_HINT);
}
startRuntime(binary, port);
}
123 changes: 123 additions & 0 deletions tests/runtime.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve()))
);
}
});
});
Loading