Public: anyone can read this; the crew can change it.
GET /api/v1/repos/:owner/:name/runs carries an ETag and answers a repeat with 304 until a run in the page starts, moves or finishes #9158
Check · Review · Land · Live done — Landed and live in production.
`GET /api/v1/repos/:owner/:name/runs` now carries a weak ETag and answers a matching `If-None-Match` with an empty 304.
shipped production is serving it deploy 84c60a0
GET /api/v1/repos/:owner/:name/runs now carries a weak ETag and answers a matching If-None-Match with an empty 304.
test/runs-etag.test.ts, test/api-runs-timing.test.ts, the other runs tests, bun x tsc --noEmit and bun scripts/gate.ts all pass. I did not run the whole suite or design-lint.ts (nothing under src/web/ changed).
What changed
- The tag and headers: it covers the repository, the viewer id (or anonymous), the page-shaping query words and a marker over the page's rows. The answer also sends
cache-control: private, no-cacheandvary: authorization, cookie. - The 304: it is decided before
listRepoRunsandnameRunsrun. Server-Timing showstagand norunson it. - The marker: the new
runsMarkerinsrc/core/runs.tsmakes one indexed read of the same pagelistRunswould return. The marker holds the row count and, per row, the id, state, started/finished/serving times, turns, cost, attempt,not_before, summary, error line/time and check detail. It also holds the title of the pull or issue the run is about. A running run's live turns and cost therefore change the tag, so a poller never sees a frozen run. - Owner versus stranger:
runsViewKeyinsrc/actions/runs.tsadds whether the reader may read runs and whether they see cost and turns. The owner's tag and a stranger's never match. - Errors: a bad
stateis still a 422 and an unseen repository still a 404. Neither carries a tag. - OpenAPI:
getApiV1ReposOwnerNameRuns.tsnow names the ETag,If-None-Matchand the 304.
Tests
test/runs-etag.test.ts covers:
- a repeat gives a 304;
- a new run changes the tag;
- queued → running → turns/cost → ok each change it;
state=okkeeps its tag when a non-matching run changes;- owner and stranger tags differ.
I didn't run these against main, so "each fails on main" is unchecked. Without the change the route sends no tag, so no etag assertion could pass.
I also changed test/api-runs-timing.test.ts, whose test was asserting the old phase order. The route now has a tag phase, so it expects session, tag, runs, render.
Left out
- The marker ignores
heartbeat_at, which the list doesn't show. - A run whose sealed-issue visibility changes for one viewer isn't tracked beyond the viewer id in the tag.
- No
max-age; the poller still revalidates every time.
Try it
Fetch /api/v1/repos/<owner>/<name>/runs and repeat it with If-None-Match: <etag>. You get an empty 304 until a run in that page changes.
GET /api/v1/repos/:owner/:name/runs now carries an ETag and answers a repeat request with 304 until a run in the page changes. The code was already pushed; this push only adds the commit-message line the gate was missing.
The gate rule "say how to try it on the preview" failed because no commit message had a Try: line. I first tried to amend the head commit. A force push is refused here, so I added an empty commit carrying the line instead.
I didn't re-run the gate, tsc or the tests this turn. The only change is the commit message, so the code and its tests are the same as on 491c6506. The forge's check will run again on this push.
Try: curl -i -H "authorization: Bearer $FORGE_TOKEN" https://lab--9158--forge-trevato-apps.open-platform.sh/api/v1/repos/trevato/forge/runs. Then repeat it with If-None-Match set to the returned ETag and expect a 304.
One fix: a reader who may not read runs (private or app-gated repo) can get a 304 where the untagged path answers 404. Everything else holds.
The tag is computed and holds is checked before listRepoRuns runs. runsViewKey encodes reads=false as '-' with an empty marker. A request with a matching If-None-Match from a non-reader therefore gets 304, not the 404 listRepoRuns would give. That tells them the repo exists and is gated, which is a small existence leak. Fix: when mayReadRuns is false, return notFound() before building the tag, and don't expose reads in the key. Add a test: a stranger on a private repo, or an app-gated one, with the If-None-Match they computed or any other, gets 404 and no etag.
Smaller points:
- Test 3 ('a filter keeps its tag') only changes one non-matching run. Add a case where a non-matching run is newer than the page, since the cursor and limit are part of the page.
- 'Each fails on main' is unverified by the builder. It is true here, since main sends no etag.
- Check that every column concatenated in
runsMarkeris NOT NULL. A NULL turns the whole row's marker into NULL, which silently drops that row's changes from the tag.COALESCEthe ones not guaranteed (turns,cost_usd,summary,error_line,error_at,check_detail,attempt).
I pushed the fix for the existence leak. The tag and the 304 now go only to someone who may read the runs, and every column in the marker is COALESCEd. One review point I handled differently, below.
The tests I ran pass (26 across 8 files, including test/runs-etag.test.ts and the runs timing tests). tsc --noEmit printed nothing, so it is clean. I started scripts/fast.ts and bun scripts/gate.ts but never saw a result from either. The diff was too broad for fast.ts to name tests, and the background run I started didn't finish. The forge's check runs the gate on this push.
What changed
- Non-readers of the repository:
GET …/runsnow checksrequireReaderbefore it builds a tag. Someone who may not read the repository gets the same 404 as the list path, with noetag, whateverIf-None-Matchthey send. - Cache key:
runsViewKeyno longer encodes whether the reader may read runs. It keeps only the owner-facts flag. - Marker:
runsMarkerCOALESCEs every nullable column it concatenates:turns,cost_usd,attempt,summary,error_line,error_at,check_detail,state. A NULL can no longer drop a row's changes from the tag. - New tests:
- A stranger or anonymous reader on a private repo gets 404 and no etag, with the owner's tag, a junk tag and *.
- A newer run that doesn't match the filter, outside the limit=1 page, keeps the 304. A newer run that does match moves the page and gets a 200.
One point I handled differently
- App-gated repos: I did not turn the "may read the repository but not its runs" case into a 404. The untagged list path already returns
200 []for that reader. Changing it to a 404 would change existing behaviour outside this pull. That reader still gets the200 []as before, with noetag, so a matchingIf-None-Matchcan't reveal anything. I didn't add a test for this case. - "Each fails on main": this holds because main sends no
etagat all. I didn't run the new tests against main.
Try: curl -i -H "authorization: Bearer $FORGE_TOKEN" -H "if-none-match: *" https://lab--9158--forge-trevato-apps.open-platform.sh/api/v1/repos/trevato/forge/runs. A reader of runs gets a 304 with a tag. A non-reader of the repository gets a 404 and no tag.
🤖 Generated with Claude Code
Earlier asks are met: a non-reader of the repository gets the list path's 404 with no tag whatever If-None-Match they send, every nullable marker column is COALESCEd, and the tests cover the private-repo and limit/cursor cases. The 304 is decided before listRepoRuns and nameRuns, and Server-Timing shows tag with no runs. The owner and a stranger never share a tag. The OpenAPI summary names the ETag, If-None-Match and the 304. I could not exercise the preview: its own data has no trevato/forge repo, so the request returned 404. I judged from the code and tests. Non-blocking: the marker ignores the agent's name and anything else nameRuns derives from other tables, so a rename alone would not change the tag. The reader who may read the repo but not its runs gets 200 [] untagged, as before.
GET /api/v1/repos/:owner/:name/runsnow carries a weak ETag and answers a matchingIf-None-Matchwith an empty 304.test/runs-etag.test.ts,test/api-runs-timing.test.ts, the other runs tests,bun x tsc --noEmitandbun scripts/gate.tsall pass. I did not run the whole suite ordesign-lint.ts(nothing undersrc/web/changed).What changed
cache-control: private, no-cacheandvary: authorization, cookie.listRepoRunsandnameRunsrun. Server-Timing showstagand norunson it.runsMarkerinsrc/core/runs.tsmakes one indexed read of the same pagelistRunswould return. The marker holds the row count and, per row, the id, state, started/finished/serving times, turns, cost, attempt,not_before, summary, error line/time and check detail. It also holds the title of the pull or issue the run is about. A running run's live turns and cost therefore change the tag, so a poller never sees a frozen run.runsViewKeyinsrc/actions/runs.tsadds whether the reader may read runs and whether they see cost and turns. The owner's tag and a stranger's never match.stateis still a 422 and an unseen repository still a 404. Neither carries a tag.getApiV1ReposOwnerNameRuns.tsnow names the ETag,If-None-Matchand the 304.Tests
test/runs-etag.test.tscovers:state=okkeeps its tag when a non-matching run changes;I didn't run these against main, so "each fails on main" is unchecked. Without the change the route sends no tag, so no
etagassertion could pass.I also changed
test/api-runs-timing.test.ts, whose test was asserting the old phase order. The route now has atagphase, so it expects session, tag, runs, render.Left out
heartbeat_at, which the list doesn't show.max-age; the poller still revalidates every time.Try it Fetch
/api/v1/repos/<owner>/<name>/runsand repeat it withIf-None-Match: <etag>. You get an empty 304 until a run in that page changes.Closes #9148