feat(ci): live CI page — fleet event stream, live test results, in-flight first #552

Merged
gmackie merged 1 commit from feat/ci-row-app-and-pr-first into main 2026-09-04 18:56:06 +00:00
Owner

Makes /ci a live view of current work. Three parts: a layout that puts in-flight work first, a fleet-wide event channel, and live test results that actually arrive.

Layout

Three zones, because the page answers three questions and only two are urgent.

  • In flight gets cards, a status rail, and a clock ticking against the pipeline's median duration. The rail stops at 94% and the label flips to over ~8:00 rather than implying a completion it cannot know. A queued run gets no clock, just waiting for a runner.
  • Just landed keeps cards and the outcome rail at a smaller scale.
  • Earlier is a plain ledger. It is the part you scan, not the part you look at.

Identity (app + PR, or the branch when there is no PR) is one shared component across all three, so the same two fields lead everywhere and only the type scale changes.

Fleet channel

Every existing broadcast is addressed to pipeline:<appId>. That is right for an app's Pipeline tab and unusable for /ci, which would need a socket per app across 77 of them. Queued and running Forgejo run progress now also goes to ci:fleet, stamped with the app it came from so a fleet consumer can attribute it.

Live test results — why they never worked

The agent already tails the build worktree's .fg/check-events.ndjson and posts a folded summary every couple of seconds. That already fanned out on apps/web's eventBus, whose own comment says it only works:

because ForgeGraph runs as a single instance

That stopped being true when the control plane moved to Cloudflare Workers. Each request can land in a different isolate, so the isolate receiving the agent's POST is almost never the one holding a browser's SSE connection. The publish succeeded and nobody was listening.

Test progress now also goes over the hub, which is a real cross-instance broker and already carries pipeline events. The eventBus publish stays, because fg watch consumes it.

Consumption

useFleetCiStream subscribes to the one fleet channel. LiveRefresh treats an event as a hint to look, not as data: it re-runs the server render, so every number moves together. A partial live-patch would let the header disagree with the list, which on a status page is worse than being a second late.

  • Bursts are coalesced — test progress is roughly every 2s per running build.
  • A hidden tab disconnects entirely and refreshes once on return.
  • Polling takes over whenever the hub is unreachable, so the page degrades to exactly its previous behaviour rather than going silent. The indicator reads live / polling / paused so the mode is never a guess.

What I found and did not ship

handlers/workflow-run.ts maps a requested run to a queued build and is wired and correct — and cannot fire on this install. Forgejo 15.0.7 has no workflow_run webhook event; a PATCH containing one returns 200 and silently drops it (I probed workflow_run, workflow_job, action_run, actions, status). I wrote and tested a webhook-convergence fix before discovering this and reverted it, because it would have added an event Forgejo discards. That is why the builds table shows a near-empty queue while Forgejo has runs waiting, and it is worth its own decision rather than being buried here.

Verification

packages/api and apps/web typecheck clean. next build succeeds and emits the /ci client-reference manifest. 42 tests pass across the lib suites, including 5 new ones asserting both channels receive run progress and test progress, that the fleet copy carries appId, that an unconfigured hub sends nothing, and that an unreachable hub never rejects the request that produced the event. oxlint reports nothing new on the changed files.

Stacking

Built on fix/runner-label-parsing (#551). Merge that first.

🤖 Generated with Claude Code

Makes `/ci` a live view of current work. Three parts: a layout that puts in-flight work first, a fleet-wide event channel, and live test results that actually arrive. ## Layout Three zones, because the page answers three questions and only two are urgent. - **In flight** gets cards, a status rail, and a clock ticking against the pipeline's median duration. The rail stops at 94% and the label flips to `over ~8:00` rather than implying a completion it cannot know. A queued run gets no clock, just *waiting for a runner*. - **Just landed** keeps cards and the outcome rail at a smaller scale. - **Earlier** is a plain ledger. It is the part you scan, not the part you look at. Identity (app + PR, or the branch when there is no PR) is one shared component across all three, so the same two fields lead everywhere and only the type scale changes. ## Fleet channel Every existing broadcast is addressed to `pipeline:<appId>`. That is right for an app's Pipeline tab and unusable for `/ci`, which would need a socket per app across 77 of them. Queued and running Forgejo run progress now also goes to **`ci:fleet`**, stamped with the app it came from so a fleet consumer can attribute it. ## Live test results — why they never worked The agent already tails the build worktree's `.fg/check-events.ndjson` and posts a folded summary every couple of seconds. That already fanned out on `apps/web`'s `eventBus`, whose own comment says it only works: > because ForgeGraph runs as a single instance **That stopped being true when the control plane moved to Cloudflare Workers.** Each request can land in a different isolate, so the isolate receiving the agent's POST is almost never the one holding a browser's SSE connection. The publish succeeded and nobody was listening. Test progress now also goes over the hub, which is a real cross-instance broker and already carries pipeline events. The `eventBus` publish stays, because `fg watch` consumes it. ## Consumption `useFleetCiStream` subscribes to the one fleet channel. `LiveRefresh` treats an event as **a hint to look, not as data**: it re-runs the server render, so every number moves together. A partial live-patch would let the header disagree with the list, which on a status page is worse than being a second late. - Bursts are coalesced — test progress is roughly every 2s per running build. - A hidden tab disconnects entirely and refreshes once on return. - Polling takes over whenever the hub is unreachable, so the page degrades to exactly its previous behaviour rather than going silent. The indicator reads `live` / `polling` / `paused` so the mode is never a guess. ## What I found and did not ship `handlers/workflow-run.ts` maps a `requested` run to a queued build and is wired and correct — and **cannot fire on this install**. Forgejo 15.0.7 has no `workflow_run` webhook event; a PATCH containing one returns 200 and silently drops it (I probed `workflow_run`, `workflow_job`, `action_run`, `actions`, `status`). I wrote and tested a webhook-convergence fix before discovering this and reverted it, because it would have added an event Forgejo discards. That is why the `builds` table shows a near-empty queue while Forgejo has runs waiting, and it is worth its own decision rather than being buried here. ## Verification `packages/api` and `apps/web` typecheck clean. `next build` succeeds and emits the `/ci` client-reference manifest. 42 tests pass across the lib suites, including 5 new ones asserting both channels receive run progress and test progress, that the fleet copy carries `appId`, that an unconfigured hub sends nothing, and that an unreachable hub never rejects the request that produced the event. `oxlint` reports nothing new on the changed files. ## Stacking Built on `fix/runner-label-parsing` (#551). Merge that first. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
#549 shipped a runner-label coverage check and it has never produced a
single result in production. It guarded with `Array.isArray(row.labels)`,
but `ci_runners.labels` is a text[] that does NOT come back as a
JavaScript array on the deployed stack -- it arrives as the raw Postgres
literal, `{ubuntu-latest,ubuntu-24.04,forgegraph-ci}`. So every row
yielded an empty label list, coverage was empty, and `labelRisks` came
back `[]`.

Verified against production right after the merge: GET /api/fg/ci/queue
returned `labelRisks: []` for a fleet where `ubuntu-latest` was carried
by exactly one online runner (labnuc; vanuc offline since 01:05) -- the
precise condition the check exists to catch, reported as clean.

`apps/web/.../ci/runners/route.ts` already carried a hand parser for this
column, which is the only reason that endpoint renders labels at all. So
the knowledge existed and the new code did not use it. Extract that
parser to packages/api/src/lib/runner-labels.ts, delete the route's local
copy, and have all three readers share it so they cannot drift again.

Also fold in `runnerLabelName`: act_runner registers labels with an
execution suffix (labnuc's config has
`ubuntu-latest:docker://localhost/forgegraph-act:node-24.14.0`) and
Forgejo matches on the part before the first colon, so the suffix has to
be dropped or one pool counts as two.

Tests cover the shapes production actually returns -- the Postgres
literal, a real array, quoted and escaped elements, NULL members, and
non-string input -- rather than only the shape the code assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
feat(ci): lead each CI row with the app and PR, not the pipeline name
Some checks failed
CI / gitleaks (pull_request) Successful in 6s
CI / storybook (pull_request) Successful in 1m36s
CI / ci (pull_request) Has been cancelled
bf1ea8e7fe
The /ci list gave its loudest slot to the least useful field. Every row
titled itself `forgejo:ci` -- the same string on nearly every row, which
identifies nothing -- at 14px semibold, while the app name sat in 10px
grey mono beside the branch, and the PR number appeared only when a
branch happened to be named after one.

Reordered to what actually distinguishes one row from another:

  before   forgejo:ci  PASSED
           streamConductor  fix/dashboard-panels  --  7h ago

  after    streamConductor  #57  PASSED                          2s
           fix/dashboard-panels · forgejo:ci · 7h ago

The app name leads at 15px semibold and the PR number sits next to it as
a link-toned chip. The pipeline name drops to the metadata line with the
branch and timestamp. Rows with no PR put the branch in the PR's slot so
they still identify themselves rather than reading as a bare app name.

Getting a real PR number needed one: `builds` had no link to a pull
request, and the branch field only sometimes holds one. `pull_requests`
matches on (repo, head_ref), but it is unique on (repo, number), so a
branch opened, closed and reopened has several rows and a left join would
silently multiply the build list. Resolved in a second query instead,
newest PR per branch, with a fallback that reads the number straight out
of the branch for `pull_request`-triggered builds that arrive with
sourceBranch already set to "#57".

The matching is extracted to lib/build-pull-requests.ts and tested for
both traps plus the near-misses (a branch like `release-2026` is not a
PR, two repos sharing a branch name stay apart).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
gmackie force-pushed feat/ci-row-app-and-pr-first from bf1ea8e7fe
Some checks failed
CI / gitleaks (pull_request) Successful in 6s
CI / storybook (pull_request) Successful in 1m36s
CI / ci (pull_request) Has been cancelled
to f6e9ca42d2
All checks were successful
CI / gitleaks (pull_request) Successful in 6s
CI / storybook (pull_request) Successful in 1m48s
forgegraph/ci CI passed
CI / ci (pull_request) Successful in 9m3s
2026-09-04 16:22:45 +00:00
Compare
gmackie changed title from feat(ci): lead each CI row with the app and PR, not the pipeline name to feat(ci): give in-flight and just-landed runs the page, demote history 2026-09-04 16:23:08 +00:00
gmackie force-pushed feat/ci-row-app-and-pr-first from f6e9ca42d2
All checks were successful
CI / gitleaks (pull_request) Successful in 6s
CI / storybook (pull_request) Successful in 1m48s
forgegraph/ci CI passed
CI / ci (pull_request) Successful in 9m3s
to 35940f0fc7
Some checks failed
CI / gitleaks (pull_request) Successful in 8s
CI / storybook (pull_request) Successful in 1m14s
CI / ci (pull_request) Has been cancelled
2026-09-04 18:15:40 +00:00
Compare
gmackie force-pushed feat/ci-row-app-and-pr-first from 35940f0fc7
Some checks failed
CI / gitleaks (pull_request) Successful in 8s
CI / storybook (pull_request) Successful in 1m14s
CI / ci (pull_request) Has been cancelled
to 1189df575a
Some checks failed
CI / gitleaks (pull_request) Successful in 6s
CI / storybook (pull_request) Successful in 1m56s
forgegraph/ci CI failed
CI / ci (pull_request) Failing after 7m54s
2026-09-04 18:21:11 +00:00
Compare
gmackie changed title from feat(ci): give in-flight and just-landed runs the page, demote history to feat(ci): live CI page — fleet event stream, live test results, in-flight first 2026-09-04 18:21:31 +00:00
gmackie force-pushed feat/ci-row-app-and-pr-first from 1189df575a
Some checks failed
CI / gitleaks (pull_request) Successful in 6s
CI / storybook (pull_request) Successful in 1m56s
forgegraph/ci CI failed
CI / ci (pull_request) Failing after 7m54s
to 96444cac4c
Some checks failed
CI / gitleaks (pull_request) Successful in 8s
CI / storybook (pull_request) Has been cancelled
CI / ci (pull_request) Has been cancelled
2026-09-04 18:40:12 +00:00
Compare
gmackie changed target branch from fix/runner-label-parsing to main 2026-09-04 18:40:36 +00:00
gmackie force-pushed feat/ci-row-app-and-pr-first from 96444cac4c
Some checks failed
CI / gitleaks (pull_request) Successful in 8s
CI / storybook (pull_request) Has been cancelled
CI / ci (pull_request) Has been cancelled
to 3070c319f3
All checks were successful
CI / gitleaks (pull_request) Successful in 10s
CI / storybook (pull_request) Successful in 2m38s
forgegraph/ci CI passed
CI / ci (pull_request) Successful in 12m48s
2026-09-04 18:40:50 +00:00
Compare
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
gmackie/ForgeGraph!552
No description provided.