phase: 07_kiosk_layout_state
Build and Push Containers / test (push) Successful in 39s
Build and Push Containers / build-and-push-app (push) Successful in 12s

Every AGENTS.md rule claim checks out against the code — no stale statements. All verification is complete.

**Phase 07 — final verification pass report**

- **Verified, not re-implemented:** all 3 tasks already in `complete/` — `Arrange`/`layout` frames + `hello.kiosk` (protocol), `Layout`/`set_layout`/`layout_dto` policy (registry), `arrange` dispatch + `GET /api/kiosk` (app).
- **Live check:** idle `/api/kiosk` → `{"staged": null, "fullscreen": false, "followers": 0}`; drove `arrange` over a real WebSocket (websocat absent → python `websockets` client): state reflected in `/api/kiosk`, kiosk join bumped `followers` to 1 and pushed to other viewers, sharer teardown reset `staged` to `null` in exactly one `layout` frame — no stale id.
- **Gates (all 4):** `uv run ruff check . && uv run ruff format --check .` → clean; `uv run pyright` → 0 errors; `uv run pytest` → **319 passed, 5 skipped** (realdisplay, by design); `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → **4 passed, 1 skipped** (documented host has no audio source).
- **Criteria 1/4/5:** targeted suite 154 passed incl. all new layout cases; stale-fallback unit tests cover unpublish, disconnect, and stream-replacement; `git diff` of `src/mirror/static/` and `tests/e2e/` is empty, e2e green within the full run.
- **Criterion 6:** full suite 100% green; coverage 95% total — new/modified code: protocol 100%, registry 99%, app 94% (all >90%).
- **Criterion 7:** `PLAN.md` untouched.
- **Documentation:** README.md reviewed in full and cross-checked against code (routes, CLI flags, config table incl. 25 Mbit/s default, quality-preset table, watch/share key tables, docs references, the phase-07 shared-state paragraph) — consistent, no fixes needed. AGENTS.md — verified, no stale statements, no changes.
- **Note:** `PLAN.md` §6 says the default `MIRROR_MAX_BITRATE` is "12 Mbit/s" but code/README say 25 — pre-existing, in a LOCKED file, left as-is (README is correct).
- **Next pending phase:** `08_kiosk_page`.
This commit is contained in:
2026-11-03 09:53:38 -05:00
parent 1f60a6b40c
commit 736da1c1ef
28 changed files with 826 additions and 11 deletions
@@ -1,113 +0,0 @@
# Phase 07 — Shared presentation state on the server
**Source:** `TODO.md` L3 — "Add a /kiosk endpoint that is controllable by other clients… There should be a /kiosk endpoint which stores 'state' (fullscreen, which mirror is being viewed) that any client can manipulate and will update immediately…" (the server half of that item)
**Story:** n/a
**Context:** `.agents/PLAN.md` §2 A5 (one WebSocket, JSON, verb in / noun out), A6 (camelCase on the wire), A7 (in-memory state only — no database, no persistence), A8 (`Registry` stays pure and returns `list[Envelope]`), A11/A19 (no accounts; the address is the gate); `src/mirror/protocol.py`, `src/mirror/registry.py`, `src/mirror/app.py`; `tests/test_registry.py`, `tests/test_protocol.py`, `tests/test_app.py`
## Objective
Give the server one piece of presentation state — **which mirror is on stage, and
whether it should be fullscreen** — that any connected peer may change and that is
pushed to everyone the moment it changes. This is the piece the TODO item is
actually short of: `/watch` already stages and fullscreens locally (phase 01), but
that decision lives in one tab and dies there, so an unattended display can never
be driven from elsewhere.
This phase is **server-side only**: no new page, no browser-visible behaviour yet.
It leaves the app exactly as usable as it was, with a wire format and a
`GET /api/kiosk` read surface that phases 08 and 09 build their clients on.
## The contract this phase fixes (phases 08/09 code against it verbatim)
Inbound — verb, like `publish`/`subscribe` (PLAN A5), from **any** peer:
```json
{"type":"arrange","staged":"p1ab2c3d","fullscreen":true}
```
* `staged`: a stream id, or `null` for "back to the wall", or **absent** for "no
opinion". Absent and explicit-`null` are different requests, which is why the
model is read with pydantic's `model_fields_set` rather than with defaults.
* `fullscreen`: `bool`, absent = no opinion.
* A `staged` value that does not name a live mirror is refused with the existing
`unknown_stream` error and changes nothing.
* No audio/mute field. `SECURITY.md` §4: sound must trace back to a gesture on the
device that plays it — a remote hand cannot unmute a stranger's display.
Outbound — noun, to every viewer and back to the sender:
```json
{"type":"layout","staged":"p1ab2c3d","fullscreen":true,"followers":1}
```
* `followers` is how many connected peers announced `hello{"kiosk":true}`, i.e.
how many displays are obeying this. A controller is a plain viewer and is not
counted, which is what makes "nobody is showing a wall" distinguishable from
"the wall will follow".
* Emitted on: any accepted change, a kiosk display joining or leaving, and the
stale fallback when the staged mirror disappears.
* `welcome` carries the same object as its `layout` field, so a page is arranged
before it has asked for anything — the TODO's "navigate to it and it arranges
itself" depends on this.
`GET /api/kiosk` answers the same object, so the state is debuggable with `curl`
exactly like `/api/streams` (AGENTS.md §8).
## What is deliberately *not* here
* **No third role.** `Role` stays `sharer | viewer`; a kiosk is a viewer that
happens to obey the layout. The signal-authorisation matrix and A4 are
untouched.
* **No media coupling.** Staging never subscribes, never negotiates, never touches
an `RTCPeerConnection` — the kiosk already subscribes to every mirror it can
show, so "which one is on stage" is pure presentation.
* **One state per server process.** Not per display, not per room. Two `/kiosk`
pages follow the same state; that is the product's single-wall model (A7).
* **No persistence.** Restart the server and the wall is a wall. Consistent with
A7's reasoning: state that outlives the peers is a lie.
* **No auto-stage-newest.** The state says what is on stage; nothing here invents
a value. (`PLAN.md` §7 item 2, "Wall UX", is where auto-stage belongs.)
## Dependencies
- `06_sharer_quality_presets` (complete) — the current wall/stage/keyboard
behaviour and the green four-gate baseline this phase must not disturb.
(`.agents/phases/todo/` is empty, so this is the phase directly before 07.)
## Tasks
1. `01_arrange_and_layout_frames.md` — the two message shapes in `protocol.py`
(`Arrange` inbound, `layout()` outbound builder) plus the `kiosk` flag on
`hello`, with parse/round-trip tests.
2. `02_registry_layout_policy.md` — `Layout` state, `Registry.set_layout()` and
`Registry.layout_dto()`, the fan-out set, the follower count, and the
stale-staged fallback on teardown; unit-tested with no I/O.
3. `03_dispatch_read_endpoint_and_wire_tests.md` — `Hub._dispatch` handles
`arrange`, `GET /api/kiosk` serves the state, and the whole path is covered
over a real `TestClient` WebSocket.
## Testing & Quality
- **Unit (`tests/test_registry.py`)** owns the policy: partial update vs explicit
clear, refusal of an unknown stream, fan-out to viewers plus the sender,
follower counting, the fallback when the staged mirror goes away, and `welcome`
carrying the state.
- **Wire (`tests/test_app.py`, `tests/test_protocol.py`)** owns the camelCase
round-trip and the socket semantics: an `arrange` frame from a sharer, from a
viewer, with a bad stream id (error frame, socket stays open), and the shape +
`no-store` of `/api/kiosk` behind the address gate.
- Coverage: **>90%** on new/modified code (the project sits ~95%; `registry.py` is
the product and must stay near 100%).
- All four gates from `AGENTS.md` §4, including `-m e2e` and the `realdisplay`
suite: this phase changes frames every browser test consumes, so "nothing in the
browser changed" is itself the assertion.
## Completion Criteria
- [ ] `uv run pytest tests/test_registry.py tests/test_protocol.py tests/test_app.py` green, including the new layout cases
- [ ] `curl -s localhost:8055/api/kiosk | python -m json.tool` answers `{"staged": null, "fullscreen": false, "followers": 0}` on an idle server, and reflects an `arrange` frame sent with `websocat`
- [ ] `welcome` carries a `layout` object; a second viewer connecting after an `arrange` receives the same state without being asked
- [ ] the staged mirror going away (unpublish, disconnect, eviction) resets `staged` to `null` and pushes one `layout` frame — never a stale id
- [ ] no page, no stylesheet and no browser test changed behaviour (`uv run pytest -m e2e` green with the existing assertions untouched)
- [ ] full test suite **100% passing**, coverage >90% on new code
- [ ] `PLAN.md` untouched (no anchor moved: A5/A7/A8/A11 all hold, see above)
@@ -1,92 +0,0 @@
# Task 01 — The `arrange` / `layout` frames
**Phase:** `07_kiosk_layout_state` · **Source:** `TODO.md:3` — "There should be a /kiosk endpoint which stores 'state' (fullscreen, which mirror is being viewed) that any client can manipulate and will update immediately"
**Story:** n/a
## Objective
Put both halves of the presentation-state message on the wire: an inbound
`arrange` verb, an outbound `layout` noun, and a `kiosk` flag on `hello` so the
server can tell a display from a controller. No behaviour yet — `Registry` is
wired in task 02, the socket in task 03.
## Work
1. `src/mirror/protocol.py` — add the inbound verb after `Subscribe`/`Unsubscribe`,
in the `_Inbound` house style. Both fields are `| None` **and** optional, because
"leave what I did not mention alone" and "clear it" are different requests:
```python
class Arrange(_Inbound):
"""Ask the wall to show a given mirror. Absent fields are left alone;
``staged: null`` explicitly returns the displays to the wall. The caller
does not have to be the display's operator — see ``SECURITY.md`` §2.
"""
type: Literal["arrange"]
staged: str | None = None
fullscreen: bool | None = None
```
Add `Arrange` to the `ClientMessage` discriminated union and re-export nothing
else — the union is what `Hub._dispatch` switches on.
2. `src/mirror/protocol.py` — `Hello` gains one field, single word so there is no
camelCase alias to get wrong (but round-trip it anyway, A6):
```python
#: true when this peer is a wall display that obeys the shared layout.
kiosk: bool = False
```
Keep `Role = Literal["sharer", "viewer"]` exactly as it is: a kiosk is a viewer
with an attribute, not a third role (see the phase overview).
3. `src/mirror/protocol.py` — add the outbound builder beside `welcome()`:
```python
def layout(
*, staged: str | None, fullscreen: bool, followers: int
) -> dict[str, Any]:
"""The wall's presentation state, pushed to every viewer as it changes."""
return {"type": "layout", "staged": staged, "fullscreen": fullscreen, "followers": followers}
```
Key order and spelling are the contract in `00_phase.md`; the outbound builders
are hand-written dicts, so there is no alias generator to catch a typo here —
the round-trip test in this task is what catches it.
4. `src/mirror/protocol.py` — `welcome()` gains a `layout` parameter and a
`"layout"` key so a page arrives already arranged. Give it a keyword-only
default of `{"staged": None, "fullscreen": False, "followers": 0}`? **No** —
a mutable default is a shared dict. Take it as a required keyword; the one
caller (`Registry.join`) is updated in task 02, so this task must not leave a
call site broken: update `registry.py`'s `welcome(...)` call in the same commit
to pass `layout=protocol.layout(staged=None, fullscreen=False, followers=0)`
as a placeholder that task 02 replaces with the real state.
5. Do **not** touch `Hub`, `create_app`, `Registry` policy or any JS in this task.
## Testing & Quality
- `tests/test_protocol.py` — extend the message-parsing suite:
* `{"type":"arrange"}`, `{"type":"arrange","staged":null}` and
`{"type":"arrange","staged":"p1","fullscreen":true}` all parse, and
`model_fields_set` distinguishes the first two from the third (this is the
whole reason the model is written this way — test it, do not trust it).
* `hello` defaults `kiosk` to `False` and accepts `kiosk: true`.
* `protocol.layout(...)` emits exactly `type/staged/fullscreen/followers` —
assert the key set, which is how the outbound dicts are guarded elsewhere.
- `tests/test_app.py` — `test_an_unknown_message_type_is_reported_but_survivable`
must still pass, and `arrange` must *not* appear as unknown once task 03 lands;
nothing to assert here yet.
- Coverage: **>90%** on this task's new/modified code (the models and the builder
are covered directly by the parse/shape tests above).
## Completion Criteria
- [ ] `uv run pytest tests/test_protocol.py` green with the new `arrange`, `kiosk`
and `layout()` cases
- [ ] `uv run pyright` reports 0 errors (`Arrange` is in the union, so the
`isinstance` chain in `app.py` is exhaustive-checked in task 03)
- [ ] `uv run pytest -m e2e` still green with **no** test file edited — nothing a
browser does has changed
- [ ] full test suite **100% passing**
- [ ] no behavior change in completed work
@@ -1,118 +0,0 @@
# Task 02 — `Registry` owns the layout: set, validate, broadcast, fall back
**Phase:** `07_kiosk_layout_state` · **Source:** `TODO.md:3` — "stores 'state' (fullscreen, which mirror is being viewed) that any client can manipulate and will update immediately"
**Story:** n/a
## Objective
The state itself, in `Registry`, with the policy that makes it trustworthy: only
live mirrors may be named, the change reaches every viewer in the same call that
produced it, and a mirror disappearing never leaves a display pointing at nothing.
Pure Python, envelopes out — A8 to the letter.
## Work
1. `src/mirror/registry.py` — a sentinel for "the caller did not mention this
field", because `None` already means "clear it":
```python
class _Unset:
"""Marker: the caller expressed no opinion, keep the current value."""
UNSET = _Unset()
```
2. `src/mirror/registry.py` — the state next to `Peer`/`Stream`, with the
dataclass house style (`slots=True`):
```python
@dataclass(slots=True)
class Layout:
"""What the wall displays. One value per server process (A7): there is
one wall, and every display obeying it is showing the same thing."""
staged: str | None = None
fullscreen: bool = False
```
`staged` holds a stream id, which is also a sharer peer id (`streamId ==
sharerId`, PLAN §3) — the fallback in step 6 relies on that identity.
3. `src/mirror/registry.py` — `Peer` gains `kiosk: bool = False`; `Registry.join()`
gains a `kiosk: bool = False` keyword and stores it. The follower count is
derived, never bookkept: `sum(1 for p in self.peers.values() if p.kiosk)`.
4. `src/mirror/registry.py` — `Registry.__init__` creates `self.layout = Layout()`;
`join()` builds its `welcome` with `layout=self.layout_dto()` (replacing task
01's placeholder), so a page is arranged before it asks.
5. `src/mirror/registry.py` — the two public operations:
* `layout_dto() -> dict[str, Any]` — `protocol.layout(staged=…, fullscreen=…,
followers=<derived count>)`. This is the only place the DTO is built, so
`/api/kiosk` (task 03) and the socket can never disagree.
* `set_layout(peer_id, *, staged: Any = UNSET, fullscreen: Any = UNSET)
-> list[Envelope]` —
- `self._peer(peer_id)` first, so a dead peer gets `unknown_peer` like every
other operation;
- `staged` given as a non-`None` string → `self._stream(staged)` (reuses the
existing `unknown_stream` code — no new error code);
- `staged` explicitly `None` → clears; absent → unchanged; same shape for
`fullscreen`;
- if nothing actually changed, return **only** the ack to the sender — no
broadcast storm from a controller that re-sends the same state;
- otherwise return the broadcast: every peer whose role is `viewer`, **plus
the sender** (a sharer that sends an `arrange` still gets its own ack),
each carrying `protocol.layout(**self.layout_dto())`. Deduplicate the
sender into the set so no peer is sent the frame twice.
6. `src/mirror/registry.py` — the fallback, in `_drop_stream()` (the single funnel
for *every* removal path: `unpublish`, disconnect, replaced stream, capacity
eviction): if `self.layout.staged == stream.id`, set it to `None` and append
the layout broadcast to the envelopes it already returns. The comment there
already explains "tell every viewer, not just subscribers" — extend it: a
display aimed at a mirror that vanished must fall back to the wall *in the same
frame set*, because there is no polling to correct it later.
7. `src/mirror/registry.py` — follower joins/leaves must also push state, or a
controller sits on a stale `followers: 0` until someone moves a mirror:
`join()` appends the layout broadcast to the other viewers' envelopes (not to
the joiner — it got the state in its `welcome`), and `leave()` appends the same
broadcast when the departing peer had `kiosk=True`.
8. `src/mirror/registry.py` module docstring — add the invariant to the bulleted
list: *the layout names either nothing or a live mirror; a mirror's removal
clears it in the same envelope set.*
9. Do **not** touch `Hub`, `app.py`, or any JS. Do not add persistence, a layout
per peer, or an audio field (see `00_phase.md`, "What is deliberately not here").
## Testing & Quality
- `tests/test_registry.py` — a new `TestLayout` class covering, in this order:
* default state is the wall, not fullscreen, zero followers;
* `set_layout` with `staged="pX"` broadcasts to viewers **and** the sender, and
no peer receives it twice;
* `staged` absent + `fullscreen=True` changes only fullscreen (the
partial-update rule, asserted on the returned DTO);
* `staged=None` clears an existing selection, and `set_layout` with no fields at
all produces no broadcast;
* an unknown stream id raises `RegistryError` with `code == "unknown_stream"`
and leaves `self.layout` untouched;
* a second `set_layout` with identical values sends the ack and nothing else;
* `unpublish`, `leave()` of the sharer, and a *replaced* stream each reset
`staged` to `None` and emit exactly one layout frame per viewer;
* `followers` counts only `kiosk=True` viewers, and a kiosk joining/leaving
pushes a layout frame whose `followers` is the new count;
* `welcome` of a peer that joins **after** an `arrange` carries that state —
this is "navigate to `/kiosk` and it arranges itself", stated at the layer
where it is decided.
Use the file's existing helpers/idioms (explicit peer ids via `id_factory`, the
`codes_and_targets` style helpers already in the module) rather than a new
harness.
- Coverage: **>90%** on this task's new/modified code — target ~100% of
`registry.py`, which the project treats as the product.
## Completion Criteria
- [ ] `uv run pytest tests/test_registry.py` green, including every `TestLayout` case above
- [ ] `uv run pyright` → 0 errors; `Registry` still imports no socket, no FastAPI, no `await` (A8, AGENTS.md §3)
- [ ] `grep -n "await\|WebSocket\|fastapi" src/mirror/registry.py` finds nothing
- [ ] a layout naming a mirror that has gone is impossible to observe: the frame
every viewer receives after a teardown says `staged: null`
- [ ] full test suite **100% passing**
- [ ] no behavior change in completed work (no wire frame any current client sends
or reads changed shape)
@@ -1,99 +0,0 @@
# Task 03 — Socket dispatch, `GET /api/kiosk`, and the wire suite
**Phase:** `07_kiosk_layout_state` · **Source:** `TODO.md:3` — "state … that any client can manipulate and will update immediately so I can just navigate to that endpoint on the kiosk and it will automatically arrange itself"
**Story:** n/a
## Objective
Connect the frames to the transport and make the state readable without a
browser: `Hub` accepts `arrange` from any peer and passes the caller's *expressed*
fields to `Registry`, and `GET /api/kiosk` answers the same DTO the socket pushes.
After this task the feature is real and drivable with `websocat`, one step below
the browser.
## Work
1. `src/mirror/app.py` — `Hub._introduce`: pass the flag through,
`self.registry.join(role=hello.role, name=hello.name, device=hello.device,
kiosk=hello.kiosk)`.
2. `src/mirror/app.py` — `Hub._dispatch`, in the existing `isinstance` chain
before the `else` that marks the union exhaustive:
```python
elif isinstance(message, protocol.Arrange):
fields = message.model_fields_set
outbox = self.registry.set_layout(
peer_id,
staged=message.staged if "staged" in fields else UNSET,
fullscreen=message.fullscreen if "fullscreen" in fields else UNSET,
)
```
Import `UNSET` from `.registry`. `model_fields_set` is the one place the
"absent ≠ null" rule survives the trip from JSON to policy; a comment there
should say so, because the next reader will try to simplify it to
`message.staged` and silently lose "back to the wall".
3. `src/mirror/app.py` — the read endpoint, next to `/api/streams`, same
`Cache-Control: no-store` (a roster that is cached is a lie about what is on
the wall right now):
```python
@app.get("/api/kiosk")
async def kiosk_layout() -> JSONResponse:
"""Server truth about the wall display: what is on stage, fullscreen, and
how many displays are obeying it. Read-only by design — the only writer is
the `arrange` frame on /ws, so there is one path that can change the wall."""
return JSONResponse(registry.layout_dto(), headers={"Cache-Control": "no-store"})
```
ASSUMPTION: writes stay on the WebSocket only. An HTTP `POST` would be a second
write path with no authentication (A11) and no same-origin protection to lose,
and the WS already gives any admitted address the same power — so the read-only
endpoint buys the `curl` debugging (AGENTS.md §8) without buying a new surface.
4. `src/mirror/app.py` — **do not** add `/kiosk` to `PAGES` yet: there is no page
until phase 08. `/api/kiosk` is gated like every other route (only `/healthz`
is exempt, AGENTS.md §3), and must not be added to that exemption.
5. No JS in this task.
## Testing & Quality
- `tests/test_app.py` — extend `TestSignalingSocket` (or the class the existing
roster tests live in) with:
* the round-trip: sharer publishes, viewer connects, viewer sends
`{"type":"arrange","staged":<streamId>,"fullscreen":true}` → both the sender
and a second viewer receive `layout` with `staged` and `fullscreen` set, and
`GET /api/kiosk` says the same;
* `followers` — two viewers with `hello{kiosk:true}` and one plain viewer
report `followers: 2`, and closing one of the kiosk sockets pushes a layout
frame that says `1`;
* a bad stream id answers `error{code:"unknown_stream"}` and **does not** close
the socket (send `ping`, get `pong`);
* an `arrange` with only `fullscreen` leaves `staged` alone end to end, and
`arrange{staged:null}` returns the wall;
* a **sharer** that sends `arrange` gets the ack, and a layout frame is not
pushed to other sharers (viewers plus sender only);
* the staged mirror's sharer disconnects → every viewer gets `staged: null`.
- `tests/test_app.py::TestAddressGate` — `/api/kiosk` is refused with the rest of
the app: add it to the parametrised "refused address gets nothing" list and
assert a refused address never learns the layout, plus one test that the real
address gets `200` and `cache-control: no-store`.
- `tests/test_app.py::TestConfigEndpoints`-style shape check: `/api/kiosk` returns
exactly the three keys (`staged`, `fullscreen`, `followers`) so a future field
addition is a decision, not a leak.
- Coverage: **>90%** on this task's new/modified code.
## Completion Criteria
- [ ] `websocat ws://localhost:8055/ws` can drive the wall by hand: `hello`, then
`{"type":"arrange","staged":"<id from /api/streams>","fullscreen":true}` →
a `layout` frame comes back (AGENTS.md §8 rung 2)
- [ ] `curl -s localhost:8055/api/kiosk | python -m json.tool` reflects that state
and answers `no-store`
- [ ] the staged mirror's sharer leaving resets `staged` to `null` for every
viewer, observed over the socket
- [ ] `uv run pytest tests/test_app.py` green (new cases + all existing, unedited)
- [ ] all four gates from `AGENTS.md` §4 green, including `-m e2e` and
`MIRROR_REALDISPLAY=1 -m realdisplay` with **no** browser-side change
- [ ] full test suite **100% passing**, coverage >90% on new code
- [ ] README.md updated for anything user-visible this task changes (a protocol
note is enough here; the pages come in phases 08/09)