phase: 01_runnable_service_scaffold

**Phase 01 final verification pass — all criteria green**

- Verified (nothing left to implement; all 4 task files already in `complete/`): toolchain lock, typed `Settings`+`.env.example`, debugpy bootstrap, app factory, CLI (`--host/--port/--reload/--levels-dir`, `python -m undercooked`), static shell + `/static`, security headers, no-CDN/doc guards, `validate.sh`.
- `uv sync --extra dev` → exit 0; `uv.lock` present (untracked → harness commits it with the phase).
- `uv run undercooked --host 127.0.0.1 --port 8000` → `curl /healthz` = 200 `{"status":"ok","version":"0.1.0","rooms":0}` (superset of the criterion's body — now documented in README); `curl /` = 200 shell HTML; static css/js 200; CSP/nosniff/no-referrer/DENY on 200, 404 and 405 too.
- `DEBUGPY=1`: port 5678 is held on this machine by the user's own podman dev-container debugpy adapter — **no live change made/killed**; verified via `DEBUGPY=1 DEBUGPY_PORT=5679` → listener opens, `/healthz` still 200; the occupied-port case degraded gracefully as designed (warning, server serves). Bad config → one line + exit 2 ✓.
- `bash .agents/validate.sh` (root **and** `.agents/`) → exit 0: `ruff check .` 0 errors, `pyright` 0 errors, levels-CLI notice-skip (matches README prose), **284 passed** full suite incl. 10 Playwright e2e, coverage **100.00%** (floor 90), repo hygiene clean. `uv run pytest -m e2e` → 10 passed.
- Docs fixed: README Status → phase 01 complete + next pending 02 (was pointing at `todo/01…`), documented real `/healthz` body, precedence now "CLI flags > env > .env > default", added the PLAN §13-promised **How to play** section (honest about shell-only today), Layout rows marked what exists vs phases 02/07. AGENTS.md: recorded the `data:`-URL exemption in the no-CDN rule (test allows it) and matched the session-token rule to the browser's WebCrypto/base64url implementation. Also corrected a stale "session-wide chromium" docstring in `tests/e2e/conftest.py` (fixtures are function-scoped).
- Deviations: no container build run (needs an image pull; not a phase criterion — `Containerfile`/`compose.yaml` reviewed and consistent with `docs/DEPLOYMENT.md`); `HEAD /` is 405 (routes are GET-only by design; health checks use GET). No git index/history commands run.
- Next pending phase: `02_level_content_and_validation`.
This commit is contained in:
2026-10-06 01:51:20 -04:00
parent f84c6ebaef
commit 6fd83446ec
43 changed files with 5478 additions and 30 deletions
@@ -1,48 +0,0 @@
# Phase 01 — Runnable service scaffold
**Story:** `US-01_create_anonymous_lobby` (partial — landing surface only)
**Context:** `.agents/PLAN.md` §4 (app/static/HTTP boundary), A1, A2, A12, A17, A18, A20
## Objective
Turn the empty repository into a launchable FastAPI service that serves the client
shell, exposes `/healthz`, enforces the no-external-asset rule, and has its quality
toolchain wired end to end. Nothing about gameplay exists yet; the app must still boot,
serve, and pass every check.
## Dependencies
- none (first phase; `pyproject.toml`, `Containerfile`, `compose.yaml`, `README.md`,
`AGENTS.md`, `.agents/PLAN.md` already exist in the repo)
## Tasks
1. `01_lock_toolchain_and_packages.md` — deps resolve, `uv sync` clean, test tree + markers, first passing test
2. `02_config_and_debugpy.md` — typed env config, `.env.example`, debugpy bootstrap
3. `03_fastapi_app_and_static_shell.md` — app factory, CLI entry point, static shell page, security headers
4. `04_no_cdn_guard_and_docs.md` — no-external-asset test, header tests, README/AGENTS verified
## Testing & Quality
- Unit: config parsing/defaults/overrides; static asset scan finds zero external URLs;
security headers present; `/healthz` returns 200 with a JSON body.
- Integration: app boots through `create_app()` and serves `/` and `/static/*`.
- Coverage: **>90%** on new/modified code (`config.py` and `server/app.py` fully covered).
- The phase is "Complete" only when the **entire** project test suite passes 100%
(every test in the project, not just this phase's).
- Gate commands, all green: `uv run ruff check .`, `uv run pyright`, `uv run pytest`.
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it
will stand after this phase — fixing every inconsistency found, **including
pre-existing ones not caused by this phase** (e.g. commands or variable names that
drifted from `config.py`).
- Tasks update `README.md`/`AGENTS.md` immediately when their own changes are
user-visible (setup, commands, configuration, features, conventions).
## Completion Criteria
- [ ] `uv sync --extra dev` succeeds and `uv.lock` is committed
- [ ] `uv run undercooked --host 127.0.0.1 --port 8000 &` then
`curl -fsS http://127.0.0.1:8000/healthz` → `{"status":"ok"}`; `curl -fsS http://127.0.0.1:8000/` → the shell HTML
- [ ] `DEBUGPY=1 uv run undercooked` opens port 5678 and still serves `/healthz`
- [ ] `uv run pytest -q` → 100% passing, coverage ≥ 90%
- [ ] `uv run ruff check .` and `uv run pyright` → zero errors
- [ ] no file under `app/static/` references an external URL (test-enforced)
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases (n/a — first phase)
@@ -1,50 +0,0 @@
# Task 01 — Lock toolchain and packages
**Phase:** `01_runnable_service_scaffold` · **Story:** `n/a`
## Objective
Make `uv` resolve the locked dependency set and establish the test tree, markers, and
first green test run, so every later phase starts from a working harness.
## Work
1. `pyproject.toml` — verify it matches A1/A13/A20: runtime deps (`fastapi`,
`uvicorn[standard]`, `pydantic`, `python-dotenv`, `pyyaml`, `websockets`), dev extras
(`debugpy`, `ruff`, `pyright`, `pytest`, `pytest-asyncio`, `pytest-cov`, `httpx`,
`playwright`). Add nothing else; if something is missing, add it and note it in the
report. Confirm `[project.scripts]` entries point at modules created in tasks 02–03
(`undercooked.__main__:main`, `undercooked.tools.levels:main`) — the second one is
created in phase 02, so temporarily drop it **only if** it breaks `uv sync`; otherwise
leave it and let the console script fail lazily.
2. Run `uv sync --extra dev`; commit `uv.lock` (it is tracked — verify `.gitignore` does
not exclude it).
3. Create the package skeleton: `src/undercooked/__init__.py` (`__version__ = "0.1.0"`,
empty `__all__`), `src/undercooked/server/__init__.py`, `src/undercooked/sim/__init__.py`,
`src/undercooked/tools/__init__.py`.
4. Create the test tree with `tests/__init__.py`? **No** — keep tests a non-package tree;
add `tests/conftest.py` with: a `settings` fixture factory (`make_settings(**overrides)`),
an `app`/`client` fixture pair (`fastapi.testclient.TestClient`), and a
`random_seed` fixture. Add empty `__init__.py`-free dirs `tests/unit/`,
`tests/integration/`, `tests/sim/`, `tests/e2e/` each with a `README.md` one-liner
describing what belongs there.
5. `tests/unit/test_harness.py` — asserts `undercooked.__version__`, that the four test
markers are registered (`--strict-markers` passes), and that
`pytest -m e2e --collect-only` collects zero items without error (no e2e tests yet).
6. `tests/conftest.py` — add an autouse fixture that sets `UNDERCOOKED_LEVELS_DIR` to a
test fixture dir (`tests/fixtures/levels/`, created empty with a `.gitkeep`) so no
test can accidentally read the real content before phase 02 exists.
7. Install the Playwright browser once and record the command in `README.md`
(`uv run playwright install chromium`); do not add a conftest hook that downloads
browsers during unit runs.
## Testing & Quality
- Unit: `tests/unit/test_harness.py` passes; `uv run pytest -q` green.
- Coverage: **>90%** on this task's new/modified code (trivial modules may hit 100%).
## Completion Criteria
- [ ] `uv sync --extra dev` exits 0; `uv run python -c "import fastapi, yaml, websockets"` exits 0
- [ ] `uv run pytest -q` → all collected tests pass, coverage gate met
- [ ] `uv run ruff check .` and `uv run pyright` → zero errors
- [ ] `git status --porcelain uv.lock` shows the lock file tracked (staged by the owner later)
- [ ] full test suite **100% passing** (every test in the project, not just this task's)
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -1,49 +0,0 @@
# Task 02 — Typed config and debugpy bootstrap
**Phase:** `01_runnable_service_scaffold` · **Story:** `n/a`
## Objective
Give the project one authoritative, env-driven `Settings` object (A17) plus a
`DEBUGPY`-gated debugger hook (A18), so no later phase ever reads `os.environ` inline.
## Work
1. `src/undercooked/config.py`:
- `@dataclass(frozen=True, slots=True) class Settings` with fields matching the
README table: `host`, `port`, `tick_hz`, `snapshot_hz`, `max_players`, `max_rooms`,
`levels_dir: Path`, `lobby_idle_timeout_s`, `guest_idle_timeout_s`,
`max_message_bytes`, `max_inputs_per_s`, `sim_latency_ms`, `debugpy: bool`,
`debugpy_port`, `allowed_origins: tuple[str, ...]`.
- `load_settings(env: Mapping[str, str] | None = None) -> Settings` reading
`UNDERCOOKED_*` / `DEBUGPY*` keys, calling `dotenv.load_dotenv()` once when
`env is None`, with the README's defaults, `Path` expansion for `levels_dir`,
comma-splitting for `ALLOWED_ORIGINS`, and validation that raises
`ConfigError` (defined here) on: non-integer values, `tick_hz` outside 5–60,
`snapshot_hz` > `tick_hz`, `max_players` outside 1–4, `max_rooms` < 1, negative
timeouts, `max_message_bytes` < 256.
- `get_settings()` cached accessor (`functools.lru_cache` on a private `_load`) plus
`reset_settings_cache()` for tests. No module-level import-time env reads that
break test isolation.
2. `.env.example` — every variable, defaulted, commented (one line each). No secrets, no
real hostnames.
3. `src/undercooked/debug.py` — `maybe_start_debugpy(settings) -> bool`: returns `False`
and does nothing when `debugpy` is false; otherwise imports debugpy inside the
function, `listen(("0.0.0.0", settings.debugpy_port))`, `wait_for_client=False`, logs
one line, and never raises (a debug failure must not stop the server).
4. `tests/unit/test_config.py` — defaults, per-var overrides, `Path` handling,
comma-separated origins, every `ConfigError` branch, cache/reset behavior,
`.env` file loading via `monkeypatch` + `tmp_path`.
5. `tests/unit/test_debug.py` — `maybe_start_debugpy` is a no-op when disabled; when
enabled it calls `listen` exactly once (patch `debugpy` via `sys.modules` so the real
debugger never binds a port in CI) and swallows import/listen errors.
## Testing & Quality
- Unit: all `ConfigError` branches, default/override matrix, debugpy on/off/error paths.
- Coverage: **>90%** on `config.py` and `debug.py` (aim 100%).
## Completion Criteria
- [ ] `uv run pytest tests/unit/test_config.py tests/unit/test_debug.py -q` passes
- [ ] `UNDERCOOKED_TICK_HZ=99 uv run python -c "from undercooked.config import load_settings; load_settings()"` fails with a clear `ConfigError` message
- [ ] `uv run python -c "from undercooked.config import load_settings; print(load_settings().tick_hz)"` prints `20` with no `.env` present
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -1,68 +0,0 @@
# Task 03 — FastAPI app factory, CLI entry point, static shell
**Phase:** `01_runnable_service_scaffold` · **Story:** `US-01_create_anonymous_lobby` (landing surface)
## Objective
Create the process that later phases attach rooms and sockets to: `create_app()`,
`python -m undercooked`, security headers, and the client shell page served from
`app/static/`.
## Work
1. `src/undercooked/server/app.py`:
- `create_app(settings: Settings | None = None) -> FastAPI` — builds the app, stores
`app.state.settings`, installs middleware (see 3), registers `GET /healthz`
returning `{"status":"ok","version":__version__,"rooms":0}` (`rooms` is a placeholder
literal until phase 05 wires the supervisor), mounts
`app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")`, and adds
`GET /` returning `FileResponse(STATIC_DIR / "index.html")`.
- `STATIC_DIR` resolved from the package location (`Path(__file__).resolve().parents[3] / "app" / "static"`)
with an env override `UNDERCOOKED_STATIC_DIR` for containers; add it to `Settings`
(extend `.env.example` and the README table in this task).
- Startup hook: log the resolved settings (never log `.env` contents), and
`maybe_start_debugpy(settings)`.
2. `src/undercooked/__main__.py` — `main()` with argparse: `--host`, `--port`, `--reload`,
`--levels-dir`, precedence CLI > env > default; calls
`uvicorn.run("undercooked.server.app:app", ...)` (reload-capable string form) and
module-attribute `app = create_app()` guarded so import-time work stays cheap.
Exit code 0 on clean shutdown, 2 on `ConfigError` (usage/config error) with a one-line
message on stderr.
3. `src/undercooked/server/headers.py` — middleware adding:
`Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' ws: wss:; font-src 'self'; base-uri 'none'; frame-ancestors 'none'`,
`X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`,
`X-Frame-Options: DENY`, `Permissions-Policy: camera=(), microphone=(), geolocation=()`.
No `Report-To`/remote endpoints.
4. Client shell (vanilla ES modules, zero external references):
- `app/static/index.html` — `<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">`,
a `#screen-lobby` section with heading, *Create lobby* button, *Join* form
(`inputmode="text"`, `autocapitalize="characters"`, `maxlength="5"`, label,
visible focus styles), a `#status` `aria-live="polite"` region, and
`<script type="module" src="/static/js/app.js">`. Buttons wired to
`console.log`-free placeholders that render "not implemented until phase 05" into
`#status`.
- `app/static/css/base.css` — CSS custom properties for the palette, system font
stack, `prefers-reduced-motion` block, 44 px minimum hit targets, ≥ 4.5:1 text
contrast, 360 px → 4K fluid layout, `#kcanvas` sized by JS later.
- `app/static/js/app.js` — imports nothing external; small `mount()` that reads the
form, and a `getToken()` helper using `sessionStorage`
(`secrets`-style random via `crypto.getRandomValues`, base64url) storing
`uc.token`; keeps `uc.lastCode` in `sessionStorage`.
5. `tests/integration/test_app.py` — `create_app()` boots; `/healthz` 200 + JSON shape;
`/` serves HTML containing `/static/js/app.js`; `/static/css/base.css` 200 with
`text/css`; unknown path 404; `/static/../pyproject.toml` cannot escape (404/400);
every response carries the five security headers; `create_app(Settings(...))` honors
an overridden `max_players`.
## Testing & Quality
- Integration: routes, static mount, traversal attempt, header presence on 3 route types.
- Unit: `__main__._resolve()` precedence logic (CLI > env > default) extracted as a pure
function so it is testable without spawning uvicorn.
- Coverage: **>90%** on `app.py`, `headers.py`, `__main__.py`.
## Completion Criteria
- [ ] `uv run undercooked --port 8137 &` then `curl -fsSD- http://127.0.0.1:8137/healthz` → 200 with the security headers visible
- [ ] `curl -fsS http://127.0.0.1:8137/ | grep -c '/static/js/app.js'` → ≥ 1
- [ ] `curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8137/static/../pyproject.toml` → 404 or 400
- [ ] `uv run pytest -q` → all green, coverage ≥ 90%
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -1,61 +0,0 @@
# Task 04 — No-CDN guard, gate wiring, docs verification
**Phase:** `01_runnable_service_scaffold` · **Story:** `n/a`
## Objective
Make the no-external-asset rule (A12) mechanically enforced, adapt the pipeline gate to
this project's real checks, and prove the README/AGENTS instructions are true as written.
## Work
1. `tests/unit/test_no_external_assets.py`:
- walk `app/static/**` (html, css, js, svg) and fail on `http://`, `https://`,
`//cdn.`, `@import url(`, `<link rel="stylesheet" href="//`, `srcset`, `fetch(` with
an absolute URL, `import "` with an absolute URL, `WebSocket("ws`, and
`new EventSource`.
- parse `index.html` for `<script src>`/`<link href>`/`<img src>` and assert each is
a same-origin `/static/...` path.
- fail if any `.html`/`.js` file contains `googleapis`, `gstatic`, `jsdelivr`,
`unpkg`, `cdnjs`, or `tailwindcss`.
- resolve the static root the same way `app.py` does (import `STATIC_DIR`), so the
test cannot pass by scanning the wrong directory.
2. `tests/unit/test_docs_commands.py` — extract fenced `bash` blocks from `README.md`,
and assert every command line starts with a known-safe prefix
(`uv `, `podman `, `bash .agents/validate.sh`, `curl `, `cd `, `#`), i.e. the docs do
not drift into unrunnable pseudo-commands; assert every `UNDERCOOKED_*` variable named
in README also appears in `config.py` or `.env.example` (and vice versa).
3. `.agents/validate.sh` — if the phased-execution harness has not installed it yet, copy
its template; then adapt it to this project, in this order, failing fast with clear
output: `uv run ruff check .`, `uv run pyright`, `uv run undercooked-levels validate`
(skip with a notice only if `src/undercooked/tools/levels.py` does not exist yet —
phase 02 makes it real), `uv run pytest -q` (full suite, coverage gate on), plus the
template's repo-hygiene checks (tracked files matching `.gitignore`, log-drop/key
patterns). Keep `set -euo pipefail` and make it runnable from the repo root or the
`.agents/` directory.
4. Run `bash .agents/validate.sh` and fix everything it reports.
5. Create the doc stubs the PLAN §13 documentation index promises, each with a one-paragraph
purpose and a "filled in by phase NN" marker so no later phase references a missing file:
`docs/DESIGN.md`, `docs/TESTING.md`, `docs/DEPLOYMENT.md`. `docs/TESTING.md` documents the
`tests/{unit,sim,server,integration,e2e}` layout, the marker set (`slow`, `e2e`), and the
artifact policy (`test-results/` is never committed).
6. Add a `tests/unit/test_docs_index.py` guard: every `docs/*.md` listed in PLAN §13's
documentation index exists, and no `docs/` file exists that the index does not list — so
the doc set stays exactly the promised one.
7. Docs pass for this phase: confirm README's Quickstart block, config table (add
`UNDERCOOKED_STATIC_DIR`, `UNDERCOOKED_ALLOWED_ORIGINS`), and Layout section match
the code as it now stands; confirm AGENTS.md §5's test path
`tests/unit/test_no_external_assets.py` matches the file created here.
## Testing & Quality
- Unit: the guard itself (temp fixture dirs with a planted external URL must fail —
`tmp_path` + monkeypatched root; assert the failure message names the offending file).
- Coverage: **>90%** on new test-support code; content tests are self-verifying.
- Full suite green; `bash .agents/validate.sh` exits 0.
## Completion Criteria
- [ ] `bash .agents/validate.sh` exits 0 from both the repo root and `.agents/`
- [ ] planting `https://cdn.example.com/x.js` in a temp static file makes `uv run pytest tests/unit/test_no_external_assets.py -q` fail (then removed)
- [ ] `uv run pytest -q` → 100% passing, coverage ≥ 90%
- [ ] every README command block runs as written on a clean `uv sync --extra dev` checkout
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] full test suite **100% passing** (every test in the project, not just this phase's)
- [ ] no behavior change in completed phases