Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ Milestone M2 of `docs/design/fleet.md`; the operator flow is `docs/fleet/README.

## Fleet: the operator view + dispatch API (M3)

Milestone M3 of `docs/design/fleet.md`, and the end of v0. The **HTTP** wire is specified as its own versioned contract in **`docs/fleet/fleet-api.md`** (M1's MQTT one is `control-plane.md`); the operator flow is `docs/fleet/README.md` §6–9 and the measurements are `m3-verification.md`. **The two directions of the loop take different paths on purpose.** *Reads* ride MQTT: the browser subscribes to `mote/v1/+/{presence,health,pose,task/status}` over WebSockets, and because all of those are retained it has the whole fleet's state within a second of loading — no polling, no service in the middle. *Writes* ride HTTP: `POST /v1/robots/<id>/dispatch` authorizes an operator token (`fleetctl operator new --name <you>`; the name is what the audit row records), writes the audit row, then publishes to the same `task/command` topic. **The topic tree did not change — only who publishes to it**, and `fleetctl dispatch` moved to the API too, so there is one write path rather than one per client. The command grammar is still parsed only by the robot's task layer: a parser in the server would be a second grammar to keep in step. **The browser cannot publish**: `server/ui/mqtt.mjs` is a hand-rolled subscribe-only MQTT 3.1.1 client that implements no PUBLISH packet, so the split is enforced by omission (M7 makes it structural with a subscribe-only broker credential). The UI is static ES modules — no bundler, no npm, no vendored library — served by the same stdlib `http.server`; `map.mjs` holds the Q5 world→pixel transform (`px = (wx-origin_x)/res`, `py = height - (wy-origin_y)/res`) and a pan/zoom/follow canvas, and only draws robots on the *same* site+floor as the selected one because a pose from another floor is a different map frame. **Basemaps come from site bundles on the fleet box** (`--maps-dir`, default `$MOTE_FLEET_HOME/sites`, the layout `sites.py` writes, seeded by rsync until **M4** makes the registry canonical behind the same two routes). **M1's websockets blocker is settled**: `pixi run fleet-broker` runs `eclipse-mosquitto` under docker with the repo's own `mosquitto.conf`, because conda-forge's build has none; `pixi run -e fleet fleet-broker-local` is the conda binary for a box without docker, and it strips the WS stanza and says so. Two things that run in the same file (`test_ui.py` → `ui_test.mjs`) are the MQTT codec and the transform, tested under node against the very files the browser loads; `browser_check.mjs` drives a real headless Chrome over CDP against a running stack and is an operator's tool, not a CI test — `pixi run fleet-ui-check` is that stack in one command (broker on ephemeral ports, server, a temp `MOTE_FLEET_HOME`, the sim's `office_world` bundle as the basemap, and `test/fake_robots.py`, which publishes `protocol.py` payloads and answers `task/command` and is *not* a second robot implementation), torn down afterwards; `-- --keep` leaves it up for UI work. It stays out of CI because it needs docker (conda's mosquitto still has no websockets) *and* a chrome, which the arm runner has not — the decision, and what wiring it in would take, are recorded in `m3-verification.md` §2 rather than left looking like coverage. **The phone is the realistic off-LAN client**, so below 760 px the three panes become one at a time behind a bottom tab bar (`server/ui/layout.mjs`), selecting a robot in the roster navigates to the map — what the desktop layout gets for free by showing both — and the canvas gained pinch-to-zoom (`pinchSpan`/`pinchUpdate` in `map.mjs`, pure and tested, because a division by a zero span puts NaN in the view scale and blanks the map for good) plus a fingertip-sized hit target. The breakpoint is a **silent** seam — CSS decides what is displayed, JS decides when a selection navigates, and disagreement yields a tab bar over stacked panes rather than an error — so it lives in `layout.mjs` and `ui_test.mjs` reads the stylesheet and holds it there, as it does for every pane having a tab and for `touch-action: none` on the canvas (without which the browser eats the drag and the pinch before a single pointer event arrives). Dispatch gained a **zone picker that writes `goto <zone>` into the command box rather than sending it**: the grammar stays the robot's, and the keyboard leaves the common case. Three pre-existing bugs fell out, all of which a desk hides: `hidden` does not hide an element whose class sets `display` (the empty promote picker), the canvas backing store was resized on width alone so a height change left the previous frame's scale bar under the new one, and the scale bar was drawn in the dark theme's near-white on a white basemap — a canvas gets no cascade, so it now reads `--dim` off the element. Measurements, including `browser_check.mjs`'s phone pass, are `m3-verification.md` §9; **a real device is still the acceptance** — emulation gets the viewport and the touch points right and the thumb wrong.
Milestone M3 of `docs/design/fleet.md`, and the end of v0. The **HTTP** wire is specified as its own versioned contract in **`docs/fleet/fleet-api.md`** (M1's MQTT one is `control-plane.md`); the operator flow is `docs/fleet/README.md` §6–9 and the measurements are `m3-verification.md`. **The two directions of the loop take different paths on purpose.** *Reads* ride MQTT: the browser subscribes to `mote/v1/+/{presence,health,pose,task/status}` over WebSockets, and because all of those are retained it has the whole fleet's state within a second of loading — no polling, no service in the middle. *Writes* ride HTTP: `POST /v1/robots/<id>/dispatch` authorizes an operator token (`fleetctl operator new --name <you>`; the name is what the audit row records), writes the audit row, then publishes to the same `task/command` topic. **The topic tree did not change — only who publishes to it**, and `fleetctl dispatch` moved to the API too, so there is one write path rather than one per client. The command grammar is still parsed only by the robot's task layer: a parser in the server would be a second grammar to keep in step. **The browser cannot publish**: `server/ui/mqtt.mjs` is a hand-rolled subscribe-only MQTT 3.1.1 client that implements no PUBLISH packet, so the split is enforced by omission (M7 makes it structural with a subscribe-only broker credential). The UI is static ES modules — no bundler, no npm, no vendored library — served by the same stdlib `http.server`; `map.mjs` holds the Q5 world→pixel transform (`px = (wx-origin_x)/res`, `py = height - (wy-origin_y)/res`) and a pan/zoom/follow canvas, and only draws robots on the *same* site+floor as the selected one because a pose from another floor is a different map frame. **Basemaps come from site bundles on the fleet box** (`--maps-dir`, default `$MOTE_FLEET_HOME/sites`, the layout `sites.py` writes, seeded by rsync until **M4** makes the registry canonical behind the same two routes). **M1's websockets blocker is settled**: `pixi run fleet-broker` runs `eclipse-mosquitto` under docker with the repo's own `mosquitto.conf`, because conda-forge's build has none; `pixi run -e fleet fleet-broker-local` is the conda binary for a box without docker, and it strips the WS stanza and says so. Two things that run in the same file (`test_ui.py` → `ui_test.mjs`) are the MQTT codec and the transform, tested under node against the very files the browser loads; `browser_check.mjs` drives a real headless Chrome over CDP against a running stack and is an operator's tool, not a CI test — `pixi run fleet-ui-check` is that stack in one command (broker on ephemeral ports, server, a temp `MOTE_FLEET_HOME`, the sim's `office_world` bundle as the basemap, and `test/fake_robots.py`, which publishes `protocol.py` payloads and answers `task/command` and is *not* a second robot implementation), torn down afterwards; `-- --keep` leaves it up for UI work. It stays out of CI because it needs docker (conda's mosquitto still has no websockets) *and* a chrome, which the arm runner has not — the decision, and what wiring it in would take, are recorded in `m3-verification.md` §2 rather than left looking like coverage. **A fourth pane, `review`, is where a candidate map is looked at and promoted** (`server/ui/review.mjs`; routes and rationale under the map registry below). It is a *mode*, not a column: opening it stands the operations panes down at every width, because two canvases — one canonical with robots on it, one a candidate without — is the confusion a dedicated view exists to remove. **The phone is the realistic off-LAN client**, so below 760 px the panes become one at a time behind a bottom tab bar (`server/ui/layout.mjs`), selecting a robot in the roster navigates to the map — what the desktop layout gets for free by showing both — and the canvas gained pinch-to-zoom (`pinchSpan`/`pinchUpdate` in `map.mjs`, pure and tested, because a division by a zero span puts NaN in the view scale and blanks the map for good) plus a fingertip-sized hit target. The breakpoint is a **silent** seam — CSS decides what is displayed, JS decides when a selection navigates, and disagreement yields a tab bar over stacked panes rather than an error — so it lives in `layout.mjs` and `ui_test.mjs` reads the stylesheet and holds it there, as it does for every pane having a tab and for `touch-action: none` on the canvas (without which the browser eats the drag and the pinch before a single pointer event arrives). Dispatch gained a **zone picker that writes `goto <zone>` into the command box rather than sending it**: the grammar stays the robot's, and the keyboard leaves the common case. Three pre-existing bugs fell out, all of which a desk hides: `hidden` does not hide an element whose class sets `display` (the empty promote picker), the canvas backing store was resized on width alone so a height change left the previous frame's scale bar under the new one, and the scale bar was drawn in the dark theme's near-white on a white basemap — a canvas gets no cascade, so it now reads `--dim` off the element. Measurements, including `browser_check.mjs`'s phone pass, are `m3-verification.md` §9; **a real device is still the acceptance** — emulation gets the viewport and the touch points right and the thumb wrong.

## Fleet: the map registry (M4)

Expand Down Expand Up @@ -163,6 +163,27 @@ its map at startup, so health now carries the revision each robot is actually
running. M3's `/v1/maps` routes kept their shape and changed source; the
dashboard additionally draws the floor's taught zones (circle, polygon or
waypoint cross) from `/v1/maps/<site>/<floor>/zones.json`.
**Promotion is a decision, so the operator has to be able to see what they are
deciding about** — and until the review pane existed the only thing on screen
was a timestamp, the canvas beside the picker being always the *canonical*
basemap. Three GETs answer it, the same three questions `/v1/maps` answers asked
of a revision that is not canonical: `…/revisions/<rev>/{map.json,map.png,
zones.json}`. Three things are load-bearing. `read_map`'s **`image_url` is
revision-aware**, because a transform from the revision and pixels from
`/v1/maps` would draw the published map under the candidate's label — convincing
and wrong, which is the exact failure being removed. The zones read is
**revision-scoped and deliberately not gated on a published map** where
`read_zones` is (the review that matters most is the first candidate on a floor
with nothing published), which does not loosen the vocabulary/binding split:
naming a revision is naming a map frame, and these stay under `/v1/maps`-shaped
paths and never under `/v1/zones`. And it reports **`source: revision|floor`**,
because `_zones_file` falls back to the floor's `zones.yaml` and inherited zones
were taught in a *previous* session's frame — they draw perfectly over the new
map and are wrong by however far the two origins differ, which no coordinate can
say. One UI ordering bug fell out and is fixed in both panes: the floor's
revisions were fetched only *after* its basemap loaded, behind an early return,
so a floor whose only revisions were candidates listed none of them and **the
first promotion on any floor could never be made from a browser**.

## Fleet: the zone vocabulary

Expand Down
58 changes: 52 additions & 6 deletions docs/fleet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -623,8 +623,10 @@ heartbeats stopped.

`fleet-server` serves the operator view at `http://<fleet-box>:8080/`. It is the
fleet-wide picture — who is out there, where they are, what they are doing, and
sending one of them somewhere — and nothing else: the deep single-robot view
(3D, sensors, teleop) is Foxglove's job (M2), which each robot row deep-links to.
sending one of them somewhere — plus the one decision the fleet cannot make for
you: which map a floor should be on (the **review** pane, §11). The deep
single-robot view (3D, sensors, teleop) is Foxglove's job (M2), which each robot
row deep-links to.

![The fleet dashboard](../images/fleet-ui.webp)

Expand Down Expand Up @@ -662,14 +664,17 @@ outline for a `polygon`, a cross for a bare waypoint, each labelled — so the
canonical revision, in that revision's map frame.

Beside the map's floor label is the **canonical revision** it is showing, and,
when a robot has published one, a picker to promote a candidate onto the floor
(§11). Both need the operator token; without one the pane is read-only.
when the floor has candidates waiting, a button into the **review** pane —
which is where a candidate is looked at and promoted (§11). The map pane keeps
no promote control of its own: this canvas draws robots on the *published*
basemap, so promoting from beside it would mean promoting a map you have not
seen.

![The dashboard on a phone](../images/fleet-ui-phone.webp)

**On a phone.** The realistic off-LAN client is a phone — it is what an operator
has in a corridor, and "where is the robot and what is it doing" is exactly the
question you ask from one. Below 760 px the three panes become **one at a time**
question you ask from one. Below 760 px the four panes become **one at a time**
behind a tab bar at the bottom of the screen, within thumb reach, so the map
gets the whole display instead of a couple of hundred pixels between the roster
and the detail pane. Two things follow from losing the side-by-side view:
Expand Down Expand Up @@ -862,7 +867,48 @@ pixi run -e fleet fleetctl -- promote home ground 20260728T090412
# announced on mote/v1/registry/site/home/floor/ground/current (retained); agents will pull it
```

The dashboard does the same thing with a picker beside the map (§9).
### Reviewing one before you promote it

`fleetctl sites <site> <floor>` tells you a revision is *valid*. It cannot tell
you whether it is the map you want, and for a long time neither could the
dashboard: the promote picker listed candidates as timestamps and the canvas
beside it was always the published basemap, so a promotion was an act of faith
in a filename. The dashboard's **review** pane is where that decision is now
made.

Open it from the tab bar, or from the map pane's `N candidates — review` button,
which appears whenever the floor on screen has something waiting. It shows:

- **A site/floor picker of its own**, fed by the registry rather than by which
robot is selected. The floor worth reviewing is often one no robot is
reporting — mapped by a robot since switched off, or side-loaded.
- **Every revision of that floor**, newest first, the published one included so
you can see what you would be replacing. A revision the validator refused is
listed too, with its reason, because "why can I not promote the map my robot
just published" is a question this pane should answer.
- **The candidate's own map**, drawn from that revision's own image — not the
published one — with its own zones over it. Switching between two candidates
keeps the viewport, which is how you compare them.
- **Why it is promotable**: the validator's verdict and warnings, plus where the
revision came from, when it was mapped, its size and resolution, the free/
occupied/unknown split, whether it carries a posegraph (i.e. whether mapping
can be continued in this frame), its bytes and digest.
- **The zones in it**, and — the part that is easy to miss — whether they are
the revision's own or **inherited from the floor**. Inherited zones were
taught in a previous session's frame: they draw perfectly over the new map and
are wrong by however far the two origins differ. The pane says so in words,
because the canvas cannot.
- **The promote button**, which is the same audited flip `fleetctl promote`
makes.

Everything except that button is a read. Nothing you do here changes any floor
until you promote.

**A floor with nothing published yet works the same way** — which was not always
true: the dashboard used to fetch a floor's revisions only after its basemap had
loaded, so a floor whose only revisions were candidates listed none of them and
its first promotion could not be made in a browser at all. Reviewing and
promoting the first map on a floor is now the ordinary path.

### What the robots then do

Expand Down
Loading