FE-764: Brunch<> Petrinaut stream — ephemeral orchestrator hosted SSE server#165
Conversation
1c21816 to
bfb687f
Compare
424ec07 to
7cc9491
Compare
PR SummaryMedium Risk Overview New plumbing includes
Docs: Reviewed by Cursor Bugbot for commit c1203f3. Bugbot is set up for automated code reviews on this repo. Configure here. |
fe3de23 to
b81899c
Compare
lunelson
left a comment
There was a problem hiding this comment.
Really solid work — the replay-equivalence oracle (proving the live stream and the static export reduce to the same thing), the await-ordering invariant, the real-HTTP server tests, and verifying the wire shape against the actual Petrinaut repo all give a lot of confidence in the parts that have a counterpart to check against.
A few things above the code level, all questions rather than asks:
-
cookbecomes a network host.brunch cookwas a batch CLI; with this it can stand up an HTTP listener. It's nicely contained — localhost-only, ephemeral, off by default — but it's a genuine posture change. Is "cook hosts its own transient services" a direction we want to lean into, or is this a one-off for Petrinaut? Worth being deliberate about, since the next feature may reach for the same pattern. -
Petrinaut-specific hooks on the generic engine contract.
OrchestratorInputnow carriespetrinautFold,onPetrinautEvent, andsetupPetrinautStream— and the last returns a callback that's fanned out alongsideonPetrinautEvent, so two of the three overlap. The generic orchestrator also now names a specific consumer in its core input type. Could the streaming concern collapse to a single seam, or sit behind a generic event sink that the Petrinaut layer adapts, so the engine stays consumer-agnostic? -
Producer ahead of consumer. Since no live Petrinaut client consumes this yet, the
transition_firingframes in particular are unexercised by a real reader. Thedefinitionframe is pinned to the real schema, which helps. Is the live-frame shape firm enough to commit to, or worth treating as provisional until a consumer lands?
One observation, not a concern: streaming pushed pi-actions from spawnSync to async spawn so the loop can flush frames — sensible, just noting the agent-execution path's concurrency model changed in service of a viz feature.
Approving — none of this blocks the merge.
Full scope card. Thin HTTP shell — the load-bearing pub/sub + frame
translation already lives in petrinaut-stream-bus.ts.
- New petrinaut-stream-server.ts: createPetrinautStreamServer({ bus, host?, port? })
returns { start, stop } over a real Node http.Server bound on
127.0.0.1:<ephemeral>; one route GET /stream serves SSE.
- One bus subscription per HTTP connection; replay on connect, live
frames, close after terminal.
- Lifecycle: subscribe-on-open, unsubscribe-on-close, idempotent stop.
- 404 for non-/stream routes; OPTIONS /stream returns CORS preflight.
- Localhost-only bind (CORS-permissive is safe).
- No Last-Event-ID resume (buffer IS the timeline); no keep-alive in v1.
- Tested with real http.createServer + listen(0) + Node fetch (no mocks).
Slice deliberately does NOT touch runCook — slice 4 wires the server in
behind --petrinaut-stream so 3b stays pure HTTP and 4 becomes a one-line
glue.
Co-authored-by: Amp <amp@ampcode.com>
- New petrinaut-stream-server.ts: createPetrinautStreamServer({ bus, host?, port? })
returns { start, stop, connectionCount } over a real Node http.Server bound
on 127.0.0.1:<ephemeral>.
- One route GET /stream returns text/event-stream; one bus subscription per
HTTP connection; replay-on-connect (synchronous), live frames, res.end()
after terminal frame.
- OPTIONS /stream returns 204 with CORS preflight; non-/stream returns 404.
- stop() ends every in-flight response then server.close(); idempotent.
- 13-test suite using real http.createServer + listen(0) + Node fetch — no
HTTP mocks. Covers wire conformance, replay (terminated + mid-stream
buses), concurrent connections, AbortController-disconnect-unsubscribes,
404, OPTIONS, idempotent stop, in-flight cleanup on stop.
- runCook integration deferred to slice 4 (the --petrinaut-stream gate);
3b ships only the transport module.
npm run verify green (1551 tests).
Co-authored-by: Amp <amp@ampcode.com>
…URL + auto-open Co-authored-by: Amp <amp@ampcode.com>
…eam hook, conditional .env load, companion-flag validation, drop redundant HTTP e2e Co-authored-by: Amp <amp@ampcode.com>
…to-open - New petrinaut-launcher-url.ts (pure resolver + URL composer, 8 tests) - OrchestratorInput.setupPetrinautStream: awaited factory hook so the SSE server is listening before the engine emits initial_marking; engine refactor decouples stream setup from FE-762 best-effort file writes - createPetrinautStreamSetup factory in cook-cli.ts: bus + server + open lifecycle with injected seams; server.stop() in finally - Three new flags: --petrinaut-stream, --petrinaut-base-url=<url>, --no-petrinaut-open (companion-flag validation: stream-only flags hard error without --petrinaut-stream) - loadLocalEnvShellWins: orchestrator-local .env loader with shell-wins precedence (matches standard dotenv tooling); only loaded when streaming - Multi-tier base-URL resolution: CLI flag > PETRINAUT_BASE_URL env > hard fail; resolved BEFORE banner/loadPlan/createSandbox - .env.example gains PETRINAUT_BASE_URL with documentation comment - Engine-contract test asserts await-ordering invariant (no events emitted before the setup hook resolves) - npm run verify green (1571 tests) Co-authored-by: Amp <amp@ampcode.com>
`runPi` used `spawnSync`, freezing the shared event loop for the 12–86s each evaluate/tests/code step takes. The FE-764 SSE stream server lives on that same loop, so connections were accepted at the kernel layer but never serviced — curl saw `Connected` then total silence, no `definition` frame. Convert `runPi` to async `spawn` (same args, 300s timeout, 10MB stdout cap, identical error messages) and `await` it at all four call sites. Also swap verify-epic's `bun test` `execSync` for async `execAsync`, since it blocked the same loop during epic verification. The loop now stays free, so buffered frames flush on connect and transition firings stream live. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
The stream server bound a kernel-chosen ephemeral port every run, so the launcher URL / Petrinaut consumer couldn't target a stable endpoint. Add `resolvePetrinautStreamPort`: a set, valid `PORT` pins the bind port; unset or blank keeps the dynamic behaviour; an invalid value throws loudly. Thread the resolved port through `createPetrinautStreamSetup` into the default server factory. Note: `PORT` doubles as the backend's fallback port var (`resolveBackendPort`), so pinning it binds both — documented inline. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
f043d6e to
cea1d2a
Compare
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 1206275. Configure here.
Co-authored-by: Cursor <cursoragent@cursor.com>


Screen Recording 2026-06-02 at 11.18.34 PM.mov (uploaded via Graphite)
Linear: FE-764 · Stack:
main ← … ← ka/fe-784 (PR #160) ← ka/fe-764 (this)Stream an executing
brunch cookrun into Petrinaut's read-only "actual/live" tab over one SSE connection hosted by the cook process itself. Ephemeral, localhost-only, dies with the run, persists nothing. Replay-on-connect:definition→initial_state→ all firings so far → live firings →terminal.Changes
BrunchExecutionExportcontract types + reducer (petrinaut-stream-export.ts)--petrinaut-fold=color|identitycook CLI flag (defaultidentity); sharedNetFoldingseam between static export and live stream (cook-cli.ts,petrinaut-fold.ts,engine.ts)PetrinautEvent→BrunchExecutionExportFrametranslator (petrinaut-stream-bus.ts)GET /streamroute (petrinaut-stream-server.ts)--petrinaut-streamcook wiring with multi-tier base-URL (CLI flag >PETRINAUT_BASE_URLenv > hard fail), launcher-URL composer, auto-open viaopenpackage;server.stop()infinally(cook-cli.ts,petrinaut-launcher-url.ts,engine.ts)OrchestratorInput.setupPetrinautStreamawaited factory hook so the SSE server is listening beforeinitial_markingfires (types.ts,engine.ts)loadLocalEnvShellWinslocal.envloader (shell-wins precedence, only loaded when streaming).env.exampleline forPETRINAUT_BASE_URLWeb-UI button + endpoint discovery left as a follow-up (
memory/CARDS.mdslice 5 sketch).How to try it
Flags:
--petrinaut-stream— boot the SSE server + compose URL (opt-in; default off)--petrinaut-base-url=<url>— override env (requires--petrinaut-stream)--no-petrinaut-open— suppress auto-open; URL still prints (CI=truealso suppresses)--petrinaut-fold=color|identity— projection mode (defaultidentity; orthogonal to streaming)Base-URL resolution: CLI flag >
PETRINAUT_BASE_URLenv > hard fail before any cook side effect.Endpoints (cook-hosted, ephemeral, localhost-only)
SSE frame shape (one event per
BrunchExecutionExportFrame):Curl test guide
Grab
<port>from the cook stderr, then:Late-connect replay: the same
curl -N $URLafter the run finishes still receives the full timeline (definition→initial_state→ all firings →terminal) before the connection closes.Architecture
Key invariant: the SSE server is listening before the engine emits
initial_marking. Enforced by an await-ordering test inengine-contract.test.ts.Petrinaut contract alignment (verified against
hashintel/hash)Marking≡InitialMarkingbyte-for-byte (petrinaut-core/simulation/api.ts)NetDefinitionis a tight subset ofsdcpnFileSchema; missing fields default to[]inparseSDCPNFile— can reuse the existing parser unchangedcolorId,inputArcs,outputArcs,lambdaType,lambdaCode,transitionKernelCode,dynamicsEnabled,differentialEquationId); schemaversion: 1matchesSDCPN_FILE_FORMAT_VERSIONOut of scope (v1)
Write-back / editing · persistent runs DB · auth · non-localhost transport · colour SDCPN (waits on Petrinaut H-6518/H-6519) · discovery endpoint ·
Last-Event-IDresume · SSE keep-alive.Verification
npm run verify— 1571 tests green. Real-HTTP coverage inpetrinaut-stream-server.test.ts; cook-CLI lifecycle incook-cli.test.ts; await-ordering invariant inengine-contract.test.ts.