Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
b27380b
Add status views to the SDCPN schema, actions, and file format
claude Sep 1, 2026
a9da9ad
Add identities to the SDCPN and identityRef key markers on colour ele…
claude Sep 1, 2026
e0f0483
Add ticket-processing example and a status view to Production with Ma…
claude Sep 1, 2026
b2649c6
Record token values on actual-mode transition firings
claude Sep 1, 2026
2c70243
Derive per-instance status and time-in-state from execution frames
claude Sep 1, 2026
bc68c40
Add a status views authoring panel to the simulate view
claude Sep 1, 2026
f98932c
Show status badges and label tinting on component-instance nodes
claude Sep 1, 2026
761bd91
Add a Kanban board toggle and status timing surfaces
claude Sep 1, 2026
543ecdf
Fix machine-fleet identity keys and the stranded Kanban view mode
claude Sep 16, 2026
a4a1713
Make actual-mode token replay honor firing counts and never drop the …
claude Sep 1, 2026
cdd57e2
Fail closed on broken status conditions and validate status-view refe…
claude Sep 1, 2026
60fb28e
Surface status-view failures in the UI and share one condition-compil…
claude Sep 1, 2026
67bc06f
Drive the events status column with the shared evaluator and end repl…
claude Sep 1, 2026
5e0019b
Cover validateStatusViewCompiles with unit tests
claude Sep 1, 2026
fb46167
Keep dangling-place wording timeless
claude Sep 1, 2026
d3b4565
Split the status-views changeset per package
claude Sep 1, 2026
d83129f
Keep identities and status views when normalizing an SDCPN input
claude Sep 2, 2026
a60b0b9
Keep the Kanban board clear of the editor's overlay panels
claude Sep 2, 2026
bcc772e
Polish Kanban cards and columns and deselect on board click
claude Sep 2, 2026
f328d70
Give example tickets human-readable ids
claude Sep 2, 2026
efbd416
Cover description and metadata in normalizeSDCPN and update the GPU e…
claude Sep 4, 2026
64dbfbd
Gate status views behind an experimental feature flag
claude Sep 15, 2026
e71189a
Drop status view exports nothing outside their modules uses
claude Sep 15, 2026
201c005
Carry the Status views section and overlay in the example URL contract
claude Sep 15, 2026
654370a
List the Ticket Processing example only while status views are on
claude Sep 15, 2026
0bed7ed
Regenerate the protocol schema and Python models for the status-condi…
claude Sep 15, 2026
b3d4087
Carry statusConditions, identities and statusViews in the CLI and API…
claude Sep 15, 2026
830f97d
Sort the type imports in canvas-frame-store
claude Sep 15, 2026
1329f1f
Move the Kanban board into the workspace view switcher
claude Sep 16, 2026
ee63ca7
Merge branch 'main' into claude/h-6819-status-views
CiaranMn Sep 21, 2026
047c3b6
Record actual-mode firings as token values and drop the count maps
claude Sep 22, 2026
b7f3fae
Keep the Load example menu inline and gate Ticket Processing in place
claude Sep 22, 2026
c94dfe3
Inset the Kanban board with useCanvasInsets
claude Sep 22, 2026
c8b6a09
Leave the Production with Machine Failure example unchanged
claude Sep 22, 2026
c3831b0
Drop tests that repeat coverage held elsewhere
claude Sep 22, 2026
a82813d
Share the status view drawer footer and submit validation
claude Sep 22, 2026
5b978f9
Show total time across stays on Kanban cards
claude Sep 22, 2026
185c03a
Share status view test fixtures and the key-coherence predicate
claude Sep 22, 2026
6bcdd35
Replay actual-mode frames once for the frame source and the Events co…
claude Sep 22, 2026
f066758
Require each side of a firing and reuse the wire type in the fixture
claude Sep 22, 2026
6b1df9f
Remove the legacy count-form firing normaliser and older recording ve…
claude Sep 22, 2026
48bde82
Describe actual-mode firings without reference to earlier shapes
claude Sep 22, 2026
1e3ec20
Set the actual-mode recording version to 2
claude Sep 23, 2026
5105bce
Reject actual-mode firings that consume tokens the marking does not hold
claude Sep 23, 2026
27f3189
Align the Kanban status-view select with the view switcher
claude Sep 23, 2026
c06ad42
Require actual-mode token records to carry every colour element
claude Sep 23, 2026
20cec45
Replace try/finally in worked-model revision settling with Promise fi…
claude Sep 23, 2026
80d61fa
Reject token counts on actual-mode places whose colour has elements
claude Sep 23, 2026
a3740f7
Prune status label places that no longer resolve on every delete path
CiaranMn Sep 23, 2026
a6cddde
Count each frame's Kanban condition errors once
CiaranMn Sep 23, 2026
3ce692d
Drop status view tests that restate constants or repeat coverage held…
CiaranMn Sep 23, 2026
9482896
Validate status view labels in the form with the core schema
CiaranMn Sep 23, 2026
456a74a
Drop core exports nothing outside the package uses
CiaranMn Sep 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/status-views-core.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut-core": patch
---

SDCPN documents carry optional `identities` (named instance identities that colour elements reference via `identityRef`) and `statusViews` (ordered, place-mapped status labels with optional token conditions and an optional exit label); both validate against the entity schemas and survive file import/export. Actual-mode transition firings carry the attribute values of the tokens they consumed and produced (`inputTokens` / `outputTokens`), and recordings carry version 2. A token record for a place with a colour must carry exactly the colour's elements, each a value of its element's type, and a record for an uncoloured place must be empty; a place whose colour has elements lists token records, not a token count. Replaying a firing whose records break that rule, or that consumes a token the marking does not hold, throws, and a recording containing one fails to parse. New evaluators derive per-instance status, time-in-state, and dwell summaries from simulation frames.
5 changes: 5 additions & 0 deletions .changeset/status-views-ui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut": patch
---

Behind the experimental **Status views** setting (off by default): the simulate panel displays and allows editing of a net's status views, token type attributes can be keyed to an identity, the events panel shows per-instance status changes, and the view switcher gains a Kanban board that lays out instances by status label with each instance's time-in-state. Component-instance nodes show a status badge tinted by the active label whenever the net declares a status view.
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export const validOptimizationInput = {
parameters: [],
subnets: [],
componentInstances: [],
identities: [],
statusViews: [],
scenarios: [
{
id: "baseline",
Expand Down
73 changes: 51 additions & 22 deletions apps/petrinaut-website/scripts/brunch-sse-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,15 @@ import http, { type ServerResponse } from "node:http";
import { homedir } from "node:os";
import { resolve } from "node:path";

import { ACTUAL_MODE_RECORDING_VERSION } from "@hashintel/petrinaut-core";

import type {
BrunchNetDefinitionInput,
BrunchTransitionInput,
} from "../src/main/app/brunch-demo/brunch-protocol";
import type {
ActualModeReceivedEvent,
ActualModeTokenValues,
ActualModeTransitionFiring,
} from "@hashintel/petrinaut-core";

Expand Down Expand Up @@ -192,25 +195,27 @@ const parseNumericMarking = (data: unknown, label: string): NumericMarking => {
return marking;
};

const parseTransitionEffect = (
const parseTokenValues = (
data: unknown,
label: string,
): ActualModeTransitionFiring["input"] => {
): ActualModeTokenValues => {
if (!isRecord(data)) {
throw new Error(`Recording ${label} must be an object.`);
}

const effect: ActualModeTransitionFiring["input"] = {};
const tokenValues: ActualModeTokenValues = {};

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.`);
for (const [placeId, tokens] of Object.entries(data)) {
if (!Array.isArray(tokens) || !tokens.every(isRecord)) {
throw new Error(
`Recording ${label}.${placeId} must be an array of token records.`,
);
}

effect[placeId] = value;
tokenValues[placeId] = tokens as ActualModeTokenValues[string];
}

return effect;
return tokenValues;
};

const parseTransitionFiring = (
Expand All @@ -233,8 +238,8 @@ const parseTransitionFiring = (

return {
transitionId: data.transitionId,
input: parseTransitionEffect(data.input, `${label}.input`),
output: parseTransitionEffect(data.output, `${label}.output`),
inputTokens: parseTokenValues(data.inputTokens, `${label}.inputTokens`),
outputTokens: parseTokenValues(data.outputTokens, `${label}.outputTokens`),
ts: data.ts,
};
};
Expand Down Expand Up @@ -413,6 +418,12 @@ const parseRecordingEvents = (data: unknown): ActualModeReceivedEvent[] => {
throw new Error("Recording root must be an object.");
}

if (data.version !== ACTUAL_MODE_RECORDING_VERSION) {
throw new Error(
`Recording version must be ${ACTUAL_MODE_RECORDING_VERSION}, got ${String(data.version)}.`,
);
}

if ("events" in data) {
return parseReceivedEventsRecording(data);
}
Expand All @@ -426,7 +437,7 @@ const parseRecordingEvents = (data: unknown): ActualModeReceivedEvent[] => {
}

throw new Error(
"Recording must be an Actual Events export with `events` or an older normalized recording.",
"Recording must be an Actual Events export with `events` or a normalized recording with `transitionFirings`.",
);
};

Expand Down Expand Up @@ -517,12 +528,12 @@ 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, tokens] of Object.entries(firing.inputTokens)) {
marking[placeId] = (marking[placeId] ?? 0) - tokens.length;
}

for (const [placeId, value] of Object.entries(firing.output)) {
marking[placeId] = (marking[placeId] ?? 0) + value;
for (const [placeId, tokens] of Object.entries(firing.outputTokens)) {
marking[placeId] = (marking[placeId] ?? 0) + tokens.length;
}
};

Expand Down Expand Up @@ -609,32 +620,40 @@ const canFire = (marking: NumericMarking, transitionId: string): boolean => {
);
};

/** The fixture's places are uncoloured, so each moved token is an empty record. */
const emptyTokens = (count: number): ActualModeTokenValues[string] =>
Array.from({ length: count }, () => ({}));

const applyTransition = (
marking: NumericMarking,
transitionId: string,
): ActualModeTransitionFiring => {
const transition = getTransition(transitionId);
const input: ActualModeTransitionFiring["input"] = {};
const output: ActualModeTransitionFiring["output"] = {};
const inputTokens: ActualModeTokenValues = {};
const outputTokens: ActualModeTokenValues = {};

for (const arc of transition.inputArcs) {
if ((arc.type ?? "standard") !== "standard") {
continue;
}

input[arc.placeId] = (input[arc.placeId] ?? 0) + arc.weight;
inputTokens[arc.placeId] = (inputTokens[arc.placeId] ?? []).concat(
emptyTokens(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;
outputTokens[arc.placeId] = (outputTokens[arc.placeId] ?? []).concat(
emptyTokens(arc.weight),
);
marking[arc.placeId] = (marking[arc.placeId] ?? 0) + arc.weight;
}

return {
transitionId,
input,
output,
inputTokens,
outputTokens,
ts: new Date().toISOString(),
};
};
Expand Down Expand Up @@ -864,6 +883,16 @@ const server = http.createServer((request, response) => {
);
});

const producedTokensPerPlace = (
outputTokens: ActualModeTokenValues,
): Record<string, number> =>
Object.fromEntries(
Object.entries(outputTokens).map(([placeId, tokens]) => [
placeId,
tokens.length,
]),
);

const broadcastLiveFiring = (): void => {
const firing = nextLiveFiring();

Expand All @@ -877,7 +906,7 @@ const broadcastLiveFiring = (): void => {

console.log(
`[${firing.ts}] transition_firing ${firing.transitionId} -> ${JSON.stringify(
firing.output,
producedTokensPerPlace(firing.outputTokens),
)}`,
);
};
Expand Down
5 changes: 4 additions & 1 deletion apps/petrinaut-website/src/examples/example-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,13 @@ import {
*/
export const sharedModes = ["edit", "simulate", "actual"] as const;

export const sharedEditViews = ["canvas", "definitions"] as const;
export const sharedEditViews = ["canvas", "definitions", "kanban"] as const;

export const sharedSimulateViews = [
"scenarios",
"metrics",
"experiments",
"status-views",
] as const;

export const sharedOverlays = [
Expand All @@ -38,6 +39,7 @@ export const sharedOverlays = [
"create-scenario",
"create-metric",
"create-experiment",
"create-status-view",
] as const;

export const sharedSettingsSections = ["general", "viewport", "labs"] as const;
Expand All @@ -46,6 +48,7 @@ export const sharedResourceTypes = [
"scenario",
"metric",
"experiment",
"status-view",
] as const;

export type SharedEditView = (typeof sharedEditViews)[number];
Expand Down
4 changes: 3 additions & 1 deletion apps/petrinaut-website/src/examples/navigation-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,9 @@ export const sharedSearchToNavigationState = (
? "scenarios"
: search.resourceType === "experiment"
? "experiments"
: "metrics"
: search.resourceType === "status-view"
? "status-views"
: "metrics"
: (search.view ?? baseline.simulateView),
simulateResource:
search.resourceType && search.resourceId
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// @vitest-environment jsdom

import { act, render } from "@testing-library/react";
import { use } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { ActualModeContext } from "@hashintel/petrinaut/react";

import { BrunchActualModeProvider } from "./brunch-actual-mode-provider";

class FakeEventSource {
static latest: FakeEventSource | null = null;

readonly listeners = new Map<string, ((event: Event) => void)[]>();

constructor() {
FakeEventSource.latest = this;
}

addEventListener(type: string, listener: (event: Event) => void) {
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
}

removeEventListener() {}

close() {}

emit(type: string, data: unknown) {
const event = new MessageEvent(type, { data: JSON.stringify(data) });
for (const listener of this.listeners.get(type) ?? []) {
listener(event);
}
}
}

const emit = (type: string, data: unknown) => {
act(() => {
FakeEventSource.latest!.emit(type, data);
});
};

const emitDefinition = async () => {
await act(async () => {
FakeEventSource.latest!.emit("definition", {
places: [
{ id: "queued", name: "Queued", x: 0, y: 0 },
{ id: "done", name: "Done", x: 100, y: 0 },
],
transitions: [],
});
});
};

const StatusProbe = () => {
const actualMode = use(ActualModeContext);
return (
<output>
{actualMode.status}|{actualMode.transitionFirings.length}|
{actualMode.error ?? ""}
</output>
);
};

const renderProvider = () =>
render(
<BrunchActualModeProvider endpoint="https://brunch.example/events">
<StatusProbe />
</BrunchActualModeProvider>,
);

const finishFiring = (inputTokens: Record<string, unknown[]>) => ({
transitionId: "finish",
inputTokens,
outputTokens: { done: [{}] },
ts: "2026-06-05T10:00:00.000Z",
});

describe("BrunchActualModeProvider", () => {
beforeEach(() => {
vi.stubGlobal("EventSource", FakeEventSource);
});

afterEach(() => {
vi.unstubAllGlobals();
FakeEventSource.latest = null;
});

it("appends a firing that the marking absorbs", async () => {
const { container } = renderProvider();

await emitDefinition();
emit("initial_state", { queued: 1, done: 0 });
emit("transition_firing", finishFiring({ queued: [{}] }));

expect(container.textContent).toBe("streaming|1|");
});

it("ends the stream with an error for a firing that consumes a token the marking does not hold", async () => {
const { container } = renderProvider();

await emitDefinition();
emit("initial_state", { queued: 1, done: 0 });
emit("transition_firing", finishFiring({ queued: [{}] }));
emit("transition_firing", finishFiring({ queued: [{}] }));

expect(container.textContent).toBe(
'error|1|Invalid Brunch transition_firing frame: Transition firing of "finish" at 2026-06-05T10:00:00.000Z consumes 1 token from place "queued", which holds 0',
);
});

it("checks firings received before the initial state once it arrives", async () => {
const { container } = renderProvider();

await emitDefinition();
emit("transition_firing", finishFiring({ queued: [{}, {}] }));
emit("initial_state", { queued: 1, done: 0 });

expect(container.textContent).toBe(
'error|1|Invalid Brunch transition_firing frame: Transition firing of "finish" at 2026-06-05T10:00:00.000Z consumes 2 tokens from place "queued", which holds 1',
);
});

it("checks frames received before the definition once it arrives", async () => {
const { container } = renderProvider();

emit("initial_state", { queued: 1, done: 0 });
emit("transition_firing", finishFiring({ queued: [{}, {}] }));
expect(container.textContent).toBe("streaming|1|");

await emitDefinition();

expect(container.textContent).toBe(
'error|1|Invalid Brunch transition_firing frame: Transition firing of "finish" at 2026-06-05T10:00:00.000Z consumes 2 tokens from place "queued", which holds 1',
);
});

it("ends the stream with an error for a firing whose token record does not fit its place", async () => {
const { container } = renderProvider();

await emitDefinition();
emit("initial_state", { queued: 1, done: 0 });
emit("transition_firing", finishFiring({ queued: [{ ticket_id: "a" }] }));

expect(container.textContent).toBe(
'error|0|Invalid Brunch transition_firing frame: Transition firing of "finish" at 2026-06-05T10:00:00.000Z consumes token {"ticket_id":"a"} from place "queued", which carries attribute "ticket_id" although the place has no colour',
);
});
});
Loading
Loading