Serve the experiment, and stream it as it happens - #80
Merged
Conversation
M5: the lab becomes a server. Fastify on loopback :7317, no auth in the slice — stated rather than implied — with experiment routes, an SSE stream, and a read-only window onto ADP. **The stream is the part with sharp edges.** Every frame a browser can receive is appended to one `events.jsonl` first and numbered as it lands, so `Last-Event-ID` survives a page refresh *and* a lab restart. Numbering from an in-memory counter would restart at 1 and hand a reconnecting client frames it had already rendered under ids it had already seen. Two rules keep the seam honest, and both fail silently when wrong: - **Subscribe before reading the file.** A client that reads and *then* subscribes loses whatever was appended in between — a gap a browser cannot distinguish from a frame that never existed. This subscribes first, buffers, and drops what the read already covered, so the seam yields neither a gap nor a duplicate. - **Heartbeats carry no `id:`.** They keep an idle socket open; they are not replayable content. Numbering one would advance the client's resume point past a frame that never had any. **The proxy is why "ADP has no CORS" is a decision rather than a gap.** Six literal read patterns, with owner and repo resolved from the experiment record server-side — never from the caller — because ADP has no per-repo authz and a leaked `repo:read` token reads every repository on the instance. A wildcard pass-through would have handed a browser the lab's token by proxy. **Cancel signals, escalates, then closes the books.** SIGTERM first, because `session.abort()` is cooperative and a backend given a moment unwinds its own turn; SIGKILL after the grace, because a wedged native call will not. Open runs are then **abandoned, not closed** — a cancelled variant produced no commit, and closing against nothing would sign an attestation about no particular state. One design fix the tests forced: the frame-log cache is per-server and keyed by the file it writes to, not module-global and keyed by experiment id. An id does not name a file, and two labs in one process would have shared an entry pointing at a path that was no longer theirs — replay silently returning nothing.
DeDuva
marked this pull request as ready for review
August 6, 2026 04:17
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
M5 — the lab becomes a server. Fastify on loopback
:7317, no auth inthe slice (stated, not implied), with experiment routes, a live SSE stream,
and a read-only window onto ADP.
The stream, which is where the sharp edges are
Every frame a browser can receive is appended to one
events.jsonlfirstand numbered as it lands, so
Last-Event-IDsurvives a page refresh and alab restart. An in-memory counter would restart at 1 and hand a reconnecting
client frames it had already rendered, under ids it had already seen.
Two rules keep the reconnect seam honest, and both fail silently when wrong:
anything appended in between — a gap a browser cannot tell apart from a frame
that never existed. This subscribes first, buffers, and drops what the read
already covered, so the seam produces neither a gap nor a duplicate.
id:. They keep an idle socket open; they are notreplayable content. Numbering one would push the client's resume point past a
frame that never had any.
busframes carry theSquadEventverbatim plusvariantId, soruntime/event-payloads.tsstays the contract for both sides.The proxy, and why no CORS is a decision
Six literal patterns, with owner and repo resolved from the experiment record
server-side, never from the caller. ADP has no per-repo authz — a leaked
repo:readtoken reads every repository on the instance — so a wildcardpass-through would have handed a browser the lab's token by proxy.
Cancel
SIGTERM, 5 s, SIGKILL:
session.abort()is cooperative, so a backend given amoment unwinds its own turn, but a wedged native call will not. Open runs are
then abandoned, not closed — a cancelled variant produced no commit, and
closing against nothing would sign an attestation about no particular state.
Abandoning keeps the trajectory, which is the part worth reading afterwards.
Verification
npx vitest run test/lab-*.test.ts test/adp-trajectory-recorder.test.ts→115 passing (21 new).
npm run lintand the squad-lab typecheck are clean.Live, against a real ADP 0.2.0 with no model invoked
(
npm run stream-check -w @deduvafork/squad-lab) — this is M5's own check:M4's
grade-checkstill passes unchanged against the same server.One design fix the tests forced
The frame-log cache is per-server and keyed by the file it writes to, not
module-global keyed by experiment id. An id does not name a file: two labs in
one process would have shared an entry pointing at a path that was no longer
theirs, and replay would have quietly returned nothing. The
Last-Event-IDtest caught exactly that.Scope
@fastify/staticand the SPA are deliberately not here — there is no SPA toserve until M6, and an unused static mount is dead weight. M5 is the server the
SPA will talk to.