From 4fe2f8ac9f698ddb79b5dc936ea06bbf8df74996 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Tue, 20 Oct 2026 13:46:10 +0200 Subject: [PATCH 1/4] FE-2028: Make Petrinaut's Actual Mode independent of what feeds it Co-authored-by: Cursor --- .changeset/host-agnostic-actual-mode.md | 6 + apps/petrinaut-website/README.md | 35 ++++++ .../scripts/brunch-sse-fixture.ts | 24 ++-- .../brunch-actual-mode-provider.tsx | 29 +++-- .../brunch-demo/brunch-actual-mode-route.tsx | 6 +- .../main/app/brunch-demo/brunch-petrinaut.tsx | 39 +++--- .../main/app/brunch-demo/brunch-protocol.ts | 6 + .../brunch-demo/brunch-stream-export.test.ts | 48 ++++++++ .../app/brunch-demo/brunch-stream-export.tsx | 92 ++++++++++++++ .../petrinaut-core/src/actual-mode/README.md | 59 ++++----- .../src/actual-mode/actual-mode.test.ts | 45 +++---- .../src/actual-mode/constants.ts | 2 +- .../petrinaut-core/src/actual-mode/context.ts | 1 - .../petrinaut-core/src/actual-mode/index.ts | 5 - .../petrinaut-core/src/actual-mode/marking.ts | 4 +- .../src/actual-mode/recording.ts | 18 --- .../petrinaut-core/src/actual-mode/schemas.ts | 26 +--- .../petrinaut-core/src/actual-mode/types.ts | 25 ++-- libs/@hashintel/petrinaut-core/src/ai.ts | 2 +- libs/@hashintel/petrinaut-core/src/index.ts | 3 - libs/@hashintel/petrinaut/docs/README.md | 4 +- libs/@hashintel/petrinaut/docs/actual-mode.md | 33 ++--- .../react/execution-frame/provider.test.tsx | 8 +- .../src/react/execution-frame/provider.tsx | 4 +- .../file-io/export-actual-mode-recording.ts | 35 ++---- libs/@hashintel/petrinaut/src/ui/plugin.ts | 2 + .../petrinaut/src/ui/plugins/README.md | 2 + .../plugins/define-petrinaut-plugin.test.ts | 32 +++++ .../src/ui/plugins/define-petrinaut-plugin.ts | 27 +++- .../ui/plugins/plugin-contributions.test.ts | 6 + .../src/ui/plugins/plugin-contributions.ts | 6 + .../plugins-section/plugin-row.tsx | 1 + .../subviews/actual-events.test.tsx | 115 ++++++++++++++---- .../BottomPanel/subviews/actual-events.tsx | 44 +++++-- .../content/plugins/overview.mdx | 2 + .../content/plugins/reference.mdx | 7 +- .../content/plugins/writing-a-plugin.mdx | 28 +++++ 37 files changed, 562 insertions(+), 269 deletions(-) create mode 100644 .changeset/host-agnostic-actual-mode.md create mode 100644 apps/petrinaut-website/src/main/app/brunch-demo/brunch-stream-export.test.ts create mode 100644 apps/petrinaut-website/src/main/app/brunch-demo/brunch-stream-export.tsx diff --git a/.changeset/host-agnostic-actual-mode.md b/.changeset/host-agnostic-actual-mode.md new file mode 100644 index 00000000000..84384a853fa --- /dev/null +++ b/.changeset/host-agnostic-actual-mode.md @@ -0,0 +1,6 @@ +--- +"@hashintel/petrinaut-core": patch +"@hashintel/petrinaut": patch +--- + +Actual mode no longer assumes where its execution comes from. `ActualModeSource` is `{ id, label? }`, an opaque id and an optional label the host chooses. `ActualModeContextValue` drops `receivedEvents`, and core drops `ActualModeReceivedEvent`, `ActualModeReceivedEventsRecording` and `createActualModeReceivedEventsRecording`. `ACTUAL_MODE_RECORDING_VERSION` is now 2, and `parseActualModeRecording` rejects version 1 recordings. The Events tab's **Export Recording** downloads Petrinaut's recording, and plugins can add their own items to its Export menu with the new `actualExports` contribution. diff --git a/apps/petrinaut-website/README.md b/apps/petrinaut-website/README.md index 96e9f14d85e..6f43d3c6bd6 100644 --- a/apps/petrinaut-website/README.md +++ b/apps/petrinaut-website/README.md @@ -98,6 +98,41 @@ from the same `website.local-copy` plugin as on example pages, saves it at its o events, transactions, spans and breadcrumbs have the fragment stripped before sending. +## Brunch live runs in Actual mode + +The `/brunch` route feeds Petrinaut's Actual mode from a Brunch run. The URL +names the Brunch stream endpoint, and optionally the run: + +```text +/brunch?sse=&runId= +``` + +The route connects to the stream with `EventSource`, 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 with Petrinaut extensions +disabled: no colours, stochasticity, dynamics or parameters. The adapter lives +in `src/main/app/brunch-demo/`. + +If the stream connection is interrupted, the route keeps any loaded data +visible and waits for the browser to reconnect. On reconnect the Brunch stream +replays the run from the beginning and the route rebuilds the timeline and +events from that replay, so an interruption neither duplicates transition +events nor misses ones that fired while disconnected. If the stream sends +invalid data, the route shows an error page with a link back to the demo site. + +In the Events tab, **Export Stream** comes from the required +`website.brunch-stream-export` plugin. It downloads a +`.petrinaut-actual.json` file with the frames exactly as received: an `events` +array whose items hold the SSE event name and the parsed JSON payload, plus +`version: 1`, `exportedAt`, `title`, and `source: { kind: "brunch", endpoint, +runId? }`. The stream may use any id string; Petrinaut converts ids to UUIDs +for rendering, but the export keeps them as received. Petrinaut's own +**Export Recording** and **Export Net** sit below it. + +`yarn brunch:fixture` serves a local Brunch-compatible stream and prints +`/brunch` URLs for it. Pass `--recording=` to replay an Export Stream or +Export Recording file instead of generated events. + ## Example embeds and oEmbed Canonical example pages live below `/examples`. The JSON oEmbed endpoint at diff --git a/apps/petrinaut-website/scripts/brunch-sse-fixture.ts b/apps/petrinaut-website/scripts/brunch-sse-fixture.ts index f2dbc9d6fe3..d43ff29a313 100644 --- a/apps/petrinaut-website/scripts/brunch-sse-fixture.ts +++ b/apps/petrinaut-website/scripts/brunch-sse-fixture.ts @@ -28,12 +28,10 @@ import { resolve } from "node:path"; import type { BrunchNetDefinitionInput, + BrunchReceivedEvent, BrunchTransitionInput, } from "../src/main/app/brunch-demo/brunch-protocol"; -import type { - ActualModeReceivedEvent, - ActualModeTransitionFiring, -} from "@hashintel/petrinaut-core"; +import type { ActualModeTransitionFiring } from "@hashintel/petrinaut-core"; type NumericMarking = Record; @@ -48,7 +46,7 @@ type TimedSseFrame = SseFrame & { type RecordingReplay = { definition: BrunchNetDefinitionInput; - events: ActualModeReceivedEvent[]; + events: BrunchReceivedEvent[]; initialState: NumericMarking; path: string; transitionFirings: ActualModeTransitionFiring[]; @@ -241,7 +239,7 @@ const parseTransitionFiring = ( const parseReceivedEventsRecording = ( data: Record, -): ActualModeReceivedEvent[] => { +): BrunchReceivedEvent[] => { if (!Array.isArray(data.events)) { throw new Error("Recording events must be an array."); } @@ -390,7 +388,7 @@ const toBrunchDefinitionInput = (data: unknown): BrunchNetDefinitionInput => { const parseNormalizedRecording = ( data: Record, -): ActualModeReceivedEvent[] => { +): BrunchReceivedEvent[] => { if (!Array.isArray(data.transitionFirings)) { throw new Error("Recording transitionFirings must be an array."); } @@ -408,7 +406,7 @@ const parseNormalizedRecording = ( ]; }; -const parseRecordingEvents = (data: unknown): ActualModeReceivedEvent[] => { +const parseRecordingEvents = (data: unknown): BrunchReceivedEvent[] => { if (!isRecord(data)) { throw new Error("Recording root must be an object."); } @@ -426,14 +424,14 @@ const parseRecordingEvents = (data: unknown): ActualModeReceivedEvent[] => { } throw new Error( - "Recording must be an Actual Events export with `events` or an older normalized recording.", + "Recording must be an Export Stream file with `events` or an Export Recording file.", ); }; const findFirstEvent = ( - events: ActualModeReceivedEvent[], + events: BrunchReceivedEvent[], eventName: string, -): ActualModeReceivedEvent | null => +): BrunchReceivedEvent | null => events.find((event) => event.event === eventName) ?? null; const parseRecordingReplay = (path: string): RecordingReplay => { @@ -462,7 +460,7 @@ const parseRecordingReplay = (path: string): RecordingReplay => { }; }; -const getEventTimestampMs = (event: ActualModeReceivedEvent): number | null => { +const getEventTimestampMs = (event: BrunchReceivedEvent): number | null => { if (!isRecord(event.data) || typeof event.data.ts !== "string") { return null; } @@ -490,7 +488,7 @@ const retimeEventData = (data: unknown, deltaMs: number): unknown => { }; const createTimedReplayFrames = ( - events: ActualModeReceivedEvent[], + events: BrunchReceivedEvent[], launchTimeMs: number, ): TimedSseFrame[] => { const firstTimestampMs = events 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 index 8974dd4e4ef..3f5fea236c3 100644 --- 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 @@ -10,26 +10,29 @@ import { parseMarkingFrameData, parseTransitionFiringFrameData, } from "./brunch-frame-parsers"; +import { BrunchStreamContext } from "./brunch-stream-export"; +import type { BrunchReceivedEvent } from "./brunch-protocol"; import type { ActualModeContextValue } from "@hashintel/petrinaut-core"; -type AvailableActualModeContextValue = Extract< +type BrunchActualModeState = Extract< ActualModeContextValue, { available: true } ->; +> & { + receivedEvents: readonly BrunchReceivedEvent[]; +}; const createLoadingActualModeValue = ( endpoint: string, runId: string | undefined, -): AvailableActualModeContextValue => { +): BrunchActualModeState => { const now = Date.now(); return { available: true, source: { - kind: "brunch", - endpoint, - ...(runId ? { runId } : {}), + id: runId ? `brunch:${endpoint}#${runId}` : `brunch:${endpoint}`, + label: endpoint, }, status: "loading", title: null, @@ -46,7 +49,7 @@ const createLoadingActualModeValue = ( export const BrunchActualModeProvider: FC< PropsWithChildren<{ endpoint: string; runId?: string }> > = ({ children, endpoint, runId }) => { - const [value, setValue] = useState(() => + const [value, setValue] = useState(() => createLoadingActualModeValue(endpoint, runId), ); @@ -266,5 +269,15 @@ export const BrunchActualModeProvider: FC< }; }, [endpoint, runId]); - return {children}; + const { receivedEvents, ...actualMode } = value; + + 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 index ce49b1c3833..518a5cc61a6 100644 --- 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 @@ -30,7 +30,11 @@ export const BrunchActualModeRoute = ({ key={`${endpointResult.endpoint}:${endpointResult.runId ?? ""}`} runId={endpointResult.runId} > - + ); }; 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 index bb71af70ac3..98dfae47361 100644 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-petrinaut.tsx +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-petrinaut.tsx @@ -12,30 +12,24 @@ import { Petrinaut } from "@hashintel/petrinaut/ui"; import { StatusPage } from "../../../shared/status-page"; import { sentryFeedbackPlugin } from "../plugins/sentry-feedback/plugin"; +import { brunchStreamExportPlugin } from "./brunch-stream-export"; -import type { - ActualModeSource, - PetrinautDocHandle, - SDCPN, -} from "@hashintel/petrinaut-core"; - -const getSourceKey = (source: ActualModeSource): string => - `${source.kind}:${source.endpoint}:${source.runId ?? ""}`; +import type { PetrinautDocHandle, SDCPN } from "@hashintel/petrinaut-core"; const BrunchPetrinautWithHandle = ({ definition, navigation, - source, + runId, title, }: { definition: SDCPN; navigation: PetrinautNavigationController; - source: ActualModeSource; + runId?: string; title: string; }) => { const [handle] = useState(() => createJsonDocHandle({ - id: source.runId ? `brunch-${source.runId}` : "brunch-actual", + id: runId ? `brunch-${runId}` : "brunch-actual", initial: definition, capabilities: { readonly: true, @@ -51,7 +45,7 @@ const BrunchPetrinautWithHandle = ({ handle={handle} hideNetManagementControls="except-title" navigation={navigation} - plugins={[sentryFeedbackPlugin]} + plugins={[sentryFeedbackPlugin, brunchStreamExportPlugin]} readonly setTitle={() => {}} title={title} @@ -61,15 +55,17 @@ const BrunchPetrinautWithHandle = ({ }; export const BrunchPetrinaut = ({ + endpoint, navigation, + runId, }: { + endpoint: string; navigation: PetrinautNavigationController; + runId?: string; }) => { 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 ( @@ -85,12 +81,12 @@ export const BrunchPetrinaut = ({ ); } - if (!definition || !initialState || !source || !sourceKey) { + if (!definition || !initialState) { return ( ); } @@ -106,13 +102,10 @@ export const BrunchPetrinaut = ({ 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 index 799259d9ae7..2882731dee3 100644 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-protocol.ts +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-protocol.ts @@ -75,3 +75,9 @@ export type BrunchNetDefinitionInput = z.input< typeof brunchNetDefinitionSchema >; export type BrunchTransitionInput = z.input; + +/** A Brunch SSE frame as received: its event name and parsed JSON payload. */ +export type BrunchReceivedEvent = { + event: string; + data: unknown; +}; diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-stream-export.test.ts b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-stream-export.test.ts new file mode 100644 index 00000000000..ef2c0252448 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-stream-export.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { createBrunchStreamRecording } from "./brunch-stream-export"; + +describe("createBrunchStreamRecording", () => { + it("keeps the frames as received, in the version 1 stream format", () => { + const rawDefinition = { + title: "Raw Brunch run", + places: [{ id: "queued", name: "Queued" }], + transitions: [], + }; + + expect( + createBrunchStreamRecording( + { + endpoint: "http://127.0.0.1:5184/stream", + runId: "run-1", + title: "Replay", + receivedEvents: [{ event: "definition", data: rawDefinition }], + }, + "2026-06-05T10:01:00.000Z", + ), + ).toEqual({ + version: 1, + exportedAt: "2026-06-05T10:01:00.000Z", + title: "Replay", + source: { + kind: "brunch", + endpoint: "http://127.0.0.1:5184/stream", + runId: "run-1", + }, + events: [{ event: "definition", data: rawDefinition }], + }); + }); + + it("omits the run id when the route has none", () => { + const recording = createBrunchStreamRecording({ + endpoint: "http://127.0.0.1:5184/stream", + title: null, + receivedEvents: [], + }); + + expect(recording.source).toEqual({ + kind: "brunch", + endpoint: "http://127.0.0.1:5184/stream", + }); + }); +}); diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-stream-export.tsx b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-stream-export.tsx new file mode 100644 index 00000000000..0c1bb5778a0 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-stream-export.tsx @@ -0,0 +1,92 @@ +import { createContext, use } from "react"; + +import { + definePetrinautPlugin, + type PluginHook, +} from "@hashintel/petrinaut/ui/plugin"; + +import type { BrunchReceivedEvent } from "./brunch-protocol"; + +/** What the Brunch provider received from one stream, for the raw export. */ +export type BrunchStream = { + endpoint: string; + runId?: string; + title: string | null; + receivedEvents: readonly BrunchReceivedEvent[]; +}; + +export const BrunchStreamContext = createContext(null); + +/** + * The raw stream export: every SSE frame as received. `scripts/brunch-sse-fixture.ts` + * replays it with `--recording`. + */ +export type BrunchStreamRecording = { + version: 1; + exportedAt: string; + title: string | null; + source: { kind: "brunch"; endpoint: string; runId?: string }; + events: BrunchReceivedEvent[]; +}; + +export const createBrunchStreamRecording = ( + { endpoint, runId, title, receivedEvents }: BrunchStream, + exportedAt = new Date().toISOString(), +): BrunchStreamRecording => ({ + version: 1, + exportedAt, + title, + source: { kind: "brunch", endpoint, ...(runId ? { runId } : {}) }, + events: receivedEvents.map(({ event, data }) => ({ event, data })), +}); + +const downloadBrunchStreamRecording = (stream: BrunchStream) => { + const recording = createBrunchStreamRecording(stream); + const name = (stream.title ?? "actual-mode-recording") + .replace(/[^a-z0-9]/giu, "_") + .toLowerCase(); + const timestamp = recording.exportedAt.replace(/:/gu, "-"); + const url = URL.createObjectURL( + new Blob([JSON.stringify(recording, null, 2)], { + type: "application/json", + }), + ); + const link = document.createElement("a"); + link.href = url; + link.download = `${name}_${timestamp}.petrinaut-actual.json`; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 0); +}; + +const BrunchStreamExportPlugin = definePetrinautPlugin({ + id: "website.brunch-stream-export", + name: "Brunch stream export", + description: + "Export Stream in the Actual mode Events tab: downloads the Brunch stream's frames exactly as received.", + author: "HASH", + required: true, + actualExports: { stream: { label: "Export Stream" } }, +}); + +const useBrunchStreamExportPlugin: PluginHook< + typeof BrunchStreamExportPlugin +> = () => { + const stream = use(BrunchStreamContext); + + return { + actualExports: { + stream: { + run: () => { + if (stream) { + downloadBrunchStreamRecording(stream); + } + }, + disabled: !stream || stream.receivedEvents.length === 0, + }, + }, + }; +}; + +export const brunchStreamExportPlugin = BrunchStreamExportPlugin( + useBrunchStreamExportPlugin, +); diff --git a/libs/@hashintel/petrinaut-core/src/actual-mode/README.md b/libs/@hashintel/petrinaut-core/src/actual-mode/README.md index ace369c6efd..64dc0590473 100644 --- a/libs/@hashintel/petrinaut-core/src/actual-mode/README.md +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/README.md @@ -9,46 +9,45 @@ 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 demo website's `/brunch` route is one example host: its adapter lives in +`apps/petrinaut-website/src/main/app/brunch-demo/`. -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 +61,13 @@ 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. `ACTUAL_MODE_RECORDING_VERSION` is `2`; version 1 recordings, +whose source carried host-specific connection fields, are rejected. + ## File Map - `constants.ts`: shared Actual Mode constants. @@ -70,8 +76,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..1c9877cf8f4 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,7 @@ import { describe, expect, it } from "vitest"; import { - createActualModeReceivedEventsRecording, + ACTUAL_MODE_RECORDING_VERSION, createActualModeRecording, createActualModeTimelineFrameReader, parseActualModeRecording, @@ -36,8 +36,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,27 +67,18 @@ 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("rejects version 1 recordings", () => { + expect(() => + parseActualModeRecording({ + version: 1, + exportedAt: "2026-06-05T10:01:00.000Z", + title: "Replay", + source: { kind: "host", endpoint: "http://127.0.0.1:5184/stream" }, + definition, + initialState: { queued: 1 }, + transitionFirings: [], + }), + ).toThrow(); }); it("retimes transition firings relative to the first event", () => { @@ -125,7 +116,7 @@ describe("Actual mode recordings", () => { it("fills the collections a recorded definition omits", () => { const parsed = parseActualModeRecording({ - version: 1, + version: ACTUAL_MODE_RECORDING_VERSION, exportedAt: "2026-06-05T10:01:00.000Z", title: "Replay", source: null, @@ -141,7 +132,7 @@ describe("Actual mode recordings", () => { it("rejects transition firings with extra fields", () => { expect(() => parseActualModeRecording({ - version: 1, + version: ACTUAL_MODE_RECORDING_VERSION, exportedAt: "2026-06-05T10:01:00.000Z", title: "Replay", source: null, @@ -163,7 +154,7 @@ describe("Actual mode recordings", () => { it("rejects transition firings with non-count effect values", () => { expect(() => parseActualModeRecording({ - version: 1, + version: ACTUAL_MODE_RECORDING_VERSION, exportedAt: "2026-06-05T10:01:00.000Z", title: "Replay", source: null, diff --git a/libs/@hashintel/petrinaut-core/src/actual-mode/constants.ts b/libs/@hashintel/petrinaut-core/src/actual-mode/constants.ts index 8827add9c1b..c8667500b8f 100644 --- a/libs/@hashintel/petrinaut-core/src/actual-mode/constants.ts +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/constants.ts @@ -1,2 +1,2 @@ export const ACTUAL_MODE_TIMELINE_TICK_MS = 500; -export const ACTUAL_MODE_RECORDING_VERSION = 1; +export const ACTUAL_MODE_RECORDING_VERSION = 2; 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..b3dc396e236 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, net and host-supplied 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..d0396692a42 100644 --- a/libs/@hashintel/petrinaut/docs/actual-mode.md +++ b/libs/@hashintel/petrinaut/docs/actual-mode.md @@ -1,32 +1,27 @@ # 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 +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. -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. +## Timeline and events -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. +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**. -## Timeline and events +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. -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**. +## Exporting -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. +Use the **Export** dropdown in the Events tab: -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. +- **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. -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. +The host application can add its own exports above these, such as one that keeps the data exactly as the host received it. -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 +32,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/plugin.ts b/libs/@hashintel/petrinaut/src/ui/plugin.ts index 875e3105e7f..0c0d9dd9c43 100644 --- a/libs/@hashintel/petrinaut/src/ui/plugin.ts +++ b/libs/@hashintel/petrinaut/src/ui/plugin.ts @@ -12,6 +12,8 @@ export type { PetrinautPlugin, PetrinautPluginDefinition, PetrinautPluginManifest, + PluginActualExport, + PluginActualExportSpec, PluginApi, PluginButton, PluginButtonPlace, diff --git a/libs/@hashintel/petrinaut/src/ui/plugins/README.md b/libs/@hashintel/petrinaut/src/ui/plugins/README.md index 1a847881dc0..e1d4c4f6f22 100644 --- a/libs/@hashintel/petrinaut/src/ui/plugins/README.md +++ b/libs/@hashintel/petrinaut/src/ui/plugins/README.md @@ -68,6 +68,8 @@ gives the typing rules. providers and supplies a command registry when the host passes none. - The editor view renders the outlets; User settings renders `PluginSettingsRows` and, under Plugins, one row per status entry. +- Actual mode's Events tab reads `actualExports` with `useRunningPlugins` and + lists them in its Export menu. - `ui/index.ts` and `main.ts` export the definition, the types and the hooks plugin code imports. diff --git a/libs/@hashintel/petrinaut/src/ui/plugins/define-petrinaut-plugin.test.ts b/libs/@hashintel/petrinaut/src/ui/plugins/define-petrinaut-plugin.test.ts index d71733c4001..d5595dbd415 100644 --- a/libs/@hashintel/petrinaut/src/ui/plugins/define-petrinaut-plugin.test.ts +++ b/libs/@hashintel/petrinaut/src/ui/plugins/define-petrinaut-plugin.test.ts @@ -36,6 +36,12 @@ const NoticePlugin = definePetrinautPlugin({ readOnlyAction: { label: "Make a copy" }, }); +const ExportPlugin = definePetrinautPlugin({ + id: "test.export", + name: "Export", + actualExports: { raw: { label: "Export Stream" } }, +}); + const EditorPlugin = definePetrinautPlugin({ id: "test.editor", name: "Editor", @@ -156,6 +162,32 @@ describe("definePetrinautPlugin", () => { }); }); + it("requires each declared Actual mode export's run", () => { + const run = () => {}; + const plugin = ExportPlugin({ + actualExports: { raw: { run, disabled: true } }, + }); + // @ts-expect-error A declared export the contributions leave out. + ExportPlugin({ actualExports: {} }); + // @ts-expect-error An export the manifest does not declare. + ExportPlugin({ actualExports: { raw: { run }, other: { run } } }); + PalettePlugin({ + buttons: { toggle: { icon: null, onClick: noop } }, + // @ts-expect-error Exports the manifest does not declare. + actualExports: { raw: { run } }, + }); + definePetrinautPlugin({ + id: "test.misspelt-export", + name: "Misspelt export", + // @ts-expect-error A key an export spec does not have. + actualExports: { raw: { label: "Export", lable: "Export" } }, + }); + + expect(plugin[pluginRuntime].hook(undefined as never)).toEqual({ + actualExports: { raw: { run, disabled: true } }, + }); + }); + it("returns the plugin directly when given the implementation", () => { const contributions = { buttons: { help: { icon: null, onClick: noop } } }; const manifest = { diff --git a/libs/@hashintel/petrinaut/src/ui/plugins/define-petrinaut-plugin.ts b/libs/@hashintel/petrinaut/src/ui/plugins/define-petrinaut-plugin.ts index 6a4ca552784..97a8baeb4f2 100644 --- a/libs/@hashintel/petrinaut/src/ui/plugins/define-petrinaut-plugin.ts +++ b/libs/@hashintel/petrinaut/src/ui/plugins/define-petrinaut-plugin.ts @@ -127,6 +127,12 @@ export interface PluginTopBarItemSpec { readonly place: PluginTopBarPlace; } +/** An Actual mode export the plugin declares; what it does comes from the hook under the same key. */ +export interface PluginActualExportSpec { + /** The item's text in the Export menu, e.g. "Export Stream". */ + readonly label: string; +} + /** What a plugin declares, as plain data Petrinaut reads before running the plugin. */ export interface PetrinautPluginManifest { /** @@ -169,6 +175,12 @@ export interface PetrinautPluginManifest { /** The button's text. */ readonly label: string; }; + /** + * Items in the Export menu of Actual mode's Events tab, before the built-in + * ones, such as an export of the host's raw execution data. The hook + * returns what each one does under the same key. + */ + readonly actualExports?: Readonly>; /** * UI inside the editor: dialogs, overlays, tours. The hook returns it under * `root`, and may return `overlay` while it covers the editor. @@ -259,6 +271,14 @@ export interface PluginReadOnlyAction { readonly run: () => void; } +/** What a declared Actual mode export does; its label comes from the manifest. */ +export interface PluginActualExport { + /** Runs when the item is chosen. */ + readonly run: () => void; + /** Shows the item greyed out, e.g. before there is anything to export; defaults to `false`. */ + readonly disabled?: boolean; +} + interface ContributionTable { /** Rendered inside the editor behind the plugin's error boundary; `null` while there is nothing to show. */ root: ReactNode; @@ -274,6 +294,9 @@ interface ContributionTable { readonly [K in keyof NonNullable]: ReactNode; }; readOnlyAction: PluginReadOnlyAction; + actualExports: { + readonly [K in keyof NonNullable]: PluginActualExport; + }; provides: ServiceOf; } @@ -324,6 +347,7 @@ export interface AnyPluginContributions { readonly buttons?: Readonly>; readonly topBarItems?: Readonly>; readonly readOnlyAction?: PluginReadOnlyAction; + readonly actualExports?: Readonly>; readonly provides?: unknown; } @@ -440,7 +464,8 @@ type ManifestChecks = RejectUnknownKeys & ? { readonly readOnlyAction: RejectUnknownKeys } : unknown) & EntryChecks & - EntryChecks; + EntryChecks & + EntryChecks; /** A plugin's implementation, whatever its manifest. */ type AnyImplementation = PluginRuntime["hook"] | AnyPluginContributions; diff --git a/libs/@hashintel/petrinaut/src/ui/plugins/plugin-contributions.test.ts b/libs/@hashintel/petrinaut/src/ui/plugins/plugin-contributions.test.ts index 23804e3c420..cd1183decb1 100644 --- a/libs/@hashintel/petrinaut/src/ui/plugins/plugin-contributions.test.ts +++ b/libs/@hashintel/petrinaut/src/ui/plugins/plugin-contributions.test.ts @@ -13,6 +13,7 @@ describe("describePluginContributions", () => { buttons: { mute: { label: "Mute", place: "viewport-controls" } }, topBarItems: { status: { place: "top-bar-end" } }, readOnlyAction: { label: "Make a copy" }, + actualExports: { raw: { label: "Export Stream" } }, commands: { mute: { label: "Mute the assistant", shortcut: "f2" }, unmute: { label: "Unmute the assistant" }, @@ -34,6 +35,11 @@ describe("describePluginContributions", () => { { kind: "button", subject: "Mute", detail: "Viewport controls" }, { kind: "top-bar-item", subject: "status", detail: "End of the top bar" }, { kind: "read-only-action", subject: "Make a copy" }, + { + kind: "actual-export", + subject: "Export Stream", + detail: "Events tab", + }, { kind: "command", subject: "Mute the assistant", detail: "F2" }, { kind: "command", subject: "Unmute the assistant" }, { kind: "setting", subject: "Captions", detail: "Labs" }, diff --git a/libs/@hashintel/petrinaut/src/ui/plugins/plugin-contributions.ts b/libs/@hashintel/petrinaut/src/ui/plugins/plugin-contributions.ts index 37d4a08ac77..b86c938398e 100644 --- a/libs/@hashintel/petrinaut/src/ui/plugins/plugin-contributions.ts +++ b/libs/@hashintel/petrinaut/src/ui/plugins/plugin-contributions.ts @@ -13,6 +13,7 @@ export type PluginContributionKind = | "button" | "top-bar-item" | "read-only-action" + | "actual-export" | "command" | "setting" | "provides" @@ -64,6 +65,11 @@ export const describePluginContributions = ( subject: manifest.readOnlyAction.label, }, ]), + ...Object.values(manifest.actualExports ?? {}).map((spec) => ({ + kind: "actual-export" as const, + subject: spec.label, + detail: "Events tab", + })), ...Object.values(manifest.commands ?? {}).map((spec) => ({ kind: "command" as const, subject: spec.label, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog/plugins-section/plugin-row.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog/plugins-section/plugin-row.tsx index 9d6b5bb0d9d..1e2961e6ed8 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog/plugins-section/plugin-row.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog/plugins-section/plugin-row.tsx @@ -208,6 +208,7 @@ const contributionKinds: Record< button: { label: "Button", icon: "cursor" }, "top-bar-item": { label: "Top bar", icon: "sidebar", code: true }, "read-only-action": { label: "Read-only notice", icon: "lockClosed" }, + "actual-export": { label: "Actual export", icon: "download" }, command: { label: "Command", icon: "lightning" }, setting: { label: "Setting", icon: "sliders" }, provides: { label: "Provides", icon: "plug", code: true }, 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..ca45470822f 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 @@ -1,12 +1,24 @@ /** * @vitest-environment jsdom */ -import { cleanup, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it } from "vitest"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { toPetrinautId, type SDCPN } from "@hashintel/petrinaut-core"; +import { + toPetrinautId, + type ActualModeContextValue, + type SDCPN, +} from "@hashintel/petrinaut-core"; import { ActualModeContext } from "../../../../../../react/actual-mode-context"; +import { definePetrinautPlugin } from "../../../../../plugins/define-petrinaut-plugin"; +import { renderPlugins } from "../../../../../plugins/plugins-test-harness"; import { actualEventsSubView } from "./actual-events"; const queueId = toPetrinautId("place__queue"); @@ -52,35 +64,37 @@ const definition: SDCPN = { parameters: [], }; -afterEach(cleanup); +const actualMode: ActualModeContextValue = { + available: true, + source: { id: "run-1" }, + status: "complete", + title: null, + definition, + initialState: {}, + transitionFirings: [ + { + transitionId: serveId, + input: { [queueId]: 1 }, + output: { [servedId]: 1 }, + ts: "2026-10-07T00:00:00.000Z", + }, + ], + timelineStartedAtMs: null, + timelineNowMs: null, + error: null, +}; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); describe("Actual mode events", () => { it("names the transition and places of each event, and falls back to the id", () => { const Events = actualEventsSubView.component; render( - + , ); @@ -89,4 +103,53 @@ describe("Actual mode events", () => { expect(screen.getByText("Queue: 1")).not.toBeNull(); expect(screen.getByText(`${servedId}: 1`)).not.toBeNull(); }); + + it("lists a plugin's exports before the built-in ones and runs them", async () => { + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + const Events = actualEventsSubView.component; + const run = vi.fn(); + const rawExportPlugin = definePetrinautPlugin( + { + id: "test.raw-export", + name: "Raw export", + actualExports: { raw: { label: "Export Stream" } }, + }, + { actualExports: { raw: { run } } }, + ); + + renderPlugins( + [rawExportPlugin], + + + , + ); + + const trigger = screen.getByRole("button", { name: /^Export/ }); + fireEvent.click(trigger); + await waitFor(() => + expect(trigger.getAttribute("aria-expanded")).toBe("true"), + ); + const items = screen.getAllByRole("menuitem"); + + expect(items.map((item) => item.textContent)).toEqual([ + "Export Stream", + "Export Recording", + "Export Net", + ]); + + // The menu selects the highlighted item, and a pointer-down highlights it. + fireEvent.pointerDown(items[0]!); + await waitFor(() => + expect(items[0]!.hasAttribute("data-highlighted")).toBe(true), + ); + fireEvent.click(items[0]!); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + }); }); 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..ebba298e07c 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 @@ -6,6 +6,7 @@ import { css, cx } from "@hashintel/ds-helpers/css"; import { ActualModeContext } from "../../../../../../react/actual-mode-context"; import { exportActualModeRecording } from "../../../../../file-io/export-actual-mode-recording"; import { exportSDCPN } from "../../../../../file-io/export-sdcpn"; +import { useRunningPlugins } from "../../../../../plugins/plugins-provider"; import type { SubView } from "../../../../../components/sub-view/types"; import type { @@ -243,26 +244,43 @@ const EventRow: React.FC<{ const ActualEventsContent: React.FC = () => { const actualMode = use(ActualModeContext); - const canExportStream = + const runningPlugins = useRunningPlugins(); + 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 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 pluginExportItems: MenuItem[] = runningPlugins.flatMap( + ({ manifest, contributions }) => + Object.entries(manifest.actualExports ?? {}).map(([key, spec]) => { + const actualExport = contributions.actualExports?.[key]; + + return { + id: `${manifest.id}.actualExports.${key}`, + text: spec.label, + disabled: !actualExport || actualExport.disabled === true, + onClick: () => actualExport?.run(), + }; + }), + ); + + 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, @@ -281,11 +299,12 @@ const ActualEventsContent: React.FC = () => { }; const exportMenuItems: MenuItem[] = [ + ...pluginExportItems, { - id: "export-stream", - text: "Export Stream", - disabled: !canExportStream, - onClick: handleExportStream, + id: "export-recording", + text: "Export Recording", + disabled: !canExportRecording, + onClick: handleExportRecording, }, { id: "export-net", @@ -294,6 +313,7 @@ const ActualEventsContent: React.FC = () => { onClick: handleExportNet, }, ]; + const canExport = exportMenuItems.some((item) => !item.disabled); if (!actualMode.available) { return Actual mode is not available.; @@ -410,6 +430,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/plugins/overview.mdx b/libs/@local/petrinaut-arch-docs/content/plugins/overview.mdx index 7f71caee206..d42ae358425 100644 --- a/libs/@local/petrinaut-arch-docs/content/plugins/overview.mdx +++ b/libs/@local/petrinaut-arch-docs/content/plugins/overview.mdx @@ -64,6 +64,8 @@ under the same key. - **Inside the editor**: `root`, for dialogs, overlays and tours. - **The read-only notice**: `readOnlyAction`, a button on the notice that explains a blocked edit on a document the host made read-only. +- **Actual mode's Events tab**: `actualExports`, items in its Export menu, + such as an export of the host's raw execution data. - **User settings**: the plugin's setting rows, and its row under Plugins, where the user switches it off. diff --git a/libs/@local/petrinaut-arch-docs/content/plugins/reference.mdx b/libs/@local/petrinaut-arch-docs/content/plugins/reference.mdx index a045d3a556f..290292db2d7 100644 --- a/libs/@local/petrinaut-arch-docs/content/plugins/reference.mdx +++ b/libs/@local/petrinaut-arch-docs/content/plugins/reference.mdx @@ -23,6 +23,7 @@ together. | `buttons` | Per key: `{ label, place, tooltip? }`. `label` is the accessible name; `tooltip` defaults to it. | | `buttons` | | `topBarItems` | Per key: `{ place }`. | | `topBarItems` | | `readOnlyAction` | `{ label }`: a button on the notice of a blocked edit on a document the host made read-only. | | `readOnlyAction` | +| `actualExports` | Per key: `{ label }`: an item in the Export menu of Actual mode's Events tab, before the built-in ones. | | `actualExports` | | `root` | `true`: the plugin renders UI inside the editor. | | `root` | | `provides` | `pluginService()`, the type of the service the hook provides. | | `provides` | @@ -180,6 +181,7 @@ type EditRefusal = ReadOnlyReason | { kind: "no-title-setter" }; | `buttons` | Declared | Per key: `{ icon, onClick?, command?, className?, ref? }`. `onClick` runs before `command`, one of the plugin's command keys. A button has at least one of `onClick`, `command` and `ref`. | | `topBarItems` | Declared | Per key: a `ReactNode`. | | `readOnlyAction` | Declared | `{ run }`: what the read-only notice's button does. The notice shows the action of the first running plugin that declares one, and closes when the button is clicked. | +| `actualExports` | Declared | Per key: `{ run, disabled? }`. `disabled` defaults to `false`; while `true`, the item shows greyed out. Every running plugin's items show, in plugin order. | | `provides` | Declared | The service type `T` of `pluginService()`. | Every declared key is required and every undeclared one is a type error. @@ -211,8 +213,9 @@ From `@hashintel/petrinaut/ui/plugin`. `` itself comes from `@hashint `ServiceOf`. - Manifest entries: `PluginSettingSpec`, `PluginSettingSection`, `PluginCommandSpec`, `PluginButtonSpec`, `PluginButtonPlace`, - `PluginTopBarItemSpec`, `PluginTopBarPlace`. -- Contributions: `PluginButton`, `PluginCommand`, `PluginReadOnlyAction`. + `PluginTopBarItemSpec`, `PluginTopBarPlace`, `PluginActualExportSpec`. +- Contributions: `PluginButton`, `PluginCommand`, `PluginReadOnlyAction`, + `PluginActualExport`. - Access: `PluginAccess`, `AccessApi`, `PluginDocumentReader`, `PluginDocument`, `PluginDiagnostics`, `PluginEdits`, `PluginExperimentsReader`, `PluginExperiments`, `PluginEditorReader`, diff --git a/libs/@local/petrinaut-arch-docs/content/plugins/writing-a-plugin.mdx b/libs/@local/petrinaut-arch-docs/content/plugins/writing-a-plugin.mdx index 6efb0c3e774..d77b313db91 100644 --- a/libs/@local/petrinaut-arch-docs/content/plugins/writing-a-plugin.mdx +++ b/libs/@local/petrinaut-arch-docs/content/plugins/writing-a-plugin.mdx @@ -147,6 +147,34 @@ const useCopyPlugin: PluginHook = ({ document }) => ({ export const copyPlugin = CopyPlugin(useCopyPlugin); ``` +## Add an Actual mode export + +A host that feeds Actual mode may hold data Petrinaut does not, such as the +payloads exactly as it received them. Declare `actualExports` with a `label` +per key to add items to the Export menu of the Events tab, before Petrinaut's +own **Export Recording** and **Export Net**, and return what each one does +under the same key. Return `disabled: true` while there is nothing to export. + +```tsx +const RawExportPlugin = definePetrinautPlugin({ + id: "example.raw-export", + name: "Raw export", + actualExports: { raw: { label: "Export Stream" } }, +}); + +const useRawExportPlugin: PluginHook = () => { + const events = use(HostEventsContext); + + return { + actualExports: { + raw: { run: () => download(events), disabled: events.length === 0 }, + }, + }; +}; + +export const rawExportPlugin = RawExportPlugin(useRawExportPlugin); +``` + ## Add settings Declare each setting under `settings`. The editor renders its row in User From 48d445e0736ec290adaf6e1ad76e6ef49139e721 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Tue, 20 Oct 2026 15:27:17 +0200 Subject: [PATCH 2/4] FE-2028: Trim the Actual Mode export tests Co-authored-by: Cursor --- .../brunch-demo/brunch-stream-export.test.ts | 13 ------- .../subviews/actual-events.test.tsx | 37 ++++--------------- 2 files changed, 7 insertions(+), 43 deletions(-) diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-stream-export.test.ts b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-stream-export.test.ts index ef2c0252448..09469fda684 100644 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-stream-export.test.ts +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-stream-export.test.ts @@ -32,17 +32,4 @@ describe("createBrunchStreamRecording", () => { events: [{ event: "definition", data: rawDefinition }], }); }); - - it("omits the run id when the route has none", () => { - const recording = createBrunchStreamRecording({ - endpoint: "http://127.0.0.1:5184/stream", - title: null, - receivedEvents: [], - }); - - expect(recording.source).toEqual({ - kind: "brunch", - endpoint: "http://127.0.0.1:5184/stream", - }); - }); }); 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 ca45470822f..0444b9483f2 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 @@ -8,7 +8,7 @@ import { screen, waitFor, } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { toPetrinautId, @@ -84,10 +84,7 @@ const actualMode: ActualModeContextValue = { error: null, }; -afterEach(() => { - cleanup(); - vi.unstubAllGlobals(); -}); +afterEach(cleanup); describe("Actual mode events", () => { it("names the transition and places of each event, and falls back to the id", () => { @@ -104,24 +101,15 @@ describe("Actual mode events", () => { expect(screen.getByText(`${servedId}: 1`)).not.toBeNull(); }); - it("lists a plugin's exports before the built-in ones and runs them", async () => { - vi.stubGlobal( - "ResizeObserver", - class { - observe() {} - unobserve() {} - disconnect() {} - }, - ); + it("lists a plugin's exports before the built-in ones", async () => { const Events = actualEventsSubView.component; - const run = vi.fn(); const rawExportPlugin = definePetrinautPlugin( { id: "test.raw-export", name: "Raw export", actualExports: { raw: { label: "Export Stream" } }, }, - { actualExports: { raw: { run } } }, + { actualExports: { raw: { run: () => {} } } }, ); renderPlugins( @@ -136,20 +124,9 @@ describe("Actual mode events", () => { await waitFor(() => expect(trigger.getAttribute("aria-expanded")).toBe("true"), ); - const items = screen.getAllByRole("menuitem"); - expect(items.map((item) => item.textContent)).toEqual([ - "Export Stream", - "Export Recording", - "Export Net", - ]); - - // The menu selects the highlighted item, and a pointer-down highlights it. - fireEvent.pointerDown(items[0]!); - await waitFor(() => - expect(items[0]!.hasAttribute("data-highlighted")).toBe(true), - ); - fireEvent.click(items[0]!); - await waitFor(() => expect(run).toHaveBeenCalledOnce()); + expect( + screen.getAllByRole("menuitem").map((item) => item.textContent), + ).toEqual(["Export Stream", "Export Recording", "Export Net"]); }); }); From c759573bfb62e891048acbed5c481d842064d379 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Tue, 20 Oct 2026 17:15:49 +0200 Subject: [PATCH 3/4] FE-2028: Drop the raw stream export instead of moving it into a plugin Co-authored-by: Cursor --- .changeset/host-agnostic-actual-mode.md | 2 +- apps/petrinaut-website/README.md | 14 +-- .../scripts/brunch-sse-fixture.ts | 21 +++-- .../brunch-actual-mode-provider.tsx | 38 +------- .../main/app/brunch-demo/brunch-petrinaut.tsx | 3 +- .../main/app/brunch-demo/brunch-protocol.ts | 6 -- .../brunch-demo/brunch-stream-export.test.ts | 35 ------- .../app/brunch-demo/brunch-stream-export.tsx | 92 ------------------- libs/@hashintel/petrinaut-core/src/ai.ts | 2 +- libs/@hashintel/petrinaut/docs/actual-mode.md | 2 - libs/@hashintel/petrinaut/src/ui/plugin.ts | 2 - .../petrinaut/src/ui/plugins/README.md | 2 - .../plugins/define-petrinaut-plugin.test.ts | 32 ------- .../src/ui/plugins/define-petrinaut-plugin.ts | 27 +----- .../ui/plugins/plugin-contributions.test.ts | 6 -- .../src/ui/plugins/plugin-contributions.ts | 6 -- .../plugins-section/plugin-row.tsx | 1 - .../subviews/actual-events.test.tsx | 87 +++++------------- .../BottomPanel/subviews/actual-events.tsx | 19 +--- .../content/plugins/overview.mdx | 2 - .../content/plugins/reference.mdx | 7 +- .../content/plugins/writing-a-plugin.mdx | 28 ------ 22 files changed, 49 insertions(+), 385 deletions(-) delete mode 100644 apps/petrinaut-website/src/main/app/brunch-demo/brunch-stream-export.test.ts delete mode 100644 apps/petrinaut-website/src/main/app/brunch-demo/brunch-stream-export.tsx diff --git a/.changeset/host-agnostic-actual-mode.md b/.changeset/host-agnostic-actual-mode.md index 84384a853fa..820d52523c1 100644 --- a/.changeset/host-agnostic-actual-mode.md +++ b/.changeset/host-agnostic-actual-mode.md @@ -3,4 +3,4 @@ "@hashintel/petrinaut": patch --- -Actual mode no longer assumes where its execution comes from. `ActualModeSource` is `{ id, label? }`, an opaque id and an optional label the host chooses. `ActualModeContextValue` drops `receivedEvents`, and core drops `ActualModeReceivedEvent`, `ActualModeReceivedEventsRecording` and `createActualModeReceivedEventsRecording`. `ACTUAL_MODE_RECORDING_VERSION` is now 2, and `parseActualModeRecording` rejects version 1 recordings. The Events tab's **Export Recording** downloads Petrinaut's recording, and plugins can add their own items to its Export menu with the new `actualExports` contribution. +Actual mode no longer assumes where its execution comes from. `ActualModeSource` is `{ id, label? }`, an opaque id and an optional label the host chooses. `ActualModeContextValue` drops `receivedEvents`, and core drops `ActualModeReceivedEvent`, `ActualModeReceivedEventsRecording` and `createActualModeReceivedEventsRecording`. `ACTUAL_MODE_RECORDING_VERSION` is now 2, and `parseActualModeRecording` rejects version 1 recordings. The Events tab's **Export Stream** is now **Export Recording**, and always downloads Petrinaut's recording. diff --git a/apps/petrinaut-website/README.md b/apps/petrinaut-website/README.md index 6f43d3c6bd6..0a675ce93a8 100644 --- a/apps/petrinaut-website/README.md +++ b/apps/petrinaut-website/README.md @@ -120,18 +120,10 @@ events from that replay, so an interruption neither duplicates transition events nor misses ones that fired while disconnected. If the stream sends invalid data, the route shows an error page with a link back to the demo site. -In the Events tab, **Export Stream** comes from the required -`website.brunch-stream-export` plugin. It downloads a -`.petrinaut-actual.json` file with the frames exactly as received: an `events` -array whose items hold the SSE event name and the parsed JSON payload, plus -`version: 1`, `exportedAt`, `title`, and `source: { kind: "brunch", endpoint, -runId? }`. The stream may use any id string; Petrinaut converts ids to UUIDs -for rendering, but the export keeps them as received. Petrinaut's own -**Export Recording** and **Export Net** sit below it. - `yarn brunch:fixture` serves a local Brunch-compatible stream and prints -`/brunch` URLs for it. Pass `--recording=` to replay an Export Stream or -Export Recording file instead of generated events. +`/brunch` URLs for it. Pass `--recording=` to replay an Export Recording +file, or an older raw stream file with an `events` array, instead of generated +events. ## Example embeds and oEmbed diff --git a/apps/petrinaut-website/scripts/brunch-sse-fixture.ts b/apps/petrinaut-website/scripts/brunch-sse-fixture.ts index d43ff29a313..255c60d0a21 100644 --- a/apps/petrinaut-website/scripts/brunch-sse-fixture.ts +++ b/apps/petrinaut-website/scripts/brunch-sse-fixture.ts @@ -28,13 +28,14 @@ import { resolve } from "node:path"; import type { BrunchNetDefinitionInput, - BrunchReceivedEvent, BrunchTransitionInput, } from "../src/main/app/brunch-demo/brunch-protocol"; import type { ActualModeTransitionFiring } from "@hashintel/petrinaut-core"; type NumericMarking = Record; +type ReceivedEvent = { event: string; data: unknown }; + type SseFrame = { data: unknown; event: string; @@ -46,7 +47,7 @@ type TimedSseFrame = SseFrame & { type RecordingReplay = { definition: BrunchNetDefinitionInput; - events: BrunchReceivedEvent[]; + events: ReceivedEvent[]; initialState: NumericMarking; path: string; transitionFirings: ActualModeTransitionFiring[]; @@ -239,7 +240,7 @@ const parseTransitionFiring = ( const parseReceivedEventsRecording = ( data: Record, -): BrunchReceivedEvent[] => { +): ReceivedEvent[] => { if (!Array.isArray(data.events)) { throw new Error("Recording events must be an array."); } @@ -388,7 +389,7 @@ const toBrunchDefinitionInput = (data: unknown): BrunchNetDefinitionInput => { const parseNormalizedRecording = ( data: Record, -): BrunchReceivedEvent[] => { +): ReceivedEvent[] => { if (!Array.isArray(data.transitionFirings)) { throw new Error("Recording transitionFirings must be an array."); } @@ -406,7 +407,7 @@ const parseNormalizedRecording = ( ]; }; -const parseRecordingEvents = (data: unknown): BrunchReceivedEvent[] => { +const parseRecordingEvents = (data: unknown): ReceivedEvent[] => { if (!isRecord(data)) { throw new Error("Recording root must be an object."); } @@ -424,14 +425,14 @@ const parseRecordingEvents = (data: unknown): BrunchReceivedEvent[] => { } throw new Error( - "Recording must be an Export Stream file with `events` or an Export Recording file.", + "Recording must be an Export Recording file or an older raw stream file with `events`.", ); }; const findFirstEvent = ( - events: BrunchReceivedEvent[], + events: ReceivedEvent[], eventName: string, -): BrunchReceivedEvent | null => +): ReceivedEvent | null => events.find((event) => event.event === eventName) ?? null; const parseRecordingReplay = (path: string): RecordingReplay => { @@ -460,7 +461,7 @@ const parseRecordingReplay = (path: string): RecordingReplay => { }; }; -const getEventTimestampMs = (event: BrunchReceivedEvent): number | null => { +const getEventTimestampMs = (event: ReceivedEvent): number | null => { if (!isRecord(event.data) || typeof event.data.ts !== "string") { return null; } @@ -488,7 +489,7 @@ const retimeEventData = (data: unknown, deltaMs: number): unknown => { }; const createTimedReplayFrames = ( - events: BrunchReceivedEvent[], + events: ReceivedEvent[], launchTimeMs: number, ): TimedSseFrame[] => { const firstTimestampMs = events 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 index 3f5fea236c3..58581f47077 100644 --- 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 @@ -10,22 +10,18 @@ import { parseMarkingFrameData, parseTransitionFiringFrameData, } from "./brunch-frame-parsers"; -import { BrunchStreamContext } from "./brunch-stream-export"; -import type { BrunchReceivedEvent } from "./brunch-protocol"; import type { ActualModeContextValue } from "@hashintel/petrinaut-core"; -type BrunchActualModeState = Extract< +type AvailableActualModeContextValue = Extract< ActualModeContextValue, { available: true } -> & { - receivedEvents: readonly BrunchReceivedEvent[]; -}; +>; const createLoadingActualModeValue = ( endpoint: string, runId: string | undefined, -): BrunchActualModeState => { +): AvailableActualModeContextValue => { const now = Date.now(); return { @@ -39,7 +35,6 @@ const createLoadingActualModeValue = ( definition: null, initialState: null, transitionFirings: [], - receivedEvents: [], timelineStartedAtMs: now, timelineNowMs: now, error: null, @@ -49,7 +44,7 @@ const createLoadingActualModeValue = ( export const BrunchActualModeProvider: FC< PropsWithChildren<{ endpoint: string; runId?: string }> > = ({ children, endpoint, runId }) => { - const [value, setValue] = useState(() => + const [value, setValue] = useState(() => createLoadingActualModeValue(endpoint, runId), ); @@ -146,7 +141,6 @@ export const BrunchActualModeProvider: FC< return { ...prev, transitionFirings: [], - receivedEvents: [], timelineStartedAtMs: Date.now(), timelineNowMs: Date.now(), error, @@ -163,10 +157,6 @@ export const BrunchActualModeProvider: FC< setValue((prev) => ({ ...prev, status: prev.status === "complete" ? "complete" : "streaming", - receivedEvents: [ - ...prev.receivedEvents, - { event: "definition", data }, - ], timelineNowMs: Date.now(), error: null, })); @@ -199,10 +189,6 @@ export const BrunchActualModeProvider: FC< ...prev, status: prev.status === "complete" ? "complete" : "streaming", initialState, - receivedEvents: [ - ...prev.receivedEvents, - { event: "initial_state", data }, - ], timelineNowMs: Date.now(), error: null, })); @@ -222,10 +208,6 @@ export const BrunchActualModeProvider: FC< ...prev, status: prev.status === "complete" ? "complete" : "streaming", transitionFirings: [...prev.transitionFirings, firing], - receivedEvents: [ - ...prev.receivedEvents, - { event: "transition_firing", data }, - ], timelineNowMs: Date.now(), error: null, })); @@ -269,15 +251,5 @@ export const BrunchActualModeProvider: FC< }; }, [endpoint, runId]); - const { receivedEvents, ...actualMode } = value; - - return ( - - - {children} - - - ); + return {children}; }; 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 index 98dfae47361..d3c316df3fc 100644 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-petrinaut.tsx +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-petrinaut.tsx @@ -12,7 +12,6 @@ import { Petrinaut } from "@hashintel/petrinaut/ui"; import { StatusPage } from "../../../shared/status-page"; import { sentryFeedbackPlugin } from "../plugins/sentry-feedback/plugin"; -import { brunchStreamExportPlugin } from "./brunch-stream-export"; import type { PetrinautDocHandle, SDCPN } from "@hashintel/petrinaut-core"; @@ -45,7 +44,7 @@ const BrunchPetrinautWithHandle = ({ handle={handle} hideNetManagementControls="except-title" navigation={navigation} - plugins={[sentryFeedbackPlugin, brunchStreamExportPlugin]} + plugins={[sentryFeedbackPlugin]} readonly setTitle={() => {}} title={title} 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 index 2882731dee3..799259d9ae7 100644 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-protocol.ts +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-protocol.ts @@ -75,9 +75,3 @@ export type BrunchNetDefinitionInput = z.input< typeof brunchNetDefinitionSchema >; export type BrunchTransitionInput = z.input; - -/** A Brunch SSE frame as received: its event name and parsed JSON payload. */ -export type BrunchReceivedEvent = { - event: string; - data: unknown; -}; diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-stream-export.test.ts b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-stream-export.test.ts deleted file mode 100644 index 09469fda684..00000000000 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-stream-export.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { createBrunchStreamRecording } from "./brunch-stream-export"; - -describe("createBrunchStreamRecording", () => { - it("keeps the frames as received, in the version 1 stream format", () => { - const rawDefinition = { - title: "Raw Brunch run", - places: [{ id: "queued", name: "Queued" }], - transitions: [], - }; - - expect( - createBrunchStreamRecording( - { - endpoint: "http://127.0.0.1:5184/stream", - runId: "run-1", - title: "Replay", - receivedEvents: [{ event: "definition", data: rawDefinition }], - }, - "2026-06-05T10:01:00.000Z", - ), - ).toEqual({ - version: 1, - exportedAt: "2026-06-05T10:01:00.000Z", - title: "Replay", - source: { - kind: "brunch", - endpoint: "http://127.0.0.1:5184/stream", - runId: "run-1", - }, - events: [{ event: "definition", data: rawDefinition }], - }); - }); -}); diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-stream-export.tsx b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-stream-export.tsx deleted file mode 100644 index 0c1bb5778a0..00000000000 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-stream-export.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { createContext, use } from "react"; - -import { - definePetrinautPlugin, - type PluginHook, -} from "@hashintel/petrinaut/ui/plugin"; - -import type { BrunchReceivedEvent } from "./brunch-protocol"; - -/** What the Brunch provider received from one stream, for the raw export. */ -export type BrunchStream = { - endpoint: string; - runId?: string; - title: string | null; - receivedEvents: readonly BrunchReceivedEvent[]; -}; - -export const BrunchStreamContext = createContext(null); - -/** - * The raw stream export: every SSE frame as received. `scripts/brunch-sse-fixture.ts` - * replays it with `--recording`. - */ -export type BrunchStreamRecording = { - version: 1; - exportedAt: string; - title: string | null; - source: { kind: "brunch"; endpoint: string; runId?: string }; - events: BrunchReceivedEvent[]; -}; - -export const createBrunchStreamRecording = ( - { endpoint, runId, title, receivedEvents }: BrunchStream, - exportedAt = new Date().toISOString(), -): BrunchStreamRecording => ({ - version: 1, - exportedAt, - title, - source: { kind: "brunch", endpoint, ...(runId ? { runId } : {}) }, - events: receivedEvents.map(({ event, data }) => ({ event, data })), -}); - -const downloadBrunchStreamRecording = (stream: BrunchStream) => { - const recording = createBrunchStreamRecording(stream); - const name = (stream.title ?? "actual-mode-recording") - .replace(/[^a-z0-9]/giu, "_") - .toLowerCase(); - const timestamp = recording.exportedAt.replace(/:/gu, "-"); - const url = URL.createObjectURL( - new Blob([JSON.stringify(recording, null, 2)], { - type: "application/json", - }), - ); - const link = document.createElement("a"); - link.href = url; - link.download = `${name}_${timestamp}.petrinaut-actual.json`; - link.click(); - setTimeout(() => URL.revokeObjectURL(url), 0); -}; - -const BrunchStreamExportPlugin = definePetrinautPlugin({ - id: "website.brunch-stream-export", - name: "Brunch stream export", - description: - "Export Stream in the Actual mode Events tab: downloads the Brunch stream's frames exactly as received.", - author: "HASH", - required: true, - actualExports: { stream: { label: "Export Stream" } }, -}); - -const useBrunchStreamExportPlugin: PluginHook< - typeof BrunchStreamExportPlugin -> = () => { - const stream = use(BrunchStreamContext); - - return { - actualExports: { - stream: { - run: () => { - if (stream) { - downloadBrunchStreamRecording(stream); - } - }, - disabled: !stream || stream.receivedEvents.length === 0, - }, - }, - }; -}; - -export const brunchStreamExportPlugin = BrunchStreamExportPlugin( - useBrunchStreamExportPlugin, -); diff --git a/libs/@hashintel/petrinaut-core/src/ai.ts b/libs/@hashintel/petrinaut-core/src/ai.ts index b3dc396e236..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, read-only net, timeline scrubbing, Events tab with recording, net and host-supplied exports.", + "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/docs/actual-mode.md b/libs/@hashintel/petrinaut/docs/actual-mode.md index d0396692a42..f27cfbfa9d0 100644 --- a/libs/@hashintel/petrinaut/docs/actual-mode.md +++ b/libs/@hashintel/petrinaut/docs/actual-mode.md @@ -19,8 +19,6 @@ Use the **Export** dropdown in the Events tab: - **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. -The host application can add its own exports above these, such as one that keeps the data exactly as the host received it. - 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 diff --git a/libs/@hashintel/petrinaut/src/ui/plugin.ts b/libs/@hashintel/petrinaut/src/ui/plugin.ts index 0c0d9dd9c43..875e3105e7f 100644 --- a/libs/@hashintel/petrinaut/src/ui/plugin.ts +++ b/libs/@hashintel/petrinaut/src/ui/plugin.ts @@ -12,8 +12,6 @@ export type { PetrinautPlugin, PetrinautPluginDefinition, PetrinautPluginManifest, - PluginActualExport, - PluginActualExportSpec, PluginApi, PluginButton, PluginButtonPlace, diff --git a/libs/@hashintel/petrinaut/src/ui/plugins/README.md b/libs/@hashintel/petrinaut/src/ui/plugins/README.md index e1d4c4f6f22..1a847881dc0 100644 --- a/libs/@hashintel/petrinaut/src/ui/plugins/README.md +++ b/libs/@hashintel/petrinaut/src/ui/plugins/README.md @@ -68,8 +68,6 @@ gives the typing rules. providers and supplies a command registry when the host passes none. - The editor view renders the outlets; User settings renders `PluginSettingsRows` and, under Plugins, one row per status entry. -- Actual mode's Events tab reads `actualExports` with `useRunningPlugins` and - lists them in its Export menu. - `ui/index.ts` and `main.ts` export the definition, the types and the hooks plugin code imports. diff --git a/libs/@hashintel/petrinaut/src/ui/plugins/define-petrinaut-plugin.test.ts b/libs/@hashintel/petrinaut/src/ui/plugins/define-petrinaut-plugin.test.ts index d5595dbd415..d71733c4001 100644 --- a/libs/@hashintel/petrinaut/src/ui/plugins/define-petrinaut-plugin.test.ts +++ b/libs/@hashintel/petrinaut/src/ui/plugins/define-petrinaut-plugin.test.ts @@ -36,12 +36,6 @@ const NoticePlugin = definePetrinautPlugin({ readOnlyAction: { label: "Make a copy" }, }); -const ExportPlugin = definePetrinautPlugin({ - id: "test.export", - name: "Export", - actualExports: { raw: { label: "Export Stream" } }, -}); - const EditorPlugin = definePetrinautPlugin({ id: "test.editor", name: "Editor", @@ -162,32 +156,6 @@ describe("definePetrinautPlugin", () => { }); }); - it("requires each declared Actual mode export's run", () => { - const run = () => {}; - const plugin = ExportPlugin({ - actualExports: { raw: { run, disabled: true } }, - }); - // @ts-expect-error A declared export the contributions leave out. - ExportPlugin({ actualExports: {} }); - // @ts-expect-error An export the manifest does not declare. - ExportPlugin({ actualExports: { raw: { run }, other: { run } } }); - PalettePlugin({ - buttons: { toggle: { icon: null, onClick: noop } }, - // @ts-expect-error Exports the manifest does not declare. - actualExports: { raw: { run } }, - }); - definePetrinautPlugin({ - id: "test.misspelt-export", - name: "Misspelt export", - // @ts-expect-error A key an export spec does not have. - actualExports: { raw: { label: "Export", lable: "Export" } }, - }); - - expect(plugin[pluginRuntime].hook(undefined as never)).toEqual({ - actualExports: { raw: { run, disabled: true } }, - }); - }); - it("returns the plugin directly when given the implementation", () => { const contributions = { buttons: { help: { icon: null, onClick: noop } } }; const manifest = { diff --git a/libs/@hashintel/petrinaut/src/ui/plugins/define-petrinaut-plugin.ts b/libs/@hashintel/petrinaut/src/ui/plugins/define-petrinaut-plugin.ts index 97a8baeb4f2..6a4ca552784 100644 --- a/libs/@hashintel/petrinaut/src/ui/plugins/define-petrinaut-plugin.ts +++ b/libs/@hashintel/petrinaut/src/ui/plugins/define-petrinaut-plugin.ts @@ -127,12 +127,6 @@ export interface PluginTopBarItemSpec { readonly place: PluginTopBarPlace; } -/** An Actual mode export the plugin declares; what it does comes from the hook under the same key. */ -export interface PluginActualExportSpec { - /** The item's text in the Export menu, e.g. "Export Stream". */ - readonly label: string; -} - /** What a plugin declares, as plain data Petrinaut reads before running the plugin. */ export interface PetrinautPluginManifest { /** @@ -175,12 +169,6 @@ export interface PetrinautPluginManifest { /** The button's text. */ readonly label: string; }; - /** - * Items in the Export menu of Actual mode's Events tab, before the built-in - * ones, such as an export of the host's raw execution data. The hook - * returns what each one does under the same key. - */ - readonly actualExports?: Readonly>; /** * UI inside the editor: dialogs, overlays, tours. The hook returns it under * `root`, and may return `overlay` while it covers the editor. @@ -271,14 +259,6 @@ export interface PluginReadOnlyAction { readonly run: () => void; } -/** What a declared Actual mode export does; its label comes from the manifest. */ -export interface PluginActualExport { - /** Runs when the item is chosen. */ - readonly run: () => void; - /** Shows the item greyed out, e.g. before there is anything to export; defaults to `false`. */ - readonly disabled?: boolean; -} - interface ContributionTable { /** Rendered inside the editor behind the plugin's error boundary; `null` while there is nothing to show. */ root: ReactNode; @@ -294,9 +274,6 @@ interface ContributionTable { readonly [K in keyof NonNullable]: ReactNode; }; readOnlyAction: PluginReadOnlyAction; - actualExports: { - readonly [K in keyof NonNullable]: PluginActualExport; - }; provides: ServiceOf; } @@ -347,7 +324,6 @@ export interface AnyPluginContributions { readonly buttons?: Readonly>; readonly topBarItems?: Readonly>; readonly readOnlyAction?: PluginReadOnlyAction; - readonly actualExports?: Readonly>; readonly provides?: unknown; } @@ -464,8 +440,7 @@ type ManifestChecks = RejectUnknownKeys & ? { readonly readOnlyAction: RejectUnknownKeys } : unknown) & EntryChecks & - EntryChecks & - EntryChecks; + EntryChecks; /** A plugin's implementation, whatever its manifest. */ type AnyImplementation = PluginRuntime["hook"] | AnyPluginContributions; diff --git a/libs/@hashintel/petrinaut/src/ui/plugins/plugin-contributions.test.ts b/libs/@hashintel/petrinaut/src/ui/plugins/plugin-contributions.test.ts index cd1183decb1..23804e3c420 100644 --- a/libs/@hashintel/petrinaut/src/ui/plugins/plugin-contributions.test.ts +++ b/libs/@hashintel/petrinaut/src/ui/plugins/plugin-contributions.test.ts @@ -13,7 +13,6 @@ describe("describePluginContributions", () => { buttons: { mute: { label: "Mute", place: "viewport-controls" } }, topBarItems: { status: { place: "top-bar-end" } }, readOnlyAction: { label: "Make a copy" }, - actualExports: { raw: { label: "Export Stream" } }, commands: { mute: { label: "Mute the assistant", shortcut: "f2" }, unmute: { label: "Unmute the assistant" }, @@ -35,11 +34,6 @@ describe("describePluginContributions", () => { { kind: "button", subject: "Mute", detail: "Viewport controls" }, { kind: "top-bar-item", subject: "status", detail: "End of the top bar" }, { kind: "read-only-action", subject: "Make a copy" }, - { - kind: "actual-export", - subject: "Export Stream", - detail: "Events tab", - }, { kind: "command", subject: "Mute the assistant", detail: "F2" }, { kind: "command", subject: "Unmute the assistant" }, { kind: "setting", subject: "Captions", detail: "Labs" }, diff --git a/libs/@hashintel/petrinaut/src/ui/plugins/plugin-contributions.ts b/libs/@hashintel/petrinaut/src/ui/plugins/plugin-contributions.ts index b86c938398e..37d4a08ac77 100644 --- a/libs/@hashintel/petrinaut/src/ui/plugins/plugin-contributions.ts +++ b/libs/@hashintel/petrinaut/src/ui/plugins/plugin-contributions.ts @@ -13,7 +13,6 @@ export type PluginContributionKind = | "button" | "top-bar-item" | "read-only-action" - | "actual-export" | "command" | "setting" | "provides" @@ -65,11 +64,6 @@ export const describePluginContributions = ( subject: manifest.readOnlyAction.label, }, ]), - ...Object.values(manifest.actualExports ?? {}).map((spec) => ({ - kind: "actual-export" as const, - subject: spec.label, - detail: "Events tab", - })), ...Object.values(manifest.commands ?? {}).map((spec) => ({ kind: "command" as const, subject: spec.label, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog/plugins-section/plugin-row.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog/plugins-section/plugin-row.tsx index 1e2961e6ed8..9d6b5bb0d9d 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog/plugins-section/plugin-row.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog/plugins-section/plugin-row.tsx @@ -208,7 +208,6 @@ const contributionKinds: Record< button: { label: "Button", icon: "cursor" }, "top-bar-item": { label: "Top bar", icon: "sidebar", code: true }, "read-only-action": { label: "Read-only notice", icon: "lockClosed" }, - "actual-export": { label: "Actual export", icon: "download" }, command: { label: "Command", icon: "lightning" }, setting: { label: "Setting", icon: "sliders" }, provides: { label: "Provides", icon: "plug", code: true }, 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 0444b9483f2..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 @@ -1,24 +1,12 @@ /** * @vitest-environment jsdom */ -import { - cleanup, - fireEvent, - render, - screen, - waitFor, -} from "@testing-library/react"; +import { cleanup, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it } from "vitest"; -import { - toPetrinautId, - type ActualModeContextValue, - type SDCPN, -} from "@hashintel/petrinaut-core"; +import { toPetrinautId, type SDCPN } from "@hashintel/petrinaut-core"; import { ActualModeContext } from "../../../../../../react/actual-mode-context"; -import { definePetrinautPlugin } from "../../../../../plugins/define-petrinaut-plugin"; -import { renderPlugins } from "../../../../../plugins/plugins-test-harness"; import { actualEventsSubView } from "./actual-events"; const queueId = toPetrinautId("place__queue"); @@ -64,26 +52,6 @@ const definition: SDCPN = { parameters: [], }; -const actualMode: ActualModeContextValue = { - available: true, - source: { id: "run-1" }, - status: "complete", - title: null, - definition, - initialState: {}, - transitionFirings: [ - { - transitionId: serveId, - input: { [queueId]: 1 }, - output: { [servedId]: 1 }, - ts: "2026-10-07T00:00:00.000Z", - }, - ], - timelineStartedAtMs: null, - timelineNowMs: null, - error: null, -}; - afterEach(cleanup); describe("Actual mode events", () => { @@ -91,7 +59,27 @@ describe("Actual mode events", () => { const Events = actualEventsSubView.component; render( - + , ); @@ -100,33 +88,4 @@ describe("Actual mode events", () => { expect(screen.getByText("Queue: 1")).not.toBeNull(); expect(screen.getByText(`${servedId}: 1`)).not.toBeNull(); }); - - it("lists a plugin's exports before the built-in ones", async () => { - const Events = actualEventsSubView.component; - const rawExportPlugin = definePetrinautPlugin( - { - id: "test.raw-export", - name: "Raw export", - actualExports: { raw: { label: "Export Stream" } }, - }, - { actualExports: { raw: { run: () => {} } } }, - ); - - renderPlugins( - [rawExportPlugin], - - - , - ); - - const trigger = screen.getByRole("button", { name: /^Export/ }); - fireEvent.click(trigger); - await waitFor(() => - expect(trigger.getAttribute("aria-expanded")).toBe("true"), - ); - - expect( - screen.getAllByRole("menuitem").map((item) => item.textContent), - ).toEqual(["Export Stream", "Export Recording", "Export Net"]); - }); }); 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 ebba298e07c..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 @@ -6,7 +6,6 @@ import { css, cx } from "@hashintel/ds-helpers/css"; import { ActualModeContext } from "../../../../../../react/actual-mode-context"; import { exportActualModeRecording } from "../../../../../file-io/export-actual-mode-recording"; import { exportSDCPN } from "../../../../../file-io/export-sdcpn"; -import { useRunningPlugins } from "../../../../../plugins/plugins-provider"; import type { SubView } from "../../../../../components/sub-view/types"; import type { @@ -244,31 +243,17 @@ const EventRow: React.FC<{ const ActualEventsContent: React.FC = () => { const actualMode = use(ActualModeContext); - const runningPlugins = useRunningPlugins(); const canExportRecording = actualMode.available && actualMode.definition !== null && actualMode.initialState !== null; const canExportNet = actualMode.available && actualMode.definition !== null; + 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 pluginExportItems: MenuItem[] = runningPlugins.flatMap( - ({ manifest, contributions }) => - Object.entries(manifest.actualExports ?? {}).map(([key, spec]) => { - const actualExport = contributions.actualExports?.[key]; - - return { - id: `${manifest.id}.actualExports.${key}`, - text: spec.label, - disabled: !actualExport || actualExport.disabled === true, - onClick: () => actualExport?.run(), - }; - }), - ); - const handleExportRecording = () => { if ( !actualMode.available || @@ -299,7 +284,6 @@ const ActualEventsContent: React.FC = () => { }; const exportMenuItems: MenuItem[] = [ - ...pluginExportItems, { id: "export-recording", text: "Export Recording", @@ -313,7 +297,6 @@ const ActualEventsContent: React.FC = () => { onClick: handleExportNet, }, ]; - const canExport = exportMenuItems.some((item) => !item.disabled); if (!actualMode.available) { return Actual mode is not available.; diff --git a/libs/@local/petrinaut-arch-docs/content/plugins/overview.mdx b/libs/@local/petrinaut-arch-docs/content/plugins/overview.mdx index d42ae358425..7f71caee206 100644 --- a/libs/@local/petrinaut-arch-docs/content/plugins/overview.mdx +++ b/libs/@local/petrinaut-arch-docs/content/plugins/overview.mdx @@ -64,8 +64,6 @@ under the same key. - **Inside the editor**: `root`, for dialogs, overlays and tours. - **The read-only notice**: `readOnlyAction`, a button on the notice that explains a blocked edit on a document the host made read-only. -- **Actual mode's Events tab**: `actualExports`, items in its Export menu, - such as an export of the host's raw execution data. - **User settings**: the plugin's setting rows, and its row under Plugins, where the user switches it off. diff --git a/libs/@local/petrinaut-arch-docs/content/plugins/reference.mdx b/libs/@local/petrinaut-arch-docs/content/plugins/reference.mdx index 290292db2d7..a045d3a556f 100644 --- a/libs/@local/petrinaut-arch-docs/content/plugins/reference.mdx +++ b/libs/@local/petrinaut-arch-docs/content/plugins/reference.mdx @@ -23,7 +23,6 @@ together. | `buttons` | Per key: `{ label, place, tooltip? }`. `label` is the accessible name; `tooltip` defaults to it. | | `buttons` | | `topBarItems` | Per key: `{ place }`. | | `topBarItems` | | `readOnlyAction` | `{ label }`: a button on the notice of a blocked edit on a document the host made read-only. | | `readOnlyAction` | -| `actualExports` | Per key: `{ label }`: an item in the Export menu of Actual mode's Events tab, before the built-in ones. | | `actualExports` | | `root` | `true`: the plugin renders UI inside the editor. | | `root` | | `provides` | `pluginService()`, the type of the service the hook provides. | | `provides` | @@ -181,7 +180,6 @@ type EditRefusal = ReadOnlyReason | { kind: "no-title-setter" }; | `buttons` | Declared | Per key: `{ icon, onClick?, command?, className?, ref? }`. `onClick` runs before `command`, one of the plugin's command keys. A button has at least one of `onClick`, `command` and `ref`. | | `topBarItems` | Declared | Per key: a `ReactNode`. | | `readOnlyAction` | Declared | `{ run }`: what the read-only notice's button does. The notice shows the action of the first running plugin that declares one, and closes when the button is clicked. | -| `actualExports` | Declared | Per key: `{ run, disabled? }`. `disabled` defaults to `false`; while `true`, the item shows greyed out. Every running plugin's items show, in plugin order. | | `provides` | Declared | The service type `T` of `pluginService()`. | Every declared key is required and every undeclared one is a type error. @@ -213,9 +211,8 @@ From `@hashintel/petrinaut/ui/plugin`. `` itself comes from `@hashint `ServiceOf`. - Manifest entries: `PluginSettingSpec`, `PluginSettingSection`, `PluginCommandSpec`, `PluginButtonSpec`, `PluginButtonPlace`, - `PluginTopBarItemSpec`, `PluginTopBarPlace`, `PluginActualExportSpec`. -- Contributions: `PluginButton`, `PluginCommand`, `PluginReadOnlyAction`, - `PluginActualExport`. + `PluginTopBarItemSpec`, `PluginTopBarPlace`. +- Contributions: `PluginButton`, `PluginCommand`, `PluginReadOnlyAction`. - Access: `PluginAccess`, `AccessApi`, `PluginDocumentReader`, `PluginDocument`, `PluginDiagnostics`, `PluginEdits`, `PluginExperimentsReader`, `PluginExperiments`, `PluginEditorReader`, diff --git a/libs/@local/petrinaut-arch-docs/content/plugins/writing-a-plugin.mdx b/libs/@local/petrinaut-arch-docs/content/plugins/writing-a-plugin.mdx index d77b313db91..6efb0c3e774 100644 --- a/libs/@local/petrinaut-arch-docs/content/plugins/writing-a-plugin.mdx +++ b/libs/@local/petrinaut-arch-docs/content/plugins/writing-a-plugin.mdx @@ -147,34 +147,6 @@ const useCopyPlugin: PluginHook = ({ document }) => ({ export const copyPlugin = CopyPlugin(useCopyPlugin); ``` -## Add an Actual mode export - -A host that feeds Actual mode may hold data Petrinaut does not, such as the -payloads exactly as it received them. Declare `actualExports` with a `label` -per key to add items to the Export menu of the Events tab, before Petrinaut's -own **Export Recording** and **Export Net**, and return what each one does -under the same key. Return `disabled: true` while there is nothing to export. - -```tsx -const RawExportPlugin = definePetrinautPlugin({ - id: "example.raw-export", - name: "Raw export", - actualExports: { raw: { label: "Export Stream" } }, -}); - -const useRawExportPlugin: PluginHook = () => { - const events = use(HostEventsContext); - - return { - actualExports: { - raw: { run: () => download(events), disabled: events.length === 0 }, - }, - }; -}; - -export const rawExportPlugin = RawExportPlugin(useRawExportPlugin); -``` - ## Add settings Declare each setting under `settings`. The editor renders its row in User From 4ec55b04f6262b295f75cde44e50c36cd0c0a804 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Wed, 21 Oct 2026 09:32:43 +0200 Subject: [PATCH 4/4] FE-2028: Remove the website's Brunch Actual Mode route and keep recording version 1 Co-authored-by: Cursor --- .changeset/host-agnostic-actual-mode.md | 6 - apps/petrinaut-website/README.md | 27 - apps/petrinaut-website/package.json | 1 - .../scripts/brunch-sse-fixture.ts | 940 ------------------ .../src/examples/example-search.ts | 4 +- .../src/examples/navigation-search.ts | 2 +- .../brunch-actual-mode-provider.tsx | 255 ----- .../brunch-demo/brunch-actual-mode-route.tsx | 40 - .../main/app/brunch-demo/brunch-definition.ts | 72 -- .../app/brunch-demo/brunch-demo-app.test.tsx | 56 -- .../main/app/brunch-demo/brunch-demo-app.tsx | 31 - .../app/brunch-demo/brunch-endpoint.test.ts | 63 -- .../main/app/brunch-demo/brunch-endpoint.ts | 57 -- .../app/brunch-demo/brunch-frame-parsers.ts | 116 --- .../main/app/brunch-demo/brunch-petrinaut.tsx | 110 -- .../main/app/brunch-demo/brunch-protocol.ts | 77 -- .../app/brunch-demo/brunch-search.test.ts | 103 -- .../src/main/app/brunch-demo/brunch-search.ts | 48 - apps/petrinaut-website/src/routes/brunch.tsx | 35 - .../petrinaut-core/src/actual-mode/README.md | 5 +- .../src/actual-mode/actual-mode.test.ts | 21 +- .../src/actual-mode/constants.ts | 2 +- .../content/handle/host-integration.mdx | 1 - .../content/website/plugins.mdx | 3 +- .../content/website/router-integration.mdx | 14 +- 25 files changed, 13 insertions(+), 2076 deletions(-) delete mode 100644 .changeset/host-agnostic-actual-mode.md delete mode 100644 apps/petrinaut-website/scripts/brunch-sse-fixture.ts delete mode 100644 apps/petrinaut-website/src/main/app/brunch-demo/brunch-actual-mode-provider.tsx delete mode 100644 apps/petrinaut-website/src/main/app/brunch-demo/brunch-actual-mode-route.tsx delete mode 100644 apps/petrinaut-website/src/main/app/brunch-demo/brunch-definition.ts delete mode 100644 apps/petrinaut-website/src/main/app/brunch-demo/brunch-demo-app.test.tsx delete mode 100644 apps/petrinaut-website/src/main/app/brunch-demo/brunch-demo-app.tsx delete mode 100644 apps/petrinaut-website/src/main/app/brunch-demo/brunch-endpoint.test.ts delete mode 100644 apps/petrinaut-website/src/main/app/brunch-demo/brunch-endpoint.ts delete mode 100644 apps/petrinaut-website/src/main/app/brunch-demo/brunch-frame-parsers.ts delete mode 100644 apps/petrinaut-website/src/main/app/brunch-demo/brunch-petrinaut.tsx delete mode 100644 apps/petrinaut-website/src/main/app/brunch-demo/brunch-protocol.ts delete mode 100644 apps/petrinaut-website/src/main/app/brunch-demo/brunch-search.test.ts delete mode 100644 apps/petrinaut-website/src/main/app/brunch-demo/brunch-search.ts delete mode 100644 apps/petrinaut-website/src/routes/brunch.tsx diff --git a/.changeset/host-agnostic-actual-mode.md b/.changeset/host-agnostic-actual-mode.md deleted file mode 100644 index 820d52523c1..00000000000 --- a/.changeset/host-agnostic-actual-mode.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@hashintel/petrinaut-core": patch -"@hashintel/petrinaut": patch ---- - -Actual mode no longer assumes where its execution comes from. `ActualModeSource` is `{ id, label? }`, an opaque id and an optional label the host chooses. `ActualModeContextValue` drops `receivedEvents`, and core drops `ActualModeReceivedEvent`, `ActualModeReceivedEventsRecording` and `createActualModeReceivedEventsRecording`. `ACTUAL_MODE_RECORDING_VERSION` is now 2, and `parseActualModeRecording` rejects version 1 recordings. The Events tab's **Export Stream** is now **Export Recording**, and always downloads Petrinaut's recording. diff --git a/apps/petrinaut-website/README.md b/apps/petrinaut-website/README.md index 0a675ce93a8..96e9f14d85e 100644 --- a/apps/petrinaut-website/README.md +++ b/apps/petrinaut-website/README.md @@ -98,33 +98,6 @@ from the same `website.local-copy` plugin as on example pages, saves it at its o events, transactions, spans and breadcrumbs have the fragment stripped before sending. -## Brunch live runs in Actual mode - -The `/brunch` route feeds Petrinaut's Actual mode from a Brunch run. The URL -names the Brunch stream endpoint, and optionally the run: - -```text -/brunch?sse=&runId= -``` - -The route connects to the stream with `EventSource`, 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 with Petrinaut extensions -disabled: no colours, stochasticity, dynamics or parameters. The adapter lives -in `src/main/app/brunch-demo/`. - -If the stream connection is interrupted, the route keeps any loaded data -visible and waits for the browser to reconnect. On reconnect the Brunch stream -replays the run from the beginning and the route rebuilds the timeline and -events from that replay, so an interruption neither duplicates transition -events nor misses ones that fired while disconnected. If the stream sends -invalid data, the route shows an error page with a link back to the demo site. - -`yarn brunch:fixture` serves a local Brunch-compatible stream and prints -`/brunch` URLs for it. Pass `--recording=` to replay an Export Recording -file, or an older raw stream file with an `events` array, instead of generated -events. - ## Example embeds and oEmbed Canonical example pages live below `/examples`. The JSON oEmbed endpoint at 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 255c60d0a21..00000000000 --- a/apps/petrinaut-website/scripts/brunch-sse-fixture.ts +++ /dev/null @@ -1,940 +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 { ActualModeTransitionFiring } from "@hashintel/petrinaut-core"; - -type NumericMarking = Record; - -type ReceivedEvent = { event: string; data: unknown }; - -type SseFrame = { - data: unknown; - event: string; -}; - -type TimedSseFrame = SseFrame & { - delayMs: number; -}; - -type RecordingReplay = { - definition: BrunchNetDefinitionInput; - events: ReceivedEvent[]; - 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, -): ReceivedEvent[] => { - 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, -): ReceivedEvent[] => { - 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): ReceivedEvent[] => { - 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 Export Recording file or an older raw stream file with `events`.", - ); -}; - -const findFirstEvent = ( - events: ReceivedEvent[], - eventName: string, -): ReceivedEvent | 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: ReceivedEvent): 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: ReceivedEvent[], - 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 58581f47077..00000000000 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-actual-mode-provider.tsx +++ /dev/null @@ -1,255 +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: { - id: runId ? `brunch:${endpoint}#${runId}` : `brunch:${endpoint}`, - label: endpoint, - }, - status: "loading", - title: null, - definition: null, - initialState: null, - transitionFirings: [], - 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: [], - 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", - 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, - 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], - 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 518a5cc61a6..00000000000 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-actual-mode-route.tsx +++ /dev/null @@ -1,40 +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 d3c316df3fc..00000000000 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-petrinaut.tsx +++ /dev/null @@ -1,110 +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 { PetrinautDocHandle, SDCPN } from "@hashintel/petrinaut-core"; - -const BrunchPetrinautWithHandle = ({ - definition, - navigation, - runId, - title, -}: { - definition: SDCPN; - navigation: PetrinautNavigationController; - runId?: string; - title: string; -}) => { - const [handle] = useState(() => - createJsonDocHandle({ - id: runId ? `brunch-${runId}` : "brunch-actual", - initial: definition, - capabilities: { - readonly: true, - disabledExtensions: PETRINAUT_EXTENSION_NAMES, - }, - historyLimit: 0, - }), - ); - - return ( -
- {}} - title={title} - /> -
- ); -}; - -export const BrunchPetrinaut = ({ - endpoint, - navigation, - runId, -}: { - endpoint: string; - navigation: PetrinautNavigationController; - runId?: string; -}) => { - const actualMode = use(ActualModeContext); - const definition = actualMode.available ? actualMode.definition : null; - const initialState = actualMode.available ? actualMode.initialState : null; - - if (!actualMode.available) { - return ( - - ); - } - - if (actualMode.status === "error") { - return ( - - ); - } - - if (!definition || !initialState) { - 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 64dc0590473..f0578b415ef 100644 --- a/libs/@hashintel/petrinaut-core/src/actual-mode/README.md +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/README.md @@ -32,8 +32,6 @@ independently of how a host transports events: - the context value type shared with the React package Wire formats, connection handling and raw-payload exports belong to the host. -The demo website's `/brunch` route is one example host: its adapter lives in -`apps/petrinaut-website/src/main/app/brunch-demo/`. ## Host Flow @@ -65,8 +63,7 @@ before/after markings. `createActualModeRecording` builds a normalized recording: the SDCPN, the initial marking, the transition firings and the source. `parseActualModeRecording` -reads one back. `ACTUAL_MODE_RECORDING_VERSION` is `2`; version 1 recordings, -whose source carried host-specific connection fields, are rejected. +reads one back. ## File Map 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 1c9877cf8f4..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 { - ACTUAL_MODE_RECORDING_VERSION, createActualModeRecording, createActualModeTimelineFrameReader, parseActualModeRecording, @@ -67,20 +66,6 @@ describe("Actual mode recordings", () => { }); }); - it("rejects version 1 recordings", () => { - expect(() => - parseActualModeRecording({ - version: 1, - exportedAt: "2026-06-05T10:01:00.000Z", - title: "Replay", - source: { kind: "host", endpoint: "http://127.0.0.1:5184/stream" }, - definition, - initialState: { queued: 1 }, - transitionFirings: [], - }), - ).toThrow(); - }); - it("retimes transition firings relative to the first event", () => { const recording = createActualModeRecording({ title: "Replay", @@ -116,7 +101,7 @@ describe("Actual mode recordings", () => { it("fills the collections a recorded definition omits", () => { const parsed = parseActualModeRecording({ - version: ACTUAL_MODE_RECORDING_VERSION, + version: 1, exportedAt: "2026-06-05T10:01:00.000Z", title: "Replay", source: null, @@ -132,7 +117,7 @@ describe("Actual mode recordings", () => { it("rejects transition firings with extra fields", () => { expect(() => parseActualModeRecording({ - version: ACTUAL_MODE_RECORDING_VERSION, + version: 1, exportedAt: "2026-06-05T10:01:00.000Z", title: "Replay", source: null, @@ -154,7 +139,7 @@ describe("Actual mode recordings", () => { it("rejects transition firings with non-count effect values", () => { expect(() => parseActualModeRecording({ - version: ACTUAL_MODE_RECORDING_VERSION, + version: 1, exportedAt: "2026-06-05T10:01:00.000Z", title: "Replay", source: null, diff --git a/libs/@hashintel/petrinaut-core/src/actual-mode/constants.ts b/libs/@hashintel/petrinaut-core/src/actual-mode/constants.ts index c8667500b8f..8827add9c1b 100644 --- a/libs/@hashintel/petrinaut-core/src/actual-mode/constants.ts +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/constants.ts @@ -1,2 +1,2 @@ export const ACTUAL_MODE_TIMELINE_TICK_MS = 500; -export const ACTUAL_MODE_RECORDING_VERSION = 2; +export const ACTUAL_MODE_RECORDING_VERSION = 1; 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