From 8127141aaccf1c0ebc75959d7eda1b97b2e7a5f1 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Fri, 2 Oct 2026 07:58:49 -1000 Subject: [PATCH 1/3] build: ship the explorer's web UI in the agentenv-framework wheel The wheel carried only the explorer's API: the server serves a built UI from agent_env/explorer/static/, but no release step built it, so `agent-env up` from PyPI served JSON at / and printed a /docs link that 404s. The publish workflow now builds the UI's static export in its own job, which can't mint a PyPI token and uses no cache, so no npm package runs where it could publish. The framework job copies the export into explorer/static/ before building and fails if the wheel lacks index.html, and the protocol job waits for the UI too, so a failed UI build publishes nothing. explorer/static/ is git-ignored and listed as a hatch artifact. The sdist excludes the UI's node_modules, .next and out, which hatch otherwise packs because it reads only the root .gitignore: building in place made a 159 MB sdist, over PyPI's limit. The ui job on pull requests packages the export the same way and checks both, so a regression shows up before a release. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/local-backends.yml | 13 ++++++++++ .github/workflows/publish-pypi.yml | 38 +++++++++++++++++++++++++++- .gitignore | 3 +++ AGENTS.md | 2 +- pyproject.toml | 8 ++++-- 5 files changed, 60 insertions(+), 4 deletions(-) diff --git a/.github/workflows/local-backends.yml b/.github/workflows/local-backends.yml index afd250c9..25e6477a 100644 --- a/.github/workflows/local-backends.yml +++ b/.github/workflows/local-backends.yml @@ -72,6 +72,19 @@ jobs: run: npm run test:smoke - name: Static export run: npm run build:static + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + # The release copies the static export into explorer/static/ before building the wheel; a packaging + # regression would otherwise surface only at release time, after the protocol has published. + - name: Package the UI the way the release does + working-directory: . + run: | + python -m pip install --quiet --index-url https://pypi.org/simple uv + cp -R src/agent_env/explorer/ui/out src/agent_env/explorer/static + uv build --no-sources --out-dir dist + unzip -l dist/*.whl | grep -q 'agent_env/explorer/static/index.html' || { echo "::error::the wheel has no agent_env/explorer/static/index.html"; exit 1; } + if tar tzf dist/*.tar.gz | grep -q '/explorer/ui/node_modules/'; then echo "::error::the sdist includes the UI's node_modules"; exit 1; fi integration-local: runs-on: ubuntu-latest diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 55860ba2..ccd6b718 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -14,7 +14,37 @@ permissions: contents: read jobs: + explorer-ui: + # The explorer's built web UI ships inside the agentenv-framework wheel. It is built here, in a job that can't + # mint a PyPI token and uses no cache, so no npm package runs where it could publish; `framework` takes only + # the output. + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + defaults: + run: + working-directory: src/agent_env/explorer/ui + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "22" + - run: npm ci + - name: Build the static export + run: npm run build:static + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: explorer-ui + path: src/agent_env/explorer/ui/out + if-no-files-found: error + retention-days: 1 + protocol: + # Nothing publishes unless the whole release, UI included, has built. + needs: explorer-ui runs-on: ubuntu-latest timeout-minutes: 15 environment: pypi-protocol @@ -42,7 +72,7 @@ jobs: run: uv publish --trusted-publishing always --check-url https://pypi.org/simple/ dist/* framework: - needs: protocol + needs: [protocol, explorer-ui] runs-on: ubuntu-latest timeout-minutes: 15 environment: pypi @@ -58,6 +88,10 @@ jobs: python-version: "3.12" - name: Install uv from public PyPI run: python -m pip install --quiet --index-url https://pypi.org/simple uv + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: explorer-ui + path: src/agent_env/explorer/static - name: Build agentenv-framework, pinned to this release's agentenv-framework-protocol run: | protocol=$(sed -n 's/^version = "\(.*\)"/\1/p' packages/agentenv-protocol/pyproject.toml) @@ -66,6 +100,8 @@ jobs: sed -i "s/\"agentenv-framework-protocol[^\"]*\"/\"agentenv-framework-protocol==${protocol}\"/" pyproject.toml grep -q "\"agentenv-framework-protocol==${protocol}\"" pyproject.toml uv build --no-sources --out-dir dist + - name: Check the wheel carries the explorer UI + run: unzip -l dist/*.whl | grep -q 'agent_env/explorer/static/index.html' || { echo "::error::the wheel has no agent_env/explorer/static/index.html"; exit 1; } - name: Check its metadata renders run: uvx --index-url https://pypi.org/simple twine check --strict dist/* - name: Publish agentenv-framework diff --git a/.gitignore b/.gitignore index cbf4d7dc..acf3d125 100644 --- a/.gitignore +++ b/.gitignore @@ -167,3 +167,6 @@ cython_debug/ # Generated local-store state (stores default local; see runtime._ensure_agentenv_gitignore) /.agentenv/ + +# The explorer's built web UI, copied in by the release build (.github/workflows/publish-pypi.yml) +src/agent_env/explorer/static/ diff --git a/AGENTS.md b/AGENTS.md index 16266609..f08765f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,7 @@ and deserialized through a registry. | `providers/` | `providers/sandbox_providers/` holds the sandbox providers `local`, `modal`, `modal_vm`, `e2b`; `[sandbox] default` and `agent_default` accept a comma-separated fallback chain. `providers/env_providers/` holds the environment providers: `EnvironmentProvider` (an env's containers and state store) and `EnvironmentGatewayProvider`, which renders a docker-compose for the gateway and its MCP servers inside the sandbox; `providers/env_state/` holds env-state providers (`local_postgres` built in). | | `a2a_agent/` | The `A2AAgent` entity (`a2a_agent`), its stores and the validator steps. The protocol package provides the agent-side framework. | | `runner/` | The `[runner]` seam: `Runner.submit()` returns `(run_id, instance_id)`; `LocalRunner` is built in. | -| `explorer/` | Optional local web UI: `agent-env up`, needs the `explorer` extra, binds loopback `:8234`. | +| `explorer/` | Optional local web UI: `agent-env up`, needs the `explorer` extra, binds loopback `:8234`. The UI's Next.js source is `explorer/ui/`; the release builds its static export into the wheel as `explorer/static/`, which is git-ignored, and a source checkout without it serves only the API. | | `cli/` | Click CLI with the groups `a2a-agent`, `artifact`, `config`, `env`, `eval`, `plugin`, `run`, `task`, `up`. | | `examples/` | The bundles agent-env ships (`hello`), registered under `agent_env.bundles` in `pyproject.toml`. Tests run them, but they are examples for users, not fixtures. | diff --git a/pyproject.toml b/pyproject.toml index f743d2de..b16f4ef9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,12 +87,16 @@ Issues = "https://github.com/scaleapi/agentenv-framework/issues" [tool.hatch.build.targets.wheel] packages = ["src/agent_env"] # The explorer UI *source* lives at src/agent_env/explorer/ui/ (a Next.js project). -# Keep it out of the wheel — only its built output ships, copied to explorer/static/ -# by the UI build step. node_modules/out/.next are already .gitignore-excluded. +# Keep it out of the wheel — only its built output ships: the release copies the static +# export to explorer/static/, which is git-ignored and so listed as an artifact. exclude = ["src/agent_env/explorer/ui"] +artifacts = ["src/agent_env/explorer/static"] [tool.hatch.build.targets.sdist] only-include = ["src", "tst", "packages/agentenv-protocol", ".github/scripts"] +# Hatch reads only the root .gitignore, so a UI build's leftovers would otherwise land in the sdist. +exclude = ["src/agent_env/explorer/ui/node_modules", "src/agent_env/explorer/ui/.next", "src/agent_env/explorer/ui/out"] +artifacts = ["src/agent_env/explorer/static"] [tool.uv.workspace] members = ["packages/agentenv-protocol"] From 9626fccba8293acb8b98c177de05343f9ed61f1c Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Fri, 2 Oct 2026 08:13:28 -1000 Subject: [PATCH 2/3] build: leave the explorer UI's source out of the sdist too Hatch reads only the root .gitignore, so packing src/agent_env/explorer/ui/ took whatever a working tree held there: node_modules, build output, or an .env.local with credentials. The sdist now ships the UI the way the wheel does, as its built export, and its source stays in the repository. The sdist's copy of the UI source was already incomplete: the root .gitignore's lib/ dropped all of ui/src/lib/, so it could not rebuild the UI anyway. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/local-backends.yml | 2 +- AGENTS.md | 2 +- pyproject.toml | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/local-backends.yml b/.github/workflows/local-backends.yml index 25e6477a..2bdeb208 100644 --- a/.github/workflows/local-backends.yml +++ b/.github/workflows/local-backends.yml @@ -84,7 +84,7 @@ jobs: cp -R src/agent_env/explorer/ui/out src/agent_env/explorer/static uv build --no-sources --out-dir dist unzip -l dist/*.whl | grep -q 'agent_env/explorer/static/index.html' || { echo "::error::the wheel has no agent_env/explorer/static/index.html"; exit 1; } - if tar tzf dist/*.tar.gz | grep -q '/explorer/ui/node_modules/'; then echo "::error::the sdist includes the UI's node_modules"; exit 1; fi + if tar tzf dist/*.tar.gz | grep -q '/explorer/ui/'; then echo "::error::the sdist includes the UI's source tree"; exit 1; fi integration-local: runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index f08765f2..f42f1c5b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,7 @@ and deserialized through a registry. | `providers/` | `providers/sandbox_providers/` holds the sandbox providers `local`, `modal`, `modal_vm`, `e2b`; `[sandbox] default` and `agent_default` accept a comma-separated fallback chain. `providers/env_providers/` holds the environment providers: `EnvironmentProvider` (an env's containers and state store) and `EnvironmentGatewayProvider`, which renders a docker-compose for the gateway and its MCP servers inside the sandbox; `providers/env_state/` holds env-state providers (`local_postgres` built in). | | `a2a_agent/` | The `A2AAgent` entity (`a2a_agent`), its stores and the validator steps. The protocol package provides the agent-side framework. | | `runner/` | The `[runner]` seam: `Runner.submit()` returns `(run_id, instance_id)`; `LocalRunner` is built in. | -| `explorer/` | Optional local web UI: `agent-env up`, needs the `explorer` extra, binds loopback `:8234`. The UI's Next.js source is `explorer/ui/`; the release builds its static export into the wheel as `explorer/static/`, which is git-ignored, and a source checkout without it serves only the API. | +| `explorer/` | Optional local web UI: `agent-env up`, needs the `explorer` extra, binds loopback `:8234`. The UI's Next.js source is `explorer/ui/`; the release builds its static export into both distributions as `explorer/static/`, which is git-ignored, while the UI source stays in the repository; a source checkout without the export serves only the API. | | `cli/` | Click CLI with the groups `a2a-agent`, `artifact`, `config`, `env`, `eval`, `plugin`, `run`, `task`, `up`. | | `examples/` | The bundles agent-env ships (`hello`), registered under `agent_env.bundles` in `pyproject.toml`. Tests run them, but they are examples for users, not fixtures. | diff --git a/pyproject.toml b/pyproject.toml index b16f4ef9..ed2b7d03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,8 +94,9 @@ artifacts = ["src/agent_env/explorer/static"] [tool.hatch.build.targets.sdist] only-include = ["src", "tst", "packages/agentenv-protocol", ".github/scripts"] -# Hatch reads only the root .gitignore, so a UI build's leftovers would otherwise land in the sdist. -exclude = ["src/agent_env/explorer/ui/node_modules", "src/agent_env/explorer/ui/.next", "src/agent_env/explorer/ui/out"] +# The UI ships as its built export, as in the wheel; its source stays in the repository. Hatch reads only the root +# .gitignore, so including ui/ would pack a working tree's node_modules, build output and .env.local. +exclude = ["src/agent_env/explorer/ui"] artifacts = ["src/agent_env/explorer/static"] [tool.uv.workspace] From b968051770faab6e5af18c77c4b358f5f9bbe45e Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Fri, 2 Oct 2026 08:27:45 -1000 Subject: [PATCH 3/3] build: check every asset the explorer's index.html loads is in the wheel Both UI checks looked only for index.html, so a wheel missing the _next/ scripts, stylesheets and fonts would pass and serve a page that never loads. .github/scripts/check_explorer_ui.py reads index.html from the wheel and fails unless every /_next/ asset it references is there; references are percent-decoded, since the catch-all page's chunk is referenced as %5B%5B...slug%5D%5D but stored as [[...slug]]. The release and the ui job both run it, and its unit tests cover a complete wheel, missing assets, a missing index.html, an index with no built assets and the encoded reference. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/check_explorer_ui.py | 45 ++++++++++++++++++++ .github/workflows/local-backends.yml | 2 +- .github/workflows/publish-pypi.yml | 2 +- tst/unit/test_check_explorer_ui.py | 63 ++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 .github/scripts/check_explorer_ui.py create mode 100644 tst/unit/test_check_explorer_ui.py diff --git a/.github/scripts/check_explorer_ui.py b/.github/scripts/check_explorer_ui.py new file mode 100644 index 00000000..4c9c17f7 --- /dev/null +++ b/.github/scripts/check_explorer_ui.py @@ -0,0 +1,45 @@ +"""Fail unless a built agentenv-framework wheel carries the explorer's web UI: ``agent_env/explorer/static/index.html`` +and every ``/_next/`` script, stylesheet and font it references, so ``agent-env up`` serves a page that loads. + +Usage: ``python .github/scripts/check_explorer_ui.py WHEEL``. Run by the release and by the ``ui`` job on pull requests. +""" + +from __future__ import annotations + +import re +import sys +import zipfile +from pathlib import Path +from urllib.parse import unquote + +STATIC = "agent_env/explorer/static/" +_ASSET = re.compile(r'(?:src|href)="/(_next/[^"?#]+)') + + +def ui_problems(wheel: Path) -> list[str]: + """What the wheel's explorer UI is missing; empty when index.html and every asset it references are there.""" + with zipfile.ZipFile(wheel) as archive: + names = set(archive.namelist()) + if STATIC + "index.html" not in names: + return [f"{STATIC}index.html is missing"] + # The catch-all page's chunk is referenced percent-encoded (%5B%5B...slug%5D%5D) but stored as [[...slug]]. + assets = sorted({unquote(a) for a in _ASSET.findall(archive.read(STATIC + "index.html").decode())}) + if not assets: + return [f"{STATIC}index.html references no /_next/ assets"] + return [f"{STATIC}{asset} is missing" for asset in assets if STATIC + asset not in names] + + +def main(argv: list[str]) -> int: + if len(argv) != 1: + print("usage: check_explorer_ui.py WHEEL", file=sys.stderr) + return 2 + problems = ui_problems(Path(argv[0])) + for problem in problems: + print(f"::error::the wheel's explorer UI is incomplete: {problem}") + if not problems: + print(f"{argv[0]}: the explorer UI's index.html and every asset it references are in the wheel") + return 1 if problems else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/.github/workflows/local-backends.yml b/.github/workflows/local-backends.yml index 2bdeb208..fbab3b37 100644 --- a/.github/workflows/local-backends.yml +++ b/.github/workflows/local-backends.yml @@ -83,7 +83,7 @@ jobs: python -m pip install --quiet --index-url https://pypi.org/simple uv cp -R src/agent_env/explorer/ui/out src/agent_env/explorer/static uv build --no-sources --out-dir dist - unzip -l dist/*.whl | grep -q 'agent_env/explorer/static/index.html' || { echo "::error::the wheel has no agent_env/explorer/static/index.html"; exit 1; } + python .github/scripts/check_explorer_ui.py dist/*.whl if tar tzf dist/*.tar.gz | grep -q '/explorer/ui/'; then echo "::error::the sdist includes the UI's source tree"; exit 1; fi integration-local: diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index ccd6b718..dc7c1632 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -101,7 +101,7 @@ jobs: grep -q "\"agentenv-framework-protocol==${protocol}\"" pyproject.toml uv build --no-sources --out-dir dist - name: Check the wheel carries the explorer UI - run: unzip -l dist/*.whl | grep -q 'agent_env/explorer/static/index.html' || { echo "::error::the wheel has no agent_env/explorer/static/index.html"; exit 1; } + run: python .github/scripts/check_explorer_ui.py dist/*.whl - name: Check its metadata renders run: uvx --index-url https://pypi.org/simple twine check --strict dist/* - name: Publish agentenv-framework diff --git a/tst/unit/test_check_explorer_ui.py b/tst/unit/test_check_explorer_ui.py new file mode 100644 index 00000000..2228b7fe --- /dev/null +++ b/tst/unit/test_check_explorer_ui.py @@ -0,0 +1,63 @@ +"""The wheel check in ``.github/scripts/check_explorer_ui.py``: the explorer's index.html and every ``/_next/`` asset +it references must be in the built wheel.""" + +from __future__ import annotations + +import importlib.util +import zipfile +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parents[2] / ".github" / "scripts" / "check_explorer_ui.py" +_spec = importlib.util.spec_from_file_location("check_explorer_ui", SCRIPT) +check_explorer_ui = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(check_explorer_ui) + +STATIC = "agent_env/explorer/static/" +INDEX = ( + '' + '' + 'Docs' +) +ASSETS = ["_next/static/css/app.css", "_next/static/media/font.woff2", "_next/static/chunks/main.js"] + + +def _wheel(tmp_path: Path, files: dict[str, str]) -> Path: + wheel = tmp_path / "agentenv_framework-1.0-py3-none-any.whl" + with zipfile.ZipFile(wheel, "w") as archive: + for name, text in files.items(): + archive.writestr(name, text) + return wheel + + +def test_a_wheel_with_index_html_and_every_asset_it_references_passes(tmp_path): + wheel = _wheel(tmp_path, {STATIC + "index.html": INDEX, **{STATIC + asset: "x" for asset in ASSETS}}) + + assert check_explorer_ui.ui_problems(wheel) == [] + + +def test_each_referenced_asset_the_wheel_leaves_out_is_named(tmp_path): + wheel = _wheel(tmp_path, {STATIC + "index.html": INDEX, STATIC + ASSETS[0]: "x"}) + + assert check_explorer_ui.ui_problems(wheel) == [ + f"{STATIC}_next/static/chunks/main.js is missing", + f"{STATIC}_next/static/media/font.woff2 is missing", + ] + + +def test_a_percent_encoded_reference_matches_the_file_stored_under_its_literal_name(tmp_path): + index = '' + wheel = _wheel(tmp_path, {STATIC + "index.html": index, STATIC + "_next/static/chunks/pages/[[...slug]]-abc.js": "x"}) + + assert check_explorer_ui.ui_problems(wheel) == [] + + +def test_a_wheel_without_index_html_fails(tmp_path): + wheel = _wheel(tmp_path, {"agent_env/__init__.py": ""}) + + assert check_explorer_ui.ui_problems(wheel) == [f"{STATIC}index.html is missing"] + + +def test_an_index_html_that_references_no_built_assets_fails(tmp_path): + wheel = _wheel(tmp_path, {STATIC + "index.html": "placeholder"}) + + assert check_explorer_ui.ui_problems(wheel) == [f"{STATIC}index.html references no /_next/ assets"]