phase: 07_kiosk_layout_state
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:
@@ -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)
|
||||
Reference in New Issue
Block a user