From 053fc573609ab3b97d21bfd3ea1a0c2e5e68c92a Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Wed, 30 Sep 2026 06:42:20 -1000 Subject: [PATCH 01/12] refactor(cli): the put commands build through one build_image() The six put commands ran nine copies of the same `docker build` (a2a-agent, gateway, mcp-server, service-db x3, website x2, website-browser). They now call agent_env.utils.docker_build.build_image(), which library code can import too: stdlib only, no click, no providers. - build_image(dockerfile, context, tag, *, platform, build_args=None) builds only; each caller still puts the image. platform has no default, so a caller chooses; None or "" builds host-native. - A failed build raises DockerBuildError with docker's whole output (stderr merged into stdout, bytes that don't decode replaced). The CLI renders it as one user-facing error and exits 1, replacing the five per-site prefixes and service-db's "Aborted!". A missing docker binary is the same one line instead of a traceback. - DEFAULT_BUILD_PLATFORM moves to the new module; docker_build_platform_args goes. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/cli/__init__.py | 9 ++- src/agent_env/cli/a2a_agent/put.py | 14 +--- src/agent_env/cli/env/gateway.py | 14 +--- src/agent_env/cli/env/mcp_server.py | 14 +--- src/agent_env/cli/env/service_db.py | 40 ++-------- src/agent_env/cli/env/website.py | 24 +----- src/agent_env/cli/env/website_browser.py | 16 +--- src/agent_env/cli/utils.py | 8 +- src/agent_env/utils/docker_build.py | 29 +++++++ tst/unit/cli/build_platform_test.py | 75 ++++++++++++++----- tst/unit/cli/env_provider_type_test.py | 4 +- tst/unit/cli/env_put_validation_test.py | 2 +- tst/unit/cli/environment_name_alias_test.py | 4 +- tst/unit/cli/sandbox_url_relocation_test.py | 12 +-- .../envs/test_mcp_server_plugin_provider.py | 2 +- tst/unit/utils/docker_build_test.py | 73 ++++++++++++++++++ 16 files changed, 197 insertions(+), 143 deletions(-) create mode 100644 src/agent_env/utils/docker_build.py create mode 100644 tst/unit/utils/docker_build_test.py diff --git a/src/agent_env/cli/__init__.py b/src/agent_env/cli/__init__.py index 31d72126..e39688d4 100644 --- a/src/agent_env/cli/__init__.py +++ b/src/agent_env/cli/__init__.py @@ -5,6 +5,7 @@ from agent_env.plugins._cli import load_cli_plugins, load_cli_root_options from agent_env.store.base import NotFoundError from agent_env.store.routing import namespace_routing +from agent_env.utils.docker_build import DockerBuildError from .a2a_agent import a2a_agent from .artifact import artifact @@ -17,11 +18,11 @@ from .up import up -# Errors that mean "not allowed" or "not there" rather than "agent-env is broken". Spelled out -# because there is no domain base class to catch: ConfigError subclasses ValueError (so does -# pydantic's ValidationError), while NotFoundError subclasses Exception. A TypeError or a +# Errors that mean "not allowed", "not there" or "your build failed" rather than "agent-env is broken". +# Spelled out because there is no domain base class to catch: ConfigError subclasses ValueError (so does +# pydantic's ValidationError), while NotFoundError and DockerBuildError don't. A TypeError or a # retry-exhausted DuplicateKeyError is a defect, and a defect keeps its traceback. -_USER_FACING_ERRORS = (ValueError, NotFoundError) +_USER_FACING_ERRORS = (ValueError, NotFoundError, DockerBuildError) class _UserErrorsAreNotCrashes(click.Group): diff --git a/src/agent_env/cli/a2a_agent/put.py b/src/agent_env/cli/a2a_agent/put.py index 1900f585..bc6777e9 100644 --- a/src/agent_env/cli/a2a_agent/put.py +++ b/src/agent_env/cli/a2a_agent/put.py @@ -1,4 +1,3 @@ -import subprocess import sys from pathlib import Path @@ -6,7 +5,8 @@ from agent_env.a2a_agent import A2AAgent from agent_env.artifact import DockerImageArtifact -from agent_env.cli.utils import build_platform_option, detect_env_metadata, docker_build_platform_args, skips_local_validation +from agent_env.cli.utils import build_platform_option, detect_env_metadata, skips_local_validation +from agent_env.utils.docker_build import build_image @click.command() @@ -44,15 +44,7 @@ def put(agent_id: str, dockerfile: str, context_path: str | None, env_var_pairs: image_tag = f"a2a-agent-{agent_id}" click.echo(f"Building Docker image...") - result = subprocess.run( - ["docker", "build", *docker_build_platform_args(build_platform), - "-f", str(dockerfile_path), "-t", image_tag, str(context)], - capture_output=True, - text=True, - ) - if result.returncode != 0: - click.echo(f"Docker build failed: {result.stderr}", err=True) - sys.exit(1) + build_image(dockerfile_path, context, image_tag, platform=build_platform) click.echo(f"Creating DockerImageArtifact...") artifact = DockerImageArtifact.put( diff --git a/src/agent_env/cli/env/gateway.py b/src/agent_env/cli/env/gateway.py index e4a37c4f..c9f06356 100644 --- a/src/agent_env/cli/env/gateway.py +++ b/src/agent_env/cli/env/gateway.py @@ -1,12 +1,12 @@ -import subprocess import sys from pathlib import Path import click from agent_env.artifact import DockerImageArtifact -from agent_env.cli.utils import build_platform_option, detect_env_metadata, docker_build_platform_args +from agent_env.cli.utils import build_platform_option, detect_env_metadata from agent_env.env import GatewayEnv +from agent_env.utils.docker_build import build_image _PACKAGE_ROOT = Path(__file__).parent.parent.parent GATEWAY_DOCKERFILE = _PACKAGE_ROOT / "env" / "gateway" / "Dockerfile" @@ -28,15 +28,7 @@ def put(env_id: str, metadata_pairs: tuple[str, ...], build_platform: str): """Build and upload a gateway environment.""" click.echo(f"Building gateway Docker image...") - result = subprocess.run( - ["docker", "build", *docker_build_platform_args(build_platform), - "-f", str(GATEWAY_DOCKERFILE), "-t", GATEWAY_IMAGE_TAG, str(GATEWAY_CONTEXT)], - capture_output=True, - text=True, - ) - if result.returncode != 0: - click.echo(f"Docker build failed: {result.stderr}", err=True) - sys.exit(1) + build_image(GATEWAY_DOCKERFILE, GATEWAY_CONTEXT, GATEWAY_IMAGE_TAG, platform=build_platform) click.echo(f"Creating DockerImageArtifact...") artifact = DockerImageArtifact.put( diff --git a/src/agent_env/cli/env/mcp_server.py b/src/agent_env/cli/env/mcp_server.py index e1844526..8030855c 100644 --- a/src/agent_env/cli/env/mcp_server.py +++ b/src/agent_env/cli/env/mcp_server.py @@ -1,6 +1,5 @@ import asyncio import os -import subprocess import sys from pathlib import Path @@ -8,17 +7,16 @@ from agent_env.artifact import DockerImageArtifact, EnvironmentArtifact from agent_env.cli.utils import ( - DEFAULT_BUILD_PLATFORM, deployed_env_from_instance, build_platform_option, detect_env_metadata, - docker_build_platform_args, env_provider_type_option, environment_name_options, resolve_environment_name, skips_local_validation, ) from agent_env.utils.card_naming import card_name_from_github, card_name_from_source +from agent_env.utils.docker_build import DEFAULT_BUILD_PLATFORM, build_image from agent_env.env import Env, MCPServerEnv @@ -194,15 +192,7 @@ def _on_progress(step: str, message: str, percent: int) -> None: image_tag = f"mcp-server-{env_id}" click.echo(f"Building MCP server Docker image...") - result = subprocess.run( - ["docker", "build", *docker_build_platform_args(build_platform), - "-f", str(dockerfile_path), "-t", image_tag, str(context)], - capture_output=True, - text=True, - ) - if result.returncode != 0: - click.echo(f"Docker build failed: {result.stderr}", err=True) - sys.exit(1) + build_image(dockerfile_path, context, image_tag, platform=build_platform) click.echo(f"Creating DockerImageArtifact...") artifact = DockerImageArtifact.put( diff --git a/src/agent_env/cli/env/service_db.py b/src/agent_env/cli/env/service_db.py index 4d0cec95..121e0590 100644 --- a/src/agent_env/cli/env/service_db.py +++ b/src/agent_env/cli/env/service_db.py @@ -1,14 +1,14 @@ """CLI commands for ServiceDBEnv.""" -import subprocess from pathlib import Path import click from agent_env.artifact import DockerImageArtifact -from agent_env.cli.utils import build_platform_option, detect_env_metadata, docker_build_platform_args +from agent_env.cli.utils import build_platform_option, detect_env_metadata from agent_env.env.envs.service_db import ServiceDBEnv from agent_env.config import get_config +from agent_env.utils.docker_build import build_image # Path to ServiceDB Dockerfile SERVICE_DB_DOCKERFILE = Path(__file__).parent.parent.parent / "env" / "envs" / "service_db" / "Dockerfile" @@ -37,17 +37,7 @@ def put(env_id: str, metadata_pairs: tuple[str, ...], build_platform: str): click.echo(f"Building ServiceDB image from {SERVICE_DB_DOCKERFILE}...") # Build Docker image - result = subprocess.run( - ["docker", "build", *docker_build_platform_args(build_platform), - "-f", str(SERVICE_DB_DOCKERFILE), - "-t", SERVICE_DB_IMAGE_NAME, - str(SERVICE_DB_DOCKERFILE.parent)], - capture_output=True, - text=True, - ) - if result.returncode != 0: - click.echo(f"Docker build failed: {result.stderr}", err=True) - raise click.Abort() + build_image(SERVICE_DB_DOCKERFILE, SERVICE_DB_DOCKERFILE.parent, SERVICE_DB_IMAGE_NAME, platform=build_platform) click.echo("Docker build successful") # Create DB DockerImageArtifact @@ -61,17 +51,7 @@ def put(env_id: str, metadata_pairs: tuple[str, ...], build_platform: str): # Build db-web image (build instead of pull to avoid docker save manifest issues on Apple Silicon) click.echo(f"Building db-web image from {DB_WEB_DOCKERFILE}...") - result = subprocess.run( - ["docker", "build", *docker_build_platform_args(build_platform), - "-f", str(DB_WEB_DOCKERFILE), - "-t", DB_WEB_IMAGE_NAME, - str(DB_WEB_DOCKERFILE.parent)], - capture_output=True, - text=True, - ) - if result.returncode != 0: - click.echo(f"db-web build failed: {result.stderr}", err=True) - raise click.Abort() + build_image(DB_WEB_DOCKERFILE, DB_WEB_DOCKERFILE.parent, DB_WEB_IMAGE_NAME, platform=build_platform) click.echo("db-web build successful") # Create db-web DockerImageArtifact @@ -85,17 +65,7 @@ def put(env_id: str, metadata_pairs: tuple[str, ...], build_platform: str): # Build db-mcp image (PostgreSQL MCP server for direct DB access) click.echo(f"Building db-mcp image from {DB_MCP_DOCKERFILE}...") - result = subprocess.run( - ["docker", "build", *docker_build_platform_args(build_platform), - "-f", str(DB_MCP_DOCKERFILE), - "-t", DB_MCP_IMAGE_NAME, - str(DB_MCP_DOCKERFILE.parent)], - capture_output=True, - text=True, - ) - if result.returncode != 0: - click.echo(f"db-mcp build failed: {result.stderr}", err=True) - raise click.Abort() + build_image(DB_MCP_DOCKERFILE, DB_MCP_DOCKERFILE.parent, DB_MCP_IMAGE_NAME, platform=build_platform) click.echo("db-mcp build successful") # Create db-mcp DockerImageArtifact diff --git a/src/agent_env/cli/env/website.py b/src/agent_env/cli/env/website.py index 1fd5be10..49bb4d1c 100644 --- a/src/agent_env/cli/env/website.py +++ b/src/agent_env/cli/env/website.py @@ -1,6 +1,5 @@ import asyncio import os -import subprocess import sys from pathlib import Path @@ -8,17 +7,16 @@ from agent_env.artifact import DockerImageArtifact, EnvironmentArtifact from agent_env.cli.utils import ( - DEFAULT_BUILD_PLATFORM, deployed_env_from_instance, build_platform_option, detect_env_metadata, - docker_build_platform_args, env_provider_type_option, environment_name_options, resolve_environment_name, skips_local_validation, ) from agent_env.utils.card_naming import card_name_from_github, card_name_from_source +from agent_env.utils.docker_build import DEFAULT_BUILD_PLATFORM, build_image from agent_env.env import Env from agent_env.env.envs.website import WebsiteEnv from agent_env.providers import get_env_sandbox_provider @@ -152,15 +150,7 @@ def _frontend_progress(step: str, message: str, percent: int) -> None: click.echo(f"Derived environment_name={environment_name!r} from the environment card.") click.echo(f"Building website backend Docker image...") - result = subprocess.run( - ["docker", "build", *docker_build_platform_args(build_platform), - "-f", str(backend_dockerfile_path), "-t", backend_tag, str(backend_ctx)], - capture_output=True, - text=True, - ) - if result.returncode != 0: - click.echo(f"Backend Docker build failed: {result.stderr}", err=True) - sys.exit(1) + build_image(backend_dockerfile_path, backend_ctx, backend_tag, platform=build_platform) click.echo(f"Creating backend DockerImageArtifact...") backend_artifact = DockerImageArtifact.put( @@ -175,15 +165,7 @@ def _frontend_progress(step: str, message: str, percent: int) -> None: frontend_tag = f"website-frontend-{env_id}" click.echo(f"Building website frontend Docker image...") - result = subprocess.run( - ["docker", "build", *docker_build_platform_args(build_platform), - "-f", str(frontend_dockerfile_path), "-t", frontend_tag, str(frontend_ctx)], - capture_output=True, - text=True, - ) - if result.returncode != 0: - click.echo(f"Frontend Docker build failed: {result.stderr}", err=True) - sys.exit(1) + build_image(frontend_dockerfile_path, frontend_ctx, frontend_tag, platform=build_platform) click.echo(f"Creating frontend DockerImageArtifact...") frontend_artifact = DockerImageArtifact.put( diff --git a/src/agent_env/cli/env/website_browser.py b/src/agent_env/cli/env/website_browser.py index 84339d21..59f74c46 100644 --- a/src/agent_env/cli/env/website_browser.py +++ b/src/agent_env/cli/env/website_browser.py @@ -1,17 +1,17 @@ -import subprocess import sys from pathlib import Path import click from agent_env.artifact import DockerImageArtifact -from agent_env.cli.utils import build_platform_option, detect_env_metadata, docker_build_platform_args +from agent_env.cli.utils import build_platform_option, detect_env_metadata from agent_env.env import MCPServerEnv from agent_env.env.envs.website_browser import ( PLAYWRIGHT_MCP_VERSION, WEBSITE_BROWSER_IMAGE_TAG, WEBSITE_BROWSER_ENVIRONMENT_NAME, ) +from agent_env.utils.docker_build import build_image _PACKAGE_ROOT = Path(__file__).parent.parent.parent WEBSITE_BROWSER_DOCKERFILE = _PACKAGE_ROOT / "env" / "envs" / "website_browser" / "Dockerfile" @@ -36,16 +36,8 @@ def put(env_id: str, metadata_pairs: tuple[str, ...], build_platform: str): env_id = get_config().default_website_browser_env_id click.echo("Building website browser Docker image...") - result = subprocess.run( - ["docker", "build", *docker_build_platform_args(build_platform), - "--build-arg", f"PLAYWRIGHT_MCP_VERSION={PLAYWRIGHT_MCP_VERSION}", - "-f", str(WEBSITE_BROWSER_DOCKERFILE), "-t", WEBSITE_BROWSER_IMAGE_TAG, str(WEBSITE_BROWSER_CONTEXT)], - capture_output=True, - text=True, - ) - if result.returncode != 0: - click.echo(f"Docker build failed: {result.stderr}", err=True) - sys.exit(1) + build_image(WEBSITE_BROWSER_DOCKERFILE, WEBSITE_BROWSER_CONTEXT, WEBSITE_BROWSER_IMAGE_TAG, platform=build_platform, + build_args={"PLAYWRIGHT_MCP_VERSION": PLAYWRIGHT_MCP_VERSION}) click.echo("Creating DockerImageArtifact...") artifact = DockerImageArtifact.put( diff --git a/src/agent_env/cli/utils.py b/src/agent_env/cli/utils.py index c6c06f10..47aeffc4 100644 --- a/src/agent_env/cli/utils.py +++ b/src/agent_env/cli/utils.py @@ -10,8 +10,7 @@ from agent_env.providers.env_providers.env_server_provider import EnvironmentServerProvider from agent_env.store.base import NotFoundError from agent_env.store.ids import is_local_id - -DEFAULT_BUILD_PLATFORM = "linux/amd64" +from agent_env.utils.docker_build import DEFAULT_BUILD_PLATFORM def build_platform_option(f): @@ -35,11 +34,6 @@ def build_platform_option(f): )(f) -def docker_build_platform_args(platform: str | None) -> list[str]: - """['--platform', ] when a platform is set, else [] (host-native build).""" - return ["--platform", platform] if platform else [] - - def env_provider_type_option(help: str, env_type: str = "mcp_server"): """Shared `--env-provider-type` option (default `gateway`), refused at parse time unless an installed provider has that type, and for an env other than one MCP server, unless that provider deploys more than one.""" diff --git a/src/agent_env/utils/docker_build.py b/src/agent_env/utils/docker_build.py new file mode 100644 index 00000000..44123687 --- /dev/null +++ b/src/agent_env/utils/docker_build.py @@ -0,0 +1,29 @@ +"""Build a Docker image on this machine, the one way every put command and bundle writer does it.""" + +from __future__ import annotations + +import subprocess +from collections.abc import Mapping +from pathlib import Path + +DEFAULT_BUILD_PLATFORM = "linux/amd64" # the remote sandbox VMs + + +class DockerBuildError(RuntimeError): + """A local ``docker build`` that failed or couldn't start; the message carries docker's output.""" + + +def build_image(dockerfile: Path, context: Path, tag: str, *, platform: str | None, + build_args: Mapping[str, str] | None = None) -> None: + """Build ``context`` with ``dockerfile`` into the local image ``tag``, for ``platform``, or for this host's + platform when it's None or empty. Raises DockerBuildError, with the build's output, when it fails.""" + argv = ["docker", "build", *(["--platform", platform] if platform else [])] + for name, value in (build_args or {}).items(): + argv += ["--build-arg", f"{name}={value}"] + argv += ["-f", str(dockerfile), "-t", tag, str(context)] + try: + result = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, errors="replace") + except FileNotFoundError as e: + raise DockerBuildError(f"docker build of {tag} failed: docker is not on PATH") from e + if result.returncode != 0: + raise DockerBuildError(f"docker build of {tag} failed (exit {result.returncode}):\n{result.stdout.rstrip()}") diff --git a/tst/unit/cli/build_platform_test.py b/tst/unit/cli/build_platform_test.py index 50b6e7b3..a683572a 100644 --- a/tst/unit/cli/build_platform_test.py +++ b/tst/unit/cli/build_platform_test.py @@ -1,30 +1,69 @@ -"""Tests for the shared docker build --platform CLI option.""" +"""Each put command builds through ``build_image`` with its ``--platform``, and a build that fails is reported +as one line, not a traceback.""" +import importlib +from unittest.mock import patch + +import pytest from click.testing import CliRunner -from agent_env.cli.utils import DEFAULT_BUILD_PLATFORM, docker_build_platform_args +from agent_env.cli import cli +from agent_env.env.envs.website_browser import PLAYWRIGHT_MCP_VERSION, WEBSITE_BROWSER_IMAGE_TAG +from agent_env.utils.docker_build import DockerBuildError +gateway = importlib.import_module("agent_env.cli.env.gateway") # the packages export the click commands +service_db = importlib.import_module("agent_env.cli.env.service_db") +website_browser = importlib.import_module("agent_env.cli.env.website_browser") -def test_default_platform_preserves_amd64(): - # Backward compatibility: unchanged callers still build amd64. - assert DEFAULT_BUILD_PLATFORM == "linux/amd64" - assert docker_build_platform_args(DEFAULT_BUILD_PLATFORM) == ["--platform", "linux/amd64"] +def test_platform_option_wired_on_mcp_server_put(): + result = CliRunner().invoke(cli, ["env", "mcp-server", "put", "--help"]) + assert result.exit_code == 0 + assert "--platform" in result.output + assert "linux/amd64" in result.output # default shown -def test_explicit_arm64_platform(): - assert docker_build_platform_args("linux/arm64") == ["--platform", "linux/arm64"] +def _dockerfile(tmp_path, name="Dockerfile"): + path = tmp_path / name + path.write_text("FROM scratch\n") + return path -def test_empty_platform_builds_host_native(): - # Empty / None omits the flag entirely so docker uses the host platform. - assert docker_build_platform_args("") == [] - assert docker_build_platform_args(None) == [] +def _put_commands(tmp_path): + """Each put command, and the first build it makes: its Dockerfile, context and tag, and its build args.""" + dockerfile = _dockerfile(tmp_path) + backend, frontend = _dockerfile(tmp_path, "backend.Dockerfile"), _dockerfile(tmp_path, "frontend.Dockerfile") + return { + "a2a-agent": ("agent_env.cli.a2a_agent.put", ["a2a-agent", "put", "--id", "x", "--dockerfile", str(dockerfile)], + (dockerfile, tmp_path, "a2a-agent-x"), None), + "mcp-server": ("agent_env.cli.env.mcp_server", + ["env", "mcp-server", "put", "--id", "x", "--environment-name", "items", "--dockerfile", + str(dockerfile)], (dockerfile, tmp_path, "mcp-server-x"), None), + "website": ("agent_env.cli.env.website", + ["env", "website", "put", "--id", "x", "--environment-name", "shop", "--backend-dockerfile", str(backend), + "--frontend-dockerfile", str(frontend)], (backend, tmp_path, "website-backend-x"), None), + "gateway": ("agent_env.cli.env.gateway", ["env", "gateway", "put", "--id", "x"], + (gateway.GATEWAY_DOCKERFILE, gateway.GATEWAY_CONTEXT, gateway.GATEWAY_IMAGE_TAG), None), + "service-db": ("agent_env.cli.env.service_db", ["env", "service-db", "put", "--id", "x"], + (service_db.SERVICE_DB_DOCKERFILE, service_db.SERVICE_DB_DOCKERFILE.parent, + service_db.SERVICE_DB_IMAGE_NAME), None), + "website-browser": ("agent_env.cli.env.website_browser", ["env", "website-browser", "put", "--id", "x"], + (website_browser.WEBSITE_BROWSER_DOCKERFILE, website_browser.WEBSITE_BROWSER_CONTEXT, + WEBSITE_BROWSER_IMAGE_TAG), {"PLAYWRIGHT_MCP_VERSION": PLAYWRIGHT_MCP_VERSION}), + } -def test_platform_option_wired_on_mcp_server_put(): - from agent_env.cli.env.mcp_server import put as mcp_server_put - result = CliRunner().invoke(mcp_server_put, ["--help"]) - assert result.exit_code == 0 - assert "--platform" in result.output - assert "linux/amd64" in result.output # default shown +@pytest.mark.parametrize("name", ["a2a-agent", "mcp-server", "website", "gateway", "service-db", "website-browser"]) +def test_each_put_builds_through_build_image_and_a_failed_build_is_one_line(tmp_path, name): + module, argv, (dockerfile, context, tag), build_args = _put_commands(tmp_path)[name] + + with patch(f"{module}.build_image", side_effect=DockerBuildError(f"docker build of {tag} failed (exit 1):\nboom")) \ + as build: + result = CliRunner().invoke(cli, [*argv, "--platform", "linux/arm64"]) + + expected = {"platform": "linux/arm64", **({"build_args": build_args} if build_args else {})} + assert build.call_args.args == (dockerfile, context, tag) + assert build.call_args.kwargs == expected + assert result.exit_code == 1 + assert result.output.endswith(f"Error: docker build of {tag} failed (exit 1):\nboom\n") + assert "Traceback" not in result.output diff --git a/tst/unit/cli/env_provider_type_test.py b/tst/unit/cli/env_provider_type_test.py index fbf2b7bf..23a57446 100644 --- a/tst/unit/cli/env_provider_type_test.py +++ b/tst/unit/cli/env_provider_type_test.py @@ -24,7 +24,7 @@ def test_the_github_build_puts_the_declared_type(flag, put_with): def test_an_unknown_type_is_refused_before_anything_is_built(tmp_path): - with patch("agent_env.cli.env.mcp_server.subprocess.run") as build, patch("agent_env.cli.env.mcp_server.MCPServerEnv.put") as put: + with patch("agent_env.cli.env.mcp_server.build_image") as build, patch("agent_env.cli.env.mcp_server.MCPServerEnv.put") as put: result = CliRunner().invoke(cli, [*_PUT, "--dockerfile", str(_dockerfile(tmp_path)), "--env-provider-type", "vm"]) assert result.exit_code == 2 and "Invalid value for '--env-provider-type'" in result.output assert (build.called, put.called) == (False, False) @@ -40,7 +40,7 @@ def _capture(**kwargs): captured.update(kwargs) return _put_env(kwargs) - with patch("agent_env.cli.env.mcp_server.subprocess.run", return_value=MagicMock(returncode=0, stdout="", stderr="")), \ + with patch("agent_env.cli.env.mcp_server.build_image"), \ patch("agent_env.cli.env.mcp_server.DockerImageArtifact.put", return_value=MagicMock(id="a", version=1)), \ patch("agent_env.cli.env.mcp_server.detect_env_metadata", return_value={}), \ patch("agent_env.cli.env.mcp_server.MCPServerEnv.put", side_effect=_capture): diff --git a/tst/unit/cli/env_put_validation_test.py b/tst/unit/cli/env_put_validation_test.py index 3dfae881..fc45b897 100644 --- a/tst/unit/cli/env_put_validation_test.py +++ b/tst/unit/cli/env_put_validation_test.py @@ -69,7 +69,7 @@ def _mcp_put(tmp_path, build: str, *flags: str): return result, gate dockerfile = tmp_path / "Dockerfile" dockerfile.write_text("FROM scratch\n") - with patch("agent_env.cli.env.mcp_server.subprocess.run", return_value=MagicMock(returncode=0, stdout="", stderr="")), \ + with patch("agent_env.cli.env.mcp_server.build_image"), \ patch("agent_env.cli.env.mcp_server.DockerImageArtifact.put", return_value=MagicMock(id="a", version=1)), \ patch("agent_env.cli.env.mcp_server.detect_env_metadata", return_value={}), \ patch("agent_env.cli.env.mcp_server.MCPServerEnv.put", return_value=env): diff --git a/tst/unit/cli/environment_name_alias_test.py b/tst/unit/cli/environment_name_alias_test.py index 49ab33e5..9a598ac2 100644 --- a/tst/unit/cli/environment_name_alias_test.py +++ b/tst/unit/cli/environment_name_alias_test.py @@ -116,7 +116,7 @@ def _capture(**kwargs): raise SystemExit(0) with patch("agent_env.cli.env.mcp_server.card_name_from_source", return_value="items") as derive, \ - patch("agent_env.cli.env.mcp_server.subprocess.run", return_value=MagicMock(returncode=0, stdout="", stderr="")), \ + patch("agent_env.cli.env.mcp_server.build_image"), \ patch("agent_env.cli.env.mcp_server.DockerImageArtifact.put", return_value=MagicMock(id="a", version=1)), \ patch("agent_env.cli.env.mcp_server.detect_env_metadata", return_value={}), \ patch("agent_env.cli.env.mcp_server.MCPServerEnv.put", side_effect=_capture): @@ -141,7 +141,7 @@ def _capture(**kwargs): raise SystemExit(0) with patch("agent_env.cli.env.website.card_name_from_source", return_value="webitems") as derive, \ - patch("agent_env.cli.env.website.subprocess.run", return_value=MagicMock(returncode=0, stdout="", stderr="")), \ + patch("agent_env.cli.env.website.build_image"), \ patch("agent_env.cli.env.website.DockerImageArtifact.put", return_value=MagicMock(id="a", version=1)), \ patch("agent_env.cli.env.website.detect_env_metadata", return_value={}), \ patch("agent_env.cli.env.website.WebsiteEnv.put", side_effect=_capture): diff --git a/tst/unit/cli/sandbox_url_relocation_test.py b/tst/unit/cli/sandbox_url_relocation_test.py index d293c89d..d941db8d 100644 --- a/tst/unit/cli/sandbox_url_relocation_test.py +++ b/tst/unit/cli/sandbox_url_relocation_test.py @@ -11,6 +11,8 @@ from click.testing import CliRunner from unittest.mock import MagicMock, patch +from agent_env.utils.docker_build import DockerBuildError + def test_old_cli_path_no_longer_re_exports_the_helper(): # The compatibility re-export is retired: its consumer, a sandbox proxy @@ -95,8 +97,7 @@ def test_service_db_put_falls_back_to_the_config_default(): cfg = _config(default_service_db_env_id="svc-db-from-config") with patch("agent_env.cli.env.service_db.get_config", return_value=cfg), \ patch("agent_env.cli.env.service_db.DockerImageArtifact") as artifact, \ - patch("agent_env.cli.env.service_db.subprocess.run") as run: - run.return_value = MagicMock(returncode=0) + patch("agent_env.cli.env.service_db.build_image") as run: artifact.put.side_effect = RuntimeError("stop once the id is recorded") res = CliRunner().invoke(service_db, ["put"]) @@ -108,8 +109,8 @@ def test_service_db_put_explicit_id_wins_and_skips_the_config_read(): from agent_env.cli.env.service_db import service_db with patch("agent_env.cli.env.service_db.get_config") as get_config, \ - patch("agent_env.cli.env.service_db.subprocess.run") as run: - run.return_value = MagicMock(returncode=1, stderr="stop here") + patch("agent_env.cli.env.service_db.build_image") as run: + run.side_effect = DockerBuildError("stop here") CliRunner().invoke(service_db, ["put", "--id", "explicit-env"]) get_config.assert_not_called() @@ -121,8 +122,7 @@ def test_website_browser_put_falls_back_to_the_config_default(): cfg = _config(default_website_browser_env_id="wb-from-config") with patch("agent_env.config.get_config", return_value=cfg), \ patch("agent_env.cli.env.website_browser.DockerImageArtifact") as artifact, \ - patch("agent_env.cli.env.website_browser.subprocess.run") as run: - run.return_value = MagicMock(returncode=0) + patch("agent_env.cli.env.website_browser.build_image") as run: artifact.put.side_effect = RuntimeError("stop once the id is recorded") res = CliRunner().invoke(website_browser, ["put"]) diff --git a/tst/unit/env/envs/test_mcp_server_plugin_provider.py b/tst/unit/env/envs/test_mcp_server_plugin_provider.py index 6bad8a4a..553c615a 100644 --- a/tst/unit/env/envs/test_mcp_server_plugin_provider.py +++ b/tst/unit/env/envs/test_mcp_server_plugin_provider.py @@ -338,7 +338,7 @@ def _put(tmp_path, *flags): dockerfile = tmp_path / "Dockerfile" dockerfile.write_text("FROM scratch\n") put = MagicMock(return_value=MagicMock(id="x", version=1, environment_name="email")) - with patch("agent_env.cli.env.mcp_server.subprocess.run", return_value=MagicMock(returncode=0, stdout="", stderr="")), \ + with patch("agent_env.cli.env.mcp_server.build_image"), \ patch("agent_env.cli.env.mcp_server.DockerImageArtifact.put", return_value=MagicMock(id="a", version=1)), \ patch("agent_env.cli.env.mcp_server.detect_env_metadata", return_value={}), \ patch("agent_env.cli.env.mcp_server.MCPServerEnv.put", put): diff --git a/tst/unit/utils/docker_build_test.py b/tst/unit/utils/docker_build_test.py new file mode 100644 index 00000000..047752c1 --- /dev/null +++ b/tst/unit/utils/docker_build_test.py @@ -0,0 +1,73 @@ +"""``build_image``: the argv it runs, what a failed or impossible build raises, and that importing it pulls in +neither the CLI nor the providers, so library code can build too.""" + +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from agent_env.utils import docker_build +from agent_env.utils.docker_build import DEFAULT_BUILD_PLATFORM, DockerBuildError, build_image + + +@pytest.fixture +def runs(monkeypatch): + calls = [] + + def run(argv, **kwargs): + calls.append((argv, kwargs)) + return SimpleNamespace(returncode=0, stdout="") + + monkeypatch.setattr(docker_build.subprocess, "run", run) + return calls + + +def test_the_default_platform_is_the_remote_sandboxes(): + assert DEFAULT_BUILD_PLATFORM == "linux/amd64" + + +@pytest.mark.parametrize("platform, flags", [ + ("linux/amd64", ["--platform", "linux/amd64"]), + ("linux/arm64", ["--platform", "linux/arm64"]), + ("", []), + (None, []), +], ids=["amd64", "arm64", "empty-is-host-native", "none-is-host-native"]) +def test_it_builds_the_context_with_the_dockerfile_into_the_tag(runs, platform, flags): + build_image(Path("/ctx/Dockerfile"), Path("/ctx"), "my-image", platform=platform, build_args={"VERSION": "1.2"}) + + (argv, kwargs), = runs + assert argv == ["docker", "build", *flags, "--build-arg", "VERSION=1.2", "-f", "/ctx/Dockerfile", "-t", "my-image", + "/ctx"] + assert kwargs["stderr"] is subprocess.STDOUT + + +def test_a_failed_build_raises_with_its_output(monkeypatch): + output = "#5 [2/3] RUN pip install nope\n#5 ERROR: process did not complete\n" + monkeypatch.setattr(docker_build.subprocess, "run", lambda argv, **_: SimpleNamespace(returncode=1, stdout=output)) + + with pytest.raises(DockerBuildError) as failed: + build_image(Path("Dockerfile"), Path("."), "my-image", platform=None) + + assert str(failed.value) == f"docker build of my-image failed (exit 1):\n{output.rstrip()}" + + +def test_a_missing_docker_raises_rather_than_crashing(monkeypatch): + def run(argv, **_): + raise FileNotFoundError(2, "No such file or directory", "docker") + + monkeypatch.setattr(docker_build.subprocess, "run", run) + + with pytest.raises(DockerBuildError, match="docker build of my-image failed: docker is not on PATH"): + build_image(Path("Dockerfile"), Path("."), "my-image", platform=None) + + +def test_importing_it_pulls_neither_cli_nor_providers(): + code = ( + "import sys; import agent_env.utils.docker_build; " + "assert 'agent_env.cli' not in sys.modules, 'cli imported'; " + "assert 'agent_env.providers' not in sys.modules, 'providers imported'" + ) + proc = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr From 6b04ba71ede5c64200c8e73b413f12cfa77ae51c Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Wed, 30 Sep 2026 06:42:20 -1000 Subject: [PATCH 02/12] test(cli): check every build of the multi-image puts and how build_image captures output - The put test stopped each command at its first build, so service-db's db-web and db-mcp builds and website's frontend build were never checked. A test with the builds succeeding now asserts all of them; website's backend and frontend get separate folders so a swapped context shows. - A fake docker on PATH checks that both streams, and a byte that isn't UTF-8, reach DockerBuildError. - Drop two mock names the lift left unused. Co-Authored-By: Claude Opus 5.5 (1M context) --- tst/unit/cli/build_platform_test.py | 35 +++++++++++++++++++-- tst/unit/cli/sandbox_url_relocation_test.py | 4 +-- tst/unit/utils/docker_build_test.py | 13 ++++++++ 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/tst/unit/cli/build_platform_test.py b/tst/unit/cli/build_platform_test.py index a683572a..bbbc4cd3 100644 --- a/tst/unit/cli/build_platform_test.py +++ b/tst/unit/cli/build_platform_test.py @@ -2,7 +2,7 @@ as one line, not a traceback.""" import importlib -from unittest.mock import patch +from unittest.mock import call, patch import pytest from click.testing import CliRunner @@ -23,8 +23,9 @@ def test_platform_option_wired_on_mcp_server_put(): assert "linux/amd64" in result.output # default shown -def _dockerfile(tmp_path, name="Dockerfile"): - path = tmp_path / name +def _dockerfile(folder, name="Dockerfile"): + folder.mkdir(exist_ok=True) + path = folder / name path.write_text("FROM scratch\n") return path @@ -67,3 +68,31 @@ def test_each_put_builds_through_build_image_and_a_failed_build_is_one_line(tmp_ assert result.exit_code == 1 assert result.output.endswith(f"Error: docker build of {tag} failed (exit 1):\nboom\n") assert "Traceback" not in result.output + + +def _multi_build_puts(tmp_path): + """The puts that build more than one image: the env type each writes, and every build it makes.""" + backend, frontend = _dockerfile(tmp_path / "backend"), _dockerfile(tmp_path / "frontend") + return { + "service-db": ("agent_env.cli.env.service_db", "ServiceDBEnv", ["env", "service-db", "put", "--id", "x"], [ + (service_db.SERVICE_DB_DOCKERFILE, service_db.SERVICE_DB_DOCKERFILE.parent, service_db.SERVICE_DB_IMAGE_NAME), + (service_db.DB_WEB_DOCKERFILE, service_db.DB_WEB_DOCKERFILE.parent, service_db.DB_WEB_IMAGE_NAME), + (service_db.DB_MCP_DOCKERFILE, service_db.DB_MCP_DOCKERFILE.parent, service_db.DB_MCP_IMAGE_NAME), + ]), + "website": ("agent_env.cli.env.website", "WebsiteEnv", + ["env", "website", "put", "--id", "x", "--environment-name", "shop", "--skip-validation", + "--backend-dockerfile", str(backend), "--frontend-dockerfile", str(frontend)], + [(backend, backend.parent, "website-backend-x"), (frontend, frontend.parent, "website-frontend-x")]), + } + + +@pytest.mark.parametrize("name", ["service-db", "website"]) +def test_a_put_that_builds_several_images_builds_every_one_for_its_platform(tmp_path, name): + module, env_type, argv, builds = _multi_build_puts(tmp_path)[name] + + with patch(f"{module}.build_image") as build, patch(f"{module}.DockerImageArtifact.put"), \ + patch(f"{module}.{env_type}.put"): + result = CliRunner().invoke(cli, [*argv, "--platform", "linux/arm64"]) + + assert result.exit_code == 0, result.output + assert build.call_args_list == [call(*args, platform="linux/arm64") for args in builds] diff --git a/tst/unit/cli/sandbox_url_relocation_test.py b/tst/unit/cli/sandbox_url_relocation_test.py index d941db8d..64ed8408 100644 --- a/tst/unit/cli/sandbox_url_relocation_test.py +++ b/tst/unit/cli/sandbox_url_relocation_test.py @@ -97,7 +97,7 @@ def test_service_db_put_falls_back_to_the_config_default(): cfg = _config(default_service_db_env_id="svc-db-from-config") with patch("agent_env.cli.env.service_db.get_config", return_value=cfg), \ patch("agent_env.cli.env.service_db.DockerImageArtifact") as artifact, \ - patch("agent_env.cli.env.service_db.build_image") as run: + patch("agent_env.cli.env.service_db.build_image"): artifact.put.side_effect = RuntimeError("stop once the id is recorded") res = CliRunner().invoke(service_db, ["put"]) @@ -122,7 +122,7 @@ def test_website_browser_put_falls_back_to_the_config_default(): cfg = _config(default_website_browser_env_id="wb-from-config") with patch("agent_env.config.get_config", return_value=cfg), \ patch("agent_env.cli.env.website_browser.DockerImageArtifact") as artifact, \ - patch("agent_env.cli.env.website_browser.build_image") as run: + patch("agent_env.cli.env.website_browser.build_image"): artifact.put.side_effect = RuntimeError("stop once the id is recorded") res = CliRunner().invoke(website_browser, ["put"]) diff --git a/tst/unit/utils/docker_build_test.py b/tst/unit/utils/docker_build_test.py index 047752c1..ad06d64d 100644 --- a/tst/unit/utils/docker_build_test.py +++ b/tst/unit/utils/docker_build_test.py @@ -1,6 +1,7 @@ """``build_image``: the argv it runs, what a failed or impossible build raises, and that importing it pulls in neither the CLI nor the providers, so library code can build too.""" +import os import subprocess import sys from pathlib import Path @@ -53,6 +54,18 @@ def test_a_failed_build_raises_with_its_output(monkeypatch): assert str(failed.value) == f"docker build of my-image failed (exit 1):\n{output.rstrip()}" +def test_the_error_carries_both_of_docker_s_streams_with_undecodable_bytes_replaced(tmp_path, monkeypatch): + docker = tmp_path / "docker" + docker.write_text("#!/bin/sh\nprintf 'step 1\\n'\nprintf 'bad \\377 byte\\n' >&2\nexit 3\n") + docker.chmod(0o755) + monkeypatch.setenv("PATH", f"{tmp_path}{os.pathsep}{os.environ['PATH']}") + + with pytest.raises(DockerBuildError) as failed: + build_image(Path("Dockerfile"), Path("."), "my-image", platform=None) + + assert str(failed.value) == "docker build of my-image failed (exit 3):\nstep 1\nbad � byte" + + def test_a_missing_docker_raises_rather_than_crashing(monkeypatch): def run(argv, **_): raise FileNotFoundError(2, "No such file or directory", "docker") From e6e7f55fec88795817ff25d25bcf48ab74ad863d Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Wed, 30 Sep 2026 07:12:54 -1000 Subject: [PATCH 03/12] feat(bundle): build an agent's image from its folder's Dockerfile An agent folder with a Dockerfile and no `image` was parsed and planned as a built image, then refused by materialize. It is now built with `build_image()` on this machine, for its own platform, and written as the @local docker_image `__agent_image` (pushed to the local registry, saved as a tarball in the local object store, its build context kept for install/v1), just before the agent written over it. - The ledger tracks built images: an image is made from every file of its folder, the entry's toml too, since a Dockerfile can copy it. An unchanged folder reuses the image; a changed file rebuilds it and rewrites the agent. What the build fetches (base image, packages) isn't an input. - Without docker on PATH, a built image is refused before any write. A failed build is a BundleError naming the agent's folder, with the end of docker's output. - `materialize(on_build=...)` is called before each build; `agent-env run` prints it and labels image writes apart from their agent. - `COPY .` (or `ADD .`) keeps the whole build context in an image's saved context; the COPY-source parser dropped `.`, which left install/v1 with only the Dockerfile. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +- .../artifact/artifacts/docker_image.py | 2 + src/agent_env/bundle/ledger.py | 22 +++- src/agent_env/bundle/materialize.py | 54 ++++++++- src/agent_env/bundle/run.py | 13 ++- .../artifact/test_dockerfile_copy_sources.py | 6 + tst/unit/bundle/ledger_test.py | 27 ++++- tst/unit/bundle/materialize_test.py | 103 +++++++++++++++++- 8 files changed, 208 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index cebd52b7..7c02ae98 100644 --- a/README.md +++ b/README.md @@ -906,11 +906,11 @@ Each seed writes `-seed_<8hex>.json` with `metadata.seed` (the row) A bundle is a folder holding tasks and what they need, which `agent-env run` writes and runs without registering anything first. In this release it can hold: - `artifacts//`, whose files become a file artifact, or a file-artifact universe when there are several; -- `agents//agent.toml`, an A2A agent whose `image` names a `docker_image` artifact in a store; +- `agents//`, an A2A agent: an `agent.toml` whose `image` names a `docker_image` artifact in a store, or a `Dockerfile` the run builds the agent's image from; - `tasks/.json`, a list of steps that refer to the bundle's entities by name; - `evals/.toml`, with `tasks = [...]` naming the bundle's tasks. -An `agent.toml` takes `image`, a store id or `{ artifact = "", version = }` to pin one, and optionally `default_env_vars` (string values) and a `[metadata]` table of `default_model` and `min_disk_size_gb`. The agent's card isn't authored: the image serves it when the agent deploys. +An `agent.toml` takes `image`, a store id or `{ artifact = "", version = }` to pin one, and optionally `default_env_vars` (string values) and a `[metadata]` table of `default_model` and `min_disk_size_gb`. The agent's card isn't authored: the image serves it when the agent deploys. Leave `image` out, or the whole `agent.toml`, and the folder's `Dockerfile` builds the image instead, as described below. ```toml # agents/solver/agent.toml @@ -923,7 +923,9 @@ default_model = "claude-sonnet-4-6" A task naming `solver`, in a `deploy_agent`'s `a2a_agent_id` or a `rubrics_verifier`'s `judge_a2a_agent_id`, deploys this agent, so its image is the one pinned here. -A run that needs anything else written, such as an environment, or an image built from an agent folder's Dockerfile, is refused before any write. +An agent folder with a `Dockerfile` and no `image` is built by the run, with `docker build` on this machine and the folder as the build context, into the `@local` image `__agent_image`: pushed to the local registry, which starts on the first push, and saved as a tarball in the local object store. The run prints a line when a build starts. It is reused while every file of the folder stays the same, the `agent.toml` too, since a Dockerfile can copy it; a changed file rebuilds it, and rewrites the agent over the new image. What the build fetches, such as the base image a `FROM` tag names, isn't an input, so a newer one arrives only with the next rebuild. The image is built for this machine's platform and stays on this machine, so the local sandbox runs it; remote sandbox providers can't use it yet. The run needs `docker` on `PATH`, and refuses before any write without it. + +A run that needs anything else written, such as an environment, is refused before any write. agent-env ships one bundle, `hello`. Its task deploys a local sandbox (a work folder on this machine), loads the two files of `artifacts/greeting/` into it, and checks them with `verify_sandbox`: a file probe, and `bash check.sh`. It needs `bash` and the usual shell tools, and no Docker, model or configuration. diff --git a/src/agent_env/artifact/artifacts/docker_image.py b/src/agent_env/artifact/artifacts/docker_image.py index cdb323c1..8f4e20e9 100644 --- a/src/agent_env/artifact/artifacts/docker_image.py +++ b/src/agent_env/artifact/artifacts/docker_image.py @@ -432,6 +432,8 @@ def _parse_copy_sources(dockerfile_text: str, dockerfile_rel_path: str | None = if src.startswith("/") or "://" in src: continue src_clean = src.rstrip("/") + if src_clean in ("", "."): + return ["."] # the whole context top_dir = src_clean.split("/")[0] if top_dir and top_dir != "." and top_dir not in paths: paths.append(top_dir) diff --git a/src/agent_env/bundle/ledger.py b/src/agent_env/bundle/ledger.py index 70a36649..54382027 100644 --- a/src/agent_env/bundle/ledger.py +++ b/src/agent_env/bundle/ledger.py @@ -33,7 +33,7 @@ from agent_env.task.store import TASKS_COLLECTION from .authoring import entry_files -from .parse import Bundle, BundleKind +from .parse import CONFIG_FILES, Bundle, BundleKind from .plan import Plan, Write, folder_walk, keeps_base_from_toml from .resolve import BuiltImage @@ -108,11 +108,15 @@ def digest(self, write: Write) -> Digest | None: """What ``write`` is made from, or None when the ledger can't tell it all, so it is written every run.""" if not _tracked(write): return None - inputs = {"type": _type(write), "config": _sha256(_canonical(write.source.config)), + config = {"dockerfile": write.source.dockerfile} if isinstance(write.source, BuiltImage) else write.source.config + inputs = {"type": _type(write), "config": _sha256(_canonical(config)), "files": {}, "needs": {}, "store_refs": {}} if write.kind is BundleKind.ARTIFACT: for key, path in entry_files(self._plan.bundle.bundle, write.source.entry).items(): inputs["files"][key] = _file_sha256(path) + toml = write.source.entry.path / CONFIG_FILES[write.source.entry.kind] + if isinstance(write.source, BuiltImage) and toml.is_file(): + inputs["files"][toml.name] = _file_sha256(toml) elif write.kind in (BundleKind.ENV, BundleKind.AGENT): # An env's or agent's document records the versions of what it references, so one written anew, or # a store entity it names without a version getting a new one, means it must be written again. A @@ -190,10 +194,14 @@ def _lock_path(id: str) -> Path: def _tracked(write: Write) -> bool: - """Whether the ledger can list everything ``write`` is made from. Not yet for a built image or a skill, - nor for a type with a ``from_toml`` of its own, which may read its folder in ways it can't see. An agent - is made from its agent.toml alone: its image is a reference.""" - if isinstance(write.source, BuiltImage) or write.kind is BundleKind.SKILL: + """Whether the ledger can list everything ``write`` is made from. Not yet for a skill, nor for a type with + a ``from_toml`` of its own, which may read its folder in ways it can't see. An agent is made from its + agent.toml alone: its image is a reference. A built image is made from every file of its entry's folder, + its build context, the toml too, since a Dockerfile can copy it; what the build fetches (its base image, + packages) isn't an input.""" + if isinstance(write.source, BuiltImage): + return True + if write.kind is BundleKind.SKILL: return False if write.kind is BundleKind.ARTIFACT: return folder_walk(get_artifact_registry().get(_type(write))) is not None @@ -204,6 +212,8 @@ def _tracked(write: Write) -> bool: def _type(write: Write) -> str: + if isinstance(write.source, BuiltImage): + return "docker_image" type_ = write.source.entry.type return canonical_type(type_) if write.kind is BundleKind.ARTIFACT else type_ diff --git a/src/agent_env/bundle/materialize.py b/src/agent_env/bundle/materialize.py index 5cb31a8d..bda291c8 100644 --- a/src/agent_env/bundle/materialize.py +++ b/src/agent_env/bundle/materialize.py @@ -2,7 +2,8 @@ Materializing first refuses every write this release has no writer for, so nothing is written for a bundle that can't be written whole. It needs the CLI's namespace routing, which sends ``@local`` writes to the -``@local`` namespace's store. Holding the bundle's lock, it writes the entities in the plan's order, then +``@local`` namespace's store. Holding the bundle's lock, it writes the entities in the plan's order, an image +built from an entry's Dockerfile just before the entry, with ``docker build`` on this machine; then it builds and preflights every task before writing any of them, and writes the evals last, since they name the tasks. Each write goes through the ledger, so one whose inputs haven't changed reuses the version the bundle last wrote. A reused task keeps whatever its steps took from config when it was first written, such as a @@ -12,18 +13,22 @@ from __future__ import annotations import copy +import shutil from collections.abc import Callable, Iterator from contextlib import contextmanager from dataclasses import dataclass from typing import Any from agent_env.a2a_agent import A2AAgent +from agent_env.artifact.artifacts.docker_image import DockerImageArtifact from agent_env.artifact.registry import canonical_type, get_artifact_registry from agent_env.entity_refs import EntityRef, RefRole, ref_sites from agent_env.eval import Eval, EvalTask +from agent_env.store.ids import image_repository from agent_env.store.routing import namespace_routing_enabled from agent_env.task import Task from agent_env.task_step.registry import get_task_step_registry +from agent_env.utils.docker_build import DockerBuildError, build_image from ._fs import relative, with_article from .authoring import AuthoringContext @@ -60,11 +65,13 @@ def materialize( plan: Plan, *, on_wait: Callable[[], None] | None = None, + on_build: Callable[[Write], None] | None = None, on_write: Callable[[Materialized], None] | None = None, ) -> Materialization: """Write ``plan``'s entities, tasks and evals. The first write that fails stops it, and every earlier one stays: they're in the ledger, so the next run reuses them. ``on_wait`` is called when another run - holds a lock this one needs, and ``on_write`` after each write, reused or not.""" + holds a lock this one needs, ``on_build`` before an image is built, and ``on_write`` after each write, + reused or not.""" _refuse_unwritable(plan) if not namespace_routing_enabled(): raise RuntimeError("materializing a bundle needs namespace routing, which the agent-env CLI turns on; " @@ -82,7 +89,7 @@ def through_ledger(write: Write, write_fn: Callable[[], int]) -> None: with materializing(plan.bundle.bundle, on_wait): for write in entities: - through_ledger(write, lambda: _WRITERS[write.kind](plan, write)) + through_ledger(write, lambda: _write_entity(plan, write, on_build)) built, problems = {}, [] for write in tasks: with _noted(plan, write, "preflighting"): @@ -97,6 +104,40 @@ def through_ledger(write: Write, write_fn: Callable[[], int]) -> None: return Materialization(plan, tuple(done[_key(write)] for write in plan.writes)) +def _write_entity(plan: Plan, write: Write, on_build: Callable[[Write], None] | None) -> int: + if not isinstance(write.source, BuiltImage): + return _WRITERS[write.kind](plan, write) + if on_build is not None: + on_build(write) + return _write_built_image(plan, write) + + +def _write_built_image(plan: Plan, write: Write) -> int: + """Build the image an entry's Dockerfile describes, with the entry's folder as its build context, and + write it as a docker_image artifact: pushed to the image store, saved as a tarball, and its build context + kept for installing it into a running container.""" + image = write.source + dockerfile = image.entry.path / image.dockerfile + tag = f"{image_repository(write.id)}:bundle" + try: + build_image(dockerfile, image.entry.path, tag, platform=None) + except DockerBuildError as e: + raise BundleError([f"{_path(plan, write)}: {_tail(str(e))}"]) from None + return DockerImageArtifact.put( + id=write.id, description=f"built from {_path(plan, write)}/{image.dockerfile}", image_name=tag, + build_context_path=str(image.entry.path), dockerfile_path=str(dockerfile), + ).version + + +_BUILD_OUTPUT_TAIL_LINES = 40 + + +def _tail(message: str) -> str: + """A failed build's message, its first line and the end of docker's output.""" + first, _, output = message.partition("\n") + return "\n".join([first, *output.splitlines()[-_BUILD_OUTPUT_TAIL_LINES:]]) + + def _write_artifact(plan: Plan, write: Write) -> int: entry = write.source.entry cls = get_artifact_registry()[canonical_type(entry.type)] @@ -141,15 +182,16 @@ def _write_eval(plan: Plan, write: Write) -> int: def _refuse_unwritable(plan: Plan) -> None: problems = [f"{_path(plan, write)}: writing {what} isn't supported yet" for write in plan.writes if (what := _unwritable(write))] + if shutil.which("docker") is None: + problems.extend(f"{_path(plan, write)}: building its image from {write.source.dockerfile} needs docker, " + "and it isn't on PATH" for write in plan.writes if isinstance(write.source, BuiltImage)) if problems: raise BundleError(problems) def _unwritable(write: Write) -> str | None: """What ``write`` would write, when this release has no writer for it.""" - if isinstance(write.source, BuiltImage): - return f"an image built from {write.source.dockerfile}" - if write.kind is BundleKind.TASK: + if isinstance(write.source, BuiltImage) or write.kind is BundleKind.TASK: return None if write.kind not in _WRITERS: return with_article(write.kind.value.removesuffix("s")) diff --git a/src/agent_env/bundle/run.py b/src/agent_env/bundle/run.py index 01b2d043..ad2cf36c 100644 --- a/src/agent_env/bundle/run.py +++ b/src/agent_env/bundle/run.py @@ -30,8 +30,8 @@ from ._fs import relative from .materialize import Materialization, Materialized, materialize from .parse import BundleEntry, BundleError, BundleKind, parse_bundle -from .plan import Plan, plan_bundle -from .resolve import Reference, resolve_bundle +from .plan import Plan, Write, plan_bundle +from .resolve import BuiltImage, Reference, resolve_bundle logger = logging.getLogger(__name__) @@ -189,6 +189,7 @@ def run_bundle( materialization = materialize( plan, on_wait=lambda: say("waiting for another agent-env run to finish writing this bundle's ids"), + on_build=lambda write: say(f"{_label(plan, write)}: building with docker, which can take minutes"), on_write=lambda done: say(_written(plan, done)), ) wanted = {entry.entry.id for entry in plan.tasks} @@ -353,9 +354,15 @@ def _task_run_command(ref: Reference) -> str: def _written(plan: Plan, done: Materialized) -> str: - what = f"{_path(plan, done.write.source.entry)}: v{done.version}" + what = f"{_label(plan, done.write)}: v{done.version}" return f"{what}, unchanged" if done.reused else f"{what} ({'; '.join(done.reasons)})" +def _label(plan: Plan, write: Write) -> str: + """How a write is named: its entry's folder, and for an image built from it, which image.""" + path = _path(plan, write.source.entry) + return f"{path} ({write.source.dockerfile} image)" if isinstance(write.source, BuiltImage) else path + + def _path(plan: Plan, entry: BundleEntry) -> str: return relative(plan.bundle.bundle.root, entry.path) diff --git a/tst/unit/artifact/test_dockerfile_copy_sources.py b/tst/unit/artifact/test_dockerfile_copy_sources.py index 5029a32c..1c97a8e9 100644 --- a/tst/unit/artifact/test_dockerfile_copy_sources.py +++ b/tst/unit/artifact/test_dockerfile_copy_sources.py @@ -39,3 +39,9 @@ def test_context_relative_dockerfile_still_works(tmp_path, monkeypatch): def test_no_dockerfile_returns_dot(): assert _get_dockerfile_copy_sources(Path("/tmp"), None) == ["."] + + +def test_copying_the_whole_context_keeps_the_whole_context(tmp_path): + ctx = _make_tree(tmp_path) + (ctx / "Dockerfile").write_text("FROM python:3.11-slim\nCOPY app/ ./app/\nCOPY . /src\n") + assert _get_dockerfile_copy_sources(ctx, str(ctx / "Dockerfile")) == ["."] diff --git a/tst/unit/bundle/ledger_test.py b/tst/unit/bundle/ledger_test.py index 32580fec..c781c400 100644 --- a/tst/unit/bundle/ledger_test.py +++ b/tst/unit/bundle/ledger_test.py @@ -359,14 +359,9 @@ def test_the_ledger_never_touches_the_configured_store(bundle_dir, cli_routing): ({"artifacts/greeting/artifact.toml": 'type = "own_file_ledger_test"\n'}, None, GREETING), ({"envs/own/env.toml": 'type = "own_env_ledger_test"\n'}, {"id": "own", "type": "deploy_env", "env_id": "own"}, f"{ROOT}/own"), - ({"envs/imaged/env.toml": 'type = "imaged_ledger_test"\n', "envs/imaged/Dockerfile": "FROM scratch\n"}, - {"id": "imaged", "type": "deploy_env", "env_id": "imaged"}, f"{ROOT}/imaged__env_image"), ({"skills/pdf/SKILL.md": "---\nname: pdf\n---\n"}, {"id": "pdf", "type": "load_artifact", "env_id": "tickets", "artifact_id": "pdf"}, f"{ROOT}/pdf"), - ({"agents/solver/Dockerfile": "FROM scratch\n"}, - {"id": "agent", "type": "deploy_agent", "env_ids": ["tickets"], "a2a_agent_id": "solver"}, - f"{ROOT}/solver__agent_image"), -], ids=["artifact-with-own-from_toml", "env-with-own-from_toml", "built-image", "skill", "agent-image"]) +], ids=["artifact-with-own-from_toml", "env-with-own-from_toml", "skill"]) def test_a_write_whose_inputs_arent_tracked_is_written_every_run(bundle_dir, files, step, id): for rel, text in files.items(): (bundle_dir / rel).parent.mkdir(parents=True, exist_ok=True) @@ -378,6 +373,26 @@ def test_a_write_whose_inputs_arent_tracked_is_written_every_run(bundle_dir, fil assert _run(bundle_dir)[id].reasons == UNTRACKED +def test_a_built_image_is_made_from_every_file_of_its_folder_the_toml_too(bundle_dir): + (bundle_dir / "agents/solver").mkdir(parents=True) + (bundle_dir / "agents/solver/Dockerfile").write_text("FROM scratch\nCOPY run.sh /\n") + (bundle_dir / "agents/solver/run.sh").write_text("echo hi\n") + (bundle_dir / "tasks/t.json").write_text( + _steps({"id": "agent", "type": "deploy_agent", "env_ids": ["tickets"], "a2a_agent_id": "solver"})) + image, agent = f"{ROOT}/solver__agent_image", f"{ROOT}/solver" + + assert _run(bundle_dir)[image].reasons == ("new",) + assert _run(bundle_dir)[image].unchanged + (bundle_dir / "agents/solver/agent.toml").write_text('[metadata]\ndefault_model = "m"\n') + retoml = _run(bundle_dir) + (bundle_dir / "agents/solver/run.sh").write_text("echo bye\n") + rebuilt = _run(bundle_dir) + + assert retoml[image].reasons == ("files added: agent.toml",) and not retoml[agent].unchanged + assert rebuilt[image].reasons == ("files changed: run.sh",) + assert not rebuilt[agent].unchanged + + @pytest.mark.parametrize("other", [ "the-same-folder", "another-folder-with-its-id-root", "another-bundle-declaring-its-id", ]) diff --git a/tst/unit/bundle/materialize_test.py b/tst/unit/bundle/materialize_test.py index 04e634d7..4ae6acce 100644 --- a/tst/unit/bundle/materialize_test.py +++ b/tst/unit/bundle/materialize_test.py @@ -17,6 +17,7 @@ from agent_env.artifact.store import get_artifact_store from agent_env.bundle import BundleError from agent_env.bundle.ledger import LEDGER_COLLECTION +from agent_env.bundle import materialize as materialize_module from agent_env.bundle.materialize import materialize from agent_env.config import configure from agent_env.config.runtime import Config @@ -24,6 +25,7 @@ from agent_env.env.env import Env from agent_env.eval import Eval, EvalTask from agent_env.store import Filter +from agent_env.store.ids import image_repository from agent_env.store.routing import disable_namespace_routing from agent_env.task import Task from agent_env.task_step.task_step import TaskStep @@ -172,7 +174,7 @@ def test_a_rerun_reuses_every_version_and_an_edit_rewrites_only_what_it_changed( assert third.version_of("task", f"{ROOT}/t") == first.version_of("task", f"{ROOT}/t") -def test_what_has_no_writer_yet_is_refused_before_anything_is_written(bundle_dir): +def test_what_has_no_writer_yet_is_refused_before_anything_is_written(bundle_dir, docker_on_path): layout(bundle_dir, { "envs/tickets/Dockerfile": "FROM scratch\n", "envs/imaged/env.toml": 'type = "imaged_materialize_test"\n', @@ -193,11 +195,9 @@ def test_what_has_no_writer_yet_is_refused_before_anything_is_written(bundle_dir ]) assert sorted(_problems(lambda: _run(bundle_dir))) == [ - "agents/solver: writing an image built from Dockerfile isn't supported yet", "artifacts/base-mcp: writing a docker_image artifact isn't supported yet", "artifacts/snap: writing an environment artifact isn't supported yet", "envs/imaged: writing an env isn't supported yet", - "envs/imaged: writing an image built from Dockerfile isn't supported yet", "envs/tickets: writing an env isn't supported yet", "skills/pdf: writing a skill isn't supported yet", ] @@ -281,6 +281,103 @@ def test_an_unpinned_store_image_is_written_at_the_version_the_plan_checked(bund assert A2AAgent.get(f"{ROOT}/solver").docker_image_artifact.version == 1 +@pytest.fixture +def docker_on_path(monkeypatch): + monkeypatch.setattr(materialize_module.shutil, "which", lambda name: f"/usr/bin/{name}") + + +@pytest.fixture +def builds(monkeypatch, docker_on_path): + """Stands in for docker: each build is recorded, and the image written as a docker_image document.""" + calls = [] + + def put(id, *, description, image_name, build_context_path=None, dockerfile_path=None): + calls[-1]["put"] = (id, image_name, build_context_path) + return get_artifact_store().put_document(DockerImageArtifact( + id=id, description=description, image_name=image_name, tar_gz_s3_url=f"file:///{id}.tar.gz")) + + monkeypatch.setattr(materialize_module, "build_image", + lambda dockerfile, context, tag, *, platform: calls.append( + {"build": (dockerfile, context, tag, platform)})) + monkeypatch.setattr(materialize_module.DockerImageArtifact, "put", put) + return calls + + +def test_an_agent_folder_with_a_dockerfile_is_built_and_the_agent_written_over_its_image(bundle_dir, builds): + layout(bundle_dir, {"agents/solver/Dockerfile": "FROM scratch\nCOPY run.sh /\n", "agents/solver/run.sh": "echo hi\n"}) + _steps(bundle_dir, [{"id": "agent", "type": "deploy_agent", "env_ids": [], "a2a_agent_id": "solver"}]) + image = f"{ROOT}/solver__agent_image" + announced = [] + + first = materialize(plan_of(bundle_dir), on_build=lambda write: announced.append(write.id)) + + folder = bundle_dir / "agents/solver" + assert builds == [{"build": (folder / "Dockerfile", folder, f"{image_repository(image)}:bundle", None), + "put": (image, f"{image_repository(image)}:bundle", str(folder))}] + assert announced == [image] + assert {id: summary[:2] for id, summary in _summary(first).items() if "solver" in id} == { + image: (1, False), f"{ROOT}/solver": (1, False)} + agent = A2AAgent.get(f"{ROOT}/solver") + assert (agent.docker_image_artifact.id, agent.docker_image_artifact.version) == (image, 1) + + again = materialize(plan_of(bundle_dir), on_build=lambda write: announced.append(write.id)) + + assert len(builds) == 1 and announced == [image] + assert _summary(again)[image] == (1, True, ()) and _summary(again)[f"{ROOT}/solver"] == (1, True, ()) + + +def test_a_changed_build_context_rebuilds_the_image_and_rewrites_its_agent(bundle_dir, builds): + layout(bundle_dir, {"agents/solver/Dockerfile": "FROM scratch\nCOPY run.sh /\n", "agents/solver/run.sh": "echo hi\n"}) + _steps(bundle_dir, [{"id": "agent", "type": "deploy_agent", "env_ids": [], "a2a_agent_id": "solver"}]) + _run(bundle_dir) + (bundle_dir / "agents/solver/run.sh").write_text("echo bye\n") + + rebuilt = _summary(_run(bundle_dir)) + + assert len(builds) == 2 + assert rebuilt[f"{ROOT}/solver__agent_image"][:2] == (2, False) + assert rebuilt[f"{ROOT}/solver"][:2] == (2, False) + assert A2AAgent.get(f"{ROOT}/solver").docker_image_artifact.version == 2 + + +def test_an_agent_toml_edit_rebuilds_the_image_too_since_a_dockerfile_can_copy_it(bundle_dir, builds): + layout(bundle_dir, {"agents/solver/Dockerfile": "FROM scratch\nCOPY . /agent\n"}) + _steps(bundle_dir, [{"id": "agent", "type": "deploy_agent", "env_ids": [], "a2a_agent_id": "solver"}]) + _run(bundle_dir) + (bundle_dir / "agents/solver/agent.toml").write_text('[metadata]\ndefault_model = "claude-sonnet-4-6"\n') + + edited = _summary(_run(bundle_dir)) + + assert len(builds) == 2 + assert edited[f"{ROOT}/solver__agent_image"][:3] == (2, False, ("files added: agent.toml",)) + assert edited[f"{ROOT}/solver"][:2] == (2, False) + + +def test_a_failed_build_is_a_bundle_problem_naming_the_agent(bundle_dir, builds, monkeypatch): + def fail(dockerfile, context, tag, *, platform): + output = "\n".join(f"#{n} step" for n in range(60)) + raise materialize_module.DockerBuildError(f"docker build of {tag} failed (exit 1):\n{output}\nERROR: failed to solve") + + monkeypatch.setattr(materialize_module, "build_image", fail) + layout(bundle_dir, {"agents/solver/Dockerfile": "FROM scratch\n"}) + _steps(bundle_dir, [{"id": "agent", "type": "deploy_agent", "env_ids": [], "a2a_agent_id": "solver"}]) + + (problem,) = _problems(lambda: _run(bundle_dir)) + lines = problem.splitlines() + assert lines[0].startswith("agents/solver: docker build of ") and lines[0].endswith(" failed (exit 1):") + assert lines[1:] == [*(f"#{n} step" for n in range(21, 60)), "ERROR: failed to solve"] + + +def test_an_image_to_build_without_docker_on_path_is_refused_before_anything_is_written(bundle_dir, monkeypatch): + monkeypatch.setattr(materialize_module.shutil, "which", lambda name: None) + layout(bundle_dir, {"agents/solver/Dockerfile": "FROM scratch\n"}) + _steps(bundle_dir, [{"id": "agent", "type": "deploy_agent", "env_ids": [], "a2a_agent_id": "solver"}]) + + assert _problems(lambda: _run(bundle_dir)) == ( + "agents/solver: building its image from Dockerfile needs docker, and it isn't on PATH",) + assert not local_store().path.exists() + + def _put_image(id): get_artifact_store().put_document(DockerImageArtifact( id=id, description=id, image_name=f"{id}:v1", tar_gz_s3_url=f"file:///{id}.tar.gz")) From 3f1da31f72386fa22c67df1c212307a3f8b2b0a4 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 5 Oct 2026 18:40:48 -0700 Subject: [PATCH 04/12] fix(bundle): build an agent's image from exactly the files the ledger hashes The build ran over the agent's folder while the ledger hashed the folder less what the OS or Python leaves behind (`__pycache__`, `.DS_Store`, ...), and docker copied links as links. A change there could reuse an image built from other files. The image is now built from a temporary copy of `build_context_files` (the folder's files and its toml) and that copy is saved as its build context, so the hash, the build and the saved context hold the same files. The folder's `.dockerignore` still applies to the build. This also drops the change to the shared COPY-source parser, so `a2a-agent put`, `env mcp-server put` and GitHub builds behave as on main. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../artifact/artifacts/docker_image.py | 2 - src/agent_env/bundle/authoring.py | 10 ++++ src/agent_env/bundle/ledger.py | 15 +++--- src/agent_env/bundle/materialize.py | 33 ++++++++----- .../artifact/test_dockerfile_copy_sources.py | 6 --- tst/unit/bundle/materialize_test.py | 47 +++++++++++++++---- 6 files changed, 75 insertions(+), 38 deletions(-) diff --git a/src/agent_env/artifact/artifacts/docker_image.py b/src/agent_env/artifact/artifacts/docker_image.py index e105a954..fc86794a 100644 --- a/src/agent_env/artifact/artifacts/docker_image.py +++ b/src/agent_env/artifact/artifacts/docker_image.py @@ -429,8 +429,6 @@ def _parse_copy_sources(dockerfile_text: str, dockerfile_rel_path: str | None = if src.startswith("/") or "://" in src: continue src_clean = src.rstrip("/") - if src_clean in ("", "."): - return ["."] # the whole context top_dir = src_clean.split("/")[0] if top_dir and top_dir != "." and top_dir not in paths: paths.append(top_dir) diff --git a/src/agent_env/bundle/authoring.py b/src/agent_env/bundle/authoring.py index 8cd43299..521953ee 100644 --- a/src/agent_env/bundle/authoring.py +++ b/src/agent_env/bundle/authoring.py @@ -123,6 +123,16 @@ def entry_files(bundle: Bundle, entry: BundleEntry) -> dict[str, Path]: return _Walk(bundle, entry).files() +def build_context_files(bundle: Bundle, entry: BundleEntry) -> dict[str, Path]: + """What an image built from an entry's folder is built from: its ``entry_files`` and its own toml, which + the Dockerfile can copy too.""" + files = entry_files(bundle, entry) + toml = entry.path / CONFIG_FILES[entry.kind] + if toml.is_file(): + files[toml.name] = toml + return dict(sorted(files.items())) + + def entry_file(bundle: Bundle, entry: BundleEntry) -> tuple[str, Path]: """The one file in an entry's folder, as ``(name, path)``. Raises BundleError otherwise.""" files = entry_files(bundle, entry) diff --git a/src/agent_env/bundle/ledger.py b/src/agent_env/bundle/ledger.py index 69b3d1c2..570dc4fa 100644 --- a/src/agent_env/bundle/ledger.py +++ b/src/agent_env/bundle/ledger.py @@ -32,8 +32,8 @@ from agent_env.store.local_state import ensure_state_dir from agent_env.task.store import TASKS_COLLECTION -from .authoring import entry_files -from .parse import CONFIG_FILES, Bundle, BundleKind +from .authoring import build_context_files, entry_files +from .parse import Bundle, BundleKind from .plan import Plan, Write, folder_walk, keeps_base_from_toml from .resolve import BuiltImage @@ -122,11 +122,9 @@ def digest(self, write: Write, needs: Mapping[tuple[str, str], int]) -> Digest | inputs = {"type": _type(write), "config": _sha256(_canonical(config)), "files": {}, "needs": {}, "store_refs": {}} if write.kind is BundleKind.ARTIFACT: - for key, path in entry_files(self._plan.bundle.bundle, write.source.entry).items(): + files = build_context_files if isinstance(write.source, BuiltImage) else entry_files + for key, path in files(self._plan.bundle.bundle, write.source.entry).items(): inputs["files"][key] = _file_sha256(path) - toml = write.source.entry.path / CONFIG_FILES[write.source.entry.kind] - if isinstance(write.source, BuiltImage) and toml.is_file(): - inputs["files"][toml.name] = _file_sha256(toml) elif write.kind in (BundleKind.ENV, BundleKind.AGENT): # An env's or agent's document records the versions of what it references, so one written anew, or # a store entity it names without a version getting a new one, means it must be written again. A @@ -205,9 +203,8 @@ def _lock_path(id: str) -> Path: def _tracked(write: Write) -> bool: """Whether the ledger can list everything ``write`` is made from. Not yet for a skill, nor for a type with a ``from_toml`` of its own, which may read its folder in ways it can't see. An agent is made from its - agent.toml alone: its image is a reference. A built image is made from every file of its entry's folder, - its build context, the toml too, since a Dockerfile can copy it; what the build fetches (its base image, - packages) isn't an input.""" + agent.toml alone: its image is a reference. A built image is made from its build context, + ``build_context_files``; what the build fetches (its base image, packages) isn't an input.""" if isinstance(write.source, BuiltImage): return True if write.kind is BundleKind.SKILL: diff --git a/src/agent_env/bundle/materialize.py b/src/agent_env/bundle/materialize.py index d3b9d6b4..03ff43d4 100644 --- a/src/agent_env/bundle/materialize.py +++ b/src/agent_env/bundle/materialize.py @@ -15,9 +15,11 @@ import copy import shutil +import tempfile from collections.abc import Callable, Iterator from contextlib import contextmanager, nullcontext from dataclasses import dataclass +from pathlib import Path from typing import Any from agent_env.a2a_agent import A2AAgent @@ -33,7 +35,7 @@ from agent_env.utils.docker_build import DockerBuildError, build_image from ._fs import relative, with_article -from .authoring import AuthoringContext +from .authoring import AuthoringContext, build_context_files from .ledger import Ledger, materializing from .parse import BundleError, BundleKind from .plan import Plan, Write, folder_walk @@ -134,20 +136,25 @@ def _write_entity(plan: Plan, write: Write, on_build: Callable[[Write], None] | def _write_built_image(plan: Plan, write: Write) -> int: - """Build the image an entry's Dockerfile describes, with the entry's folder as its build context, and - write it as a docker_image artifact: pushed to the image store, saved as a tarball, and its build context - kept for installing it into a running container.""" + """Build the image an entry's Dockerfile describes and write it as a docker_image artifact: pushed to the + image store, saved as a tarball, and its build context kept for installing it into a running container. + The build context is a copy of the files the ledger hashes, ``build_context_files``, so an image the + ledger reuses was built from what it hashed.""" image = write.source - dockerfile = image.entry.path / image.dockerfile tag = f"{image_repository(write.id)}:bundle" - try: - build_image(dockerfile, image.entry.path, tag, platform=None) - except DockerBuildError as e: - raise BundleError([f"{_path(plan, write)}: {_tail(str(e))}"]) from None - return DockerImageArtifact.put( - id=write.id, description=f"built from {_path(plan, write)}/{image.dockerfile}", image_name=tag, - build_context_path=str(image.entry.path), dockerfile_path=str(dockerfile), - ).version + with tempfile.TemporaryDirectory(prefix="agent-env-build-") as staged: + context = Path(staged) + for key, path in build_context_files(plan.bundle.bundle, image.entry).items(): + (context / key).parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, context / key) + try: + build_image(context / image.dockerfile, context, tag, platform=None) + except DockerBuildError as e: + raise BundleError([f"{_path(plan, write)}: {_tail(str(e))}"]) from None + return DockerImageArtifact.put( + id=write.id, description=f"built from {_path(plan, write)}/{image.dockerfile}", image_name=tag, + build_context_path=str(context), + ).version _BUILD_OUTPUT_TAIL_LINES = 40 diff --git a/tst/unit/artifact/test_dockerfile_copy_sources.py b/tst/unit/artifact/test_dockerfile_copy_sources.py index 1c97a8e9..5029a32c 100644 --- a/tst/unit/artifact/test_dockerfile_copy_sources.py +++ b/tst/unit/artifact/test_dockerfile_copy_sources.py @@ -39,9 +39,3 @@ def test_context_relative_dockerfile_still_works(tmp_path, monkeypatch): def test_no_dockerfile_returns_dot(): assert _get_dockerfile_copy_sources(Path("/tmp"), None) == ["."] - - -def test_copying_the_whole_context_keeps_the_whole_context(tmp_path): - ctx = _make_tree(tmp_path) - (ctx / "Dockerfile").write_text("FROM python:3.11-slim\nCOPY app/ ./app/\nCOPY . /src\n") - assert _get_dockerfile_copy_sources(ctx, str(ctx / "Dockerfile")) == ["."] diff --git a/tst/unit/bundle/materialize_test.py b/tst/unit/bundle/materialize_test.py index 4dc68b1f..a7ee8521 100644 --- a/tst/unit/bundle/materialize_test.py +++ b/tst/unit/bundle/materialize_test.py @@ -5,6 +5,7 @@ import sys import textwrap import time +from pathlib import Path from typing import Literal import pytest @@ -316,21 +317,28 @@ def docker_on_path(monkeypatch): @pytest.fixture def builds(monkeypatch, docker_on_path): - """Stands in for docker: each build is recorded, and the image written as a docker_image document.""" + """Stands in for docker: each build is recorded with the files of its context, a link marked with a + trailing ``@``, and the image written as a docker_image document.""" calls = [] + def build(dockerfile, context, tag, *, platform): + calls.append({"build": (dockerfile.relative_to(context).as_posix(), _listing(context), tag, platform)}) + def put(id, *, description, image_name, build_context_path=None, dockerfile_path=None): - calls[-1]["put"] = (id, image_name, build_context_path) + calls[-1]["put"] = (id, image_name, _listing(build_context_path), dockerfile_path) return get_artifact_store().put_document(DockerImageArtifact( id=id, description=description, image_name=image_name, tar_gz_s3_url=f"file:///{id}.tar.gz")) - monkeypatch.setattr(materialize_module, "build_image", - lambda dockerfile, context, tag, *, platform: calls.append( - {"build": (dockerfile, context, tag, platform)})) + monkeypatch.setattr(materialize_module, "build_image", build) monkeypatch.setattr(materialize_module.DockerImageArtifact, "put", put) return calls +def _listing(folder): + return sorted(path.relative_to(folder).as_posix() + ("@" if path.is_symlink() else "") + for path in Path(folder).rglob("*") if not path.is_dir() or path.is_symlink()) + + def test_an_agent_folder_with_a_dockerfile_is_built_and_the_agent_written_over_its_image(bundle_dir, builds): layout(bundle_dir, {"agents/solver/Dockerfile": "FROM scratch\nCOPY run.sh /\n", "agents/solver/run.sh": "echo hi\n"}) _steps(bundle_dir, [{"id": "agent", "type": "deploy_agent", "env_ids": [], "a2a_agent_id": "solver"}]) @@ -339,9 +347,9 @@ def test_an_agent_folder_with_a_dockerfile_is_built_and_the_agent_written_over_i first = materialize(plan_of(bundle_dir), on_build=lambda write: announced.append(write.id)) - folder = bundle_dir / "agents/solver" - assert builds == [{"build": (folder / "Dockerfile", folder, f"{image_repository(image)}:bundle", None), - "put": (image, f"{image_repository(image)}:bundle", str(folder))}] + tag = f"{image_repository(image)}:bundle" + assert builds == [{"build": ("Dockerfile", ["Dockerfile", "run.sh"], tag, None), + "put": (image, tag, ["Dockerfile", "run.sh"], None)}] assert announced == [image] assert {id: summary[:2] for id, summary in _summary(first).items() if "solver" in id} == { image: (1, False), f"{ROOT}/solver": (1, False)} @@ -354,6 +362,29 @@ def test_an_agent_folder_with_a_dockerfile_is_built_and_the_agent_written_over_i assert _summary(again)[image] == (1, True, ()) and _summary(again)[f"{ROOT}/solver"] == (1, True, ()) +def test_an_image_is_built_from_the_files_the_ledger_hashes_so_a_reused_one_matches_them(bundle_dir, builds): + layout(bundle_dir, { + "agents/solver/Dockerfile": "FROM scratch\nCOPY . /agent\n", + "agents/solver/agent.toml": '[metadata]\ndefault_model = "claude-sonnet-4-6"\n', + "agents/solver/.dockerignore": "notes.md\n", + "agents/solver/lib/run.sh": "echo hi\n", + "agents/solver/__pycache__/run.cpython-312.pyc": "compiled", + "agents/solver/.DS_Store": "finder", + }) + (bundle_dir / "agents/solver/run.sh").symlink_to("lib/run.sh") + _steps(bundle_dir, [{"id": "agent", "type": "deploy_agent", "env_ids": [], "a2a_agent_id": "solver"}]) + + _run(bundle_dir) + (bundle_dir / "agents/solver/__pycache__/run.cpython-312.pyc").write_text("recompiled") + (bundle_dir / "agents/solver/.DS_Store").write_text("moved") + again = _summary(_run(bundle_dir)) + + context = [".dockerignore", "Dockerfile", "agent.toml", "lib/run.sh", "run.sh"] + assert [call["build"][1] for call in builds] == [context] + assert builds[0]["put"][2] == context + assert again[f"{ROOT}/solver__agent_image"] == (1, True, ()) + + def test_a_changed_build_context_rebuilds_the_image_and_rewrites_its_agent(bundle_dir, builds): layout(bundle_dir, {"agents/solver/Dockerfile": "FROM scratch\nCOPY run.sh /\n", "agents/solver/run.sh": "echo hi\n"}) _steps(bundle_dir, [{"id": "agent", "type": "deploy_agent", "env_ids": [], "a2a_agent_id": "solver"}]) From a44ba215f1dd8e256d47531c9e5118ce31ab3f5a Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 5 Oct 2026 18:41:43 -0700 Subject: [PATCH 05/12] fix(bundle): need docker only for an image that will be built The docker check refused every built image when docker wasn't on PATH, including one the ledger would reuse, and it ran in the dry run too. It now runs after the ledger is opened and refuses only the images it would build, still before anything is written, in the run and the dry run alike. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/bundle/materialize.py | 15 ++++++++++++--- tst/unit/bundle/materialize_test.py | 20 +++++++++++++++++--- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/agent_env/bundle/materialize.py b/src/agent_env/bundle/materialize.py index 03ff43d4..c74efbbc 100644 --- a/src/agent_env/bundle/materialize.py +++ b/src/agent_env/bundle/materialize.py @@ -89,6 +89,7 @@ def materialize( raise RuntimeError("materializing a bundle needs namespace routing, which the agent-env CLI turns on; " "call it inside agent_env.store.routing.namespace_routing()") ledger = Ledger.for_plan(plan) + _refuse_builds_without_docker(plan, ledger) entities = [write for write in plan.writes if write.kind not in (BundleKind.TASK, BundleKind.EVAL)] tasks = [write for write in plan.writes if write.kind is BundleKind.TASK] evals = [write for write in plan.writes if write.kind is BundleKind.EVAL] @@ -210,9 +211,17 @@ def _write_eval(plan: Plan, write: Write) -> int: def _refuse_unwritable(plan: Plan) -> None: problems = [f"{_path(plan, write)}: writing {what} isn't supported yet" for write in plan.writes if (what := _unwritable(write))] - if shutil.which("docker") is None: - problems.extend(f"{_path(plan, write)}: building its image from {write.source.dockerfile} needs docker, " - "and it isn't on PATH" for write in plan.writes if isinstance(write.source, BuiltImage)) + if problems: + raise BundleError(problems) + + +def _refuse_builds_without_docker(plan: Plan, ledger: Ledger) -> None: + """An image the ledger will reuse needs no docker, so only the ones it would build are refused.""" + if shutil.which("docker") is not None: + return + problems = [f"{_path(plan, write)}: building its image from {write.source.dockerfile} needs docker, and it isn't " + "on PATH" for write in plan.writes + if isinstance(write.source, BuiltImage) and not ledger.check(write, {}).unchanged] if problems: raise BundleError(problems) diff --git a/tst/unit/bundle/materialize_test.py b/tst/unit/bundle/materialize_test.py index a7ee8521..c065c637 100644 --- a/tst/unit/bundle/materialize_test.py +++ b/tst/unit/bundle/materialize_test.py @@ -180,7 +180,7 @@ def test_a_rerun_reuses_every_version_and_an_edit_rewrites_only_what_it_changed( @_RUN_OR_DRY_RUN -def test_what_has_no_writer_yet_is_refused_before_anything_is_written(bundle_dir, dry_run, docker_on_path): +def test_what_has_no_writer_yet_is_refused_before_anything_is_written(bundle_dir, dry_run): layout(bundle_dir, { "envs/tickets/Dockerfile": "FROM scratch\n", "envs/imaged/env.toml": 'type = "imaged_materialize_test"\n', @@ -427,16 +427,30 @@ def fail(dockerfile, context, tag, *, platform): assert lines[1:] == [*(f"#{n} step" for n in range(21, 60)), "ERROR: failed to solve"] -def test_an_image_to_build_without_docker_on_path_is_refused_before_anything_is_written(bundle_dir, monkeypatch): +@_RUN_OR_DRY_RUN +def test_an_image_to_build_without_docker_on_path_is_refused_before_anything_is_written( + bundle_dir, monkeypatch, dry_run, +): monkeypatch.setattr(materialize_module.shutil, "which", lambda name: None) layout(bundle_dir, {"agents/solver/Dockerfile": "FROM scratch\n"}) _steps(bundle_dir, [{"id": "agent", "type": "deploy_agent", "env_ids": [], "a2a_agent_id": "solver"}]) - assert _problems(lambda: _run(bundle_dir)) == ( + assert _problems(lambda: _run(bundle_dir, dry_run)) == ( "agents/solver: building its image from Dockerfile needs docker, and it isn't on PATH",) assert not local_store().path.exists() +@_RUN_OR_DRY_RUN +def test_an_image_the_ledger_reuses_needs_no_docker(bundle_dir, builds, monkeypatch, dry_run): + layout(bundle_dir, {"agents/solver/Dockerfile": "FROM scratch\n"}) + _steps(bundle_dir, [{"id": "agent", "type": "deploy_agent", "env_ids": [], "a2a_agent_id": "solver"}]) + _run(bundle_dir) + monkeypatch.setattr(materialize_module.shutil, "which", lambda name: None) + + assert _summary(_run(bundle_dir, dry_run))[f"{ROOT}/solver__agent_image"] == (1, True, ()) + assert len(builds) == 1 + + def _put_image(id): get_artifact_store().put_document(DockerImageArtifact( id=id, description=id, image_name=f"{id}:v1", tar_gz_s3_url=f"file:///{id}.tar.gz")) From a8c07265824f6a5c6ca9f7fba6065adf86105384 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 5 Oct 2026 18:42:07 -0700 Subject: [PATCH 06/12] fix(bundle): report a failed image push or save as a bundle problem A RuntimeError from the image store (the local registry failing to start), docker push or docker save reached the user as a traceback. It is now a problem naming the agent's folder, like a failed build. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/bundle/materialize.py | 11 +++++++---- tst/unit/bundle/materialize_test.py | 12 ++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/agent_env/bundle/materialize.py b/src/agent_env/bundle/materialize.py index c74efbbc..e3116304 100644 --- a/src/agent_env/bundle/materialize.py +++ b/src/agent_env/bundle/materialize.py @@ -152,10 +152,13 @@ def _write_built_image(plan: Plan, write: Write) -> int: build_image(context / image.dockerfile, context, tag, platform=None) except DockerBuildError as e: raise BundleError([f"{_path(plan, write)}: {_tail(str(e))}"]) from None - return DockerImageArtifact.put( - id=write.id, description=f"built from {_path(plan, write)}/{image.dockerfile}", image_name=tag, - build_context_path=str(context), - ).version + try: + return DockerImageArtifact.put( + id=write.id, description=f"built from {_path(plan, write)}/{image.dockerfile}", image_name=tag, + build_context_path=str(context), + ).version + except RuntimeError as e: # the image store, docker push or docker save + raise BundleError([f"{_path(plan, write)}: {e}"]) from None _BUILD_OUTPUT_TAIL_LINES = 40 diff --git a/tst/unit/bundle/materialize_test.py b/tst/unit/bundle/materialize_test.py index c065c637..e5015953 100644 --- a/tst/unit/bundle/materialize_test.py +++ b/tst/unit/bundle/materialize_test.py @@ -427,6 +427,18 @@ def fail(dockerfile, context, tag, *, platform): assert lines[1:] == [*(f"#{n} step" for n in range(21, 60)), "ERROR: failed to solve"] +def test_a_failed_push_is_a_bundle_problem_naming_the_agent(bundle_dir, builds, monkeypatch): + def fail(id, **kwargs): + raise RuntimeError("could not start the local registry on port 5000: port is already allocated") + + monkeypatch.setattr(materialize_module.DockerImageArtifact, "put", fail) + layout(bundle_dir, {"agents/solver/Dockerfile": "FROM scratch\n"}) + _steps(bundle_dir, [{"id": "agent", "type": "deploy_agent", "env_ids": [], "a2a_agent_id": "solver"}]) + + assert _problems(lambda: _run(bundle_dir)) == ( + "agents/solver: could not start the local registry on port 5000: port is already allocated",) + + @_RUN_OR_DRY_RUN def test_an_image_to_build_without_docker_on_path_is_refused_before_anything_is_written( bundle_dir, monkeypatch, dry_run, From 789b4391396974eed1418cd3a04d1903a3ae073b Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 5 Oct 2026 18:43:07 -0700 Subject: [PATCH 07/12] fix(artifact): write a docker image's objects under a prefix per attempt DockerImageArtifact.put uploaded its tarball and build context to write-once keys named by version, then wrote the document. A put that stopped in between, say on Ctrl-C during a long upload, left those keys behind, and every later put of the id picked the same version and failed on them. The objects now go under `ArtifactStore.attempt_prefix`, as file artifacts' already do. Only new objects' keys change; readers follow the URLs in the document. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../artifact/artifacts/docker_image.py | 22 ++++++--------- .../store/local_backends_composition_test.py | 28 +++++++++++++++++-- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/src/agent_env/artifact/artifacts/docker_image.py b/src/agent_env/artifact/artifacts/docker_image.py index fc86794a..de9fb37c 100644 --- a/src/agent_env/artifact/artifacts/docker_image.py +++ b/src/agent_env/artifact/artifacts/docker_image.py @@ -86,8 +86,12 @@ def put( store = get_artifact_store() version = store.next_version(id) + # Objects are written once, so each attempt writes under a prefix of its own: one that stopped + # before its document was written doesn't block the next. + prefix = store.attempt_prefix("docker_image", id) config = get_config() + objects = config.get_object_store_to_write(prefix, id) image_store = config.get_image_store_for(id) repository = image_repository(id) image_ref = image_store.image_ref(repository, f"v{version}") @@ -115,13 +119,8 @@ def put( stderr = save_proc.stderr.read().decode() if save_proc.stderr else "" raise RuntimeError(f"docker save {image_ref} failed: {stderr}") - tar_gz_s3_url = store.put_object_file( - artifact_type="docker_image", - id=id, - version=version, - object_name=f"{fs_safe(id)}-v{version}.tar.gz", - file_path=str(tmp_path), - content_type="application/gzip", + tar_gz_s3_url = objects.put_file_at( + f"{prefix}{fs_safe(id)}-v{version}.tar.gz", str(tmp_path), "application/gzip" ) finally: if tmp_path.exists(): @@ -139,13 +138,8 @@ def put( full_path = context_dir / rel_path if full_path.exists(): tar.add(str(full_path), arcname=rel_path) - build_context_s3_url = store.put_object_file( - artifact_type="docker_image", - id=id, - version=version, - object_name="build-context.tar.gz", - file_path=str(ctx_tmp_path), - content_type="application/gzip", + build_context_s3_url = objects.put_file_at( + f"{prefix}build-context.tar.gz", str(ctx_tmp_path), "application/gzip" ) finally: if ctx_tmp_path.exists(): diff --git a/tst/unit/store/local_backends_composition_test.py b/tst/unit/store/local_backends_composition_test.py index cecbe86d..12966764 100644 --- a/tst/unit/store/local_backends_composition_test.py +++ b/tst/unit/store/local_backends_composition_test.py @@ -229,8 +229,9 @@ def wait(self, timeout=None): @pytest.mark.parametrize("entity_id, registry, repository, tarball", [ (HOSTILE, "localhost:5000", HOSTILE_SEGMENT, - f"artifacts/docker_image/{HOSTILE_SEGMENT}/1/local-my-work-tickets-v2-ee679b8e5d3a-v1.tar.gz"), - ("legacy-image", "fake.registry", "legacy-image", "artifacts/docker_image/legacy-image/1/legacy-image-v1.tar.gz"), + (f"artifacts/docker_image/{HOSTILE_SEGMENT}/1-", "/local-my-work-tickets-v2-ee679b8e5d3a-v1.tar.gz")), + ("legacy-image", "fake.registry", "legacy-image", + ("artifacts/docker_image/legacy-image/1-", "/legacy-image-v1.tar.gz")), ]) def test_a_docker_image_names_its_repository_and_tarball_from_the_encoded_id( local_stores, cli_routing, monkeypatch, entity_id, registry, repository, tarball, @@ -247,7 +248,9 @@ def test_a_docker_image_names_its_repository_and_tarball_from_the_encoded_id( assert images.repositories + local_registry == [repository] assert pushed == [f"{registry}/{repository}:v1"] and art.image_name == pushed[0] - assert local_stores.get_object_store().get_object_key(art.tar_gz_object_url) == tarball + before, after = tarball + key = local_stores.get_object_store().get_object_key(art.tar_gz_object_url) + assert re.fullmatch(f"{re.escape(before)}[0-9a-f]{{8}}{re.escape(after)}", key), key assert gzip.decompress(docker_image.DockerImageArtifact.get(entity_id).load()) == b"image-tar-bytes" @@ -315,6 +318,25 @@ def interrupted(**kwargs): assert {key: fa.load() for key, fa in universe.get_file_artifacts().items()} == {"a.txt": b"A", "b.txt": b"B"} +def test_a_docker_image_put_that_stops_before_its_document_doesnt_block_the_next(local_stores, monkeypatch): + set_image_store(FakeImageStore()) + monkeypatch.setattr(docker_image, "_push_local_image", lambda src, ref, store: None) + monkeypatch.setattr(docker_image.subprocess, "Popen", _DockerSave) + put_tar = docker_image.DockerImageArtifact.put_tar + + def interrupted(*args, **kwargs): + raise KeyboardInterrupt + + monkeypatch.setattr(docker_image.DockerImageArtifact, "put_tar", interrupted) + with pytest.raises(KeyboardInterrupt): + docker_image.DockerImageArtifact.put(id="interrupted", description="d", image_name="src:latest") + monkeypatch.setattr(docker_image.DockerImageArtifact, "put_tar", put_tar) + art = docker_image.DockerImageArtifact.put(id="interrupted", description="d", image_name="src:latest") + + assert art.version == 1 + assert gzip.decompress(art.load()) == b"image-tar-bytes" + + def test_get_many_writes_each_local_id_into_its_own_encoded_directory(local_stores, cli_routing, tmp_path): store = local_stores.get_object_store() for uid, name in (("@local/t/A", "1"), ("@local/t/A/1", "f")): From 62cf45d31d8e23f268001aaf0b95048dc8c12f29 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 5 Oct 2026 18:45:44 -0700 Subject: [PATCH 08/12] test(bundle): what a dry run and a run say about a built image; name it with derive_id The image id a bundle builds is now spelled by `derive_id`, as every other derived id is (the same bytes as before). New tests: the dry run lists an image it would build, predicts a rebuild and the agent written over it, and builds nothing; a run says before it builds and names the image apart from its agent. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/bundle/resolve.py | 4 +- tst/unit/bundle/materialize_test.py | 16 ++++++ tst/unit/bundle/run_test.py | 76 ++++++++++++++++++++++++++++- 3 files changed, 93 insertions(+), 3 deletions(-) diff --git a/src/agent_env/bundle/resolve.py b/src/agent_env/bundle/resolve.py index db9ffe1d..21f96bac 100644 --- a/src/agent_env/bundle/resolve.py +++ b/src/agent_env/bundle/resolve.py @@ -21,7 +21,7 @@ from agent_env.env.registry import get_env_registry from agent_env.eval.eval import Eval from agent_env.plugins import _registration -from agent_env.store.ids import LOCAL_PREFIX, validate_local_id +from agent_env.store.ids import LOCAL_PREFIX, derive_id, validate_local_id from agent_env.task_step.registry import get_task_step_registry from agent_env.task_step.task_step import TaskStep, attach_retry_config, dependencies @@ -230,7 +230,7 @@ def _image(self, entry: BundleEntry, config: dict, ref: EntityRef, references: l self._problem(entry, f"{ref.path}: there is no {dockerfile!r} in this folder to build") return True role = f"{entry.kind.store}_image" if ref.path == "image" else ref.path - image = BuiltImage(f"{entry.id}__{role}", entry, dockerfile) + image = BuiltImage(derive_id(entry.id, role), entry, dockerfile) config[ref.path] = image.id self.built.append(image) references.append(Reference(EntityKind.ARTIFACT, image.id, None, image, ref.path, ref.artifact_type)) diff --git a/tst/unit/bundle/materialize_test.py b/tst/unit/bundle/materialize_test.py index e5015953..8c6b548e 100644 --- a/tst/unit/bundle/materialize_test.py +++ b/tst/unit/bundle/materialize_test.py @@ -399,6 +399,22 @@ def test_a_changed_build_context_rebuilds_the_image_and_rewrites_its_agent(bundl assert A2AAgent.get(f"{ROOT}/solver").docker_image_artifact.version == 2 +def test_a_dry_run_predicts_a_rebuild_and_its_agents_rewrite_without_building(bundle_dir, builds): + layout(bundle_dir, {"agents/solver/Dockerfile": "FROM scratch\nCOPY run.sh /\n", "agents/solver/run.sh": "echo hi\n"}) + _steps(bundle_dir, [{"id": "agent", "type": "deploy_agent", "env_ids": [], "a2a_agent_id": "solver"}]) + _run(bundle_dir) + (bundle_dir / "agents/solver/run.sh").write_text("echo bye\n") + image = f"{ROOT}/solver__agent_image" + + predicted = _summary(_run(bundle_dir, dry_run=True)) + + assert len(builds) == 1 + assert predicted[image] == (2, False, ("files changed: run.sh",)) + assert predicted[f"{ROOT}/solver"] == (2, False, (f"artifact {image} is written anew (v1 → v2)",)) + assert _summary(_run(bundle_dir)) == predicted + assert len(builds) == 2 + + def test_an_agent_toml_edit_rebuilds_the_image_too_since_a_dockerfile_can_copy_it(bundle_dir, builds): layout(bundle_dir, {"agents/solver/Dockerfile": "FROM scratch\nCOPY . /agent\n"}) _steps(bundle_dir, [{"id": "agent", "type": "deploy_agent", "env_ids": [], "a2a_agent_id": "solver"}]) diff --git a/tst/unit/bundle/run_test.py b/tst/unit/bundle/run_test.py index 64d2ae52..a3328b91 100644 --- a/tst/unit/bundle/run_test.py +++ b/tst/unit/bundle/run_test.py @@ -10,6 +10,7 @@ from click.testing import CliRunner import agent_env.bundle.run as run_module +from agent_env.bundle import materialize as materialize_module from agent_env.artifact.artifacts.docker_image import DockerImageArtifact from agent_env.artifact.artifacts.file import FileArtifact from agent_env.artifact.store import get_artifact_store @@ -19,6 +20,7 @@ from agent_env.bundle.resolve import resolve_bundle from agent_env.cli import cli from agent_env.config.runtime import Config +from agent_env.entity_refs import EntityRef from agent_env.store import Filter from agent_env.store.routing import namespace_routing from agent_env.task import Task @@ -77,6 +79,24 @@ async def execute(self, context): raise asyncio.CancelledError +class _NamesAgent(_Scored): + """Names an agent, so the agent and its image are written, without deploying it.""" + + type = "names_agent_run_test" + entity_refs = (EntityRef.agent("a2a_agent_id"),) + + def __init__(self, a2a_agent_id=None, **base): + super().__init__(**base) + self.a2a_agent_id = a2a_agent_id + + @classmethod + def from_dict(cls, data): + return cls(**cls._base_from_dict(data), a2a_agent_id=data.get("a2a_agent_id")) + + def to_dict(self): + return {**super().to_dict(), "a2a_agent_id": self.a2a_agent_id} + + def _scored(score): return json.dumps([{"id": "check", "type": "scored_run_test", "verifier_id": "v", "score": score}]) @@ -93,7 +113,7 @@ def _scored(score): @pytest.fixture(autouse=True) def registries(monkeypatch): - steps = {**Config().task_step_registry(), **{cls.type: cls for cls in (_Scored, _Failing, _Cancelled)}} + steps = {**Config().task_step_registry(), **{cls.type: cls for cls in (_Scored, _Failing, _Cancelled, _NamesAgent)}} monkeypatch.setattr(Config, "task_step_registry", lambda self: steps) monkeypatch.setattr(_Scored, "seen", []) monkeypatch.setattr(_Scored, "peak", 0) @@ -105,6 +125,25 @@ def bundle_dir(tmp_path, monkeypatch, local_stores): return layout(tmp_path / "triage", LAYOUT) +BUILT_AGENT = { + "agents/solver/Dockerfile": "FROM scratch\n", + "tasks/agent.json": json.dumps([{"id": "agent", "type": "names_agent_run_test", "a2a_agent_id": "solver"}]), +} + + +@pytest.fixture +def docker(monkeypatch): + """Stands in for docker: each build's tag is recorded, and the image written as a docker_image document.""" + builds = [] + monkeypatch.setattr(materialize_module.shutil, "which", lambda name: f"/usr/bin/{name}") + monkeypatch.setattr(materialize_module, "build_image", + lambda dockerfile, context, tag, *, platform: builds.append(tag)) + monkeypatch.setattr(materialize_module.DockerImageArtifact, "put", lambda id, **kwargs: get_artifact_store( + ).put_document(DockerImageArtifact(id=id, description="d", image_name=kwargs["image_name"], + tar_gz_s3_url=f"file:///{id}.tar.gz"))) + return builds + + def _names(runs): return [run.entry.name for run in runs] @@ -256,6 +295,21 @@ def broken(line): assert calls == ["tasks/a.json: v1, unchanged"] +def test_a_run_says_before_it_builds_an_image_and_names_the_image_apart_from_its_agent(bundle_dir, docker): + layout(bundle_dir, BUILT_AGENT) + lines = [] + + run_bundle(bundle_dir, tasks=["agent"], on_progress=lines.append) + + assert lines[:4] == [ + "agents/solver (Dockerfile image): building with docker, which can take minutes", + "agents/solver (Dockerfile image): v1 (new)", + "agents/solver: v1 (new)", + "tasks/agent.json: v1 (new)", + ] + assert len(docker) == 1 + + def test_a_cancelled_run_raises_rather_than_being_kept_as_a_failure(bundle_dir): (bundle_dir / "tasks/c.json").write_text(json.dumps([{"id": "stop", "type": "cancelled_run_test"}])) @@ -468,6 +522,26 @@ def test_the_cli_dry_run_reads_another_bundles_entity_and_says_why_an_agent_pinn ) +def test_the_cli_dry_run_lists_an_image_it_would_build_and_builds_nothing(bundle_dir, docker, quiet_logs): + layout(bundle_dir, BUILT_AGENT) + + result = CliRunner().invoke(cli, ["run", str(bundle_dir), "--task", "agent", "--dry-run"]) + + assert result.exit_code == 0, result.output + assert result.output == ( + DRY_RUN + + "agents/solver (Dockerfile image): v1 (new)\n" + "agents/solver: v1 (new)\n" + "tasks/agent.json: v1 (new)\n" + "\n" + "Would run:\n" + " tasks/agent.json v1\n" + + DRY_RUN + ) + assert docker == [] + assert not local_store().path.exists() + + def test_the_cli_dry_run_prints_a_problem_as_one_line_and_exits_1(bundle_dir, quiet_logs): result = CliRunner().invoke(cli, ["run", str(bundle_dir), "--task", "nope", "--dry-run"]) From 15f26cd4f0f751ec9e7321c61109e144b4933331 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 5 Oct 2026 20:32:28 -0700 Subject: [PATCH 09/12] fix(bundle): decide whether an image needs docker once the bundle's lock is held Another run writing the same ids may be building the image; once it releases the lock, the ledger can reuse what it built, so a run without docker waits for it instead of refusing. The check still comes before any write. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/bundle/materialize.py | 2 +- tst/unit/bundle/materialize_test.py | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/agent_env/bundle/materialize.py b/src/agent_env/bundle/materialize.py index e3116304..c4fefd60 100644 --- a/src/agent_env/bundle/materialize.py +++ b/src/agent_env/bundle/materialize.py @@ -89,7 +89,6 @@ def materialize( raise RuntimeError("materializing a bundle needs namespace routing, which the agent-env CLI turns on; " "call it inside agent_env.store.routing.namespace_routing()") ledger = Ledger.for_plan(plan) - _refuse_builds_without_docker(plan, ledger) entities = [write for write in plan.writes if write.kind not in (BundleKind.TASK, BundleKind.EVAL)] tasks = [write for write in plan.writes if write.kind is BundleKind.TASK] evals = [write for write in plan.writes if write.kind is BundleKind.EVAL] @@ -109,6 +108,7 @@ def through_ledger(write: Write, write_fn: Callable[[], int]) -> None: on_write(done[_key(write)]) with nullcontext() if dry_run else materializing(plan.bundle.bundle, on_wait): + _refuse_builds_without_docker(plan, ledger) # once another run writing these ids is done for write in entities: through_ledger(write, lambda: _write_entity(plan, write, on_build)) unwritten = {_key(item.write) for item in done.values() if not item.reused} if dry_run else set() diff --git a/tst/unit/bundle/materialize_test.py b/tst/unit/bundle/materialize_test.py index 8c6b548e..6a082343 100644 --- a/tst/unit/bundle/materialize_test.py +++ b/tst/unit/bundle/materialize_test.py @@ -1,5 +1,6 @@ """Materializing a planned bundle: what gets written, what is reused, and what is refused before any write.""" +import contextlib import json import subprocess import sys @@ -468,6 +469,29 @@ def test_an_image_to_build_without_docker_on_path_is_refused_before_anything_is_ assert not local_store().path.exists() +def test_whether_an_image_needs_docker_is_decided_once_another_run_writing_it_is_done(bundle_dir, builds, monkeypatch): + """Another run may be building the image; once its lock is released, the ledger can reuse what it built.""" + events = [] + locked = materialize_module.materializing + + @contextlib.contextmanager + def materializing(bundle, on_wait=None): + with locked(bundle, on_wait): + events.append("locked") + yield + + monkeypatch.setattr(materialize_module, "materializing", materializing) + check = materialize_module._refuse_builds_without_docker + monkeypatch.setattr(materialize_module, "_refuse_builds_without_docker", + lambda plan, ledger: events.append("docker checked") or check(plan, ledger)) + layout(bundle_dir, {"agents/solver/Dockerfile": "FROM scratch\n"}) + _steps(bundle_dir, [{"id": "agent", "type": "deploy_agent", "env_ids": [], "a2a_agent_id": "solver"}]) + + _run(bundle_dir) + + assert events == ["locked", "docker checked"] + + @_RUN_OR_DRY_RUN def test_an_image_the_ledger_reuses_needs_no_docker(bundle_dir, builds, monkeypatch, dry_run): layout(bundle_dir, {"agents/solver/Dockerfile": "FROM scratch\n"}) From d2eb329ab88c17e4b30e793606f0f03d2c411506 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 5 Oct 2026 23:24:49 -0700 Subject: [PATCH 10/12] test(bundle): build and run agents from each shape of agent Dockerfile, for real A slow-tier test runs a bundle of four agents built by docker and deployed on the local sandbox, each with a Dockerfile of another shape: - at the folder's root, with no agent.toml; - `COPY .`, with a .dockerignore, a link and what the OS or Python leaves behind; - in a subfolder named by agent.toml, whose env vars reach the container; - multi-stage under another name. Each agent replies with what its build put in it, and its task checks the reply. A rerun builds nothing, an edited file rebuilds only its own agent, and a Dockerfile that fails is one problem with docker's output. HOME stays put: docker's credential helper can hang a build when it moves. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cli/run_bundle_built_agents_test.py | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 tst/integration/cli/run_bundle_built_agents_test.py diff --git a/tst/integration/cli/run_bundle_built_agents_test.py b/tst/integration/cli/run_bundle_built_agents_test.py new file mode 100644 index 00000000..03c3f319 --- /dev/null +++ b/tst/integration/cli/run_bundle_built_agents_test.py @@ -0,0 +1,200 @@ +"""``agent-env run`` on a bundle whose agents are built from their folders' Dockerfiles, with real docker builds and +the agents deployed on the local sandbox: the shapes a Dockerfile can take in an agent folder, a rerun that builds +nothing, an edit that rebuilds only its own agent, and a build that fails. Needs a Docker daemon and the local registry.""" + +import json +import logging +import shutil + +import pytest +from click.testing import CliRunner + +from agent_env.artifact.store import reset_artifact_store +from agent_env.cli import cli +from agent_env.config import configure, reset_config +from tst.util.a2a_test_agent import AGENT_DIR, PROTOCOL_DIR + +pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] + +BASE = (AGENT_DIR / "Dockerfile").read_text().splitlines()[0] # the echo agent's pinned base image +NO_MODEL = {"LITELLM_API_KEY": "unused", "LITELLM_BASE_URL": "http://unused.invalid"} + +# The echo agent, replying with what its build put in it instead of an echo: a greeting (the GREETING env var, else +# greeting.txt beside it) and which of the files a build might leave out it can see. +AGENT_PY = (AGENT_DIR / "agent.py").read_text().replace( + 'reply = f"Echo: {prompt}"', + 'greeting = os.environ.get("GREETING") or (HERE / "greeting.txt").read_text().strip()\n' + ' seen = [name for name in ("notes.md", "secret.txt", "__pycache__") if (HERE / name).exists()]\n' + ' reply = f"{greeting} sees {seen}"', +).replace("from typing import Any\n", "import os\nfrom pathlib import Path\nfrom typing import Any\n\nHERE = Path(__file__).parent\n") +assert "HERE = Path" in AGENT_PY and "sees {seen}" in AGENT_PY + +# The steps every agent's image starts with, so the build cache shares them. +PREFIX = f'''{BASE} +WORKDIR /app +COPY agentenv-protocol/ ./agentenv-protocol/ +RUN pip install --no-cache-dir './agentenv-protocol[agent]' +''' + + +class Link(str): + """A symlink to this path, relative to the link.""" + + +def _toml(**fields): + lines = [f"{key} = {value}" for key, value in fields.items() if key != "env"] + env = {**NO_MODEL, **fields.get("env", {})} + return "\n".join([*lines, "[default_env_vars]", *(f'{key} = "{value}"' for key, value in env.items())]) + "\n" + + +AGENTS = { + # A Dockerfile at the folder's root and no agent.toml: the deploy step passes the model variables. + "plain": { + "Dockerfile": PREFIX + 'COPY agent.py greeting.txt ./\nCMD ["python", "agent.py"]\n', + "greeting.txt": "plain-v1\n", + }, + # `COPY .`: the whole folder, less what .dockerignore names. A link is copied as its target, and what the OS or + # Python leaves behind stays out of the build. + "whole": { + "Dockerfile": PREFIX + 'COPY . ./\nCMD ["python", "agent.py"]\n', + ".dockerignore": "secret.txt\n", + "data/greeting.txt": "whole-v1\n", + "greeting.txt": Link("data/greeting.txt"), + "notes.md": "kept\n", + "secret.txt": "left out by .dockerignore\n", + "__pycache__/agent.cpython-312.pyc": "left out of the build\n", + ".DS_Store": "left out of the build\n", + "agent.toml": _toml(), + }, + # A Dockerfile in a subfolder, named by agent.toml; the build context is still the agent's folder. agent.toml's + # env vars reach the container. + "subdir": { + "docker/Dockerfile": PREFIX + 'COPY agent.py ./\nCMD ["python", "agent.py"]\n', + "agent.toml": _toml(image='{ dockerfile = "docker/Dockerfile" }', env={"GREETING": "subdir-from-toml"}), + }, + # A multi-stage build under another name. + "staged": { + "Dockerfile.agent": f'{BASE} AS greeting\nRUN echo staged-v1 > /greeting.txt\n\n' + PREFIX + + 'COPY agent.py ./\nCOPY --from=greeting /greeting.txt ./greeting.txt\n' + + 'CMD ["python", "agent.py"]\n', + "agent.toml": _toml(image='{ dockerfile = "Dockerfile.agent" }'), + }, +} + +# How the run names each agent's image: its folder, and the Dockerfile it's built from. +IMAGES = {"plain": "agents/plain (Dockerfile image)", "whole": "agents/whole (Dockerfile image)", + "subdir": "agents/subdir (docker/Dockerfile image)", "staged": "agents/staged (Dockerfile.agent image)"} + +REPLIES = { + "plain": "plain-v1 sees []", + "whole": "whole-v1 sees ['notes.md']", + "subdir": "subdir-from-toml sees []", + "staged": "staged-v1 sees []", +} + + +def _task(name, reply): + deploy = {"id": "deploy", "type": "deploy_agent", "env_ids": [], "a2a_agent_id": name, "sandbox_type": "local"} + if name == "plain": + deploy["env_vars"] = NO_MODEL + return json.dumps([ + deploy, + {"id": "ask", "type": "prompt_agent", "prompt": "what did your build put in you?", "prompt_id": "ask", + "depends_on": ["deploy"]}, + {"id": "check", "type": "agent_prompt_response_verifier", "prompt_id": "ask", "verifier_id": "reply", + "criteria": [{"type": "response_contains", "needles": [reply]}], "depends_on": ["ask"]}, + ]) + + +def _agent_folder(root, name, files): + folder = root / "agents" / name + for path, text in {"agent.py": AGENT_PY, **files}.items(): + (folder / path).parent.mkdir(parents=True, exist_ok=True) + if isinstance(text, Link): + (folder / path).symlink_to(text) + else: + (folder / path).write_text(text) + shutil.copytree(PROTOCOL_DIR, folder / "agentenv-protocol", + ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "tests", "examples", "*.egg-info")) + return folder + + +@pytest.fixture +def state(monkeypatch, tmp_path): + """Local stores under this test's folder. Whatever ran, no sandbox work folder may be left behind.""" + sandboxes = tmp_path / "sandboxes" + # HOME stays: docker's credential helper (the macOS keychain, for one) can hang a build's base-image lookup + # when HOME moves. + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state")) + monkeypatch.setenv("AGENT_ENV_DOCUMENT_STORE", "local") + monkeypatch.setenv("AGENT_ENV_OBJECT_STORE", "local") + monkeypatch.setenv("AGENT_ENV_LOCAL_SANDBOX_DIR", str(sandboxes)) + configure() + reset_artifact_store() + logging.disable(logging.CRITICAL) # pytest's live logging would take CliRunner's stdout + try: + yield tmp_path + assert not sandboxes.exists() or not any(sandboxes.iterdir()), "a run left a sandbox work folder" + finally: + logging.disable(logging.NOTSET) + reset_artifact_store() + reset_config() + + +def _run(root): + return CliRunner().invoke(cli, ["run", str(root)]) + + +def test_each_shape_of_agent_dockerfile_builds_runs_and_is_rebuilt_only_when_its_folder_changes(state): + root = state / "agents-bundle" + for name, files in AGENTS.items(): + _agent_folder(root, name, files) + (root / "tasks").mkdir(parents=True, exist_ok=True) + (root / f"tasks/{name}.json").write_text(_task(name, REPLIES[name])) + + first = _run(root) + + assert first.exit_code == 0, first.output + for name in AGENTS: + assert f"{IMAGES[name]}: building with docker" in first.output, first.output + assert f"{IMAGES[name]}: v1 (new)" in first.output, first.output + assert f"tasks/{name}.json v1: passed" in first.output, first.output + + again = _run(root) + + assert again.exit_code == 0, again.output + assert "building with docker" not in again.output, again.output + for name in AGENTS: + assert f"{IMAGES[name]}: v1, unchanged" in again.output, again.output + assert f"tasks/{name}.json v1: passed" in again.output, again.output + + (root / "agents/plain/greeting.txt").write_text("plain-v2\n") + (root / "tasks/plain.json").write_text(_task("plain", "plain-v2 sees []")) + (root / "agents/whole/__pycache__/agent.cpython-312.pyc").write_text("recompiled\n") + (root / "agents/whole/.DS_Store").write_text("moved\n") + + edited = _run(root) + + assert edited.exit_code == 0, edited.output + assert f"{IMAGES['plain']}: v2 (files changed: greeting.txt)" in edited.output, edited.output + assert "agents/plain: v2 (" in edited.output, edited.output + for name in ("whole", "subdir", "staged"): + assert f"{IMAGES[name]}: v1, unchanged" in edited.output, edited.output + assert "tasks/plain.json v2: passed" in edited.output, edited.output + assert edited.output.count("building with docker") == 1, edited.output + + +def test_a_dockerfile_that_fails_to_build_is_one_problem_with_dockers_output_and_writes_no_agent(state): + root = state / "broken-bundle" + _agent_folder(root, "broken", {"Dockerfile": f"{BASE}\nRUN echo about to fail && exit 3\n"}) + (root / "tasks").mkdir() + (root / "tasks/broken.json").write_text(_task("broken", "never")) + + result = _run(root) + + assert result.exit_code == 1, result.output + assert "Error: agents/broken: docker build of " in result.output, result.output + assert "about to fail" in result.output, result.output + assert "Traceback" not in result.output, result.output + assert "agents/broken: v1" not in result.output, result.output From e6994de241616b5e99f72a8ec382ef496e811387 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 5 Oct 2026 23:42:27 -0700 Subject: [PATCH 11/12] test(bundle): close the grant server the built-agent test started The test's agents move their trajectories through this process's local grant server, which serves a certificate from the test's state root. A later test in the same process gave its agent another root's CA, so its upload failed with transfer_unavailable. The test now closes the server, and the next one to issue a grant starts it afresh. Co-Authored-By: Claude Opus 5.5 (1M context) --- tst/integration/cli/run_bundle_built_agents_test.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tst/integration/cli/run_bundle_built_agents_test.py b/tst/integration/cli/run_bundle_built_agents_test.py index 03c3f319..78e26674 100644 --- a/tst/integration/cli/run_bundle_built_agents_test.py +++ b/tst/integration/cli/run_bundle_built_agents_test.py @@ -12,6 +12,7 @@ from agent_env.artifact.store import reset_artifact_store from agent_env.cli import cli from agent_env.config import configure, reset_config +from agent_env.store.object_store.local.grant_server import grant_server from tst.util.a2a_test_agent import AGENT_DIR, PROTOCOL_DIR pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] @@ -138,6 +139,9 @@ def state(monkeypatch, tmp_path): assert not sandboxes.exists() or not any(sandboxes.iterdir()), "a run left a sandbox work folder" finally: logging.disable(logging.NOTSET) + # The agents moved their trajectories through this process's grant server, which serves a certificate from + # this test's state root; a later test's agents trust another root's CA, so it must start afresh. + grant_server(None, None).close() reset_artifact_store() reset_config() From 2d74c7b6d0e2b6c776ad1af4fd8c79d7d3beee59 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 6 Oct 2026 06:19:50 -0700 Subject: [PATCH 12/12] refactor(artifact): name a docker image put's object URLs as object URLs The locals in DockerImageArtifact.put and put_from_github held object URLs from whichever object store is configured (file://, s3://, gs://), but were still named tar_gz_s3_url and build_context_s3_url. They now match the fields they fill, tar_gz_object_url and build_context_object_url. put_tar's keyword arguments and the stored documents' keys keep their names. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../artifact/artifacts/docker_image.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/agent_env/artifact/artifacts/docker_image.py b/src/agent_env/artifact/artifacts/docker_image.py index de9fb37c..f2bb5e0d 100644 --- a/src/agent_env/artifact/artifacts/docker_image.py +++ b/src/agent_env/artifact/artifacts/docker_image.py @@ -119,14 +119,14 @@ def put( stderr = save_proc.stderr.read().decode() if save_proc.stderr else "" raise RuntimeError(f"docker save {image_ref} failed: {stderr}") - tar_gz_s3_url = objects.put_file_at( + tar_gz_object_url = objects.put_file_at( f"{prefix}{fs_safe(id)}-v{version}.tar.gz", str(tmp_path), "application/gzip" ) finally: if tmp_path.exists(): tmp_path.unlink() - build_context_s3_url = None + build_context_object_url = None if build_context_path: context_dir = Path(build_context_path) paths_to_include = _get_dockerfile_copy_sources(context_dir, dockerfile_path) @@ -138,7 +138,7 @@ def put( full_path = context_dir / rel_path if full_path.exists(): tar.add(str(full_path), arcname=rel_path) - build_context_s3_url = objects.put_file_at( + build_context_object_url = objects.put_file_at( f"{prefix}build-context.tar.gz", str(ctx_tmp_path), "application/gzip" ) finally: @@ -149,8 +149,8 @@ def put( id, description=description, image_name=image_ref, - tar_gz_s3_url=tar_gz_s3_url, - build_context_s3_url=build_context_s3_url, + tar_gz_s3_url=tar_gz_object_url, + build_context_s3_url=build_context_object_url, ) @classmethod @@ -291,7 +291,7 @@ def _signed_put(key: str) -> tuple[str, str]: ) return url, put - tar_gz_s3_url, image_put_url = await asyncio.to_thread(_signed_put, f"github-builds/{id}/{image_tag}.tar.gz") + tar_gz_object_url, image_put_url = await asyncio.to_thread(_signed_put, f"github-builds/{id}/{image_tag}.tar.gz") await sandbox.exec_script(f'curl -fsSL -X PUT --upload-file /tmp/image.tar.gz "{image_put_url}"') log("upload_context", "Uploading build context...", 80) @@ -300,7 +300,7 @@ def _signed_put(key: str) -> tuple[str, str]: copy_sources = _parse_copy_sources(dockerfile_content, df_rel) tar_paths = " ".join(shlex.quote(p) for p in copy_sources) await sandbox.exec_script(f"tar czf /tmp/build-context.tar.gz -C {context_abs} {tar_paths}") - build_context_s3_url, context_put_url = await asyncio.to_thread( + build_context_object_url, context_put_url = await asyncio.to_thread( _signed_put, f"github-builds/{id}/{image_tag}-context.tar.gz" ) await sandbox.exec_script(f'curl -fsSL -X PUT --upload-file /tmp/build-context.tar.gz "{context_put_url}"') @@ -313,8 +313,8 @@ def _signed_put(key: str) -> tuple[str, str]: id=id, description=f"Built from GitHub: {dockerfile_github_url}", image_name=image_ref, - tar_gz_s3_url=tar_gz_s3_url, - build_context_s3_url=build_context_s3_url, + tar_gz_s3_url=tar_gz_object_url, + build_context_s3_url=build_context_object_url, ) logger.info(f"put_from_github: created artifact id={artifact.id} version={artifact.version}")