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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/scripts/check_explorer_ui.py
Original file line number Diff line number Diff line change
@@ -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:]))
13 changes: 13 additions & 0 deletions .github/workflows/local-backends.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Source files escape archive check

If the sdist contains explorer/ui/ and tar still has paths to list, grep -q stops at the first match and closes the pipe. With pipefail, tar can then fail from a broken pipe, making the if condition false. The job passes instead of flagging the files. Let grep read the full listing.

Suggested change
if tar tzf dist/*.tar.gz | grep -q '/explorer/ui/'; then echo "::error::the sdist includes the UI's source tree"; exit 1; fi
if tar tzf dist/*.tar.gz | grep '/explorer/ui/' >/dev/null; then echo "::error::the sdist includes the UI's source tree"; exit 1; fi
Prompt To Fix With AI
This is a comment left during a code review.
Path: .github/workflows/local-backends.yml
Line: 87

Comment:
**Source files escape archive check**

If the sdist contains `explorer/ui/` and `tar` still has paths to list, `grep -q` stops at the first match and closes the pipe. With `pipefail`, `tar` can then fail from a broken pipe, making the `if` condition false. The job passes instead of flagging the files. Let `grep` read the full listing.

```suggestion
          if tar tzf dist/*.tar.gz | grep '/explorer/ui/' >/dev/null; then echo "::error::the sdist includes the UI's source tree"; exit 1; fi
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Cursor Fix in Claude Code Fix in Codex

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. Both checks now run .github/scripts/check_explorer_ui.py. It reads index.html from the wheel and fails unless every /_next/ script, stylesheet and font that page references is in the wheel too. Writing it surfaced a subtlety: the catch-all page's chunk is referenced percent-encoded (%5B%5B...slug%5D%5D) but stored as [[...slug]], so references are decoded before the lookup. Against real wheels: the full UI wheel passes, the same wheel with _next/ stripped fails and names all 14 missing assets, and the current UI-less 0.9.1265 wheel fails on the missing index.html. tst/unit/test_check_explorer_ui.py covers those cases plus the encoded reference.


integration-local:
runs-on: ubuntu-latest
Expand Down
38 changes: 37 additions & 1 deletion .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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: 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
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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. |

Expand Down
9 changes: 7 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,17 @@ 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"]
# 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]
members = ["packages/agentenv-protocol"]
Expand Down
63 changes: 63 additions & 0 deletions tst/unit/test_check_explorer_ui.py
Original file line number Diff line number Diff line change
@@ -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 = (
'<html><head><link rel="stylesheet" href="/_next/static/css/app.css"/>'
'<link rel="preload" href="/_next/static/media/font.woff2"/><link rel="icon" href="/favicon.ico"/></head>'
'<body><a href="/docs">Docs</a><script src="/_next/static/chunks/main.js?dpl=1"></script></body></html>'
)
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 = '<script src="/_next/static/chunks/pages/%5B%5B...slug%5D%5D-abc.js"></script>'
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": "<html><body>placeholder</body></html>"})

assert check_explorer_ui.ui_problems(wheel) == [f"{STATIC}index.html references no /_next/ assets"]
Loading