Goal
/runs updates itself. No F5.
New: spire-ui/src/useLiveRuns.ts
Model it on spire-ui/src/useLiveReviews.ts. Copy the socket lifecycle rather than reinventing
it — every branch in it was paid for by a live incident:
| Piece |
Why it is there |
wsDelivered ref |
The REST snapshot can land after the socket's. Applying it then overwrites fresher live state. |
onclose asks fetchMe() before reconnecting |
An expired handshake is answered with a redirect the browser cannot follow, so the socket fails with no useful close code. The session cookie's default life is five minutes, which makes expiry ordinary, not rare. Reconnecting blindly hammered the identity provider several times a second, forever. |
needsLogin(me) -> goToFullLogin() |
Asks for every URL prefix in one sequence instead of discovering each missing one a page load at a time. |
isLeavingForAuth() guards the error banner |
Otherwise logging out paints an alarming red failure over the dashboard for the moment before the page goes away. |
setTimeout(connect, 1500) |
The reconnect delay. |
Frame handling, same as reviews:
- Array -> snapshot; replace the list.
- Object with a
runId -> upsert by runId.
- Anything else -> ignore. Validate minimally (
typeof d.runId === 'string'); a row without an id
produces undefined or duplicate React keys and an unmergeable row.
Sort by startedAt descending with runId as tiebreak — the same order
FactoryRunProjection.list() uses, so the client never re-orders the server's page.
Change: spire-ui/src/components/Runs.tsx
- Delete the
getRuns effect. Read from useLiveRuns().
- The Kind and Status selects filter the live list client-side. Consequence, and the point of
doing it this way: a run that changes into the active filter now appears on its own. A
server-side filter cannot do that, because the socket would have to know each client's filter.
- Keep
getRuns in api.ts. Other callers use it and the endpoint stays public.
- Rename the
Started header to Queued (see the sibling task on agent_started_at).
Traps
- A UI assertion like "some dash exists" is satisfied by whichever column happens to be empty.
This has bitten this file three times — most recently when the new Proposed column supplied the
dash the cost test was asserting on. Scope every assertion to a row or a cell:
within(row).getAllByRole('cell').
Array.at(-1) is not in this project's TS lib target. Use cells[cells.length - 1].
- Filter option labels collide with cell text.
Succeeded is both a status pill and an option in the
Status select, so screen.getByText('Succeeded') matches two nodes. Read from the row.
vi.spyOn re-wraps the same module function, so call history leaks between tests in a file.
vitest.setup.ts restores all mocks — rely on it, do not hand-roll.
- A status the union does not list falls into the success branch.
refused once rendered as five
green segments. runStatusLabel, runStatusPill and isRunUnfinished already default an unknown
value to unknown — reuse them, never re-implement.
Acceptance
Part of #147
Goal
/runsupdates itself. No F5.New:
spire-ui/src/useLiveRuns.tsModel it on
spire-ui/src/useLiveReviews.ts. Copy the socket lifecycle rather than reinventingit — every branch in it was paid for by a live incident:
wsDeliveredrefoncloseasksfetchMe()before reconnectingneedsLogin(me)->goToFullLogin()isLeavingForAuth()guards the error bannersetTimeout(connect, 1500)Frame handling, same as reviews:
runId-> upsert byrunId.typeof d.runId === 'string'); a row without an idproduces undefined or duplicate React keys and an unmergeable row.
Sort by
startedAtdescending withrunIdas tiebreak — the same orderFactoryRunProjection.list()uses, so the client never re-orders the server's page.Change:
spire-ui/src/components/Runs.tsxgetRunseffect. Read fromuseLiveRuns().doing it this way: a run that changes into the active filter now appears on its own. A
server-side filter cannot do that, because the socket would have to know each client's filter.
getRunsinapi.ts. Other callers use it and the endpoint stays public.Startedheader toQueued(see the sibling task onagent_started_at).Traps
This has bitten this file three times — most recently when the new Proposed column supplied the
dash the cost test was asserting on. Scope every assertion to a row or a cell:
within(row).getAllByRole('cell').Array.at(-1)is not in this project's TS lib target. Usecells[cells.length - 1].Succeededis both a status pill and an option in theStatus select, so
screen.getByText('Succeeded')matches two nodes. Read from the row.vi.spyOnre-wraps the same module function, so call history leaks between tests in a file.vitest.setup.tsrestores all mocks — rely on it, do not hand-roll.refusedonce rendered as fivegreen segments.
runStatusLabel,runStatusPillandisRunUnfinishedalready default an unknownvalue to unknown — reuse them, never re-implement.
Acceptance
goToFullLogin()and does not reconnect.Mutation-verify: reconnect unconditionally, this test must fail.
cd spire-ui && npm testgreen;npx tsc --noEmitsilent.Part of #147