diff --git a/.changeset/petrinaut-actual-mode.md b/.changeset/petrinaut-actual-mode.md new file mode 100644 index 00000000000..2047c84949f --- /dev/null +++ b/.changeset/petrinaut-actual-mode.md @@ -0,0 +1,6 @@ +--- +"@hashintel/petrinaut": minor +"@hashintel/petrinaut-core": minor +--- + +Add Actual mode: a read-only live-execution view fed by a host-provided event stream, with an Actual timeline and Events tab, recording export helpers, and a redesigned timeline series selector. diff --git a/apps/petrinaut-website/package.json b/apps/petrinaut-website/package.json index a80542d4671..51f0050d04e 100644 --- a/apps/petrinaut-website/package.json +++ b/apps/petrinaut-website/package.json @@ -4,6 +4,7 @@ "private": true, "type": "module", "scripts": { + "brunch:fixture": "node --experimental-strip-types scripts/brunch-sse-fixture.ts", "build": "vite build", "dev": "vite", "fix:eslint": "oxlint --fix --type-aware --report-unused-disable-directives-severity=error .", diff --git a/apps/petrinaut-website/scripts/brunch-sse-fixture.ts b/apps/petrinaut-website/scripts/brunch-sse-fixture.ts new file mode 100644 index 00000000000..f2dbc9d6fe3 --- /dev/null +++ b/apps/petrinaut-website/scripts/brunch-sse-fixture.ts @@ -0,0 +1,941 @@ +#!/usr/bin/env node + +/** + * Local Brunch-compatible SSE fixture for testing Petrinaut Actual mode. + * + * The script serves a small Petri net execution plan, an initial marking, + * historical transition firings, and then streams new `transition_firing` + * events over `/stream`. It also exposes `/snapshot` for inspecting the full + * fixture state as JSON and prints copy-pasteable Petrinaut `/brunch` URLs + * when it starts. + * + * Pass `--recording=/path/to/export.petrinaut-actual.json` to replay an export + * from Petrinaut's Actual > Events tab instead of generating dummy events. Each + * `/stream` connection replays that export from the beginning and shifts + * timestamped events so the first recorded transition happens at connection + * time. + * + * This Brunch/Petrinaut interface is experimental: the event names, payload + * shapes, and endpoint layout are only for this first integration pass. The + * durable version will likely become a protocol owned by Petrinaut Core and + * standardized with the Brunch team later. + */ + +import { readFileSync } from "node:fs"; +import http, { type ServerResponse } from "node:http"; +import { homedir } from "node:os"; +import { resolve } from "node:path"; + +import type { + BrunchNetDefinitionInput, + BrunchTransitionInput, +} from "../src/main/app/brunch-demo/brunch-protocol"; +import type { + ActualModeReceivedEvent, + ActualModeTransitionFiring, +} from "@hashintel/petrinaut-core"; + +type NumericMarking = Record; + +type SseFrame = { + data: unknown; + event: string; +}; + +type TimedSseFrame = SseFrame & { + delayMs: number; +}; + +type RecordingReplay = { + definition: BrunchNetDefinitionInput; + events: ActualModeReceivedEvent[]; + initialState: NumericMarking; + path: string; + transitionFirings: ActualModeTransitionFiring[]; +}; + +type ParsedArgs = { + options: Map; + positionals: string[]; +}; + +const readArgs = (): ParsedArgs => { + const options = new Map(); + const positionals: string[] = []; + const rawArgs = process.argv.slice(2); + + for (let index = 0; index < rawArgs.length; index += 1) { + const arg = rawArgs[index]!; + + if (!arg.startsWith("--")) { + positionals.push(arg); + continue; + } + + const normalizedArg = arg.slice(2); + const equalsIndex = normalizedArg.indexOf("="); + + if (equalsIndex !== -1) { + options.set( + normalizedArg.slice(0, equalsIndex), + normalizedArg.slice(equalsIndex + 1), + ); + continue; + } + + const nextArg = rawArgs[index + 1]; + + if (nextArg && !nextArg.startsWith("--")) { + options.set(normalizedArg, nextArg); + index += 1; + } else { + options.set(normalizedArg, "true"); + } + } + + return { options, positionals }; +}; + +const { options: args, positionals } = readArgs(); + +const readPositiveNumberArg = ( + name: string, + environmentName: string, + defaultValue: number, +): number => { + const rawValue = args.get(name) ?? process.env[environmentName]; + + if (rawValue === undefined) { + return defaultValue; + } + + const value = Number(rawValue); + + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`Expected --${name} to be a positive number.`); + } + + return value; +}; + +const host = args.get("host") ?? process.env.HOST ?? "127.0.0.1"; +const intervalMs = readPositiveNumberArg("interval", "INTERVAL", 2_500); +const port = readPositiveNumberArg("port", "PORT", 5_184); +const expandFilePath = (path: string): string => { + const expandedPath = + path === "~" + ? homedir() + : path.startsWith("~/") + ? resolve(homedir(), path.slice(2)) + : path; + + return resolve(expandedPath); +}; + +const rawRecordingPath = + args.get("recording") ?? + args.get("replay") ?? + process.env.RECORDING ?? + positionals[0]; +const recordingPath = rawRecordingPath + ? expandFilePath(rawRecordingPath) + : undefined; +const runId = args.get("runId") ?? process.env.RUN_ID ?? "dummy-brunch-run"; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const parseJsonFile = (path: string): unknown => { + try { + return JSON.parse(readFileSync(path, "utf8")) as unknown; + } catch (err) { + throw new Error( + `Unable to load recording at ${path}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } +}; + +const assertString = ( + value: unknown, + message: string, +): asserts value is string => { + if (typeof value !== "string") { + throw new Error(message); + } +}; + +const unwrapDefinitionData = (data: unknown): unknown => + isRecord(data) && "definition" in data ? data.definition : data; + +const unwrapInitialStateData = (data: unknown): unknown => + isRecord(data) && "initialState" in data ? data.initialState : data; + +const parseNumericMarking = (data: unknown, label: string): NumericMarking => { + const candidate = unwrapInitialStateData(data); + + if (!isRecord(candidate)) { + throw new Error(`Recording ${label} must be an object.`); + } + + const marking: NumericMarking = {}; + + for (const [placeId, value] of Object.entries(candidate)) { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`Recording ${label}.${placeId} must be a finite number.`); + } + + marking[placeId] = value; + } + + return marking; +}; + +const parseTransitionEffect = ( + data: unknown, + label: string, +): ActualModeTransitionFiring["input"] => { + if (!isRecord(data)) { + throw new Error(`Recording ${label} must be an object.`); + } + + const effect: ActualModeTransitionFiring["input"] = {}; + + for (const [placeId, value] of Object.entries(data)) { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`Recording ${label}.${placeId} must be a finite number.`); + } + + effect[placeId] = value; + } + + return effect; +}; + +const parseTransitionFiring = ( + data: unknown, + label: string, +): ActualModeTransitionFiring => { + if (!isRecord(data)) { + throw new Error(`Recording ${label} must be an object.`); + } + + assertString( + data.transitionId, + `Recording ${label}.transitionId must be a string.`, + ); + assertString(data.ts, `Recording ${label}.ts must be a string.`); + + if (!Number.isFinite(Date.parse(data.ts))) { + throw new Error(`Recording ${label}.ts must be a valid timestamp.`); + } + + return { + transitionId: data.transitionId, + input: parseTransitionEffect(data.input, `${label}.input`), + output: parseTransitionEffect(data.output, `${label}.output`), + ts: data.ts, + }; +}; + +const parseReceivedEventsRecording = ( + data: Record, +): ActualModeReceivedEvent[] => { + if (!Array.isArray(data.events)) { + throw new Error("Recording events must be an array."); + } + + return data.events.map((event, index) => { + if (!isRecord(event)) { + throw new Error(`Recording events[${index}] must be an object.`); + } + + assertString( + event.event, + `Recording events[${index}].event must be a string.`, + ); + + return { + event: event.event, + data: event.data, + }; + }); +}; + +const toBrunchDefinitionInput = (data: unknown): BrunchNetDefinitionInput => { + const candidate = unwrapDefinitionData(data); + + if (!isRecord(candidate)) { + throw new Error("Recording definition must be an object."); + } + + if (!Array.isArray(candidate.places)) { + throw new Error("Recording definition.places must be an array."); + } + + if (!Array.isArray(candidate.transitions)) { + throw new Error("Recording definition.transitions must be an array."); + } + + return { + ...(typeof candidate.version === "number" + ? { version: candidate.version } + : {}), + ...(isRecord(candidate.meta) ? { meta: candidate.meta } : {}), + title: typeof candidate.title === "string" ? candidate.title : "Brunch run", + places: candidate.places.map((place, index) => { + if (!isRecord(place)) { + throw new Error( + `Recording definition.places[${index}] must be an object.`, + ); + } + + assertString( + place.id, + `Recording definition.places[${index}].id must be a string.`, + ); + + return { + id: place.id, + name: typeof place.name === "string" ? place.name : place.id, + ...(typeof place.x === "number" ? { x: place.x } : {}), + ...(typeof place.y === "number" ? { y: place.y } : {}), + }; + }), + transitions: candidate.transitions.map((transition, transitionIndex) => { + if (!isRecord(transition)) { + throw new Error( + `Recording definition.transitions[${transitionIndex}] must be an object.`, + ); + } + + assertString( + transition.id, + `Recording definition.transitions[${transitionIndex}].id must be a string.`, + ); + + if (!Array.isArray(transition.inputArcs)) { + throw new Error( + `Recording definition.transitions[${transitionIndex}].inputArcs must be an array.`, + ); + } + + if (!Array.isArray(transition.outputArcs)) { + throw new Error( + `Recording definition.transitions[${transitionIndex}].outputArcs must be an array.`, + ); + } + + return { + id: transition.id, + name: + typeof transition.name === "string" ? transition.name : transition.id, + inputArcs: transition.inputArcs.map((arc, arcIndex) => { + if (!isRecord(arc)) { + throw new Error( + `Recording definition.transitions[${transitionIndex}].inputArcs[${arcIndex}] must be an object.`, + ); + } + + assertString( + arc.placeId, + `Recording definition.transitions[${transitionIndex}].inputArcs[${arcIndex}].placeId must be a string.`, + ); + + if (typeof arc.weight !== "number") { + throw new Error( + `Recording definition.transitions[${transitionIndex}].inputArcs[${arcIndex}].weight must be a number.`, + ); + } + + return { + placeId: arc.placeId, + weight: arc.weight, + type: + arc.type === "read" || arc.type === "inhibitor" + ? arc.type + : "standard", + }; + }), + outputArcs: transition.outputArcs.map((arc, arcIndex) => { + if (!isRecord(arc)) { + throw new Error( + `Recording definition.transitions[${transitionIndex}].outputArcs[${arcIndex}] must be an object.`, + ); + } + + assertString( + arc.placeId, + `Recording definition.transitions[${transitionIndex}].outputArcs[${arcIndex}].placeId must be a string.`, + ); + + if (typeof arc.weight !== "number") { + throw new Error( + `Recording definition.transitions[${transitionIndex}].outputArcs[${arcIndex}].weight must be a number.`, + ); + } + + return { + placeId: arc.placeId, + weight: arc.weight, + }; + }), + ...(typeof transition.x === "number" ? { x: transition.x } : {}), + ...(typeof transition.y === "number" ? { y: transition.y } : {}), + }; + }), + }; +}; + +const parseNormalizedRecording = ( + data: Record, +): ActualModeReceivedEvent[] => { + if (!Array.isArray(data.transitionFirings)) { + throw new Error("Recording transitionFirings must be an array."); + } + + return [ + { event: "definition", data: toBrunchDefinitionInput(data.definition) }, + { + event: "initial_state", + data: parseNumericMarking(data.initialState, "initialState"), + }, + ...data.transitionFirings.map((firing, index) => ({ + event: "transition_firing", + data: parseTransitionFiring(firing, `transitionFirings[${index}]`), + })), + ]; +}; + +const parseRecordingEvents = (data: unknown): ActualModeReceivedEvent[] => { + if (!isRecord(data)) { + throw new Error("Recording root must be an object."); + } + + if ("events" in data) { + return parseReceivedEventsRecording(data); + } + + if ( + "definition" in data && + "initialState" in data && + "transitionFirings" in data + ) { + return parseNormalizedRecording(data); + } + + throw new Error( + "Recording must be an Actual Events export with `events` or an older normalized recording.", + ); +}; + +const findFirstEvent = ( + events: ActualModeReceivedEvent[], + eventName: string, +): ActualModeReceivedEvent | null => + events.find((event) => event.event === eventName) ?? null; + +const parseRecordingReplay = (path: string): RecordingReplay => { + const events = parseRecordingEvents(parseJsonFile(path)); + const definitionEvent = findFirstEvent(events, "definition"); + const initialStateEvent = findFirstEvent(events, "initial_state"); + + if (!definitionEvent) { + throw new Error("Recording is missing a definition event."); + } + + if (!initialStateEvent) { + throw new Error("Recording is missing an initial_state event."); + } + + return { + definition: toBrunchDefinitionInput(definitionEvent.data), + events, + initialState: parseNumericMarking(initialStateEvent.data, "initial_state"), + path, + transitionFirings: events.flatMap((event, index) => + event.event === "transition_firing" + ? [parseTransitionFiring(event.data, `events[${index}].data`)] + : [], + ), + }; +}; + +const getEventTimestampMs = (event: ActualModeReceivedEvent): number | null => { + if (!isRecord(event.data) || typeof event.data.ts !== "string") { + return null; + } + + const timestampMs = Date.parse(event.data.ts); + + if (!Number.isFinite(timestampMs)) { + throw new Error( + `Recording event ${event.event} has an invalid timestamp: ${event.data.ts}`, + ); + } + + return timestampMs; +}; + +const retimeEventData = (data: unknown, deltaMs: number): unknown => { + if (!isRecord(data) || typeof data.ts !== "string") { + return data; + } + + return { + ...data, + ts: new Date(Date.parse(data.ts) + deltaMs).toISOString(), + }; +}; + +const createTimedReplayFrames = ( + events: ActualModeReceivedEvent[], + launchTimeMs: number, +): TimedSseFrame[] => { + const firstTimestampMs = events + .map((event) => getEventTimestampMs(event)) + .find((timestampMs) => timestampMs !== null); + const deltaMs = + firstTimestampMs !== undefined ? launchTimeMs - firstTimestampMs : 0; + + return events.map((event) => { + const timestampMs = getEventTimestampMs(event); + + return { + event: event.event, + data: retimeEventData(event.data, deltaMs), + delayMs: + timestampMs === null + ? 0 + : Math.max(0, timestampMs + deltaMs - launchTimeMs), + }; + }); +}; + +const applyFiringToMarking = ( + marking: NumericMarking, + firing: ActualModeTransitionFiring, +): void => { + for (const [placeId, value] of Object.entries(firing.input)) { + marking[placeId] = (marking[placeId] ?? 0) - value; + } + + for (const [placeId, value] of Object.entries(firing.output)) { + marking[placeId] = (marking[placeId] ?? 0) + value; + } +}; + +const defaultDefinition: BrunchNetDefinitionInput = { + version: 1, + title: "Dummy Brunch Execution Plan", + meta: { + generator: "petrinaut-website/scripts/brunch-sse-fixture.ts", + generatorVersion: "1", + }, + places: [ + { id: "ideas", name: "Ideas" }, + { id: "queued", name: "Queued Work" }, + { id: "implementing", name: "Implementing" }, + { id: "reviewing", name: "Reviewing" }, + { id: "done", name: "Done" }, + ], + transitions: [ + { + id: "plan_task", + name: "Plan Task", + inputArcs: [{ placeId: "ideas", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "queued", weight: 1 }], + }, + { + id: "start_implementation", + name: "Start Implementation", + inputArcs: [{ placeId: "queued", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "implementing", weight: 1 }], + }, + { + id: "submit_review", + name: "Submit Review", + inputArcs: [{ placeId: "implementing", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "reviewing", weight: 1 }], + }, + { + id: "merge_change", + name: "Merge Change", + inputArcs: [{ placeId: "reviewing", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "done", weight: 1 }], + }, + ], +}; + +const defaultInitialState: NumericMarking = { + ideas: 100, + queued: 0, + implementing: 0, + reviewing: 0, + done: 0, +}; + +const recordingReplay = recordingPath + ? parseRecordingReplay(recordingPath) + : null; + +const definition = recordingReplay?.definition ?? defaultDefinition; +const initialState = recordingReplay?.initialState ?? defaultInitialState; + +const transitionById = new Map( + definition.transitions.map((transition) => [transition.id, transition]), +); + +const cloneMarking = (marking: NumericMarking): NumericMarking => ({ + ...marking, +}); + +const getTransition = (transitionId: string): BrunchTransitionInput => { + const transition = transitionById.get(transitionId); + + if (!transition) { + throw new Error(`Unknown transition: ${transitionId}`); + } + + return transition; +}; + +const canFire = (marking: NumericMarking, transitionId: string): boolean => { + const transition = getTransition(transitionId); + + return transition.inputArcs.every( + (arc) => (marking[arc.placeId] ?? 0) >= arc.weight, + ); +}; + +const applyTransition = ( + marking: NumericMarking, + transitionId: string, +): ActualModeTransitionFiring => { + const transition = getTransition(transitionId); + const input: ActualModeTransitionFiring["input"] = {}; + const output: ActualModeTransitionFiring["output"] = {}; + + for (const arc of transition.inputArcs) { + if ((arc.type ?? "standard") !== "standard") { + continue; + } + + input[arc.placeId] = (input[arc.placeId] ?? 0) + arc.weight; + marking[arc.placeId] = (marking[arc.placeId] ?? 0) - arc.weight; + } + + for (const arc of transition.outputArcs) { + output[arc.placeId] = (output[arc.placeId] ?? 0) + arc.weight; + marking[arc.placeId] = (marking[arc.placeId] ?? 0) + arc.weight; + } + + return { + transitionId, + input, + output, + ts: new Date().toISOString(), + }; +}; + +const currentMarking = cloneMarking(initialState); +const transitionFirings: ActualModeTransitionFiring[] = recordingReplay + ? recordingReplay.transitionFirings.map((firing) => ({ ...firing })) + : []; +const replayFrames: SseFrame[] = [ + { event: "definition", data: definition }, + { event: "initial_state", data: initialState }, +]; + +const appendFiring = (transitionId: string): ActualModeTransitionFiring => { + const firing = applyTransition(currentMarking, transitionId); + + transitionFirings.push(firing); + replayFrames.push({ event: "transition_firing", data: firing }); + + return firing; +}; + +if (recordingReplay) { + for (const firing of recordingReplay.transitionFirings) { + applyFiringToMarking(currentMarking, firing); + } +} else { + for (const transitionId of [ + "plan_task", + "start_implementation", + "plan_task", + "submit_review", + "merge_change", + ]) { + appendFiring(transitionId); + } +} + +let liveTransitionIndex = 0; +const liveTransitionCycle = [ + "start_implementation", + "plan_task", + "submit_review", + "start_implementation", + "merge_change", + "plan_task", + "submit_review", + "merge_change", +]; + +const nextLiveFiring = (): ActualModeTransitionFiring | null => { + for (let attempts = 0; attempts < liveTransitionCycle.length; attempts += 1) { + const transitionId = liveTransitionCycle[liveTransitionIndex]!; + liveTransitionIndex = + (liveTransitionIndex + 1) % liveTransitionCycle.length; + + if (canFire(currentMarking, transitionId)) { + return appendFiring(transitionId); + } + } + + // No transition is enabled (the dummy net ran out of tokens); skip the + // tick instead of firing an impossible transition into negative counts. + return null; +}; + +const streamFrame = ( + response: ServerResponse, + event: string, + data: unknown, +): void => { + response.write(`event: ${event}\n`); + response.write(`data: ${JSON.stringify(data)}\n\n`); +}; + +const streamComment = (response: ServerResponse, comment: string): void => { + response.write(`: ${comment}\n\n`); +}; + +const clients = new Map[]>(); + +const addClient = ( + response: ServerResponse, +): ReturnType[] => { + const timers: ReturnType[] = []; + clients.set(response, timers); + + return timers; +}; + +const closeClient = (response: ServerResponse): void => { + const timers = clients.get(response); + + if (timers) { + for (const timer of timers) { + clearTimeout(timer); + } + } + + clients.delete(response); +}; + +const publicHost = host === "0.0.0.0" ? "127.0.0.1" : host; +const endpoint = `http://${publicHost}:${port}/stream`; +const encodedEndpoint = encodeURIComponent(endpoint); +const brunchRoute = `/brunch?sse=${encodedEndpoint}&runId=${encodeURIComponent( + runId, +)}`; +const snapshotUrl = `http://${publicHost}:${port}/snapshot`; + +const replayRecordingToClient = ( + response: ServerResponse, + recording: RecordingReplay, +): void => { + const timers = clients.get(response); + + if (!timers) { + return; + } + + const frames = createTimedReplayFrames(recording.events, Date.now()); + let lastDelayMs = 0; + + for (const frame of frames) { + lastDelayMs = Math.max(lastDelayMs, frame.delayMs); + + const sendFrame = () => { + streamFrame(response, frame.event, frame.data); + + if ( + frame.event === "transition_firing" && + isRecord(frame.data) && + typeof frame.data.transitionId === "string" && + typeof frame.data.ts === "string" + ) { + console.log( + `[${frame.data.ts}] replay transition_firing ${frame.data.transitionId}`, + ); + } + }; + + if (frame.delayMs === 0) { + sendFrame(); + } else { + timers.push(setTimeout(sendFrame, frame.delayMs)); + } + } + + timers.push( + setTimeout(() => { + streamFrame(response, "terminal", { + reason: "recording_replay_complete", + }); + response.end(); + }, lastDelayMs + 50), + ); +}; + +const server = http.createServer((request, response) => { + const url = new URL(request.url ?? "/", endpoint); + + if (url.pathname === "/stream") { + response.writeHead(200, { + "Access-Control-Allow-Origin": "*", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "Content-Type": "text/event-stream", + }); + + streamComment(response, "dummy Brunch SSE fixture connected"); + addClient(response); + + if (recordingReplay) { + replayRecordingToClient(response, recordingReplay); + } else { + for (const frame of replayFrames) { + streamFrame(response, frame.event, frame.data); + } + } + + request.on("close", () => { + closeClient(response); + }); + + return; + } + + if (url.pathname === "/snapshot") { + response.writeHead(200, { + "Access-Control-Allow-Origin": "*", + "Content-Type": "application/json", + }); + response.end( + JSON.stringify( + { + mode: recordingReplay ? "recording-replay" : "generated", + recordingPath: recordingReplay?.path ?? null, + definition, + initialState, + currentMarking, + transitionFirings, + }, + null, + 2, + ), + ); + + return; + } + + response.writeHead(200, { "Content-Type": "text/plain" }); + response.end( + [ + "Dummy Brunch SSE fixture", + "", + `SSE endpoint: ${endpoint}`, + `Petrinaut route: ${brunchRoute}`, + `Snapshot JSON: ${snapshotUrl}`, + "", + recordingReplay + ? `Replaying recording: ${recordingReplay.path}` + : "Generated fixture mode.", + recordingReplay + ? "Each /stream connection replays definition, initial_state, and retimed transition_firing events from the beginning." + : "Connect to /stream to receive definition, initial_state, previous transition_firing events, then live transition_firing events.", + ].join("\n"), + ); +}); + +const broadcastLiveFiring = (): void => { + const firing = nextLiveFiring(); + + if (!firing) { + return; + } + + for (const client of clients.keys()) { + streamFrame(client, "transition_firing", firing); + } + + console.log( + `[${firing.ts}] transition_firing ${firing.transitionId} -> ${JSON.stringify( + firing.output, + )}`, + ); +}; + +server.on("error", (err: Error & { code?: string }) => { + if (err.code === "EADDRINUSE") { + console.error( + `Port ${port} is already in use on ${host}. Stop the existing fixture or pass another port, for example:`, + ); + console.error( + " yarn workspace @apps/petrinaut-website brunch:fixture -- --port=5185", + ); + process.exit(1); + } + + throw err; +}); + +server.listen(port, host, () => { + console.log("Dummy Brunch SSE fixture running"); + console.log(""); + console.log(`SSE endpoint: ${endpoint}`); + console.log(`Petrinaut route: ${brunchRoute}`); + console.log(`Snapshot JSON: ${snapshotUrl}`); + console.log(""); + if (recordingReplay) { + console.log(`Replaying recording: ${recordingReplay.path}`); + console.log( + `Recorded transition events: ${recordingReplay.transitionFirings.length}`, + ); + console.log( + "Each /stream connection replays from the beginning with retimed timestamps.", + ); + } else { + console.log(`Streaming live transitions every ${intervalMs}ms.`); + } + console.log("Stop with Ctrl-C."); +}); + +const interval = recordingReplay + ? null + : setInterval(broadcastLiveFiring, intervalMs); + +const shutdown = (): void => { + if (interval) { + clearInterval(interval); + } + + for (const client of clients.keys()) { + streamComment(client, "fixture shutting down"); + client.end(); + closeClient(client); + } + + server.close(() => { + process.exit(0); + }); +}; + +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); diff --git a/apps/petrinaut-website/src/main.tsx b/apps/petrinaut-website/src/main.tsx index 4d1c2ade620..f45641dc79a 100644 --- a/apps/petrinaut-website/src/main.tsx +++ b/apps/petrinaut-website/src/main.tsx @@ -5,7 +5,7 @@ import * as Sentry from "@sentry/react"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import { DevApp } from "./main/app"; +import { DemoApp } from "./main/app"; import { SentryErrorTrackerProvider } from "./sentry/sentry-error-tracker-provider"; const root = createRoot(document.getElementById("root")!, { @@ -25,7 +25,7 @@ const root = createRoot(document.getElementById("root")!, { root.render( - + , ); diff --git a/apps/petrinaut-website/src/main/app.tsx b/apps/petrinaut-website/src/main/app.tsx index b418482bd55..b22fd8654c2 100644 --- a/apps/petrinaut-website/src/main/app.tsx +++ b/apps/petrinaut-website/src/main/app.tsx @@ -1,299 +1,11 @@ -import { produce } from "immer"; -import { useEffect, useMemo, useState } from "react"; +import { BrunchDemoApp } from "./app/brunch-demo/brunch-demo-app"; +import { isBrunchDemoRoute } from "./app/brunch-demo/brunch-route"; +import { LocalStorageDemoApp } from "./app/local-storage-demo/local-storage-demo-app"; -import { createJsonDocHandle } from "@hashintel/petrinaut-core"; -import { - DefaultChatTransport, - Petrinaut, - type PetrinautAiChatTransport, - type PetrinautAiMessage, - WalkthroughProvider, -} from "@hashintel/petrinaut/ui"; - -import { useSentryFeedbackAction } from "./app/sentry-feedback-button"; -import { useLocalStorageAiMessages } from "./app/use-local-storage-ai-messages"; -import { - type SDCPNInLocalStorage, - useLocalStorageSDCPNs, -} from "./app/use-local-storage-sdcpns"; -import { walkthroughSteps } from "./app/walkthrough/walkthrough-steps"; - -import type { - MinimalNetMetadata, - PetrinautDocHandle, - SDCPN, -} from "@hashintel/petrinaut-core"; - -const isEmptySDCPN = (sdcpn: SDCPN) => - sdcpn.places.length === 0 && - sdcpn.transitions.length === 0 && - sdcpn.types.length === 0 && - sdcpn.parameters.length === 0 && - sdcpn.differentialEquations.length === 0; - -const emptySDCPN: SDCPN = { - places: [], - transitions: [], - types: [], - parameters: [], - differentialEquations: [], -}; - -const createDefaultStoredSDCPN = (): SDCPNInLocalStorage => ({ - id: "net-1", - title: "New Process", - sdcpn: emptySDCPN, - lastUpdated: new Date(0).toISOString(), -}); - -/** - * Creates the localStorage record for a newly created net, keeping the generated - * id and last-updated timestamp in sync. - */ -const createLocalStorageNetRecord = (params: { - petriNetDefinition: SDCPN; - title: string; -}): SDCPNInLocalStorage => { - const now = new Date(); - - return { - id: `net-${now.getTime()}`, - title: params.title, - sdcpn: params.petriNetDefinition, - lastUpdated: now.toISOString(), - }; -}; - -const createHandle = (net: SDCPNInLocalStorage): PetrinautDocHandle => - createJsonDocHandle({ id: net.id, initial: net.sdcpn }); - -const petrinautAiChatTransport: PetrinautAiChatTransport = - new DefaultChatTransport({ - api: "/api/chat", - }); - -const getStoredSDCPNsForDisplay = ( - storedSDCPNs: Record, -): Record => { - if (Object.values(storedSDCPNs).length > 0) { - return storedSDCPNs; - } - - const defaultStoredSDCPN = createDefaultStoredSDCPN(); - return { [defaultStoredSDCPN.id]: defaultStoredSDCPN }; -}; - -type ActiveHandle = { - handle: PetrinautDocHandle; - netId: string; - fallbackNet: SDCPNInLocalStorage; -}; - -const createActiveHandle = (net: SDCPNInLocalStorage): ActiveHandle => ({ - handle: createHandle(net), - netId: net.id, - fallbackNet: net, -}); - -/** - * Demo-site shell for Petrinaut. - * - * Local storage is the persistence layer for saved nets, while the active - * Petrinaut document handle owns the currently open net's live editable state. - * Switching files replaces the active handle instead of keeping handles alive - * for background nets. - */ -export const DevApp = () => { - const sentryFeedbackAction = useSentryFeedbackAction(); - const { aiMessagesByNetId, setAiMessagesByNetId } = - useLocalStorageAiMessages(); - const { storedSDCPNs, setStoredSDCPNs } = useLocalStorageSDCPNs(); - const storedSDCPNsForDisplay = getStoredSDCPNsForDisplay(storedSDCPNs); - - // Pick the most recently modified net - const mostRecentlyModifiedNet = - Object.values(storedSDCPNsForDisplay).sort( - (a, b) => - new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime(), - )[0] ?? null; - - // The net currently selected in the UI. - const [currentNetId, setCurrentNetId] = useState( - () => mostRecentlyModifiedNet?.id ?? null, - ); - - // Metadata and persisted SDCPN snapshot for the selected net. - const currentNet = currentNetId - ? (storedSDCPNsForDisplay[currentNetId] ?? null) - : null; - - // Live editable document handle for the selected net only. - const [activeHandle, setActiveHandle] = useState(() => - mostRecentlyModifiedNet - ? createActiveHandle(mostRecentlyModifiedNet) - : null, - ); - - useEffect(() => { - if (!activeHandle) { - return; - } - - const { fallbackNet, handle, netId } = activeHandle; - - return handle.subscribe((event) => { - const lastUpdated = new Date().toISOString(); - - setStoredSDCPNs((prev) => { - const stored = prev[netId] ?? fallbackNet; - - return produce(prev, (draft) => { - draft[netId] = { - ...stored, - sdcpn: event.next, - lastUpdated, - }; - }); - }); - }); - }, [activeHandle, setStoredSDCPNs]); - - const existingNets: MinimalNetMetadata[] = Object.values(storedSDCPNs) - .map((net) => ({ - netId: net.id, - title: net.title, - lastUpdated: net.lastUpdated, - })) - .sort( - (a, b) => - new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime(), - ); - - const createNewNet = (params: { - petriNetDefinition: SDCPN; - title: string; - }) => { - const newNet = createLocalStorageNetRecord(params); - const previousNet = - currentNetId && currentNetId !== newNet.id ? currentNet : null; - const previousNetIdToRemove = previousNet !== null ? currentNetId : null; - - setStoredSDCPNs((prev) => { - const next = { ...prev, [newNet.id]: newNet }; - - // Remove the previous net if it was empty and unmodified - if ( - previousNetIdToRemove && - previousNet && - isEmptySDCPN(prev[previousNetIdToRemove]?.sdcpn ?? previousNet.sdcpn) - ) { - delete next[previousNetIdToRemove]; - } - - return next; - }); - setActiveHandle(createActiveHandle(newNet)); - setCurrentNetId(newNet.id); - }; - - const loadPetriNet = (petriNetId: string) => { - const netToLoad = storedSDCPNsForDisplay[petriNetId]; - if (!netToLoad) { - return; - } - - // Remove the current net if it was empty and unmodified - if (currentNetId && currentNetId !== petriNetId) { - const previousNetIdToRemove = - currentNet && isEmptySDCPN(currentNet.sdcpn) ? currentNetId : null; - - setStoredSDCPNs((prev) => { - const prevNet = previousNetIdToRemove - ? prev[previousNetIdToRemove] - : null; - - if (previousNetIdToRemove && prevNet && isEmptySDCPN(prevNet.sdcpn)) { - const next = { ...prev }; - delete next[previousNetIdToRemove]; - return next; - } - return prev; - }); - } - setActiveHandle(createActiveHandle(netToLoad)); - setCurrentNetId(petriNetId); - }; - - const setTitle = (title: string) => { - if (!currentNetId || !currentNet) { - return; - } - - const lastUpdated = new Date().toISOString(); - - setStoredSDCPNs((prev) => - produce(prev, (draft) => { - draft[currentNetId] = { - ...(draft[currentNetId] ?? currentNet), - title, - lastUpdated, - }; - }), - ); - }; - - const aiAssistant = useMemo( - () => ({ - transport: petrinautAiChatTransport, - messages: currentNetId ? aiMessagesByNetId[currentNetId] : undefined, - onMessages: (messages: PetrinautAiMessage[]) => { - if (!currentNetId) { - return; - } - - setAiMessagesByNetId((prev) => ({ - ...prev, - [currentNetId]: messages, - })); - }, - onClearMessages: () => { - if (!currentNetId) { - return; - } - - setAiMessagesByNetId((prev) => { - const next = { ...prev }; - delete next[currentNetId]; - return next; - }); - }, - }), - [aiMessagesByNetId, currentNetId, setAiMessagesByNetId], - ); - - if (!currentNet) { - return null; - } - - if (!activeHandle || activeHandle.netId !== currentNet.id) { - return null; +export const DemoApp = () => { + if (isBrunchDemoRoute()) { + return ; } - return ( -
- - - -
- ); + return ; }; 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 new file mode 100644 index 00000000000..8974dd4e4ef --- /dev/null +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-actual-mode-provider.tsx @@ -0,0 +1,270 @@ +import { useEffect, useState, type FC, type PropsWithChildren } from "react"; + +import { ACTUAL_MODE_TIMELINE_TICK_MS } from "@hashintel/petrinaut-core"; +import { ActualModeContext } from "@hashintel/petrinaut/react"; + +import { normalizeBrunchDefinition } from "./brunch-definition"; +import { + parseDefinitionFrameData, + parseJsonEventData, + parseMarkingFrameData, + parseTransitionFiringFrameData, +} from "./brunch-frame-parsers"; + +import type { ActualModeContextValue } from "@hashintel/petrinaut-core"; + +type AvailableActualModeContextValue = Extract< + ActualModeContextValue, + { available: true } +>; + +const createLoadingActualModeValue = ( + endpoint: string, + runId: string | undefined, +): AvailableActualModeContextValue => { + const now = Date.now(); + + return { + available: true, + source: { + kind: "brunch", + endpoint, + ...(runId ? { runId } : {}), + }, + status: "loading", + title: null, + definition: null, + initialState: null, + transitionFirings: [], + receivedEvents: [], + timelineStartedAtMs: now, + timelineNowMs: now, + error: null, + }; +}; + +export const BrunchActualModeProvider: FC< + PropsWithChildren<{ endpoint: string; runId?: string }> +> = ({ children, endpoint, runId }) => { + const [value, setValue] = useState(() => + createLoadingActualModeValue(endpoint, runId), + ); + + useEffect(() => { + const interval = window.setInterval(() => { + setValue((prev) => + prev.status === "streaming" + ? { + ...prev, + timelineNowMs: Date.now(), + } + : prev, + ); + }, ACTUAL_MODE_TIMELINE_TICK_MS); + + return () => { + window.clearInterval(interval); + }; + }, []); + + useEffect(() => { + setValue(createLoadingActualModeValue(endpoint, runId)); + }, [endpoint, runId]); + + useEffect(() => { + let cancelled = false; + let hasConnectedBefore = false; + const eventSource = new EventSource(endpoint); + + const setFatalError = (message: string) => { + if (cancelled) { + return; + } + + eventSource.close(); + setValue((prev) => ({ + ...prev, + status: "error", + timelineNowMs: Date.now(), + error: message, + })); + }; + + const setRecoverableConnectionError = (message: string) => { + if (cancelled) { + return; + } + + setValue((prev) => { + if (prev.status === "error") { + return prev; + } + + const canRenderActualMode = + prev.definition !== null && prev.initialState !== null; + + return { + ...prev, + status: + prev.status === "complete" + ? "complete" + : canRenderActualMode + ? "streaming" + : "loading", + timelineNowMs: Date.now(), + error: message, + }; + }); + }; + + const onOpen = () => { + if (cancelled) { + return; + } + + const isReconnect = hasConnectedBefore; + hasConnectedBefore = true; + + setValue((prev) => { + const error = prev.status === "error" ? prev.error : null; + + if (!isReconnect) { + return { ...prev, error }; + } + + // The temporary Brunch protocol replays the whole run (definition, + // initial_state, and every past transition_firing) on each connection, + // so appending across an automatic reconnect would duplicate every + // previously received event. Drop the accumulated events and let the + // replay rebuild them; the already-loaded definition and initial state + // stay visible until the replay re-delivers them. The timeline start + // is re-baselined too, so the briefly empty firing list does not + // synthesize tick frames reaching back to the original page load. + return { + ...prev, + transitionFirings: [], + receivedEvents: [], + timelineStartedAtMs: Date.now(), + timelineNowMs: Date.now(), + error, + }; + }); + }; + + const onDefinition = (event: Event) => { + void (async () => { + try { + const data = parseJsonEventData(event as MessageEvent, "definition"); + const definition = parseDefinitionFrameData(data); + + setValue((prev) => ({ + ...prev, + status: prev.status === "complete" ? "complete" : "streaming", + receivedEvents: [ + ...prev.receivedEvents, + { event: "definition", data }, + ], + timelineNowMs: Date.now(), + error: null, + })); + + const sdcpn = await normalizeBrunchDefinition(definition); + + if (cancelled) { + return; + } + + setValue((prev) => ({ + ...prev, + status: prev.status === "complete" ? "complete" : "streaming", + title: definition.title, + definition: sdcpn, + timelineNowMs: Date.now(), + error: null, + })); + } catch (err) { + setFatalError(err instanceof Error ? err.message : String(err)); + } + })(); + }; + + const onInitialState = (event: Event) => { + try { + const data = parseJsonEventData(event as MessageEvent, "initial_state"); + const initialState = parseMarkingFrameData(data); + setValue((prev) => ({ + ...prev, + status: prev.status === "complete" ? "complete" : "streaming", + initialState, + receivedEvents: [ + ...prev.receivedEvents, + { event: "initial_state", data }, + ], + timelineNowMs: Date.now(), + error: null, + })); + } catch (err) { + setFatalError(err instanceof Error ? err.message : String(err)); + } + }; + + const onTransitionFiring = (event: Event) => { + try { + const data = parseJsonEventData( + event as MessageEvent, + "transition_firing", + ); + const firing = parseTransitionFiringFrameData(data); + setValue((prev) => ({ + ...prev, + status: prev.status === "complete" ? "complete" : "streaming", + transitionFirings: [...prev.transitionFirings, firing], + receivedEvents: [ + ...prev.receivedEvents, + { event: "transition_firing", data }, + ], + timelineNowMs: Date.now(), + error: null, + })); + } catch (err) { + setFatalError(err instanceof Error ? err.message : String(err)); + } + }; + + const onTerminal = () => { + eventSource.close(); + setValue((prev) => ({ + ...prev, + status: "complete", + timelineNowMs: Date.now(), + error: null, + })); + }; + + const onError = () => { + setRecoverableConnectionError( + "Connection to the Brunch stream was interrupted. Reconnecting...", + ); + }; + + eventSource.addEventListener("open", onOpen); + eventSource.addEventListener("definition", onDefinition); + eventSource.addEventListener("initial_state", onInitialState); + eventSource.addEventListener("transition_firing", onTransitionFiring); + eventSource.addEventListener("terminal", onTerminal); + eventSource.addEventListener("error", onError); + + return () => { + cancelled = true; + eventSource.close(); + eventSource.removeEventListener("open", onOpen); + eventSource.removeEventListener("definition", onDefinition); + eventSource.removeEventListener("initial_state", onInitialState); + eventSource.removeEventListener("transition_firing", onTransitionFiring); + eventSource.removeEventListener("terminal", onTerminal); + eventSource.removeEventListener("error", onError); + }; + }, [endpoint, runId]); + + return {children}; +}; diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-actual-mode-route.tsx b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-actual-mode-route.tsx new file mode 100644 index 00000000000..28ada7d3f92 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-actual-mode-route.tsx @@ -0,0 +1,36 @@ +import { BrunchActualModeProvider } from "./brunch-actual-mode-provider"; +import { getBrunchEndpointFromLocation } from "./brunch-endpoint"; +import { BrunchPetrinaut } from "./brunch-petrinaut"; +import { BrunchStatusPage } from "./brunch-status-page"; + +import type { ViewportAction } from "@hashintel/petrinaut/ui"; + +export { BrunchActualModeProvider } from "./brunch-actual-mode-provider"; +export { getBrunchEndpointFromLocation } from "./brunch-endpoint"; + +export const BrunchActualModeRoute = ({ + viewportActions, +}: { + viewportActions: ViewportAction[]; +}) => { + const endpointResult = getBrunchEndpointFromLocation(window.location); + + 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 new file mode 100644 index 00000000000..52015aeb709 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-definition.ts @@ -0,0 +1,71 @@ +import { + calculateGraphLayout, + layoutNodeDimensions, +} from "@hashintel/petrinaut-core"; + +import type { BrunchNetDefinition } from "./brunch-protocol"; +import type { SDCPN } from "@hashintel/petrinaut-core"; + +export { brunchNetDefinitionSchema } from "./brunch-protocol"; + +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 => { + const sdcpn = 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.tsx b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-demo-app.tsx new file mode 100644 index 00000000000..c7a11828aec --- /dev/null +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-demo-app.tsx @@ -0,0 +1,8 @@ +import { useSentryFeedbackAction } from "../sentry-feedback-button"; +import { BrunchActualModeRoute } from "./brunch-actual-mode-route"; + +export const BrunchDemoApp = () => { + const sentryFeedbackAction = useSentryFeedbackAction(); + + return ; +}; 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 new file mode 100644 index 00000000000..3fea08294b0 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-endpoint.ts @@ -0,0 +1,54 @@ +type BrunchEndpointResult = + | { ok: true; endpoint: string; runId?: string } + | { ok: false; error: string }; + +const normalizeEndpoint = (value: 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, window.location.href); + + // 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 getBrunchEndpointFromLocation = ( + location: Location, +): BrunchEndpointResult => { + const params = new URLSearchParams(location.search); + const rawEndpoint = params.get("sse") ?? undefined; + + try { + if (rawEndpoint !== undefined) { + return { + ok: true, + endpoint: normalizeEndpoint(rawEndpoint), + runId: params.get("runId") ?? undefined, + }; + } + } 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 new file mode 100644 index 00000000000..0e9f24b3b86 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-frame-parsers.ts @@ -0,0 +1,149 @@ +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; +}; + +/** + * Convenience parser for EventSource `definition` events. + * + * Use this when a caller only needs the validated Brunch definition and does + * not need to retain the decoded raw JSON payload for export. + */ +export const parseDefinitionFrame = ( + event: MessageEvent, +): BrunchNetDefinition => + parseDefinitionFrameData(parseJsonEventData(event, "definition")); + +/** + * 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; +}; + +/** + * Convenience parser for EventSource `initial_state` events. + * + * Use this when a caller only needs the validated marking and does not need to + * retain the decoded raw JSON payload for export. + */ +export const parseMarkingFrame = (event: MessageEvent): ActualModeMarking => + parseMarkingFrameData(parseJsonEventData(event, "initial_state")); + +/** + * 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; +}; + +/** + * Convenience parser for EventSource `transition_firing` events. + * + * Use this when a caller only needs the validated transition firing and does + * not need to retain the decoded raw JSON payload for export. + */ +export const parseTransitionFiringFrame = ( + event: MessageEvent, +): ActualModeTransitionFiring => + parseTransitionFiringFrameData( + parseJsonEventData(event, "transition_firing"), + ); 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 new file mode 100644 index 00000000000..57c45fda007 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-petrinaut.tsx @@ -0,0 +1,113 @@ +import { use, useState } from "react"; + +import { + createJsonDocHandle, + PETRINAUT_EXTENSION_NAMES, +} from "@hashintel/petrinaut-core"; +import { ActualModeContext } from "@hashintel/petrinaut/react"; +import { Petrinaut, type ViewportAction } from "@hashintel/petrinaut/ui"; + +import { BrunchStatusPage } from "./brunch-status-page"; + +import type { + ActualModeSource, + PetrinautDocHandle, + SDCPN, +} from "@hashintel/petrinaut-core"; + +const getSourceKey = (source: ActualModeSource): string => + `${source.kind}:${source.endpoint}:${source.runId ?? ""}`; + +const BrunchPetrinautWithHandle = ({ + definition, + source, + title, + viewportActions, +}: { + definition: SDCPN; + source: ActualModeSource; + title: string; + viewportActions: ViewportAction[]; +}) => { + const [handle] = useState(() => + createJsonDocHandle({ + id: source.runId ? `brunch-${source.runId}` : "brunch-actual", + initial: definition, + capabilities: { + readonly: true, + disabledExtensions: PETRINAUT_EXTENSION_NAMES, + }, + historyLimit: 0, + }), + ); + + return ( +
+ {}} + title={title} + viewportActions={viewportActions} + /> +
+ ); +}; + +export const BrunchPetrinaut = ({ + viewportActions, +}: { + viewportActions: ViewportAction[]; +}) => { + const actualMode = use(ActualModeContext); + const definition = actualMode.available ? actualMode.definition : null; + const initialState = actualMode.available ? actualMode.initialState : null; + const source = actualMode.available ? actualMode.source : null; + const sourceKey = source ? getSourceKey(source) : null; + + if (!actualMode.available) { + return ( + + ); + } + + if (actualMode.status === "error") { + return ( + + ); + } + + if (!definition || !initialState || !source || !sourceKey) { + return ( + + ); + } + + return ( + + ); +}; diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-protocol.ts b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-protocol.ts new file mode 100644 index 00000000000..6e2d2431e2a --- /dev/null +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-protocol.ts @@ -0,0 +1,77 @@ +import { z } from "zod"; + +export const brunchInputArcSchema = z + .object({ + placeId: z.string(), + weight: z.number(), + type: z + .enum(["standard", "read", "inhibitor"]) + .optional() + .default("standard"), + }) + .strict(); + +export const brunchOutputArcSchema = z + .object({ + placeId: z.string(), + weight: z.number(), + }) + .strict(); + +export const brunchPlaceSchema = z + .object({ + id: z.string(), + name: z.string(), + x: z.number().optional(), + y: z.number().optional(), + }) + .strict(); + +export 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-route.ts b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-route.ts new file mode 100644 index 00000000000..2bb6f47e54f --- /dev/null +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-route.ts @@ -0,0 +1,9 @@ +/** + * This is temporary, until Petrinaut Demo app gets a real Router. + * Adding a real Router will require to consider every parts of the app, so this is just a quick and dirty solution. + */ +export const isBrunchDemoRoute = (): boolean => { + const path = window.location.pathname.replace(/\/+$/u, "") || "/"; + + return path === "/brunch"; +}; diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-status-page.tsx b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-status-page.tsx new file mode 100644 index 00000000000..f79b3c925be --- /dev/null +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-status-page.tsx @@ -0,0 +1,62 @@ +import type { CSSProperties } from "react"; + +const pageStyle: CSSProperties = { + alignItems: "center", + background: "#f6f7f8", + color: "#1f2933", + display: "flex", + fontFamily: "Inter, system-ui, sans-serif", + height: "100vh", + justifyContent: "center", + padding: 24, + width: "100vw", +}; + +const panelStyle: CSSProperties = { + background: "#ffffff", + border: "1px solid #d7dce1", + borderRadius: 8, + boxShadow: "0 8px 24px rgba(31, 41, 51, 0.08)", + maxWidth: 560, + padding: 24, +}; + +const headingStyle: CSSProperties = { + fontSize: 20, + lineHeight: "28px", + margin: "0 0 8px", +}; + +const bodyStyle: CSSProperties = { + color: "#4b5563", + fontSize: 14, + lineHeight: "20px", + margin: "0 0 16px", +}; + +const linkStyle: CSSProperties = { + color: "#2563eb", + fontSize: 14, + fontWeight: 600, +}; + +export const BrunchStatusPage = ({ + body, + endpoint, + title, +}: { + body: string; + endpoint?: string; + title: string; +}) => ( +
+
+

{title}

+

{body}

+ {endpoint ?

{endpoint}

: null} + + Back to Petrinaut + +
+
+); diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx new file mode 100644 index 00000000000..c7093b2952f --- /dev/null +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx @@ -0,0 +1,299 @@ +import { produce } from "immer"; +import { useEffect, useMemo, useState } from "react"; + +import { createJsonDocHandle } from "@hashintel/petrinaut-core"; +import { + DefaultChatTransport, + Petrinaut, + type PetrinautAiChatTransport, + type PetrinautAiMessage, + WalkthroughProvider, +} from "@hashintel/petrinaut/ui"; + +import { useSentryFeedbackAction } from "../sentry-feedback-button"; +import { useLocalStorageAiMessages } from "./use-local-storage-ai-messages"; +import { + type SDCPNInLocalStorage, + useLocalStorageSDCPNs, +} from "./use-local-storage-sdcpns"; +import { walkthroughSteps } from "./walkthrough/walkthrough-steps"; + +import type { + MinimalNetMetadata, + PetrinautDocHandle, + SDCPN, +} from "@hashintel/petrinaut-core"; + +const isEmptySDCPN = (sdcpn: SDCPN) => + sdcpn.places.length === 0 && + sdcpn.transitions.length === 0 && + sdcpn.types.length === 0 && + sdcpn.parameters.length === 0 && + sdcpn.differentialEquations.length === 0; + +const emptySDCPN: SDCPN = { + places: [], + transitions: [], + types: [], + parameters: [], + differentialEquations: [], +}; + +const createDefaultStoredSDCPN = (): SDCPNInLocalStorage => ({ + id: "net-1", + title: "New Process", + sdcpn: emptySDCPN, + lastUpdated: new Date(0).toISOString(), +}); + +/** + * Creates the localStorage record for a newly created net, keeping the generated + * id and last-updated timestamp in sync. + */ +const createLocalStorageNetRecord = (params: { + petriNetDefinition: SDCPN; + title: string; +}): SDCPNInLocalStorage => { + const now = new Date(); + + return { + id: `net-${now.getTime()}`, + title: params.title, + sdcpn: params.petriNetDefinition, + lastUpdated: now.toISOString(), + }; +}; + +const createHandle = (net: SDCPNInLocalStorage): PetrinautDocHandle => + createJsonDocHandle({ id: net.id, initial: net.sdcpn }); + +const petrinautAiChatTransport: PetrinautAiChatTransport = + new DefaultChatTransport({ + api: "/api/chat", + }); + +const getStoredSDCPNsForDisplay = ( + storedSDCPNs: Record, +): Record => { + if (Object.values(storedSDCPNs).length > 0) { + return storedSDCPNs; + } + + const defaultStoredSDCPN = createDefaultStoredSDCPN(); + return { [defaultStoredSDCPN.id]: defaultStoredSDCPN }; +}; + +type ActiveHandle = { + handle: PetrinautDocHandle; + netId: string; + fallbackNet: SDCPNInLocalStorage; +}; + +const createActiveHandle = (net: SDCPNInLocalStorage): ActiveHandle => ({ + handle: createHandle(net), + netId: net.id, + fallbackNet: net, +}); + +/** + * Local-storage demo shell for Petrinaut. + * + * Local storage is the persistence layer for saved nets, while the active + * Petrinaut document handle owns the currently open net's live editable state. + * Switching files replaces the active handle instead of keeping handles alive + * for background nets. + */ +export const LocalStorageDemoApp = () => { + const sentryFeedbackAction = useSentryFeedbackAction(); + const { aiMessagesByNetId, setAiMessagesByNetId } = + useLocalStorageAiMessages(); + const { storedSDCPNs, setStoredSDCPNs } = useLocalStorageSDCPNs(); + const storedSDCPNsForDisplay = getStoredSDCPNsForDisplay(storedSDCPNs); + + // Pick the most recently modified net + const mostRecentlyModifiedNet = + Object.values(storedSDCPNsForDisplay).sort( + (a, b) => + new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime(), + )[0] ?? null; + + // The net currently selected in the UI. + const [currentNetId, setCurrentNetId] = useState( + () => mostRecentlyModifiedNet?.id ?? null, + ); + + // Metadata and persisted SDCPN snapshot for the selected net. + const currentNet = currentNetId + ? (storedSDCPNsForDisplay[currentNetId] ?? null) + : null; + + // Live editable document handle for the selected net only. + const [activeHandle, setActiveHandle] = useState(() => + mostRecentlyModifiedNet + ? createActiveHandle(mostRecentlyModifiedNet) + : null, + ); + + useEffect(() => { + if (!activeHandle) { + return; + } + + const { fallbackNet, handle, netId } = activeHandle; + + return handle.subscribe((event) => { + const lastUpdated = new Date().toISOString(); + + setStoredSDCPNs((prev) => { + const stored = prev[netId] ?? fallbackNet; + + return produce(prev, (draft) => { + draft[netId] = { + ...stored, + sdcpn: event.next, + lastUpdated, + }; + }); + }); + }); + }, [activeHandle, setStoredSDCPNs]); + + const existingNets: MinimalNetMetadata[] = Object.values(storedSDCPNs) + .map((net) => ({ + netId: net.id, + title: net.title, + lastUpdated: net.lastUpdated, + })) + .sort( + (a, b) => + new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime(), + ); + + const createNewNet = (params: { + petriNetDefinition: SDCPN; + title: string; + }) => { + const newNet = createLocalStorageNetRecord(params); + const previousNet = + currentNetId && currentNetId !== newNet.id ? currentNet : null; + const previousNetIdToRemove = previousNet !== null ? currentNetId : null; + + setStoredSDCPNs((prev) => { + const next = { ...prev, [newNet.id]: newNet }; + + // Remove the previous net if it was empty and unmodified + if ( + previousNetIdToRemove && + previousNet && + isEmptySDCPN(prev[previousNetIdToRemove]?.sdcpn ?? previousNet.sdcpn) + ) { + delete next[previousNetIdToRemove]; + } + + return next; + }); + setActiveHandle(createActiveHandle(newNet)); + setCurrentNetId(newNet.id); + }; + + const loadPetriNet = (petriNetId: string) => { + const netToLoad = storedSDCPNsForDisplay[petriNetId]; + if (!netToLoad) { + return; + } + + // Remove the current net if it was empty and unmodified + if (currentNetId && currentNetId !== petriNetId) { + const previousNetIdToRemove = + currentNet && isEmptySDCPN(currentNet.sdcpn) ? currentNetId : null; + + setStoredSDCPNs((prev) => { + const prevNet = previousNetIdToRemove + ? prev[previousNetIdToRemove] + : null; + + if (previousNetIdToRemove && prevNet && isEmptySDCPN(prevNet.sdcpn)) { + const next = { ...prev }; + delete next[previousNetIdToRemove]; + return next; + } + return prev; + }); + } + setActiveHandle(createActiveHandle(netToLoad)); + setCurrentNetId(petriNetId); + }; + + const setTitle = (title: string) => { + if (!currentNetId || !currentNet) { + return; + } + + const lastUpdated = new Date().toISOString(); + + setStoredSDCPNs((prev) => + produce(prev, (draft) => { + draft[currentNetId] = { + ...(draft[currentNetId] ?? currentNet), + title, + lastUpdated, + }; + }), + ); + }; + + const aiAssistant = useMemo( + () => ({ + transport: petrinautAiChatTransport, + messages: currentNetId ? aiMessagesByNetId[currentNetId] : undefined, + onMessages: (messages: PetrinautAiMessage[]) => { + if (!currentNetId) { + return; + } + + setAiMessagesByNetId((prev) => ({ + ...prev, + [currentNetId]: messages, + })); + }, + onClearMessages: () => { + if (!currentNetId) { + return; + } + + setAiMessagesByNetId((prev) => { + const next = { ...prev }; + delete next[currentNetId]; + return next; + }); + }, + }), + [aiMessagesByNetId, currentNetId, setAiMessagesByNetId], + ); + + if (!currentNet) { + return null; + } + + if (!activeHandle || activeHandle.netId !== currentNet.id) { + return null; + } + + return ( +
+ + + +
+ ); +}; diff --git a/apps/petrinaut-website/src/main/app/use-local-storage-ai-messages.ts b/apps/petrinaut-website/src/main/app/local-storage-demo/use-local-storage-ai-messages.ts similarity index 100% rename from apps/petrinaut-website/src/main/app/use-local-storage-ai-messages.ts rename to apps/petrinaut-website/src/main/app/local-storage-demo/use-local-storage-ai-messages.ts diff --git a/apps/petrinaut-website/src/main/app/use-local-storage-sdcpns.ts b/apps/petrinaut-website/src/main/app/local-storage-demo/use-local-storage-sdcpns.ts similarity index 100% rename from apps/petrinaut-website/src/main/app/use-local-storage-sdcpns.ts rename to apps/petrinaut-website/src/main/app/local-storage-demo/use-local-storage-sdcpns.ts diff --git a/apps/petrinaut-website/src/main/app/walkthrough/logo-mark.png b/apps/petrinaut-website/src/main/app/local-storage-demo/walkthrough/logo-mark.png similarity index 100% rename from apps/petrinaut-website/src/main/app/walkthrough/logo-mark.png rename to apps/petrinaut-website/src/main/app/local-storage-demo/walkthrough/logo-mark.png diff --git a/apps/petrinaut-website/src/main/app/walkthrough/videos/01-intro-example.mp4 b/apps/petrinaut-website/src/main/app/local-storage-demo/walkthrough/videos/01-intro-example.mp4 similarity index 100% rename from apps/petrinaut-website/src/main/app/walkthrough/videos/01-intro-example.mp4 rename to apps/petrinaut-website/src/main/app/local-storage-demo/walkthrough/videos/01-intro-example.mp4 diff --git a/apps/petrinaut-website/src/main/app/walkthrough/videos/02-experiments-example.mp4 b/apps/petrinaut-website/src/main/app/local-storage-demo/walkthrough/videos/02-experiments-example.mp4 similarity index 100% rename from apps/petrinaut-website/src/main/app/walkthrough/videos/02-experiments-example.mp4 rename to apps/petrinaut-website/src/main/app/local-storage-demo/walkthrough/videos/02-experiments-example.mp4 diff --git a/apps/petrinaut-website/src/main/app/walkthrough/videos/03-ai-example.mp4 b/apps/petrinaut-website/src/main/app/local-storage-demo/walkthrough/videos/03-ai-example.mp4 similarity index 100% rename from apps/petrinaut-website/src/main/app/walkthrough/videos/03-ai-example.mp4 rename to apps/petrinaut-website/src/main/app/local-storage-demo/walkthrough/videos/03-ai-example.mp4 diff --git a/apps/petrinaut-website/src/main/app/walkthrough/walkthrough-steps.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/walkthrough/walkthrough-steps.tsx similarity index 100% rename from apps/petrinaut-website/src/main/app/walkthrough/walkthrough-steps.tsx rename to apps/petrinaut-website/src/main/app/local-storage-demo/walkthrough/walkthrough-steps.tsx diff --git a/apps/petrinaut-website/vercel.json b/apps/petrinaut-website/vercel.json index 55c31b0694f..d38fb306fed 100644 --- a/apps/petrinaut-website/vercel.json +++ b/apps/petrinaut-website/vercel.json @@ -7,6 +7,12 @@ "installCommand": "./vercel-install.sh", "devCommand": "turbo run build && yarn preview", "outputDirectory": "./dist", + "rewrites": [ + { + "source": "/brunch", + "destination": "/" + } + ], "functions": { "api/chat.ts": { "maxDuration": 300 diff --git a/libs/@hashintel/petrinaut-core/src/actual-mode/README.md b/libs/@hashintel/petrinaut-core/src/actual-mode/README.md new file mode 100644 index 00000000000..27b501d83cb --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/README.md @@ -0,0 +1,72 @@ +# Actual Mode Core + +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. + +## Experimental Status + +This is not a stable Petrinaut protocol yet. + +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 +independently of how a host transports events: + +- the transition firing effect shape 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 +- the context value type shared with the React package + +## Current Brunch Flow + +The current demo path is: + +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 + transition firing effects. + +The currently accepted transition firing shape is: + +```json +{ + "transitionId": "start_implementation", + "input": { "queued": 1 }, + "output": { "implementing": 1 }, + "ts": "2026-06-05T17:17:27.866Z" +} +``` + +`input` and `output` are transition-local token count maps. They are not full +before/after markings. + +## File Map + +- `constants.ts`: shared Actual Mode constants. +- `types.ts`: transport-neutral Actual Mode types and context shape. +- `schemas.ts`: Zod schemas for core Actual Mode payloads and recordings. +- `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. +- `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 new file mode 100644 index 00000000000..1ecf1c2c256 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/actual-mode.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from "vitest"; + +import { + createActualModeReceivedEventsRecording, + createActualModeRecording, + createActualModeTimelineFrameReader, + parseActualModeRecording, + retimeActualModeRecordingForReplay, +} from "."; + +import type { SDCPN } from "../types/sdcpn"; + +const definition: SDCPN = { + places: [ + { + id: "queued", + name: "Queued", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], +}; + +describe("Actual mode recordings", () => { + it("parses exported recordings", () => { + const recording = createActualModeRecording({ + title: "Replay", + source: { + kind: "brunch", + endpoint: "http://127.0.0.1:5184/stream", + }, + definition, + initialState: { queued: 1 }, + transitionFirings: [ + { + transitionId: "start", + input: { queued: 1 }, + output: {}, + ts: "2026-06-05T10:00:00.000Z", + }, + ], + exportedAt: "2026-06-05T10:01:00.000Z", + }); + + expect(parseActualModeRecording(recording)).toEqual(recording); + }); + + it("exports raw received events without mapping to SDCPN", () => { + const rawDefinition = { + title: "Raw Brunch run", + places: [{ id: "queued", name: "Queued" }], + transitions: [], + }; + + const recording = createActualModeReceivedEventsRecording({ + title: "Replay", + source: null, + events: [{ event: "definition", data: rawDefinition }], + exportedAt: "2026-06-05T10:01:00.000Z", + }); + + expect(recording).toEqual({ + version: 1, + exportedAt: "2026-06-05T10:01:00.000Z", + title: "Replay", + source: null, + events: [{ event: "definition", data: rawDefinition }], + }); + }); + + it("retimes transition firings relative to the first event", () => { + const recording = createActualModeRecording({ + title: "Replay", + source: null, + definition, + initialState: { queued: 2 }, + transitionFirings: [ + { + transitionId: "first", + input: { queued: 1 }, + output: {}, + ts: "2026-06-05T10:00:00.000Z", + }, + { + transitionId: "second", + input: { queued: 1 }, + output: {}, + ts: "2026-06-05T10:00:03.250Z", + }, + ], + }); + + const retimed = retimeActualModeRecordingForReplay( + recording, + Date.parse("2026-06-05T12:00:00.000Z"), + ); + + expect(retimed.transitionFirings.map((firing) => firing.ts)).toEqual([ + "2026-06-05T12:00:00.000Z", + "2026-06-05T12:00:03.250Z", + ]); + }); + + it("rejects transition firings with extra fields", () => { + expect(() => + parseActualModeRecording({ + version: 1, + exportedAt: "2026-06-05T10:01:00.000Z", + title: "Replay", + source: null, + definition, + initialState: { queued: 1, done: 0 }, + transitionFirings: [ + { + transitionId: "finish", + input: { queued: 1 }, + output: { done: 1 }, + unsupported: { done: 1 }, + ts: "2026-06-05T10:00:00.000Z", + }, + ], + }), + ).toThrow(); + }); + + it("rejects transition firings with non-count effect values", () => { + expect(() => + parseActualModeRecording({ + version: 1, + exportedAt: "2026-06-05T10:01:00.000Z", + title: "Replay", + source: null, + definition, + initialState: { queued: 1, done: 0 }, + transitionFirings: [ + { + transitionId: "finish", + input: { queued: 1 }, + output: { done: [{}] }, + ts: "2026-06-05T10:00:00.000Z", + }, + ], + }), + ).toThrow(); + }); + + it("reconstructs timeline markings from firing effects", () => { + const reader = createActualModeTimelineFrameReader({ + definition: { + ...definition, + places: [ + ...definition.places, + { + id: "done", + name: "Done", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 100, + y: 0, + }, + ], + }, + initialState: { queued: 2, done: 0 }, + transitionFirings: [ + { + transitionId: "finish", + input: { queued: 1 }, + output: { done: 1 }, + ts: "2026-06-05T10:00:00.000Z", + }, + ], + transitionFiringTimesMs: [0], + point: { + kind: "transition_firing", + timeMs: 0, + transitionFiringIndex: 0, + }, + number: 1, + }); + + expect(reader.toFrameState().places).toEqual({ + queued: { tokenCount: 1 }, + done: { tokenCount: 1 }, + }); + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/actual-mode/constants.ts b/libs/@hashintel/petrinaut-core/src/actual-mode/constants.ts new file mode 100644 index 00000000000..8827add9c1b --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/constants.ts @@ -0,0 +1,2 @@ +export const ACTUAL_MODE_TIMELINE_TICK_MS = 500; +export const ACTUAL_MODE_RECORDING_VERSION = 1; diff --git a/libs/@hashintel/petrinaut-core/src/actual-mode/context.ts b/libs/@hashintel/petrinaut-core/src/actual-mode/context.ts new file mode 100644 index 00000000000..0de3543a69f --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/context.ts @@ -0,0 +1,15 @@ +import type { ActualModeContextValue } from "./types"; + +export const unavailableActualMode: ActualModeContextValue = { + available: false, + source: null, + status: "unavailable", + title: null, + 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 new file mode 100644 index 00000000000..4f4719805da --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/index.ts @@ -0,0 +1,42 @@ +export { + ACTUAL_MODE_RECORDING_VERSION, + ACTUAL_MODE_TIMELINE_TICK_MS, +} from "./constants"; +export { unavailableActualMode } from "./context"; +export { + applyActualModeTransitionFiring, + getActualModeMarkingAtTransitionFiringIndex, +} from "./marking"; +export { + createActualModeReceivedEventsRecording, + createActualModeRecording, + parseActualModeRecording, + retimeActualModeRecordingForReplay, +} from "./recording"; +export { + actualModeMarkingSchema, + actualModeReceivedEventSchema, + actualModeReceivedEventsRecordingSchema, + actualModeRecordingSchema, + actualModeSourceSchema, + actualModeTransitionEffectSchema, + actualModeTransitionFiringSchema, +} from "./schemas"; +export { + buildActualModeTimelinePoints, + createActualModeTimelineFrameReader, + getActualModeTransitionFiringTimesMs, +} from "./timeline"; +export type { + ActualModeContextValue, + ActualModeMarking, + ActualModeReceivedEvent, + ActualModeReceivedEventsRecording, + ActualModeRecording, + ActualModeSource, + ActualModeTimelinePoint, + ActualModeTimelinePointKind, + ActualModeTokenColour, + ActualModeTransitionEffect, + ActualModeTransitionFiring, +} from "./types"; diff --git a/libs/@hashintel/petrinaut-core/src/actual-mode/marking.ts b/libs/@hashintel/petrinaut-core/src/actual-mode/marking.ts new file mode 100644 index 00000000000..9fbc288c42c --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/marking.ts @@ -0,0 +1,118 @@ +import type { + ActualModeMarking, + ActualModeTokenColour, + ActualModeTransitionFiring, +} from "./types"; + +export const isActualModeTokenColourArray = ( + markingValue: number | ActualModeTokenColour[] | undefined, +): markingValue is ActualModeTokenColour[] => Array.isArray(markingValue); + +export const getActualModePlaceMarkingTokenCount = ( + markingValue: number | ActualModeTokenColour[] | undefined, +): number => { + if (markingValue === undefined) { + return 0; + } + + return isActualModeTokenColourArray(markingValue) + ? markingValue.length + : markingValue; +}; + +const cloneTokenColour = ( + token: ActualModeTokenColour, +): ActualModeTokenColour => ({ ...token }); + +const cloneMarkingValue = ( + markingValue: number | ActualModeTokenColour[], +): number | ActualModeTokenColour[] => + Array.isArray(markingValue) + ? markingValue.map((token) => cloneTokenColour(token)) + : markingValue; + +const cloneMarking = (marking: ActualModeMarking): ActualModeMarking => + Object.fromEntries( + Object.entries(marking).map(([placeId, value]) => [ + placeId, + cloneMarkingValue(value), + ]), + ); + +const emptyTokens = (count: number): ActualModeTokenColour[] => + Array.from({ length: Math.max(0, Math.floor(count)) }, () => ({})); + +const toTokenArray = ( + markingValue: number | ActualModeTokenColour[] | undefined, +): ActualModeTokenColour[] => { + if (markingValue === undefined) { + return []; + } + + return Array.isArray(markingValue) + ? markingValue.map((token) => cloneTokenColour(token)) + : emptyTokens(markingValue); +}; + +export const applyActualModeTransitionFiring = ( + marking: ActualModeMarking, + firing: ActualModeTransitionFiring, +): ActualModeMarking => { + const next = cloneMarking(marking); + const placeIds = new Set([ + ...Object.keys(next), + ...Object.keys(firing.input), + ...Object.keys(firing.output), + ]); + + for (const placeId of placeIds) { + const currentValue = next[placeId]; + const inputValue = firing.input[placeId]; + const outputValue = firing.output[placeId]; + + if ( + Array.isArray(currentValue) || + Array.isArray(inputValue) || + Array.isArray(outputValue) + ) { + const currentTokens = toTokenArray(currentValue); + const inputCount = getActualModePlaceMarkingTokenCount(inputValue); + const outputTokens = toTokenArray(outputValue); + next[placeId] = currentTokens.slice(inputCount).concat(outputTokens); + continue; + } + + next[placeId] = + (currentValue ?? 0) - (inputValue ?? 0) + (outputValue ?? 0); + } + + return next; +}; + +export const getActualModeMarkingAtTransitionFiringIndex = (params: { + initialState: ActualModeMarking; + transitionFirings: readonly ActualModeTransitionFiring[]; + transitionFiringIndex: number | null; +}): ActualModeMarking => { + const { initialState, transitionFiringIndex, transitionFirings } = params; + + if (transitionFiringIndex === null) { + return initialState; + } + + 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 + // incremental timeline reader so scrubbing does not become O(n^2). + for (let index = 0; index <= transitionFiringIndex; index += 1) { + const firing = transitionFirings[index]; + + if (firing) { + marking = applyActualModeTransitionFiring(marking, firing); + } + } + + return marking; +}; diff --git a/libs/@hashintel/petrinaut-core/src/actual-mode/recording.ts b/libs/@hashintel/petrinaut-core/src/actual-mode/recording.ts new file mode 100644 index 00000000000..1b22eb5a8ac --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/recording.ts @@ -0,0 +1,73 @@ +import { ACTUAL_MODE_RECORDING_VERSION } from "./constants"; +import { actualModeRecordingSchema } from "./schemas"; +import { parseRequiredActualModeTimestampMs } from "./time"; + +import type { SDCPN } from "../types/sdcpn"; +import type { + ActualModeMarking, + ActualModeReceivedEvent, + ActualModeReceivedEventsRecording, + ActualModeRecording, + ActualModeSource, + ActualModeTransitionFiring, +} from "./types"; + +export const createActualModeRecording = (params: { + title: string | null; + source: ActualModeSource | null; + definition: SDCPN; + initialState: ActualModeMarking; + transitionFirings: readonly ActualModeTransitionFiring[]; + exportedAt?: string; +}): ActualModeRecording => ({ + version: ACTUAL_MODE_RECORDING_VERSION, + exportedAt: params.exportedAt ?? new Date().toISOString(), + title: params.title, + source: params.source, + definition: params.definition, + initialState: params.initialState, + 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 => + actualModeRecordingSchema.parse(data); + +export const retimeActualModeRecordingForReplay = ( + recording: ActualModeRecording, + launchTimeMs = Date.now(), +): ActualModeRecording => { + const firstFiring = recording.transitionFirings[0]; + + if (!firstFiring) { + return { ...recording, transitionFirings: [] }; + } + + const firstTimestampMs = parseRequiredActualModeTimestampMs(firstFiring.ts); + const deltaMs = launchTimeMs - firstTimestampMs; + + return { + ...recording, + transitionFirings: recording.transitionFirings.map((firing) => ({ + ...firing, + ts: new Date( + parseRequiredActualModeTimestampMs(firing.ts) + deltaMs, + ).toISOString(), + })), + }; +}; diff --git a/libs/@hashintel/petrinaut-core/src/actual-mode/schemas.ts b/libs/@hashintel/petrinaut-core/src/actual-mode/schemas.ts new file mode 100644 index 00000000000..e08178022c3 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/schemas.ts @@ -0,0 +1,107 @@ +import { z } from "zod"; + +import { sdcpnSchema } from "../file-format/types"; +import { ACTUAL_MODE_RECORDING_VERSION } from "./constants"; + +import type { SDCPN } from "../types/sdcpn"; +import type { + ActualModeMarking, + ActualModeReceivedEvent, + ActualModeReceivedEventsRecording, + ActualModeRecording, + ActualModeSource, + ActualModeTransitionEffect, + ActualModeTransitionFiring, +} from "./types"; + +const actualModeTokenColourSchema = z.record(z.string(), z.number()); +const actualModeMarkingValueSchema = z.union([ + z.number(), + z.array(actualModeTokenColourSchema), +]); + +/** + * Root schema for an Actual Mode marking. + * + * This validates `initial_state` stream frames and recording snapshots. Places + * can currently be represented by a numeric token count or by token-colour + * arrays for future coloured-token support. + */ +export const actualModeMarkingSchema = z.record( + z.string(), + actualModeMarkingValueSchema, +) satisfies z.ZodType; + +/** + * Root schema for a transition-local token effect. + * + * This is intentionally not a full marking: keys are only the places affected + * by a transition, and values are the token counts consumed or produced there. + */ +export const actualModeTransitionEffectSchema = z.record( + z.string(), + z.number(), +) satisfies z.ZodType; + +const actualModeTransitionFiringEffectSchema = z + .object({ + transitionId: z.string(), + input: actualModeTransitionEffectSchema, + output: actualModeTransitionEffectSchema, + ts: z.string(), + }) + .strict(); + +/** + * Root schema for Actual Mode transition events. + * + * This is the only accepted `transition_firing` payload shape for this PR: + * `input` contains consumed token counts, `output` contains produced token + * counts, and neither field carries a full before or after marking. + */ +export const actualModeTransitionFiringSchema = + actualModeTransitionFiringEffectSchema satisfies z.ZodType; + +export const actualModeSourceSchema = z + .object({ + kind: z.literal("brunch"), + endpoint: z.string(), + runId: 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" }, +); + +/** + * Root schema for exported Actual Mode replay recordings. + * + * A recording combines the normalized SDCPN, initial marking, source metadata, + * and ordered transition events needed to reconstruct the timeline offline. + */ +export const actualModeRecordingSchema = z.object({ + version: z.literal(ACTUAL_MODE_RECORDING_VERSION), + exportedAt: z.string(), + title: z.string().nullable(), + source: actualModeSourceSchema.nullable(), + definition: actualModeRecordingDefinitionSchema, + 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/time.ts b/libs/@hashintel/petrinaut-core/src/actual-mode/time.ts new file mode 100644 index 00000000000..3950e8632d1 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/time.ts @@ -0,0 +1,19 @@ +export const parseActualModeTimestampMs = ( + timestamp: string, +): number | null => { + const parsed = Date.parse(timestamp); + + return Number.isFinite(parsed) ? parsed : null; +}; + +export const parseRequiredActualModeTimestampMs = ( + timestamp: string, +): number => { + const timestampMs = parseActualModeTimestampMs(timestamp); + + if (timestampMs === null) { + throw new Error(`Invalid Actual mode event timestamp: ${timestamp}`); + } + + return timestampMs; +}; diff --git a/libs/@hashintel/petrinaut-core/src/actual-mode/timeline.ts b/libs/@hashintel/petrinaut-core/src/actual-mode/timeline.ts new file mode 100644 index 00000000000..9d3e5627c6e --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/timeline.ts @@ -0,0 +1,285 @@ +import { ACTUAL_MODE_TIMELINE_TICK_MS } from "./constants"; +import { + getActualModeMarkingAtTransitionFiringIndex, + getActualModePlaceMarkingTokenCount, + isActualModeTokenColourArray, +} from "./marking"; +import { parseActualModeTimestampMs } from "./time"; + +import type { + SimulationFrameReader, + SimulationFrameState, + SimulationPlaceTokenValues, +} from "../simulation/api"; +import type { Color, Place, SDCPN } from "../types/sdcpn"; +import type { + ActualModeContextValue, + ActualModeMarking, + ActualModeTimelinePoint, + ActualModeTimelinePointKind, + ActualModeTransitionFiring, +} from "./types"; + +const getTimelineBaselineMs = ( + transitionFirings: readonly ActualModeTransitionFiring[], + timelineStartedAtMs: number | null, + timelineNowMs: number | null, +): number => { + for (const firing of transitionFirings) { + const timestampMs = parseActualModeTimestampMs(firing.ts); + + if (timestampMs !== null) { + return timestampMs; + } + } + + return timelineStartedAtMs ?? timelineNowMs ?? 0; +}; + +export const getActualModeTransitionFiringTimesMs = ( + transitionFirings: readonly ActualModeTransitionFiring[], + timelineStartedAtMs: number | null, + timelineNowMs: number | null, +): readonly number[] => { + const baselineMs = getTimelineBaselineMs( + transitionFirings, + timelineStartedAtMs, + timelineNowMs, + ); + const times: number[] = []; + + for (const firing of transitionFirings) { + const timestampMs = parseActualModeTimestampMs(firing.ts); + const previousTimeMs = times.at(-1) ?? 0; + const nextTimeMs = + timestampMs === null + ? previousTimeMs + 1 + : Math.max(previousTimeMs, timestampMs - baselineMs); + + times.push(nextTimeMs); + } + + return times; +}; + +export const buildActualModeTimelinePoints = (params: { + status: ActualModeContextValue["status"]; + transitionFirings: readonly ActualModeTransitionFiring[]; + timelineStartedAtMs: number | null; + timelineNowMs: number | null; +}): readonly ActualModeTimelinePoint[] => { + const { status, transitionFirings, timelineStartedAtMs, timelineNowMs } = + params; + const transitionFiringTimesMs = getActualModeTransitionFiringTimesMs( + transitionFirings, + timelineStartedAtMs, + timelineNowMs, + ); + const points: ActualModeTimelinePoint[] = [ + { kind: "initial", timeMs: 0, transitionFiringIndex: null }, + ]; + + for (const [ + transitionFiringIndex, + timeMs, + ] of transitionFiringTimesMs.entries()) { + points.push({ + kind: "transition_firing", + timeMs, + transitionFiringIndex, + }); + } + + if (status === "streaming") { + const baselineMs = getTimelineBaselineMs( + transitionFirings, + timelineStartedAtMs, + timelineNowMs, + ); + const nowTimeMs = + timelineNowMs !== null ? Math.max(0, timelineNowMs - baselineMs) : 0; + const latestEventTimeMs = transitionFiringTimesMs.at(-1) ?? 0; + const liveTimeMs = Math.max(nowTimeMs, latestEventTimeMs); + const occupiedTimes = new Set(points.map((point) => point.timeMs)); + let latestTransitionFiringIndex = -1; + + for ( + let timeMs = ACTUAL_MODE_TIMELINE_TICK_MS; + timeMs <= liveTimeMs; + timeMs += ACTUAL_MODE_TIMELINE_TICK_MS + ) { + let nextTransitionFiringIndex = latestTransitionFiringIndex + 1; + + while ( + nextTransitionFiringIndex < transitionFiringTimesMs.length && + transitionFiringTimesMs[nextTransitionFiringIndex]! <= timeMs + ) { + latestTransitionFiringIndex = nextTransitionFiringIndex; + nextTransitionFiringIndex = latestTransitionFiringIndex + 1; + } + + if (occupiedTimes.has(timeMs)) { + continue; + } + + points.push({ + kind: "tick", + timeMs, + transitionFiringIndex: + latestTransitionFiringIndex >= 0 ? latestTransitionFiringIndex : null, + }); + } + } + + return points.sort((left, right) => { + if (left.timeMs !== right.timeMs) { + return left.timeMs - right.timeMs; + } + + const order: Record = { + initial: 0, + transition_firing: 1, + tick: 2, + }; + + return order[left.kind] - order[right.kind]; + }); +}; + +const getTransitionFiringCount = ( + transitionFirings: readonly ActualModeTransitionFiring[], + transitionId: string, + transitionFiringIndex: number | null, +): number => { + if (transitionFiringIndex === null) { + return 0; + } + + let firingCount = 0; + + for (let index = 0; index <= transitionFiringIndex; index += 1) { + if (transitionFirings[index]?.transitionId === transitionId) { + firingCount += 1; + } + } + + return firingCount; +}; + +export const createActualModeTimelineFrameReader = (params: { + definition: Pick; + initialState: ActualModeMarking; + transitionFirings: readonly ActualModeTransitionFiring[]; + transitionFiringTimesMs: readonly number[]; + point: ActualModeTimelinePoint; + number: number; +}): SimulationFrameReader => { + const { + definition, + initialState, + number, + point, + transitionFirings, + transitionFiringTimesMs, + } = params; + const marking = getActualModeMarkingAtTransitionFiringIndex({ + initialState, + transitionFirings, + transitionFiringIndex: point.transitionFiringIndex, + }); + + return { + number, + time: point.timeMs / 1_000, + getPlaceTokenCount: (placeId: string) => + getActualModePlaceMarkingTokenCount(marking[placeId]), + getPlaceTokenValues: ( + placeId: string, + ): SimulationPlaceTokenValues | null => { + const place = definition.places.find( + (candidatePlace) => candidatePlace.id === placeId, + ); + const color = place + ? definition.types.find((type) => type.id === place.colorId) + : null; + const placeMarking = marking[placeId]; + + if (!place || !color || !isActualModeTokenColourArray(placeMarking)) { + return { + count: getActualModePlaceMarkingTokenCount(placeMarking), + values: new Float64Array(), + }; + } + + const values = new Float64Array( + placeMarking.length * color.elements.length, + ); + + for (const [tokenIndex, token] of placeMarking.entries()) { + for (const [elementIndex, element] of color.elements.entries()) { + values[tokenIndex * color.elements.length + elementIndex] = + token[element.name] ?? token[element.elementId] ?? 0; + } + } + + return { + count: placeMarking.length, + values, + }; + }, + getPlaceTokens: ( + place: Place, + color: Color | null | undefined, + ): Record[] => { + const placeMarking = marking[place.id]; + + if (!color || !isActualModeTokenColourArray(placeMarking)) { + return []; + } + + return placeMarking.map((token) => ({ ...token })); + }, + getTransitionState: (transitionId: string) => { + const firedInThisFrame = + point.kind === "transition_firing" && + point.transitionFiringIndex !== null && + transitionFirings[point.transitionFiringIndex]?.transitionId === + transitionId; + const firingCount = getTransitionFiringCount( + transitionFirings, + transitionId, + point.transitionFiringIndex, + ); + let lastFiringTimeMs: number | null = null; + + if (point.transitionFiringIndex !== null) { + for (let index = point.transitionFiringIndex; index >= 0; index -= 1) { + if (transitionFirings[index]?.transitionId === transitionId) { + lastFiringTimeMs = transitionFiringTimesMs[index] ?? null; + break; + } + } + } + + return { + firedInThisFrame, + firingCount, + timeSinceLastFiringMs: + lastFiringTimeMs === null + ? point.timeMs + : Math.max(0, point.timeMs - lastFiringTimeMs), + }; + }, + toFrameState: (): SimulationFrameState => ({ + number, + places: Object.fromEntries( + definition.places.map((place) => [ + place.id, + { + tokenCount: getActualModePlaceMarkingTokenCount(marking[place.id]), + }, + ]), + ), + }), + }; +}; diff --git a/libs/@hashintel/petrinaut-core/src/actual-mode/types.ts b/libs/@hashintel/petrinaut-core/src/actual-mode/types.ts new file mode 100644 index 00000000000..b9f63ba55f7 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/types.ts @@ -0,0 +1,94 @@ +import { ACTUAL_MODE_RECORDING_VERSION } from "./constants"; + +import type { SDCPN } from "../types/sdcpn"; + +/** + * Host-provided live execution state for Petrinaut's Actual mode. + * + * Core owns the transport-neutral execution primitives. React packages provide + * the concrete context/provider surface for UI consumption. + */ + +export type ActualModeTokenColour = Record; + +export type ActualModeMarking = Record< + string, + number | ActualModeTokenColour[] +>; + +export type ActualModeTransitionEffect = Record; + +export type ActualModeTransitionFiring = { + transitionId: string; + input: ActualModeTransitionEffect; + output: ActualModeTransitionEffect; + ts: string; +}; + +export type ActualModeReceivedEvent = { + event: string; + data: unknown; +}; + +export type ActualModeSource = { + kind: "brunch"; + endpoint: string; + runId?: string; +}; + +export type ActualModeRecording = { + version: typeof ACTUAL_MODE_RECORDING_VERSION; + exportedAt: string; + title: string | null; + source: ActualModeSource | null; + definition: SDCPN; + initialState: ActualModeMarking; + 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; + source: null; + status: "unavailable"; + title: null; + definition: null; + initialState: null; + transitionFirings: readonly []; + receivedEvents: readonly []; + timelineStartedAtMs: null; + timelineNowMs: null; + error: null; + } + | { + available: true; + source: ActualModeSource; + status: "loading" | "streaming" | "complete" | "error"; + title: string | null; + definition: SDCPN | null; + initialState: ActualModeMarking | null; + transitionFirings: readonly ActualModeTransitionFiring[]; + receivedEvents: readonly ActualModeReceivedEvent[]; + timelineStartedAtMs: number | null; + timelineNowMs: number | null; + error: string | null; + }; + +export type ActualModeTimelinePointKind = + | "initial" + | "transition_firing" + | "tick"; + +export type ActualModeTimelinePoint = { + kind: ActualModeTimelinePointKind; + timeMs: number; + transitionFiringIndex: number | null; +}; diff --git a/libs/@hashintel/petrinaut-core/src/ai.ts b/libs/@hashintel/petrinaut-core/src/ai.ts index a22e92079d7..9e009fd684b 100644 --- a/libs/@hashintel/petrinaut-core/src/ai.ts +++ b/libs/@hashintel/petrinaut-core/src/ai.ts @@ -89,6 +89,7 @@ export const petrinautDocNames = [ "simulation", "scenarios", "experiments", + "actual-mode", "ai-assistant", "visual-settings", "examples", @@ -109,6 +110,8 @@ export const petrinautDocSummaries: Record = { "Named simulation configurations: scenario parameters, parameter bindings, per-place vs code-mode initial state, running and switching scenarios.", experiments: "Monte Carlo batches: configuration (runs, seed, dt, max time, scenario), lifecycle/statuses, cancel/remove, results (median/mean/p10/p90), active-experiments popover.", + "actual-mode": + "Actual mode: host-provided live execution view, Brunch stream URL route, read-only extension-free net, current limits.", "ai-assistant": "In-app AI assistant: opening the panel, conversation surface, prompt chips, tool cards, read-only/simulate-mode rules, host configuration.", "visual-settings": diff --git a/libs/@hashintel/petrinaut-core/src/file-format/types.ts b/libs/@hashintel/petrinaut-core/src/file-format/types.ts index 6c9e737c7b3..7f51c74e212 100644 --- a/libs/@hashintel/petrinaut-core/src/file-format/types.ts +++ b/libs/@hashintel/petrinaut-core/src/file-format/types.ts @@ -109,7 +109,7 @@ const metricSchema = z.object({ code: z.string().default(""), }); -const sdcpnSchema = z.object({ +export const sdcpnSchema = z.object({ places: z.array(placeSchema), transitions: z.array(transitionSchema), types: z.array(colorSchema).default([]), diff --git a/libs/@hashintel/petrinaut-core/src/index.ts b/libs/@hashintel/petrinaut-core/src/index.ts index 84d4e621e84..ff2de8ba260 100644 --- a/libs/@hashintel/petrinaut-core/src/index.ts +++ b/libs/@hashintel/petrinaut-core/src/index.ts @@ -4,6 +4,38 @@ // SDCPN documents, simulation, LSP, and playback. // --- Document --- +export { + ACTUAL_MODE_RECORDING_VERSION, + ACTUAL_MODE_TIMELINE_TICK_MS, + actualModeMarkingSchema, + actualModeRecordingSchema, + actualModeSourceSchema, + actualModeTransitionEffectSchema, + actualModeTransitionFiringSchema, + applyActualModeTransitionFiring, + buildActualModeTimelinePoints, + createActualModeRecording, + createActualModeReceivedEventsRecording, + createActualModeTimelineFrameReader, + getActualModeMarkingAtTransitionFiringIndex, + getActualModeTransitionFiringTimesMs, + parseActualModeRecording, + retimeActualModeRecordingForReplay, + unavailableActualMode, +} from "./actual-mode"; +export type { + ActualModeContextValue, + ActualModeMarking, + ActualModeReceivedEvent, + ActualModeReceivedEventsRecording, + ActualModeRecording, + ActualModeSource, + ActualModeTimelinePoint, + ActualModeTimelinePointKind, + ActualModeTokenColour, + ActualModeTransitionEffect, + ActualModeTransitionFiring, +} from "./actual-mode"; export { createJsonDocHandle, type CreateJsonDocHandleOptions, diff --git a/libs/@hashintel/petrinaut/docs/README.md b/libs/@hashintel/petrinaut/docs/README.md index cd99baa8c5b..0a441774d36 100644 --- a/libs/@hashintel/petrinaut/docs/README.md +++ b/libs/@hashintel/petrinaut/docs/README.md @@ -19,10 +19,11 @@ A quick map of the things you'll encounter: - **Metric** -- a built-in or user-authored function over simulation state that returns a number to plot on the Timeline. - **Experiment** -- a Monte Carlo batch: many independent simulation runs of the current net, optionally against one scenario, aggregated as distributions over time. -Petrinaut has two global modes you switch between in the top bar: +Petrinaut has three global modes in the top bar, though **Actual** is only enabled when the host application provides a live execution source: - **Edit** -- the drawing/configuration workspace plus single-run simulation playback. - **Simulate** -- a separate management surface for scenarios, metrics, and experiments. +- **Actual** -- a read-only live-execution view supplied by a host such as Brunch. ## Contents @@ -32,6 +33,7 @@ Petrinaut has two global modes you switch between in the top bar: - [Simulation](simulation.md) -- Set initial state, run a single simulation, use the timeline, control playback. - [Scenarios](scenarios.md) -- Save and switch between named simulation configurations. - [Experiments](experiments.md) -- Run Monte Carlo batches and inspect token-count distributions over time. +- [Actual Mode](actual-mode.md) -- View a host-provided live Petri net execution, currently via Brunch. - [AI Assistant](ai-assistant.md) -- Build, review, and revise nets using natural language. - [Visual Settings](visual-settings.md) -- Configure the editor appearance and behavior. - [Examples](examples.md) -- Walkthrough of the built-in example nets. diff --git a/libs/@hashintel/petrinaut/docs/actual-mode.md b/libs/@hashintel/petrinaut/docs/actual-mode.md new file mode 100644 index 00000000000..901f37c706e --- /dev/null +++ b/libs/@hashintel/petrinaut/docs/actual-mode.md @@ -0,0 +1,44 @@ +# 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. + +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. + +## Brunch live runs + +The demo website enables Actual mode on the `/brunch` route when the URL includes a Brunch stream endpoint: + +```text +/brunch?sse= +``` + +Petrinaut connects to the stream, waits for the Petri net definition and initial state, lays out the net if the stream did not include node positions, and then shows the net in Actual mode. + +If the stream connection is interrupted, Petrinaut keeps any loaded Actual mode data visible and waits for the browser to reconnect. Once the connection is restored, the Brunch stream replays the run from the beginning and Petrinaut rebuilds the timeline and events from that replay, so an interruption does not duplicate transition events or miss ones that fired while disconnected. If the stream sends invalid data, Petrinaut shows an error page with a link back to the normal demo site. + +## Timeline and events + +Actual mode opens the bottom panel with an Actual timeline once execution data is available. The timeline can be scrubbed to inspect the net state at earlier points in the received stream. Use the series selector below the chart to show, hide, or focus individual traces. Hover a trace and click the eye icon to hide it -- it stays in place, struck through, until the pointer leaves the selector, so a second click undoes the change -- or open the dropdown to search and use **Only**. + +The bottom panel also includes an **Events** tab. It shows the received transition stream in order, including each event timestamp, transition id, input tokens, and output tokens. Use the **Export** dropdown in this tab to download either the received event stream or the current Petri net. + +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. + +Choose **Export Net** to download a normal Petrinaut JSON net file. This file contains the read-only Petri net currently shown in Actual mode and can be imported back into Petrinaut like other net exports. + +For Brunch, the export is a JSON object with an `events` array. Each item stores the SSE event name and the parsed JSON payload exactly as Petrinaut received it. Transition payloads store the firing effect rather than a full before/after snapshot. The `input` and `output` fields are numeric count maps keyed by place id: + +```json +{ + "transitionId": "start_implementation", + "input": { "queued": 1 }, + "output": { "implementing": 1 }, + "ts": "2026-06-05T17:17:27.866Z" +} +``` + +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. diff --git a/libs/@hashintel/petrinaut/docs/drawing-a-net.md b/libs/@hashintel/petrinaut/docs/drawing-a-net.md index c9564e3d282..4566b9cb643 100644 --- a/libs/@hashintel/petrinaut/docs/drawing-a-net.md +++ b/libs/@hashintel/petrinaut/docs/drawing-a-net.md @@ -4,7 +4,7 @@ The editor is organized around a central canvas where you build your net: -- **Top bar** -- net management menu, optional title field, **Edit / Simulate** mode switcher, active-experiments indicator, recent-changes history. See [Top bar](#top-bar). +- **Top bar** -- net management menu, optional title field, **Edit / Simulate / Actual** mode switcher, active-experiments indicator, recent-changes history. See [Top bar](#top-bar). - **Canvas** (center) -- the main workspace where places and transitions are displayed and connected. - **Left sidebar** -- lists of entities organized into tabs: Nodes, Types, Differential Equations, Parameters. - **Properties panel** (right) -- opens when you select an entity, showing its configurable properties. @@ -25,7 +25,7 @@ Spans the full editor width and has three sections. **Center** -- **Edit / Simulate / Actual** mode switcher. See [Edit vs Simulate mode](#edit-vs-simulate-mode) below. +- **Edit / Simulate / Actual** mode switcher. See [Global modes](#global-modes) below. **Right** @@ -33,19 +33,21 @@ Spans the full editor width and has three sections. - **Recent changes** (clock icon) -- a dropdown listing your recent undo/redo checkpoints with timestamps. Click any entry to jump to that state. This is the same history you walk via Cmd/Ctrl+Z and Cmd/Ctrl+Shift+Z. - The host application may add additional buttons here (login, share, ...). -## Edit vs Simulate mode +## Global modes -Petrinaut has two global modes, switched via the centre control in the top bar. +Petrinaut global modes are switched via the centre control in the top bar. | Mode | Workspace | | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Edit** | Canvas + left sidebar + properties panel + bottom panel + bottom toolbar (with AI assistant). This is where you draw the net, configure entities, write code, and run single simulations. | | **Simulate** | Replaces the workspace with the [Scenarios](scenarios.md) and [Experiments](experiments.md) management views. | -| **Actual** | Reserved for a future live-data mode. | +| **Actual** | Shows a host-provided live execution source. It is disabled unless the host provides Actual-mode data. See [Actual Mode](actual-mode.md). | In Simulate mode the net structure becomes read-only -- you can still create, edit, and delete scenarios and metrics, but you cannot change places, transitions, arcs, types, or parameters. Switch back to Edit mode to modify the net. -Switching modes does not stop background experiments. The active-experiments indicator remains visible in the top bar from either mode. +In Actual mode the net is also read-only. It shows the Petri net supplied by the live source, with an Actual timeline and Events tab in the bottom panel when execution data is available. + +Switching modes does not stop background experiments. The active-experiments indicator remains visible in the top bar from any mode. ## Adding places and transitions diff --git a/libs/@hashintel/petrinaut/docs/experiments.md b/libs/@hashintel/petrinaut/docs/experiments.md index ef6f7f8693f..3012b1eacb0 100644 --- a/libs/@hashintel/petrinaut/docs/experiments.md +++ b/libs/@hashintel/petrinaut/docs/experiments.md @@ -2,7 +2,7 @@ An **experiment** is a Monte Carlo batch: many independent simulation runs of the current net, all running the same scenario (or no scenario), with results aggregated as distributions of token counts over simulation time. Use experiments when one run isn't enough -- when the model is stochastic and you want to see the spread, not just one trajectory. -Experiments live under the **Simulate** [global mode](drawing-a-net.md#edit-vs-simulate-mode). Open the Simulate sidebar and choose **Experiments**. +Experiments live under the **Simulate** [global mode](drawing-a-net.md#global-modes). Open the Simulate sidebar and choose **Experiments**. ## Creating an experiment diff --git a/libs/@hashintel/petrinaut/docs/scenarios.md b/libs/@hashintel/petrinaut/docs/scenarios.md index efb6915ae34..bf3ab6d546c 100644 --- a/libs/@hashintel/petrinaut/docs/scenarios.md +++ b/libs/@hashintel/petrinaut/docs/scenarios.md @@ -2,7 +2,7 @@ A **scenario** is a saved, named configuration for running the current net: a set of initial token values, optional scenario-only parameters, and overrides for net-level parameters. Scenarios make it easy to compare several "what if" setups without editing the net itself. -Scenarios live under the **Simulate** [global mode](drawing-a-net.md#edit-vs-simulate-mode). To open the scenarios list, switch the mode selector in the top bar from **Edit** to **Simulate**, then choose the **Scenarios** tab in the Simulate sidebar. +Scenarios live under the **Simulate** [global mode](drawing-a-net.md#global-modes). To open the scenarios list, switch the mode selector in the top bar from **Edit** to **Simulate**, then choose the **Scenarios** tab in the Simulate sidebar. ## What a scenario contains diff --git a/libs/@hashintel/petrinaut/docs/simulation.md b/libs/@hashintel/petrinaut/docs/simulation.md index dfcc27bd2eb..c85e9b24fa1 100644 --- a/libs/@hashintel/petrinaut/docs/simulation.md +++ b/libs/@hashintel/petrinaut/docs/simulation.md @@ -129,7 +129,7 @@ The **Timeline** tab appears in the bottom panel during and after simulation. It - **Chart type** -- toggle between **Run** (line chart) and **Stacked** (area chart) using the control in the tab header. - **Scrub** -- click or drag on the chart to jump to any frame. A playhead indicator shows the current position. -- **Legend** -- click place names to show/hide individual traces. Hover to dim other traces. Y axis is automatically scaled to the maximum value. +- **Series selector** -- the strip below the chart lists the traces currently shown. Hover a trace and click the eye icon that replaces its colour swatch to hide it; the trace stays in place, struck through, until the pointer leaves the selector, so you can hide several traces in a row or click again to undo a change. Hidden traces are managed from the dropdown: a **+N more** chip opens the full list when there are more traces than fit, and clicking anywhere else in the selector opens it for searching. The badge shows how many traces are currently shown, and your selection is kept while you switch tabs. Use **Select All** or **Unselect All** for bulk changes, or choose **Only** on a trace row to focus the chart on that one series. Y axis is automatically scaled to the maximum value. ## Viewing state during simulation diff --git a/libs/@hashintel/petrinaut/src/react/actual-mode-context.ts b/libs/@hashintel/petrinaut/src/react/actual-mode-context.ts new file mode 100644 index 00000000000..7ea93674377 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/actual-mode-context.ts @@ -0,0 +1,11 @@ +import { createContext } from "react"; + +import { unavailableActualMode } from "@hashintel/petrinaut-core"; + +import type { ActualModeContextValue } from "@hashintel/petrinaut-core"; + +export const ActualModeContext = createContext( + unavailableActualMode, +); + +export type { ActualModeContextValue }; diff --git a/libs/@hashintel/petrinaut/src/react/execution-frame/context.ts b/libs/@hashintel/petrinaut/src/react/execution-frame/context.ts new file mode 100644 index 00000000000..e21de7eb54f --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/execution-frame/context.ts @@ -0,0 +1,69 @@ +import { createContext } from "react"; + +import type { + SimulationFrameReader, + SimulationFrameState, +} from "@hashintel/petrinaut-core"; + +/** + * A mode-agnostic source of execution frames. + * + * Petrinaut can show an execution coming from different backends: local + * simulation playback, or a host-provided Actual mode stream. Frame consumers + * (place nodes, visualizers, the timeline chart) read this interface instead + * of a backend-specific context, so they do not need to know which backend is + * active. The {@link ExecutionFrameProvider} in `./provider.tsx` decides which + * adapter feeds the context based on the editor's global mode. + */ +export type ExecutionFrameSource = { + /** + * Identity of the underlying run and time baseline. Consumers that + * accumulate state derived from frames (e.g. the timeline streaming store) + * reset their accumulation when this changes. + */ + sourceId: string; + + /** Total number of frames currently available. */ + totalFrames: number; + + /** Index of the currently viewed frame. */ + currentFrameIndex: number; + + /** Reader for the currently viewed frame; null when no frames exist. */ + currentFrameReader: SimulationFrameReader | null; + + /** + * Simplified state of the currently viewed frame (place token counts); + * null when no frames exist. + */ + currentViewedFrame: SimulationFrameState | null; + + /** + * Move the viewed frame, e.g. from timeline scrubbing. Implementations + * clamp the index to the available range. + */ + scrubToFrame: (frameIndex: number) => void; + + /** + * Read frame readers from `startIndex` (inclusive) to `endIndex` + * (exclusive, defaults to the end of the available frames). + */ + getFramesInRange: ( + startIndex: number, + endIndex?: number, + ) => Promise; +}; + +export const emptyExecutionFrameSource: ExecutionFrameSource = { + sourceId: "none", + totalFrames: 0, + currentFrameIndex: 0, + currentFrameReader: null, + currentViewedFrame: null, + scrubToFrame: () => {}, + getFramesInRange: () => Promise.resolve([]), +}; + +export const ExecutionFrameSourceContext = createContext( + emptyExecutionFrameSource, +); diff --git a/libs/@hashintel/petrinaut/src/react/execution-frame/provider.test.tsx b/libs/@hashintel/petrinaut/src/react/execution-frame/provider.test.tsx new file mode 100644 index 00000000000..237e6878cde --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/execution-frame/provider.test.tsx @@ -0,0 +1,174 @@ +/** + * @vitest-environment jsdom + */ +import { act, render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { DEFAULT_PETRINAUT_EXTENSIONS } from "@hashintel/petrinaut-core"; + +import { ActualModeContext } from "../actual-mode-context"; +import { SDCPNContext, type SDCPNContextValue } from "../state/sdcpn-context"; +import { useActualExecutionFrameSource } from "./provider"; + +import type { ExecutionFrameSource } from "./context"; +import type { ActualModeContextValue, SDCPN } from "@hashintel/petrinaut-core"; + +const definition: SDCPN = { + places: [ + { + id: "queued", + name: "Queued", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + { + id: "done", + name: "Done", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 100, + y: 0, + }, + ], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], +}; + +const sdcpnContextValue: SDCPNContextValue = { + createNewNet: () => {}, + existingNets: [], + loadPetriNet: () => {}, + petriNetId: "actual-test", + petriNetDefinition: definition, + readonly: true, + extensions: DEFAULT_PETRINAUT_EXTENSIONS, + setTitle: () => {}, + title: "Actual test", + getItemType: () => null, +}; + +const availableActualMode: ActualModeContextValue = { + available: true, + source: { + kind: "brunch", + endpoint: "http://127.0.0.1:5184/stream", + runId: "run-1", + }, + status: "complete", + title: "Run", + definition, + initialState: { queued: 2, done: 0 }, + transitionFirings: [ + { + transitionId: "finish", + input: { queued: 1 }, + output: { done: 1 }, + 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, +}; + +/** + * A test component that runs the adapter hook and exposes its value. + */ +const ActualSourceProbe = ({ + enabled, + onSource, +}: { + enabled: boolean; + onSource: (source: ExecutionFrameSource) => void; +}) => { + const source = useActualExecutionFrameSource({ enabled }); + // Call the callback during render to capture the value + onSource(source); + return null; +}; + +const renderActualSource = (params: { + actualMode: ActualModeContextValue; + enabled: boolean; +}) => { + // Use an object to hold the value so we can mutate it from the callback + const captured: { source: ExecutionFrameSource | null } = { source: null }; + const captureSource = (source: ExecutionFrameSource) => { + captured.source = source; + }; + + render( + + + + + , + ); + + return captured; +}; + +describe("useActualExecutionFrameSource", () => { + it("returns an empty source while disabled", () => { + const captured = renderActualSource({ + actualMode: availableActualMode, + enabled: false, + }); + + expect(captured.source?.totalFrames).toBe(0); + expect(captured.source?.currentFrameReader).toBeNull(); + }); + + it("derives frames from the initial state and transition firings", async () => { + const captured = renderActualSource({ + actualMode: availableActualMode, + enabled: true, + }); + + // One initial point plus one transition firing; no live ticks because the + // stream is complete. + expect(captured.source?.totalFrames).toBe(2); + expect(captured.source?.currentFrameIndex).toBe(0); + expect(captured.source?.currentViewedFrame?.places).toEqual({ + queued: { tokenCount: 2 }, + done: { tokenCount: 0 }, + }); + + const frames = (await captured.source?.getFramesInRange(0)) ?? []; + expect(frames.map((frame) => frame.number)).toEqual([0, 1]); + expect(frames[1]?.toFrameState().places).toEqual({ + queued: { tokenCount: 1 }, + done: { tokenCount: 1 }, + }); + }); + + it("clamps scrubbing to the available frame range", () => { + const captured = renderActualSource({ + actualMode: availableActualMode, + enabled: true, + }); + + act(() => { + captured.source?.scrubToFrame(5); + }); + + expect(captured.source?.currentFrameIndex).toBe(1); + expect(captured.source?.currentViewedFrame?.places).toEqual({ + queued: { tokenCount: 1 }, + done: { tokenCount: 1 }, + }); + + act(() => { + captured.source?.scrubToFrame(-3); + }); + + expect(captured.source?.currentFrameIndex).toBe(0); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/react/execution-frame/provider.tsx b/libs/@hashintel/petrinaut/src/react/execution-frame/provider.tsx new file mode 100644 index 00000000000..b44171305fa --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/execution-frame/provider.tsx @@ -0,0 +1,177 @@ +import { use, useState, type FC, type PropsWithChildren } from "react"; + +import { + buildActualModeTimelinePoints, + createActualModeTimelineFrameReader, + getActualModeTransitionFiringTimesMs, +} from "@hashintel/petrinaut-core"; + +import { ActualModeContext } from "../actual-mode-context"; +import { PlaybackContext } from "../playback/context"; +import { SimulationContext } from "../simulation/context"; +import { EditorContext } from "../state/editor-context"; +import { SDCPNContext } from "../state/sdcpn-context"; +import { + emptyExecutionFrameSource, + ExecutionFrameSourceContext, + type ExecutionFrameSource, +} from "./context"; + +import type { SimulationFrameReader } from "@hashintel/petrinaut-core"; + +/** + * Adapter exposing local simulation playback as an {@link ExecutionFrameSource}. + */ +export const useSimulationExecutionFrameSource = (): ExecutionFrameSource => { + const { dt, getFramesInRange, totalFrames } = use(SimulationContext); + const { + currentFrameIndex, + currentFrameReader, + currentViewedFrame, + setCurrentViewedFrame, + } = use(PlaybackContext); + + return { + // dt participates in the identity because a dt change rescales the time + // axis of everything derived from frame indices. + sourceId: `simulation:${dt}`, + totalFrames, + currentFrameIndex, + currentFrameReader, + currentViewedFrame, + scrubToFrame: setCurrentViewedFrame, + getFramesInRange, + }; +}; + +const parseTimestampMs = (timestamp: string): number | null => { + const parsed = Date.parse(timestamp); + + return Number.isFinite(parsed) ? parsed : null; +}; + +const getSourceBaselineKey = ( + transitionFirings: readonly { ts: string }[], + timelineStartedAtMs: number | null, +): string => { + for (const firing of transitionFirings) { + const timestampMs = parseTimestampMs(firing.ts); + + if (timestampMs !== null) { + return String(timestampMs); + } + } + + return String(timelineStartedAtMs ?? "pending"); +}; + +/** + * Adapter exposing a host-provided Actual mode stream as an + * {@link ExecutionFrameSource}. + * + * Timeline points, the current frame reader, and the viewed-frame index are + * all derived here, in one place, for both the canvas and the timeline chart. + * Pass `enabled: false` while another source is active so the adapter skips + * deriving frames from a stream nobody is looking at. + */ +export const useActualExecutionFrameSource = (params: { + enabled: boolean; +}): ExecutionFrameSource => { + const actualMode = use(ActualModeContext); + const { petriNetDefinition } = use(SDCPNContext); + const [scrubbedFrameIndex, setScrubbedFrameIndex] = useState(0); + + const initialState = + params.enabled && actualMode.available ? actualMode.initialState : null; + + if (!actualMode.available || initialState === null) { + return emptyExecutionFrameSource; + } + + const timelinePoints = buildActualModeTimelinePoints({ + status: actualMode.status, + transitionFirings: actualMode.transitionFirings, + timelineStartedAtMs: actualMode.timelineStartedAtMs, + timelineNowMs: actualMode.timelineNowMs, + }); + const transitionFiringTimesMs = getActualModeTransitionFiringTimesMs( + actualMode.transitionFirings, + actualMode.timelineStartedAtMs, + actualMode.timelineNowMs, + ); + + const totalFrames = timelinePoints.length; + const currentFrameIndex = Math.min( + scrubbedFrameIndex, + Math.max(0, totalFrames - 1), + ); + const currentPoint = timelinePoints[currentFrameIndex]; + const currentFrameReader = currentPoint + ? createActualModeTimelineFrameReader({ + definition: petriNetDefinition, + initialState, + transitionFirings: actualMode.transitionFirings, + transitionFiringTimesMs, + point: currentPoint, + number: currentFrameIndex, + }) + : null; + + const getFramesInRange = async ( + startIndex: number, + endIndex = timelinePoints.length, + ): Promise => + timelinePoints.slice(startIndex, endIndex).map((point, offset) => + createActualModeTimelineFrameReader({ + definition: petriNetDefinition, + initialState, + transitionFirings: actualMode.transitionFirings, + transitionFiringTimesMs, + point, + number: startIndex + offset, + }), + ); + + const { source } = actualMode; + const baselineKey = getSourceBaselineKey( + actualMode.transitionFirings, + actualMode.timelineStartedAtMs, + ); + + return { + sourceId: `actual:${source.kind}:${source.endpoint}:${ + source.runId ?? "" + }:${baselineKey}`, + totalFrames, + currentFrameIndex, + currentFrameReader, + currentViewedFrame: currentFrameReader?.toFrameState() ?? null, + scrubToFrame: (frameIndex: number) => { + setScrubbedFrameIndex(Math.max(0, Math.floor(frameIndex))); + }, + getFramesInRange, + }; +}; + +/** + * Routes {@link ExecutionFrameSourceContext} to the adapter matching the + * editor's global mode: the host-provided Actual mode stream in Actual mode, + * local simulation playback otherwise. A single element provides the context + * so switching modes swaps the value without remounting the subtree. + */ +export const ExecutionFrameProvider: FC = ({ children }) => { + const { globalMode } = use(EditorContext); + const isActualMode = globalMode === "actual"; + const simulationSource = useSimulationExecutionFrameSource(); + const actualSource = useActualExecutionFrameSource({ + enabled: isActualMode, + }); + + return ( + + {children} + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx index f4249e98a8c..4356bd3aa10 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx @@ -67,6 +67,7 @@ const editorContextValue = ( collapseAllPanels: () => {}, setTimelineChartType: () => {}, setTimelineView: () => {}, + setHiddenTimelineSeriesIds: () => {}, setSimulateViewMode: () => {}, setSimulateDrawer: () => {}, setSearchOpen: () => {}, diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx index e8e73909888..00cc889ea7f 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx @@ -70,6 +70,7 @@ const editorContextValue = ( collapseAllPanels: () => {}, setTimelineChartType: () => {}, setTimelineView: () => {}, + setHiddenTimelineSeriesIds: () => {}, setSimulateViewMode: () => {}, setSimulateDrawer: () => {}, setSearchOpen: () => {}, diff --git a/libs/@hashintel/petrinaut/src/react/index.ts b/libs/@hashintel/petrinaut/src/react/index.ts index cc153601646..e2866100698 100644 --- a/libs/@hashintel/petrinaut/src/react/index.ts +++ b/libs/@hashintel/petrinaut/src/react/index.ts @@ -7,6 +7,8 @@ export { PetrinautInstanceContext } from "./instance-context"; export { usePetrinautInstance } from "./use-petrinaut-instance"; export { useStore, useStoreSelector } from "./use-store"; +export { ActualModeContext } from "./actual-mode-context"; +export type { ActualModeContextValue } from "./actual-mode-context"; // --- Provider unification --- export { PetrinautProvider } from "./petrinaut-provider"; diff --git a/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx b/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx index 88d65be87bd..5f64ce05a46 100644 --- a/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx @@ -6,6 +6,7 @@ import { type WorkerFactory, } from "@hashintel/petrinaut-core"; +import { ExecutionFrameProvider } from "./execution-frame/provider"; import { ExperimentsProvider } from "./experiments/provider"; import { PetrinautInstanceContext } from "./instance-context"; import { LanguageClientProvider } from "./lsp/provider"; @@ -78,7 +79,9 @@ export const PetrinautProvider: React.FC = ({ - {children} + + {children} + diff --git a/libs/@hashintel/petrinaut/src/react/state/editor-context.ts b/libs/@hashintel/petrinaut/src/react/state/editor-context.ts index 2a5da4dcacf..643a74c2f67 100644 --- a/libs/@hashintel/petrinaut/src/react/state/editor-context.ts +++ b/libs/@hashintel/petrinaut/src/react/state/editor-context.ts @@ -13,12 +13,14 @@ export type DraggingStateByNodeId = Record< { dragging: boolean; position: { x: number; y: number } } >; -type EditorGlobalMode = "edit" | "simulate"; +export type EditorGlobalMode = "edit" | "simulate" | "actual"; type EditorEditionMode = "cursor" | "add-place" | "add-transition"; export type CursorMode = "select" | "pan"; export type BottomPanelTab = | "diagnostics" | "simulation-settings" + | "actual-events" + | "actual-timeline" | "simulation-timeline"; export type TimelineChartType = "run" | "stacked"; @@ -77,6 +79,12 @@ export type EditorState = { * {@link TimelineView} for the available options. */ timelineView: TimelineView; + /** + * Series hidden in the timeline chart, keyed by series id. Lifted here so + * the selection survives bottom-panel tab switches, which unmount the + * timeline subviews. + */ + hiddenTimelineSeriesIds: Set; /** * Which tab is active in the SimulateView sidebar ("scenarios" | "metrics" * | "experiments"). Lifted here so external actions (e.g. the "Manage" @@ -133,6 +141,7 @@ export type EditorActions = { collapseAllPanels: () => void; setTimelineChartType: (chartType: TimelineChartType) => void; setTimelineView: (view: TimelineView) => void; + setHiddenTimelineSeriesIds: (seriesIds: Set) => void; setSimulateViewMode: (mode: SimulateViewMode) => void; setSimulateDrawer: (drawer: SimulateDrawerState) => void; setSearchOpen: (isOpen: boolean) => void; @@ -165,6 +174,7 @@ export const initialEditorState: EditorState = { draggingStateByNodeId: {}, timelineChartType: "run", timelineView: { kind: "per-place" }, + hiddenTimelineSeriesIds: new Set(), simulateViewMode: "experiments", simulateDrawer: { type: "closed" }, isPanelAnimating: false, @@ -203,6 +213,7 @@ const DEFAULT_CONTEXT_VALUE: EditorContextValue = { collapseAllPanels: () => {}, setTimelineChartType: () => {}, setTimelineView: () => {}, + setHiddenTimelineSeriesIds: () => {}, setSimulateViewMode: () => {}, setSimulateDrawer: () => {}, setSearchOpen: () => {}, diff --git a/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx b/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx index 896d8b70659..48214ba056c 100644 --- a/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx @@ -6,6 +6,7 @@ import { type SelectionMap, } from "@hashintel/petrinaut-core"; +import { ActualModeContext } from "../actual-mode-context"; import { type DraggingStateByNodeId, type EditorActions, @@ -29,16 +30,27 @@ const canvasSelections = (selection: SelectionMap) => export const EditorProvider: React.FC = ({ children }) => { const userSettings = use(UserSettingsContext); const { petriNetDefinition } = use(SDCPNContext); + const actualMode = use(ActualModeContext); + const startsInActualMode = actualMode.available; + const startsWithActualTimeline = + startsInActualMode && + actualMode.initialState !== null && + (actualMode.status === "streaming" || actualMode.status === "complete"); const [state, setState] = useState(() => ({ ...initialEditorState, + globalMode: startsInActualMode ? "actual" : initialEditorState.globalMode, cursorMode: userSettings.cursorMode, isLeftSidebarOpen: userSettings.isLeftSidebarOpen, leftSidebarWidth: userSettings.leftSidebarWidth, propertiesPanelWidth: userSettings.propertiesPanelWidth, - isBottomPanelOpen: userSettings.isBottomPanelOpen, + isBottomPanelOpen: startsWithActualTimeline + ? true + : userSettings.isBottomPanelOpen, bottomPanelHeight: userSettings.bottomPanelHeight, - activeBottomPanelTab: userSettings.activeBottomPanelTab, + activeBottomPanelTab: startsWithActualTimeline + ? "actual-timeline" + : userSettings.activeBottomPanelTab, timelineChartType: userSettings.timelineChartType, })); @@ -216,6 +228,8 @@ export const EditorProvider: React.FC = ({ children }) => { setState((prev) => ({ ...prev, timelineChartType: chartType })), setTimelineView: (view) => setState((prev) => ({ ...prev, timelineView: view })), + setHiddenTimelineSeriesIds: (seriesIds) => + setState((prev) => ({ ...prev, hiddenTimelineSeriesIds: seriesIds })), setSimulateViewMode: (mode) => setState((prev) => ({ ...prev, simulateViewMode: mode })), setSimulateDrawer: (drawer) => diff --git a/libs/@hashintel/petrinaut/src/react/state/use-read-only-reason.ts b/libs/@hashintel/petrinaut/src/react/state/use-read-only-reason.ts index 1a237d0d4a1..f944517d199 100644 --- a/libs/@hashintel/petrinaut/src/react/state/use-read-only-reason.ts +++ b/libs/@hashintel/petrinaut/src/react/state/use-read-only-reason.ts @@ -10,11 +10,13 @@ import { SDCPNContext } from "./sdcpn-context"; * * - `host-readonly`: the consumer passed `readonly`, or the handle is read-only. * - `simulate-mode`: the user has switched to simulate mode. + * - `actual-mode`: the user is viewing a host-provided live execution stream. * - `simulation-active`: a simulation is Running, Paused, or Complete. */ export type ReadOnlyReason = | { kind: "host-readonly" } | { kind: "simulate-mode" } + | { kind: "actual-mode" } | { kind: "simulation-active"; state: "Running" | "Paused" | "Complete" }; /** @@ -33,6 +35,9 @@ export const useReadOnlyReason = (): ReadOnlyReason | null => { if (globalMode === "simulate") { return { kind: "simulate-mode" }; } + if (globalMode === "actual") { + return { kind: "actual-mode" }; + } if ( simulationState === "Running" || simulationState === "Paused" || @@ -52,7 +57,9 @@ export const formatReadOnlyReason = (reason: ReadOnlyReason): string => { case "host-readonly": return "This document is read-only; mutations are disabled."; case "simulate-mode": - return "The editor is in simulate mode. Ask the user to switch reset the simulation before mutating."; + return "The editor is in simulate mode. Ask the user to switch to edit mode before mutating."; + case "actual-mode": + return "The editor is in actual mode. Ask the user to switch to edit mode before mutating."; case "simulation-active": return `A simulation is currently ${reason.state.toLowerCase()}. Ask the user to reset the simulation before mutating.`; } diff --git a/libs/@hashintel/petrinaut/src/ui/constants/ui-subviews.ts b/libs/@hashintel/petrinaut/src/ui/constants/ui-subviews.ts index 5429b0db965..d6f196d2608 100644 --- a/libs/@hashintel/petrinaut/src/ui/constants/ui-subviews.ts +++ b/libs/@hashintel/petrinaut/src/ui/constants/ui-subviews.ts @@ -5,9 +5,11 @@ * also consume EditorContext, which imports numeric constants from ui.ts. */ +import { actualEventsSubView } from "../views/Editor/panels/BottomPanel/subviews/actual-events"; import { diagnosticsSubView } from "../views/Editor/panels/BottomPanel/subviews/diagnostics"; import { simulationSettingsSubView } from "../views/Editor/panels/BottomPanel/subviews/simulation-settings"; -import { simulationTimelineSubView } from "../views/Editor/panels/BottomPanel/subviews/simulation-timeline"; +import { actualTimelineSubView } from "../views/Editor/panels/BottomPanel/subviews/simulation-timeline/actual"; +import { simulationTimelineSubView } from "../views/Editor/panels/BottomPanel/subviews/simulation-timeline/main"; import { differentialEquationsListSubView } from "../views/Editor/panels/LeftSideBar/subviews/differential-equations-list"; import { entitiesTreeSubView } from "../views/Editor/panels/LeftSideBar/subviews/entities-tree"; import { nodesListSubView } from "../views/Editor/panels/LeftSideBar/subviews/nodes-list"; @@ -31,5 +33,11 @@ export const BOTTOM_PANEL_SUBVIEWS: SubView[] = [ simulationSettingsSubView, ]; +// Bottom panel subviews visible in Actual mode. +export const ACTUAL_BOTTOM_PANEL_SUBVIEWS: SubView[] = [ + actualTimelineSubView, + actualEventsSubView, +]; + // Subviews only visible when simulation is running/paused export const SIMULATION_ONLY_SUBVIEWS: SubView[] = [simulationTimelineSubView]; 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 new file mode 100644 index 00000000000..8fb74cb1764 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/file-io/export-actual-mode-recording.ts @@ -0,0 +1,57 @@ +import { + createActualModeRecording, + createActualModeReceivedEventsRecording, + type ActualModeMarking, + type ActualModeReceivedEvent, + type ActualModeSource, + type ActualModeTransitionFiring, + type SDCPN, +} from "@hashintel/petrinaut-core"; + +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[]; + 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; + } + + downloadBlob({ + content: JSON.stringify(recording, null, 2), + mimeType: "application/json", + filename: timestampedFilename( + title ?? "actual-mode-recording", + "petrinaut-actual.json", + ), + }); +} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx index cc2f673ed42..d50ba4d8e89 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx @@ -41,7 +41,7 @@ const bottomBarPositionStyle = css({ position: "absolute", left: "[50%]", transform: "translateX(-50%)", - zIndex: "[calc(var(--z-index-sticky) - 3)]", + zIndex: "[calc(var(--z-index-sticky) + 1)]", display: "flex", gap: "[20px]", }); @@ -77,6 +77,7 @@ export const BottomBar: React.FC = ({ cursorMode, onCursorModeChange, }) => { + const isActualMode = mode === "actual"; const { isBottomPanelOpen, setBottomPanelOpen, @@ -99,10 +100,10 @@ export const BottomBar: React.FC = ({ setBottomPanelOpen(!isBottomPanelOpen); }, [setBottomPanelOpen, isBottomPanelOpen]); - // Fallback to 'pan' mode when switching to simulate mode if mutative mode + // Fallback to cursor mode when switching away from edit while in a mutative mode. useEffect(() => { if ( - mode === "simulate" && + mode !== "edit" && (editionMode === "add-place" || editionMode === "add-transition") ) { onEditionModeChange("cursor"); @@ -138,8 +139,9 @@ export const BottomBar: React.FC = ({ onEditionModeChange={onEditionModeChange} cursorMode={cursorMode} onCursorModeChange={onCursorModeChange} + showEditTools={!isActualMode} /> - {hasAiAssistant && ( + {hasAiAssistant && !isActualMode && ( <> = ({ )} - - - + {!isActualMode && ( + <> + + + + + )} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-modes.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-modes.tsx index ab8d7950153..a5a43942576 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-modes.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-modes.tsx @@ -113,6 +113,7 @@ interface ToolbarModesProps { onEditionModeChange: (mode: EditorEditionMode) => void; cursorMode: CursorMode; onCursorModeChange: (mode: CursorMode) => void; + showEditTools?: boolean; } export const ToolbarModes: React.FC = ({ @@ -120,6 +121,7 @@ export const ToolbarModes: React.FC = ({ onEditionModeChange, cursorMode, onCursorModeChange, + showEditTools = true, }) => { const isReadOnly = useIsReadOnly(); @@ -131,7 +133,7 @@ export const ToolbarModes: React.FC = ({ cursorMode={cursorMode} onCursorModeChange={onCursorModeChange} /> - {!isReadOnly && ( + {showEditTools && !isReadOnly && ( <> void; + actualModeAvailable: boolean; + mode: EditorGlobalMode; + onChange: (mode: EditorGlobalMode) => void; } -const options: SegmentOption[] = [ +const getOptions = (actualModeAvailable: boolean): SegmentOption[] => [ { label: "Edit", value: "edit", @@ -24,20 +26,23 @@ const options: SegmentOption[] = [ label: "Actual", value: "actual", icon: , - disabled: true, - tooltip: "Actual mode is not yet available.", + disabled: !actualModeAvailable, + tooltip: actualModeAvailable + ? "View actual execution state." + : "Actual mode is not yet available.", }, ]; export const ModeSelector: React.FC = ({ + actualModeAvailable, mode, onChange, }) => { return ( onChange(value as "edit" | "simulate")} + options={getOptions(actualModeAvailable)} + onChange={(value) => onChange(value as EditorGlobalMode)} /> ); }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/TopBar/top-bar.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/TopBar/top-bar.tsx index 1faa41f84c4..da50eaedd2a 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/TopBar/top-bar.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/TopBar/top-bar.tsx @@ -49,6 +49,7 @@ const rightSectionStyle = css({ }); interface TopBarProps { + actualModeAvailable: boolean; menuItems: MenuItem[]; title: string; onTitleChange: (value: string) => void; @@ -60,6 +61,7 @@ interface TopBarProps { } export const TopBar: React.FC = ({ + actualModeAvailable, menuItems, title, onTitleChange, @@ -115,7 +117,11 @@ export const TopBar: React.FC = ({ {/* Center section - mode switcher */} - +
)} - {/* Bottom Panel - Diagnostics, Simulation Settings */} + {/* Bottom Panel */} + isActualMode + ? ACTUAL_BOTTOM_PANEL_SUBVIEWS + : [ + ...BOTTOM_PANEL_SUBVIEWS, + ...(isSimulationActive ? SIMULATION_ONLY_SUBVIEWS : []), + ]; + /** * BottomPanel shows tabs for Diagnostics and Simulation Settings. * Positioned at the bottom of the viewport. @@ -89,45 +105,98 @@ export const BottomPanel: React.FC = () => { setActiveBottomPanelTab: setActiveTab, toggleBottomPanel, isPanelAnimating, + globalMode, } = use(EditorContext); // Simulation state for conditional subviews const { state: simulationState } = use(SimulationContext); + const actualMode = use(ActualModeContext); + const isActualMode = globalMode === "actual"; const isSimulationActive = - simulationState === "Running" || - simulationState === "Paused" || - simulationState === "Complete"; + !isActualMode && + (simulationState === "Running" || + simulationState === "Paused" || + simulationState === "Complete"); + const isActualTimelineActive = + isActualMode && + actualMode.available && + actualMode.initialState !== null && + (actualMode.status === "streaming" || actualMode.status === "complete"); - // Track previous simulation state to detect when simulation starts - const prevSimulationActiveRef = useRef(isSimulationActive); + // Track previous run states to detect when a timeline becomes available. + const prevSimulationActiveRef = useRef(false); + const prevActualTimelineActiveRef = useRef(false); - // Dynamically compute subviews based on simulation state - const subViews = isSimulationActive - ? [...BOTTOM_PANEL_SUBVIEWS, ...SIMULATION_ONLY_SUBVIEWS] - : BOTTOM_PANEL_SUBVIEWS; + // Dynamically compute subviews based on available execution modes. + const subViews = getBottomPanelSubViews({ + isActualMode, + isSimulationActive, + }); - // Automatically open bottom panel and switch to timeline when simulation starts, - // and fall back to diagnostics when simulation stops + // Automatically open bottom panel and switch to the relevant timeline when a + // run starts, and fall back to diagnostics when the active timeline disappears. useEffect(() => { - const wasActive = prevSimulationActiveRef.current; + const wasSimulationActive = prevSimulationActiveRef.current; + const wasActualTimelineActive = prevActualTimelineActiveRef.current; prevSimulationActiveRef.current = isSimulationActive; + prevActualTimelineActiveRef.current = isActualTimelineActive; - // Simulation just started (transition from inactive to active) - if (isSimulationActive && !wasActive) { + if (isActualTimelineActive && !wasActualTimelineActive) { + setBottomPanelOpen(true); + setActiveTab("actual-timeline"); + } else if (!isActualMode && isSimulationActive && !wasSimulationActive) { setBottomPanelOpen(true); setActiveTab("simulation-timeline"); } - // Simulation just stopped (transition from active to inactive) - // If the current tab is simulation-only, fall back to diagnostics if ( !isSimulationActive && - wasActive && + wasSimulationActive && activeTab === "simulation-timeline" ) { setActiveTab("diagnostics"); } - }, [isSimulationActive, setBottomPanelOpen, setActiveTab, activeTab]); + + if ( + !isActualMode && + !isActualTimelineActive && + wasActualTimelineActive && + activeTab === "actual-timeline" + ) { + // Only fall back to diagnostics when leaving Actual mode entirely; while + // still in Actual mode the timeline tab stays valid even without data. + setActiveTab("diagnostics"); + } + }, [ + activeTab, + isActualMode, + isActualTimelineActive, + isSimulationActive, + setActiveTab, + setBottomPanelOpen, + ]); + + useEffect(() => { + const availableSubViews = getBottomPanelSubViews({ + isActualMode, + isSimulationActive, + }); + + if (!availableSubViews.some((subView) => subView.id === activeTab)) { + const fallbackTab = availableSubViews[0]?.id as + | BottomPanelTab + | undefined; + + if (fallbackTab) { + setActiveTab(fallbackTab); + } + } + }, [activeTab, isActualMode, isSimulationActive, setActiveTab]); + + const renderedActiveTab = + subViews.find((subView) => subView.id === activeTab)?.id ?? + subViews[0]?.id ?? + activeTab; const handleTabChange = (tabId: string) => { setActiveTab(tabId as BottomPanelTab); @@ -171,27 +240,32 @@ export const BottomPanel: React.FC = () => {
-
{/* Scrollable content */} - + ); }; 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 new file mode 100644 index 00000000000..5c75f8d3f75 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/actual-events.tsx @@ -0,0 +1,394 @@ +import { use } from "react"; + +import { Button, Icon, Menu, type MenuItem } from "@hashintel/ds-components"; +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 type { SubView } from "../../../../../components/sub-view/types"; +import type { + ActualModeMarking, + ActualModeTransitionFiring, +} from "@hashintel/petrinaut-core"; + +const MAX_VISIBLE_EVENTS = 500; + +const rootStyle = css({ + display: "flex", + flexDirection: "column", + height: "full", + minHeight: "[0]", + gap: "3", +}); + +const toolbarStyle = css({ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: "3", + flexWrap: "wrap", + flexShrink: 0, +}); + +const statusStyle = css({ + display: "flex", + alignItems: "center", + gap: "2", + color: "neutral.s105", + fontSize: "xs", +}); + +const countStyle = css({ + color: "neutral.s120", + fontWeight: "medium", +}); + +const exportChevronStyle = css({ + opacity: "[0.65]", +}); + +const emptyStyle = css({ + color: "neutral.s100", + fontStyle: "italic", + fontSize: "xs", +}); + +const tableWrapperStyle = css({ + minHeight: "[0]", + overflow: "auto", + borderTopWidth: "thin", + borderColor: "neutral.bd.subtle", +}); + +const tableStyle = css({ + width: "[100%]", + borderCollapse: "separate", + borderSpacing: "[0]", + tableLayout: "fixed", +}); + +const headerRowStyle = css({ + position: "sticky", + top: "[0]", + zIndex: "base", + backgroundColor: "white.a85", + backdropFilter: "[blur(13px)]", + color: "neutral.s95", + fontSize: "[10px]", + fontWeight: "semibold", + letterSpacing: "[0]", + lineHeight: "[1]", + textTransform: "uppercase", +}); + +const cellStyle = css({ + padding: "[8px 10px]", + borderBottomWidth: "thin", + borderColor: "neutral.bd.subtle", + textAlign: "left", + verticalAlign: "top", +}); + +const singleLineCellStyle = css({ + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", +}); + +const eventColumnStyle = css({ + width: "[64px]", +}); + +const timestampColumnStyle = css({ + width: "[180px]", +}); + +const transitionColumnStyle = css({ + width: "[220px]", +}); + +const effectColumnStyle = css({ + width: "[280px]", +}); + +const indexCellStyle = css({ + color: "neutral.s90", + fontFamily: "mono", +}); + +const timestampCellStyle = css({ + color: "neutral.s105", + fontFamily: "mono", +}); + +const transitionCellStyle = css({ + color: "neutral.s125", + fontFamily: "mono", + fontWeight: "medium", +}); + +const markingCellStyle = css({ + color: "neutral.s115", + fontFamily: "mono", + fontSize: "[11px]", +}); + +const footerNoteStyle = css({ + color: "neutral.s90", + fontSize: "[11px]", + flexShrink: 0, +}); + +const formatTimestamp = (timestamp: string): string => { + const date = new Date(timestamp); + + if (Number.isNaN(date.getTime())) { + return timestamp; + } + + return date.toLocaleString(undefined, { + dateStyle: "short", + timeStyle: "medium", + }); +}; + +const formatMarkingValue = ( + value: ActualModeMarking[string] | undefined, +): string => { + if (value === undefined) { + return "0"; + } + + return Array.isArray(value) ? String(value.length) : String(value); +}; + +const formatMarking = (marking: ActualModeMarking): string => + Object.entries(marking).length === 0 + ? "none" + : Object.entries(marking) + .map(([placeId, value]) => `${placeId}: ${formatMarkingValue(value)}`) + .join(", "); + +const EventRow: React.FC<{ + firing: ActualModeTransitionFiring; + index: number; +}> = ({ firing, index }) => ( + + + #{index + 1} + + + {formatTimestamp(firing.ts)} + + + {firing.transitionId} + + + {formatMarking(firing.input)} + + + {formatMarking(firing.output)} + + +); + +const ActualEventsContent: React.FC = () => { + const actualMode = use(ActualModeContext); + const canExportStream = + actualMode.available && + (actualMode.receivedEvents.length > 0 || + (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 handleExportStream = () => { + if (!actualMode.available || !canExportStream) { + return; + } + + exportActualModeRecording({ + definition: actualMode.definition, + initialState: actualMode.initialState, + receivedEvents: actualMode.receivedEvents, + source: actualMode.source, + title: actualMode.title, + transitionFirings, + }); + }; + + const handleExportNet = () => { + if (!actualMode.available || actualMode.definition === null) { + return; + } + + exportSDCPN({ + petriNetDefinition: actualMode.definition, + title: actualMode.title ?? "actual-mode-net", + }); + }; + + const exportMenuItems: MenuItem[] = [ + { + id: "export-stream", + text: "Export Stream", + disabled: !canExportStream, + onClick: handleExportStream, + }, + { + id: "export-net", + text: "Export Net", + disabled: !canExportNet, + onClick: handleExportNet, + }, + ]; + + if (!actualMode.available) { + return Actual mode is not available.; + } + + return ( +
+
+
+ + Status: {actualMode.status} + + + Events:{" "} + {transitionFirings.length} + +
+ + } + > + Export + + } + items={exportMenuItems} + position="bottom" + /> +
+ + {visibleFirings.length === 0 ? ( + No transition events received yet. + ) : ( +
+ + + + + + + + + + + + {visibleFirings.map((firing, index) => ( + + ))} + +
+ Event + + Time + + Transition + + Input + Output
+
+ )} + + {transitionFirings.length > MAX_VISIBLE_EVENTS && ( + + Showing the latest {MAX_VISIBLE_EVENTS} events. Export includes the + full stream. + + )} +
+ ); +}; + +export const actualEventsSubView: SubView = { + id: "actual-events", + title: "Events", + tooltip: + "Inspect the Actual mode transition stream and export stream or net JSON.", + component: ActualEventsContent, +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx deleted file mode 100644 index a727161c3de..00000000000 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx +++ /dev/null @@ -1,1471 +0,0 @@ -import { - use, - useEffect, - useMemo, - useRef, - useState, - useSyncExternalStore, -} from "react"; -import uPlot from "uplot"; - -import { Button } from "@hashintel/ds-components"; -import { css } from "@hashintel/ds-helpers/css"; -import "uplot/dist/uPlot.min.css"; - -import { - compileMetric, - buildMetricState, - type CompiledMetric, -} from "@hashintel/petrinaut-core"; - -import { createValueStore } from "../../../../../../react/create-value-store"; -import { useElementSize } from "../../../../../../react/hooks/use-element-size"; -import { useLatest } from "../../../../../../react/hooks/use-latest"; -import { useStableCallback } from "../../../../../../react/hooks/use-stable-callback"; -import { PlaybackContext } from "../../../../../../react/playback/context"; -import { - SimulationContext, - type SimulationFrameReader, -} from "../../../../../../react/simulation/context"; -import { - EditorContext, - type TimelineChartType, - type TimelineView, -} from "../../../../../../react/state/editor-context"; -import { SDCPNContext } from "../../../../../../react/state/sdcpn-context"; -import { SegmentGroup } from "../../../../../components/segment-group"; -import { Select } from "../../../../../components/select"; -import { CreateMetricDrawer } from "../../SimulateView/metrics/create-metric-drawer"; -import { ViewMetricDrawer } from "../../SimulateView/metrics/view-metric-drawer"; - -import type { SubView } from "../../../../../components/sub-view/types"; - -// -- Styles ------------------------------------------------------------------- - -const containerStyle = css({ - display: "flex", - flexDirection: "column", - height: "[100%]", - paddingTop: "[4px]", -}); - -const chartAreaStyle = css({ - position: "relative", - flex: "[1]", - minHeight: "[0]", -}); - -const legendContainerStyle = css({ - display: "flex", - flexWrap: "wrap", - gap: "[12px]", - fontSize: "[11px]", - color: "[#666]", - paddingY: "3", - paddingX: "3", -}); - -const legendItemStyle = css({ - display: "flex", - alignItems: "center", - gap: "[4px]", - cursor: "pointer", - userSelect: "none", - transition: "[opacity 0.15s ease]", - _hover: { - opacity: 1, - }, -}); - -const legendColorStyle = css({ - width: "[10px]", - height: "[10px]", - borderRadius: "[2px]", -}); - -const tooltipStyle = css({ - position: "absolute", - pointerEvents: "none", - backgroundColor: "[rgba(0, 0, 0, 0.85)]", - color: "neutral.s00", - padding: "[6px 10px]", - borderRadius: "md", - fontSize: "[11px]", - lineHeight: "[1.4]", - zIndex: "[1000]", - whiteSpace: "nowrap", - boxShadow: "[0 2px 8px rgba(0, 0, 0, 0.25)]", - display: "none", -}); - -const tooltipLabelStyle = css({ - display: "flex", - alignItems: "center", - gap: "[6px]", -}); - -const tooltipDotStyle = css({ - width: "[8px]", - height: "[8px]", - borderRadius: "[50%]", - flexShrink: "[0]", -}); - -const tooltipValueStyle = css({ - fontWeight: "semibold", - marginLeft: "[4px]", -}); - -// -- Constants ---------------------------------------------------------------- - -const DEFAULT_COLORS = [ - "#3b82f6", // blue - "#ef4444", // red - "#22c55e", // green - "#f59e0b", // amber - "#8b5cf6", // violet - "#06b6d4", // cyan - "#ec4899", // pink - "#84cc16", // lime -]; - -const CHART_TYPE_OPTIONS = [ - { value: "run", label: "Run" }, - { value: "stacked", label: "Stacked" }, -]; - -// -- Types -------------------------------------------------------------------- - -/** Metadata for each place (stable across streaming updates). */ -interface PlaceMeta { - placeId: string; - placeName: string; - color: string; -} - -// -- Header action ------------------------------------------------------------ - -const headerActionsStyle = css({ - display: "flex", - alignItems: "center", - gap: "[8px]", -}); - -const metricPickerLabelStyle = css({ - fontSize: "[10px]", - fontWeight: "semibold", - textTransform: "uppercase", - color: "neutral.a100", - letterSpacing: "[0.5px]", - flexShrink: 0, -}); - -const metricPickerWrapperStyle = css({ - width: "[200px]", -}); - -// Sentinel values for the native views in the picker. Metric ids are UUIDs -// (or `metric__*` in examples) so these cannot collide. -const PER_PLACE_VALUE = "__per_place__"; -const PER_TYPE_VALUE = "__per_type__"; -const PER_TRANSITION_VALUE = "__per_transition__"; - -function viewToSelectValue(view: TimelineView): string { - switch (view.kind) { - case "per-place": - return PER_PLACE_VALUE; - case "per-type": - return PER_TYPE_VALUE; - case "per-transition": - return PER_TRANSITION_VALUE; - case "metric": - return view.metricId; - } -} - -function selectValueToView(value: string): TimelineView { - if (value === PER_PLACE_VALUE) { - return { kind: "per-place" }; - } - if (value === PER_TYPE_VALUE) { - return { kind: "per-type" }; - } - if (value === PER_TRANSITION_VALUE) { - return { kind: "per-transition" }; - } - return { kind: "metric", metricId: value }; -} - -const TimelineChartTypeSelector: React.FC = () => { - const { timelineChartType: chartType, setTimelineChartType: setChartType } = - use(EditorContext); - - return ( - setChartType(value as TimelineChartType)} - size="sm" - /> - ); -}; - -const TimelineViewPicker: React.FC = () => { - const { timelineView, setTimelineView, setGlobalMode, setSimulateViewMode } = - use(EditorContext); - const { - extensions, - petriNetDefinition: { metrics = [] }, - } = use(SDCPNContext); - const colorsEnabled = extensions.colors; - - const [isCreateOpen, setIsCreateOpen] = useState(false); - const [isViewOpen, setIsViewOpen] = useState(false); - - useEffect(() => { - if (!colorsEnabled && timelineView.kind === "per-type") { - setTimelineView({ kind: "per-place" }); - } - }, [colorsEnabled, setTimelineView, timelineView.kind]); - - const selectedMetric = - timelineView.kind === "metric" - ? metrics.find((m) => m.id === timelineView.metricId) - : undefined; - - const options = [ - { value: PER_PLACE_VALUE, label: "Tokens per place" }, - ...(colorsEnabled - ? [{ value: PER_TYPE_VALUE, label: "Tokens per type" }] - : []), - { value: PER_TRANSITION_VALUE, label: "Transition firings" }, - ...metrics.map((m) => ({ value: m.id, label: m.name })), - ]; - const selectedValue = - colorsEnabled || timelineView.kind !== "per-type" - ? viewToSelectValue(timelineView) - : PER_PLACE_VALUE; - - return ( - <> - Metric -
-