From b27380b60d51c59fa5d99f1a4610e9b16ff91de4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 17:55:44 +0000 Subject: [PATCH 01/52] Add status views to the SDCPN schema, actions, and file format StatusView is a first-class SDCPN collection next to metrics: an ordered list of labels, each mapping a set of places (optionally instance-scoped) to a named status with a display colour, an optional token-attribute condition, and an optional exit label for instances whose token left the view's places. Label order is the array position; a moveStatusViewLabel mutation reorders labels. The instanceId::placeId convention moves from the simulation engine into a shared scoped-ids module. --- .changeset/status-views.md | 6 + .../petrinaut-core/src/action-schemas.ts | 44 ++++++ .../petrinaut-core/src/actions.test.ts | 67 ++++++++++ libs/@hashintel/petrinaut-core/src/actions.ts | 61 +++++++++ libs/@hashintel/petrinaut-core/src/ai.ts | 1 + .../petrinaut-core/src/extensions.ts | 10 ++ .../src/file-format/parse-sdcpn-file.ts | 8 ++ .../src/file-format/remove-visual-info.ts | 21 ++- .../src/file-format/serialize-sdcpn.test.ts | 47 +++++++ .../src/file-format/serialize-sdcpn.ts | 1 + .../petrinaut-core/src/file-format/types.ts | 20 +++ libs/@hashintel/petrinaut-core/src/index.ts | 8 ++ .../src/optimization/optimization.test.ts | 1 + .../src/schemas/status-view-schema.test.ts | 81 ++++++++++++ .../src/schemas/status-view-schema.ts | 125 ++++++++++++++++++ .../petrinaut-core/src/scoped-ids.test.ts | 69 ++++++++++ .../petrinaut-core/src/scoped-ids.ts | 64 +++++++++ .../engine/flatten-component-instances.ts | 23 +--- .../petrinaut-core/src/types/sdcpn.ts | 48 +++++++ .../react/hooks/use-petrinaut-mutations.ts | 12 ++ .../simulate-mode-allowed-mutation-names.ts | 4 + 21 files changed, 701 insertions(+), 20 deletions(-) create mode 100644 .changeset/status-views.md create mode 100644 libs/@hashintel/petrinaut-core/src/schemas/status-view-schema.test.ts create mode 100644 libs/@hashintel/petrinaut-core/src/schemas/status-view-schema.ts create mode 100644 libs/@hashintel/petrinaut-core/src/scoped-ids.test.ts create mode 100644 libs/@hashintel/petrinaut-core/src/scoped-ids.ts diff --git a/.changeset/status-views.md b/.changeset/status-views.md new file mode 100644 index 00000000000..78f03ee9059 --- /dev/null +++ b/.changeset/status-views.md @@ -0,0 +1,6 @@ +--- +"@hashintel/petrinaut-core": patch +"@hashintel/petrinaut": patch +--- + +Status views: SDCPN documents carry `identities` (named instance identities keyed by colour elements via `identityRef`) and `statusViews` (ordered, place-mapped status labels with optional token conditions and an optional exit label). Actual-mode firing records may carry the consumed/produced token values, per-instance status and time-in-state are derived from frames, the simulate panel authors status views, component-instance nodes show status badges tinted by the active label, and the canvas offers a Kanban projection with per-instance dwell. diff --git a/libs/@hashintel/petrinaut-core/src/action-schemas.ts b/libs/@hashintel/petrinaut-core/src/action-schemas.ts index 3e3a5644e21..5e21067dfd5 100644 --- a/libs/@hashintel/petrinaut-core/src/action-schemas.ts +++ b/libs/@hashintel/petrinaut-core/src/action-schemas.ts @@ -19,6 +19,10 @@ import { } from "./schemas/entity-schemas"; import { metricSchema as simulationMetricSchema } from "./schemas/metric-schema"; import { scenarioSchema as simulationScenarioSchema } from "./schemas/scenario-schema"; +import { + statusViewObjectSchema, + statusViewSchema, +} from "./schemas/status-view-schema"; import type { SelectionItem } from "./types/selection"; @@ -46,6 +50,11 @@ export { scenarioSchema as simulationScenarioSchema, type ScenarioSchema, } from "./schemas/scenario-schema"; +export { + statusLabelSchema, + statusViewSchema, + type StatusViewSchema, +} from "./schemas/status-view-schema"; export { simulationMetricSchema as metricSchema, simulationScenarioSchema as scenarioSchema, @@ -115,6 +124,14 @@ export const metricUpdateSchema = simulationMetricSchema "Fields to assign to an existing metric. Omitted fields are left unchanged.", }); +export const statusViewUpdateSchema = statusViewObjectSchema + .omit({ id: true }) + .partial() + .meta({ + description: + "Fields to assign to an existing status view. Omitted fields are left unchanged.", + }); + export const componentInstanceUpdateSchema = componentInstanceSchema .omit({ id: true, x: true, y: true }) .partial() @@ -480,6 +497,32 @@ export const mutationActionInputSchemas = { removeMetric: z .strictObject({ metricId: idSchema }) .meta({ description: "Remove a simulation metric." }), + addStatusView: statusViewSchema.meta({ + description: + "Add a status view: a named mapping from places (plus optional token conditions) to ordered status labels for the instances of one identity. Label order is the `labels` array position.", + }), + updateStatusView: z + .strictObject({ + statusViewId: idSchema, + update: statusViewUpdateSchema, + }) + .meta({ description: "Update fields on an existing status view." }), + removeStatusView: z + .strictObject({ statusViewId: idSchema }) + .meta({ description: "Remove a status view." }), + moveStatusViewLabel: z + .strictObject({ + statusViewId: idSchema, + labelId: idSchema, + toIndex: z.number().int().nonnegative().meta({ + description: + "Destination index for the label within the view's `labels` array.", + }), + }) + .meta({ + description: + "Move a label within a status view. Label order is the array position, so this reorders Kanban columns and legends.", + }), addSubnet: subnetSchema.meta({ description: "Add a reusable subnet definition. Mark subnet places with `isPort: true` to expose them as component ports.", @@ -552,6 +595,7 @@ export type DifferentialEquationInput = z.infer< export type ParameterInput = z.infer; export type ScenarioInput = z.infer; export type MetricInput = z.infer; +export type StatusViewInput = z.infer; export type ComponentInstanceInput = z.infer; export type SubnetInput = z.infer; export type NodePositionCommitInput = z.infer; diff --git a/libs/@hashintel/petrinaut-core/src/actions.test.ts b/libs/@hashintel/petrinaut-core/src/actions.test.ts index 79acb2ce35d..d3b59224baa 100644 --- a/libs/@hashintel/petrinaut-core/src/actions.test.ts +++ b/libs/@hashintel/petrinaut-core/src/actions.test.ts @@ -652,6 +652,73 @@ describe("Petrinaut core actions", () => { ]); }); + test("adds, updates, moves labels within, and removes status views", () => { + const instance = createInstance(); + + instance.mutations.addStatusView({ + id: "view-1", + name: "Ticket status", + identityRef: "identity-ticket", + labels: [ + { + id: "label-1", + name: "Todo", + displayColor: "#808080", + places: ["p1"], + }, + { + id: "label-2", + name: "Done", + displayColor: "#00AA00", + places: ["p2"], + }, + ], + }); + expect(instance.definition.get().statusViews).toHaveLength(1); + + instance.mutations.updateStatusView({ + statusViewId: "view-1", + update: { name: "Tickets" }, + }); + expect(instance.definition.get().statusViews![0]!.name).toBe("Tickets"); + + instance.mutations.moveStatusViewLabel({ + statusViewId: "view-1", + labelId: "label-2", + toIndex: 0, + }); + expect( + instance.definition + .get() + .statusViews![0]!.labels.map((label) => label.id), + ).toEqual(["label-2", "label-1"]); + + expect(() => + instance.mutations.updateStatusView({ + statusViewId: "view-1", + update: { + labels: [ + { + id: "label-1", + name: "Same", + displayColor: "#808080", + places: [], + }, + { + id: "label-2", + name: "Same", + displayColor: "#00AA00", + places: [], + }, + ], + }, + }), + ).toThrow(/Duplicate label name/); + + instance.mutations.removeStatusView({ statusViewId: "view-1" }); + expect(instance.definition.get().statusViews).toHaveLength(0); + }); + test("deleteItemsByIds removes referenced types and equations", () => { const instance = createInstance({ ...emptySDCPN, diff --git a/libs/@hashintel/petrinaut-core/src/actions.ts b/libs/@hashintel/petrinaut-core/src/actions.ts index a16c8dcc285..5376223e82c 100644 --- a/libs/@hashintel/petrinaut-core/src/actions.ts +++ b/libs/@hashintel/petrinaut-core/src/actions.ts @@ -7,6 +7,7 @@ import { mutationActionInputSchemas, placeSchema, scenarioSchema, + statusViewSchema, subnetSchema, transitionSchema, type MutationActionInput, @@ -1080,6 +1081,66 @@ export function createPetrinautActions( } }); }, + addStatusView(statusView) { + const parsedStatusView = statusViewSchema.parse(statusView); + mutateWithExtensionGuards((sdcpn) => { + const targetSdcpn = sdcpn; + targetSdcpn.statusViews ??= []; + const statusViews = targetSdcpn.statusViews; + statusViews.push(parsedStatusView); + }); + }, + updateStatusView(input) { + const parsed = mutationActionInputSchemas.updateStatusView.parse(input); + mutateWithExtensionGuards((sdcpn) => { + for (const statusView of sdcpn.statusViews ?? []) { + if (statusView.id === parsed.statusViewId) { + Object.assign(statusView, parsed.update); + statusViewSchema.parse(statusView); + break; + } + } + }); + }, + removeStatusView(input) { + const { statusViewId: parsedStatusViewId } = + mutationActionInputSchemas.removeStatusView.parse(input); + mutateWithExtensionGuards((sdcpn) => { + const statusViews = sdcpn.statusViews; + if (!statusViews) { + return; + } + for (const [index, statusView] of statusViews.entries()) { + if (statusView.id === parsedStatusViewId) { + statusViews.splice(index, 1); + break; + } + } + }); + }, + moveStatusViewLabel(input) { + const parsed = + mutationActionInputSchemas.moveStatusViewLabel.parse(input); + mutateWithExtensionGuards((sdcpn) => { + for (const statusView of sdcpn.statusViews ?? []) { + if (statusView.id === parsed.statusViewId) { + const fromIndex = statusView.labels.findIndex( + (label) => label.id === parsed.labelId, + ); + if (fromIndex === -1) { + break; + } + const [label] = statusView.labels.splice(fromIndex, 1); + if (label) { + // Splice clamps out-of-range destinations to the array end. + statusView.labels.splice(parsed.toIndex, 0, label); + statusViewSchema.parse(statusView); + } + break; + } + } + }); + }, addSubnet(subnet) { const parsedSubnet = subnetSchema.parse(subnet); mutateWithExtensionGuards((sdcpn) => { diff --git a/libs/@hashintel/petrinaut-core/src/ai.ts b/libs/@hashintel/petrinaut-core/src/ai.ts index 5b1470a108e..d2f5058ffa2 100644 --- a/libs/@hashintel/petrinaut-core/src/ai.ts +++ b/libs/@hashintel/petrinaut-core/src/ai.ts @@ -33,6 +33,7 @@ export { mutationActionInputSchemas, placeSchema, scenarioSchema, + statusViewSchema, subnetSchema, transitionSchema, } from "./action-schemas"; diff --git a/libs/@hashintel/petrinaut-core/src/extensions.ts b/libs/@hashintel/petrinaut-core/src/extensions.ts index 4585e3b7e89..f434a8a83ee 100644 --- a/libs/@hashintel/petrinaut-core/src/extensions.ts +++ b/libs/@hashintel/petrinaut-core/src/extensions.ts @@ -507,6 +507,16 @@ export const sanitizeSDCPNForExtensions = ( next.metrics = sdcpn.metrics.map((metric) => ({ ...metric })); } + if (sdcpn.statusViews) { + next.statusViews = sdcpn.statusViews.map((statusView) => ({ + ...statusView, + labels: statusView.labels.map((label) => ({ + ...label, + places: [...label.places], + })), + })); + } + if (sdcpn.subnets) { next.subnets = sdcpn.subnets.map(cloneSubnet); } diff --git a/libs/@hashintel/petrinaut-core/src/file-format/parse-sdcpn-file.ts b/libs/@hashintel/petrinaut-core/src/file-format/parse-sdcpn-file.ts index 3d318cf98a4..154346e1220 100644 --- a/libs/@hashintel/petrinaut-core/src/file-format/parse-sdcpn-file.ts +++ b/libs/@hashintel/petrinaut-core/src/file-format/parse-sdcpn-file.ts @@ -65,6 +65,7 @@ const fillMissingVisualInfo = (sdcpn: { places: Array<{ x?: number; y?: number }>; transitions: Array<{ x?: number; y?: number }>; types: Array<{ iconSlug?: string; displayColor?: string }>; + statusViews?: Array<{ labels: Array<{ displayColor?: string }> }>; componentInstances?: Array<{ x?: number; y?: number }>; subnets?: Array<{ places: Array<{ x?: number; y?: number }>; @@ -90,6 +91,13 @@ const fillMissingVisualInfo = (sdcpn: { iconSlug: type.iconSlug ?? "circle", displayColor: type.displayColor ?? "#808080", })), + statusViews: (sdcpn.statusViews ?? []).map((statusView) => ({ + ...statusView, + labels: statusView.labels.map((label) => ({ + ...label, + displayColor: label.displayColor ?? "#808080", + })), + })), componentInstances: (sdcpn.componentInstances ?? []).map((instance) => ({ ...instance, x: instance.x ?? 0, diff --git a/libs/@hashintel/petrinaut-core/src/file-format/remove-visual-info.ts b/libs/@hashintel/petrinaut-core/src/file-format/remove-visual-info.ts index cbb22b70cd2..2d8df3c24ac 100644 --- a/libs/@hashintel/petrinaut-core/src/file-format/remove-visual-info.ts +++ b/libs/@hashintel/petrinaut-core/src/file-format/remove-visual-info.ts @@ -3,6 +3,8 @@ import type { ComponentInstance, Place, SDCPN, + StatusLabel, + StatusView, Subnet, Transition, } from "../types/sdcpn"; @@ -18,8 +20,15 @@ type NetWithoutVisualInfo = Omit< }; type SubnetWithoutVisualInfo = NetWithoutVisualInfo; -type SDCPNWithoutVisualInfo = NetWithoutVisualInfo & { +type StatusViewWithoutVisualInfo = Omit & { + labels: Array>; +}; +type SDCPNWithoutVisualInfo = Omit< + NetWithoutVisualInfo, + "statusViews" +> & { subnets: SubnetWithoutVisualInfo[]; + statusViews?: StatusViewWithoutVisualInfo[]; }; const stripVisualFromNet = ( @@ -47,5 +56,15 @@ export function removeVisualInformation(sdcpn: SDCPN): SDCPNWithoutVisualInfo { return { ...stripVisualFromNet(sdcpn), subnets: (sdcpn.subnets ?? []).map((subnet) => stripVisualFromNet(subnet)), + ...(sdcpn.statusViews + ? { + statusViews: sdcpn.statusViews.map((statusView) => ({ + ...statusView, + labels: statusView.labels.map( + ({ displayColor: _displayColor, ...label }) => label, + ), + })), + } + : {}), }; } diff --git a/libs/@hashintel/petrinaut-core/src/file-format/serialize-sdcpn.test.ts b/libs/@hashintel/petrinaut-core/src/file-format/serialize-sdcpn.test.ts index 2ec279c327f..c5e8b5bd421 100644 --- a/libs/@hashintel/petrinaut-core/src/file-format/serialize-sdcpn.test.ts +++ b/libs/@hashintel/petrinaut-core/src/file-format/serialize-sdcpn.test.ts @@ -46,6 +46,29 @@ const sourceDocument = { componentInstances: [ { id: "instance1", name: "Instance 1", subnetId: "subnet1", x: 0, y: 0 }, ], + statusViews: [ + { + id: "view1", + name: "Ticket status", + identityRef: "identity-ticket", + labels: [ + { + id: "label1", + name: "In Progress", + displayColor: "#1E90FF", + places: ["p1", "instance1::p1"], + tokenCondition: "attempts === 0", + }, + { + id: "label2", + name: "Gone", + displayColor: "#333333", + places: [], + isExit: true, + }, + ], + }, + ], }; const parseFixture = () => { @@ -111,6 +134,29 @@ describe("serializeSDCPN", () => { expect(reimported.hadMissingPositions).toBe(true); }); + it("strips status label colours with removeVisualInfo and defaults them on import", () => { + const sdcpn = parseFixture(); + + const text = serializeSDCPN({ + petriNetDefinition: sdcpn, + title: "Test Net", + removeVisualInfo: true, + format: "json", + }); + + const document = JSON.parse(text) as { + statusViews: { labels: { displayColor?: string }[] }[]; + }; + expect(document.statusViews[0]!.labels[0]!.displayColor).toBeUndefined(); + + const reimported = parseSDCPNDocument(text); + expect(reimported.ok).toBe(true); + if (!reimported.ok) return; + expect(reimported.sdcpn.statusViews![0]!.labels[0]!.displayColor).toBe( + "#808080", + ); + }); + it("writes format metadata first, then the sections in dependency order", () => { const sdcpn = parseFixture(); @@ -144,6 +190,7 @@ describe("serializeSDCPN", () => { "transitions", "metrics", "scenarios", + "statusViews", ]); expect(Object.keys(document.subnets![0]!)).toEqual([ "id", diff --git a/libs/@hashintel/petrinaut-core/src/file-format/serialize-sdcpn.ts b/libs/@hashintel/petrinaut-core/src/file-format/serialize-sdcpn.ts index a3fac5c890b..1b895a6a661 100644 --- a/libs/@hashintel/petrinaut-core/src/file-format/serialize-sdcpn.ts +++ b/libs/@hashintel/petrinaut-core/src/file-format/serialize-sdcpn.ts @@ -28,6 +28,7 @@ const DOCUMENT_KEY_ORDER = [ "transitions", "metrics", "scenarios", + "statusViews", ] as const satisfies readonly SDCPNDocumentKey[]; const SUBNET_KEY_ORDER = [ diff --git a/libs/@hashintel/petrinaut-core/src/file-format/types.ts b/libs/@hashintel/petrinaut-core/src/file-format/types.ts index 50dd63d746f..f9d4693afae 100644 --- a/libs/@hashintel/petrinaut-core/src/file-format/types.ts +++ b/libs/@hashintel/petrinaut-core/src/file-format/types.ts @@ -19,6 +19,10 @@ import { scenarioParameterSchema as currentScenarioParameterSchema, scenarioSchema as currentScenarioSchema, } from "../schemas/scenario-schema"; +import { + statusLabelSchema as currentStatusLabelSchema, + statusViewObjectSchema as currentStatusViewObjectSchema, +} from "../schemas/status-view-schema"; export const SDCPN_FILE_FORMAT_VERSION = 1; @@ -137,6 +141,21 @@ const metricSchema = z.object({ code: z.string().default(""), }); +const statusLabelSchema = z.object({ + ...currentStatusLabelSchema.shape, + id: z.string(), + name: z.string(), + displayColor: z.string().optional(), +}); + +const statusViewSchema = z.object({ + ...currentStatusViewObjectSchema.shape, + id: z.string(), + name: z.string(), + identityRef: z.string(), + labels: z.array(statusLabelSchema).default([]), +}); + const componentInstanceSchema = z.object({ ...currentComponentInstanceSchema.shape, id: z.string(), @@ -169,6 +188,7 @@ export const sdcpnSchema = z.object({ parameters: z.array(parameterSchema).default([]), scenarios: z.array(scenarioSchema).default([]), metrics: z.array(metricSchema).default([]), + statusViews: z.array(statusViewSchema).default([]), subnets: z.array(subnetSchema).default([]), componentInstances: z.array(componentInstanceSchema).default([]), }); diff --git a/libs/@hashintel/petrinaut-core/src/index.ts b/libs/@hashintel/petrinaut-core/src/index.ts index 5794c6d02b9..8240186755b 100644 --- a/libs/@hashintel/petrinaut-core/src/index.ts +++ b/libs/@hashintel/petrinaut-core/src/index.ts @@ -220,6 +220,7 @@ export { scenarioSchema, setNetTitleToolInputSchema, setNetTitleToolName, + statusViewSchema, subnetSchema, transitionSchema, } from "./ai"; @@ -424,6 +425,13 @@ export { placeArcEndpoint, } from "./arc-endpoints"; export { GRID_SIZE } from "./grid-size"; +export { + formatScopedId, + isScopedId, + parseScopedId, + SCOPED_ID_SEPARATOR, + type ParsedScopedId, +} from "./scoped-ids"; export { type DefaultParameterValues, deriveDefaultParameterValues, diff --git a/libs/@hashintel/petrinaut-core/src/optimization/optimization.test.ts b/libs/@hashintel/petrinaut-core/src/optimization/optimization.test.ts index a441e610672..a4b525d2aae 100644 --- a/libs/@hashintel/petrinaut-core/src/optimization/optimization.test.ts +++ b/libs/@hashintel/petrinaut-core/src/optimization/optimization.test.ts @@ -28,6 +28,7 @@ const definition = { componentInstances: [], scenarios: [scenario], metrics: [{ id: "profit", name: "Profit", code: "return 1;" }], + statusViews: [], }; const validManifest = { diff --git a/libs/@hashintel/petrinaut-core/src/schemas/status-view-schema.test.ts b/libs/@hashintel/petrinaut-core/src/schemas/status-view-schema.test.ts new file mode 100644 index 00000000000..4b4096af5f2 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/schemas/status-view-schema.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; + +import { statusViewSchema } from "./status-view-schema"; + +import type { StatusView } from "../types/sdcpn"; + +const todoLabel = { + id: "label-todo", + name: "Todo", + displayColor: "#808080", + places: ["place-todo"], +}; + +const inProgressLabel = { + id: "label-in-progress", + name: "In Progress", + displayColor: "#1E90FF", + places: ["place-doing", "instance-1::place-doing"], + tokenCondition: "attempts === 0", +}; + +const exitLabel = { + id: "label-exit", + name: "Gone", + displayColor: "#333333", + places: [], + isExit: true, +}; + +const validStatusView: StatusView = { + id: "view-1", + name: "Ticket status", + identityRef: "identity-ticket", + labels: [todoLabel, inProgressLabel, exitLabel], +}; + +const parseExpectingIssues = (input: unknown) => { + const result = statusViewSchema.safeParse(input); + expect(result.success).toBe(false); + return result.success ? [] : result.error.issues; +}; + +describe("statusViewSchema", () => { + it("accepts a view with place labels, a token condition, and one exit label", () => { + expect(statusViewSchema.parse(validStatusView)).toEqual(validStatusView); + }); + + it("rejects duplicate label ids", () => { + const issues = parseExpectingIssues({ + ...validStatusView, + labels: [todoLabel, { ...inProgressLabel, id: todoLabel.id }], + }); + expect(issues.some((issue) => issue.path.includes("id"))).toBe(true); + }); + + it("rejects duplicate label names", () => { + const issues = parseExpectingIssues({ + ...validStatusView, + labels: [todoLabel, { ...inProgressLabel, name: "Todo" }], + }); + expect(issues.some((issue) => issue.path.includes("name"))).toBe(true); + }); + + it("rejects more than one exit label", () => { + const issues = parseExpectingIssues({ + ...validStatusView, + labels: [exitLabel, { ...exitLabel, id: "label-exit-2", name: "Gone 2" }], + }); + expect( + issues.some((issue) => issue.message.includes("at most one exit label")), + ).toBe(true); + }); + + it("rejects an exit label with places", () => { + const issues = parseExpectingIssues({ + ...validStatusView, + labels: [{ ...exitLabel, places: ["place-done"] }], + }); + expect(issues.some((issue) => issue.path.includes("places"))).toBe(true); + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/schemas/status-view-schema.ts b/libs/@hashintel/petrinaut-core/src/schemas/status-view-schema.ts new file mode 100644 index 00000000000..54223da2a7e --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/schemas/status-view-schema.ts @@ -0,0 +1,125 @@ +import { z } from "zod"; + +import { displayNameSchema } from "../validation/display-name"; +import { idSchema } from "./entity-schemas"; + +import type { StatusLabel, StatusView } from "../types/sdcpn"; + +export const statusLabelSchema = z + .strictObject({ + id: idSchema, + name: displayNameSchema.meta({ + description: + "Human-readable label name shown on badges and as a Kanban column title (e.g. `In Progress`, `Blocked`).", + }), + displayColor: z.string().min(1).meta({ + description: + 'CSS colour string for the label\'s badge, tint, and Kanban column, e.g. `"#1E90FF"`.', + }), + places: z.array(idSchema).meta({ + description: + "IDs of the places whose tokens carry this label. Several places may map to one label. A componentInstance's copy of a subnet place is addressed by its scoped id `instanceId::placeId` (nested instances give `outer::inner::placeId`). MUST be empty when `isExit` is true.", + }), + tokenCondition: z.string().optional().meta({ + description: + "Optional boolean expression over the token's attributes (e.g. `attempts > 0`). The label applies only while the token is in the label's places AND this expression holds. Omit to match every token in the places.", + }), + isExit: z.boolean().optional().meta({ + description: + "Marks the view's exit label: it is assigned to a tracked instance whose token has left every place of the view's labels (e.g. consumed by a final transition with no sink place). At most one label per view may set this, and an exit label has no `places`. Prefer explicit sink places for distinct terminal states such as Done vs Failed.", + }), + }) + .meta({ + description: "One named status within a status view.", + }) satisfies z.ZodType; + +/** + * The `statusViewSchema` shape without the whole-view label invariants, for + * deriving partial-update schemas. Parse full views with `statusViewSchema`. + */ +export const statusViewObjectSchema = z.strictObject({ + id: idSchema, + name: displayNameSchema.meta({ + description: "Human-readable status view name (e.g. `Ticket status`).", + }), + description: z.string().optional().meta({ + description: "Optional status view summary shown to users.", + }), + identityRef: idSchema.meta({ + description: + "ID of the Identity this view tracks. Tokens participate when their colour has an element referencing the same identity, and instances are keyed by that element's value.", + }), + labels: z.array(statusLabelSchema).meta({ + description: + "The view's labels. Position in this array is the label's order: the Kanban column position and the legend position. Reorder with `moveStatusViewLabel`.", + }), +}); + +const assertStatusViewLabelInvariants = (ctx: { + value: { labels: StatusLabel[] }; + issues: { + push(issue: { + code: "custom"; + path: (string | number)[]; + message: string; + input: unknown; + }): void; + }; +}) => { + const seenIds = new Set(); + const seenNames = new Set(); + let exitLabelSeen = false; + + for (const [index, label] of ctx.value.labels.entries()) { + if (seenIds.has(label.id)) { + ctx.issues.push({ + code: "custom", + path: ["labels", index, "id"], + message: `Duplicate label id \`${label.id}\`. Label ids must be unique within a status view.`, + input: label.id, + }); + } + seenIds.add(label.id); + + if (seenNames.has(label.name)) { + ctx.issues.push({ + code: "custom", + path: ["labels", index, "name"], + message: `Duplicate label name \`${label.name}\`. Label names must be unique within a status view.`, + input: label.name, + }); + } + seenNames.add(label.name); + + if (label.isExit) { + if (exitLabelSeen) { + ctx.issues.push({ + code: "custom", + path: ["labels", index, "isExit"], + message: "A status view may declare at most one exit label.", + input: label.isExit, + }); + } + exitLabelSeen = true; + + if (label.places.length > 0) { + ctx.issues.push({ + code: "custom", + path: ["labels", index, "places"], + message: + "An exit label has no places — it applies to instances whose token has left the view's places.", + input: label.places, + }); + } + } + } +}; + +export const statusViewSchema = statusViewObjectSchema + .check(assertStatusViewLabelInvariants) + .meta({ + description: + "A user-defined mapping from net state to named statuses for the instances of one identity. Which label a tracked instance carries is derived from where its token sits (and the labels' token conditions) — status is never stored.", + }) satisfies z.ZodType; + +export type StatusViewSchema = typeof statusViewSchema; diff --git a/libs/@hashintel/petrinaut-core/src/scoped-ids.test.ts b/libs/@hashintel/petrinaut-core/src/scoped-ids.test.ts new file mode 100644 index 00000000000..b4393225c44 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/scoped-ids.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; + +import { + formatScopedId, + isScopedId, + parseScopedId, + SCOPED_ID_SEPARATOR, +} from "./scoped-ids"; + +describe("formatScopedId", () => { + it("returns the entity id unchanged for an empty instance path", () => { + expect(formatScopedId([], "place-1")).toBe("place-1"); + }); + + it("joins the instance path and entity id with the separator", () => { + expect(formatScopedId(["instance-1"], "place-1")).toBe( + "instance-1::place-1", + ); + expect(formatScopedId(["outer", "inner"], "place-1")).toBe( + "outer::inner::place-1", + ); + }); + + it("rejects segments containing the separator", () => { + expect(() => formatScopedId([], `a${SCOPED_ID_SEPARATOR}b`)).toThrow( + /scope separator/, + ); + expect(() => formatScopedId(["a::b"], "place-1")).toThrow( + /scope separator/, + ); + }); +}); + +describe("parseScopedId", () => { + it("parses an unscoped id to an empty instance path", () => { + expect(parseScopedId("place-1")).toEqual({ + instancePath: [], + entityId: "place-1", + }); + }); + + it("parses a scoped id into path and entity id", () => { + expect(parseScopedId("instance-1::place-1")).toEqual({ + instancePath: ["instance-1"], + entityId: "place-1", + }); + }); + + it("parses nested instance paths outermost-first", () => { + expect(parseScopedId("outer::inner::place-1")).toEqual({ + instancePath: ["outer", "inner"], + entityId: "place-1", + }); + }); + + it("round-trips through formatScopedId", () => { + const { instancePath, entityId } = parseScopedId("outer::inner::place-1"); + expect(formatScopedId(instancePath, entityId)).toBe( + "outer::inner::place-1", + ); + }); +}); + +describe("isScopedId", () => { + it("distinguishes scoped from unscoped ids", () => { + expect(isScopedId("place-1")).toBe(false); + expect(isScopedId("instance-1::place-1")).toBe(true); + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/scoped-ids.ts b/libs/@hashintel/petrinaut-core/src/scoped-ids.ts new file mode 100644 index 00000000000..6243b948428 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/scoped-ids.ts @@ -0,0 +1,64 @@ +/** + * Scoped entity ids for component instances. + * + * When a net containing component instances is flattened for simulation, + * every subnet entity id is rewritten to `instanceId::entityId`, with nested + * instances giving `outer::inner::entityId`. Simulation frames, firing + * records, and status-view place references all use these ids, so parsing + * and formatting live here rather than being re-derived from the string + * shape at each consumer. + */ + +import type { ID } from "./types/sdcpn"; + +export const SCOPED_ID_SEPARATOR = "::"; + +const assertScopableId = (id: ID): void => { + if (id.includes(SCOPED_ID_SEPARATOR)) { + throw new Error( + `SDCPN IDs used with component instances must not contain the scope separator \`${SCOPED_ID_SEPARATOR}\`: \`${id}\`.`, + ); + } +}; + +/** + * Formats an entity id under a component-instance path. An empty path + * returns the id unchanged (a root-net entity). Throws when any segment + * already contains the separator, since such an id cannot be parsed back. + */ +export const formatScopedId = ( + instancePath: readonly ID[], + entityId: ID, +): ID => { + for (const instanceId of instancePath) { + assertScopableId(instanceId); + } + assertScopableId(entityId); + + return instancePath.length === 0 + ? entityId + : [...instancePath, entityId].join(SCOPED_ID_SEPARATOR); +}; + +export type ParsedScopedId = { + /** + * Component-instance ids from outermost to innermost. Empty for a + * root-net entity id. + */ + instancePath: ID[]; + /** The entity's id within its defining net. */ + entityId: ID; +}; + +/** + * Splits a (possibly) scoped id into its component-instance path and the + * entity id within the defining net. An unscoped id parses to an empty path. + */ +export const parseScopedId = (scopedId: ID): ParsedScopedId => { + const segments = scopedId.split(SCOPED_ID_SEPARATOR); + const entityId = segments[segments.length - 1] ?? scopedId; + return { instancePath: segments.slice(0, -1), entityId }; +}; + +/** Whether an id addresses an entity inside a component instance. */ +export const isScopedId = (id: ID): boolean => id.includes(SCOPED_ID_SEPARATOR); diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/flatten-component-instances.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/flatten-component-instances.ts index b828c856030..494a8c35d71 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/flatten-component-instances.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/flatten-component-instances.ts @@ -1,4 +1,5 @@ import { getArcEndpoint } from "../../arc-endpoints"; +import { formatScopedId, SCOPED_ID_SEPARATOR } from "../../scoped-ids"; import { createUserKeyedRecord, getOwn } from "../../validation/record-keys"; import type { @@ -31,24 +32,7 @@ export const getArcPlaceNameOverrideKey = ({ placeId: ID; }): string => `${transitionId}\u0000${placeId}`; -const scopeSeparator = "::"; - -const assertScopableId = (id: ID): void => { - if (id.includes(scopeSeparator)) { - throw new Error( - `SDCPN IDs used with component instances must not contain the scope separator \`${scopeSeparator}\`: \`${id}\`.`, - ); - } -}; - -const scopedId = (path: readonly ID[], id: ID): ID => { - for (const part of path) { - assertScopableId(part); - } - assertScopableId(id); - - return path.length === 0 ? id : [...path, id].join(scopeSeparator); -}; +const scopedId = formatScopedId; const _codeIdentifier = (value: string): string => { const cleaned = value.replace(/[^A-Za-z0-9_$]/g, "_"); @@ -64,7 +48,7 @@ const scopedPortPlaceName = ({ }: { instance: ComponentInstance; portName: string; -}): string => `${instance.name}${scopeSeparator}${portName}`; +}): string => `${instance.name}${SCOPED_ID_SEPARATOR}${portName}`; const coerceParameterValue = ( parameter: Parameter, @@ -456,6 +440,7 @@ export const flattenComponentInstancesForSimulation = ({ parameters: [], scenarios: sdcpn.scenarios?.map((scenario) => ({ ...scenario })), metrics: sdcpn.metrics?.map((metric) => ({ ...metric })), + statusViews: sdcpn.statusViews?.map((statusView) => ({ ...statusView })), subnets: [], componentInstances: [], }; diff --git a/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts b/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts index 249fb42cb20..3ffc5ad29f5 100644 --- a/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts +++ b/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts @@ -336,6 +336,53 @@ export type Metric = { code: string; }; +/** + * One named status within a status view, mapped to the places whose tokens + * carry it. Labels are many-to-one: several places can map to the same label. + */ +export type StatusLabel = { + id: ID; + name: string; + /** CSS colour used for the label's badge, tint, and Kanban column. */ + displayColor: string; + /** + * Places whose tokens carry this label. A componentInstance's copy of a + * subnet place is addressed by scoped id (`instanceId::placeId`, see + * `scoped-ids.ts`). Empty for an exit label. + */ + places: ID[]; + /** + * Optional boolean expression over the token's attributes; the label + * applies only while the token is in the label's places AND the + * expression holds. + */ + tokenCondition?: string; + /** + * Marks the view's exit label, assigned to an instance whose token has + * left every place of the view's labels. At most one per view, and it + * has no places. + */ + isExit?: boolean; +}; + +/** + * A user-defined mapping from net state to named statuses for the instances + * of one identity: which label each tracked instance carries is derived from + * where its token sits (and the labels' token conditions), never stored. + */ +export type StatusView = { + id: ID; + name: string; + description?: string; + /** Id of the Identity this view tracks. */ + identityRef: ID; + /** + * Position in this array is the label's order: the Kanban column + * position and the legend position. + */ + labels: StatusLabel[]; +}; + /** * An instance of a subnet placed inside another net. */ @@ -383,6 +430,7 @@ export type SDCPN = { parameters: Parameter[]; scenarios?: Scenario[]; metrics?: Metric[]; + statusViews?: StatusView[]; subnets?: Subnet[]; componentInstances?: ComponentInstance[]; }; diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts index 52a8829ceff..3e7d91739b6 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts @@ -107,6 +107,18 @@ export function usePetrinautMutations(): PetrinautMutations { removeMetric: withReadonlyGuard("removeMetric", { targetActiveSubnet: false, }), + addStatusView: withReadonlyGuard("addStatusView", { + targetActiveSubnet: false, + }), + updateStatusView: withReadonlyGuard("updateStatusView", { + targetActiveSubnet: false, + }), + removeStatusView: withReadonlyGuard("removeStatusView", { + targetActiveSubnet: false, + }), + moveStatusViewLabel: withReadonlyGuard("moveStatusViewLabel", { + targetActiveSubnet: false, + }), addSubnet: withReadonlyGuard("addSubnet", { targetActiveSubnet: false }), updateSubnet: withReadonlyGuard("updateSubnet", { targetActiveSubnet: false, diff --git a/libs/@hashintel/petrinaut/src/react/state/simulate-mode-allowed-mutation-names.ts b/libs/@hashintel/petrinaut/src/react/state/simulate-mode-allowed-mutation-names.ts index 8800f95d344..3debb298af7 100644 --- a/libs/@hashintel/petrinaut/src/react/state/simulate-mode-allowed-mutation-names.ts +++ b/libs/@hashintel/petrinaut/src/react/state/simulate-mode-allowed-mutation-names.ts @@ -15,4 +15,8 @@ export const simulateModeAllowedMutationNames = new Set< "addMetric", "updateMetric", "removeMetric", + "addStatusView", + "updateStatusView", + "removeStatusView", + "moveStatusViewLabel", ]); From a9da9ad3e3c5a047443ad8148ec1b0c598863d09 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 18:08:04 +0000 Subject: [PATCH 02/52] Add identities to the SDCPN and identityRef key markers on colour elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Identity is its own root concept: `identities` on the SDCPN root declares what a status view tracks (e.g. "ticket") and the key's element types, with two or more entries forming a compound key correlated by tuple equality. A colour element referencing an identity via `identityRef` is that identity's key element — there is no separate key flag — so keys correlate across colours without relying on element-name equality. Removing an identity clears element references and dependent status views atomically. The type properties panel gains a per-element identity picker that can also create a new identity. --- .../petrinaut-core/src/action-schemas.ts | 25 +++++++ .../petrinaut-core/src/actions.test.ts | 52 ++++++++++++++ libs/@hashintel/petrinaut-core/src/actions.ts | 56 +++++++++++++++ libs/@hashintel/petrinaut-core/src/ai.ts | 1 + .../petrinaut-core/src/extensions.ts | 7 ++ .../src/file-format/serialize-sdcpn.test.ts | 24 +++++++ .../src/file-format/serialize-sdcpn.ts | 1 + .../petrinaut-core/src/file-format/types.ts | 8 +++ libs/@hashintel/petrinaut-core/src/index.ts | 1 + .../src/optimization/optimization.test.ts | 1 + .../src/schemas/entity-schemas.ts | 22 ++++++ .../engine/flatten-component-instances.ts | 1 + .../petrinaut-core/src/types/sdcpn.ts | 24 +++++++ .../react/hooks/use-petrinaut-mutations.ts | 9 +++ .../type-properties/subviews/main.tsx | 72 ++++++++++++++++++- 15 files changed, 303 insertions(+), 1 deletion(-) diff --git a/libs/@hashintel/petrinaut-core/src/action-schemas.ts b/libs/@hashintel/petrinaut-core/src/action-schemas.ts index 5e21067dfd5..d2c1b5714b3 100644 --- a/libs/@hashintel/petrinaut-core/src/action-schemas.ts +++ b/libs/@hashintel/petrinaut-core/src/action-schemas.ts @@ -7,6 +7,7 @@ import { colorSchema, componentInstanceSchema, differentialEquationSchema, + identitySchema, idSchema, inputArcSchema, nodePositionCommitSchema, @@ -33,6 +34,7 @@ export { colorSchema, componentInstanceSchema, differentialEquationSchema, + identitySchema, idSchema, nodePositionCommitSchema, parameterSchema, @@ -124,6 +126,14 @@ export const metricUpdateSchema = simulationMetricSchema "Fields to assign to an existing metric. Omitted fields are left unchanged.", }); +export const identityUpdateSchema = identitySchema + .omit({ id: true }) + .partial() + .meta({ + description: + "Fields to assign to an existing identity. Omitted fields are left unchanged.", + }); + export const statusViewUpdateSchema = statusViewObjectSchema .omit({ id: true }) .partial() @@ -497,6 +507,20 @@ export const mutationActionInputSchemas = { removeMetric: z .strictObject({ metricId: idSchema }) .meta({ description: "Remove a simulation metric." }), + addIdentity: identitySchema.meta({ + description: + "Add an instance identity (e.g. `Ticket`). Mark a colour element as the identity's key by setting the element's `identityRef` to the identity's ID.", + }), + updateIdentity: z + .strictObject({ + identityId: idSchema, + update: identityUpdateSchema, + }) + .meta({ description: "Update fields on an existing identity." }), + removeIdentity: z.strictObject({ identityId: idSchema }).meta({ + description: + "Remove an identity, clearing `identityRef` from colour elements that reference it and removing status views that track it.", + }), addStatusView: statusViewSchema.meta({ description: "Add a status view: a named mapping from places (plus optional token conditions) to ordered status labels for the instances of one identity. Label order is the `labels` array position.", @@ -595,6 +619,7 @@ export type DifferentialEquationInput = z.infer< export type ParameterInput = z.infer; export type ScenarioInput = z.infer; export type MetricInput = z.infer; +export type IdentityInput = z.infer; export type StatusViewInput = z.infer; export type ComponentInstanceInput = z.infer; export type SubnetInput = z.infer; diff --git a/libs/@hashintel/petrinaut-core/src/actions.test.ts b/libs/@hashintel/petrinaut-core/src/actions.test.ts index d3b59224baa..48a68b17c5a 100644 --- a/libs/@hashintel/petrinaut-core/src/actions.test.ts +++ b/libs/@hashintel/petrinaut-core/src/actions.test.ts @@ -652,6 +652,58 @@ describe("Petrinaut core actions", () => { ]); }); + test("adds, updates, and removes identities, clearing references on removal", () => { + const instance = createInstance(); + + instance.mutations.addIdentity({ + id: "identity-ticket", + name: "Ticket", + keyElementTypes: ["string"], + }); + instance.mutations.updateIdentity({ + identityId: "identity-ticket", + update: { keyElementTypes: ["uuid"] }, + }); + expect(instance.definition.get().identities).toEqual([ + { id: "identity-ticket", name: "Ticket", keyElementTypes: ["uuid"] }, + ]); + + instance.mutations.addType({ + id: "type-1", + name: "Ticket", + iconSlug: "circle", + displayColor: "#1E90FF", + elements: [ + { + elementId: "element-1", + name: "ticket_id", + type: "uuid", + identityRef: "identity-ticket", + }, + ], + }); + instance.mutations.addStatusView({ + id: "view-1", + name: "Ticket status", + identityRef: "identity-ticket", + labels: [ + { + id: "label-1", + name: "Todo", + displayColor: "#808080", + places: ["p1"], + }, + ], + }); + + instance.mutations.removeIdentity({ identityId: "identity-ticket" }); + + const definition = instance.definition.get(); + expect(definition.identities).toEqual([]); + expect(definition.types[0]!.elements[0]!.identityRef).toBeUndefined(); + expect(definition.statusViews).toEqual([]); + }); + test("adds, updates, moves labels within, and removes status views", () => { const instance = createInstance(); diff --git a/libs/@hashintel/petrinaut-core/src/actions.ts b/libs/@hashintel/petrinaut-core/src/actions.ts index 5376223e82c..55b3bf7b20f 100644 --- a/libs/@hashintel/petrinaut-core/src/actions.ts +++ b/libs/@hashintel/petrinaut-core/src/actions.ts @@ -2,6 +2,7 @@ import { colorSchema, componentInstanceSchema, differentialEquationSchema, + identitySchema, metricSchema, parameterSchema, mutationActionInputSchemas, @@ -1081,6 +1082,61 @@ export function createPetrinautActions( } }); }, + addIdentity(identity) { + const parsedIdentity = + mutationActionInputSchemas.addIdentity.parse(identity); + mutateWithExtensionGuards((sdcpn) => { + const targetSdcpn = sdcpn; + targetSdcpn.identities ??= []; + const identities = targetSdcpn.identities; + identities.push(parsedIdentity); + }); + }, + updateIdentity(input) { + const parsed = mutationActionInputSchemas.updateIdentity.parse(input); + mutateWithExtensionGuards((sdcpn) => { + for (const identity of sdcpn.identities ?? []) { + if (identity.id === parsed.identityId) { + Object.assign(identity, parsed.update); + identitySchema.parse(identity); + break; + } + } + }); + }, + removeIdentity(input) { + const { identityId: parsedIdentityId } = + mutationActionInputSchemas.removeIdentity.parse(input); + mutateWithExtensionGuards((sdcpn) => { + const identities = sdcpn.identities; + if (!identities) { + return; + } + for (const [index, identity] of identities.entries()) { + if (identity.id === parsedIdentityId) { + identities.splice(index, 1); + break; + } + } + for (const net of getAllMutableNets(sdcpn)) { + for (const type of net.types) { + for (const element of type.elements) { + if (element.identityRef === parsedIdentityId) { + delete element.identityRef; + } + } + } + } + const statusViews = sdcpn.statusViews; + if (statusViews) { + for (let index = statusViews.length - 1; index >= 0; index--) { + if (statusViews[index]!.identityRef === parsedIdentityId) { + statusViews.splice(index, 1); + } + } + } + }); + }, addStatusView(statusView) { const parsedStatusView = statusViewSchema.parse(statusView); mutateWithExtensionGuards((sdcpn) => { diff --git a/libs/@hashintel/petrinaut-core/src/ai.ts b/libs/@hashintel/petrinaut-core/src/ai.ts index d2f5058ffa2..e401e3de7af 100644 --- a/libs/@hashintel/petrinaut-core/src/ai.ts +++ b/libs/@hashintel/petrinaut-core/src/ai.ts @@ -28,6 +28,7 @@ export { colorSchema, componentInstanceSchema, differentialEquationSchema, + identitySchema, metricSchema, parameterSchema, mutationActionInputSchemas, diff --git a/libs/@hashintel/petrinaut-core/src/extensions.ts b/libs/@hashintel/petrinaut-core/src/extensions.ts index f434a8a83ee..0fb3222360a 100644 --- a/libs/@hashintel/petrinaut-core/src/extensions.ts +++ b/libs/@hashintel/petrinaut-core/src/extensions.ts @@ -507,6 +507,13 @@ export const sanitizeSDCPNForExtensions = ( next.metrics = sdcpn.metrics.map((metric) => ({ ...metric })); } + if (sdcpn.identities) { + next.identities = sdcpn.identities.map((identity) => ({ + ...identity, + keyElementTypes: [...identity.keyElementTypes], + })); + } + if (sdcpn.statusViews) { next.statusViews = sdcpn.statusViews.map((statusView) => ({ ...statusView, diff --git a/libs/@hashintel/petrinaut-core/src/file-format/serialize-sdcpn.test.ts b/libs/@hashintel/petrinaut-core/src/file-format/serialize-sdcpn.test.ts index c5e8b5bd421..c565f68bf74 100644 --- a/libs/@hashintel/petrinaut-core/src/file-format/serialize-sdcpn.test.ts +++ b/libs/@hashintel/petrinaut-core/src/file-format/serialize-sdcpn.test.ts @@ -46,6 +46,29 @@ const sourceDocument = { componentInstances: [ { id: "instance1", name: "Instance 1", subnetId: "subnet1", x: 0, y: 0 }, ], + types: [ + { + id: "color1", + name: "Ticket", + iconSlug: "circle", + displayColor: "#1E90FF", + elements: [ + { + elementId: "element1", + name: "ticket_id", + type: "string", + identityRef: "identity-ticket", + }, + ], + }, + ], + identities: [ + { + id: "identity-ticket", + name: "Ticket", + keyElementTypes: ["string"], + }, + ], statusViews: [ { id: "view1", @@ -182,6 +205,7 @@ describe("serializeSDCPN", () => { "description", "metadata", "parameters", + "identities", "types", "differentialEquations", "subnets", diff --git a/libs/@hashintel/petrinaut-core/src/file-format/serialize-sdcpn.ts b/libs/@hashintel/petrinaut-core/src/file-format/serialize-sdcpn.ts index 1b895a6a661..fad08166eda 100644 --- a/libs/@hashintel/petrinaut-core/src/file-format/serialize-sdcpn.ts +++ b/libs/@hashintel/petrinaut-core/src/file-format/serialize-sdcpn.ts @@ -20,6 +20,7 @@ const DOCUMENT_KEY_ORDER = [ "description", "metadata", "parameters", + "identities", "types", "differentialEquations", "subnets", diff --git a/libs/@hashintel/petrinaut-core/src/file-format/types.ts b/libs/@hashintel/petrinaut-core/src/file-format/types.ts index f9d4693afae..0a525b5e30b 100644 --- a/libs/@hashintel/petrinaut-core/src/file-format/types.ts +++ b/libs/@hashintel/petrinaut-core/src/file-format/types.ts @@ -7,6 +7,7 @@ import { componentInstanceSchema as currentComponentInstanceSchema, descriptionSchema, differentialEquationSchema as currentDifferentialEquationSchema, + identitySchema as currentIdentitySchema, inputArcSchema as currentInputArcSchema, metadataSchema, outputArcSchema as currentOutputArcSchema, @@ -141,6 +142,12 @@ const metricSchema = z.object({ code: z.string().default(""), }); +const identitySchema = z.object({ + ...currentIdentitySchema.shape, + id: z.string(), + name: z.string(), +}); + const statusLabelSchema = z.object({ ...currentStatusLabelSchema.shape, id: z.string(), @@ -188,6 +195,7 @@ export const sdcpnSchema = z.object({ parameters: z.array(parameterSchema).default([]), scenarios: z.array(scenarioSchema).default([]), metrics: z.array(metricSchema).default([]), + identities: z.array(identitySchema).default([]), statusViews: z.array(statusViewSchema).default([]), subnets: z.array(subnetSchema).default([]), componentInstances: z.array(componentInstanceSchema).default([]), diff --git a/libs/@hashintel/petrinaut-core/src/index.ts b/libs/@hashintel/petrinaut-core/src/index.ts index 8240186755b..6a0651e1f3e 100644 --- a/libs/@hashintel/petrinaut-core/src/index.ts +++ b/libs/@hashintel/petrinaut-core/src/index.ts @@ -206,6 +206,7 @@ export { differentialEquationSchema, getLatestNetDefinitionToolName, getNetCompilationErrorsToolName, + identitySchema, metricSchema, parameterSchema, petrinautAiCommandTools, diff --git a/libs/@hashintel/petrinaut-core/src/optimization/optimization.test.ts b/libs/@hashintel/petrinaut-core/src/optimization/optimization.test.ts index a4b525d2aae..29b86bd5ed4 100644 --- a/libs/@hashintel/petrinaut-core/src/optimization/optimization.test.ts +++ b/libs/@hashintel/petrinaut-core/src/optimization/optimization.test.ts @@ -28,6 +28,7 @@ const definition = { componentInstances: [], scenarios: [scenario], metrics: [{ id: "profit", name: "Profit", code: "return 1;" }], + identities: [], statusViews: [], }; diff --git a/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts b/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts index 3abf71c59fa..d4324e5dff5 100644 --- a/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts +++ b/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts @@ -17,6 +17,7 @@ import type { Color, ComponentInstance, DifferentialEquation, + Identity, InputArc, OutputArc, Parameter, @@ -181,6 +182,10 @@ export const colorElementSchema = z description: "Token attribute identifier used DIRECTLY in code. Lambdas, kernels, dynamics, visualizers, and metrics destructure tokens as `{ }`, so this must be a valid JavaScript identifier (e.g. `machine_damage_ratio`, `x`, `velocity`). Spaces, hyphens, and leading digits will break user code that references the attribute; prefer lower_snake_case for consistency with parameter naming.", }), + identityRef: idSchema.optional().meta({ + description: + "ID of the Identity whose key this element carries. Setting it marks the element as a key element — there is no separate key flag — and tokens whose key elements are tuple-equal are the same instance, across colours. Status views track instances of the referenced identity.", + }), type: z.enum(COLOR_ELEMENT_TYPES).meta({ description: "`real` is continuous and may be updated by dynamics. `integer`, `boolean`, `uuid`, and `string` are discrete token attributes updated by transition kernels. `integer` values are stored as Float64 and rounded on read/write: they are exact only within ±2^53 (±9,007,199,254,740,992); values beyond that lose precision silently. `uuid` is a 128-bit RFC 4122 identifier: runtime code sees it as a `bigint`, frame buffers store it as two little-endian 64-bit lanes, and at-rest data (documents, scenarios) uses canonical lowercase strings. `uuid` fields are OPTIONAL in kernel outputs — omitted values are auto-generated deterministically from the seeded simulation RNG — and non-UUID inputs are converted deterministically via UUIDv5. `string` is variable-length text, compared by value: runtime code sees plain JS strings, and each distinct value is stored once per run via interning — frame buffers hold 64-bit pool references. Kernels and markings write `string` values (missing values become the empty string); dynamics can read but never update them.", @@ -190,6 +195,23 @@ export const colorElementSchema = z description: "One typed attribute on a coloured token.", }); +export const identitySchema = z + .strictObject({ + id: idSchema, + name: displayNameSchema.meta({ + description: + "Human-readable identity name — the thing being tracked, e.g. `Ticket` or `Machine`.", + }), + keyElementTypes: z.array(z.enum(COLOR_ELEMENT_TYPES)).min(1).meta({ + description: + "Type(s) of the key element(s), in key order. A single entry is a simple key; two or more entries form a compound key, correlated by tuple equality. Every colour whose elements reference this identity must carry key elements matching these types in this order.", + }), + }) + .meta({ + description: + "A named instance identity. Colour elements reference it via `identityRef` to mark themselves as key elements, so keys correlate across colours without relying on element-name equality; status views name the identity they track.", + }) satisfies z.ZodType; + export const placeSchema = z .strictObject({ id: idSchema, diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/flatten-component-instances.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/flatten-component-instances.ts index 494a8c35d71..0c91e5c87df 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/flatten-component-instances.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/flatten-component-instances.ts @@ -440,6 +440,7 @@ export const flattenComponentInstancesForSimulation = ({ parameters: [], scenarios: sdcpn.scenarios?.map((scenario) => ({ ...scenario })), metrics: sdcpn.metrics?.map((metric) => ({ ...metric })), + identities: sdcpn.identities?.map((identity) => ({ ...identity })), statusViews: sdcpn.statusViews?.map((statusView) => ({ ...statusView })), subnets: [], componentInstances: [], diff --git a/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts b/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts index 3ffc5ad29f5..08fcd5fab65 100644 --- a/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts +++ b/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts @@ -113,6 +113,23 @@ export type Place = { y: number; }; +/** + * A named instance identity, e.g. "ticket": the thing whose per-instance + * status a status view tracks. Colour elements reference it via + * `identityRef` to mark themselves as key elements, so identity is declared + * once and correlates keys across colours without relying on element-name + * equality. + */ +export type Identity = { + id: ID; + name: string; + /** + * Type(s) of the key element(s), in key order; two or more entries form a + * compound key, correlated by tuple equality. + */ + keyElementTypes: ColorElementType[]; +}; + export type Color = { id: ID; name: string; @@ -123,6 +140,12 @@ export type Color = { elementId: string; name: string; type: ColorElementType; + /** + * Id of the Identity whose key this element carries; setting it marks + * the element as a key element. Tokens whose key elements are + * tuple-equal are the same instance, across colours. + */ + identityRef?: ID; }[]; }; @@ -430,6 +453,7 @@ export type SDCPN = { parameters: Parameter[]; scenarios?: Scenario[]; metrics?: Metric[]; + identities?: Identity[]; statusViews?: StatusView[]; subnets?: Subnet[]; componentInstances?: ComponentInstance[]; diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts index 3e7d91739b6..6f86411abb5 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts @@ -107,6 +107,15 @@ export function usePetrinautMutations(): PetrinautMutations { removeMetric: withReadonlyGuard("removeMetric", { targetActiveSubnet: false, }), + addIdentity: withReadonlyGuard("addIdentity", { + targetActiveSubnet: false, + }), + updateIdentity: withReadonlyGuard("updateIdentity", { + targetActiveSubnet: false, + }), + removeIdentity: withReadonlyGuard("removeIdentity", { + targetActiveSubnet: false, + }), addStatusView: withReadonlyGuard("addStatusView", { targetActiveSubnet: false, }), diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx index 2aa97bfb145..64af81e4d24 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { use, useState } from "react"; import { v4 as uuidv4 } from "uuid"; import { @@ -15,6 +15,8 @@ import { type ColorElementType, } from "@hashintel/petrinaut-core"; +import { usePetrinautMutations } from "../../../../../../../react/hooks/use-petrinaut-mutations"; +import { SDCPNContext } from "../../../../../../../react/state/sdcpn-context"; import { useIsReadOnly } from "../../../../../../../react/state/use-is-read-only"; import { DescriptionField } from "../../../../../../components/description-field"; import { DraftFieldInput } from "../../../../../../components/draft-field-input"; @@ -130,6 +132,11 @@ const dimensionTypeSelectStyle = css({ flexShrink: 0, }); +const dimensionIdentitySelectStyle = css({ + width: "[110px]", + flexShrink: 0, +}); + const deleteDimensionButtonStyle = css({ color: "neutral.s90", @@ -149,6 +156,9 @@ type ElementNameInputState = Record< { sourceName: string; value: string } >; +const NO_IDENTITY_VALUE = "__none__"; +const NEW_IDENTITY_VALUE = "__new__"; + const typeOptions: SelectItem[] = [ { value: "real", text: "Real" }, { value: "integer", text: "Integer" }, @@ -187,6 +197,9 @@ const TypeMainContent: React.FC = () => { } = useTypePropertiesContext(); const isDisabled = useIsReadOnly(); const presentation = usePetrinautPresentation(); + const { petriNetDefinition } = use(SDCPNContext); + const { addIdentity } = usePetrinautMutations(); + const identities = petriNetDefinition.identities ?? []; const [draggedIndex, setDraggedIndex] = useState(null); const [dragOverIndex, setDragOverIndex] = useState(null); const [elementNameInputs, setElementNameInputs] = @@ -291,6 +304,43 @@ const TypeMainContent: React.FC = () => { }); }; + const identityOptions: SelectItem[] = [ + { value: NO_IDENTITY_VALUE, text: "No identity" }, + ...identities.map((identity) => ({ + value: identity.id, + text: identity.name, + })), + { value: NEW_IDENTITY_VALUE, text: "New identity…" }, + ]; + + const handleUpdateElementIdentity = ( + element: (typeof type.elements)[number], + selectedValue: string, + ) => { + if (selectedValue === NEW_IDENTITY_VALUE) { + const identityId = uuidv4(); + addIdentity({ + id: identityId, + name: type.name, + keyElementTypes: [element.type], + }); + updateTypeElement({ + typeId: type.id, + elementId: element.elementId, + update: { identityRef: identityId }, + }); + return; + } + updateTypeElement({ + typeId: type.id, + elementId: element.elementId, + update: { + identityRef: + selectedValue === NO_IDENTITY_VALUE ? undefined : selectedValue, + }, + }); + }; + const handleDragStart = (index: number) => { setDraggedIndex(index); }; @@ -477,6 +527,26 @@ const TypeMainContent: React.FC = () => { connectToLeftInput /> + + +