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 afd250c9..fbab3b37 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 + 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: runs-on: ubuntu-latest diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 55860ba2..dc7c1632 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: 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/.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..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`. | +| `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 f743d2de..ed2b7d03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] 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"]