feat(phase-3): web UI served same-origin by the API

Add a no-build, same-origin web frontend so an operator can open a URL,
see every agent's state, and answer a paused question with no terminal
(Phase 3 DoD). The UI is a client of the existing API — no endpoint,
schema, or auth change — so the 106 existing tests pass unchanged.

- app.py serves the bundled UI from / and /static, gated on UI_ENABLED
  (default on); optional CORS_ORIGINS (default empty => no middleware)
  for hosting the UI on a separate origin. Dedicated /static prefix +
  explicit / route so API routes are never shadowed. Zero new runtime
  deps (StaticFiles/CORSMiddleware ship with Starlette).
- static/: vanilla fetch + plain CSS + vendored alpine.min.js (v3.14.8,
  no CDN). Token captured once into localStorage; all API values render
  via x-text (never x-html) to block agent-authored markup injection.
  Project switcher, agent list, checkmark panel, paginated log, shared
  feed, and Answer / Answer & Resume. Polling scoped to the selected
  agent to avoid an N+1 over the fleet.
- config.py: ui_enabled, cors_origins (+ cors_origin_list); documented
  in .env.example.
- tests/test_api_ui.py: serving, unauthenticated shell, non-shadowing
  401 regression, CORS toggle, UI_ENABLED=false. 114 tests, ruff clean.

The static assets ship in the wheel by default (they live inside the
packaged src/handler tree) — no force-include needed.
This commit is contained in:
2026-07-09 20:43:23 -04:00
parent 6fb26115ce
commit bbb01d0882
10 changed files with 881 additions and 5 deletions
+2
View File
@@ -31,6 +31,8 @@ dev = [
handler = "handler.control.cli:main"
[tool.hatch.build.targets.wheel]
# The bundled web UI (src/handler/api/static/*) ships automatically: it lives inside the
# packaged `src/handler` tree, and hatchling includes non-.py files there by default.
packages = ["src/handler"]
[tool.pytest.ini_options]