From cea7acb9ce24c647e0efa3c7ed913d865f8eaf62 Mon Sep 17 00:00:00 2001 From: Abhishek Enaguthi Date: Sun, 12 Jul 2026 22:04:29 -0700 Subject: [PATCH 1/2] Add agentex_dev_doctor for OSS local dev preflight (#163). Checks docker/uv, compose file, port conflicts, and private ECR DOCKER_REGISTRY before make dev. Documents LOCAL_DEV.md for external contributors. --- README.md | 2 + agentex/tests/unit/scripts/test_dev_doctor.py | 26 +++ docs/LOCAL_DEV.md | 34 ++++ scripts/agentex_dev_doctor.py | 148 ++++++++++++++++++ 4 files changed, 210 insertions(+) create mode 100644 agentex/tests/unit/scripts/test_dev_doctor.py create mode 100644 docs/LOCAL_DEV.md create mode 100644 scripts/agentex_dev_doctor.py diff --git a/README.md b/README.md index d104ff35..d03bc714 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,8 @@ The installation was successful if you see help output after you run `agentex -h ## Setup +> **OSS contributors:** Run `python scripts/agentex_dev_doctor.py` before `./dev.sh` to catch Docker/port/ECR issues ([#163](https://github.com/scaleapi/scale-agentex/issues/163)). See [docs/LOCAL_DEV.md](docs/LOCAL_DEV.md). + Before you share your agents with other people (or your company), you'll want a fully isolated developer sandbox. This allows you to freely develop agents without affecting anyone else or be affected by anyone else. To do this, you just need to spin up the [Agentex Server](https://github.com/scaleapi/scale-agentex/tree/main/agentex) and a [Developer UI](https://github.com/scaleapi/scale-agentex/tree/main/agentex-ui) which allows you to interact with your agent nicely. This way you'll know how your agent feels in a simple UI. diff --git a/agentex/tests/unit/scripts/test_dev_doctor.py b/agentex/tests/unit/scripts/test_dev_doctor.py new file mode 100644 index 00000000..351e611a --- /dev/null +++ b/agentex/tests/unit/scripts/test_dev_doctor.py @@ -0,0 +1,26 @@ +"""Tests for scripts/agentex_dev_doctor.py""" + +import unittest +from unittest import mock + +from scripts.agentex_dev_doctor import _check_docker_registry_env, _port_free + + +class TestAgentexDevDoctor(unittest.TestCase): + def test_registry_unset_passes(self): + with mock.patch.dict("os.environ", {}, clear=True): + gate = _check_docker_registry_env() + self.assertTrue(gate["passed"]) + + def test_private_ecr_fails(self): + env = {"DOCKER_REGISTRY": "022465994601.dkr.ecr.us-west-2.amazonaws.com/golden/"} + with mock.patch.dict("os.environ", env, clear=True): + gate = _check_docker_registry_env() + self.assertFalse(gate["passed"]) + + def test_port_free_helper(self): + self.assertIsInstance(_port_free(59999), bool) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/LOCAL_DEV.md b/docs/LOCAL_DEV.md new file mode 100644 index 00000000..7e3c0ca5 --- /dev/null +++ b/docs/LOCAL_DEV.md @@ -0,0 +1,34 @@ +# Local development (OSS contributors) + +Agentex local dev uses **public** Docker Hub base images by default. The `agentex/Dockerfile` +accepts an optional `DOCKER_REGISTRY` build arg — leave it **unset** for open-source onboarding. + +## Quick preflight + +Before `./dev.sh` or `make dev`: + +```bash +python scripts/agentex_dev_doctor.py +``` + +Checks: `docker`, `uv`, daemon reachable, `agentex/docker-compose.yml` present, critical ports +free, and `DOCKER_REGISTRY` not pointing at private ECR ([#163](https://github.com/scaleapi/scale-agentex/issues/163)). + +## Common failures + +| Symptom | Fix | +|---------|-----| +| `401 Unauthorized` pulling `*.amazonaws.com` base image | `unset DOCKER_REGISTRY` and rebuild | +| Redis port conflict on `6379` | `brew services stop redis` (macOS) | +| Postgres port conflict on `5432` | Stop local Postgres or change host mapping | +| Docker not running | Start Docker Desktop | + +## Start stack + +```bash +./dev.sh # macOS/Linux orchestration +# or +cd agentex && make dev +``` + +The compose file builds `agentex/Dockerfile` target `dev` without a private registry prefix. diff --git a/scripts/agentex_dev_doctor.py b/scripts/agentex_dev_doctor.py new file mode 100644 index 00000000..e45130cc --- /dev/null +++ b/scripts/agentex_dev_doctor.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Preflight checks before Agentex local development (./dev.sh / make dev). + +Addresses onboarding failures when Docker, uv, or port conflicts block OSS contributors +(see scaleapi/scale-agentex#163). + +Usage: + python scripts/agentex_dev_doctor.py + python scripts/agentex_dev_doctor.py --json +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import socket +import subprocess +import sys +from pathlib import Path +from typing import Any + +REPO_ROOT = Path(__file__).resolve().parents[1] +COMPOSE_FILE = REPO_ROOT / "agentex" / "docker-compose.yml" +PRIVATE_ECR_MARKERS = ("amazonaws.com", "dkr.ecr.") + + +def _check_cmd(name: str) -> dict[str, Any]: + path = shutil.which(name) + return { + "name": f"cmd_{name}", + "passed": path is not None, + "detail": path or f"{name} not found on PATH", + } + + +def _check_docker_daemon() -> dict[str, Any]: + try: + subprocess.run( + ["docker", "info"], + check=True, + capture_output=True, + text=True, + timeout=15, + ) + return {"name": "docker_daemon", "passed": True, "detail": "docker daemon reachable"} + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as exc: + return {"name": "docker_daemon", "passed": False, "detail": f"docker info failed: {exc}"} + + +def _port_free(port: int) -> bool: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind(("127.0.0.1", port)) + return True + except OSError: + return False + + +def _check_ports() -> list[dict[str, Any]]: + ports = { + 6379: "redis (stop local redis: brew services stop redis)", + 5432: "postgres", + 5003: "agentex API", + } + gates = [] + for port, label in ports.items(): + ok = _port_free(port) + gates.append( + { + "name": f"port_{port}", + "passed": ok, + "detail": f"{label} port {port} available" if ok else f"{label} port {port} in use", + } + ) + return gates + + +def _check_docker_registry_env() -> dict[str, Any]: + reg = os.environ.get("DOCKER_REGISTRY", "") + if not reg: + return { + "name": "docker_registry", + "passed": True, + "detail": "DOCKER_REGISTRY unset — public python/node base images used", + } + if any(marker in reg for marker in PRIVATE_ECR_MARKERS): + return { + "name": "docker_registry", + "passed": False, + "detail": ( + f"DOCKER_REGISTRY={reg!r} points at private ECR; " + "unset for OSS local dev (see docs/LOCAL_DEV.md)" + ), + } + return { + "name": "docker_registry", + "passed": True, + "detail": f"DOCKER_REGISTRY={reg!r}", + "warn": True, + } + + +def _check_compose_file() -> dict[str, Any]: + ok = COMPOSE_FILE.is_file() + return { + "name": "compose_file", + "passed": ok, + "detail": str(COMPOSE_FILE) if ok else "agentex/docker-compose.yml missing", + } + + +def run_doctor() -> dict[str, Any]: + gates: list[dict[str, Any]] = [ + _check_cmd("docker"), + _check_cmd("uv"), + _check_docker_daemon(), + _check_compose_file(), + _check_docker_registry_env(), + * _check_ports(), + ] + active = [g for g in gates if not g.get("warn")] + passed = all(g["passed"] for g in active) + return {"repo": str(REPO_ROOT), "gates": gates, "passed": passed} + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Agentex local dev preflight") + parser.add_argument("--json", action="store_true") + args = parser.parse_args(argv) + + report = run_doctor() + if args.json: + print(json.dumps(report, indent=2)) + else: + print(f"Agentex dev doctor — {'PASS' if report['passed'] else 'FAIL'}") + for gate in report["gates"]: + mark = "PASS" if gate["passed"] else "FAIL" + if gate.get("warn"): + mark = "WARN" + print(f" [{mark}] {gate['name']}: {gate['detail']}") + return 0 if report["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) From 4e7c4a601f85f71eca0e992b37989cc2e4db5e79 Mon Sep 17 00:00:00 2001 From: Abhishek Enaguthi Date: Sun, 12 Jul 2026 22:18:52 -0700 Subject: [PATCH 2/2] fix(agentex): address Greptile review on dev doctor tests and port probe Load doctor module via importlib for pytest compatibility and remove SO_REUSEADDR from port availability checks. --- agentex/tests/unit/scripts/test_dev_doctor.py | 25 ++++++++++++++++--- scripts/agentex_dev_doctor.py | 1 - 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/agentex/tests/unit/scripts/test_dev_doctor.py b/agentex/tests/unit/scripts/test_dev_doctor.py index 351e611a..167e811a 100644 --- a/agentex/tests/unit/scripts/test_dev_doctor.py +++ b/agentex/tests/unit/scripts/test_dev_doctor.py @@ -1,25 +1,42 @@ """Tests for scripts/agentex_dev_doctor.py""" +from __future__ import annotations + +import importlib.util +import sys import unittest +from pathlib import Path from unittest import mock -from scripts.agentex_dev_doctor import _check_docker_registry_env, _port_free +_MODULE_PATH = Path(__file__).resolve().parents[4] / "scripts" / "agentex_dev_doctor.py" + + +def _load_module(): + spec = importlib.util.spec_from_file_location("agentex_dev_doctor", _MODULE_PATH) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules["agentex_dev_doctor"] = module + spec.loader.exec_module(module) + return module + + +agentex_dev_doctor = _load_module() class TestAgentexDevDoctor(unittest.TestCase): def test_registry_unset_passes(self): with mock.patch.dict("os.environ", {}, clear=True): - gate = _check_docker_registry_env() + gate = agentex_dev_doctor._check_docker_registry_env() self.assertTrue(gate["passed"]) def test_private_ecr_fails(self): env = {"DOCKER_REGISTRY": "022465994601.dkr.ecr.us-west-2.amazonaws.com/golden/"} with mock.patch.dict("os.environ", env, clear=True): - gate = _check_docker_registry_env() + gate = agentex_dev_doctor._check_docker_registry_env() self.assertFalse(gate["passed"]) def test_port_free_helper(self): - self.assertIsInstance(_port_free(59999), bool) + self.assertIsInstance(agentex_dev_doctor._port_free(59999), bool) if __name__ == "__main__": diff --git a/scripts/agentex_dev_doctor.py b/scripts/agentex_dev_doctor.py index e45130cc..e658c968 100644 --- a/scripts/agentex_dev_doctor.py +++ b/scripts/agentex_dev_doctor.py @@ -51,7 +51,6 @@ def _check_docker_daemon() -> dict[str, Any]: def _port_free(port: int) -> bool: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: sock.bind(("127.0.0.1", port)) return True