From 8ebf033e19f45dc27f05276970aec064f51e9792 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Thu, 23 Apr 2026 00:25:52 +0200 Subject: [PATCH] docs: add l4d2 implementation plans Capture the agreed host-library and web-app architecture, contracts, and execution tasks so implementation can proceed with minimal ambiguity. --- .../plans/2026-04-22-l4d2-host-lib-v1.md | 739 +++++++++++++++++ .../plans/2026-04-23-l4d2-web-app-v1.md | 749 ++++++++++++++++++ 2 files changed, 1488 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-22-l4d2-host-lib-v1.md create mode 100644 docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md diff --git a/docs/superpowers/plans/2026-04-22-l4d2-host-lib-v1.md b/docs/superpowers/plans/2026-04-22-l4d2-host-lib-v1.md new file mode 100644 index 0000000..0f98b37 --- /dev/null +++ b/docs/superpowers/plans/2026-04-22-l4d2-host-lib-v1.md @@ -0,0 +1,739 @@ +# L4D2 Host Library v1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a Python `l4d2` host library and `l4d2ctl` CLI with exactly five commands (`install`, `initialize`, `start`, `stop`, `delete`) plus read APIs needed by the local web app. + +**Architecture:** Runtime paths are hard-coded under `/opt/l4d2`. Write operations are imperative and fail-fast with no lock manager, no rollback, and no preflight checks. CLI behavior remains raw/stderr-first, while library internals additionally expose callback-based streaming and read APIs (`get_instance_status`, `stream_instance_logs`) for the web app. + +**Tech Stack:** Python 3.12+, Typer, PyYAML, pytest, subprocess, systemd user units, fuse-overlayfs. + +--- + +## Scope and Contracts + +- Command surface is fixed in v1: + - `l4d2ctl install` + - `l4d2ctl initialize -f ` + - `l4d2ctl start ` + - `l4d2ctl stop ` + - `l4d2ctl delete ` +- CLI `` is the server identity source (YAML does not carry identity semantics). +- Hard-coded runtime paths: + - `/opt/l4d2/installation` + - `/opt/l4d2/overlays/` + - `/opt/l4d2/instances/` + - `/opt/l4d2/runtime//{upper,work,merged}` +- Spec fields: + - required: `port` + - optional: `overlays`, `arguments`, `config` (all default to `[]`) + - unknown keys ignored +- Overlay order is preserved from YAML, and the first overlay has highest precedence. +- `initialize` always writes `server.cfg`; if `config` is empty/missing, `server.cfg` is empty. +- `delete` is no-op success when instance/runtime directories are already missing. +- CLI errors: print raw subprocess stderr and exit with subprocess return code. +- Additional read APIs for web app (no extra CLI commands): + - `get_instance_status(name)` + - `stream_instance_logs(name, lines=200, follow=True)` + +## Planned File Layout + +- `components/l4d2-host-lib/pyproject.toml` +- `components/l4d2-host-lib/src/l4d2host/cli.py` +- `components/l4d2-host-lib/src/l4d2host/spec.py` +- `components/l4d2-host-lib/src/l4d2host/process.py` +- `components/l4d2-host-lib/src/l4d2host/steam_install.py` +- `components/l4d2-host-lib/src/l4d2host/systemd_user.py` +- `components/l4d2-host-lib/src/l4d2host/fs/base.py` +- `components/l4d2-host-lib/src/l4d2host/fs/fuse_overlayfs.py` +- `components/l4d2-host-lib/src/l4d2host/instances.py` +- `components/l4d2-host-lib/src/l4d2host/status.py` +- `components/l4d2-host-lib/src/l4d2host/logs.py` +- `components/l4d2-host-lib/src/l4d2host/templates/l4d2@.service` +- `components/l4d2-host-lib/tests/test_cli.py` +- `components/l4d2-host-lib/tests/test_spec.py` +- `components/l4d2-host-lib/tests/test_process.py` +- `components/l4d2-host-lib/tests/test_install.py` +- `components/l4d2-host-lib/tests/test_initialize.py` +- `components/l4d2-host-lib/tests/test_lifecycle.py` +- `components/l4d2-host-lib/tests/test_status.py` +- `components/l4d2-host-lib/tests/test_logs.py` +- `components/l4d2-host-lib/README.md` + +### Task 1: Scaffold package and CLI entrypoint + +**Files:** +- Create: `components/l4d2-host-lib/pyproject.toml` +- Create: `components/l4d2-host-lib/src/l4d2host/__init__.py` +- Create: `components/l4d2-host-lib/src/l4d2host/cli.py` +- Test: `components/l4d2-host-lib/tests/test_cli.py` + +- [ ] **Step 1: Write failing CLI help test** + +```python +from typer.testing import CliRunner +from l4d2host.cli import app + + +def test_help_lists_v1_commands(): + result = CliRunner().invoke(app, ["--help"]) + assert result.exit_code == 0 + for command in ["install", "initialize", "start", "stop", "delete"]: + assert command in result.output +``` + +- [ ] **Step 2: Run test and verify failure** + +Run: `pytest components/l4d2-host-lib/tests/test_cli.py -q` +Expected: FAIL with missing package/app. + +- [ ] **Step 3: Implement pyproject script and minimal CLI app** + +```toml +[project.scripts] +l4d2ctl = "l4d2host.cli:app" +``` + +```python +import typer + +app = typer.Typer(no_args_is_help=True) + + +def _todo() -> None: + raise typer.Exit(code=1) + + +@app.command() +def install() -> None: + _todo() + + +@app.command() +def initialize(name: str, spec: str = typer.Option(..., "--spec", "-f")) -> None: + _todo() + + +@app.command() +def start(name: str) -> None: + _todo() + + +@app.command() +def stop(name: str) -> None: + _todo() + + +@app.command() +def delete(name: str) -> None: + _todo() +``` + +- [ ] **Step 4: Run tests and verify pass** + +Run: `pytest components/l4d2-host-lib/tests/test_cli.py -q` +Expected: PASS. + +- [ ] **Step 5: Commit scaffold** + +```bash +git add components/l4d2-host-lib +git commit -m "feat(l4d2): scaffold package and v1 CLI entrypoint" +``` + +### Task 2: Implement YAML spec parser + +**Files:** +- Create: `components/l4d2-host-lib/src/l4d2host/spec.py` +- Test: `components/l4d2-host-lib/tests/test_spec.py` + +- [ ] **Step 1: Write failing parser tests** + +```python +import pytest +from pathlib import Path +from l4d2host.spec import load_spec + + +def test_minimal_spec_parses(tmp_path: Path): + p = tmp_path / "server.yaml" + p.write_text("port: 27015\noverlays: [standard]\n") + spec = load_spec(p) + assert spec.port == 27015 + assert spec.overlays == ["standard"] + + +def test_defaults_are_empty_lists(tmp_path: Path): + p = tmp_path / "server.yaml" + p.write_text("port: 27015\n") + spec = load_spec(p) + assert spec.overlays == [] + assert spec.arguments == [] + assert spec.config == [] + + +def test_missing_port_fails(tmp_path: Path): + p = tmp_path / "server.yaml" + p.write_text("overlays: [standard]\n") + with pytest.raises((KeyError, ValueError)): + load_spec(p) + + +def test_unknown_keys_ignored(tmp_path: Path): + p = tmp_path / "server.yaml" + p.write_text("port: 27015\nfoo: bar\n") + spec = load_spec(p) + assert spec.port == 27015 +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run: `pytest components/l4d2-host-lib/tests/test_spec.py -q` +Expected: FAIL. + +- [ ] **Step 3: Implement parser and dataclass** + +```python +from dataclasses import dataclass, field +from pathlib import Path +import yaml + + +@dataclass +class InstanceSpec: + port: int + overlays: list[str] = field(default_factory=list) + arguments: list[str] = field(default_factory=list) + config: list[str] = field(default_factory=list) + + +def load_spec(path: Path) -> InstanceSpec: + raw = yaml.safe_load(path.read_text()) or {} + return InstanceSpec( + port=int(raw["port"]), + overlays=[str(x) for x in raw.get("overlays", [])], + arguments=[str(x) for x in raw.get("arguments", [])], + config=[str(x) for x in raw.get("config", [])], + ) +``` + +- [ ] **Step 4: Run tests and verify pass** + +Run: `pytest components/l4d2-host-lib/tests/test_spec.py -q` +Expected: PASS. + +- [ ] **Step 5: Commit parser** + +```bash +git add components/l4d2-host-lib/src/l4d2host/spec.py components/l4d2-host-lib/tests/test_spec.py +git commit -m "feat(l4d2): add spec parser with required port and permissive fields" +``` + +### Task 3: Build streaming process runner with callbacks + +**Files:** +- Create: `components/l4d2-host-lib/src/l4d2host/process.py` +- Test: `components/l4d2-host-lib/tests/test_process.py` + +- [ ] **Step 1: Write failing process tests** + +```python +import pytest +from l4d2host.process import run_command + + +def test_callbacks_receive_lines(): + out, err = [], [] + run_command(["python3", "-c", "import sys; print('ok'); print('warn', file=sys.stderr)"], on_stdout=out.append, on_stderr=err.append) + assert out == ["ok"] + assert err == ["warn"] + + +def test_nonzero_exit_raises(): + with pytest.raises(Exception): + run_command(["python3", "-c", "import sys; sys.exit(7)"]) +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run: `pytest components/l4d2-host-lib/tests/test_process.py -q` +Expected: FAIL. + +- [ ] **Step 3: Implement process runner** + +```python +from dataclasses import dataclass +import subprocess +import threading +import sys + + +@dataclass +class CommandResult: + returncode: int + stdout: str + stderr: str + + +def run_command(cmd, *, on_stdout=None, on_stderr=None, passthrough=False): + stdout_lines, stderr_lines = [], [] + + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1) + + def pump(stream, sink, callback, out_stream): + for raw in iter(stream.readline, ""): + line = raw.rstrip("\n") + sink.append(line) + if callback: + callback(line) + if passthrough: + print(line, file=out_stream) + + t_out = threading.Thread(target=pump, args=(proc.stdout, stdout_lines, on_stdout, sys.stdout), daemon=True) + t_err = threading.Thread(target=pump, args=(proc.stderr, stderr_lines, on_stderr, sys.stderr), daemon=True) + t_out.start(); t_err.start() + returncode = proc.wait() + t_out.join(); t_err.join() + + result = CommandResult(returncode=returncode, stdout="\n".join(stdout_lines), stderr="\n".join(stderr_lines)) + if returncode != 0: + raise subprocess.CalledProcessError(returncode=returncode, cmd=cmd, output=result.stdout, stderr=result.stderr) + return result +``` + +- [ ] **Step 4: Run tests and verify pass** + +Run: `pytest components/l4d2-host-lib/tests/test_process.py -q` +Expected: PASS. + +- [ ] **Step 5: Commit process runner** + +```bash +git add components/l4d2-host-lib/src/l4d2host/process.py components/l4d2-host-lib/tests/test_process.py +git commit -m "feat(l4d2): add callback-capable streaming process runner" +``` + +### Task 4: Implement install command with callback passthrough + +**Files:** +- Create: `components/l4d2-host-lib/src/l4d2host/steam_install.py` +- Test: `components/l4d2-host-lib/tests/test_install.py` + +- [ ] **Step 1: Write failing install tests** + +```python +import subprocess +import pytest +from l4d2host.steam_install import SteamInstaller + + +def test_windows_then_linux(monkeypatch): + calls = [] + monkeypatch.setattr("l4d2host.steam_install.run_command", lambda cmd, **kwargs: calls.append(cmd)) + SteamInstaller().install_or_update() + assert "windows" in calls[0] + assert "linux" in calls[1] + + +def test_fail_fast_on_first_failure(monkeypatch): + calls = [] + + def fake(cmd, **kwargs): + calls.append(cmd) + if len(calls) == 1: + raise subprocess.CalledProcessError(returncode=1, cmd=cmd) + + monkeypatch.setattr("l4d2host.steam_install.run_command", fake) + with pytest.raises(subprocess.CalledProcessError): + SteamInstaller().install_or_update() + assert len(calls) == 1 +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run: `pytest components/l4d2-host-lib/tests/test_install.py -q` +Expected: FAIL. + +- [ ] **Step 3: Implement installer** + +```python +from pathlib import Path +from l4d2host.process import run_command + + +class SteamInstaller: + def __init__(self, install_dir: Path = Path("/opt/l4d2/installation"), steamcmd: str = "steamcmd"): + self.install_dir = install_dir + self.steamcmd = steamcmd + + def install_or_update(self, *, on_stdout=None, on_stderr=None, passthrough=False) -> None: + for platform in ("windows", "linux"): + run_command([ + self.steamcmd, + "+force_install_dir", str(self.install_dir), + "+login", "anonymous", + "+@sSteamCmdForcePlatformType", platform, + "+app_update", "222860", "validate", + "+quit", + ], on_stdout=on_stdout, on_stderr=on_stderr, passthrough=passthrough) +``` + +- [ ] **Step 4: Run tests and verify pass** + +Run: `pytest components/l4d2-host-lib/tests/test_install.py -q` +Expected: PASS. + +- [ ] **Step 5: Commit install command** + +```bash +git add components/l4d2-host-lib/src/l4d2host/steam_install.py components/l4d2-host-lib/tests/test_install.py +git commit -m "feat(l4d2): implement callback-aware install command" +``` + +### Task 5: Implement initialize and systemd template management + +**Files:** +- Create: `components/l4d2-host-lib/src/l4d2host/systemd_user.py` +- Create: `components/l4d2-host-lib/src/l4d2host/instances.py` +- Create: `components/l4d2-host-lib/src/l4d2host/templates/l4d2@.service` +- Test: `components/l4d2-host-lib/tests/test_initialize.py` + +- [ ] **Step 1: Write failing initialize tests** + +```python +from pathlib import Path +from l4d2host.instances import initialize_instance + + +def test_initialize_writes_files(tmp_path: Path): + spec = tmp_path / "spec.yaml" + spec.write_text("port: 27015\noverlays: [a,b]\nconfig: [\"sv_consistency 1\"]\n") + initialize_instance("alpha", spec, root=tmp_path) + assert (tmp_path / "instances/alpha/instance.env").exists() + assert (tmp_path / "instances/alpha/server.cfg").exists() + + +def test_empty_config_writes_empty_server_cfg(tmp_path: Path): + spec = tmp_path / "spec.yaml" + spec.write_text("port: 27015\n") + initialize_instance("alpha", spec, root=tmp_path) + assert (tmp_path / "instances/alpha/server.cfg").read_text() == "" +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run: `pytest components/l4d2-host-lib/tests/test_initialize.py -q` +Expected: FAIL. + +- [ ] **Step 3: Implement initialize flow** + +```python +from pathlib import Path +from l4d2host.spec import load_spec +from l4d2host.systemd_user import ensure_template_unit, daemon_reload + + +def initialize_instance(name: str, spec_path: Path, *, root: Path = Path("/opt/l4d2"), on_stdout=None, on_stderr=None) -> None: + spec = load_spec(spec_path) + instance_dir = root / "instances" / name + runtime_dir = root / "runtime" / name + + instance_dir.mkdir(parents=True, exist_ok=True) + (runtime_dir / "upper").mkdir(parents=True, exist_ok=True) + (runtime_dir / "work").mkdir(parents=True, exist_ok=True) + (runtime_dir / "merged").mkdir(parents=True, exist_ok=True) + + lowerdirs = [str(root / "overlays" / overlay) for overlay in spec.overlays] + [str(root / "installation")] + (instance_dir / "instance.env").write_text( + f"L4D2_PORT={spec.port}\n" + f"L4D2_ARGS={' '.join(spec.arguments)}\n" + f"L4D2_LOWERDIRS={':'.join(lowerdirs)}\n" + ) + (instance_dir / "server.cfg").write_text("\n".join(spec.config) + ("\n" if spec.config else "")) + + ensure_template_unit() + daemon_reload(on_stdout=on_stdout, on_stderr=on_stderr) +``` + +- [ ] **Step 4: Run tests and verify pass** + +Run: `pytest components/l4d2-host-lib/tests/test_initialize.py -q` +Expected: PASS. + +- [ ] **Step 5: Commit initialize logic** + +```bash +git add components/l4d2-host-lib/src/l4d2host/{instances.py,systemd_user.py,templates/l4d2@.service} components/l4d2-host-lib/tests/test_initialize.py +git commit -m "feat(l4d2): implement initialize flow and systemd user template management" +``` + +### Task 6: Implement start/stop/delete lifecycle commands + +**Files:** +- Create: `components/l4d2-host-lib/src/l4d2host/fs/base.py` +- Create: `components/l4d2-host-lib/src/l4d2host/fs/fuse_overlayfs.py` +- Modify: `components/l4d2-host-lib/src/l4d2host/instances.py` +- Test: `components/l4d2-host-lib/tests/test_lifecycle.py` + +- [ ] **Step 1: Write failing lifecycle tests** + +```python +from pathlib import Path +from l4d2host.instances import start_instance, stop_instance, delete_instance + + +def test_start_order(monkeypatch): + calls = [] + monkeypatch.setattr("l4d2host.instances.run_command", lambda cmd, **kwargs: calls.append(cmd)) + start_instance("alpha") + assert calls[0][0] == "fuse-overlayfs" + assert calls[1][:3] == ["systemctl", "--user", "start"] + + +def test_delete_missing_is_noop(tmp_path: Path): + delete_instance("missing", root=tmp_path) + assert True +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run: `pytest components/l4d2-host-lib/tests/test_lifecycle.py -q` +Expected: FAIL. + +- [ ] **Step 3: Implement lifecycle methods with callbacks** + +```python +def start_instance(name: str, *, root: Path = Path("/opt/l4d2"), on_stdout=None, on_stderr=None, passthrough=False) -> None: + instance_dir = root / "instances" / name + runtime_dir = root / "runtime" / name + + env = { + line.split("=", 1)[0]: line.split("=", 1)[1] + for line in (instance_dir / "instance.env").read_text().splitlines() + if "=" in line + } + + run_command([ + "fuse-overlayfs", + "-o", + f"lowerdir={env['L4D2_LOWERDIRS']},upperdir={runtime_dir/'upper'},workdir={runtime_dir/'work'}", + str(runtime_dir / "merged"), + ], on_stdout=on_stdout, on_stderr=on_stderr, passthrough=passthrough) + + shutil.copy2(instance_dir / "server.cfg", runtime_dir / "merged" / "left4dead2/cfg/server.cfg") + + run_command([ + "systemctl", "--user", "start", f"l4d2@{name}.service" + ], on_stdout=on_stdout, on_stderr=on_stderr, passthrough=passthrough) + + +def stop_instance(name: str, *, root: Path = Path("/opt/l4d2"), on_stdout=None, on_stderr=None, passthrough=False) -> None: + run_command([ + "systemctl", "--user", "stop", f"l4d2@{name}.service" + ], on_stdout=on_stdout, on_stderr=on_stderr, passthrough=passthrough) + + run_command([ + "fusermount3", "-u", str(root / "runtime" / name / "merged") + ], on_stdout=on_stdout, on_stderr=on_stderr, passthrough=passthrough) + + +def delete_instance(name: str, *, root: Path = Path("/opt/l4d2"), on_stdout=None, on_stderr=None, passthrough=False) -> None: + instance_dir = root / "instances" / name + runtime_dir = root / "runtime" / name + if not instance_dir.exists() and not runtime_dir.exists(): + return + stop_instance(name, root=root, on_stdout=on_stdout, on_stderr=on_stderr, passthrough=passthrough) + if instance_dir.exists(): + shutil.rmtree(instance_dir) + if runtime_dir.exists(): + shutil.rmtree(runtime_dir) +``` + +- [ ] **Step 4: Run tests and verify pass** + +Run: `pytest components/l4d2-host-lib/tests/test_lifecycle.py -q` +Expected: PASS. + +- [ ] **Step 5: Commit lifecycle implementation** + +```bash +git add components/l4d2-host-lib/src/l4d2host/{fs/base.py,fs/fuse_overlayfs.py,instances.py} components/l4d2-host-lib/tests/test_lifecycle.py +git commit -m "feat(l4d2): implement start stop delete lifecycle with callback support" +``` + +### Task 7: Add status and log read APIs + +**Files:** +- Create: `components/l4d2-host-lib/src/l4d2host/status.py` +- Create: `components/l4d2-host-lib/src/l4d2host/logs.py` +- Test: `components/l4d2-host-lib/tests/test_status.py` +- Test: `components/l4d2-host-lib/tests/test_logs.py` + +- [ ] **Step 1: Write failing read-API tests** + +```python +from l4d2host.status import map_active_state + + +def test_status_mapping(): + assert map_active_state("active") == "running" + assert map_active_state("inactive") == "stopped" + assert map_active_state("weird") == "unknown" +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run: `pytest components/l4d2-host-lib/tests/test_status.py components/l4d2-host-lib/tests/test_logs.py -q` +Expected: FAIL. + +- [ ] **Step 3: Implement status/log readers** + +```python +from dataclasses import dataclass +import subprocess + +from l4d2host.process import run_command + + +@dataclass +class InstanceStatus: + state: str + raw_active_state: str + raw_sub_state: str + + +def get_instance_status(name: str) -> InstanceStatus: + try: + result = run_command([ + "systemctl", + "--user", + "show", + f"l4d2@{name}.service", + "--property=ActiveState", + "--property=SubState", + "--no-pager", + ]) + except subprocess.CalledProcessError: + return InstanceStatus(state="unknown", raw_active_state="unknown", raw_sub_state="unknown") + + pairs = {} + for line in result.stdout.splitlines(): + if "=" in line: + k, v = line.split("=", 1) + pairs[k] = v + + active = pairs.get("ActiveState", "unknown") + sub = pairs.get("SubState", "unknown") + if active == "active": + mapped = "running" + elif active in {"inactive", "failed"}: + mapped = "stopped" + else: + mapped = "unknown" + return InstanceStatus(state=mapped, raw_active_state=active, raw_sub_state=sub) + + +def stream_instance_logs(name: str, *, lines: int = 200, follow: bool = True): + cmd = [ + "journalctl", + "--user", + "-u", + f"l4d2@{name}.service", + "-n", + str(lines), + "-o", + "cat", + ] + if follow: + cmd.append("-f") + + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1) + try: + for raw in iter(proc.stdout.readline, ""): + yield raw.rstrip("\n") + finally: + if proc.poll() is None: + proc.terminate() + proc.wait(timeout=2) +``` + +- [ ] **Step 4: Run tests and verify pass** + +Run: `pytest components/l4d2-host-lib/tests/test_status.py components/l4d2-host-lib/tests/test_logs.py -q` +Expected: PASS. + +- [ ] **Step 5: Commit read APIs** + +```bash +git add components/l4d2-host-lib/src/l4d2host/{status.py,logs.py} components/l4d2-host-lib/tests/test_{status,logs}.py +git commit -m "feat(l4d2): add status and journald log read APIs" +``` + +### Task 8: Wire CLI to implementations and finalize docs + +**Files:** +- Modify: `components/l4d2-host-lib/src/l4d2host/cli.py` +- Create: `components/l4d2-host-lib/README.md` +- Modify: tests under `components/l4d2-host-lib/tests/` + +- [ ] **Step 1: Write failing CLI exit-code test** + +```python +from typer.testing import CliRunner +from l4d2host.cli import app + + +def test_cli_propagates_subprocess_return_code(monkeypatch): + def fail(*args, **kwargs): + import subprocess + raise subprocess.CalledProcessError(returncode=9, cmd=["x"]) + + monkeypatch.setattr("l4d2host.cli.start_instance", fail) + r = CliRunner().invoke(app, ["start", "alpha"]) + assert r.exit_code == 9 +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run: `pytest components/l4d2-host-lib/tests/test_cli.py -q` +Expected: FAIL. + +- [ ] **Step 3: Implement CLI handlers** + +```python +import subprocess +import typer + + +def _exit_from_subprocess_error(exc: subprocess.CalledProcessError) -> None: + raise typer.Exit(code=exc.returncode) + + +@app.command() +def start(name: str) -> None: + try: + start_instance(name, passthrough=True) + except subprocess.CalledProcessError as exc: + _exit_from_subprocess_error(exc) +``` + +- [ ] **Step 4: Final test run** + +Run: `pytest components/l4d2-host-lib/tests -q` +Expected: PASS. + +- [ ] **Step 5: Commit finalization** + +```bash +git add components/l4d2-host-lib +git commit -m "docs(l4d2): finalize v1 CLI contracts and web-facing read APIs" +``` + +--- + +## Self-Review + +- [ ] Spec coverage: command surface fixed, hard-coded paths, config semantics, delete no-op, callback streaming, read APIs. +- [ ] Placeholder scan: no TODO/TBD placeholders. +- [ ] Consistency: argument names (`on_stdout`, `on_stderr`, `passthrough`) are consistent across tasks. +- [ ] Verification: each task contains exact test commands and expected outcomes. diff --git a/docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md b/docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md new file mode 100644 index 0000000..51d5553 --- /dev/null +++ b/docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md @@ -0,0 +1,749 @@ +# L4D2 Web App v1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a local Flask web app where users manage L4D2 servers with auth, overlay selection, async jobs, desired-vs-actual status, and live logs. + +**Architecture:** The app runs as a single Flask process with Jinja templates and vendored local HTMX. It imports `l4d2host` directly (no subprocess boundary), persists state in SQLite, and executes lifecycle actions asynchronously through an in-process scheduler with lock rules: same server serialized, different servers parallel, install global-exclusive. + +**Tech Stack:** Python 3.12+, Flask, SQLAlchemy, Alembic, pytest, vendored HTMX, custom CSS, vanilla JS (SSE). + +--- + +## Scope and Constraints + +- In scope: + - public signup/login + - admin role with global permissions + - server CRUD with ordered overlays + - async lifecycle actions (`install`, `initialize`, `start`, `stop`, `delete`) + - live job logs and live server logs + - desired-vs-actual state in server list +- Out of scope (later phases): + - workshop mod management + - user-created overlays and web file manager +- Frontend constraints: + - no CSS/JS framework dependencies + - HTMX vendored locally only (no CDN) + - custom CSS only; links use consistent accent `#0F766E` +- Runtime constraints: + - single-process deployment for v1 + - periodic status refresh every 8 seconds + +## Planned File Layout + +- `components/l4d2-web-app/pyproject.toml` +- `components/l4d2-web-app/src/l4d2web/app.py` +- `components/l4d2-web-app/src/l4d2web/config.py` +- `components/l4d2-web-app/src/l4d2web/db.py` +- `components/l4d2-web-app/src/l4d2web/models.py` +- `components/l4d2-web-app/src/l4d2web/auth.py` +- `components/l4d2-web-app/src/l4d2web/cli.py` +- `components/l4d2-web-app/src/l4d2web/services/l4d2_facade.py` +- `components/l4d2-web-app/src/l4d2web/services/spec_yaml.py` +- `components/l4d2-web-app/src/l4d2web/services/job_worker.py` +- `components/l4d2-web-app/src/l4d2web/services/status.py` +- `components/l4d2-web-app/src/l4d2web/routes/auth_routes.py` +- `components/l4d2-web-app/src/l4d2web/routes/server_routes.py` +- `components/l4d2-web-app/src/l4d2web/routes/overlay_routes.py` +- `components/l4d2-web-app/src/l4d2web/routes/job_routes.py` +- `components/l4d2-web-app/src/l4d2web/routes/log_routes.py` +- `components/l4d2-web-app/src/l4d2web/templates/*.html` +- `components/l4d2-web-app/src/l4d2web/static/vendor/htmx.min.js` +- `components/l4d2-web-app/src/l4d2web/static/css/{tokens,layout,components,logs}.css` +- `components/l4d2-web-app/src/l4d2web/static/js/sse.js` +- `components/l4d2-web-app/alembic.ini` +- `components/l4d2-web-app/alembic/env.py` +- `components/l4d2-web-app/alembic/versions/0001_initial.py` +- `components/l4d2-web-app/tests/*.py` +- `components/l4d2-web-app/README.md` + +### Task 1: Scaffold Flask app, config, and health endpoint + +**Files:** +- Create: `components/l4d2-web-app/pyproject.toml` +- Create: `components/l4d2-web-app/src/l4d2web/app.py` +- Create: `components/l4d2-web-app/src/l4d2web/config.py` +- Test: `components/l4d2-web-app/tests/test_health.py` + +- [ ] **Step 1: Write failing health test** + +```python +from l4d2web.app import create_app + + +def test_health_endpoint(): + app = create_app({"TESTING": True}) + client = app.test_client() + r = client.get("/health") + assert r.status_code == 200 + assert r.get_json() == {"status": "ok"} +``` + +- [ ] **Step 2: Run test and verify failure** + +Run: `pytest components/l4d2-web-app/tests/test_health.py -q` +Expected: FAIL. + +- [ ] **Step 3: Implement app factory and route** + +```python +from flask import Flask, jsonify + + +def create_app(test_config=None): + app = Flask(__name__) + app.config.from_mapping( + SECRET_KEY="dev", + DATABASE_URL="sqlite:///l4d2web.db", + STATUS_REFRESH_SECONDS=8, + ) + if test_config: + app.config.update(test_config) + + @app.get("/health") + def health(): + return jsonify({"status": "ok"}) + + return app +``` + +- [ ] **Step 4: Run test and verify pass** + +Run: `pytest components/l4d2-web-app/tests/test_health.py -q` +Expected: PASS. + +- [ ] **Step 5: Commit scaffold** + +```bash +git add components/l4d2-web-app +git commit -m "feat(l4d2-web): scaffold flask app and health endpoint" +``` + +### Task 2: Add database models and migration baseline + +**Files:** +- Create: `components/l4d2-web-app/src/l4d2web/db.py` +- Create: `components/l4d2-web-app/src/l4d2web/models.py` +- Create: `components/l4d2-web-app/alembic.ini` +- Create: `components/l4d2-web-app/alembic/env.py` +- Create: `components/l4d2-web-app/alembic/versions/0001_initial.py` +- Test: `components/l4d2-web-app/tests/test_models.py` + +- [ ] **Step 1: Write failing model tests** + +```python +from l4d2web.db import init_db, session_scope +from l4d2web.models import User + + +def test_create_user(tmp_path, monkeypatch): + monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path/'app.db'}") + init_db() + with session_scope() as s: + user = User(username="alice", password_digest="digest", admin=False) + s.add(user) + s.flush() + assert user.id is not None +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run: `pytest components/l4d2-web-app/tests/test_models.py -q` +Expected: FAIL. + +- [ ] **Step 3: Implement schema with Rails-style names** + +```python +class User(Base): + __tablename__ = "users" + id = mapped_column(Integer, primary_key=True) + username = mapped_column(String(64), unique=True, nullable=False) + password_digest = mapped_column(String(255), nullable=False) + admin = mapped_column(Boolean, default=False, nullable=False) + + +class Server(Base): + __tablename__ = "servers" + id = mapped_column(Integer, primary_key=True) + user_id = mapped_column(ForeignKey("users.id"), nullable=False) + name = mapped_column(String(128), unique=True, nullable=False) + port = mapped_column(Integer, nullable=False) + desired_state = mapped_column(String(16), default="stopped", nullable=False) + actual_state = mapped_column(String(16), default="unknown", nullable=False) +``` + +- [ ] **Step 4: Run tests and verify pass** + +Run: `pytest components/l4d2-web-app/tests/test_models.py -q` +Expected: PASS. + +- [ ] **Step 5: Commit models and migration** + +```bash +git add components/l4d2-web-app/src/l4d2web/{db.py,models.py} components/l4d2-web-app/alembic* +git commit -m "feat(l4d2-web): add sqlite schema and migration baseline" +``` + +### Task 3: Implement auth flows and admin bootstrap CLI + +**Files:** +- Create: `components/l4d2-web-app/src/l4d2web/auth.py` +- Create: `components/l4d2-web-app/src/l4d2web/routes/auth_routes.py` +- Create: `components/l4d2-web-app/src/l4d2web/cli.py` +- Modify: `components/l4d2-web-app/src/l4d2web/app.py` +- Test: `components/l4d2-web-app/tests/test_auth.py` + +- [ ] **Step 1: Write failing auth tests** + +```python +def test_signup_creates_user(client): + r = client.post("/signup", data={"username": "alice", "password": "secret"}) + assert r.status_code == 302 + + +def test_login_sets_session(client, seed_user): + r = client.post("/login", data={"username": "alice", "password": "secret"}) + assert r.status_code == 302 + with client.session_transaction() as sess: + assert sess["user_id"] == seed_user.id +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run: `pytest components/l4d2-web-app/tests/test_auth.py -q` +Expected: FAIL. + +- [ ] **Step 3: Implement auth and promote-admin command** + +```python +from werkzeug.security import generate_password_hash, check_password_hash + + +def hash_password(raw: str) -> str: + return generate_password_hash(raw) + + +def verify_password(raw: str, digest: str) -> bool: + return check_password_hash(digest, raw) +``` + +```python +@click.command("promote-admin") +@click.argument("username") +def promote_admin(username: str): + with session_scope() as s: + user = s.scalar(select(User).where(User.username == username)) + if not user: + raise click.ClickException("user not found") + user.admin = True + s.commit() +``` + +- [ ] **Step 4: Run tests and verify pass** + +Run: `pytest components/l4d2-web-app/tests/test_auth.py -q` +Expected: PASS. + +- [ ] **Step 5: Commit auth** + +```bash +git add components/l4d2-web-app/src/l4d2web/{auth.py,cli.py,app.py} components/l4d2-web-app/src/l4d2web/routes/auth_routes.py components/l4d2-web-app/tests/test_auth.py +git commit -m "feat(l4d2-web): add signup/login and promote-admin bootstrap command" +``` + +### Task 4: Implement overlays and server spec CRUD + +**Files:** +- Create: `components/l4d2-web-app/src/l4d2web/routes/overlay_routes.py` +- Create: `components/l4d2-web-app/src/l4d2web/routes/server_routes.py` +- Test: `components/l4d2-web-app/tests/test_servers_overlays.py` + +- [ ] **Step 1: Write failing CRUD tests** + +```python +def test_admin_can_create_overlay(admin_client): + r = admin_client.post("/admin/overlays", data={"name": "standard", "path": "/opt/l4d2/overlays/standard"}) + assert r.status_code == 302 + + +def test_server_create_persists_overlay_order(user_client, seed_overlays): + payload = {"name": "alpha", "port": 27015, "overlay_ids": [2, 1], "arguments": [], "config": []} + r = user_client.post("/servers", json=payload) + assert r.status_code == 201 +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run: `pytest components/l4d2-web-app/tests/test_servers_overlays.py -q` +Expected: FAIL. + +- [ ] **Step 3: Implement routes with ownership rules** + +```python +@bp.post("/servers") +@require_login +def create_server(): + payload = request.get_json() + with session_scope() as s: + server = Server( + user_id=current_user().id, + name=payload["name"], + port=int(payload["port"]), + arguments=json.dumps(payload.get("arguments", [])), + config=json.dumps(payload.get("config", [])), + ) + s.add(server) + s.flush() + for position, overlay_id in enumerate(payload.get("overlay_ids", [])): + s.add(ServerOverlay(server_id=server.id, overlay_id=overlay_id, position=position)) + s.commit() + return {"id": server.id}, 201 +``` + +- [ ] **Step 4: Run tests and verify pass** + +Run: `pytest components/l4d2-web-app/tests/test_servers_overlays.py -q` +Expected: PASS. + +- [ ] **Step 5: Commit CRUD routes** + +```bash +git add components/l4d2-web-app/src/l4d2web/routes/{overlay_routes.py,server_routes.py} components/l4d2-web-app/tests/test_servers_overlays.py +git commit -m "feat(l4d2-web): add overlay admin CRUD and server spec CRUD with ordering" +``` + +### Task 5: Integrate direct `l4d2host` facade and YAML generation + +**Files:** +- Create: `components/l4d2-web-app/src/l4d2web/services/spec_yaml.py` +- Create: `components/l4d2-web-app/src/l4d2web/services/l4d2_facade.py` +- Test: `components/l4d2-web-app/tests/test_l4d2_facade.py` + +- [ ] **Step 1: Write failing facade tests** + +```python +def test_initialize_writes_yaml_and_calls_library(monkeypatch, server_record): + called = {} + + def fake_initialize(name, spec_path, **kwargs): + called["name"] = name + called["spec_path"] = str(spec_path) + + monkeypatch.setattr("l4d2web.services.l4d2_facade.initialize_instance", fake_initialize) + from l4d2web.services.l4d2_facade import initialize_server + initialize_server(server_record) + assert called["name"] == server_record.name + assert called["spec_path"].endswith(".yaml") +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run: `pytest components/l4d2-web-app/tests/test_l4d2_facade.py -q` +Expected: FAIL. + +- [ ] **Step 3: Implement facade and YAML generator** + +```python +def write_temp_spec(server, overlays: list[str]) -> Path: + payload = { + "port": server.port, + "overlays": overlays, + "arguments": json.loads(server.arguments), + "config": json.loads(server.config), + } + f = tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) + yaml.safe_dump(payload, f, sort_keys=False) + f.close() + return Path(f.name) +``` + +- [ ] **Step 4: Run tests and verify pass** + +Run: `pytest components/l4d2-web-app/tests/test_l4d2_facade.py -q` +Expected: PASS. + +- [ ] **Step 5: Commit facade** + +```bash +git add components/l4d2-web-app/src/l4d2web/services/{spec_yaml.py,l4d2_facade.py} components/l4d2-web-app/tests/test_l4d2_facade.py +git commit -m "feat(l4d2-web): add direct l4d2host facade with generated yaml specs" +``` + +### Task 6: Build scheduler, in-process worker pool, and recovery + +**Files:** +- Create: `components/l4d2-web-app/src/l4d2web/services/job_worker.py` +- Modify: `components/l4d2-web-app/src/l4d2web/app.py` +- Test: `components/l4d2-web-app/tests/test_job_worker.py` + +- [ ] **Step 1: Write failing worker tests** + +```python +def test_same_server_jobs_serialized(worker_fixture): + result = worker_fixture.run_once() + assert result["same_server_parallel"] is False + + +def test_install_is_global_exclusive(worker_fixture): + result = worker_fixture.run_once() + assert result["install_parallel"] is False + + +def test_startup_recovers_stale_running_jobs(app): + from l4d2web.services.job_worker import recover_stale_jobs + recovered = recover_stale_jobs() + assert recovered >= 0 +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run: `pytest components/l4d2-web-app/tests/test_job_worker.py -q` +Expected: FAIL. + +- [ ] **Step 3: Implement scheduler and lock rules** + +```python +class SchedulerState: + def __init__(self): + self.install_running = False + self.running_servers: set[int] = set() + + +def can_start(job, state: SchedulerState) -> bool: + if job.operation == "install": + return (not state.install_running) and (not state.running_servers) + if state.install_running: + return False + return job.server_id not in state.running_servers +``` + +- [ ] **Step 4: Add single-process guard and stale job recovery** + +Run: `pytest components/l4d2-web-app/tests/test_job_worker.py -q` +Expected: PASS. + +- [ ] **Step 5: Commit worker engine** + +```bash +git add components/l4d2-web-app/src/l4d2web/services/job_worker.py components/l4d2-web-app/src/l4d2web/app.py components/l4d2-web-app/tests/test_job_worker.py +git commit -m "feat(l4d2-web): implement async scheduler, lock rules, and startup recovery" +``` + +### Task 7: Persist command logs and add SSE job stream + +**Files:** +- Create: `components/l4d2-web-app/src/l4d2web/routes/job_routes.py` +- Modify: `components/l4d2-web-app/src/l4d2web/services/job_worker.py` +- Test: `components/l4d2-web-app/tests/test_job_logs.py` + +- [ ] **Step 1: Write failing log tests** + +```python +def test_job_logs_seq_monotonic(db_session, finished_job): + rows = db_session.execute(text("select seq from job_logs where job_id=:id order by seq"), {"id": finished_job.id}).all() + assert rows == sorted(rows) + + +def test_sse_resume_from_last_seq(client, seeded_job_logs): + r = client.get(f"/jobs/{seeded_job_logs.job_id}/stream?last_seq=3") + assert r.status_code == 200 +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run: `pytest components/l4d2-web-app/tests/test_job_logs.py -q` +Expected: FAIL. + +- [ ] **Step 3: Implement log persistence + SSE** + +```python +def append_job_log(session, job_id: int, stream: str, line: str) -> None: + last_seq = session.scalar(select(func.max(JobLog.seq)).where(JobLog.job_id == job_id)) or 0 + clipped = line[:4096] + session.add(JobLog(job_id=job_id, seq=last_seq + 1, stream=stream, line=clipped)) + session.flush() +``` + +- [ ] **Step 4: Run tests and verify pass** + +Run: `pytest components/l4d2-web-app/tests/test_job_logs.py -q` +Expected: PASS. + +- [ ] **Step 5: Commit job logs** + +```bash +git add components/l4d2-web-app/src/l4d2web/{routes/job_routes.py,services/job_worker.py} components/l4d2-web-app/tests/test_job_logs.py +git commit -m "feat(l4d2-web): persist command logs and stream job output over sse" +``` + +### Task 8: Add SSE server runtime logs + +**Files:** +- Create: `components/l4d2-web-app/src/l4d2web/routes/log_routes.py` +- Test: `components/l4d2-web-app/tests/test_server_logs.py` + +- [ ] **Step 1: Write failing server-log tests** + +```python +def test_owner_can_stream_server_logs(owner_client, owned_server): + r = owner_client.get(f"/servers/{owned_server.id}/logs/stream") + assert r.status_code == 200 + + +def test_non_owner_forbidden(user_client, foreign_server): + r = user_client.get(f"/servers/{foreign_server.id}/logs/stream") + assert r.status_code == 403 +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run: `pytest components/l4d2-web-app/tests/test_server_logs.py -q` +Expected: FAIL. + +- [ ] **Step 3: Implement runtime log stream route** + +```python +@bp.get("/servers//logs/stream") +@require_login +def stream_server_logs(server_id: int): + server = load_authorized_server(server_id) + + def gen(): + for line in facade.stream_server_logs(server.name, lines=200, follow=True): + yield f"data: {line}\n\n" + + return Response(gen(), mimetype="text/event-stream") +``` + +- [ ] **Step 4: Run tests and verify pass** + +Run: `pytest components/l4d2-web-app/tests/test_server_logs.py -q` +Expected: PASS. + +- [ ] **Step 5: Commit server log streaming** + +```bash +git add components/l4d2-web-app/src/l4d2web/routes/log_routes.py components/l4d2-web-app/tests/test_server_logs.py +git commit -m "feat(l4d2-web): add live server log streaming with ownership checks" +``` + +### Task 9: Implement desired-vs-actual state model and refresh + +**Files:** +- Create: `components/l4d2-web-app/src/l4d2web/services/status.py` +- Modify: `components/l4d2-web-app/src/l4d2web/services/job_worker.py` +- Modify: `components/l4d2-web-app/src/l4d2web/routes/server_routes.py` +- Test: `components/l4d2-web-app/tests/test_status.py` + +- [ ] **Step 1: Write failing status tests** + +```python +def test_display_state_priority(status_service): + assert status_service.display_state(active_job_operation="start", actual_state="stopped") == "starting" + + +def test_drift_badge(status_service): + assert status_service.is_drift(desired_state="running", actual_state="stopped", has_active_job=False) is True +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run: `pytest components/l4d2-web-app/tests/test_status.py -q` +Expected: FAIL. + +- [ ] **Step 3: Implement state logic and refresh hooks** + +```python +def compute_display_state(active_operation, actual_state): + priority = { + "delete": "deleting", + "start": "starting", + "stop": "stopping", + "initialize": "initializing", + } + if active_operation in priority: + return priority[active_operation] + return actual_state +``` + +- [ ] **Step 4: Implement 8-second actual-state refresh loop** + +Run: `pytest components/l4d2-web-app/tests/test_status.py -q` +Expected: PASS. + +- [ ] **Step 5: Commit state model** + +```bash +git add components/l4d2-web-app/src/l4d2web/{services/status.py,services/job_worker.py,routes/server_routes.py} components/l4d2-web-app/tests/test_status.py +git commit -m "feat(l4d2-web): add desired vs actual status model with 8s refresh" +``` + +### Task 10: Apply security and reliability hardening + +**Files:** +- Modify: `components/l4d2-web-app/src/l4d2web/app.py` +- Modify: `components/l4d2-web-app/src/l4d2web/routes/auth_routes.py` +- Modify: `components/l4d2-web-app/src/l4d2web/routes/overlay_routes.py` +- Modify: `components/l4d2-web-app/src/l4d2web/db.py` +- Test: `components/l4d2-web-app/tests/test_security.py` + +- [ ] **Step 1: Write failing hardening tests** + +```python +def test_csrf_required(client, seed_user): + r = client.post("/servers", data={"name": "x"}) + assert r.status_code == 400 + + +def test_overlay_path_must_be_under_opt_l4d2_overlays(admin_client): + r = admin_client.post("/admin/overlays", data={"name": "bad", "path": "/tmp/bad"}) + assert r.status_code == 400 +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run: `pytest components/l4d2-web-app/tests/test_security.py -q` +Expected: FAIL. + +- [ ] **Step 3: Implement hardening** + +```python +app.config.update( + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE="Lax", +) +``` + +```python +engine = create_engine(db_url, connect_args={"check_same_thread": False}) +with engine.connect() as conn: + conn.exec_driver_sql("PRAGMA journal_mode=WAL;") + conn.exec_driver_sql("PRAGMA busy_timeout=5000;") +``` + +- [ ] **Step 4: Run tests and verify pass** + +Run: `pytest components/l4d2-web-app/tests/test_security.py -q` +Expected: PASS. + +- [ ] **Step 5: Commit hardening** + +```bash +git add components/l4d2-web-app/src/l4d2web/{app.py,db.py} components/l4d2-web-app/src/l4d2web/routes/{auth_routes.py,overlay_routes.py} components/l4d2-web-app/tests/test_security.py +git commit -m "feat(l4d2-web): add csrf, rate limits, sqlite wal, and path safety checks" +``` + +### Task 11: Build minimal UI with vendored HTMX and custom CSS + +**Files:** +- Create: `components/l4d2-web-app/src/l4d2web/templates/base.html` +- Create: `components/l4d2-web-app/src/l4d2web/templates/dashboard.html` +- Create: `components/l4d2-web-app/src/l4d2web/templates/server_detail.html` +- Create: `components/l4d2-web-app/src/l4d2web/templates/admin_overlays.html` +- Create: `components/l4d2-web-app/src/l4d2web/static/vendor/htmx.min.js` +- Create: `components/l4d2-web-app/src/l4d2web/static/css/tokens.css` +- Create: `components/l4d2-web-app/src/l4d2web/static/css/layout.css` +- Create: `components/l4d2-web-app/src/l4d2web/static/css/components.css` +- Create: `components/l4d2-web-app/src/l4d2web/static/css/logs.css` +- Create: `components/l4d2-web-app/src/l4d2web/static/js/sse.js` +- Test: `components/l4d2-web-app/tests/test_pages.py` + +- [ ] **Step 1: Write failing page tests** + +```python +def test_dashboard_renders_server_name(auth_client_with_server): + r = auth_client_with_server.get("/dashboard") + assert r.status_code == 200 + assert "alpha" in r.get_data(as_text=True) + + +def test_non_admin_cannot_open_overlay_admin(auth_client): + r = auth_client.get("/admin/overlays") + assert r.status_code == 403 +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run: `pytest components/l4d2-web-app/tests/test_pages.py -q` +Expected: FAIL. + +- [ ] **Step 3: Implement templates and style system** + +```css +/* tokens.css */ +:root { + --color-link: #0F766E; + --color-text: #132028; + --color-bg: #f7fbfa; + --space-2: 0.5rem; + --space-4: 1rem; + --radius: 10px; +} + +a { color: var(--color-link); } +``` + +- [ ] **Step 4: Run tests and verify pass** + +Run: `pytest components/l4d2-web-app/tests/test_pages.py -q` +Expected: PASS. + +- [ ] **Step 5: Commit UI** + +```bash +git add components/l4d2-web-app/src/l4d2web/{templates,static} components/l4d2-web-app/tests/test_pages.py +git commit -m "feat(l4d2-web): add minimal htmx ui with vendored assets and custom css" +``` + +### Task 12: Final integration, docs, and full verification + +**Files:** +- Create: `components/l4d2-web-app/README.md` +- Modify: `components/l4d2-web-app/src/l4d2web/app.py` +- Test: all tests under `components/l4d2-web-app/tests` + +- [ ] **Step 1: Write failing integration smoke test** + +```python +def test_core_routes_registered(app): + routes = {r.rule for r in app.url_map.iter_rules()} + assert "/dashboard" in routes + assert "/jobs//stream" in routes +``` + +- [ ] **Step 2: Run test and verify failure (if wiring incomplete)** + +Run: `pytest components/l4d2-web-app/tests -q` +Expected: FAIL or partial PASS before final wiring. + +- [ ] **Step 3: Finalize app wiring and README** + +README sections required: +- deployment model (single process) +- auth/admin bootstrap (`l4d2web promote-admin `) +- scheduler lock semantics +- desired vs actual model and 8s refresh +- log architecture (`job_logs` forever, server logs from journald) +- frontend dependency policy (vendored HTMX only, custom CSS) + +- [ ] **Step 4: Run full test suite** + +Run: `pytest components/l4d2-web-app/tests -q` +Expected: PASS. + +- [ ] **Step 5: Commit finalization** + +```bash +git add components/l4d2-web-app +git commit -m "docs(l4d2-web): finalize v1 architecture contracts and verification" +``` + +--- + +## Self-Review + +- [ ] Spec coverage complete for approved constraints (auth, admin bootstrap, overlays, async jobs, status model, logging, minimal UI). +- [ ] No placeholders (`TODO`, `TBD`, vague directives). +- [ ] Naming consistency (`user_id`, `server_id`, `overlay_id`, `job_id`, `password_digest`, `admin`). +- [ ] Every task includes concrete test commands and expected outcomes.