FE-764: Brunch<> Petrinaut stream — ephemeral orchestrator hosted SSE server#165
FE-764: Brunch<> Petrinaut stream — ephemeral orchestrator hosted SSE server#165kostandinang wants to merge 20 commits into
Conversation
1c21816 to
bfb687f
Compare
…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>
424ec07 to
7cc9491
Compare
`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>
PR SummaryMedium Risk Overview Cook CLI & lifecycle: New flags include Streaming stack: Docs/tests: Reviewed by Cursor Bugbot for commit f043d6e. Bugbot is set up for automated code reviews on this repo. Configure here. |
Co-authored-by: Cursor <cursoragent@cursor.com>
fe3de23 to
b81899c
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 cf7b682. 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.