Skip to content

FE-764: Brunch<> Petrinaut stream — ephemeral orchestrator hosted SSE server#165

Merged
kostandinang merged 23 commits into
mainfrom
ka/fe-764-petri-sync-server
Jun 8, 2026
Merged

FE-764: Brunch<> Petrinaut stream — ephemeral orchestrator hosted SSE server#165
kostandinang merged 23 commits into
mainfrom
ka/fe-764-petri-sync-server

Conversation

@kostandinang

@kostandinang kostandinang commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

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 cook run 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: definitioninitial_state → all firings so far → live firings → terminal.

Changes

  • BrunchExecutionExport contract types + reducer (petrinaut-stream-export.ts)
  • --petrinaut-fold=color|identity cook CLI flag (default identity); shared NetFolding seam between static export and live stream (cook-cli.ts, petrinaut-fold.ts, engine.ts)
  • In-process stream bus with replay buffer + PetrinautEventBrunchExecutionExportFrame translator (petrinaut-stream-bus.ts)
  • Ephemeral HTTP/SSE server over the bus, localhost-only, one GET /stream route (petrinaut-stream-server.ts)
  • --petrinaut-stream cook wiring with multi-tier base-URL (CLI flag > PETRINAUT_BASE_URL env > hard fail), launcher-URL composer, auto-open via open package; server.stop() in finally (cook-cli.ts, petrinaut-launcher-url.ts, engine.ts)
  • OrchestratorInput.setupPetrinautStream awaited factory hook so the SSE server is listening before initial_marking fires (types.ts, engine.ts)
  • loadLocalEnvShellWins local .env loader (shell-wins precedence, only loaded when streaming)
  • .env.example line for PETRINAUT_BASE_URL

Web-UI button + endpoint discovery left as a follow-up (memory/CARDS.md slice 5 sketch).

How to try it

# one-time setup
echo 'PETRINAUT_BASE_URL=https://your-petrinaut.example/import' >> .env

# run cook with live stream
brunch cook <dir> --petrinaut-stream
# → prints launcher URL, auto-opens browser

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=true also suppresses)
  • --petrinaut-fold=color|identity — projection mode (default identity; orthogonal to streaming)

Base-URL resolution: CLI flag > PETRINAUT_BASE_URL env > hard fail before any cook side effect.

Endpoints (cook-hosted, ephemeral, localhost-only)

http://127.0.0.1:<ephemeral-port>
  ├── GET     /stream  →  200 text/event-stream  (replay → live → close on terminal)
  ├── OPTIONS /stream  →  204 CORS preflight (GET, OPTIONS, *)
  └── *                →  404 Not Found

SSE frame shape (one event per BrunchExecutionExportFrame):

event: definition       data: {"version":1,"meta":…,"places":[…],"transitions":[…]}
event: initial_state    data: {"<placeId>": <number | TokenColour[]>, …}
event: transition_firing data: {"transitionId":"…","input":…,"output":…,"ts":"…"}
event: terminal         data:

Curl test guide

Grab <port> from the cook stderr, then:

URL=http://127.0.0.1:<port>/stream

curl -N $URL                       # live feed (closes on terminal frame)
curl -sI $URL                      # headers — text/event-stream + CORS
curl -sX OPTIONS -D - $URL -o-     # CORS preflight — 204
curl -sI $URL/nope                 # 404

Late-connect replay: the same curl -N $URL after the run finishes still receives the full timeline (definitioninitial_state → all firings → terminal) before the connection closes.

Architecture

brunch cook  ──┐
               │   compileTopology + folding + sdcpnFile
               ▼
       ╭───────────────╮   await   ╭──────────────────────╮
       │ engine.run    │──────────▶│ setupPetrinautStream │
       │               │           │ ├─ createBus()        │
       │ emit events ──┼──────────▶│ ├─ createServer()     │
       │               │   onEvent │ ├─ server.start() (await)
       │               │           │ ├─ composeLauncherUrl()
       ╰───────────────╯           │ ├─ openUrl()          │
                                   │ ╰─ return bus.publish │
                                   ╰──────────────────────╯
                                              │
                                       SSE /stream
                                              ▼
                                      Petrinaut client

Key invariant: the SSE server is listening before the engine emits initial_marking. Enforced by an await-ordering test in engine-contract.test.ts.

Petrinaut contract alignment (verified against hashintel/hash)

  • MarkingInitialMarking byte-for-byte (petrinaut-core/simulation/api.ts)
  • NetDefinition is a tight subset of sdcpnFileSchema; missing fields default to [] in parseSDCPNFile — can reuse the existing parser unchanged
  • Field names match (colorId, inputArcs, outputArcs, lambdaType, lambdaCode, transitionKernelCode, dynamicsEnabled, differentialEquationId); schema version: 1 matches SDCPN_FILE_FORMAT_VERSION
  • No live/SSE consumer exists in Petrinaut yet

Out 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-ID resume · SSE keep-alive.

Verification

npm run verify — 1571 tests green. Real-HTTP coverage in petrinaut-stream-server.test.ts; cook-CLI lifecycle in cook-cli.test.ts; await-ordering invariant in engine-contract.test.ts.

Comment thread src/orchestrator/src/petrinaut-stream-server.ts Outdated
@kostandinang kostandinang marked this pull request as ready for review June 3, 2026 06:33
@cursor

cursor Bot commented Jun 3, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Introduces cook-time network I/O, browser auto-open, and strict engine/setup ordering before first Petrinaut events; mitigated by localhost bind, opt-in flag, and pre-flight base-URL failure.

Overview
Adds FE-764 end-to-end Petrinaut live streaming for brunch cook: an opt-in --petrinaut-stream path boots a localhost-only ephemeral SSE server (GET /stream) that replays then streams BrunchExecutionExport frames (definitioninitial_state → firings → terminal).

New plumbing includes reduceBrunchExecutionExport, an in-process createPetrinautStreamBus (replay-on-subscribe), createPetrinautStreamServer, and pure resolvePetrinautBaseUrl / composeLauncherUrl. runCook resolves PETRINAUT_BASE_URL (CLI > env > hard fail before cook side effects), prints/opens a launcher URL (runId, mode=actual, sse=…), and server.stop() in finally.

--petrinaut-fold=color|identity (default identity) plus createIdentityFolding share one NetFolding between on-disk export and the live stream via OrchestratorInput.petrinautFold. The engine awaits setupPetrinautStream before initial_marking, and happy-path runs now emit net_completed through the Petrinaut event/SSE path.

Docs: .env.example, memory/PLAN.md, memory/SPEC.md (identity fold), and memory/CARDS.md (slice status).

Reviewed by Cursor Bugbot for commit c1203f3. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment thread src/orchestrator/src/engine.ts
@kostandinang kostandinang changed the title FE-764: Brunch → Petrinaut live stream — ephemeral cook-hosted SSE server FE-764: Brunch<> Petrinaut stream — ephemeral orchestrator hosted SSE server Jun 3, 2026
@kostandinang kostandinang self-assigned this Jun 3, 2026
@kostandinang kostandinang requested review from kube and lunelson June 3, 2026 07:00
@kostandinang kostandinang force-pushed the ka/fe-764-petri-sync-server branch from fe3de23 to b81899c Compare June 3, 2026 07:12
Comment thread src/orchestrator/src/petrinaut-stream-bus.ts
Comment thread src/orchestrator/src/engine.ts
Comment thread src/orchestrator/src/engine.ts
lunelson
lunelson previously approved these changes Jun 8, 2026

@lunelson lunelson left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. cook becomes a network host. brunch cook was 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.

  2. Petrinaut-specific hooks on the generic engine contract. OrchestratorInput now carries petrinautFold, onPetrinautEvent, and setupPetrinautStream — and the last returns a callback that's fanned out alongside onPetrinautEvent, 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?

  3. Producer ahead of consumer. Since no live Petrinaut client consumes this yet, the transition_firing frames in particular are unexercised by a real reader. The definition frame 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.

kostandinang and others added 11 commits June 8, 2026 13:26
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>
@kostandinang kostandinang force-pushed the ka/fe-764-petri-sync-server branch from f043d6e to cea1d2a Compare June 8, 2026 11:29
Comment thread src/orchestrator/src/petrinaut-launcher-url.ts
Comment thread src/orchestrator/src/cook-cli.ts Outdated
Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread src/orchestrator/src/cook-cli.ts
Co-authored-by: Cursor <cursoragent@cursor.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

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

Comment thread src/orchestrator/src/cook-cli.ts
@kostandinang kostandinang requested a review from lunelson June 8, 2026 12:04
Co-authored-by: Cursor <cursoragent@cursor.com>
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.

2 participants