Gunnar

nordisk/dwarves

.nornir/dwarves-embed-run-picker-design.md

<!-- nornir:class: design -->
# dwarves — the Forge(live) **run-picker**

> A follow-up deferred in `3e082eb` (the `GetRunReport` + `dwarves-embed` live tab): the
> live pane rendered only the **newest** run (or the one pinned via `$DWARVES_RUN_ID`).
> This adds a **run-picker** — a dropdown of recent runs — so a viewer chooses which
> historized run to render, live, from the UI. **No proto change** (the `History` RPC
> already carried the run list), **additive** (unpinned still ⇒ newest), and it **reuses**
> the same `GetRunReport` → `ForgePipelineAdapter` → `PipelineView` path.

![run-picker flow](assets/dwarves-embed-run-picker.svg)

## The one-call fetch (`dwarves-embed::fetch_forge_split`)

The pane's fetch returns **both** halves in a SINGLE connection, with the two legs **split**:

- **`fetch_forge_split(endpoint, token, run_id, history_limit) -> Result<ForgeFetch>`** —
  one `History(limit)` call feeds **both** the picker list *and* newest-run selection (when
  `run_id` is unpinned), then one `GetRunReport(id)` fetches the selected run.
  `ForgeFetch { runs: Vec<RunRow>, report: Result<RunReport> }` keeps the legs apart: the
  **outer `Err`** is the LIST leg failing (couldn't connect / `History` errored ⇒ genuinely
  unavailable); a `report: Err(…)` is only the SELECTED run's report failing (a bad pin / a
  run that vanished) while the run list is fresh.
- **`fetch_forge(…) -> (Vec<RunRow>, RunReport)`** folds the split back to the strict
  all-or-nothing pair (a report error ⇒ overall `Err`); **`fetch_run_report`** is a thin
  wrapper over *that*. No twin — three functions, one fetch body.

## Report-error resilience — a bad pin never blanks the picker

When `History` succeeds but the pinned run's `GetRunReport` fails, the pane lands in a
distinct **`report_error`** `Conn` state instead of collapsing to `unavailable`:

- `apply_fetch(Result<ForgeFetch>)` (split out of `refresh` so the transitions are
  **drivable headless**) repopulates `runs` from the fresh `History` list *before* looking
  at the report — so the dropdown stays usable to switch to a working run. Only when the
  forge has **no runs at all** does a report error stay the plain `unavailable`/no-runs state.
- The control strip shows an amber *"run &lt;id&gt; unavailable — pick another run"* (the
  error on hover); `state_json.status == "report_error"` with `run_count` intact. Picking a
  good run re-fetches and clears the error. Previously the whole result was discarded on a
  report error, throwing the freshly-fetched list away — the fix that closed the last
  run-picker deferral.

## The pure mapping (`run_picks_from_wire`)

- **`RunPick { run_id, pipeline, status }`** — one dropdown entry, `Serialize` for
  `state_json`.
- **`run_picks_from_wire(&[RunRow]) -> Vec<RunPick>`** — pure, order-preserving
  (newest-first, as the server returns), so the picker list is unit-testable with **no
  server**.

## The pane (`ForgePanel`)

- Two new fields: `runs: Vec<RunPick>` (the offered list) + `history_limit: u32`
  (`DEFAULT_HISTORY_LIMIT = 20`). `refresh()` → `apply_fetch()` repopulates `runs` from the
  fresh `History` list whenever the list leg succeeds; a **dead-server fetch error leaves
  the list as-is** (a blip doesn't blank the dropdown), and a **report-only error keeps the
  fresh list** (the `report_error` state above).
- **UI:** an `egui::ComboBox` in the control strip — `● newest` plus one entry per run
  (`run_id · pipeline · status`). Selecting an entry pins its `run_id` and re-fetches;
  `● newest` clears the pin. The click drives the **exact same seam** a robot uses.
- **`state_json`** gains `selected_run_id` (the pin; `null` ⇒ newest), `runs`,
  `run_count`, `history_limit` — the robot-observable picker surface.
- **`update_json`** already pinned `{"run_id": "…"}` (`""` ⇒ newest); now also
  `{"history_limit": N}` resizes the list. Both re-fetch.

## Why this shape

- **Reuse, no twin (L5).** `fetch_run_report` becomes a wrapper over `fetch_forge`; the
  render path (`GetRunReport` → `ForgePipelineAdapter::from_report` → `PipelineView`) is
  untouched. No new RPC, no new viewer.
- **Additive (L2).** The default (unpinned ⇒ newest, auto-poll) is byte-for-byte the
  prior behaviour; the picker is purely additional surface.
- **Robot-tested (L4).** The picker (list + pin + clear) *and* the `report_error` recovery
  drive headless over `state_json` (`apply_fetch` → `state_json`), closing the coverage gap
  on the newest UI surface (the live-forge tab was mounted but not yet robot-ledgered).

## Deferred

- The picker lists **`History(limit)`** runs (default 20) — no paging / infinite scroll.
- No live-search/filter box over the list yet (fine at 20; add a filter if `limit` grows).
- Selection is by `run_id` only; a richer label (project + finished-at) is cosmetic.