diff --git a/apps/petrinaut-website/package.json b/apps/petrinaut-website/package.json index a1024c70909..bde15e02360 100644 --- a/apps/petrinaut-website/package.json +++ b/apps/petrinaut-website/package.json @@ -4,7 +4,6 @@ "private": true, "type": "module", "scripts": { - "brunch:fixture": "node --experimental-strip-types scripts/brunch-sse-fixture.ts", "build": "vite build", "codegen": "node --experimental-strip-types scripts/generate-route-tree.ts", "dev": "bash scripts/dev.sh", diff --git a/apps/petrinaut-website/scripts/brunch-sse-fixture.ts b/apps/petrinaut-website/scripts/brunch-sse-fixture.ts deleted file mode 100644 index f2dbc9d6fe3..00000000000 --- a/apps/petrinaut-website/scripts/brunch-sse-fixture.ts +++ /dev/null @@ -1,941 +0,0 @@ -#!/usr/bin/env node - -/** - * Local Brunch-compatible SSE fixture for testing Petrinaut Actual mode. - * - * The script serves a small Petri net execution plan, an initial marking, - * historical transition firings, and then streams new `transition_firing` - * events over `/stream`. It also exposes `/snapshot` for inspecting the full - * fixture state as JSON and prints copy-pasteable Petrinaut `/brunch` URLs - * when it starts. - * - * Pass `--recording=/path/to/export.petrinaut-actual.json` to replay an export - * from Petrinaut's Actual > Events tab instead of generating dummy events. Each - * `/stream` connection replays that export from the beginning and shifts - * timestamped events so the first recorded transition happens at connection - * time. - * - * This Brunch/Petrinaut interface is experimental: the event names, payload - * shapes, and endpoint layout are only for this first integration pass. The - * durable version will likely become a protocol owned by Petrinaut Core and - * standardized with the Brunch team later. - */ - -import { readFileSync } from "node:fs"; -import http, { type ServerResponse } from "node:http"; -import { homedir } from "node:os"; -import { resolve } from "node:path"; - -import type { - BrunchNetDefinitionInput, - BrunchTransitionInput, -} from "../src/main/app/brunch-demo/brunch-protocol"; -import type { - ActualModeReceivedEvent, - ActualModeTransitionFiring, -} from "@hashintel/petrinaut-core"; - -type NumericMarking = Record; - -type SseFrame = { - data: unknown; - event: string; -}; - -type TimedSseFrame = SseFrame & { - delayMs: number; -}; - -type RecordingReplay = { - definition: BrunchNetDefinitionInput; - events: ActualModeReceivedEvent[]; - initialState: NumericMarking; - path: string; - transitionFirings: ActualModeTransitionFiring[]; -}; - -type ParsedArgs = { - options: Map; - positionals: string[]; -}; - -const readArgs = (): ParsedArgs => { - const options = new Map(); - const positionals: string[] = []; - const rawArgs = process.argv.slice(2); - - for (let index = 0; index < rawArgs.length; index += 1) { - const arg = rawArgs[index]!; - - if (!arg.startsWith("--")) { - positionals.push(arg); - continue; - } - - const normalizedArg = arg.slice(2); - const equalsIndex = normalizedArg.indexOf("="); - - if (equalsIndex !== -1) { - options.set( - normalizedArg.slice(0, equalsIndex), - normalizedArg.slice(equalsIndex + 1), - ); - continue; - } - - const nextArg = rawArgs[index + 1]; - - if (nextArg && !nextArg.startsWith("--")) { - options.set(normalizedArg, nextArg); - index += 1; - } else { - options.set(normalizedArg, "true"); - } - } - - return { options, positionals }; -}; - -const { options: args, positionals } = readArgs(); - -const readPositiveNumberArg = ( - name: string, - environmentName: string, - defaultValue: number, -): number => { - const rawValue = args.get(name) ?? process.env[environmentName]; - - if (rawValue === undefined) { - return defaultValue; - } - - const value = Number(rawValue); - - if (!Number.isFinite(value) || value <= 0) { - throw new Error(`Expected --${name} to be a positive number.`); - } - - return value; -}; - -const host = args.get("host") ?? process.env.HOST ?? "127.0.0.1"; -const intervalMs = readPositiveNumberArg("interval", "INTERVAL", 2_500); -const port = readPositiveNumberArg("port", "PORT", 5_184); -const expandFilePath = (path: string): string => { - const expandedPath = - path === "~" - ? homedir() - : path.startsWith("~/") - ? resolve(homedir(), path.slice(2)) - : path; - - return resolve(expandedPath); -}; - -const rawRecordingPath = - args.get("recording") ?? - args.get("replay") ?? - process.env.RECORDING ?? - positionals[0]; -const recordingPath = rawRecordingPath - ? expandFilePath(rawRecordingPath) - : undefined; -const runId = args.get("runId") ?? process.env.RUN_ID ?? "dummy-brunch-run"; - -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - -const parseJsonFile = (path: string): unknown => { - try { - return JSON.parse(readFileSync(path, "utf8")) as unknown; - } catch (err) { - throw new Error( - `Unable to load recording at ${path}: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - } -}; - -const assertString = ( - value: unknown, - message: string, -): asserts value is string => { - if (typeof value !== "string") { - throw new Error(message); - } -}; - -const unwrapDefinitionData = (data: unknown): unknown => - isRecord(data) && "definition" in data ? data.definition : data; - -const unwrapInitialStateData = (data: unknown): unknown => - isRecord(data) && "initialState" in data ? data.initialState : data; - -const parseNumericMarking = (data: unknown, label: string): NumericMarking => { - const candidate = unwrapInitialStateData(data); - - if (!isRecord(candidate)) { - throw new Error(`Recording ${label} must be an object.`); - } - - const marking: NumericMarking = {}; - - for (const [placeId, value] of Object.entries(candidate)) { - if (typeof value !== "number" || !Number.isFinite(value)) { - throw new Error(`Recording ${label}.${placeId} must be a finite number.`); - } - - marking[placeId] = value; - } - - return marking; -}; - -const parseTransitionEffect = ( - data: unknown, - label: string, -): ActualModeTransitionFiring["input"] => { - if (!isRecord(data)) { - throw new Error(`Recording ${label} must be an object.`); - } - - const effect: ActualModeTransitionFiring["input"] = {}; - - for (const [placeId, value] of Object.entries(data)) { - if (typeof value !== "number" || !Number.isFinite(value)) { - throw new Error(`Recording ${label}.${placeId} must be a finite number.`); - } - - effect[placeId] = value; - } - - return effect; -}; - -const parseTransitionFiring = ( - data: unknown, - label: string, -): ActualModeTransitionFiring => { - if (!isRecord(data)) { - throw new Error(`Recording ${label} must be an object.`); - } - - assertString( - data.transitionId, - `Recording ${label}.transitionId must be a string.`, - ); - assertString(data.ts, `Recording ${label}.ts must be a string.`); - - if (!Number.isFinite(Date.parse(data.ts))) { - throw new Error(`Recording ${label}.ts must be a valid timestamp.`); - } - - return { - transitionId: data.transitionId, - input: parseTransitionEffect(data.input, `${label}.input`), - output: parseTransitionEffect(data.output, `${label}.output`), - ts: data.ts, - }; -}; - -const parseReceivedEventsRecording = ( - data: Record, -): ActualModeReceivedEvent[] => { - if (!Array.isArray(data.events)) { - throw new Error("Recording events must be an array."); - } - - return data.events.map((event, index) => { - if (!isRecord(event)) { - throw new Error(`Recording events[${index}] must be an object.`); - } - - assertString( - event.event, - `Recording events[${index}].event must be a string.`, - ); - - return { - event: event.event, - data: event.data, - }; - }); -}; - -const toBrunchDefinitionInput = (data: unknown): BrunchNetDefinitionInput => { - const candidate = unwrapDefinitionData(data); - - if (!isRecord(candidate)) { - throw new Error("Recording definition must be an object."); - } - - if (!Array.isArray(candidate.places)) { - throw new Error("Recording definition.places must be an array."); - } - - if (!Array.isArray(candidate.transitions)) { - throw new Error("Recording definition.transitions must be an array."); - } - - return { - ...(typeof candidate.version === "number" - ? { version: candidate.version } - : {}), - ...(isRecord(candidate.meta) ? { meta: candidate.meta } : {}), - title: typeof candidate.title === "string" ? candidate.title : "Brunch run", - places: candidate.places.map((place, index) => { - if (!isRecord(place)) { - throw new Error( - `Recording definition.places[${index}] must be an object.`, - ); - } - - assertString( - place.id, - `Recording definition.places[${index}].id must be a string.`, - ); - - return { - id: place.id, - name: typeof place.name === "string" ? place.name : place.id, - ...(typeof place.x === "number" ? { x: place.x } : {}), - ...(typeof place.y === "number" ? { y: place.y } : {}), - }; - }), - transitions: candidate.transitions.map((transition, transitionIndex) => { - if (!isRecord(transition)) { - throw new Error( - `Recording definition.transitions[${transitionIndex}] must be an object.`, - ); - } - - assertString( - transition.id, - `Recording definition.transitions[${transitionIndex}].id must be a string.`, - ); - - if (!Array.isArray(transition.inputArcs)) { - throw new Error( - `Recording definition.transitions[${transitionIndex}].inputArcs must be an array.`, - ); - } - - if (!Array.isArray(transition.outputArcs)) { - throw new Error( - `Recording definition.transitions[${transitionIndex}].outputArcs must be an array.`, - ); - } - - return { - id: transition.id, - name: - typeof transition.name === "string" ? transition.name : transition.id, - inputArcs: transition.inputArcs.map((arc, arcIndex) => { - if (!isRecord(arc)) { - throw new Error( - `Recording definition.transitions[${transitionIndex}].inputArcs[${arcIndex}] must be an object.`, - ); - } - - assertString( - arc.placeId, - `Recording definition.transitions[${transitionIndex}].inputArcs[${arcIndex}].placeId must be a string.`, - ); - - if (typeof arc.weight !== "number") { - throw new Error( - `Recording definition.transitions[${transitionIndex}].inputArcs[${arcIndex}].weight must be a number.`, - ); - } - - return { - placeId: arc.placeId, - weight: arc.weight, - type: - arc.type === "read" || arc.type === "inhibitor" - ? arc.type - : "standard", - }; - }), - outputArcs: transition.outputArcs.map((arc, arcIndex) => { - if (!isRecord(arc)) { - throw new Error( - `Recording definition.transitions[${transitionIndex}].outputArcs[${arcIndex}] must be an object.`, - ); - } - - assertString( - arc.placeId, - `Recording definition.transitions[${transitionIndex}].outputArcs[${arcIndex}].placeId must be a string.`, - ); - - if (typeof arc.weight !== "number") { - throw new Error( - `Recording definition.transitions[${transitionIndex}].outputArcs[${arcIndex}].weight must be a number.`, - ); - } - - return { - placeId: arc.placeId, - weight: arc.weight, - }; - }), - ...(typeof transition.x === "number" ? { x: transition.x } : {}), - ...(typeof transition.y === "number" ? { y: transition.y } : {}), - }; - }), - }; -}; - -const parseNormalizedRecording = ( - data: Record, -): ActualModeReceivedEvent[] => { - if (!Array.isArray(data.transitionFirings)) { - throw new Error("Recording transitionFirings must be an array."); - } - - return [ - { event: "definition", data: toBrunchDefinitionInput(data.definition) }, - { - event: "initial_state", - data: parseNumericMarking(data.initialState, "initialState"), - }, - ...data.transitionFirings.map((firing, index) => ({ - event: "transition_firing", - data: parseTransitionFiring(firing, `transitionFirings[${index}]`), - })), - ]; -}; - -const parseRecordingEvents = (data: unknown): ActualModeReceivedEvent[] => { - if (!isRecord(data)) { - throw new Error("Recording root must be an object."); - } - - if ("events" in data) { - return parseReceivedEventsRecording(data); - } - - if ( - "definition" in data && - "initialState" in data && - "transitionFirings" in data - ) { - return parseNormalizedRecording(data); - } - - throw new Error( - "Recording must be an Actual Events export with `events` or an older normalized recording.", - ); -}; - -const findFirstEvent = ( - events: ActualModeReceivedEvent[], - eventName: string, -): ActualModeReceivedEvent | null => - events.find((event) => event.event === eventName) ?? null; - -const parseRecordingReplay = (path: string): RecordingReplay => { - const events = parseRecordingEvents(parseJsonFile(path)); - const definitionEvent = findFirstEvent(events, "definition"); - const initialStateEvent = findFirstEvent(events, "initial_state"); - - if (!definitionEvent) { - throw new Error("Recording is missing a definition event."); - } - - if (!initialStateEvent) { - throw new Error("Recording is missing an initial_state event."); - } - - return { - definition: toBrunchDefinitionInput(definitionEvent.data), - events, - initialState: parseNumericMarking(initialStateEvent.data, "initial_state"), - path, - transitionFirings: events.flatMap((event, index) => - event.event === "transition_firing" - ? [parseTransitionFiring(event.data, `events[${index}].data`)] - : [], - ), - }; -}; - -const getEventTimestampMs = (event: ActualModeReceivedEvent): number | null => { - if (!isRecord(event.data) || typeof event.data.ts !== "string") { - return null; - } - - const timestampMs = Date.parse(event.data.ts); - - if (!Number.isFinite(timestampMs)) { - throw new Error( - `Recording event ${event.event} has an invalid timestamp: ${event.data.ts}`, - ); - } - - return timestampMs; -}; - -const retimeEventData = (data: unknown, deltaMs: number): unknown => { - if (!isRecord(data) || typeof data.ts !== "string") { - return data; - } - - return { - ...data, - ts: new Date(Date.parse(data.ts) + deltaMs).toISOString(), - }; -}; - -const createTimedReplayFrames = ( - events: ActualModeReceivedEvent[], - launchTimeMs: number, -): TimedSseFrame[] => { - const firstTimestampMs = events - .map((event) => getEventTimestampMs(event)) - .find((timestampMs) => timestampMs !== null); - const deltaMs = - firstTimestampMs !== undefined ? launchTimeMs - firstTimestampMs : 0; - - return events.map((event) => { - const timestampMs = getEventTimestampMs(event); - - return { - event: event.event, - data: retimeEventData(event.data, deltaMs), - delayMs: - timestampMs === null - ? 0 - : Math.max(0, timestampMs + deltaMs - launchTimeMs), - }; - }); -}; - -const applyFiringToMarking = ( - marking: NumericMarking, - firing: ActualModeTransitionFiring, -): void => { - for (const [placeId, value] of Object.entries(firing.input)) { - marking[placeId] = (marking[placeId] ?? 0) - value; - } - - for (const [placeId, value] of Object.entries(firing.output)) { - marking[placeId] = (marking[placeId] ?? 0) + value; - } -}; - -const defaultDefinition: BrunchNetDefinitionInput = { - version: 1, - title: "Dummy Brunch Execution Plan", - meta: { - generator: "petrinaut-website/scripts/brunch-sse-fixture.ts", - generatorVersion: "1", - }, - places: [ - { id: "ideas", name: "Ideas" }, - { id: "queued", name: "Queued Work" }, - { id: "implementing", name: "Implementing" }, - { id: "reviewing", name: "Reviewing" }, - { id: "done", name: "Done" }, - ], - transitions: [ - { - id: "plan_task", - name: "Plan Task", - inputArcs: [{ placeId: "ideas", weight: 1, type: "standard" }], - outputArcs: [{ placeId: "queued", weight: 1 }], - }, - { - id: "start_implementation", - name: "Start Implementation", - inputArcs: [{ placeId: "queued", weight: 1, type: "standard" }], - outputArcs: [{ placeId: "implementing", weight: 1 }], - }, - { - id: "submit_review", - name: "Submit Review", - inputArcs: [{ placeId: "implementing", weight: 1, type: "standard" }], - outputArcs: [{ placeId: "reviewing", weight: 1 }], - }, - { - id: "merge_change", - name: "Merge Change", - inputArcs: [{ placeId: "reviewing", weight: 1, type: "standard" }], - outputArcs: [{ placeId: "done", weight: 1 }], - }, - ], -}; - -const defaultInitialState: NumericMarking = { - ideas: 100, - queued: 0, - implementing: 0, - reviewing: 0, - done: 0, -}; - -const recordingReplay = recordingPath - ? parseRecordingReplay(recordingPath) - : null; - -const definition = recordingReplay?.definition ?? defaultDefinition; -const initialState = recordingReplay?.initialState ?? defaultInitialState; - -const transitionById = new Map( - definition.transitions.map((transition) => [transition.id, transition]), -); - -const cloneMarking = (marking: NumericMarking): NumericMarking => ({ - ...marking, -}); - -const getTransition = (transitionId: string): BrunchTransitionInput => { - const transition = transitionById.get(transitionId); - - if (!transition) { - throw new Error(`Unknown transition: ${transitionId}`); - } - - return transition; -}; - -const canFire = (marking: NumericMarking, transitionId: string): boolean => { - const transition = getTransition(transitionId); - - return transition.inputArcs.every( - (arc) => (marking[arc.placeId] ?? 0) >= arc.weight, - ); -}; - -const applyTransition = ( - marking: NumericMarking, - transitionId: string, -): ActualModeTransitionFiring => { - const transition = getTransition(transitionId); - const input: ActualModeTransitionFiring["input"] = {}; - const output: ActualModeTransitionFiring["output"] = {}; - - for (const arc of transition.inputArcs) { - if ((arc.type ?? "standard") !== "standard") { - continue; - } - - input[arc.placeId] = (input[arc.placeId] ?? 0) + arc.weight; - marking[arc.placeId] = (marking[arc.placeId] ?? 0) - arc.weight; - } - - for (const arc of transition.outputArcs) { - output[arc.placeId] = (output[arc.placeId] ?? 0) + arc.weight; - marking[arc.placeId] = (marking[arc.placeId] ?? 0) + arc.weight; - } - - return { - transitionId, - input, - output, - ts: new Date().toISOString(), - }; -}; - -const currentMarking = cloneMarking(initialState); -const transitionFirings: ActualModeTransitionFiring[] = recordingReplay - ? recordingReplay.transitionFirings.map((firing) => ({ ...firing })) - : []; -const replayFrames: SseFrame[] = [ - { event: "definition", data: definition }, - { event: "initial_state", data: initialState }, -]; - -const appendFiring = (transitionId: string): ActualModeTransitionFiring => { - const firing = applyTransition(currentMarking, transitionId); - - transitionFirings.push(firing); - replayFrames.push({ event: "transition_firing", data: firing }); - - return firing; -}; - -if (recordingReplay) { - for (const firing of recordingReplay.transitionFirings) { - applyFiringToMarking(currentMarking, firing); - } -} else { - for (const transitionId of [ - "plan_task", - "start_implementation", - "plan_task", - "submit_review", - "merge_change", - ]) { - appendFiring(transitionId); - } -} - -let liveTransitionIndex = 0; -const liveTransitionCycle = [ - "start_implementation", - "plan_task", - "submit_review", - "start_implementation", - "merge_change", - "plan_task", - "submit_review", - "merge_change", -]; - -const nextLiveFiring = (): ActualModeTransitionFiring | null => { - for (let attempts = 0; attempts < liveTransitionCycle.length; attempts += 1) { - const transitionId = liveTransitionCycle[liveTransitionIndex]!; - liveTransitionIndex = - (liveTransitionIndex + 1) % liveTransitionCycle.length; - - if (canFire(currentMarking, transitionId)) { - return appendFiring(transitionId); - } - } - - // No transition is enabled (the dummy net ran out of tokens); skip the - // tick instead of firing an impossible transition into negative counts. - return null; -}; - -const streamFrame = ( - response: ServerResponse, - event: string, - data: unknown, -): void => { - response.write(`event: ${event}\n`); - response.write(`data: ${JSON.stringify(data)}\n\n`); -}; - -const streamComment = (response: ServerResponse, comment: string): void => { - response.write(`: ${comment}\n\n`); -}; - -const clients = new Map[]>(); - -const addClient = ( - response: ServerResponse, -): ReturnType[] => { - const timers: ReturnType[] = []; - clients.set(response, timers); - - return timers; -}; - -const closeClient = (response: ServerResponse): void => { - const timers = clients.get(response); - - if (timers) { - for (const timer of timers) { - clearTimeout(timer); - } - } - - clients.delete(response); -}; - -const publicHost = host === "0.0.0.0" ? "127.0.0.1" : host; -const endpoint = `http://${publicHost}:${port}/stream`; -const encodedEndpoint = encodeURIComponent(endpoint); -const brunchRoute = `/brunch?sse=${encodedEndpoint}&runId=${encodeURIComponent( - runId, -)}`; -const snapshotUrl = `http://${publicHost}:${port}/snapshot`; - -const replayRecordingToClient = ( - response: ServerResponse, - recording: RecordingReplay, -): void => { - const timers = clients.get(response); - - if (!timers) { - return; - } - - const frames = createTimedReplayFrames(recording.events, Date.now()); - let lastDelayMs = 0; - - for (const frame of frames) { - lastDelayMs = Math.max(lastDelayMs, frame.delayMs); - - const sendFrame = () => { - streamFrame(response, frame.event, frame.data); - - if ( - frame.event === "transition_firing" && - isRecord(frame.data) && - typeof frame.data.transitionId === "string" && - typeof frame.data.ts === "string" - ) { - console.log( - `[${frame.data.ts}] replay transition_firing ${frame.data.transitionId}`, - ); - } - }; - - if (frame.delayMs === 0) { - sendFrame(); - } else { - timers.push(setTimeout(sendFrame, frame.delayMs)); - } - } - - timers.push( - setTimeout(() => { - streamFrame(response, "terminal", { - reason: "recording_replay_complete", - }); - response.end(); - }, lastDelayMs + 50), - ); -}; - -const server = http.createServer((request, response) => { - const url = new URL(request.url ?? "/", endpoint); - - if (url.pathname === "/stream") { - response.writeHead(200, { - "Access-Control-Allow-Origin": "*", - "Cache-Control": "no-cache", - Connection: "keep-alive", - "Content-Type": "text/event-stream", - }); - - streamComment(response, "dummy Brunch SSE fixture connected"); - addClient(response); - - if (recordingReplay) { - replayRecordingToClient(response, recordingReplay); - } else { - for (const frame of replayFrames) { - streamFrame(response, frame.event, frame.data); - } - } - - request.on("close", () => { - closeClient(response); - }); - - return; - } - - if (url.pathname === "/snapshot") { - response.writeHead(200, { - "Access-Control-Allow-Origin": "*", - "Content-Type": "application/json", - }); - response.end( - JSON.stringify( - { - mode: recordingReplay ? "recording-replay" : "generated", - recordingPath: recordingReplay?.path ?? null, - definition, - initialState, - currentMarking, - transitionFirings, - }, - null, - 2, - ), - ); - - return; - } - - response.writeHead(200, { "Content-Type": "text/plain" }); - response.end( - [ - "Dummy Brunch SSE fixture", - "", - `SSE endpoint: ${endpoint}`, - `Petrinaut route: ${brunchRoute}`, - `Snapshot JSON: ${snapshotUrl}`, - "", - recordingReplay - ? `Replaying recording: ${recordingReplay.path}` - : "Generated fixture mode.", - recordingReplay - ? "Each /stream connection replays definition, initial_state, and retimed transition_firing events from the beginning." - : "Connect to /stream to receive definition, initial_state, previous transition_firing events, then live transition_firing events.", - ].join("\n"), - ); -}); - -const broadcastLiveFiring = (): void => { - const firing = nextLiveFiring(); - - if (!firing) { - return; - } - - for (const client of clients.keys()) { - streamFrame(client, "transition_firing", firing); - } - - console.log( - `[${firing.ts}] transition_firing ${firing.transitionId} -> ${JSON.stringify( - firing.output, - )}`, - ); -}; - -server.on("error", (err: Error & { code?: string }) => { - if (err.code === "EADDRINUSE") { - console.error( - `Port ${port} is already in use on ${host}. Stop the existing fixture or pass another port, for example:`, - ); - console.error( - " yarn workspace @apps/petrinaut-website brunch:fixture -- --port=5185", - ); - process.exit(1); - } - - throw err; -}); - -server.listen(port, host, () => { - console.log("Dummy Brunch SSE fixture running"); - console.log(""); - console.log(`SSE endpoint: ${endpoint}`); - console.log(`Petrinaut route: ${brunchRoute}`); - console.log(`Snapshot JSON: ${snapshotUrl}`); - console.log(""); - if (recordingReplay) { - console.log(`Replaying recording: ${recordingReplay.path}`); - console.log( - `Recorded transition events: ${recordingReplay.transitionFirings.length}`, - ); - console.log( - "Each /stream connection replays from the beginning with retimed timestamps.", - ); - } else { - console.log(`Streaming live transitions every ${intervalMs}ms.`); - } - console.log("Stop with Ctrl-C."); -}); - -const interval = recordingReplay - ? null - : setInterval(broadcastLiveFiring, intervalMs); - -const shutdown = (): void => { - if (interval) { - clearInterval(interval); - } - - for (const client of clients.keys()) { - streamComment(client, "fixture shutting down"); - client.end(); - closeClient(client); - } - - server.close(() => { - process.exit(0); - }); -}; - -process.on("SIGINT", shutdown); -process.on("SIGTERM", shutdown); diff --git a/apps/petrinaut-website/src/examples/example-search.ts b/apps/petrinaut-website/src/examples/example-search.ts index 431eade22ee..9ad5194af3f 100644 --- a/apps/petrinaut-website/src/examples/example-search.ts +++ b/apps/petrinaut-website/src/examples/example-search.ts @@ -58,8 +58,8 @@ export type SharedOverlay = (typeof sharedOverlays)[number]; * Search params understood by every example surface. A URL carries at most one * focused item: multi-selection is in-app state, not a shareable location. * - * A field the URL leaves out means "whatever this page starts from", which for - * every page but `/brunch` is the editor's own default. That is what lets Back + * A field the URL leaves out means "whatever this page starts from", which is + * the editor's own default unless the page seeds another one. That is what lets Back * undo a mode change or close an overlay: the entry it returns to simply does * not name the field. */ diff --git a/apps/petrinaut-website/src/examples/navigation-search.ts b/apps/petrinaut-website/src/examples/navigation-search.ts index 93143cfb427..23219a42ccc 100644 --- a/apps/petrinaut-website/src/examples/navigation-search.ts +++ b/apps/petrinaut-website/src/examples/navigation-search.ts @@ -9,7 +9,7 @@ * from. A URL that does not name a field means "the baseline's value", which is * what makes Back undo a mode change or close an overlay: the entry Back * returns to simply omits the field. The baseline is the editor's own default - * everywhere except `/brunch`, which starts in Actual mode. + * unless the page seeds another one. */ import { defaultPetrinautNavigationState } from "@hashintel/petrinaut/react"; diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-actual-mode-provider.tsx b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-actual-mode-provider.tsx deleted file mode 100644 index 8974dd4e4ef..00000000000 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-actual-mode-provider.tsx +++ /dev/null @@ -1,270 +0,0 @@ -import { useEffect, useState, type FC, type PropsWithChildren } from "react"; - -import { ACTUAL_MODE_TIMELINE_TICK_MS } from "@hashintel/petrinaut-core"; -import { ActualModeContext } from "@hashintel/petrinaut/react"; - -import { normalizeBrunchDefinition } from "./brunch-definition"; -import { - parseDefinitionFrameData, - parseJsonEventData, - parseMarkingFrameData, - parseTransitionFiringFrameData, -} from "./brunch-frame-parsers"; - -import type { ActualModeContextValue } from "@hashintel/petrinaut-core"; - -type AvailableActualModeContextValue = Extract< - ActualModeContextValue, - { available: true } ->; - -const createLoadingActualModeValue = ( - endpoint: string, - runId: string | undefined, -): AvailableActualModeContextValue => { - const now = Date.now(); - - return { - available: true, - source: { - kind: "brunch", - endpoint, - ...(runId ? { runId } : {}), - }, - status: "loading", - title: null, - definition: null, - initialState: null, - transitionFirings: [], - receivedEvents: [], - timelineStartedAtMs: now, - timelineNowMs: now, - error: null, - }; -}; - -export const BrunchActualModeProvider: FC< - PropsWithChildren<{ endpoint: string; runId?: string }> -> = ({ children, endpoint, runId }) => { - const [value, setValue] = useState(() => - createLoadingActualModeValue(endpoint, runId), - ); - - useEffect(() => { - const interval = window.setInterval(() => { - setValue((prev) => - prev.status === "streaming" - ? { - ...prev, - timelineNowMs: Date.now(), - } - : prev, - ); - }, ACTUAL_MODE_TIMELINE_TICK_MS); - - return () => { - window.clearInterval(interval); - }; - }, []); - - useEffect(() => { - setValue(createLoadingActualModeValue(endpoint, runId)); - }, [endpoint, runId]); - - useEffect(() => { - let cancelled = false; - let hasConnectedBefore = false; - const eventSource = new EventSource(endpoint); - - const setFatalError = (message: string) => { - if (cancelled) { - return; - } - - eventSource.close(); - setValue((prev) => ({ - ...prev, - status: "error", - timelineNowMs: Date.now(), - error: message, - })); - }; - - const setRecoverableConnectionError = (message: string) => { - if (cancelled) { - return; - } - - setValue((prev) => { - if (prev.status === "error") { - return prev; - } - - const canRenderActualMode = - prev.definition !== null && prev.initialState !== null; - - return { - ...prev, - status: - prev.status === "complete" - ? "complete" - : canRenderActualMode - ? "streaming" - : "loading", - timelineNowMs: Date.now(), - error: message, - }; - }); - }; - - const onOpen = () => { - if (cancelled) { - return; - } - - const isReconnect = hasConnectedBefore; - hasConnectedBefore = true; - - setValue((prev) => { - const error = prev.status === "error" ? prev.error : null; - - if (!isReconnect) { - return { ...prev, error }; - } - - // The temporary Brunch protocol replays the whole run (definition, - // initial_state, and every past transition_firing) on each connection, - // so appending across an automatic reconnect would duplicate every - // previously received event. Drop the accumulated events and let the - // replay rebuild them; the already-loaded definition and initial state - // stay visible until the replay re-delivers them. The timeline start - // is re-baselined too, so the briefly empty firing list does not - // synthesize tick frames reaching back to the original page load. - return { - ...prev, - transitionFirings: [], - receivedEvents: [], - timelineStartedAtMs: Date.now(), - timelineNowMs: Date.now(), - error, - }; - }); - }; - - const onDefinition = (event: Event) => { - void (async () => { - try { - const data = parseJsonEventData(event as MessageEvent, "definition"); - const definition = parseDefinitionFrameData(data); - - setValue((prev) => ({ - ...prev, - status: prev.status === "complete" ? "complete" : "streaming", - receivedEvents: [ - ...prev.receivedEvents, - { event: "definition", data }, - ], - timelineNowMs: Date.now(), - error: null, - })); - - const sdcpn = await normalizeBrunchDefinition(definition); - - if (cancelled) { - return; - } - - setValue((prev) => ({ - ...prev, - status: prev.status === "complete" ? "complete" : "streaming", - title: definition.title, - definition: sdcpn, - timelineNowMs: Date.now(), - error: null, - })); - } catch (err) { - setFatalError(err instanceof Error ? err.message : String(err)); - } - })(); - }; - - const onInitialState = (event: Event) => { - try { - const data = parseJsonEventData(event as MessageEvent, "initial_state"); - const initialState = parseMarkingFrameData(data); - setValue((prev) => ({ - ...prev, - status: prev.status === "complete" ? "complete" : "streaming", - initialState, - receivedEvents: [ - ...prev.receivedEvents, - { event: "initial_state", data }, - ], - timelineNowMs: Date.now(), - error: null, - })); - } catch (err) { - setFatalError(err instanceof Error ? err.message : String(err)); - } - }; - - const onTransitionFiring = (event: Event) => { - try { - const data = parseJsonEventData( - event as MessageEvent, - "transition_firing", - ); - const firing = parseTransitionFiringFrameData(data); - setValue((prev) => ({ - ...prev, - status: prev.status === "complete" ? "complete" : "streaming", - transitionFirings: [...prev.transitionFirings, firing], - receivedEvents: [ - ...prev.receivedEvents, - { event: "transition_firing", data }, - ], - timelineNowMs: Date.now(), - error: null, - })); - } catch (err) { - setFatalError(err instanceof Error ? err.message : String(err)); - } - }; - - const onTerminal = () => { - eventSource.close(); - setValue((prev) => ({ - ...prev, - status: "complete", - timelineNowMs: Date.now(), - error: null, - })); - }; - - const onError = () => { - setRecoverableConnectionError( - "Connection to the Brunch stream was interrupted. Reconnecting...", - ); - }; - - eventSource.addEventListener("open", onOpen); - eventSource.addEventListener("definition", onDefinition); - eventSource.addEventListener("initial_state", onInitialState); - eventSource.addEventListener("transition_firing", onTransitionFiring); - eventSource.addEventListener("terminal", onTerminal); - eventSource.addEventListener("error", onError); - - return () => { - cancelled = true; - eventSource.close(); - eventSource.removeEventListener("open", onOpen); - eventSource.removeEventListener("definition", onDefinition); - eventSource.removeEventListener("initial_state", onInitialState); - eventSource.removeEventListener("transition_firing", onTransitionFiring); - eventSource.removeEventListener("terminal", onTerminal); - eventSource.removeEventListener("error", onError); - }; - }, [endpoint, runId]); - - return {children}; -}; diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-actual-mode-route.tsx b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-actual-mode-route.tsx deleted file mode 100644 index ce49b1c3833..00000000000 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-actual-mode-route.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { StatusPage } from "../../../shared/status-page"; -import { BrunchActualModeProvider } from "./brunch-actual-mode-provider"; -import { getBrunchEndpoint } from "./brunch-endpoint"; -import { BrunchPetrinaut } from "./brunch-petrinaut"; - -import type { BrunchRouteSearch } from "./brunch-search"; -import type { PetrinautNavigationController } from "@hashintel/petrinaut/react"; - -export const BrunchActualModeRoute = ({ - navigation, - search, -}: { - navigation: PetrinautNavigationController; - search: BrunchRouteSearch; -}) => { - const endpointResult = getBrunchEndpoint({ - baseUrl: window.location.href, - search, - }); - - if (!endpointResult.ok) { - return ( - - ); - } - - return ( - - - - ); -}; diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-definition.ts b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-definition.ts deleted file mode 100644 index 9dbf06638f8..00000000000 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-definition.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { - calculateGraphLayout, - canonicalizePetrinautIds, - layoutNodeDimensions, -} from "@hashintel/petrinaut-core"; - -import type { BrunchNetDefinition } from "./brunch-protocol"; -import type { SDCPN } from "@hashintel/petrinaut-core"; - -const shouldAutoLayout = (definition: BrunchNetDefinition): boolean => { - const nodes = [...definition.places, ...definition.transitions]; - - if (nodes.some((node) => node.x === undefined || node.y === undefined)) { - return true; - } - - return ( - nodes.length > 1 && nodes.every((node) => node.x === 0 && node.y === 0) - ); -}; - -const toSDCPN = (definition: BrunchNetDefinition): SDCPN => ({ - places: definition.places.map((place) => ({ - id: place.id, - name: place.name, - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: place.x ?? 0, - y: place.y ?? 0, - })), - transitions: definition.transitions.map((transition) => ({ - id: transition.id, - name: transition.name, - inputArcs: transition.inputArcs, - outputArcs: transition.outputArcs, - lambdaType: "predicate", - lambdaCode: "", - transitionKernelCode: "", - x: transition.x ?? 0, - y: transition.y ?? 0, - })), - types: [], - differentialEquations: [], - parameters: [], -}); - -export const normalizeBrunchDefinition = async ( - definition: BrunchNetDefinition, -): Promise => { - // The stream's markings and firings convert their ids when parsed, so the - // definition they name converts too. - const sdcpn = canonicalizePetrinautIds(toSDCPN(definition)); - - if (!shouldAutoLayout(definition)) { - return sdcpn; - } - - const positions = await calculateGraphLayout(sdcpn, layoutNodeDimensions); - - return { - ...sdcpn, - places: sdcpn.places.map((place) => ({ - ...place, - ...positions[place.id], - })), - transitions: sdcpn.transitions.map((transition) => ({ - ...transition, - ...positions[transition.id], - })), - }; -}; diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-demo-app.test.tsx b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-demo-app.test.tsx deleted file mode 100644 index 08c8d99870a..00000000000 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-demo-app.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -// @vitest-environment jsdom - -import { render } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; - -import type { PetrinautNavigationController } from "@hashintel/petrinaut/react"; - -const controllers: PetrinautNavigationController[] = []; - -vi.mock("./brunch-actual-mode-route", () => ({ - BrunchActualModeRoute: ({ - navigation, - }: { - navigation: PetrinautNavigationController; - }) => { - controllers.push(navigation); - return null; - }, -})); - -const { BrunchDemoApp } = await import("./brunch-demo-app"); - -describe("BrunchDemoApp", () => { - it("opens the controlled location in Actual mode", () => { - // A controlled host replaces the navigation provider's initial state, - // including the Actual-mode default it applies when a stream is - // available. Opening in Edit mode would point the execution frame at the - // local simulation instead of the Brunch stream. - render( - {}} - search={{ sse: "https://brunch.example/events" }} - />, - ); - - expect(controllers.at(-1)!.state.mode).toBe("actual"); - }); - - it("still resolves a shared location from the URL", () => { - render( - {}} - search={{ - sse: "https://brunch.example/events", - scenario: "scenario-1", - subnet: "subnet-1", - }} - />, - ); - - const controller = controllers.at(-1)!; - expect(controller.state.mode).toBe("actual"); - expect(controller.state.scenarioId).toBe("scenario-1"); - expect(controller.state.subnetId).toBe("subnet-1"); - }); -}); diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-demo-app.tsx b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-demo-app.tsx deleted file mode 100644 index 77afe5a4ed8..00000000000 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-demo-app.tsx +++ /dev/null @@ -1,31 +0,0 @@ -/** - * @layerRoot website.brunch - * @role Brunch Actual Mode demo: streams a live net from a Brunch endpoint - */ - -import { useSharedSearchNavigation } from "../../../examples/use-shared-search-navigation"; -import { BrunchActualModeRoute } from "./brunch-actual-mode-route"; - -import type { SharedExampleSearch } from "../../../examples/example-search"; -import type { BrunchRouteSearch } from "./brunch-search"; - -export const BrunchDemoApp = ({ - onSearchChange, - search, -}: { - onSearchChange: ( - search: SharedExampleSearch, - history: "push" | "replace", - ) => void; - search: BrunchRouteSearch; -}) => { - // Petrinaut only mounts below once the Brunch stream is available, and the - // stream is the whole point of this route, so the location starts in Actual - // mode. Without this the controlled state would open in Edit mode and the - // execution frame would read the local simulation instead of the stream. - const navigation = useSharedSearchNavigation(search, onSearchChange, { - initialState: { mode: "actual" }, - }); - - return ; -}; diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-endpoint.test.ts b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-endpoint.test.ts deleted file mode 100644 index 21478dbb019..00000000000 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-endpoint.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { getBrunchEndpoint } from "./brunch-endpoint"; - -const baseUrl = "https://demo.petrinaut.org/brunch"; - -describe("getBrunchEndpoint", () => { - it("returns a friendly error when the endpoint is missing", () => { - expect(getBrunchEndpoint({ baseUrl, search: {} })).toEqual({ - ok: false, - error: "Missing Brunch stream endpoint. Add ?sse=.", - }); - }); - - it("distinguishes an empty endpoint from a missing one", () => { - expect(getBrunchEndpoint({ baseUrl, search: { sse: "" } })).toEqual({ - ok: false, - error: "Brunch endpoint is empty.", - }); - expect(getBrunchEndpoint({ baseUrl, search: { sse: " " } })).toEqual({ - ok: false, - error: "Brunch endpoint is empty.", - }); - }); - - it("resolves a relative endpoint and retains the run id", () => { - expect( - getBrunchEndpoint({ - baseUrl, - search: { runId: "run-1", sse: "/events" }, - }), - ).toEqual({ - ok: true, - endpoint: "https://demo.petrinaut.org/events", - runId: "run-1", - }); - }); - - it("adds HTTP for loopback endpoints without a protocol", () => { - expect( - getBrunchEndpoint({ - baseUrl, - search: { sse: "localhost:4000/events" }, - }), - ).toEqual({ - ok: true, - endpoint: "http://localhost:4000/events", - runId: undefined, - }); - }); - - it("rejects endpoints that do not use HTTP", () => { - expect( - getBrunchEndpoint({ - baseUrl, - search: { sse: "file:///tmp/events" }, - }), - ).toEqual({ - ok: false, - error: 'Brunch endpoint must use http(s), received "file:".', - }); - }); -}); diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-endpoint.ts b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-endpoint.ts deleted file mode 100644 index 2868c5e85b8..00000000000 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-endpoint.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { BrunchRouteSearch } from "./brunch-search"; - -type BrunchEndpointResult = - | { ok: true; endpoint: string; runId?: string } - | { ok: false; error: string }; - -const normalizeEndpoint = (value: string, baseUrl: string): string => { - const trimmed = value.trim(); - - if (trimmed.length === 0) { - throw new Error("Brunch endpoint is empty."); - } - - const url = /^https?:\/\//u.test(trimmed) - ? new URL(trimmed) - : /^(localhost|127\.0\.0\.1|\[::1\])(?::|\/)/u.test(trimmed) - ? new URL(`http://${trimmed}`) - : new URL(trimmed, baseUrl); - - // EventSource throws synchronously on non-HTTP(S) URLs; reject them here so - // the route renders the friendly status page instead. - if (url.protocol !== "http:" && url.protocol !== "https:") { - throw new Error( - `Brunch endpoint must use http(s), received "${url.protocol}".`, - ); - } - - return url.toString(); -}; - -export const getBrunchEndpoint = ({ - baseUrl, - search, -}: { - baseUrl: string; - search: BrunchRouteSearch; -}): BrunchEndpointResult => { - try { - if (search.sse !== undefined) { - return { - ok: true, - endpoint: normalizeEndpoint(search.sse, baseUrl), - runId: search.runId, - }; - } - } catch (err) { - return { - ok: false, - error: err instanceof Error ? err.message : String(err), - }; - } - - return { - ok: false, - error: "Missing Brunch stream endpoint. Add ?sse=.", - }; -}; diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-frame-parsers.ts b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-frame-parsers.ts deleted file mode 100644 index 4708a22b59b..00000000000 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-frame-parsers.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { z } from "zod"; - -import { - actualModeMarkingSchema, - actualModeTransitionFiringSchema, -} from "@hashintel/petrinaut-core"; - -import { brunchNetDefinitionSchema } from "./brunch-protocol"; - -import type { BrunchNetDefinition } from "./brunch-protocol"; -import type { - ActualModeMarking, - ActualModeTransitionFiring, -} from "@hashintel/petrinaut-core"; - -/** - * Decode the string payload from an EventSource MessageEvent. - * - * This is the first parsing stage in the Brunch stream handler. It does not - * validate the payload shape; it only turns SSE `data:` text into JSON so the - * frame-specific parsers can validate it against the expected protocol schema. - */ -export const parseJsonEventData = ( - event: MessageEvent, - label: string, -): unknown => { - try { - return JSON.parse(event.data as string) as unknown; - } catch (err) { - throw new Error( - `${label} frame is not valid JSON: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - } -}; - -const summarizeZodError = (error: z.ZodError): string => - error.issues - .map((issue) => { - const path = issue.path.length > 0 ? `${issue.path.join(".")}: ` : ""; - return `${path}${issue.message}`; - }) - .join(", "); - -/** - * Validate a decoded Brunch `definition` payload. - * - * This runs after JSON decoding and before the website normalizes the temporary - * Brunch execution-plan shape into a read-only Petrinaut SDCPN for rendering. - * The current Brunch fixture may send either the definition directly or wrap it - * as `{ definition }`, so this accepts both forms. - */ -export const parseDefinitionFrameData = ( - data: unknown, -): BrunchNetDefinition => { - const candidate = - typeof data === "object" && data !== null && "definition" in data - ? (data as { definition: unknown }).definition - : data; - const result = brunchNetDefinitionSchema.safeParse(candidate); - - if (!result.success) { - throw new Error( - `Invalid Brunch definition frame: ${summarizeZodError(result.error)}`, - ); - } - - return result.data; -}; - -/** - * Validate a decoded Brunch `initial_state` payload. - * - * This runs after JSON decoding and before the provider stores the initial - * Actual Mode marking in context. The current fixture may send either the - * marking directly or wrap it as `{ initialState }`, so this accepts both forms. - */ -export const parseMarkingFrameData = (data: unknown): ActualModeMarking => { - const candidate = - typeof data === "object" && data !== null && "initialState" in data - ? (data as { initialState: unknown }).initialState - : data; - const result = actualModeMarkingSchema.safeParse(candidate); - - if (!result.success) { - throw new Error( - `Invalid Brunch initial_state frame: ${summarizeZodError(result.error)}`, - ); - } - - return result.data; -}; - -/** - * Validate a decoded Brunch `transition_firing` payload. - * - * This runs after JSON decoding and before the provider appends the event to - * Actual Mode state. The accepted schema is the transition effect protocol: - * `{ transitionId, input, output, ts }`. - */ -export const parseTransitionFiringFrameData = ( - data: unknown, -): ActualModeTransitionFiring => { - const result = actualModeTransitionFiringSchema.safeParse(data); - - if (!result.success) { - throw new Error( - `Invalid Brunch transition_firing frame: ${summarizeZodError( - result.error, - )}`, - ); - } - - return result.data; -}; diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-petrinaut.tsx b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-petrinaut.tsx deleted file mode 100644 index bb71af70ac3..00000000000 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-petrinaut.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { use, useState } from "react"; - -import { - createJsonDocHandle, - PETRINAUT_EXTENSION_NAMES, -} from "@hashintel/petrinaut-core"; -import { - ActualModeContext, - type PetrinautNavigationController, -} from "@hashintel/petrinaut/react"; -import { Petrinaut } from "@hashintel/petrinaut/ui"; - -import { StatusPage } from "../../../shared/status-page"; -import { sentryFeedbackPlugin } from "../plugins/sentry-feedback/plugin"; - -import type { - ActualModeSource, - PetrinautDocHandle, - SDCPN, -} from "@hashintel/petrinaut-core"; - -const getSourceKey = (source: ActualModeSource): string => - `${source.kind}:${source.endpoint}:${source.runId ?? ""}`; - -const BrunchPetrinautWithHandle = ({ - definition, - navigation, - source, - title, -}: { - definition: SDCPN; - navigation: PetrinautNavigationController; - source: ActualModeSource; - title: string; -}) => { - const [handle] = useState(() => - createJsonDocHandle({ - id: source.runId ? `brunch-${source.runId}` : "brunch-actual", - initial: definition, - capabilities: { - readonly: true, - disabledExtensions: PETRINAUT_EXTENSION_NAMES, - }, - historyLimit: 0, - }), - ); - - return ( -
- {}} - title={title} - /> -
- ); -}; - -export const BrunchPetrinaut = ({ - navigation, -}: { - navigation: PetrinautNavigationController; -}) => { - const actualMode = use(ActualModeContext); - const definition = actualMode.available ? actualMode.definition : null; - const initialState = actualMode.available ? actualMode.initialState : null; - const source = actualMode.available ? actualMode.source : null; - const sourceKey = source ? getSourceKey(source) : null; - - if (!actualMode.available) { - return ( - - ); - } - - if (actualMode.status === "error") { - return ( - - ); - } - - if (!definition || !initialState || !source || !sourceKey) { - return ( - - ); - } - - return ( - - ); -}; diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-protocol.ts b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-protocol.ts deleted file mode 100644 index 799259d9ae7..00000000000 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-protocol.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { z } from "zod"; - -const brunchInputArcSchema = z - .object({ - placeId: z.string(), - weight: z.number(), - type: z - .enum(["standard", "read", "inhibitor"]) - .optional() - .default("standard"), - }) - .strict(); - -const brunchOutputArcSchema = z - .object({ - placeId: z.string(), - weight: z.number(), - }) - .strict(); - -const brunchPlaceSchema = z - .object({ - id: z.string(), - name: z.string(), - x: z.number().optional(), - y: z.number().optional(), - }) - .strict(); - -const brunchTransitionSchema = z - .object({ - id: z.string(), - name: z.string(), - inputArcs: z.array(brunchInputArcSchema), - outputArcs: z.array(brunchOutputArcSchema), - x: z.number().optional(), - y: z.number().optional(), - }) - .strict(); - -/** - * Temporary root schema for the Brunch execution-plan definition accepted by - * the demo. - * - * This is intentionally not Petrinaut's full SDCPN document format. It only - * accepts the plain graph data Actual Mode currently reads from Brunch: - * places, transitions, arcs, weights, arc types, and optional coordinates. - * - * Extension-specific SDCPN fields are excluded on purpose. The Brunch Actual - * Mode route does not currently support colours, stochasticity, dynamics, - * parameters, transition lambdas, transition kernels, visualizers, or colour - * types. `normalizeBrunchDefinition` supplies the required SDCPN defaults while - * creating a read-only handle with Petrinaut extensions disabled. - * - * The whole schema is temporary and should be replaced by the standardized - * Brunch/Petrinaut protocol once that protocol is owned in Petrinaut Core. - */ -export const brunchNetDefinitionSchema = z - .object({ - version: z.number().optional().default(1), - meta: z - .object({ - generator: z.string().optional(), - generatorVersion: z.string().optional(), - }) - .optional(), - title: z.string().optional().default("Brunch run"), - places: z.array(brunchPlaceSchema), - transitions: z.array(brunchTransitionSchema), - }) - .strict(); - -export type BrunchNetDefinition = z.output; -export type BrunchNetDefinitionInput = z.input< - typeof brunchNetDefinitionSchema ->; -export type BrunchTransitionInput = z.input; diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-search.test.ts b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-search.test.ts deleted file mode 100644 index fa76f94f14d..00000000000 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-search.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { validateBrunchSearch, withBrunchStreamKeys } from "./brunch-search"; - -describe("validateBrunchSearch", () => { - it("keeps string parameters", () => { - expect( - validateBrunchSearch({ - runId: "run-1", - sse: "https://brunch.example/events", - }), - ).toEqual({ - runId: "run-1", - sse: "https://brunch.example/events", - }); - }); - - it("drops values the search parser pre-decoded away from strings", () => { - // `?runId=1e3` reaches the schema as the number 1000, not the original - // text, so coercing it back to a string would keep a corrupted id. - expect( - validateBrunchSearch({ - runId: 1000, - sse: true, - }), - ).toEqual({ - runId: undefined, - sse: undefined, - }); - }); - - it("falls back for malformed structured parameters", () => { - expect( - validateBrunchSearch({ - runId: ["run-1"], - sse: { href: "https://brunch.example/events" }, - }), - ).toEqual({ - runId: undefined, - sse: undefined, - }); - }); - - it("keeps the shared example location next to the stream keys", () => { - expect( - validateBrunchSearch({ - sse: "https://brunch.example/events", - scenario: "scenario_baseline", - subnet: "subnet_dispatch", - }), - ).toEqual({ - sse: "https://brunch.example/events", - scenario: "scenario_baseline", - subnet: "subnet_dispatch", - }); - }); -}); - -describe("withBrunchStreamKeys", () => { - it("carries the stream keys over a navigation", () => { - // Dropping them would leave getBrunchEndpoint without an endpoint, and - // the live editor would be replaced by the status page mid-run. - expect( - withBrunchStreamKeys( - { runId: "run-1", sse: "https://brunch.example/events" }, - { subnet: "subnet-1" }, - ), - ).toEqual({ - runId: "run-1", - sse: "https://brunch.example/events", - subnet: "subnet-1", - }); - }); - - it("replaces the contract part rather than merging into it", () => { - // The hook always produces a complete contract search, so a key it omits - // is a key the new location does not have. - expect( - withBrunchStreamKeys( - { - runId: "run-1", - sse: "https://brunch.example/events", - scenario: "scenario-1", - itemType: "place", - itemId: "place-1", - }, - { subnet: "subnet-1" }, - ), - ).toEqual({ - runId: "run-1", - sse: "https://brunch.example/events", - subnet: "subnet-1", - }); - }); - - it("adds no stream keys when the current search has none", () => { - expect(withBrunchStreamKeys({}, { scenario: "scenario-1" })).toEqual({ - runId: undefined, - sse: undefined, - scenario: "scenario-1", - }); - }); -}); diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-search.ts b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-search.ts deleted file mode 100644 index 283395871b5..00000000000 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-search.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { z } from "zod"; - -import { - validateSharedExampleSearch, - type SharedExampleSearch, -} from "../../../examples/example-search"; - -/** - * The router's search parser JSON-decodes values before validation, so a - * numeric-looking parameter arrives pre-mangled (`?runId=1e3` becomes 1000, - * ids above 2^53 lose precision). Accepting only strings drops such values to - * `undefined` instead of coercing them into plausible-looking altered ids. - */ -const optionalSearchStringSchema = z.string().optional().catch(undefined); - -const brunchStreamSearchSchema = z.object({ - runId: optionalSearchStringSchema, - sse: optionalSearchStringSchema, -}); - -export type BrunchRouteSearch = z.infer & - SharedExampleSearch; - -/** - * A Brunch URL names the stream (`sse`, `runId`) and speaks the shared example - * contract for the location inside the net. - */ -export const validateBrunchSearch = ( - input: Record, -): BrunchRouteSearch => ({ - ...brunchStreamSearchSchema.parse(input), - ...validateSharedExampleSearch(input), -}); - -/** - * Replaces the contract part of a Brunch search and carries the stream keys - * over. Every other route writes a contract-only search; here the stream keys - * name the run, and dropping them would swap the live editor for the - * missing-endpoint status page mid-run. - */ -export const withBrunchStreamKeys = ( - current: BrunchRouteSearch, - next: SharedExampleSearch, -): BrunchRouteSearch => ({ - runId: current.runId, - sse: current.sse, - ...next, -}); diff --git a/apps/petrinaut-website/src/routes/brunch.tsx b/apps/petrinaut-website/src/routes/brunch.tsx deleted file mode 100644 index a92b2a94bb7..00000000000 --- a/apps/petrinaut-website/src/routes/brunch.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { - createFileRoute, - useNavigate, - useSearch, -} from "@tanstack/react-router"; - -import { BrunchDemoApp } from "../main/app/brunch-demo/brunch-demo-app"; -import { - validateBrunchSearch, - withBrunchStreamKeys, -} from "../main/app/brunch-demo/brunch-search"; - -function BrunchRoute() { - const navigate = useNavigate({ from: "/brunch" }); - const search = useSearch({ from: "/brunch" }); - - return ( - { - void navigate({ - replace: history === "replace", - // Applied to the router's own previous search, so two navigations - // in one event compose instead of the second reverting the first. - search: (previous) => withBrunchStreamKeys(previous, nextSearch), - }); - }} - search={search} - /> - ); -} - -export const Route = createFileRoute("/brunch")({ - component: BrunchRoute, - validateSearch: validateBrunchSearch, -}); diff --git a/libs/@hashintel/petrinaut-core/src/actual-mode/README.md b/libs/@hashintel/petrinaut-core/src/actual-mode/README.md index ace369c6efd..f0578b415ef 100644 --- a/libs/@hashintel/petrinaut-core/src/actual-mode/README.md +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/README.md @@ -9,46 +9,43 @@ This folder contains the experimental, transport-neutral pieces of Petrinaut Actual Mode. Actual Mode lets Petrinaut render an execution that comes from an external -source instead of from Quick Simulation or Monte Carlo. The first integration is -the Brunch demo route in `apps/petrinaut-website`, which connects to a Brunch -SSE endpoint and feeds Petrinaut a Petri net definition, an initial marking, and -transition firing events. +source instead of from Quick Simulation or Monte Carlo. A host application +connects to that source however it likes, then feeds Petrinaut a Petri net +definition, an initial marking, and transition firing events through +`ActualModeContext`. Petrinaut does not know or care what the source is. ## Experimental Status -This is not a stable Petrinaut protocol yet. +This is not a stable Petrinaut protocol yet. The shapes below may change as +more hosts adopt Actual Mode. -The Brunch SSE event names, endpoint layout, raw export shape, and temporary -Brunch definition schema are still owned by the demo website integration. They -should not be treated as a public Petrinaut Core protocol until the Brunch and -Petrinaut teams standardize that contract. - -Core currently owns only the pieces that are useful independently of React and +Core owns only the pieces that are useful independently of React and independently of how a host transports events: -- the transition firing effect shape used by Petrinaut's timeline +- the source, marking and transition firing effect shapes used by Petrinaut's + timeline - marking reconstruction from an initial state plus transition effects - timeline point generation for a live or completed external execution - a `SimulationFrameReader` adapter so existing visualizer/timeline code can inspect Actual Mode frames -- recording helpers for normalized replay artifacts and raw received events +- recording helpers for normalized replay artifacts - the context value type shared with the React package -## Current Brunch Flow +Wire formats, connection handling and raw-payload exports belong to the host. -The current demo path is: +## Host Flow -1. `apps/petrinaut-website` opens `/brunch?sse=`. -2. The Brunch provider connects with `EventSource`. -3. Website-local parsers validate the temporary Brunch definition, initial - state, and transition firing payloads. -4. The website normalizes the Brunch definition into a read-only SDCPN with - Petrinaut extensions disabled. -5. `@hashintel/petrinaut` receives `ActualModeContext`. -6. Core reconstructs markings and timeline frames from the initial state and +1. The host connects to its execution source. +2. The host validates its own payloads and converts them to an SDCPN, an + `ActualModeMarking` and `ActualModeTransitionFiring`s. +3. The host provides an `ActualModeContextValue` with an `ActualModeSource`: + an opaque `id`, unique per execution, and an optional human-readable + `label`. A new `id` starts a new timeline. +4. `@hashintel/petrinaut` reads `ActualModeContext`. +5. Core reconstructs markings and timeline frames from the initial state and transition firing effects. -The currently accepted transition firing shape is: +The accepted transition firing shape is: ```json { @@ -62,6 +59,12 @@ The currently accepted transition firing shape is: `input` and `output` are transition-local token count maps. They are not full before/after markings. +## Recordings + +`createActualModeRecording` builds a normalized recording: the SDCPN, the +initial marking, the transition firings and the source. `parseActualModeRecording` +reads one back. + ## File Map - `constants.ts`: shared Actual Mode constants. @@ -70,8 +73,5 @@ before/after markings. - `context.ts`: unavailable/default context value. - `marking.ts`: marking reconstruction helpers. - `timeline.ts`: live timeline point generation and frame-reader adapter. -- `recording.ts`: normalized and raw-event recording helpers. +- `recording.ts`: normalized recording helpers. - `time.ts`: timestamp parsing helpers used by recordings and timelines. - -When the Brunch/Petrinaut protocol becomes stable, the standardized protocol -schemas should move here from the website adapter. diff --git a/libs/@hashintel/petrinaut-core/src/actual-mode/actual-mode.test.ts b/libs/@hashintel/petrinaut-core/src/actual-mode/actual-mode.test.ts index e75441d06b5..a3869583dd9 100644 --- a/libs/@hashintel/petrinaut-core/src/actual-mode/actual-mode.test.ts +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/actual-mode.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import { - createActualModeReceivedEventsRecording, createActualModeRecording, createActualModeTimelineFrameReader, parseActualModeRecording, @@ -36,8 +35,8 @@ describe("Actual mode recordings", () => { const recording = createActualModeRecording({ title: "Replay", source: { - kind: "brunch", - endpoint: "http://127.0.0.1:5184/stream", + id: "run-1", + label: "Run 1", }, definition, initialState: { queued: 1 }, @@ -67,29 +66,6 @@ describe("Actual mode recordings", () => { }); }); - it("exports raw received events without mapping to SDCPN", () => { - const rawDefinition = { - title: "Raw Brunch run", - places: [{ id: "queued", name: "Queued" }], - transitions: [], - }; - - const recording = createActualModeReceivedEventsRecording({ - title: "Replay", - source: null, - events: [{ event: "definition", data: rawDefinition }], - exportedAt: "2026-06-05T10:01:00.000Z", - }); - - expect(recording).toEqual({ - version: 1, - exportedAt: "2026-06-05T10:01:00.000Z", - title: "Replay", - source: null, - events: [{ event: "definition", data: rawDefinition }], - }); - }); - it("retimes transition firings relative to the first event", () => { const recording = createActualModeRecording({ title: "Replay", diff --git a/libs/@hashintel/petrinaut-core/src/actual-mode/context.ts b/libs/@hashintel/petrinaut-core/src/actual-mode/context.ts index 0de3543a69f..a71ce24cd70 100644 --- a/libs/@hashintel/petrinaut-core/src/actual-mode/context.ts +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/context.ts @@ -8,7 +8,6 @@ export const unavailableActualMode: ActualModeContextValue = { definition: null, initialState: null, transitionFirings: [], - receivedEvents: [], timelineStartedAtMs: null, timelineNowMs: null, error: null, diff --git a/libs/@hashintel/petrinaut-core/src/actual-mode/index.ts b/libs/@hashintel/petrinaut-core/src/actual-mode/index.ts index 4f4719805da..4dbb5140830 100644 --- a/libs/@hashintel/petrinaut-core/src/actual-mode/index.ts +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/index.ts @@ -8,15 +8,12 @@ export { getActualModeMarkingAtTransitionFiringIndex, } from "./marking"; export { - createActualModeReceivedEventsRecording, createActualModeRecording, parseActualModeRecording, retimeActualModeRecordingForReplay, } from "./recording"; export { actualModeMarkingSchema, - actualModeReceivedEventSchema, - actualModeReceivedEventsRecordingSchema, actualModeRecordingSchema, actualModeSourceSchema, actualModeTransitionEffectSchema, @@ -30,8 +27,6 @@ export { export type { ActualModeContextValue, ActualModeMarking, - ActualModeReceivedEvent, - ActualModeReceivedEventsRecording, ActualModeRecording, ActualModeSource, ActualModeTimelinePoint, diff --git a/libs/@hashintel/petrinaut-core/src/actual-mode/marking.ts b/libs/@hashintel/petrinaut-core/src/actual-mode/marking.ts index dd8ebffbbce..6570a2d1911 100644 --- a/libs/@hashintel/petrinaut-core/src/actual-mode/marking.ts +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/marking.ts @@ -112,8 +112,8 @@ export const getActualModeMarkingAtTransitionFiringIndex = (params: { let marking = initialState; // TODO(actual-mode follow-up): this reconstructs markings by replaying from - // the beginning for each requested frame. That is acceptable for this first - // Brunch integration, but large streams need a prefix marking cache or + // the beginning for each requested frame. That is acceptable for short + // executions, but large streams need a prefix marking cache or // incremental timeline reader so scrubbing does not become O(n^2). for (let index = 0; index <= transitionFiringIndex; index += 1) { const firing = transitionFirings[index]; diff --git a/libs/@hashintel/petrinaut-core/src/actual-mode/recording.ts b/libs/@hashintel/petrinaut-core/src/actual-mode/recording.ts index 1fc7557ac95..032eae3658e 100644 --- a/libs/@hashintel/petrinaut-core/src/actual-mode/recording.ts +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/recording.ts @@ -6,8 +6,6 @@ import { parseRequiredActualModeTimestampMs } from "./time"; import type { SDCPN } from "../types/sdcpn"; import type { ActualModeMarking, - ActualModeReceivedEvent, - ActualModeReceivedEventsRecording, ActualModeRecording, ActualModeSource, ActualModeTransitionFiring, @@ -30,22 +28,6 @@ export const createActualModeRecording = (params: { transitionFirings: params.transitionFirings.map((firing) => ({ ...firing })), }); -export const createActualModeReceivedEventsRecording = (params: { - title: string | null; - source: ActualModeSource | null; - events: readonly ActualModeReceivedEvent[]; - exportedAt?: string; -}): ActualModeReceivedEventsRecording => ({ - version: ACTUAL_MODE_RECORDING_VERSION, - exportedAt: params.exportedAt ?? new Date().toISOString(), - title: params.title, - source: params.source, - events: params.events.map((event) => ({ - event: event.event, - data: event.data, - })), -}); - export const parseActualModeRecording = ( data: unknown, ): ActualModeRecording => { diff --git a/libs/@hashintel/petrinaut-core/src/actual-mode/schemas.ts b/libs/@hashintel/petrinaut-core/src/actual-mode/schemas.ts index d075cb083fd..f1eca387a57 100644 --- a/libs/@hashintel/petrinaut-core/src/actual-mode/schemas.ts +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/schemas.ts @@ -7,8 +7,6 @@ import { ACTUAL_MODE_RECORDING_VERSION } from "./constants"; import type { SDCPN } from "../types/sdcpn"; import type { ActualModeMarking, - ActualModeReceivedEvent, - ActualModeReceivedEventsRecording, ActualModeRecording, ActualModeSource, ActualModeTransitionEffect, @@ -24,7 +22,7 @@ const actualModeMarkingValueSchema = z.union([ /** * Root schema for an Actual Mode marking. * - * This validates `initial_state` stream frames and recording snapshots. Places + * This validates host-supplied initial markings and recording snapshots. Places * can currently be represented by a numeric token count or by token-colour * arrays for future coloured-token support. */ @@ -56,7 +54,7 @@ const actualModeTransitionFiringEffectSchema = z /** * Root schema for Actual Mode transition events. * - * This is the only accepted `transition_firing` payload shape for this PR: + * This is the only accepted transition firing shape: * `input` contains consumed token counts, `output` contains produced token * counts, and neither field carries a full before or after marking. */ @@ -65,19 +63,11 @@ export const actualModeTransitionFiringSchema = export const actualModeSourceSchema = z .object({ - kind: z.literal("brunch"), - endpoint: z.string(), - runId: z.string().optional(), + id: z.string(), + label: z.string().optional(), }) .strict() satisfies z.ZodType; -export const actualModeReceivedEventSchema = z - .object({ - event: z.string(), - data: z.unknown(), - }) - .strict() satisfies z.ZodType; - const actualModeRecordingDefinitionSchema = z.custom( (value) => sdcpnSchema.safeParse(value).success, { message: "Invalid SDCPN definition" }, @@ -98,11 +88,3 @@ export const actualModeRecordingSchema = z.object({ initialState: actualModeMarkingSchema, transitionFirings: z.array(actualModeTransitionFiringSchema), }) satisfies z.ZodType; - -export const actualModeReceivedEventsRecordingSchema = z.object({ - version: z.literal(ACTUAL_MODE_RECORDING_VERSION), - exportedAt: z.string(), - title: z.string().nullable(), - source: actualModeSourceSchema.nullable(), - events: z.array(actualModeReceivedEventSchema), -}) satisfies z.ZodType; diff --git a/libs/@hashintel/petrinaut-core/src/actual-mode/types.ts b/libs/@hashintel/petrinaut-core/src/actual-mode/types.ts index b9f63ba55f7..5fdb28ed61e 100644 --- a/libs/@hashintel/petrinaut-core/src/actual-mode/types.ts +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/types.ts @@ -25,15 +25,14 @@ export type ActualModeTransitionFiring = { ts: string; }; -export type ActualModeReceivedEvent = { - event: string; - data: unknown; -}; - +/** + * The host's execution source. Petrinaut treats both fields as opaque. + */ export type ActualModeSource = { - kind: "brunch"; - endpoint: string; - runId?: string; + /** Unique per execution; a new id starts a new timeline. */ + id: string; + /** Human-readable provenance, such as a run name or URL, kept in recordings. */ + label?: string; }; export type ActualModeRecording = { @@ -46,14 +45,6 @@ export type ActualModeRecording = { transitionFirings: ActualModeTransitionFiring[]; }; -export type ActualModeReceivedEventsRecording = { - version: typeof ACTUAL_MODE_RECORDING_VERSION; - exportedAt: string; - title: string | null; - source: ActualModeSource | null; - events: ActualModeReceivedEvent[]; -}; - export type ActualModeContextValue = | { available: false; @@ -63,7 +54,6 @@ export type ActualModeContextValue = definition: null; initialState: null; transitionFirings: readonly []; - receivedEvents: readonly []; timelineStartedAtMs: null; timelineNowMs: null; error: null; @@ -76,7 +66,6 @@ export type ActualModeContextValue = definition: SDCPN | null; initialState: ActualModeMarking | null; transitionFirings: readonly ActualModeTransitionFiring[]; - receivedEvents: readonly ActualModeReceivedEvent[]; timelineStartedAtMs: number | null; timelineNowMs: number | null; error: string | null; diff --git a/libs/@hashintel/petrinaut-core/src/ai.ts b/libs/@hashintel/petrinaut-core/src/ai.ts index 038c2d078be..ecf63407940 100644 --- a/libs/@hashintel/petrinaut-core/src/ai.ts +++ b/libs/@hashintel/petrinaut-core/src/ai.ts @@ -141,7 +141,7 @@ export const petrinautDocSummaries: Record = { "simulation-panels": "Experiment and scenario panels, fullscreen controls, state preservation, docked and floating AI layout, links and browser history, session limits for experiments.", "actual-mode": - "Actual mode: host-provided live execution view, Brunch stream URL route, read-only extension-free net, current limits.", + "Actual mode: host-provided live execution view, read-only net, timeline scrubbing, Events tab with recording and net exports.", preview: "Compact read-only PetrinautPreview for host-controlled embeds: shared SDCPN canvas, pan/zoom/fit/minimap, selection and responsive inspector, root/subnet navigation, URL-state ownership, omitted editing and management UI, and host-owned iframe security.", "ai-assistant": diff --git a/libs/@hashintel/petrinaut-core/src/index.ts b/libs/@hashintel/petrinaut-core/src/index.ts index 8f876b4e904..daedf84809f 100644 --- a/libs/@hashintel/petrinaut-core/src/index.ts +++ b/libs/@hashintel/petrinaut-core/src/index.ts @@ -21,7 +21,6 @@ export { applyActualModeTransitionFiring, buildActualModeTimelinePoints, createActualModeRecording, - createActualModeReceivedEventsRecording, createActualModeTimelineFrameReader, getActualModeMarkingAtTransitionFiringIndex, getActualModeTransitionFiringTimesMs, @@ -32,8 +31,6 @@ export { export type { ActualModeContextValue, ActualModeMarking, - ActualModeReceivedEvent, - ActualModeReceivedEventsRecording, ActualModeRecording, ActualModeSource, ActualModeTimelinePoint, diff --git a/libs/@hashintel/petrinaut/docs/README.md b/libs/@hashintel/petrinaut/docs/README.md index 13eb95f2559..f4f71115e6a 100644 --- a/libs/@hashintel/petrinaut/docs/README.md +++ b/libs/@hashintel/petrinaut/docs/README.md @@ -25,7 +25,7 @@ Petrinaut has three global modes in the top bar, though **Actual** is only enabl - **Edit** -- Canvas and Definitions views for editing the same net, plus single-run simulation playback on Canvas. - **Simulate** -- a separate management surface for scenarios and experiments. -- **Actual** -- a read-only live-execution view supplied by a host such as Brunch. +- **Actual** -- a read-only view of an execution supplied by the host application. ## Contents @@ -37,7 +37,7 @@ Petrinaut has three global modes in the top bar, though **Actual** is only enabl - [Ad-hoc Scenarios](ad-hoc-scenarios.md) -- The scenario form: define initial state and parameters inline for one run, or save them as a scenario. - [Experiments](experiments.md) -- Run Monte Carlo batches and inspect token-count distributions over time. - [Simulation Panels](simulation-panels.md) -- Open scenarios and experiments beside the main view, expand to fullscreen, and use links and browser history. -- [Actual Mode](actual-mode.md) -- View a host-provided live Petri net execution, currently via Brunch. +- [Actual Mode](actual-mode.md) -- View a live or completed Petri net execution supplied by the host application. - [Embedded Preview](preview.md) -- Explore a compact, read-only Petri net embedded in a host application. - [AI Assistant](ai-assistant.md) -- Build, review, and revise nets with text or inline Voice mode. - [Code Editor](code-editor.md) -- Edit model functions and expand their sections within the Properties Panel. diff --git a/libs/@hashintel/petrinaut/docs/actual-mode.md b/libs/@hashintel/petrinaut/docs/actual-mode.md index 6589d6a7ef7..f27cfbfa9d0 100644 --- a/libs/@hashintel/petrinaut/docs/actual-mode.md +++ b/libs/@hashintel/petrinaut/docs/actual-mode.md @@ -1,32 +1,25 @@ # Actual Mode -Actual mode is used when Petrinaut is opened by a host that can provide a live execution stream. In the demo website, the first supported host is Brunch. +Actual mode shows an execution that happened, or is happening, outside Petrinaut: the host application supplies a Petri net, its initial state and the transitions that fired, instead of Petrinaut simulating them. Where that execution comes from is up to the host. -When Actual mode is available, the top-bar mode selector enables **Actual** and Petrinaut opens on that tab by default. The canvas shows the Petri net from the live source. The document is read-only: you can pan, zoom, select items, inspect properties, export the net, and switch to the other global modes, but you cannot change places, transitions, arcs, parameters, types, or dynamic behaviour. +When Actual mode is available, the top-bar mode selector enables **Actual** and Petrinaut opens on that tab by default. The canvas shows the Petri net supplied by the host. The document is read-only: you can pan, zoom, select items, inspect properties, export the net, and switch to the other global modes, but you cannot change places, transitions, arcs, parameters, types, or dynamic behaviour. -## Brunch live runs - -The demo website enables Actual mode on the `/brunch` route when the URL includes a Brunch stream endpoint: - -```text -/brunch?sse= -``` - -Petrinaut connects to the stream, waits for the Petri net definition and initial state, lays out the net if the stream did not include node positions, and then shows the net in Actual mode. - -If the stream connection is interrupted, Petrinaut keeps any loaded Actual mode data visible and waits for the browser to reconnect. Once the connection is restored, the Brunch stream replays the run from the beginning and Petrinaut rebuilds the timeline and events from that replay, so an interruption does not duplicate transition events or miss ones that fired while disconnected. If the stream sends invalid data, Petrinaut shows an error page with a link back to the normal demo site. +While the host is still connecting or waiting for the net, and if the host reports an error, what you see is up to the host application. ## Timeline and events -Actual mode opens the bottom panel with an Actual timeline once execution data is available. The timeline can be scrubbed to inspect the net state at earlier points in the received stream. Use the series selector below the chart to show, hide, or focus individual traces. Hover a trace and click the eye icon to hide it -- it stays in place, struck through, until the pointer leaves the selector, so a second click undoes the change -- or open the dropdown to search and use **Only**. +Actual mode opens the bottom panel with an Actual timeline once execution data is available. The timeline can be scrubbed to inspect the net state at earlier points in the execution. Use the series selector below the chart to show, hide, or focus individual traces. Hover a trace and click the eye icon to hide it -- it stays in place, struck through, until the pointer leaves the selector, so a second click undoes the change -- or open the dropdown to search and use **Only**. + +The bottom panel also includes an **Events** tab. It shows the transitions that fired, in order, including each event timestamp, transition name, and the input and output tokens by place name. An unnamed transition or place shows its id. -The bottom panel also includes an **Events** tab. It shows the received transition stream in order, including each event timestamp, transition name, and the input and output tokens by place name. An unnamed transition or place shows its id. Use the **Export** dropdown in this tab to download either the received event stream or the current Petri net. +## Exporting -Choose **Export Stream** to download the received event stream. Brunch stream exports preserve the raw JSON payloads received from the SSE endpoint instead of the normalized SDCPN used internally for rendering. +Use the **Export** dropdown in the Events tab: -Choose **Export Net** to download a normal Petrinaut net file (YAML). This file contains the read-only Petri net currently shown in Actual mode and can be imported back into Petrinaut like other net exports. +- **Export Recording** downloads a JSON recording of the execution as Petrinaut shows it: the net, its initial state, every transition firing, and the host's source id and label. +- **Export Net** downloads a normal Petrinaut net file (YAML). This file contains the read-only Petri net currently shown in Actual mode and can be imported back into Petrinaut like other net exports. -For Brunch, the export is a JSON object with an `events` array. Each item stores the SSE event name and the parsed JSON payload exactly as Petrinaut received it. Transition payloads store the firing effect rather than a full before/after snapshot. The `input` and `output` fields are numeric count maps keyed by place id. The stream may use any id string: Petrinaut converts the ids in the net and in each event to the same UUIDs, and the export keeps the ids as received. +In a recording, each transition firing stores its effect rather than a full before/after snapshot. The `input` and `output` fields are numeric count maps keyed by place id: ```json { @@ -37,8 +30,6 @@ For Brunch, the export is a JSON object with an `events` array. Each item stores } ``` -The stream export is an event-stream artifact for tooling that can serve the Brunch SSE protocol. The demo website does not replay the file directly. - ## Current limits -The Brunch route opens a basic Petri net view with Petrinaut extensions disabled: no colours, stochasticity, dynamics, or parameters. +Transition firings carry token counts per place, not the values of coloured tokens. diff --git a/libs/@hashintel/petrinaut/src/react/execution-frame/provider.test.tsx b/libs/@hashintel/petrinaut/src/react/execution-frame/provider.test.tsx index 237e6878cde..af988e686ad 100644 --- a/libs/@hashintel/petrinaut/src/react/execution-frame/provider.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/execution-frame/provider.test.tsx @@ -55,11 +55,7 @@ const sdcpnContextValue: SDCPNContextValue = { const availableActualMode: ActualModeContextValue = { available: true, - source: { - kind: "brunch", - endpoint: "http://127.0.0.1:5184/stream", - runId: "run-1", - }, + source: { id: "run-1", label: "Run 1" }, status: "complete", title: "Run", definition, @@ -72,7 +68,6 @@ const availableActualMode: ActualModeContextValue = { ts: "2026-06-05T10:00:00.000Z", }, ], - receivedEvents: [], timelineStartedAtMs: Date.parse("2026-06-05T10:00:00.000Z"), timelineNowMs: Date.parse("2026-06-05T10:00:05.000Z"), error: null, @@ -134,6 +129,7 @@ describe("useActualExecutionFrameSource", () => { // One initial point plus one transition firing; no live ticks because the // stream is complete. + expect(captured.source?.sourceId).toMatch(/^actual:run-1:/); expect(captured.source?.totalFrames).toBe(2); expect(captured.source?.currentFrameIndex).toBe(0); expect(captured.source?.currentViewedFrame?.places).toEqual({ diff --git a/libs/@hashintel/petrinaut/src/react/execution-frame/provider.tsx b/libs/@hashintel/petrinaut/src/react/execution-frame/provider.tsx index c66cb698e32..800ed947ef6 100644 --- a/libs/@hashintel/petrinaut/src/react/execution-frame/provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/execution-frame/provider.tsx @@ -144,9 +144,7 @@ export const useActualExecutionFrameSource = (params: { ); return { - sourceId: `actual:${source.kind}:${source.endpoint}:${ - source.runId ?? "" - }:${baselineKey}`, + sourceId: `actual:${source.id}:${baselineKey}`, totalFrames, currentFrameIndex, currentFrameReader, diff --git a/libs/@hashintel/petrinaut/src/ui/file-io/export-actual-mode-recording.ts b/libs/@hashintel/petrinaut/src/ui/file-io/export-actual-mode-recording.ts index 8fb74cb1764..c8452a5015d 100644 --- a/libs/@hashintel/petrinaut/src/ui/file-io/export-actual-mode-recording.ts +++ b/libs/@hashintel/petrinaut/src/ui/file-io/export-actual-mode-recording.ts @@ -1,8 +1,6 @@ import { createActualModeRecording, - createActualModeReceivedEventsRecording, type ActualModeMarking, - type ActualModeReceivedEvent, type ActualModeSource, type ActualModeTransitionFiring, type SDCPN, @@ -13,38 +11,23 @@ import { downloadBlob, timestampedFilename } from "../lib/download-blob"; export function exportActualModeRecording({ definition, initialState, - receivedEvents, source, title, transitionFirings, }: { - definition: SDCPN | null; - initialState: ActualModeMarking | null; - receivedEvents?: readonly ActualModeReceivedEvent[]; + definition: SDCPN; + initialState: ActualModeMarking; source: ActualModeSource | null; title: string | null; transitionFirings: readonly ActualModeTransitionFiring[]; }): void { - const recording = - receivedEvents && receivedEvents.length > 0 - ? createActualModeReceivedEventsRecording({ - title, - source, - events: receivedEvents, - }) - : definition && initialState - ? createActualModeRecording({ - title, - source, - definition, - initialState, - transitionFirings, - }) - : null; - - if (!recording) { - return; - } + const recording = createActualModeRecording({ + title, + source, + definition, + initialState, + transitionFirings, + }); downloadBlob({ content: JSON.stringify(recording, null, 2), diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/actual-events.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/actual-events.test.tsx index 75203b099c1..666efa05570 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/actual-events.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/actual-events.test.tsx @@ -62,7 +62,7 @@ describe("Actual mode events", () => { { ts: "2026-10-07T00:00:00.000Z", }, ], - receivedEvents: [], timelineStartedAtMs: null, timelineNowMs: null, error: null, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/actual-events.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/actual-events.tsx index 748c48f6553..c6bb87488f4 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/actual-events.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/actual-events.tsx @@ -243,26 +243,29 @@ const EventRow: React.FC<{ const ActualEventsContent: React.FC = () => { const actualMode = use(ActualModeContext); - const canExportStream = + const canExportRecording = actualMode.available && - (actualMode.receivedEvents.length > 0 || - (actualMode.definition !== null && actualMode.initialState !== null)); + actualMode.definition !== null && + actualMode.initialState !== null; const canExportNet = actualMode.available && actualMode.definition !== null; - const canExport = canExportStream || canExportNet; + const canExport = canExportRecording || canExportNet; const transitionFirings = actualMode.transitionFirings; const visibleFirings = transitionFirings.slice(-MAX_VISIBLE_EVENTS); const firstVisibleIndex = transitionFirings.length - visibleFirings.length; const namesById = namesByIdOf(actualMode.definition); - const handleExportStream = () => { - if (!actualMode.available || !canExportStream) { + const handleExportRecording = () => { + if ( + !actualMode.available || + actualMode.definition === null || + actualMode.initialState === null + ) { return; } exportActualModeRecording({ definition: actualMode.definition, initialState: actualMode.initialState, - receivedEvents: actualMode.receivedEvents, source: actualMode.source, title: actualMode.title, transitionFirings, @@ -282,10 +285,10 @@ const ActualEventsContent: React.FC = () => { const exportMenuItems: MenuItem[] = [ { - id: "export-stream", - text: "Export Stream", - disabled: !canExportStream, - onClick: handleExportStream, + id: "export-recording", + text: "Export Recording", + disabled: !canExportRecording, + onClick: handleExportRecording, }, { id: "export-net", @@ -410,6 +413,6 @@ export const actualEventsSubView: SubView = { id: "actual-events", title: "Events", tooltip: - "Inspect the Actual mode transition stream and export stream or net JSON.", + "Inspect the Actual mode transition stream and export a recording or the net.", component: ActualEventsContent, }; diff --git a/libs/@local/petrinaut-arch-docs/content/handle/host-integration.mdx b/libs/@local/petrinaut-arch-docs/content/handle/host-integration.mdx index 6c8434d75f6..bcca1efe174 100644 --- a/libs/@local/petrinaut-arch-docs/content/handle/host-integration.mdx +++ b/libs/@local/petrinaut-arch-docs/content/handle/host-integration.mdx @@ -449,7 +449,6 @@ enabled. | ------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | | In-memory handle, explicit save button, dirty flag via `isSDCPNEqual` | `apps/hash-frontend/src/pages/processes/[uuid]/embed.page/embed-content.tsx` | | Persist on every change event to localStorage | `apps/petrinaut-website/src/main/app/local-storage-demo/documents/use-active-handle.ts` | -| Read-only mirror of a streamed definition | `apps/petrinaut-website/src/main/app/brunch-demo/brunch-petrinaut.tsx` | | One live handle per net, history survives switching | `petrinaut/src/ui/petrinaut-story-provider.tsx` | | Host plugin: top-bar items reading page state from a host context above ``, so the plugin is a module constant | `apps/hash-frontend/src/pages/processes/[uuid]/embed.page/embed-chrome-plugin.tsx` | diff --git a/libs/@local/petrinaut-arch-docs/content/website/plugins.mdx b/libs/@local/petrinaut-arch-docs/content/website/plugins.mdx index 2a115ea9cbc..9a509ecb620 100644 --- a/libs/@local/petrinaut-arch-docs/content/website/plugins.mdx +++ b/libs/@local/petrinaut-arch-docs/content/website/plugins.mdx @@ -19,8 +19,7 @@ Each plugin here is written against the [plugin API](doc:plugins/overview). ## The list the editor receives The demo shell passes one list, `demoPlugins` from `plugins/demo-plugins.ts`, -to ``. The Brunch Actual Mode route passes -`[sentryFeedbackPlugin]` alone. +to ``. Both lists start with `editorPlugins`, every plugin in the table but Petrinaut AI, so only the assistant changes. diff --git a/libs/@local/petrinaut-arch-docs/content/website/router-integration.mdx b/libs/@local/petrinaut-arch-docs/content/website/router-integration.mdx index 84e03ac6258..b4ad8ee8e46 100644 --- a/libs/@local/petrinaut-arch-docs/content/website/router-integration.mdx +++ b/libs/@local/petrinaut-arch-docs/content/website/router-integration.mdx @@ -46,9 +46,7 @@ so a host that passes a controller replaces the provider's initial state outright. That includes the Actual-mode default the provider applies when a live stream is available. A page that must open in a non-default mode states it in the hook's `initialState`, which seeds the fields the URL does not -carry; the URL still wins for the fields it owns. `/brunch` seeds -`mode: "actual"` for exactly this reason, and without it the execution frame -would read the local simulation instead of the stream. +carry; the URL still wins for the fields it owns. ## Validation normalizes the location @@ -66,14 +64,12 @@ navigation drops them, because the routes write a fresh contract-only search rather than merging into the existing one. Params an older link may still carry (`view=optimizations`, `overlay=create-optimization`, `present`) fall under the same rule: `validateSharedExampleSearch` normalises them to the -baseline location, and the first navigation rewrites the URL. `/brunch` is the exception: its -`sse` and `runId` params name the stream, so it carries them over and replaces -only the contract part. +baseline location, and the first navigation rewrites the URL. ## Which routes speak the contract Every page that mounts Petrinaut does: the editable demo at `/local/$netId`, -the Brunch stream at `/brunch`, the example pages at `/examples/$slug` and +the example pages at `/examples/$slug` and `/embed/examples/$slug`, and snapshots at `/share`. Each also names its model in the URL. The editable demo names a net saved in @@ -81,9 +77,7 @@ this browser's local storage by its id, so the link opens that net only in the browser that saved it. The demo takes the id as a prop and asks the route to open another net, so Back and Forward move between nets. The demo passes the net id as the hook's `resetKey`, so opening another net starts the location -over from the URL, as a fresh page would. `/brunch` names its -model with the stream keys it carries over, so a copied URL reproduces the -run. The example pages name the model in the path. `/share` carries the whole +over from the URL, as a fresh page would. The example pages name the model in the path. `/share` carries the whole net in its fragment, `#v1.br.`, which the browser does not send to the server. The route keeps the fragment when the view changes, and keys the snapshot page by it, so Back and Forward between snapshots open each one