Skip to content

Serve the experiment, and stream it as it happens - #80

Merged
DeDuva merged 1 commit into
replatform-0.11from
feat/squad-lab-server
Aug 6, 2026
Merged

Serve the experiment, and stream it as it happens#80
DeDuva merged 1 commit into
replatform-0.11from
feat/squad-lab-server

Conversation

@DeDuva

@DeDuva DeDuva commented Aug 6, 2026

Copy link
Copy Markdown
Owner

M5 — the lab becomes a server. Fastify on loopback :7317, no auth in
the slice
(stated, not implied), with experiment routes, a live SSE stream,
and a read-only window onto ADP.

POST /api/experiments            POST /api/experiments/:id/launch      (202)
GET  /api/experiments            POST /api/experiments/:id/cancel
GET  /api/experiments/:id        POST /api/experiments/:id/variants/:vid/regrade
GET  /api/experiments/:id/summary
GET  /api/experiments/:id/stream (SSE, Last-Event-ID honoured)
GET  /adp/…                      (six literal read patterns)

The stream, which is where the sharp edges are

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. 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:

  • Subscribe before reading the file. Reading and then subscribing loses
    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.
  • Heartbeats carry no id:. They keep an idle socket open; they are not
    replayable content. Numbering one would push the client's resume point past a
    frame that never had any.

bus frames carry the SquadEvent verbatim plus variantId, so
runtime/event-payloads.ts stays 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:read token reads every repository on the instance — so a wildcard
pass-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 a
moment 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 lint and 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:

ok  the lab booted with two distinct identities
ok  an experiment was created against real ADP — issue 1, intent 4bbc3317
ok  ids arrive and are strictly increasing from 1 — [1,2,3,4]
ok  the reconnect resumes at the next frame — resumedAt 3, expected 3
ok  no gap: the two halves cover the whole stream — [1,2] + [3,4]
ok  no duplicate: nothing appears in both halves
ok  an idle connection heartbeats · heartbeats carry no id
ok  the ADP proxy reads through the lab, and the token never crosses it
ok  a route outside the six is not served

M4's grade-check still 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-ID test caught exactly that.

Scope

@fastify/static and the SPA are deliberately not here — there is no SPA to
serve until M6, and an unused static mount is dead weight. M5 is the server the
SPA will talk to.

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
DeDuva marked this pull request as ready for review August 6, 2026 04:17
@DeDuva
DeDuva merged commit d476c0f into replatform-0.11 Aug 6, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant