Skip to content

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

Open
kostandinang wants to merge 20 commits into
ka/fe-784-petrinaut-colour-foldfrom
ka/fe-764-petri-sync-server
Open

FE-764: Brunch<> Petrinaut stream — ephemeral orchestrator hosted SSE server#165
kostandinang wants to merge 20 commits into
ka/fe-784-petrinaut-colour-foldfrom
ka/fe-764-petri-sync-server

Conversation

@kostandinang
Copy link
Copy Markdown
Contributor

@kostandinang kostandinang commented Jun 2, 2026

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.

@kostandinang kostandinang force-pushed the ka/fe-784-petrinaut-colour-fold branch from 1c21816 to bfb687f Compare June 2, 2026 18:55
kostandinang and others added 14 commits June 2, 2026 21:55
…SE live stream

Drops the static-bundle transport sketch in favour of the design settled at the
2026-06-01 cross-team meeting + 2026-06-02 scoping session:

- Cook process boots an ephemeral HTTP/SSE server on a free port; dies with the
  run; nothing is persisted.
- One stream per session, replay-on-connect: definition (once) → initial_state
  (once) → every firing-so-far → live transition_firing events → terminal.
- Payload is the BrunchExecutionExport contract (definition = SDCPN topology
  minus scenario; initialState/input/output as count Markings, verified
  compatible with Petrinaut's runtime InitialMarking).
- Brunch initiates the session (reuses runId); no discovery endpoint.
- Two trigger surfaces: cook CLI flag (auto-opens URL) and a brunch web-UI
  button.
- Fold mode selectable per run via --petrinaut-fold=color|identity, default
  identity — inverts FE-784's prior default; demo-pragmatic. Mechanism: a
  sibling createIdentityFolding constructor; serializer + event stream stay
  branch-free (NetFolding interface unchanged).

Also folds the FE-761/762/763 status updates and dep-tree refresh that the
reshape rests on, and notes on FE-784's section that color-fold is now opt-in
not the default.

Co-authored-by: Amp <amp@ampcode.com>
Two-slice prepared queue for the FE-764 frontier:
- Slice 1: promote /tmp/reduce-export.mjs into
  src/orchestrator/src/petrinaut-stream-export.ts; define BrunchExecutionExport
  + Marking types; frame-replay oracle on a synthetic 2-slice plan under
  identity folding.
- Slice 2: add createIdentityFolding constructor next to createNetFolding; add
  --petrinaut-fold=color|identity flag to brunch cook (default identity);
  extend SPEC §Lexicon with 'identity fold'.

Later slices (ephemeral SSE server, URL emission + auto-open, web-UI button +
endpoint discovery) get scoped after these two ship.

Co-authored-by: Amp <amp@ampcode.com>
Inline the full TypeScript schema in PLAN.md §petri-sync-server so the
contract is reviewable in-place rather than implied. Three corrections
against the prior draft (caught by user grilling the schema 2026-06-02):

- ts: string (not number) — confirmed in petrinaut-events.ts:53/60/69.
  PetrinautInitialMarkingEvent / TransitionFiredEvent / TerminalEvent all
  carry ts: string; reducer preserves verbatim.
- Marking = Record<PlaceId, number | TokenColour[]> — full sum type, not
  count-arm only. Identity-fold runs populate the count arm, but the type
  permits the colour arm so the same reducer feeds future colour-fold
  consumers without a type widen.
- NetDefinition is a tight 6-field projection of SdcpnFile, not
  Omit<SdcpnFile,'scenarios'>. Drops scenarios + differentialEquations
  + parameters + metrics (none of which Petrinaut's actual view reads).

CARDS.md slice 1 acceptance criteria rewritten to match: every public type
named, sum-type permitted, ts string roundtrip pinned, explicit definition
projection (not Omit), and the 4-field drop tested.

Co-authored-by: Amp <amp@ampcode.com>
…r base URL)

Four orthogonal cook CLI knobs, expanded in PLAN.md §petri-sync-server +
CARDS.md slices 3-5 sketches:

- --petrinaut-stream (opt-in, default off) boots the ephemeral SSE server.
- --petrinaut-fold=color|identity (default identity) controls the projection
  for all Petrinaut artifacts, on-disk + streamed.
- --petrinaut-base-url=<url> overrides the env-derived Petrinaut SPA base for
  one run.
- --no-petrinaut-open suppresses the browser launch; URL still prints.
  Implicit when CI=true.

Base-URL resolution: CLI flag > PETRINAUT_BASE_URL (env, .env-friendly) >
hard fail. No baked-in default — a wrong default silently opens the wrong
tab. Validation runs before the cook engine starts.

URL shape: {baseUrl}?runId={runId}&mode=actual&sse={localEndpoint} (param
names pending alignment with Chris).

CARDS.md gets sketch entries for slices 3 (ephemeral SSE server), 4 (this
CLI surface + base-URL resolution + auto-open via npm 'open'), and 5
(web-UI button + endpoint discovery). Full scope cards land after slices 1+2
ship.

Co-authored-by: Amp <amp@ampcode.com>
…ctor

Promotes /tmp/reduce-export.mjs into the orchestrator as the canonical
export-reducer seam for FE-764's live SSE stream. Mirrors the schema
locked in PLAN.md §petri-sync-server byte-for-byte.

New module src/orchestrator/src/petrinaut-stream-export.ts:
- Public contract types: PlaceId, TokenColour, Marking (sum type), SdcpnInputArc,
  SdcpnOutputArc, SdcpnPlace, SdcpnTransition, NetDefinition, TransitionFiring,
  BrunchExecutionExport.
- Pure reducer reduceBrunchExecutionExport({ sdcpnFile, events }):
  - definition: explicit 6-field projection of SdcpnFile (drops scenarios,
    differentialEquations, parameters, metrics — none read by Petrinaut's
    'actual' view).
  - initialState: count-reduced marking from the initial_marking event;
    throws on missing.
  - transitionFirings: in arrival order; transitionName mapped to
    transitionId; ts preserved verbatim as string; empty per-place keys
    not synthesized.

Adds createIdentityFolding(blueprint) to petrinaut-fold.ts (sibling to
createNetFolding) — id→id maps, no token color decoration, NetFolding
interface unchanged. Slice 2 wires it into the cook CLI via
--petrinaut-fold=color|identity (default identity). Landing it here so
slice 2 only adds CLI surface.

Tests (src/orchestrator/src/petrinaut-stream-export.test.ts, 11 cases):
- schema: envelope shape, 6-field definition projection, byte-for-byte
  passthrough of NetDefinition fields, purity (referential equality across
  two calls).
- markings: count-reduce + empty-place suppression, transitionName →
  transitionId mapping, arrival-order preservation, ts string roundtrip
  (no Date coercion / no number conversion), terminal-event filtering,
  initial_marking missing throws.
- frame-replay oracle: 3-place / 2-transition fixture with deterministic
  firing trace; referential integrity of every keyed place/transition;
  replay invariant (no place count goes negative at any frame); final
  marking sanity (source + middle drain, all tokens land on dst).
- type exports pin every public type name in the contract.

The frame-replay oracle is hand-crafted (not engine-driven) because
serializeBlueprint currently hard-codes createNetFolding; an engine-
driven version under identity folding lands in slice 2 once
serializeBlueprint accepts a folding opt.

Co-authored-by: Amp <amp@ampcode.com>
Per HANDOFF.md retirement rule (branch decision recorded, FE-764 work
committed, next-slice scope card exists, slice 1 shipped), the volatile
transfer state is fully reconciled into PLAN.md + CARDS.md + the contract
schema + the working code; delete the handoff file.

CARDS.md reconciliation:
- Slice 1 marked done with commit ref a41db69; calls out the deferral of
  the engine-driven frame-replay oracle to slice 2.
- Slice 2 narrowed: createIdentityFolding already landed in slice 1, so
  slice 2 is just CLI surface + serializeBlueprint folding opt + SPEC
  lexicon entry + engine-driven oracle.

Co-authored-by: Amp <amp@ampcode.com>
- Add --petrinaut-fold=color|identity to brunch cook (default identity).
- Plumb petrinautFold through OrchestratorInput → engine.ts.
- engine.ts builds one NetFolding per cook run and shares it between
  serializeBlueprint and createPetrinautEventStream so both seams fold
  identically.
- serializeBlueprint now requires folding: NetFolding (parallel to
  createPetrinautEventStream); no longer constructs createNetFolding
  internally. Existing color-fold tests updated to pass it explicitly.
- Engine-driven frame-replay oracle in engine-contract.test.ts: drives
  a real cook run with petrinautFold='identity', reads SDCPN + JSONL
  from disk, reduces via reduceBrunchExecutionExport, verifies
  referential integrity and no-negative-marking across all frames.
  Companion test verifies petrinautFold='color' strips slice prefixes.
- SPEC §Lexicon: add 'identity fold' alongside 'color fold' / 'folded net'.
- PLAN.md / CARDS.md reconciled: FE-764 slices 1+2 of 5 done; slice 3
  (ephemeral SSE server) is next.

npm run verify green (1525 tests).

Co-authored-by: Amp <amp@ampcode.com>
…sketch)

Slice 3a (next) — full scope card:
- New petrinaut-stream-bus.ts: createPetrinautStreamBus({ runId, sdcpnFile })
  exposes { publish, subscribe } with a replay buffer; every subscriber
  observes definition → initial_state → N transition_firing → terminal
  in order, including late subscribers.
- New BrunchExecutionExportFrame discriminated union (definition |
  initial_state | transition_firing | terminal) — the SSE wire shape.
- Refactor: extract eventToTransitionFiring into petrinaut-stream-export.ts;
  shared by the static reducer and the new bus.
- New OrchestratorInput.onPetrinautEvent fan-out hook; engine.ts threads
  it into createPetrinautEventStream so the bus subscribes without the
  engine knowing the bus exists.
- Replay-equivalence oracle ties bus frames back to the static reducer.

Slice 3b — sketch only (HTTP server + /stream over the 3a bus).
Slices 4–5 sketches unchanged; numbering preserved.

Co-authored-by: Amp <amp@ampcode.com>
Establishes the in-process pub/sub the slice-3b SSE server will mount.
Pure data-shape work — no HTTP yet.

- New petrinaut-stream-bus.ts: createPetrinautStreamBus({ runId, sdcpnFile })
  returns { publish, subscribe }. Replay buffer makes every subscriber —
  including late ones — observe the full ordered timeline of
  BrunchExecutionExportFrame values: definition → initial_state → N
  transition_firing → terminal.
- New BrunchExecutionExportFrame discriminated union — disjoint from
  PetrinautEvent kinds so the wire shape and engine event union don't
  collide.
- Refactor: extract eventToTransitionFiring + reduceMarking +
  projectNetDefinition from petrinaut-stream-export.ts; shared by the
  static reducer and the new bus so live + static produce identical content.
- New OrchestratorInput.onPetrinautEvent fan-out hook plumbed through
  engine.ts as createPetrinautEventStream's onEvent opt. Engine stays
  unaware of consumers; cook entry wires the bus.
- Engine-driven replay-equivalence oracle in engine-contract.test.ts:
  real cook run, bus subscribed pre-publish, then a late subscriber gets
  the same frame sequence synchronously.

12 new bus tests + 1 new engine integration test. npm run verify green
(1538 tests).

Co-authored-by: Amp <amp@ampcode.com>
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>
Comment thread src/orchestrator/src/petrinaut-stream-server.ts Outdated
kostandinang and others added 2 commits June 2, 2026 23:09
`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>
@kostandinang kostandinang marked this pull request as ready for review June 3, 2026 06:33
@cursor
Copy link
Copy Markdown

cursor Bot commented Jun 3, 2026

PR Summary

Medium Risk
Opt-in localhost HTTP/SSE and engine ordering hooks change cook startup/teardown; mistakes could race first events or leak listeners, but defaults are unchanged and tests cover lifecycle and await-before-marking.

Overview
FE-764 wires an ephemeral, cook-hosted SSE live stream so Petrinaut can follow a running cook in “actual” mode. Without --petrinaut-stream, cook behavior stays the same aside from new optional flags and documentation.

Cook CLI & lifecycle: New flags include --petrinaut-stream, --petrinaut-base-url, --no-petrinaut-open, and --petrinaut-fold=color|identity (default identity). Base URL resolves CLI → PETRINAUT_BASE_URL (shell-wins .env load only when streaming) → hard fail before sandbox/plan work. createPetrinautStreamSetup starts bus + GET /stream server, prints/composes {base}?runId&mode=actual&sse=…, optionally auto-opens the browser, and server.stop() in finally.

Streaming stack: reduceBrunchExecutionExport and shared fold helpers feed an in-process replay bus (definitioninitial_state → firings → terminal) and a localhost HTTP/SSE server. The engine gains setupPetrinautStream (awaited before initial_marking), createIdentityFolding, one shared NetFolding for disk export and live events, and a new net_completed terminal event on happy-path runs.

Docs/tests: .env.example, SPEC lexicon (identity fold), and PLAN/CARDS track Bristol-demo scope; broad unit/integration coverage (launcher URL, bus, server, cook-cli, engine-contract await ordering).

Reviewed by Cursor Bugbot for commit f043d6e. 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
Co-authored-by: Cursor <cursoragent@cursor.com>
@kostandinang kostandinang force-pushed the ka/fe-764-petri-sync-server branch from fe3de23 to b81899c Compare June 3, 2026 07:12
Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread src/orchestrator/src/petrinaut-stream-bus.ts
Comment thread src/orchestrator/src/engine.ts
Co-authored-by: Cursor <cursoragent@cursor.com>
Copy link
Copy Markdown

@cursor cursor Bot left a comment

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 cf7b682. Configure here.

Comment thread src/orchestrator/src/engine.ts
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.

1 participant