diff --git a/agent_assembly/runtime.py b/agent_assembly/runtime.py new file mode 100644 index 0000000..022e94b --- /dev/null +++ b/agent_assembly/runtime.py @@ -0,0 +1,134 @@ +"""Runtime auto-detection and lifecycle management for the `aasm` sidecar (F115 / AAASM-1205). + +The `init_assembly()` exported here is intentionally NOT re-exported from +`agent_assembly` at the top level: the existing gateway-based +`agent_assembly.init_assembly` keeps its meaning. Opt in to the runtime-managed +flow with ``from agent_assembly.runtime import init_assembly``. +""" + +from __future__ import annotations + +import os +import shutil +import socket +import subprocess +from pathlib import Path + +__all__ = [ + "BINARY_NAME", + "DEFAULT_PORT", + "DEFAULT_RUNTIME_HOST", + "INSTALL_HINT", + "find_aasm_binary", + "init_assembly", + "is_running", + "start_runtime", +] + +BINARY_NAME = "aasm" +DEFAULT_PORT = 7878 +DEFAULT_RUNTIME_HOST = "127.0.0.1" + +USER_LOCAL_BIN = Path.home() / ".local" / "bin" +WHEEL_BUNDLED_BIN = Path(__file__).resolve().parent / "bin" +DOCKER_BASE_BIN = Path("/usr/local/bin") + +RUNTIME_LOG_FILENAME = ".aasm-runtime.log" + +INSTALL_HINT = ( + "agent-assembly runtime not found.\n" + " Install with: pip install agent-assembly-python[runtime]\n" + " Or manually: brew install agent-assembly/tap/aasm\n" + " curl -fsSL https://get.agent-assembly.io | sh" +) + + +def find_aasm_binary() -> Path | None: + """Locate the `aasm` binary across the 5 supported install paths. + + Search order: ``$PATH`` (covers Homebrew and ``cargo install``) → + ``~/.local/bin/aasm`` (curl installer default) → + ``agent_assembly/bin/aasm`` (wheel-bundled with the ``[runtime]`` extra) → + ``/usr/local/bin/aasm`` (Docker base image). Returns the first executable + match, or ``None`` when no candidate exists. + """ + path_hit = shutil.which(BINARY_NAME) + if path_hit: + return Path(path_hit) + for candidate in ( + USER_LOCAL_BIN / BINARY_NAME, + WHEEL_BUNDLED_BIN / BINARY_NAME, + DOCKER_BASE_BIN / BINARY_NAME, + ): + if candidate.is_file() and os.access(candidate, os.X_OK): + return candidate + return None + + +def is_running(port: int = DEFAULT_PORT, *, host: str = DEFAULT_RUNTIME_HOST) -> bool: + """Return True iff a local TCP listener accepts a connect on ``host:port``. + + A 100 ms connect window keeps the probe cheap on the common idle path; any + socket error (refused, timeout, unreachable) is treated as no-sidecar. + """ + try: + with socket.create_connection((host, port), timeout=0.1): + return True + except OSError: + return False + + +def start_runtime( + binary: Path, + *, + port: int = DEFAULT_PORT, + log_dir: Path | None = None, +) -> subprocess.Popen[bytes]: + """Spawn ``aasm serve --port `` as a detached background subprocess. + + Stdout and stderr are appended to ``/.aasm-runtime.log`` (default + log directory is the current working directory) so the sidecar outlives + the parent. ``start_new_session=True`` detaches the child from this + process's controlling terminal. + """ + target_dir = log_dir if log_dir is not None else Path.cwd() + log_path = target_dir / RUNTIME_LOG_FILENAME + log_file = log_path.open("ab") + return subprocess.Popen( + [str(binary), "serve", "--port", str(port)], + stdout=log_file, + stderr=log_file, + stdin=subprocess.DEVNULL, + start_new_session=True, + ) + + +def init_assembly( + agent_id: str | None = None, + *, + port: int = DEFAULT_PORT, +) -> None: + """Ensure the local ``aasm`` sidecar is running, starting it if necessary. + + Lifecycle per F115 / AAASM-1205: + + 1. Probe ``host:port`` via :func:`is_running`; return early if the sidecar + is already up (idempotent re-init). + 2. Resolve the binary via :func:`find_aasm_binary`. + 3. Spawn the sidecar via :func:`start_runtime`. + + ``agent_id`` is accepted to keep the ticket-specified signature stable; + actual register-and-connect is performed by the existing gateway-aware + ``agent_assembly.init_assembly`` once the sidecar is reachable. + + Raises: + RuntimeError: when no ``aasm`` binary is found on disk. The message + contains copy-paste install commands for all supported channels. + """ + del agent_id # not consumed at the lifecycle layer; see docstring + if is_running(port): + return + binary = find_aasm_binary() + if binary is None: + raise RuntimeError(INSTALL_HINT) + start_runtime(binary, port=port) diff --git a/test/unit/test_runtime.py b/test/unit/test_runtime.py new file mode 100644 index 0000000..3e0c875 --- /dev/null +++ b/test/unit/test_runtime.py @@ -0,0 +1,89 @@ +"""Unit tests for agent_assembly.runtime (AAASM-1227 / F115). + +Covers the four scenarios from the AAASM-1230 AC checklist: + * binary-in-PATH + * binary-bundled (in agent_assembly/bin/aasm) + * binary-not-found + * already-running (init_assembly skips spawn when sidecar reachable) +""" + +from __future__ import annotations + +import stat +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from agent_assembly import runtime + + +def _make_fake_aasm(directory: Path) -> Path: + """Write an executable `aasm` shim into ``directory`` and return its path.""" + path = directory / runtime.BINARY_NAME + path.write_text("#!/bin/sh\nexit 0\n") + path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + return path + + +def test_find_aasm_binary_returns_path_hit_when_on_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """binary-in-PATH: shutil.which hit returns immediately, ahead of every fallback.""" + fake = _make_fake_aasm(tmp_path) + monkeypatch.setenv("PATH", str(tmp_path)) + + resolved = runtime.find_aasm_binary() + + assert resolved == fake # noqa: S101 — pytest assertion + + +def test_init_assembly_raises_runtime_error_with_install_hint_when_missing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """binary-not-found: init_assembly raises RuntimeError whose message is the + INSTALL_HINT (which includes the pip/brew/curl copy-paste commands).""" + empty_dir = tmp_path / "empty" + empty_dir.mkdir() + monkeypatch.setenv("PATH", str(empty_dir)) + monkeypatch.setattr(runtime, "USER_LOCAL_BIN", tmp_path / "no-such-local-bin") + monkeypatch.setattr(runtime, "WHEEL_BUNDLED_BIN", tmp_path / "no-such-wheel-bin") + monkeypatch.setattr(runtime, "DOCKER_BASE_BIN", tmp_path / "no-such-docker-bin") + # Ensure is_running returns False so the orchestrator reaches find_aasm_binary. + monkeypatch.setattr(runtime, "is_running", lambda *_args, **_kw: False) + + with pytest.raises(RuntimeError) as exc_info: + runtime.init_assembly() + + assert str(exc_info.value) == runtime.INSTALL_HINT + assert "agent-assembly runtime not found" in str(exc_info.value) + assert "pip install" in str(exc_info.value) + + +def test_find_aasm_binary_returns_wheel_bundled_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """binary-bundled: when PATH and ~/.local/bin both miss, the wheel-bundled + location (`agent_assembly/bin/aasm`) is the next checked fallback.""" + fake_wheel_bin = tmp_path / "wheel_bin" + fake_wheel_bin.mkdir() + fake = _make_fake_aasm(fake_wheel_bin) + monkeypatch.setenv("PATH", str(tmp_path / "empty")) # not on PATH + monkeypatch.setattr(runtime, "USER_LOCAL_BIN", tmp_path / "no-such-local-bin") + monkeypatch.setattr(runtime, "WHEEL_BUNDLED_BIN", fake_wheel_bin) + monkeypatch.setattr(runtime, "DOCKER_BASE_BIN", tmp_path / "no-such-docker-bin") + + resolved = runtime.find_aasm_binary() + + assert resolved == fake + + +def test_init_assembly_idempotent_when_already_running(monkeypatch: pytest.MonkeyPatch) -> None: + """already-running: init_assembly returns early without calling + find_aasm_binary or start_runtime when is_running reports True.""" + find_spy = MagicMock(return_value=None) + start_spy = MagicMock() + monkeypatch.setattr(runtime, "is_running", lambda *_args, **_kw: True) + monkeypatch.setattr(runtime, "find_aasm_binary", find_spy) + monkeypatch.setattr(runtime, "start_runtime", start_spy) + + runtime.init_assembly() + + find_spy.assert_not_called() + start_spy.assert_not_called()