From 0795f070dfdf9020a30df9e0e757b96caf05033a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 10:08:37 +0000 Subject: [PATCH 01/14] Add optional description and metadata fields to SDCPN nodes --- .changeset/sdcpn-description-metadata.md | 6 + .../src/file-format/parse-sdcpn-file.test.ts | 106 ++++++++++++++++++ .../src/file-format/serialize-sdcpn.test.ts | 17 ++- .../src/file-format/serialize-sdcpn.ts | 4 + .../petrinaut-core/src/file-format/types.ts | 6 + .../src/schemas/entity-schemas.ts | 20 ++++ .../petrinaut-core/src/types/sdcpn-input.ts | 68 +++++++---- .../petrinaut-core/src/types/sdcpn.ts | 22 ++++ .../petrinaut/docs/drawing-a-net.md | 2 +- .../src/ui/components/description-field.tsx | 64 +++++++++++ .../subviews/main.tsx | 14 +++ .../place-properties/subviews/main.tsx | 16 +++ .../transition-properties/subviews/main.tsx | 14 +++ .../type-properties/subviews/main.tsx | 14 +++ 14 files changed, 349 insertions(+), 24 deletions(-) create mode 100644 .changeset/sdcpn-description-metadata.md create mode 100644 libs/@hashintel/petrinaut/src/ui/components/description-field.tsx diff --git a/.changeset/sdcpn-description-metadata.md b/.changeset/sdcpn-description-metadata.md new file mode 100644 index 00000000000..61c073f99f8 --- /dev/null +++ b/.changeset/sdcpn-description-metadata.md @@ -0,0 +1,6 @@ +--- +"@hashintel/petrinaut-core": patch +"@hashintel/petrinaut": patch +--- + +SDCPN elements carry an optional `description` (places, transitions, types, subnets, component instances, and the net root) and optional host-defined `metadata` (transitions, subnets, component instances, and the net root). Both survive file import/export; the properties panels edit descriptions, while `metadata` is opaque and never rendered. diff --git a/libs/@hashintel/petrinaut-core/src/file-format/parse-sdcpn-file.test.ts b/libs/@hashintel/petrinaut-core/src/file-format/parse-sdcpn-file.test.ts index d9e5cd380f1..ad5a9483e6b 100644 --- a/libs/@hashintel/petrinaut-core/src/file-format/parse-sdcpn-file.test.ts +++ b/libs/@hashintel/petrinaut-core/src/file-format/parse-sdcpn-file.test.ts @@ -290,6 +290,112 @@ describe("parseSDCPNFile", () => { }); }); + describe("description and metadata", () => { + it("preserves description and metadata on every element that carries them", () => { + const result = parseSDCPNFile({ + version: 1, + meta: { generator: "Petrinaut" }, + ...minimalSDCPN, + description: "A test net", + metadata: { source: "flow-1", tags: ["a", "b"], nested: { n: 1 } }, + places: [{ ...minimalPlace, description: "A place" }], + transitions: [ + { + ...minimalTransition, + description: "A transition", + metadata: { stepKind: "action" }, + }, + ], + types: [ + { + id: "c1", + name: "Colour 1", + description: "A colour", + iconSlug: "circle", + displayColor: "#FF0000", + elements: [], + }, + ], + componentInstances: [ + { + id: "instance-1", + name: "Instance1", + description: "An instance", + metadata: { origin: "imported" }, + subnetId: "subnet-1", + parameterValues: {}, + x: 0, + y: 0, + }, + ], + subnets: [ + { + id: "subnet-1", + name: "Subnet 1", + description: "A subnet", + metadata: { origin: "imported" }, + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], + }, + ], + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.sdcpn.description).toBe("A test net"); + expect(result.sdcpn.metadata).toEqual({ + source: "flow-1", + tags: ["a", "b"], + nested: { n: 1 }, + }); + expect(result.sdcpn.places[0]?.description).toBe("A place"); + expect(result.sdcpn.transitions[0]?.description).toBe("A transition"); + expect(result.sdcpn.transitions[0]?.metadata).toEqual({ + stepKind: "action", + }); + expect(result.sdcpn.types[0]?.description).toBe("A colour"); + expect(result.sdcpn.componentInstances?.[0]).toMatchObject({ + description: "An instance", + metadata: { origin: "imported" }, + }); + expect(result.sdcpn.subnets?.[0]).toMatchObject({ + description: "A subnet", + metadata: { origin: "imported" }, + }); + }); + + it("parses files without description or metadata", () => { + const result = parseSDCPNFile({ + version: 1, + meta: { generator: "Petrinaut" }, + ...minimalSDCPN, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.sdcpn.description).toBeUndefined(); + expect(result.sdcpn.metadata).toBeUndefined(); + expect(result.sdcpn.places[0]?.description).toBeUndefined(); + expect(result.sdcpn.transitions[0]?.metadata).toBeUndefined(); + }); + + it("preserves description and metadata in the legacy format", () => { + const result = parseSDCPNFile({ + ...minimalSDCPN, + description: "A test net", + metadata: { source: "flow-1" }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.sdcpn.description).toBe("A test net"); + expect(result.sdcpn.metadata).toEqual({ source: "flow-1" }); + }); + }); + describe("legacy format (no version)", () => { it("parses a valid legacy file", () => { const result = parseSDCPNFile(minimalSDCPN); 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 85efb8b011a..2ec279c327f 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 @@ -7,6 +7,8 @@ const sourceDocument = { version: 1, meta: { generator: "Petrinaut" }, title: "Test Net", + description: "A test net", + metadata: { source: "test" }, places: [ { id: "p1", @@ -31,7 +33,16 @@ const sourceDocument = { y: 200, }, ], - subnets: [{ id: "subnet1", name: "Subnet 1", places: [], transitions: [] }], + subnets: [ + { + id: "subnet1", + name: "Subnet 1", + description: "A subnet", + metadata: { origin: "test" }, + places: [], + transitions: [], + }, + ], componentInstances: [ { id: "instance1", name: "Instance 1", subnetId: "subnet1", x: 0, y: 0 }, ], @@ -122,6 +133,8 @@ describe("serializeSDCPN", () => { "version", "meta", "title", + "description", + "metadata", "parameters", "types", "differentialEquations", @@ -135,6 +148,8 @@ describe("serializeSDCPN", () => { expect(Object.keys(document.subnets![0]!)).toEqual([ "id", "name", + "description", + "metadata", "parameters", "types", "differentialEquations", 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 449b4ecf339..a3fac5c890b 100644 --- a/libs/@hashintel/petrinaut-core/src/file-format/serialize-sdcpn.ts +++ b/libs/@hashintel/petrinaut-core/src/file-format/serialize-sdcpn.ts @@ -17,6 +17,8 @@ const DOCUMENT_KEY_ORDER = [ "version", "meta", "title", + "description", + "metadata", "parameters", "types", "differentialEquations", @@ -31,6 +33,8 @@ const DOCUMENT_KEY_ORDER = [ const SUBNET_KEY_ORDER = [ "id", "name", + "description", + "metadata", "parameters", "types", "differentialEquations", diff --git a/libs/@hashintel/petrinaut-core/src/file-format/types.ts b/libs/@hashintel/petrinaut-core/src/file-format/types.ts index ace36df6bca..a5dcb2902c9 100644 --- a/libs/@hashintel/petrinaut-core/src/file-format/types.ts +++ b/libs/@hashintel/petrinaut-core/src/file-format/types.ts @@ -5,8 +5,10 @@ import { colorElementSchema as currentColorElementSchema, colorSchema as currentColorSchema, componentInstanceSchema as currentComponentInstanceSchema, + descriptionSchema, differentialEquationSchema as currentDifferentialEquationSchema, inputArcSchema as currentInputArcSchema, + metadataSchema, outputArcSchema as currentOutputArcSchema, parameterSchema as currentParameterSchema, placeSchema as currentPlaceSchema, @@ -137,6 +139,8 @@ const componentInstanceSchema = z.object({ const subnetSchema = z.object({ id: z.string(), name: z.string(), + description: descriptionSchema, + metadata: metadataSchema, places: z.array(placeSchema), transitions: z.array(transitionSchema), types: z.array(colorSchema).default([]), @@ -146,6 +150,8 @@ const subnetSchema = z.object({ }); export const sdcpnSchema = z.object({ + description: descriptionSchema, + metadata: metadataSchema, places: z.array(placeSchema), transitions: z.array(transitionSchema), types: z.array(colorSchema).default([]), diff --git a/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts b/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts index d986baa7213..172cba25ac3 100644 --- a/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts +++ b/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts @@ -30,6 +30,18 @@ export const idSchema = z.string().min(1).meta({ "Stable identifier for an SDCPN entity. Use unique IDs within the net.", }); +export const descriptionSchema = z.string().optional().meta({ + description: "Optional human-readable summary shown to users.", +}); + +export const metadataSchema = z + .record(z.string(), z.json()) + .optional() + .meta({ + description: + "Optional host-defined data. Petrinaut treats it as opaque and never renders it.", + }); + export const positionSchema = z .strictObject({ x: z.number().meta({ @@ -188,6 +200,7 @@ export const placeSchema = z description: "PascalCase identifier used DIRECTLY in user code: lambdas and kernels reference input/output places as `input.PlaceName` and `{ PlaceName: [...] }`, metrics access them as `state.places.PlaceName.count`, scenario code-mode initial state keys are place names, and visualizer scope is implicitly per-place. Renaming a place breaks every code reference, so rename only when you also update dependent lambda/kernel/dynamics/metric/visualizer/scenario code in the same batch.", }), + description: descriptionSchema, colorId: idSchema.nullable().meta({ description: "ID of the token colour/type accepted by this place, or null for uncoloured token counts. Uncoloured places have no token attributes and do not appear in lambda/kernel `input` objects.", @@ -234,6 +247,8 @@ export const transitionSchema = z name: displayNameSchema.meta({ description: "Human-readable transition name.", }), + description: descriptionSchema, + metadata: metadataSchema, inputArcs: z.array(inputArcSchema).meta({ description: "Input arcs that gate transition firing. Standard arcs consume tokens, read arcs observe tokens without consuming them, and inhibitor arcs block firing based on token counts.", @@ -289,6 +304,7 @@ export const colorSchema = z name: displayNameSchema.meta({ description: "Human-readable colour/type name.", }), + description: descriptionSchema, iconSlug: z.string().min(1).meta({ description: 'Short icon identifier used by the UI for this colour/type. Typical values are `"circle"` or `"square"`; the UI defaults to `"circle"`.', @@ -377,6 +393,8 @@ export const componentInstanceSchema = z description: "PascalCase name for the component instance (e.g. MainProcessor, Ward2). Used as a code-level identifier.", }), + description: descriptionSchema, + metadata: metadataSchema, subnetId: idSchema.meta({ description: "ID of the subnet definition this component instantiates.", }), @@ -402,6 +420,8 @@ export const subnetSchema = z name: displayNameSchema.meta({ description: "Human-readable subnet name.", }), + description: descriptionSchema, + metadata: metadataSchema, places: z.array(placeSchema).meta({ description: "Places local to this subnet.", }), diff --git a/libs/@hashintel/petrinaut-core/src/types/sdcpn-input.ts b/libs/@hashintel/petrinaut-core/src/types/sdcpn-input.ts index c8ba93488cf..c3dc0f8b32a 100644 --- a/libs/@hashintel/petrinaut-core/src/types/sdcpn-input.ts +++ b/libs/@hashintel/petrinaut-core/src/types/sdcpn-input.ts @@ -6,6 +6,7 @@ import type { ID, InputArc, InputArcType, + JsonValue, Metric, OutputArc, Parameter, @@ -32,6 +33,8 @@ import type { * so existing callers are unaffected. */ export type SDCPNInput = { + description?: string; + metadata?: Record; places: SDCPNPlaceInput[]; transitions: SDCPNTransitionInput[]; /** @default [] */ @@ -49,6 +52,7 @@ export type SDCPNInput = { export type SDCPNPlaceInput = { id: ID; name: string; + description?: string; x: number; y: number; /** @default null */ @@ -87,6 +91,8 @@ export type SDCPNOutputArcInput = SDCPNArcEndpointInput & { export type SDCPNTransitionInput = { id: ID; name: string; + description?: string; + metadata?: Record; inputArcs: SDCPNInputArcInput[]; outputArcs: SDCPNOutputArcInput[]; x: number; @@ -137,6 +143,9 @@ export function normalizeSDCPN(input: SDCPNInput): SDCPN { x: place.x, y: place.y, }; + if (place.description !== undefined) { + normalized.description = place.description; + } if (place.isPort !== undefined) { normalized.isPort = place.isPort; } @@ -148,33 +157,48 @@ export function normalizeSDCPN(input: SDCPNInput): SDCPN { } return normalized; }), - transitions: input.transitions.map((transition) => ({ - id: transition.id, - name: transition.name, - inputArcs: transition.inputArcs.map( - (arc): InputArc => ({ - ...arcEndpointFields(arc), - weight: arc.weight ?? 1, - type: arc.type ?? "standard", - }), - ), - outputArcs: transition.outputArcs.map( - (arc): OutputArc => ({ - ...arcEndpointFields(arc), - weight: arc.weight ?? 1, - }), - ), - lambdaType: transition.lambdaType ?? "predicate", - lambdaCode: transition.lambdaCode ?? "", - transitionKernelCode: transition.transitionKernelCode ?? "", - x: transition.x, - y: transition.y, - })), + transitions: input.transitions.map((transition) => { + const normalized: SDCPN["transitions"][number] = { + id: transition.id, + name: transition.name, + inputArcs: transition.inputArcs.map( + (arc): InputArc => ({ + ...arcEndpointFields(arc), + weight: arc.weight ?? 1, + type: arc.type ?? "standard", + }), + ), + outputArcs: transition.outputArcs.map( + (arc): OutputArc => ({ + ...arcEndpointFields(arc), + weight: arc.weight ?? 1, + }), + ), + lambdaType: transition.lambdaType ?? "predicate", + lambdaCode: transition.lambdaCode ?? "", + transitionKernelCode: transition.transitionKernelCode ?? "", + x: transition.x, + y: transition.y, + }; + if (transition.description !== undefined) { + normalized.description = transition.description; + } + if (transition.metadata !== undefined) { + normalized.metadata = transition.metadata; + } + return normalized; + }), types: input.types ?? [], parameters: input.parameters ?? [], differentialEquations: input.differentialEquations ?? [], }; + if (input.description !== undefined) { + result.description = input.description; + } + if (input.metadata !== undefined) { + result.metadata = input.metadata; + } if (input.scenarios !== undefined) { result.scenarios = input.scenarios; } diff --git a/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts b/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts index 070f1f9e7d6..23454a7c68f 100644 --- a/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts +++ b/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts @@ -5,6 +5,14 @@ export type ID = string; +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + export type ColorElementType = | "real" | "integer" @@ -63,6 +71,9 @@ export type OutputArc = ArcEndpointReference & { export type Transition = { id: ID; name: string; + description?: string; + /** Host-defined data, opaque to Petrinaut. */ + metadata?: Record; inputArcs: InputArc[]; outputArcs: OutputArc[]; lambdaType: "predicate" | "stochastic"; @@ -76,6 +87,7 @@ export type Transition = { export type Place = { id: ID; name: string; + description?: string; colorId: null | ID; dynamicsEnabled: boolean; differentialEquationId: null | ID; @@ -104,6 +116,7 @@ export type Place = { export type Color = { id: ID; name: string; + description?: string; iconSlug: string; // e.g., "circle", "square" displayColor: string; // e.g., "#FF0000" elements: { @@ -325,6 +338,9 @@ export type ComponentInstance = { id: ID; /** Display name for this instance. */ name: string; + description?: string; + /** Host-defined data, opaque to Petrinaut. */ + metadata?: Record; /** ID of the subnet this instance instantiates. */ subnetId: ID; /** @@ -340,6 +356,9 @@ export type ComponentInstance = { export type Subnet = { id: ID; name: string; + description?: string; + /** Host-defined data, opaque to Petrinaut. */ + metadata?: Record; places: Place[]; transitions: Transition[]; types: Color[]; @@ -349,6 +368,9 @@ export type Subnet = { }; export type SDCPN = { + description?: string; + /** Host-defined data, opaque to Petrinaut. */ + metadata?: Record; places: Place[]; transitions: Transition[]; types: Color[]; diff --git a/libs/@hashintel/petrinaut/docs/drawing-a-net.md b/libs/@hashintel/petrinaut/docs/drawing-a-net.md index a615e35e937..d7b6260dcb2 100644 --- a/libs/@hashintel/petrinaut/docs/drawing-a-net.md +++ b/libs/@hashintel/petrinaut/docs/drawing-a-net.md @@ -56,7 +56,7 @@ Use the bottom toolbar to add nodes: - **Add Place** (shortcut: **N**) -- click the canvas to drop a place, or click and drag the button onto the canvas. - **Add Transition** (shortcut: **T**) -- click the canvas to drop a transition, or drag the button onto the canvas. -New nodes are named automatically (Place1, Place2, Transition1, etc.). Rename them by selecting the node and editing the name in the properties panel. +New nodes are named automatically (Place1, Place2, Transition1, etc.). Rename them by selecting the node and editing the name in the properties panel. Places, transitions, component instances, and types can also carry an optional **Description**, edited in the same panel below the name. add-place-transition-toolbar diff --git a/libs/@hashintel/petrinaut/src/ui/components/description-field.tsx b/libs/@hashintel/petrinaut/src/ui/components/description-field.tsx new file mode 100644 index 00000000000..c8d5dae4fcc --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/components/description-field.tsx @@ -0,0 +1,64 @@ +import { Form, TextArea, Tooltip } from "@hashintel/ds-components"; +import { css } from "@hashintel/ds-helpers/css"; + +import { useDraftField } from "../hooks/use-draft-field"; + +interface DescriptionTextAreaProps { + /** Stable identifier of the entity owning this field; switching it discards stale drafts. */ + sourceId: string; + /** Current canonical description. */ + sourceValue: string | undefined; + /** Called on blur when the draft differs from `sourceValue`; an empty draft passes `undefined`. */ + onCommit: (description: string | undefined) => void; + disabled?: boolean; + /** Tooltip shown when hovering the textarea itself (eg. a read-only hint). */ + tooltip?: string; +} + +const textAreaStyle = css({ + minHeight: "[64px]", +}); + +/** + * Description textarea bound to a {@link useDraftField} draft, for hosts that + * render their own label (eg. a Section title). + */ +export const DescriptionTextArea: React.FC = ({ + sourceId, + sourceValue, + onCommit, + disabled = false, + tooltip, +}) => { + const canonicalValue = sourceValue ?? ""; + const field = useDraftField({ sourceId, sourceValue: canonicalValue }); + + return ( + +