diff --git a/libs/@hashintel/petrinaut/src/core/action-schemas.ts b/libs/@hashintel/petrinaut/src/core/action-schemas.ts new file mode 100644 index 00000000000..f5856d85cee --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/action-schemas.ts @@ -0,0 +1,334 @@ +import { z } from "zod"; + +import type { SelectionItem } from "./types/selection"; +import { + arcDirectionSchema, + colorElementSchema, + colorSchema, + differentialEquationSchema, + idSchema, + nodePositionCommitSchema, + parameterSchema, + placeSchema, + positionSchema, + transitionSchema, +} from "./schemas/entity-schemas"; +import { metricSchema as simulationMetricSchema } from "./schemas/metric-schema"; +import { scenarioSchema as simulationScenarioSchema } from "./schemas/scenario-schema"; + +export { + arcDirectionSchema, + colorElementSchema, + colorSchema, + differentialEquationSchema, + idSchema, + nodePositionCommitSchema, + parameterSchema, + placeSchema, + positionSchema, + transitionSchema, +} from "./schemas/entity-schemas"; +export { + metricSchema as simulationMetricSchema, + type MetricSchema, +} from "./schemas/metric-schema"; +export { + scenarioParameterSchema, + scenarioSchema as simulationScenarioSchema, + type ScenarioSchema, +} from "./schemas/scenario-schema"; +export { + simulationMetricSchema as metricSchema, + simulationScenarioSchema as scenarioSchema, +}; + +export const placeUpdateSchema = placeSchema + .omit({ id: true, x: true, y: true }) + .partial() + .meta({ + description: + "Fields to assign to an existing place. Omitted fields are left unchanged.", + }); + +export const transitionUpdateSchema = transitionSchema + .omit({ id: true, inputArcs: true, outputArcs: true, x: true, y: true }) + .partial() + .meta({ + description: + "Fields to assign to an existing transition. Omitted fields are left unchanged.", + }); + +export const colorUpdateSchema = colorSchema + .omit({ id: true, elements: true }) + .partial() + .meta({ + description: + "Fields to assign to an existing colour/type. Omitted fields are left unchanged.", + }); + +export const colorElementUpdateSchema = colorElementSchema + .omit({ elementId: true }) + .partial() + .meta({ + description: + "Fields to assign to an existing colour/type element. Omitted fields are left unchanged.", + }); + +export const differentialEquationUpdateSchema = differentialEquationSchema + .omit({ id: true }) + .partial() + .meta({ + description: + "Fields to assign to an existing differential equation. Omitted fields are left unchanged.", + }); + +export const parameterUpdateSchema = parameterSchema + .omit({ id: true }) + .partial() + .meta({ + description: + "Fields to assign to an existing parameter. Omitted fields are left unchanged.", + }); + +export const scenarioUpdateSchema = simulationScenarioSchema + .omit({ id: true }) + .partial() + .meta({ + description: + "Fields to assign to an existing scenario. Omitted fields are left unchanged.", + }); + +export const metricUpdateSchema = simulationMetricSchema + .omit({ id: true }) + .partial() + .meta({ + description: + "Fields to assign to an existing metric. Omitted fields are left unchanged.", + }); + +export const itemTypeAndIdSchema = z + .discriminatedUnion("type", [ + z.strictObject({ type: z.literal("place"), id: idSchema }), + z.strictObject({ type: z.literal("transition"), id: idSchema }), + z.strictObject({ type: z.literal("arc"), id: idSchema }), + z.strictObject({ type: z.literal("type"), id: idSchema }), + z.strictObject({ type: z.literal("differentialEquation"), id: idSchema }), + z.strictObject({ type: z.literal("parameter"), id: idSchema }), + ]) + .meta({ + description: + "An item to delete. Arc IDs use Petrinaut's generated arc ID format.", + }) satisfies z.ZodType; + +export const mutationActionInputSchemas = { + addPlace: placeSchema.meta({ + description: "Add a place that stores tokens in the SDCPN.", + }), + updatePlace: z + .strictObject({ + placeId: idSchema, + update: placeUpdateSchema, + }) + .meta({ description: "Update fields on an existing place." }), + updatePlacePosition: z + .strictObject({ + placeId: idSchema, + position: positionSchema, + }) + .meta({ description: "Update an existing place's canvas position." }), + removePlace: z + .strictObject({ placeId: idSchema }) + .meta({ description: "Remove a place and any arcs connected to it." }), + addTransition: transitionSchema.meta({ + description: "Add a transition with firing logic and arcs.", + }), + updateTransition: z + .strictObject({ + transitionId: idSchema, + update: transitionUpdateSchema, + }) + .meta({ + description: + "Update a transition's properties, arcs, or executable code.", + }), + updateTransitionPosition: z + .strictObject({ + transitionId: idSchema, + position: positionSchema, + }) + .meta({ description: "Update an existing transition's canvas position." }), + removeTransition: z + .strictObject({ transitionId: idSchema }) + .meta({ description: "Remove a transition." }), + addArc: z + .strictObject({ + transitionId: idSchema, + arcDirection: arcDirectionSchema, + placeId: idSchema, + weight: z.number().positive().meta({ + description: "Token multiplicity for the arc.", + }), + }) + .meta({ description: "Add an input or output arc to a transition." }), + removeArc: z + .strictObject({ + transitionId: idSchema, + arcDirection: arcDirectionSchema, + placeId: idSchema, + }) + .meta({ description: "Remove an input or output arc from a transition." }), + updateArcWeight: z + .strictObject({ + transitionId: idSchema, + arcDirection: arcDirectionSchema, + placeId: idSchema, + weight: z.number().positive().meta({ + description: "Replacement token multiplicity for the arc.", + }), + }) + .meta({ description: "Update the token weight on an existing arc." }), + updateArcType: z + .strictObject({ + transitionId: idSchema, + placeId: idSchema, + type: z.enum(["standard", "inhibitor"]).meta({ + description: "Replacement input arc type.", + }), + }) + .meta({ description: "Update an existing input arc's type." }), + updateArcPlace: z + .strictObject({ + transitionId: idSchema, + arcDirection: arcDirectionSchema, + oldPlaceId: idSchema.meta({ + description: "Current place ID used by the arc.", + }), + newPlaceId: idSchema.meta({ + description: "Replacement place ID for the arc.", + }), + }) + .meta({ description: "Update the place endpoint on an existing arc." }), + addType: colorSchema.meta({ + description: "Add a coloured-token type.", + }), + updateType: z + .strictObject({ + typeId: idSchema, + update: colorUpdateSchema, + }) + .meta({ description: "Update fields on an existing colour/type." }), + removeType: z.strictObject({ typeId: idSchema }).meta({ + description: + "Remove a colour/type and clear references from places and dynamics.", + }), + addTypeElement: z + .strictObject({ + typeId: idSchema, + element: colorElementSchema, + }) + .meta({ description: "Add an element to a coloured-token type." }), + updateTypeElement: z + .strictObject({ + typeId: idSchema, + elementId: idSchema, + update: colorElementUpdateSchema, + }) + .meta({ description: "Update fields on an existing type element." }), + removeTypeElement: z + .strictObject({ + typeId: idSchema, + elementId: idSchema, + }) + .meta({ description: "Remove an element from a coloured-token type." }), + moveTypeElement: z + .strictObject({ + typeId: idSchema, + elementId: idSchema, + toIndex: z.number().int().nonnegative().meta({ + description: "Destination index for the element within the type.", + }), + }) + .meta({ description: "Move an element within a coloured-token type." }), + addDifferentialEquation: differentialEquationSchema.meta({ + description: "Add continuous dynamics for a coloured-token type.", + }), + updateDifferentialEquation: z + .strictObject({ + equationId: idSchema, + update: differentialEquationUpdateSchema, + }) + .meta({ + description: "Update fields on an existing differential equation.", + }), + removeDifferentialEquation: z.strictObject({ equationId: idSchema }).meta({ + description: + "Remove a differential equation and clear references from places.", + }), + addParameter: parameterSchema.meta({ + description: "Add a net-level parameter available to SDCPN code.", + }), + updateParameter: z + .strictObject({ + parameterId: idSchema, + update: parameterUpdateSchema, + }) + .meta({ description: "Update fields on an existing parameter." }), + removeParameter: z + .strictObject({ parameterId: idSchema }) + .meta({ description: "Remove a net-level parameter." }), + addScenario: simulationScenarioSchema.meta({ + description: + "Add a simulation scenario. Include scenarioParameters for key user-tunable assumptions, parameterOverrides keyed by existing net-level parameter IDs, and initialState with per-place content keyed by existing place IDs unless advanced code is required. Omit parameterOverrides or use {} when no net-level parameters need overriding.", + }), + updateScenario: z + .strictObject({ + scenarioId: idSchema, + update: scenarioUpdateSchema, + }) + .meta({ description: "Update fields on an existing scenario." }), + removeScenario: z + .strictObject({ scenarioId: idSchema }) + .meta({ description: "Remove a simulation scenario." }), + addMetric: simulationMetricSchema.meta({ + description: "Add a simulation metric.", + }), + updateMetric: z + .strictObject({ + metricId: idSchema, + update: metricUpdateSchema, + }) + .meta({ description: "Update fields on an existing metric." }), + removeMetric: z + .strictObject({ metricId: idSchema }) + .meta({ description: "Remove a simulation metric." }), + deleteItemsByIds: z + .strictObject({ + items: z.array(itemTypeAndIdSchema).meta({ + description: "Items to delete in one mutation.", + }), + }) + .meta({ description: "Delete selected SDCPN items by ID." }), + commitNodePositions: z + .strictObject({ + commits: z.array(nodePositionCommitSchema).meta({ + description: "Node positions to commit.", + }), + }) + .meta({ description: "Commit multiple place/transition positions." }), +} as const; + +export type PlaceInput = z.infer; +export type TransitionInput = z.infer; +export type ColorInput = z.infer; +export type DifferentialEquationInput = z.infer< + typeof differentialEquationSchema +>; +export type ParameterInput = z.infer; +export type ScenarioInput = z.infer; +export type MetricInput = z.infer; +export type NodePositionCommitInput = z.infer; + +export type MutationActionName = keyof typeof mutationActionInputSchemas; +export type MutationActionInput = z.infer< + (typeof mutationActionInputSchemas)[Name] +>; diff --git a/libs/@hashintel/petrinaut/src/core/actions.test.ts b/libs/@hashintel/petrinaut/src/core/actions.test.ts new file mode 100644 index 00000000000..38c2cf66934 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/actions.test.ts @@ -0,0 +1,479 @@ +import { describe, expect, test } from "vitest"; + +import { createJsonDocHandle } from "./handle"; +import { createPetrinaut } from "./instance"; +import type { SDCPN } from "./types/sdcpn"; + +const emptySDCPN: SDCPN = { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], +}; + +const createInstance = (initial: SDCPN = emptySDCPN) => + createPetrinaut({ + document: createJsonDocHandle({ initial: structuredClone(initial) }), + }); + +const callActionWithUnknownInput = ( + action: (input: Input) => void, + input: unknown, +): void => { + action(input as Input); +}; + +describe("Petrinaut core actions", () => { + test("adds and updates places", () => { + const instance = createInstance(); + + instance.addPlace({ + id: "place-1", + name: "Queue", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }); + instance.updatePlace({ + placeId: "place-1", + update: { + name: "UpdatedQueue", + }, + }); + instance.updatePlacePosition({ + placeId: "place-1", + position: { x: 12, y: 24 }, + }); + + expect(instance.definition.get().places).toEqual([ + { + id: "place-1", + name: "UpdatedQueue", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 12, + y: 24, + }, + ]); + }); + + test("removing a place also removes connected arcs", () => { + const instance = createInstance({ + ...emptySDCPN, + places: [ + { + id: "place-1", + name: "Input", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + { + id: "place-2", + name: "Output", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 100, + y: 0, + }, + ], + transitions: [ + { + id: "transition-1", + name: "Move", + inputArcs: [{ placeId: "place-1", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "place-2", weight: 1 }], + lambdaType: "predicate", + lambdaCode: "export default Lambda(() => true);", + transitionKernelCode: "", + x: 50, + y: 0, + }, + ], + }); + + instance.removePlace({ placeId: "place-1" }); + + const definition = instance.definition.get(); + expect(definition.places.map((place) => place.id)).toEqual(["place-2"]); + expect(definition.transitions[0]!.inputArcs).toEqual([]); + expect(definition.transitions[0]!.outputArcs).toEqual([ + { placeId: "place-2", weight: 1 }, + ]); + }); + + test("updates arc endpoints granularly", () => { + const instance = createInstance({ + ...emptySDCPN, + transitions: [ + { + id: "transition-1", + name: "Move", + inputArcs: [{ placeId: "place-1", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "place-2", weight: 1 }], + lambdaType: "predicate", + lambdaCode: "export default Lambda(() => true);", + transitionKernelCode: "", + x: 50, + y: 0, + }, + ], + }); + + instance.updateArcPlace({ + transitionId: "transition-1", + arcDirection: "input", + oldPlaceId: "place-1", + newPlaceId: "place-3", + }); + instance.updateArcPlace({ + transitionId: "transition-1", + arcDirection: "output", + oldPlaceId: "place-2", + newPlaceId: "place-4", + }); + + expect(instance.definition.get().transitions[0]).toMatchObject({ + inputArcs: [{ placeId: "place-3", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "place-4", weight: 1 }], + }); + }); + + test("adds, updates, removes, and moves type elements granularly", () => { + const instance = createInstance({ + ...emptySDCPN, + types: [ + { + id: "type-1", + name: "Particle", + iconSlug: "circle", + displayColor: "#34a0fa", + elements: [ + { elementId: "element-1", name: "Mass", type: "real" }, + { elementId: "element-2", name: "Velocity", type: "real" }, + ], + }, + ], + }); + + instance.addTypeElement({ + typeId: "type-1", + element: { elementId: "element-3", name: "Charge", type: "integer" }, + }); + instance.updateTypeElement({ + typeId: "type-1", + elementId: "element-1", + update: { name: "MassKg" }, + }); + instance.moveTypeElement({ + typeId: "type-1", + elementId: "element-3", + toIndex: 1, + }); + instance.removeTypeElement({ + typeId: "type-1", + elementId: "element-2", + }); + + expect(instance.definition.get().types[0]!.elements).toEqual([ + { elementId: "element-1", name: "MassKg", type: "real" }, + { elementId: "element-3", name: "Charge", type: "integer" }, + ]); + }); + + test("deleteItemsByIds removes referenced types and equations", () => { + const instance = createInstance({ + ...emptySDCPN, + places: [ + { + id: "place-1", + name: "Dynamic", + colorId: "type-1", + dynamicsEnabled: true, + differentialEquationId: "equation-1", + x: 0, + y: 0, + }, + ], + types: [ + { + id: "type-1", + name: "Particle", + iconSlug: "circle", + displayColor: "#34a0fa", + elements: [], + }, + ], + differentialEquations: [ + { + id: "equation-1", + name: "Motion", + colorId: "type-1", + code: "export default Dynamics(() => []);", + }, + ], + }); + + instance.deleteItemsByIds({ + items: [ + { type: "type", id: "type-1" }, + { type: "differentialEquation", id: "equation-1" }, + ], + }); + + const definition = instance.definition.get(); + expect(definition.types).toEqual([]); + expect(definition.differentialEquations).toEqual([]); + expect(definition.places[0]!.colorId).toBeNull(); + expect(definition.places[0]!.differentialEquationId).toBeNull(); + }); + + test("does not mutate readonly instances", () => { + const instance = createPetrinaut({ + document: createJsonDocHandle({ initial: structuredClone(emptySDCPN) }), + readonly: true, + }); + + instance.addPlace({ + id: "place-1", + name: "Queue", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }); + + expect(instance.definition.get().places).toEqual([]); + }); + + test("validates add action inputs before mutating", () => { + const instance = createInstance(); + + expect(() => + instance.addPlace({ + id: "", + name: "Queue", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }), + ).toThrow(); + + expect(instance.definition.get().places).toEqual([]); + }); + + test("validates callback-updated entities", () => { + const instance = createInstance(); + + instance.addPlace({ + id: "place-1", + name: "Queue", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }); + + expect(() => + instance.updatePlace({ + placeId: "place-1", + update: { + name: "", + }, + }), + ).toThrow(); + }); + + test("rejects over-wide update action payloads", () => { + const instance = createInstance(); + + expect(() => + callActionWithUnknownInput(instance.updatePlace, { + placeId: "place-1", + update: { id: "place-2" }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.updatePlace, { + placeId: "place-1", + update: { x: 10 }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.updateTransition, { + transitionId: "transition-1", + update: { inputArcs: [] }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.updateTransition, { + transitionId: "transition-1", + update: { y: 10 }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.updateType, { + typeId: "type-1", + update: { elements: [] }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.updateTypeElement, { + typeId: "type-1", + elementId: "element-1", + update: { elementId: "element-2" }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.updateDifferentialEquation, { + equationId: "equation-1", + update: { id: "equation-2" }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.updateParameter, { + parameterId: "parameter-1", + update: { id: "parameter-2" }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.updateScenario, { + scenarioId: "scenario-1", + update: { id: "scenario-2" }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.updateMetric, { + metricId: "metric-1", + update: { id: "metric-2" }, + }), + ).toThrow(); + }); + + test("validates granular arc and type element action inputs", () => { + const instance = createInstance({ + ...emptySDCPN, + transitions: [ + { + id: "transition-1", + name: "Move", + inputArcs: [{ placeId: "place-1", weight: 1, type: "standard" }], + outputArcs: [], + lambdaType: "predicate", + lambdaCode: "export default Lambda(() => true);", + transitionKernelCode: "", + x: 50, + y: 0, + }, + ], + types: [ + { + id: "type-1", + name: "Particle", + iconSlug: "circle", + displayColor: "#34a0fa", + elements: [{ elementId: "element-1", name: "Mass", type: "real" }], + }, + ], + }); + + expect(() => + instance.updateArcPlace({ + transitionId: "transition-1", + arcDirection: "input", + oldPlaceId: "place-1", + newPlaceId: "", + }), + ).toThrow(); + expect(() => + instance.addTypeElement({ + typeId: "type-1", + element: { elementId: "element-2", name: "", type: "real" }, + }), + ).toThrow(); + expect(() => + instance.moveTypeElement({ + typeId: "type-1", + elementId: "element-1", + toIndex: -1, + }), + ).toThrow(); + + expect(instance.definition.get().transitions[0]!.inputArcs).toEqual([ + { placeId: "place-1", weight: 1, type: "standard" }, + ]); + expect(instance.definition.get().types[0]!.elements).toEqual([ + { elementId: "element-1", name: "Mass", type: "real" }, + ]); + }); + + test("reuses existing name validation rules for action inputs", () => { + const instance = createInstance(); + + expect(() => + instance.addPlace({ + id: "place-1", + name: "invalid place name", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }), + ).toThrow(); + + expect(() => + instance.addTransition({ + id: "transition-1", + name: "Display Name", + inputArcs: [], + outputArcs: [], + lambdaType: "predicate", + lambdaCode: "", + transitionKernelCode: "", + x: 0, + y: 0, + }), + ).not.toThrow(); + }); + + test("preserves scenario-specific validation in action inputs", () => { + const instance = createInstance(); + + expect(() => + instance.addScenario({ + id: "scenario-1", + name: "Scenario", + scenarioParameters: [ + { type: "real", identifier: "launch_rate", default: 1 }, + { type: "integer", identifier: "launch_rate", default: 2 }, + ], + parameterOverrides: {}, + initialState: { type: "per_place", content: {} }, + }), + ).toThrow(); + + expect(() => + instance.addScenario({ + id: "scenario-1", + name: "Scenario", + scenarioParameters: [ + { type: "real", identifier: "LaunchRate", default: 1 }, + ], + parameterOverrides: {}, + initialState: { type: "per_place", content: {} }, + }), + ).toThrow(); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/core/actions.ts b/libs/@hashintel/petrinaut/src/core/actions.ts new file mode 100644 index 00000000000..b3feef4e73a --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/actions.ts @@ -0,0 +1,622 @@ +import { generateArcId } from "./arc-id"; +import { + colorSchema, + differentialEquationSchema, + metricSchema, + parameterSchema, + mutationActionInputSchemas, + placeSchema, + scenarioSchema, + transitionSchema, + type MutationActionInput, +} from "./action-schemas"; +import type { SDCPN } from "./types/sdcpn"; + +export type MutationHelperFunctions = { + [Name in keyof typeof mutationActionInputSchemas]: ( + input: MutationActionInput, + ) => void; +}; + +export function createPetrinautActions( + mutate: (fn: (sdcpn: SDCPN) => void) => void, +): MutationHelperFunctions { + return { + addPlace(place) { + const parsedPlace = placeSchema.parse(place); + mutate((sdcpn) => { + sdcpn.places.push(parsedPlace); + }); + }, + updatePlace(input) { + const parsed = mutationActionInputSchemas.updatePlace.parse(input); + mutate((sdcpn) => { + for (const place of sdcpn.places) { + if (place.id === parsed.placeId) { + Object.assign(place, parsed.update); + placeSchema.parse(place); + break; + } + } + }); + }, + updatePlacePosition(input) { + const parsed = + mutationActionInputSchemas.updatePlacePosition.parse(input); + mutate((sdcpn) => { + for (const place of sdcpn.places) { + if (place.id === parsed.placeId) { + place.x = parsed.position.x; + place.y = parsed.position.y; + break; + } + } + }); + }, + removePlace(input) { + const { placeId: parsedPlaceId } = + mutationActionInputSchemas.removePlace.parse(input); + mutate((sdcpn) => { + for (const [placeIndex, place] of sdcpn.places.entries()) { + if (place.id === parsedPlaceId) { + sdcpn.places.splice(placeIndex, 1); + + for (const transition of sdcpn.transitions) { + for (let i = transition.inputArcs.length - 1; i >= 0; i--) { + if (transition.inputArcs[i]!.placeId === parsedPlaceId) { + transition.inputArcs.splice(i, 1); + } + } + for (let i = transition.outputArcs.length - 1; i >= 0; i--) { + if (transition.outputArcs[i]!.placeId === parsedPlaceId) { + transition.outputArcs.splice(i, 1); + } + } + } + break; + } + } + }); + }, + addTransition(transition) { + const parsedTransition = transitionSchema.parse(transition); + mutate((sdcpn) => { + sdcpn.transitions.push(parsedTransition); + }); + }, + updateTransition(input) { + const parsed = mutationActionInputSchemas.updateTransition.parse(input); + mutate((sdcpn) => { + for (const transition of sdcpn.transitions) { + if (transition.id === parsed.transitionId) { + Object.assign(transition, parsed.update); + transitionSchema.parse(transition); + break; + } + } + }); + }, + updateTransitionPosition(input) { + const parsed = + mutationActionInputSchemas.updateTransitionPosition.parse(input); + mutate((sdcpn) => { + for (const transition of sdcpn.transitions) { + if (transition.id === parsed.transitionId) { + transition.x = parsed.position.x; + transition.y = parsed.position.y; + break; + } + } + }); + }, + removeTransition(input) { + const { transitionId: parsedTransitionId } = + mutationActionInputSchemas.removeTransition.parse(input); + mutate((sdcpn) => { + for (const [index, transition] of sdcpn.transitions.entries()) { + if (transition.id === parsedTransitionId) { + sdcpn.transitions.splice(index, 1); + break; + } + } + }); + }, + addArc(input) { + const parsed = mutationActionInputSchemas.addArc.parse(input); + mutate((sdcpn) => { + for (const transition of sdcpn.transitions) { + if (transition.id === parsed.transitionId) { + if (parsed.arcDirection === "input") { + transition.inputArcs.push({ + type: "standard", + placeId: parsed.placeId, + weight: parsed.weight, + }); + } else { + transition.outputArcs.push({ + placeId: parsed.placeId, + weight: parsed.weight, + }); + } + break; + } + } + }); + }, + removeArc(input) { + const parsed = mutationActionInputSchemas.removeArc.parse(input); + mutate((sdcpn) => { + for (const transition of sdcpn.transitions) { + if (transition.id === parsed.transitionId) { + for (const [index, arc] of transition[ + parsed.arcDirection === "input" ? "inputArcs" : "outputArcs" + ].entries()) { + if (arc.placeId === parsed.placeId) { + transition[ + parsed.arcDirection === "input" ? "inputArcs" : "outputArcs" + ].splice(index, 1); + break; + } + } + break; + } + } + }); + }, + updateArcWeight(input) { + const parsed = mutationActionInputSchemas.updateArcWeight.parse(input); + mutate((sdcpn) => { + for (const transition of sdcpn.transitions) { + if (transition.id === parsed.transitionId) { + for (const arc of transition[ + parsed.arcDirection === "input" ? "inputArcs" : "outputArcs" + ]) { + if (arc.placeId === parsed.placeId) { + arc.weight = parsed.weight; + break; + } + } + break; + } + } + }); + }, + updateArcType(input) { + const parsed = mutationActionInputSchemas.updateArcType.parse(input); + mutate((sdcpn) => { + for (const transition of sdcpn.transitions) { + if (transition.id === parsed.transitionId) { + for (const arc of transition.inputArcs) { + if (arc.placeId === parsed.placeId) { + arc.type = parsed.type; + break; + } + } + break; + } + } + }); + }, + updateArcPlace(input) { + const parsed = mutationActionInputSchemas.updateArcPlace.parse(input); + mutate((sdcpn) => { + for (const transition of sdcpn.transitions) { + if (transition.id === parsed.transitionId) { + for (const arc of transition[ + parsed.arcDirection === "input" ? "inputArcs" : "outputArcs" + ]) { + if (arc.placeId === parsed.oldPlaceId) { + arc.placeId = parsed.newPlaceId; + break; + } + } + break; + } + } + }); + }, + addType(type) { + const parsedType = colorSchema.parse(type); + mutate((sdcpn) => { + sdcpn.types.push(parsedType); + }); + }, + updateType(input) { + const parsed = mutationActionInputSchemas.updateType.parse(input); + mutate((sdcpn) => { + for (const type of sdcpn.types) { + if (type.id === parsed.typeId) { + Object.assign(type, parsed.update); + colorSchema.parse(type); + break; + } + } + }); + }, + addTypeElement(input) { + const parsed = mutationActionInputSchemas.addTypeElement.parse(input); + mutate((sdcpn) => { + for (const type of sdcpn.types) { + if (type.id === parsed.typeId) { + type.elements.push(parsed.element); + colorSchema.parse(type); + break; + } + } + }); + }, + updateTypeElement(input) { + const parsed = mutationActionInputSchemas.updateTypeElement.parse(input); + mutate((sdcpn) => { + for (const type of sdcpn.types) { + if (type.id === parsed.typeId) { + for (const element of type.elements) { + if (element.elementId === parsed.elementId) { + Object.assign(element, parsed.update); + colorSchema.parse(type); + break; + } + } + break; + } + } + }); + }, + removeTypeElement(input) { + const parsed = mutationActionInputSchemas.removeTypeElement.parse(input); + mutate((sdcpn) => { + for (const type of sdcpn.types) { + if (type.id === parsed.typeId) { + for (const [index, element] of type.elements.entries()) { + if (element.elementId === parsed.elementId) { + type.elements.splice(index, 1); + colorSchema.parse(type); + break; + } + } + break; + } + } + }); + }, + moveTypeElement(input) { + const parsed = mutationActionInputSchemas.moveTypeElement.parse(input); + mutate((sdcpn) => { + for (const type of sdcpn.types) { + if (type.id === parsed.typeId) { + const fromIndex = type.elements.findIndex( + (element) => element.elementId === parsed.elementId, + ); + if (fromIndex === -1) { + break; + } + const [element] = type.elements.splice(fromIndex, 1); + if (element) { + type.elements.splice(parsed.toIndex, 0, element); + colorSchema.parse(type); + } + break; + } + } + }); + }, + removeType(input) { + const { typeId: parsedTypeId } = + mutationActionInputSchemas.removeType.parse(input); + mutate((sdcpn) => { + for (const [index, type] of sdcpn.types.entries()) { + if (type.id === parsedTypeId) { + sdcpn.types.splice(index, 1); + break; + } + } + for (const place of sdcpn.places) { + if (place.colorId === parsedTypeId) { + place.colorId = null; + } + } + for (const equation of sdcpn.differentialEquations) { + if (equation.colorId === parsedTypeId) { + equation.colorId = ""; + } + } + }); + }, + addDifferentialEquation(equation) { + const parsedEquation = differentialEquationSchema.parse(equation); + mutate((sdcpn) => { + sdcpn.differentialEquations.push(parsedEquation); + }); + }, + updateDifferentialEquation(input) { + const parsed = + mutationActionInputSchemas.updateDifferentialEquation.parse(input); + mutate((sdcpn) => { + for (const equation of sdcpn.differentialEquations) { + if (equation.id === parsed.equationId) { + Object.assign(equation, parsed.update); + differentialEquationSchema.parse(equation); + break; + } + } + }); + }, + removeDifferentialEquation(input) { + const { equationId: parsedEquationId } = + mutationActionInputSchemas.removeDifferentialEquation.parse({ + ...input, + }); + mutate((sdcpn) => { + for (const [index, equation] of sdcpn.differentialEquations.entries()) { + if (equation.id === parsedEquationId) { + sdcpn.differentialEquations.splice(index, 1); + break; + } + } + for (const place of sdcpn.places) { + if (place.differentialEquationId === parsedEquationId) { + place.differentialEquationId = null; + } + } + }); + }, + addParameter(parameter) { + const parsedParameter = parameterSchema.parse(parameter); + mutate((sdcpn) => { + sdcpn.parameters.push(parsedParameter); + }); + }, + updateParameter(input) { + const parsed = mutationActionInputSchemas.updateParameter.parse(input); + mutate((sdcpn) => { + for (const parameter of sdcpn.parameters) { + if (parameter.id === parsed.parameterId) { + Object.assign(parameter, parsed.update); + parameterSchema.parse(parameter); + break; + } + } + }); + }, + removeParameter(input) { + const { parameterId: parsedParameterId } = + mutationActionInputSchemas.removeParameter.parse(input); + mutate((sdcpn) => { + for (const [index, parameter] of sdcpn.parameters.entries()) { + if (parameter.id === parsedParameterId) { + sdcpn.parameters.splice(index, 1); + break; + } + } + }); + }, + addScenario(scenario) { + const parsedScenario = scenarioSchema.parse(scenario); + mutate((sdcpn) => { + const scenarios = sdcpn.scenarios ?? []; + scenarios.push(parsedScenario); + // eslint-disable-next-line no-param-reassign -- mutating draft inside immer/structuredClone + sdcpn.scenarios = scenarios; + }); + }, + updateScenario(input) { + const parsed = mutationActionInputSchemas.updateScenario.parse(input); + mutate((sdcpn) => { + for (const scenario of sdcpn.scenarios ?? []) { + if (scenario.id === parsed.scenarioId) { + Object.assign(scenario, parsed.update); + scenarioSchema.parse(scenario); + break; + } + } + }); + }, + removeScenario(input) { + const { scenarioId: parsedScenarioId } = + mutationActionInputSchemas.removeScenario.parse(input); + mutate((sdcpn) => { + const scenarios = sdcpn.scenarios; + if (!scenarios) { + return; + } + for (const [index, scenario] of scenarios.entries()) { + if (scenario.id === parsedScenarioId) { + scenarios.splice(index, 1); + break; + } + } + }); + }, + addMetric(metric) { + const parsedMetric = metricSchema.parse(metric); + mutate((sdcpn) => { + const metrics = sdcpn.metrics ?? []; + metrics.push(parsedMetric); + // eslint-disable-next-line no-param-reassign -- mutating draft inside immer/structuredClone + sdcpn.metrics = metrics; + }); + }, + updateMetric(input) { + const parsed = mutationActionInputSchemas.updateMetric.parse(input); + mutate((sdcpn) => { + for (const metric of sdcpn.metrics ?? []) { + if (metric.id === parsed.metricId) { + Object.assign(metric, parsed.update); + metricSchema.parse(metric); + break; + } + } + }); + }, + removeMetric(input) { + const { metricId: parsedMetricId } = + mutationActionInputSchemas.removeMetric.parse(input); + mutate((sdcpn) => { + const metrics = sdcpn.metrics; + if (!metrics) { + return; + } + for (const [index, metric] of metrics.entries()) { + if (metric.id === parsedMetricId) { + metrics.splice(index, 1); + break; + } + } + }); + }, + deleteItemsByIds(input) { + const parsedItems = + mutationActionInputSchemas.deleteItemsByIds.parse(input).items; + mutate((sdcpn) => { + const placeIds = new Set(); + const transitionIds = new Set(); + const arcIds = new Set(); + const typeIds = new Set(); + const equationIds = new Set(); + const parameterIds = new Set(); + + for (const item of parsedItems) { + const { id } = item; + switch (item.type) { + case "place": + placeIds.add(id); + break; + case "transition": + transitionIds.add(id); + break; + case "arc": + arcIds.add(id); + break; + case "type": + typeIds.add(id); + break; + case "differentialEquation": + equationIds.add(id); + break; + case "parameter": + parameterIds.add(id); + break; + } + } + + const hasCanvasDeletes = + placeIds.size > 0 || transitionIds.size > 0 || arcIds.size > 0; + + if (hasCanvasDeletes) { + for (let i = sdcpn.transitions.length - 1; i >= 0; i--) { + const transition = sdcpn.transitions[i]!; + if (transitionIds.has(transition.id)) { + sdcpn.transitions.splice(i, 1); + continue; + } + + for ( + let inputArcIndex = transition.inputArcs.length - 1; + inputArcIndex >= 0; + inputArcIndex-- + ) { + const inputArc = transition.inputArcs[inputArcIndex]!; + const arcId = generateArcId({ + inputId: inputArc.placeId, + outputId: transition.id, + }); + + if (arcIds.has(arcId) || placeIds.has(inputArc.placeId)) { + transition.inputArcs.splice(inputArcIndex, 1); + } + } + + for ( + let outputArcIndex = transition.outputArcs.length - 1; + outputArcIndex >= 0; + outputArcIndex-- + ) { + const outputArc = transition.outputArcs[outputArcIndex]!; + const arcId = generateArcId({ + inputId: transition.id, + outputId: outputArc.placeId, + }); + + if (arcIds.has(arcId) || placeIds.has(outputArc.placeId)) { + transition.outputArcs.splice(outputArcIndex, 1); + } + } + } + + for (let i = sdcpn.places.length - 1; i >= 0; i--) { + if (placeIds.has(sdcpn.places[i]!.id)) { + sdcpn.places.splice(i, 1); + } + } + } + + if (typeIds.size > 0) { + for (let i = sdcpn.types.length - 1; i >= 0; i--) { + if (typeIds.has(sdcpn.types[i]!.id)) { + sdcpn.types.splice(i, 1); + } + } + for (const place of sdcpn.places) { + if (place.colorId && typeIds.has(place.colorId)) { + place.colorId = null; + } + } + for (const equation of sdcpn.differentialEquations) { + if (typeIds.has(equation.colorId)) { + equation.colorId = ""; + } + } + } + + if (equationIds.size > 0) { + for (let i = sdcpn.differentialEquations.length - 1; i >= 0; i--) { + if (equationIds.has(sdcpn.differentialEquations[i]!.id)) { + sdcpn.differentialEquations.splice(i, 1); + } + } + for (const place of sdcpn.places) { + if ( + place.differentialEquationId && + equationIds.has(place.differentialEquationId) + ) { + place.differentialEquationId = null; + } + } + } + + if (parameterIds.size > 0) { + for (let i = sdcpn.parameters.length - 1; i >= 0; i--) { + if (parameterIds.has(sdcpn.parameters[i]!.id)) { + sdcpn.parameters.splice(i, 1); + } + } + } + }); + }, + commitNodePositions(input) { + const { commits: parsedCommits } = + mutationActionInputSchemas.commitNodePositions.parse(input); + mutate((sdcpn) => { + for (const { id, itemType, position } of parsedCommits) { + if (itemType === "place") { + for (const place of sdcpn.places) { + if (place.id === id) { + place.x = position.x; + place.y = position.y; + break; + } + } + } else { + for (const transition of sdcpn.transitions) { + if (transition.id === id) { + transition.x = position.x; + transition.y = position.y; + break; + } + } + } + } + }); + }, + }; +} diff --git a/libs/@hashintel/petrinaut/src/core/ai.test.ts b/libs/@hashintel/petrinaut/src/core/ai.test.ts new file mode 100644 index 00000000000..f5f929b84d1 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/ai.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "vitest"; + +import { + createPetrinautMutationAiToolCallbacks, + petrinautAiMutationToolInputSchemas, + petrinautAiTools, +} from "./ai"; +import { createJsonDocHandle } from "./handle"; +import { createPetrinaut } from "./instance"; + +describe("Petrinaut AI core exports", () => { + test("tool metadata stays aligned with input schemas and has no execute", () => { + expect(Object.keys(petrinautAiTools).sort()).toEqual( + Object.keys(petrinautAiMutationToolInputSchemas).sort(), + ); + + for (const tool of Object.values(petrinautAiTools)) { + expect(tool.description.length).toBeGreaterThan(0); + expect(tool.inputSchema).toBeDefined(); + expect(tool.description).toBe(tool.inputSchema.description); + expect("execute" in tool).toBe(false); + } + }); + + test("callback map applies tool inputs to a Petrinaut instance", () => { + const instance = createPetrinaut({ + document: createJsonDocHandle({ + initial: { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], + }, + }), + }); + const callbacks = createPetrinautMutationAiToolCallbacks(instance); + + callbacks.addPlace({ + id: "place-1", + name: "Queue", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }); + callbacks.updatePlace({ + placeId: "place-1", + update: { name: "UpdatedQueue" }, + }); + + expect(instance.definition.get().places[0]!.name).toBe("UpdatedQueue"); + }); + + test("callback map validates tool inputs before applying them", () => { + const instance = createPetrinaut({ + document: createJsonDocHandle({ + initial: { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], + }, + }), + }); + const callbacks = createPetrinautMutationAiToolCallbacks(instance); + + expect(() => + callbacks.addPlace({ + id: "", + name: "Queue", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }), + ).toThrow(); + + expect(instance.definition.get().places).toEqual([]); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/core/ai.ts b/libs/@hashintel/petrinaut/src/core/ai.ts new file mode 100644 index 00000000000..10570468ea7 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/ai.ts @@ -0,0 +1,134 @@ +import { z } from "zod"; + +import { probabilisticSatellitesSDCPN } from "../examples/satellites-launcher"; +import { + mutationActionInputSchemas, + type MutationActionName, +} from "./action-schemas"; +import type { Petrinaut } from "./instance"; +import { typedKeys } from "./lib/typed-entries"; + +export { + colorSchema, + differentialEquationSchema, + metricSchema, + parameterSchema, + mutationActionInputSchemas, + placeSchema, + scenarioSchema, + transitionSchema, +} from "./action-schemas"; +export type { + MutationActionInput as PetrinautAiMutationToolInput, + MutationActionName as PetrinautAiMutationToolName, +} from "./action-schemas"; + +export type PetrinautAiTool = { + description: string; + inputSchema: InputSchema; +}; + +export type PetrinautAiTools = { + [Name in keyof typeof petrinautAiMutationToolInputSchemas]: PetrinautAiTool< + (typeof petrinautAiMutationToolInputSchemas)[Name] + >; +}; + +const getSchemaDescription = (schema: z.ZodType): string => { + if (!schema.description) { + throw new Error("Petrinaut AI tool schemas must have descriptions"); + } + return schema.description; +}; + +function createToolBundle>( + schemas: InputSchemas, +): { + [Name in keyof InputSchemas]: PetrinautAiTool; +} { + const tools = {} as { + [Name in keyof InputSchemas]: PetrinautAiTool; + }; + + const setTool = ( + name: Name, + inputSchema: InputSchemas[Name], + ) => { + tools[name] = { + description: getSchemaDescription(inputSchema), + inputSchema, + }; + }; + + for (const name of typedKeys(schemas)) { + setTool(name, schemas[name]); + } + + return tools; +} + +export const getLatestNetDefinitionToolName = "getLatestNetDefinition"; + +const getLatestNetDefinitionToolInputSchema = z + .strictObject({}) + .describe("Get the latest complete Petrinaut SDCPN net definition."); + +export const petrinautAiMutationToolInputSchemas = { + ...mutationActionInputSchemas, + [getLatestNetDefinitionToolName]: getLatestNetDefinitionToolInputSchema, +}; + +export const petrinautAiMutationTools = createToolBundle( + mutationActionInputSchemas, +); + +export const petrinautAiTools = { + ...petrinautAiMutationTools, + [getLatestNetDefinitionToolName]: { + description: getSchemaDescription(getLatestNetDefinitionToolInputSchema), + inputSchema: getLatestNetDefinitionToolInputSchema, + }, +} satisfies PetrinautAiTools; + +export type PetrinautAiToolName = keyof typeof petrinautAiTools; + +export type PetrinautAiToolInput = z.input< + (typeof petrinautAiTools)[Name]["inputSchema"] +>; + +export type PetrinautMutationAiToolCallbacks = Pick< + Petrinaut, + MutationActionName +>; + +export function createPetrinautMutationAiToolCallbacks( + instance: Petrinaut, +): PetrinautMutationAiToolCallbacks { + return instance; +} + +export const petrinautAiPrompt = `You are an expert assistant for building Stochastic Dynamic Coloured Petri Nets (SDCPNs) in Petrinaut. + +Use the provided tools to directly modify the current net. The tools use Petrinaut's raw mutation interfaces, so include stable IDs, full entity objects where required, and canvas positions for places and transitions. +You can check the latest complete net definition at any point using the ${getLatestNetDefinitionToolName} tool. Use it before making changes that depend on existing places, transitions, arcs, scenarios, metrics, parameters, or types. + +When the user's intent, requirements, constraints, or preferred modelling process are ambiguous, ask a concise follow-up question before making changes. If the request is clear, proceed with small, purposeful tool calls. + +When creating or revising a net: +- Prefer small, meaningful mutations rather than replacing unrelated content. +- Use coloured-token types when tokens need attributes. +- Use parameters for values the user may want to tune. +- When adding scenarios, prefer scenario parameters for key assumptions the user may want to modify between runs. Reference them as scenario.identifier in parameter overrides and initial-state expressions. +- Use stochastic transition lambdas for rate-based firing. +- Use predicate transition lambdas for boolean firing conditions. +- Use transition kernels to transform or generate coloured tokens, including stochastic distributions. +- Use differential equations only for places whose coloured tokens have continuous dynamics. +- Keep executable code self-contained and readable. + +After calling tools, do not merely summarize the added or updated items, because the user can already see those changes in the UI. Final text should add extra value: explain important modelling choices, assumptions, how the pieces work together, and useful next checks or questions. + +Here is a compact example Petrinaut document demonstrating coloured tokens, stochastic and predicate transitions, transition kernels with distributions, continuous dynamics, parameters, visualizer code, and scenarios: + +\`\`\`json +${JSON.stringify(probabilisticSatellitesSDCPN, null, 2)} +\`\`\``; diff --git a/libs/@hashintel/petrinaut/src/core/clipboard/serialize.test.ts b/libs/@hashintel/petrinaut/src/core/clipboard/serialize.test.ts index 80dd919ffa1..80b2c4ad3f3 100644 --- a/libs/@hashintel/petrinaut/src/core/clipboard/serialize.test.ts +++ b/libs/@hashintel/petrinaut/src/core/clipboard/serialize.test.ts @@ -296,6 +296,47 @@ describe("parseClipboardPayload", () => { expect(parseClipboardPayload(json)).not.toBeNull(); }); + it("keeps clipboard-specific relaxed names and input arc defaults", () => { + const json = JSON.stringify({ + format: "petrinaut-sdcpn", + version: CLIPBOARD_FORMAT_VERSION, + documentId: null, + data: { + places: [ + { + id: "place-1", + name: "Place 1", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + transitions: [ + { + id: "transition-1", + name: "Transition 1", + inputArcs: [{ placeId: "place-1", weight: 1 }], + outputArcs: [], + lambdaType: "predicate", + lambdaCode: "", + transitionKernelCode: "", + x: 0, + y: 0, + }, + ], + types: [], + differentialEquations: [], + parameters: [], + }, + }); + + const parsed = parseClipboardPayload(json); + expect(parsed).not.toBeNull(); + expect(parsed?.data.transitions[0]?.inputArcs[0]?.type).toBe("standard"); + }); + it("returns null when version is not a number", () => { const json = JSON.stringify({ format: "petrinaut-sdcpn", diff --git a/libs/@hashintel/petrinaut/src/core/clipboard/types.ts b/libs/@hashintel/petrinaut/src/core/clipboard/types.ts index 59667c2aa40..e286744cfe5 100644 --- a/libs/@hashintel/petrinaut/src/core/clipboard/types.ts +++ b/libs/@hashintel/petrinaut/src/core/clipboard/types.ts @@ -1,68 +1,73 @@ import { z } from "zod"; +import { + colorElementSchema as currentColorElementSchema, + colorSchema as currentColorSchema, + differentialEquationSchema as currentDifferentialEquationSchema, + inputArcSchema as currentInputArcSchema, + outputArcSchema as currentOutputArcSchema, + parameterSchema as currentParameterSchema, + placeSchema as currentPlaceSchema, + transitionSchema as currentTransitionSchema, +} from "../schemas/entity-schemas"; + export const CLIPBOARD_FORMAT_VERSION = 1; +const clipboardPlaceShape = currentPlaceSchema.omit({ + showAsInitialState: true, +}).shape; + const inputArcSchema = z.object({ - placeId: z.string(), - weight: z.number(), + ...currentInputArcSchema.shape, type: z.enum(["standard", "inhibitor"]).optional().default("standard"), }); const outputArcSchema = z.object({ - placeId: z.string(), - weight: z.number(), + ...currentOutputArcSchema.shape, }); +/* + * Clipboard payloads represent an in-memory selected subgraph rather than a + * full import/export file: positions and visual type fields are required, but + * scenarios/metrics are intentionally excluded. + */ const placeSchema = z.object({ + ...clipboardPlaceShape, id: z.string(), name: z.string(), - colorId: z.string().nullable(), - dynamicsEnabled: z.boolean(), - differentialEquationId: z.string().nullable(), - visualizerCode: z.string().optional(), - x: z.number(), - y: z.number(), }); const transitionSchema = z.object({ + ...currentTransitionSchema.shape, id: z.string(), name: z.string(), inputArcs: z.array(inputArcSchema), outputArcs: z.array(outputArcSchema), - lambdaType: z.enum(["predicate", "stochastic"]), - lambdaCode: z.string(), - transitionKernelCode: z.string(), - x: z.number(), - y: z.number(), }); const colorElementSchema = z.object({ + ...currentColorElementSchema.shape, elementId: z.string(), name: z.string(), - type: z.enum(["real", "integer", "boolean"]), }); const colorSchema = z.object({ + ...currentColorSchema.shape, id: z.string(), name: z.string(), - iconSlug: z.string(), - displayColor: z.string(), elements: z.array(colorElementSchema), }); const differentialEquationSchema = z.object({ + ...currentDifferentialEquationSchema.shape, id: z.string(), name: z.string(), - colorId: z.string(), - code: z.string(), }); const parameterSchema = z.object({ + ...currentParameterSchema.shape, id: z.string(), name: z.string(), - variableName: z.string(), - type: z.enum(["real", "integer", "boolean"]), - defaultValue: z.string(), }); export const clipboardPayloadSchema = z.object({ diff --git a/libs/@hashintel/petrinaut/src/core/file-format/parse-sdcpn-file.test.ts b/libs/@hashintel/petrinaut/src/core/file-format/parse-sdcpn-file.test.ts index a70657a59d8..1f81dbc4b93 100644 --- a/libs/@hashintel/petrinaut/src/core/file-format/parse-sdcpn-file.test.ts +++ b/libs/@hashintel/petrinaut/src/core/file-format/parse-sdcpn-file.test.ts @@ -61,6 +61,25 @@ describe("parseSDCPNFile", () => { expect(result.sdcpn.differentialEquations).toEqual([]); }); + it("preserves relaxed scenario and metric import defaults", () => { + const result = parseSDCPNFile({ + version: 1, + meta: { generator: "Petrinaut" }, + ...minimalSDCPN, + scenarios: [{ id: "scenario-1", name: "Scenario One" }], + metrics: [{ id: "metric-1", name: "Metric One" }], + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.sdcpn.scenarios?.[0]).toMatchObject({ + scenarioParameters: [], + parameterOverrides: {}, + initialState: { type: "per_place", content: {} }, + }); + expect(result.sdcpn.metrics?.[0]?.code).toBe(""); + }); + it("strips version and meta from the returned sdcpn", () => { const result = parseSDCPNFile({ version: 1, diff --git a/libs/@hashintel/petrinaut/src/core/file-format/types.ts b/libs/@hashintel/petrinaut/src/core/file-format/types.ts index 2cc9b682218..bc2ac806d23 100644 --- a/libs/@hashintel/petrinaut/src/core/file-format/types.ts +++ b/libs/@hashintel/petrinaut/src/core/file-format/types.ts @@ -1,49 +1,63 @@ import { z } from "zod"; +import { + colorElementSchema as currentColorElementSchema, + colorSchema as currentColorSchema, + differentialEquationSchema as currentDifferentialEquationSchema, + inputArcSchema as currentInputArcSchema, + outputArcSchema as currentOutputArcSchema, + parameterSchema as currentParameterSchema, + placeSchema as currentPlaceSchema, + transitionSchema as currentTransitionSchema, +} from "../schemas/entity-schemas"; +import { + scenarioParameterSchema as currentScenarioParameterSchema, + scenarioSchema as currentScenarioSchema, +} from "../schemas/scenario-schema"; +import { metricSchema as currentMetricSchema } from "../schemas/metric-schema"; + export const SDCPN_FILE_FORMAT_VERSION = 1; +/* + * File import intentionally stays more permissive than current runtime/action + * schemas: older files may omit visual fields and input arc type, and imported + * display names may predate current UI validation rules. + */ const inputArcSchema = z.object({ - placeId: z.string(), - weight: z.number(), + ...currentInputArcSchema.shape, type: z.enum(["standard", "inhibitor"]).optional().default("standard"), }); const outputArcSchema = z.object({ - placeId: z.string(), - weight: z.number(), + ...currentOutputArcSchema.shape, }); const placeSchema = z.object({ + ...currentPlaceSchema.shape, id: z.string(), name: z.string(), - colorId: z.string().nullable(), - dynamicsEnabled: z.boolean(), - differentialEquationId: z.string().nullable(), - visualizerCode: z.string().optional(), - showAsInitialState: z.boolean().optional(), x: z.number().optional(), y: z.number().optional(), }); const transitionSchema = z.object({ + ...currentTransitionSchema.shape, id: z.string(), name: z.string(), inputArcs: z.array(inputArcSchema), outputArcs: z.array(outputArcSchema), - lambdaType: z.enum(["predicate", "stochastic"]), - lambdaCode: z.string(), - transitionKernelCode: z.string(), x: z.number().optional(), y: z.number().optional(), }); const colorElementSchema = z.object({ + ...currentColorElementSchema.shape, elementId: z.string(), name: z.string(), - type: z.enum(["real", "integer", "boolean"]), }); const colorSchema = z.object({ + ...currentColorSchema.shape, id: z.string(), name: z.string(), iconSlug: z.string().optional(), @@ -52,53 +66,38 @@ const colorSchema = z.object({ }); const differentialEquationSchema = z.object({ + ...currentDifferentialEquationSchema.shape, id: z.string(), name: z.string(), - colorId: z.string(), - code: z.string(), }); const parameterSchema = z.object({ + ...currentParameterSchema.shape, id: z.string(), name: z.string(), - variableName: z.string(), - type: z.enum(["real", "integer", "boolean"]), - defaultValue: z.string(), }); const scenarioParameterSchema = z.object({ - type: z.enum(["real", "integer", "boolean", "ratio"]), + ...currentScenarioParameterSchema.shape, identifier: z.string(), - default: z.number(), }); -const initialStateSchema = z.discriminatedUnion("type", [ - z.object({ - type: z.literal("per_place"), - content: z.record( - z.string(), - z.union([z.string(), z.array(z.array(z.number()))]), - ), - }), - z.object({ - type: z.literal("code"), - content: z.string(), - }), -]); - const scenarioSchema = z.object({ + ...currentScenarioSchema.shape, id: z.string(), name: z.string(), - description: z.string().optional(), scenarioParameters: z.array(scenarioParameterSchema).default([]), parameterOverrides: z.record(z.string(), z.string()).default({}), - initialState: initialStateSchema.default({ type: "per_place", content: {} }), + initialState: currentScenarioSchema.shape.initialState.default({ + type: "per_place", + content: {}, + }), }); const metricSchema = z.object({ + ...currentMetricSchema.shape, id: z.string(), name: z.string(), - description: z.string().optional(), code: z.string().default(""), }); diff --git a/libs/@hashintel/petrinaut/src/core/index.ts b/libs/@hashintel/petrinaut/src/core/index.ts index 0083cc24aac..4f4171ae481 100644 --- a/libs/@hashintel/petrinaut/src/core/index.ts +++ b/libs/@hashintel/petrinaut/src/core/index.ts @@ -26,6 +26,34 @@ export type { EventStream, Petrinaut, } from "./instance"; +export { createPetrinautActions } from "./actions"; +export type { MutationHelperFunctions } from "./actions"; + +// --- AI --- +export { + colorSchema, + createPetrinautMutationAiToolCallbacks, + differentialEquationSchema, + getLatestNetDefinitionToolName, + metricSchema, + parameterSchema, + petrinautAiMutationToolInputSchemas, + petrinautAiMutationTools, + petrinautAiPrompt, + petrinautAiTools, + placeSchema, + scenarioSchema, + transitionSchema, +} from "./ai"; +export type { + PetrinautAiTool, + PetrinautMutationAiToolCallbacks, + PetrinautAiToolInput, + PetrinautAiMutationToolInput, + PetrinautAiMutationToolName, + PetrinautAiToolName, + PetrinautAiTools, +} from "./ai"; // --- Simulation --- export { diff --git a/libs/@hashintel/petrinaut/src/core/instance.ts b/libs/@hashintel/petrinaut/src/core/instance.ts index 46c8f9a6132..44be432fb08 100644 --- a/libs/@hashintel/petrinaut/src/core/instance.ts +++ b/libs/@hashintel/petrinaut/src/core/instance.ts @@ -3,6 +3,10 @@ import type { PetrinautPatch, ReadableStore, } from "./handle"; +import { + createPetrinautActions, + type MutationHelperFunctions, +} from "./actions"; import type { SDCPN } from "./types/sdcpn"; const EMPTY_SDCPN: SDCPN = { @@ -25,7 +29,7 @@ export type EventStream = { * {@link createSimulation} directly with `instance.handle.doc()` (or any other * SDCPN value). The host owns the simulation's lifecycle. */ -export type Petrinaut = { +export type Petrinaut = MutationHelperFunctions & { readonly handle: PetrinautDocHandle; /** Current SDCPN snapshot store. Falls back to an empty SDCPN until the handle is ready. */ @@ -102,17 +106,20 @@ export function createPetrinaut(config: CreatePetrinautConfig): Petrinaut { const definition = createDefinitionStore(handle); const patches = createPatchStream(handle); + const mutate = (fn: (draft: SDCPN) => void) => { + if (readonly) { + return; + } + handle.change(fn); + }; + const actions = createPetrinautActions(mutate); return { + ...actions, handle, definition, patches, - mutate(fn) { - if (readonly) { - return; - } - handle.change(fn); - }, + mutate, readonly, dispose() { for (const dispose of disposers) { diff --git a/libs/@hashintel/petrinaut/src/core/lib/typed-entries.ts b/libs/@hashintel/petrinaut/src/core/lib/typed-entries.ts new file mode 100644 index 00000000000..da7d0f63daf --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/lib/typed-entries.ts @@ -0,0 +1,47 @@ +/** + * @file vendored in from @local/advanced-types to avoid a runtime dependency (typedX) on a private in-repo package. + */ + +type TupleEntry< + T extends readonly unknown[], + I extends unknown[] = [], + R = never, +> = T extends readonly [infer Head, ...infer Tail] + ? TupleEntry + : R; + +type ObjectEntry> = T extends object + ? { [K in keyof T]: [K, Required[K]] }[keyof T] extends infer E + ? E extends [infer K, infer V] + ? K extends string + ? [K, V] + : K extends number + ? [`${K}`, V] + : never + : never + : never + : never; + +// Source: https://dev.to/harry0000/a-bit-convenient-typescript-type-definitions-for-objectentries-d6g +export type Entry> = T extends readonly [ + unknown, + ...unknown[], +] + ? TupleEntry + : T extends ReadonlyArray + ? [`${number}`, U] + : ObjectEntry; + +/** `Object.entries` analogue which returns a well-typed array. */ +export function typedEntries>( + object: T, +): ReadonlyArray> { + return Object.entries(object) as unknown as ReadonlyArray>; +} + +/** `Object.keys` analogue which returns a well-typed array. */ +export const typedKeys = >( + object: T, +): Entry[0][] => { + return Object.keys(object) as Entry[0][]; +}; diff --git a/libs/@hashintel/petrinaut/src/core/schemas/entity-schemas.ts b/libs/@hashintel/petrinaut/src/core/schemas/entity-schemas.ts new file mode 100644 index 00000000000..9918ae77a68 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/schemas/entity-schemas.ts @@ -0,0 +1,241 @@ +import { z } from "zod"; + +import type { + Color, + DifferentialEquation, + Parameter, + Place, + Transition, +} from "../types/sdcpn"; +import { displayNameSchema } from "../validation/display-name"; +import { entityNameSchema } from "../validation/entity-name"; + +export const idSchema = z.string().min(1).meta({ + description: + "Stable identifier for an SDCPN entity. Use unique IDs within the net.", +}); + +export const positionSchema = z + .strictObject({ + x: z.number().meta({ + description: "Horizontal canvas position.", + }), + y: z.number().meta({ + description: "Vertical canvas position.", + }), + }) + .meta({ + description: "Canvas position for a place or transition.", + }); + +export const nodePositionCommitSchema = z + .strictObject({ + id: idSchema, + itemType: z.enum(["place", "transition"]).meta({ + description: "Whether the positioned node is a place or transition.", + }), + position: positionSchema, + }) + .meta({ + description: "A pending canvas-position update for one node.", + }); + +export const inputArcSchema = z + .strictObject({ + placeId: idSchema.meta({ + description: "ID of the input place connected to the transition.", + }), + weight: z.number().positive().meta({ + description: "Number of tokens consumed from the input place.", + }), + type: z.enum(["standard", "inhibitor"]).meta({ + description: + "Standard arcs consume tokens from the input place; inhibitor arcs prevent firing when the source place has at least the weight indicated.", + }), + }) + .meta({ + description: "Input arc from a place into a transition.", + }); + +export const outputArcSchema = z + .strictObject({ + placeId: idSchema.meta({ + description: "ID of the output place connected from the transition.", + }), + weight: z.number().positive().meta({ + description: "Number of tokens produced into the output place.", + }), + }) + .meta({ + description: "Output arc from a transition into a place.", + }); + +export const arcDirectionSchema = z.enum(["input", "output"]).meta({ + description: + "Whether the arc connects a place into a transition or a transition out to a place.", +}); + +export const colorElementSchema = z + .strictObject({ + elementId: idSchema.meta({ + description: "Stable identifier for this colour element.", + }), + name: displayNameSchema.meta({ + description: + "Token attribute name used in lambda, kernel, visualizer, and dynamics code.", + }), + type: z.enum(["real", "integer", "boolean"]).meta({ + description: "Primitive token attribute type.", + }), + }) + .meta({ + description: "One typed attribute on a coloured token.", + }); + +export const placeSchema = z + .strictObject({ + id: idSchema, + name: entityNameSchema.meta({ + description: + "PascalCase place name. Use concise names that can be referenced by transition code.", + }), + colorId: idSchema.nullable().meta({ + description: + "ID of the token colour/type accepted by this place, or null for uncoloured token counts.", + }), + dynamicsEnabled: z.boolean().meta({ + description: + "Whether tokens in this place are updated by a differential equation during simulation.", + }), + differentialEquationId: idSchema.nullable().meta({ + description: + "ID of the differential equation used for continuous dynamics, or null when dynamics are disabled.", + }), + visualizerCode: z.string().optional().meta({ + description: + "Optional visualization module code for rendering tokens in this place.", + }), + showAsInitialState: z.boolean().optional().meta({ + description: + "Optional UI hint to show this place in the initial-state view.", + }), + x: z.number().meta({ + description: "Horizontal canvas position.", + }), + y: z.number().meta({ + description: "Vertical canvas position.", + }), + }) + .meta({ + description: + "A Petri net place. Places store tokens and may optionally use colours and continuous dynamics.", + }) satisfies z.ZodType; + +export const transitionSchema = z + .strictObject({ + id: idSchema, + name: displayNameSchema.meta({ + description: "Human-readable transition name.", + }), + inputArcs: z.array(inputArcSchema).meta({ + description: + "Input arcs that gate and consume tokens for this transition.", + }), + outputArcs: z.array(outputArcSchema).meta({ + description: + "Output arcs that receive tokens after this transition fires.", + }), + lambdaType: z.enum(["predicate", "stochastic"]).meta({ + description: + "Use predicate for boolean enabling logic; use stochastic for rate-based firing.", + }), + lambdaCode: z.string().meta({ + description: + "JavaScript module code exporting Lambda(...). Predicate lambdas return booleans; stochastic lambdas return rates.", + }), + transitionKernelCode: z.string().meta({ + description: + "Optional JavaScript module code exporting TransitionKernel(...). Use distributions here to create stochastic output token attributes.", + }), + x: z.number().meta({ + description: "Horizontal canvas position.", + }), + y: z.number().meta({ + description: "Vertical canvas position.", + }), + }) + .meta({ + description: + "A Petri net transition. Transitions connect places and define firing logic.", + }) satisfies z.ZodType; + +export const colorSchema = z + .strictObject({ + id: idSchema, + name: displayNameSchema.meta({ + description: "Human-readable colour/type name.", + }), + iconSlug: z.string().min(1).meta({ + description: "Icon identifier used by the UI for this colour/type.", + }), + displayColor: z.string().min(1).meta({ + description: "CSS colour used by the UI to display this colour/type.", + }), + elements: z.array(colorElementSchema).meta({ + description: + "Typed token attributes available on tokens of this colour/type.", + }), + }) + .meta({ + description: + "A coloured-token type. Coloured places store token objects with these attributes.", + }) satisfies z.ZodType; + +export const differentialEquationSchema = z + .strictObject({ + id: idSchema, + name: displayNameSchema.meta({ + description: "Human-readable dynamics name.", + }), + colorId: idSchema.meta({ + description: + "ID of the colour/type whose token attributes this dynamics function updates.", + }), + code: z.string().meta({ + description: + "JavaScript module code exporting Dynamics(...). Return derivatives for each token attribute that changes continuously.", + }), + }) + .meta({ + description: + "A differential equation for continuous dynamics on coloured tokens.", + }) satisfies z.ZodType; + +export const parameterSchema = z + .strictObject({ + id: idSchema, + name: displayNameSchema.meta({ + description: "Human-readable parameter name.", + }), + variableName: z.string().min(1).meta({ + description: + "Identifier used by lambda, kernel, visualizer, metric, and dynamics code.", + }), + type: z.enum(["real", "integer", "boolean"]).meta({ + description: "Primitive parameter type.", + }), + defaultValue: z.string().meta({ + description: + "Default parameter value as an expression string parsed by the simulator.", + }), + }) + .meta({ + description: + "A net-level parameter available to executable SDCPN code and scenarios.", + }) satisfies z.ZodType; + +export type PlaceSchema = typeof placeSchema; +export type TransitionSchema = typeof transitionSchema; +export type ColorSchema = typeof colorSchema; +export type DifferentialEquationSchema = typeof differentialEquationSchema; +export type ParameterSchema = typeof parameterSchema; diff --git a/libs/@hashintel/petrinaut/src/core/schemas/metric-schema.ts b/libs/@hashintel/petrinaut/src/core/schemas/metric-schema.ts index bd5acb6d5f6..92b67289366 100644 --- a/libs/@hashintel/petrinaut/src/core/schemas/metric-schema.ts +++ b/libs/@hashintel/petrinaut/src/core/schemas/metric-schema.ts @@ -1,12 +1,25 @@ import { z } from "zod"; import type { Metric } from "../types/sdcpn"; +import { displayNameSchema } from "../validation/display-name"; +import { idSchema } from "./entity-schemas"; -export const metricSchema = z.object({ - id: z.string().min(1), - name: z.string().min(1, "Metric name is required"), - description: z.string().optional(), - code: z.string(), -}) satisfies z.ZodType; +export const metricSchema = z + .strictObject({ + id: idSchema, + name: displayNameSchema.meta({ + description: "Human-readable metric name.", + }), + description: z.string().optional().meta({ + description: "Optional metric summary shown to users.", + }), + code: z.string().meta({ + description: + "JavaScript function body invoked with state in scope. It must return one number.", + }), + }) + .meta({ + description: "A simulation metric plotted over time.", + }) satisfies z.ZodType; export type MetricSchema = typeof metricSchema; diff --git a/libs/@hashintel/petrinaut/src/core/schemas/scenario-schema.ts b/libs/@hashintel/petrinaut/src/core/schemas/scenario-schema.ts index 07b4f6e0d82..6eb3f00313e 100644 --- a/libs/@hashintel/petrinaut/src/core/schemas/scenario-schema.ts +++ b/libs/@hashintel/petrinaut/src/core/schemas/scenario-schema.ts @@ -1,51 +1,109 @@ import { z } from "zod"; import type { Scenario } from "../types/sdcpn"; +import { idSchema } from "./entity-schemas"; +import { displayNameSchema } from "../validation/display-name"; const SNAKE_CASE_RE = /^[a-z][a-z0-9_]*$/; -export const scenarioParameterSchema = z.object({ - type: z.enum(["real", "integer", "boolean", "ratio"]), - identifier: z - .string() - .min(1, "Identifier cannot be empty") - .regex(SNAKE_CASE_RE, "Identifier must be snake_case"), - default: z.number(), -}); +export const scenarioParameterSchema = z + .strictObject({ + type: z.enum(["real", "integer", "boolean", "ratio"]).meta({ + description: + "Primitive type for a user-tunable scenario variable. Use ratio for 0-1 proportions, integer for counts, real for rates or other continuous values, and boolean for switches.", + }), + identifier: z + .string() + .min(1, "Identifier cannot be empty") + .regex(SNAKE_CASE_RE, "Identifier must be snake_case") + .meta({ + description: + "Scenario-scoped identifier for a user-tunable variable. Reference it as scenario.identifier in parameterOverrides and initialState expressions. Must be snake_case.", + }), + default: z.number().meta({ + description: + "Default numeric value for this scenario parameter, shown to the user before they adjust the scenario.", + }), + }) + .meta({ + description: + "A user-tunable variable scoped to one scenario. Prefer scenario parameters for key assumptions the user may want to modify between simulation runs, such as population size, initial infected ratio, intervention strength, or stress-test severity.", + }); -export const scenarioSchema = z.object({ - id: z.string().min(1), - name: z.string().min(1, "Scenario name is required"), - description: z.string().optional(), - scenarioParameters: z - .array(scenarioParameterSchema) - .superRefine((params, ctx) => { - const seen = new Set(); - for (const [index, p] of params.entries()) { - if (seen.has(p.identifier)) { - ctx.addIssue({ - code: "custom", - path: [index, "identifier"], - message: `Duplicate identifier "${p.identifier}"`, - }); - } - seen.add(p.identifier); - } +const initialStateSchema = z + .discriminatedUnion("type", [ + z + .strictObject({ + type: z.literal("per_place"), + content: z + .record( + z.string(), + z.union([z.string(), z.array(z.array(z.number()))]), + ) + .meta({ + description: + 'Map from place ID to initial tokens for that place. For uncoloured places, use a string expression that evaluates to the initial token count, for example "scenario.population * scenario.initial_ratio". For coloured places, use number[][] token rows.', + }), + }) + .meta({ + description: + "Initial state specified place-by-place. Use this for most scenarios. The content keys must be existing place IDs.", + }), + z + .strictObject({ + type: z.literal("code"), + content: z.string().meta({ + description: + "Executable code for advanced initial-state setup. It should return the full initial token mapping by place ID.", + }), + }) + .meta({ + description: + "Initial state specified by code. Use only when per_place expressions cannot express the setup.", + }), + ]) + .meta({ + description: + 'Initial token state for a scenario. Prefer type "per_place" with content keyed by place ID; use type "code" only for advanced custom setup.', + }); + +export const scenarioSchema = z + .strictObject({ + id: idSchema, + name: displayNameSchema.meta({ + description: "Human-readable scenario name.", }), - parameterOverrides: z.record(z.string(), z.string()), - initialState: z.discriminatedUnion("type", [ - z.object({ - type: z.literal("per_place"), - content: z.record( - z.string(), - z.union([z.string(), z.array(z.array(z.number()))]), - ), + description: z.string().optional().meta({ + description: "Optional scenario summary shown to users.", }), - z.object({ - type: z.literal("code"), - content: z.string(), + scenarioParameters: z + .array(scenarioParameterSchema) + .superRefine((params, ctx) => { + const seen = new Set(); + for (const [index, p] of params.entries()) { + if (seen.has(p.identifier)) { + ctx.addIssue({ + code: "custom", + path: [index, "identifier"], + message: `Duplicate identifier "${p.identifier}"`, + }); + } + seen.add(p.identifier); + } + }) + .meta({ + description: + "User-tunable parameters available only within this scenario. Add scenario parameters for important scenario variables so users can adjust them without editing net-level parameters or code. Reference them as scenario.identifier in parameterOverrides and initialState expressions.", + }), + parameterOverrides: z.record(z.string(), z.string()).default({}).meta({ + description: + 'Map from existing net-level parameter ID to a concrete value or expression for this scenario. Keys must be parameter IDs from the current net. Values may be literals such as "1.5" or expressions using scenario parameters such as "scenario.transmission_multiplier * 0.4". Omit this field or use {} when the scenario does not override any net-level parameters.', }), - ]), -}) satisfies z.ZodType; + initialState: initialStateSchema, + }) + .meta({ + description: + "A reusable simulation scenario with user-tunable scenarioParameters, overrides for existing net-level parameters, and an initial token state. Prefer adding scenario parameters for key assumptions the user may want to modify between runs.", + }) satisfies z.ZodType; export type ScenarioSchema = typeof scenarioSchema; diff --git a/libs/@hashintel/petrinaut/src/core/validation/display-name.ts b/libs/@hashintel/petrinaut/src/core/validation/display-name.ts index c04c6b49c7c..ff1f4fef5fd 100644 --- a/libs/@hashintel/petrinaut/src/core/validation/display-name.ts +++ b/libs/@hashintel/petrinaut/src/core/validation/display-name.ts @@ -8,7 +8,7 @@ import { z } from "zod"; * Valid: "Quality Check", "Start Production", "My Transition 2" * Invalid: "", " " */ -const displayNameSchema = z +export const displayNameSchema = z .string() .trim() .check( diff --git a/libs/@hashintel/petrinaut/src/core/validation/entity-name.ts b/libs/@hashintel/petrinaut/src/core/validation/entity-name.ts index 7e470fe5b54..8174a253e51 100644 --- a/libs/@hashintel/petrinaut/src/core/validation/entity-name.ts +++ b/libs/@hashintel/petrinaut/src/core/validation/entity-name.ts @@ -9,7 +9,7 @@ import { z } from "zod"; */ const PASCAL_CASE_REGEX = /^[A-Z][a-zA-Z]*\d*$/; -const entityNameSchema = z +export const entityNameSchema = z .string() .trim() .check( diff --git a/libs/@hashintel/petrinaut/src/main.ts b/libs/@hashintel/petrinaut/src/main.ts index 5be8888a443..8405cd50bd7 100644 --- a/libs/@hashintel/petrinaut/src/main.ts +++ b/libs/@hashintel/petrinaut/src/main.ts @@ -39,6 +39,8 @@ export type { EventStream, Petrinaut as PetrinautInstance, } from "./core/instance"; +export { createPetrinautActions } from "./core/actions"; +export type { MutationHelperFunctions } from "./core/actions"; export { createSimulation, createWorkerTransport, diff --git a/libs/@hashintel/petrinaut/src/react/mutation-provider.test.tsx b/libs/@hashintel/petrinaut/src/react/mutation-provider.test.tsx index c286b3ca385..054dbe3ae59 100644 --- a/libs/@hashintel/petrinaut/src/react/mutation-provider.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/mutation-provider.test.tsx @@ -5,6 +5,7 @@ import { act, renderHook } from "@testing-library/react"; import { type ReactNode, use } from "react"; import { describe, expect, test, vi } from "vitest"; +import { createPetrinautActions } from "../core/actions"; import type { Petrinaut } from "../core/instance"; import type { SDCPN } from "../core/types/sdcpn"; import { @@ -158,6 +159,7 @@ function createWrapper(options: WrapperOptions = {}) { }); const fakeInstance = { + ...createPetrinautActions(mutateFn), handle: { id: "test-net" }, definition: { get: () => currentSdcpn, subscribe: () => () => {} }, patches: { subscribe: () => () => {} }, @@ -289,10 +291,16 @@ describe("MutationProvider (instance bridge)", () => { const { result } = renderHook(useMutations, { wrapper: Wrapper }); act(() => { - result.current.commitNodePositions([ - { id: "p1", itemType: "place", position: { x: 100, y: 200 } }, - { id: "t1", itemType: "transition", position: { x: 300, y: 400 } }, - ]); + result.current.commitNodePositions({ + commits: [ + { id: "p1", itemType: "place", position: { x: 100, y: 200 } }, + { + id: "t1", + itemType: "transition", + position: { x: 300, y: 400 }, + }, + ], + }); }); expect(getSdcpn().places[0]!.x).toBe(100); @@ -373,7 +381,7 @@ describe("MutationProvider (instance bridge)", () => { const { result } = renderHook(useMutations, { wrapper: Wrapper }); act(() => { - result.current.removeType("type-1"); + result.current.removeType({ typeId: "type-1" }); }); expect(mutateFn).not.toHaveBeenCalled(); @@ -386,7 +394,7 @@ describe("MutationProvider (instance bridge)", () => { const { result } = renderHook(useMutations, { wrapper: Wrapper }); act(() => { - result.current.removeParameter("param-1"); + result.current.removeParameter({ parameterId: "param-1" }); }); expect(mutateFn).not.toHaveBeenCalled(); @@ -413,9 +421,11 @@ describe("MutationProvider (instance bridge)", () => { const { result } = renderHook(useMutations, { wrapper: Wrapper }); act(() => { - result.current.commitNodePositions([ - { id: "p1", itemType: "place", position: { x: 100, y: 200 } }, - ]); + result.current.commitNodePositions({ + commits: [ + { id: "p1", itemType: "place", position: { x: 100, y: 200 } }, + ], + }); }); expect(mutateFn).not.toHaveBeenCalled(); @@ -463,7 +473,7 @@ describe("MutationProvider (instance bridge)", () => { const { result } = renderHook(useMutations, { wrapper: Wrapper }); act(() => { - result.current.removePlace("p1"); + result.current.removePlace({ placeId: "p1" }); }); expect(getSdcpn().places).toHaveLength(1); @@ -507,7 +517,7 @@ describe("MutationProvider (instance bridge)", () => { const { result } = renderHook(useMutations, { wrapper: Wrapper }); act(() => { - result.current.removeType("type-1"); + result.current.removeType({ typeId: "type-1" }); }); expect(getSdcpn().types).toHaveLength(0); @@ -536,7 +546,7 @@ describe("MutationProvider (instance bridge)", () => { const { result } = renderHook(useMutations, { wrapper: Wrapper }); act(() => { - result.current.removeDifferentialEquation("eq-1"); + result.current.removeDifferentialEquation({ equationId: "eq-1" }); }); expect(getSdcpn().differentialEquations).toHaveLength(0); @@ -607,7 +617,7 @@ describe("MutationProvider (instance bridge)", () => { ]); act(() => { - result.current.deleteItemsByIds(items); + result.current.deleteItemsByIds({ items: Array.from(items.values()) }); }); const final = getSdcpn(); diff --git a/libs/@hashintel/petrinaut/src/react/mutation-provider.tsx b/libs/@hashintel/petrinaut/src/react/mutation-provider.tsx index 021b0b28167..9eac7bee2ba 100644 --- a/libs/@hashintel/petrinaut/src/react/mutation-provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/mutation-provider.tsx @@ -1,7 +1,5 @@ import { use, type ReactNode } from "react"; -import { generateArcId } from "../core/arc-id"; -import type { SDCPN } from "../core/types/sdcpn"; import { MutationContext, type MutationContextValue, @@ -11,10 +9,9 @@ import { useIsReadOnly } from "./state/use-is-read-only"; import { usePetrinautInstance } from "./use-petrinaut-instance"; /** - * Bridge: provides the legacy {@link MutationContext} surface, delegating all - * writes to the Core instance's `mutate`. Read-only checks honour the editor - * mode (which lives in `EditorContext`) — only `readonly` blocks scenario - * mutations. + * Provides the mutation context surface, delegating all writes to the Core + * instance's actions. Read-only checks honour the editor mode (which lives in + * `EditorContext`) — only `readonly` blocks scenario mutations. */ export const MutationProvider: React.FC<{ children: ReactNode }> = ({ children, @@ -23,481 +20,193 @@ export const MutationProvider: React.FC<{ children: ReactNode }> = ({ const { readonly } = use(SDCPNContext); const isReadOnly = useIsReadOnly(); - function guardedMutate(fn: (sdcpn: SDCPN) => void): void { + function guardedMutate(callback: () => void): void { if (isReadOnly) { return; } - instance.mutate(fn); + callback(); } /** * Scenario CRUD is allowed even in simulate mode (the Simulate panel is * where scenarios are managed). Only true `readonly` blocks them. */ - function scenarioMutate(fn: (sdcpn: SDCPN) => void): void { + function scenarioMutate(callback: () => void): void { if (readonly) { return; } - instance.mutate(fn); + callback(); } const value: MutationContextValue = { addPlace(place) { - guardedMutate((sdcpn) => { - sdcpn.places.push(place); - }); - }, - updatePlace(placeId, updateFn) { - guardedMutate((sdcpn) => { - for (const place of sdcpn.places) { - if (place.id === placeId) { - updateFn(place); - break; - } - } - }); - }, - updatePlacePosition(placeId, position) { - guardedMutate((sdcpn) => { - for (const place of sdcpn.places) { - if (place.id === placeId) { - place.x = position.x; - place.y = position.y; - break; - } - } - }); - }, - removePlace(placeId) { - guardedMutate((sdcpn) => { - for (const [placeIndex, place] of sdcpn.places.entries()) { - if (place.id === placeId) { - sdcpn.places.splice(placeIndex, 1); - - // Iterate backwards to avoid skipping entries when splicing - for (const transition of sdcpn.transitions) { - for (let i = transition.inputArcs.length - 1; i >= 0; i--) { - if (transition.inputArcs[i]!.placeId === placeId) { - transition.inputArcs.splice(i, 1); - } - } - for (let i = transition.outputArcs.length - 1; i >= 0; i--) { - if (transition.outputArcs[i]!.placeId === placeId) { - transition.outputArcs.splice(i, 1); - } - } - } - break; - } - } + guardedMutate(() => { + instance.addPlace(place); + }); + }, + updatePlace(input) { + guardedMutate(() => { + instance.updatePlace(input); + }); + }, + updatePlacePosition(input) { + guardedMutate(() => { + instance.updatePlacePosition(input); + }); + }, + removePlace(input) { + guardedMutate(() => { + instance.removePlace(input); }); }, addTransition(transition) { - guardedMutate((sdcpn) => { - sdcpn.transitions.push(transition); - }); - }, - updateTransition(transitionId, updateFn) { - guardedMutate((sdcpn) => { - for (const transition of sdcpn.transitions) { - if (transition.id === transitionId) { - updateFn(transition); - break; - } - } - }); - }, - updateTransitionPosition(transitionId, position) { - guardedMutate((sdcpn) => { - for (const transition of sdcpn.transitions) { - if (transition.id === transitionId) { - transition.x = position.x; - transition.y = position.y; - break; - } - } - }); - }, - removeTransition(transitionId) { - guardedMutate((sdcpn) => { - for (const [index, transition] of sdcpn.transitions.entries()) { - if (transition.id === transitionId) { - sdcpn.transitions.splice(index, 1); - break; - } - } - }); - }, - addArc(transitionId, arcDirection, placeId, weight) { - guardedMutate((sdcpn) => { - for (const transition of sdcpn.transitions) { - if (transition.id === transitionId) { - if (arcDirection === "input") { - transition["inputArcs"].push({ - type: "standard", - placeId, - weight, - }); - } else { - transition["outputArcs"].push({ placeId, weight }); - } - break; - } - } - }); - }, - removeArc(transitionId, arcDirection, placeId) { - guardedMutate((sdcpn) => { - for (const transition of sdcpn.transitions) { - if (transition.id === transitionId) { - for (const [index, arc] of transition[ - arcDirection === "input" ? "inputArcs" : "outputArcs" - ].entries()) { - if (arc.placeId === placeId) { - transition[ - arcDirection === "input" ? "inputArcs" : "outputArcs" - ].splice(index, 1); - break; - } - } - break; - } - } - }); - }, - updateArcWeight(transitionId, arcDirection, placeId, weight) { - guardedMutate((sdcpn) => { - for (const transition of sdcpn.transitions) { - if (transition.id === transitionId) { - for (const arc of transition[ - arcDirection === "input" ? "inputArcs" : "outputArcs" - ]) { - if (arc.placeId === placeId) { - arc.weight = weight; - break; - } - } - break; - } - } - }); - }, - updateArcType(transitionId, placeId, type) { - guardedMutate((sdcpn) => { - for (const transition of sdcpn.transitions) { - if (transition.id === transitionId) { - for (const arc of transition["inputArcs"]) { - if (arc.placeId === placeId) { - arc.type = type; - break; - } - } - break; - } - } + guardedMutate(() => { + instance.addTransition(transition); + }); + }, + updateTransition(input) { + guardedMutate(() => { + instance.updateTransition(input); + }); + }, + updateTransitionPosition(input) { + guardedMutate(() => { + instance.updateTransitionPosition(input); + }); + }, + removeTransition(input) { + guardedMutate(() => { + instance.removeTransition(input); + }); + }, + addArc(input) { + guardedMutate(() => { + instance.addArc(input); + }); + }, + removeArc(input) { + guardedMutate(() => { + instance.removeArc(input); + }); + }, + updateArcWeight(input) { + guardedMutate(() => { + instance.updateArcWeight(input); + }); + }, + updateArcType(input) { + guardedMutate(() => { + instance.updateArcType(input); + }); + }, + updateArcPlace(input) { + guardedMutate(() => { + instance.updateArcPlace(input); }); }, addType(type) { - guardedMutate((sdcpn) => { - sdcpn.types.push(type); - }); - }, - updateType(typeId, updateFn) { - guardedMutate((sdcpn) => { - for (const type of sdcpn.types) { - if (type.id === typeId) { - updateFn(type); - break; - } - } - }); - }, - removeType(typeId) { - guardedMutate((sdcpn) => { - for (const [index, type] of sdcpn.types.entries()) { - if (type.id === typeId) { - sdcpn.types.splice(index, 1); - break; - } - } - for (const place of sdcpn.places) { - if (place.colorId === typeId) { - place.colorId = null; - } - } - for (const equation of sdcpn.differentialEquations) { - if (equation.colorId === typeId) { - equation.colorId = ""; - } - } + guardedMutate(() => { + instance.addType(type); + }); + }, + updateType(input) { + guardedMutate(() => { + instance.updateType(input); + }); + }, + removeType(input) { + guardedMutate(() => { + instance.removeType(input); + }); + }, + addTypeElement(input) { + guardedMutate(() => { + instance.addTypeElement(input); + }); + }, + updateTypeElement(input) { + guardedMutate(() => { + instance.updateTypeElement(input); + }); + }, + removeTypeElement(input) { + guardedMutate(() => { + instance.removeTypeElement(input); + }); + }, + moveTypeElement(input) { + guardedMutate(() => { + instance.moveTypeElement(input); }); }, addDifferentialEquation(equation) { - guardedMutate((sdcpn) => { - sdcpn.differentialEquations.push(equation); - }); - }, - updateDifferentialEquation(equationId, updateFn) { - guardedMutate((sdcpn) => { - for (const equation of sdcpn.differentialEquations) { - if (equation.id === equationId) { - updateFn(equation); - break; - } - } - }); - }, - removeDifferentialEquation(equationId) { - guardedMutate((sdcpn) => { - for (const [index, equation] of sdcpn.differentialEquations.entries()) { - if (equation.id === equationId) { - sdcpn.differentialEquations.splice(index, 1); - break; - } - } - for (const place of sdcpn.places) { - if (place.differentialEquationId === equationId) { - place.differentialEquationId = null; - } - } + guardedMutate(() => { + instance.addDifferentialEquation(equation); + }); + }, + updateDifferentialEquation(input) { + guardedMutate(() => { + instance.updateDifferentialEquation(input); + }); + }, + removeDifferentialEquation(input) { + guardedMutate(() => { + instance.removeDifferentialEquation(input); }); }, addParameter(parameter) { - guardedMutate((sdcpn) => { - sdcpn.parameters.push(parameter); + guardedMutate(() => { + instance.addParameter(parameter); }); }, - updateParameter(parameterId, updateFn) { - guardedMutate((sdcpn) => { - for (const parameter of sdcpn.parameters) { - if (parameter.id === parameterId) { - updateFn(parameter); - break; - } - } + updateParameter(input) { + guardedMutate(() => { + instance.updateParameter(input); }); }, - removeParameter(parameterId) { - guardedMutate((sdcpn) => { - for (const [index, parameter] of sdcpn.parameters.entries()) { - if (parameter.id === parameterId) { - sdcpn.parameters.splice(index, 1); - break; - } - } + removeParameter(input) { + guardedMutate(() => { + instance.removeParameter(input); }); }, addScenario(scenario) { - scenarioMutate((sdcpn) => { - const scenarios = sdcpn.scenarios ?? []; - scenarios.push(scenario); - // eslint-disable-next-line no-param-reassign -- mutating draft inside immer/structuredClone - sdcpn.scenarios = scenarios; - }); - }, - updateScenario(scenarioId, updateFn) { - scenarioMutate((sdcpn) => { - for (const scenario of sdcpn.scenarios ?? []) { - if (scenario.id === scenarioId) { - updateFn(scenario); - break; - } - } - }); - }, - removeScenario(scenarioId) { - scenarioMutate((sdcpn) => { - const scenarios = sdcpn.scenarios; - if (!scenarios) { - return; - } - for (const [index, scenario] of scenarios.entries()) { - if (scenario.id === scenarioId) { - scenarios.splice(index, 1); - break; - } - } + scenarioMutate(() => { + instance.addScenario(scenario); + }); + }, + updateScenario(input) { + scenarioMutate(() => { + instance.updateScenario(input); + }); + }, + removeScenario(input) { + scenarioMutate(() => { + instance.removeScenario(input); }); }, addMetric(metric) { - scenarioMutate((sdcpn) => { - const metrics = sdcpn.metrics ?? []; - metrics.push(metric); - // eslint-disable-next-line no-param-reassign -- mutating draft inside immer/structuredClone - sdcpn.metrics = metrics; - }); - }, - updateMetric(metricId, updateFn) { - scenarioMutate((sdcpn) => { - for (const metric of sdcpn.metrics ?? []) { - if (metric.id === metricId) { - updateFn(metric); - break; - } - } - }); - }, - removeMetric(metricId) { - scenarioMutate((sdcpn) => { - const metrics = sdcpn.metrics; - if (!metrics) { - return; - } - for (const [index, metric] of metrics.entries()) { - if (metric.id === metricId) { - metrics.splice(index, 1); - break; - } - } - }); - }, - deleteItemsByIds(items) { - guardedMutate((sdcpn) => { - const placeIds = new Set(); - const transitionIds = new Set(); - const arcIds = new Set(); - const typeIds = new Set(); - const equationIds = new Set(); - const parameterIds = new Set(); - - for (const [id, item] of items) { - switch (item.type) { - case "place": - placeIds.add(id); - break; - case "transition": - transitionIds.add(id); - break; - case "arc": - arcIds.add(id); - break; - case "type": - typeIds.add(id); - break; - case "differentialEquation": - equationIds.add(id); - break; - case "parameter": - parameterIds.add(id); - break; - } - } - - const hasCanvasDeletes = - placeIds.size > 0 || transitionIds.size > 0 || arcIds.size > 0; - - if (hasCanvasDeletes) { - for (let i = sdcpn.transitions.length - 1; i >= 0; i--) { - const transition = sdcpn.transitions[i]!; - if (transitionIds.has(transition.id)) { - sdcpn.transitions.splice(i, 1); - continue; - } - - for ( - let inputArcIndex = transition.inputArcs.length - 1; - inputArcIndex >= 0; - inputArcIndex-- - ) { - const inputArc = transition.inputArcs[inputArcIndex]!; - const arcId = generateArcId({ - inputId: inputArc.placeId, - outputId: transition.id, - }); - - if (arcIds.has(arcId) || placeIds.has(inputArc.placeId)) { - transition.inputArcs.splice(inputArcIndex, 1); - } - } - - for ( - let outputArcIndex = transition.outputArcs.length - 1; - outputArcIndex >= 0; - outputArcIndex-- - ) { - const outputArc = transition.outputArcs[outputArcIndex]!; - const arcId = generateArcId({ - inputId: transition.id, - outputId: outputArc.placeId, - }); - - if (arcIds.has(arcId) || placeIds.has(outputArc.placeId)) { - transition.outputArcs.splice(outputArcIndex, 1); - } - } - } - - for (let i = sdcpn.places.length - 1; i >= 0; i--) { - if (placeIds.has(sdcpn.places[i]!.id)) { - sdcpn.places.splice(i, 1); - } - } - } - - if (typeIds.size > 0) { - for (let i = sdcpn.types.length - 1; i >= 0; i--) { - if (typeIds.has(sdcpn.types[i]!.id)) { - sdcpn.types.splice(i, 1); - } - } - for (const place of sdcpn.places) { - if (place.colorId && typeIds.has(place.colorId)) { - place.colorId = null; - } - } - for (const equation of sdcpn.differentialEquations) { - if (typeIds.has(equation.colorId)) { - equation.colorId = ""; - } - } - } - - if (equationIds.size > 0) { - for (let i = sdcpn.differentialEquations.length - 1; i >= 0; i--) { - if (equationIds.has(sdcpn.differentialEquations[i]!.id)) { - sdcpn.differentialEquations.splice(i, 1); - } - } - for (const place of sdcpn.places) { - if ( - place.differentialEquationId && - equationIds.has(place.differentialEquationId) - ) { - place.differentialEquationId = null; - } - } - } - - if (parameterIds.size > 0) { - for (let i = sdcpn.parameters.length - 1; i >= 0; i--) { - if (parameterIds.has(sdcpn.parameters[i]!.id)) { - sdcpn.parameters.splice(i, 1); - } - } - } - }); - }, - commitNodePositions(commits) { - guardedMutate((sdcpn) => { - for (const { id, itemType, position } of commits) { - if (itemType === "place") { - for (const place of sdcpn.places) { - if (place.id === id) { - place.x = position.x; - place.y = position.y; - break; - } - } - } else { - for (const transition of sdcpn.transitions) { - if (transition.id === id) { - transition.x = position.x; - transition.y = position.y; - break; - } - } - } - } + scenarioMutate(() => { + instance.addMetric(metric); + }); + }, + updateMetric(input) { + scenarioMutate(() => { + instance.updateMetric(input); + }); + }, + removeMetric(input) { + scenarioMutate(() => { + instance.removeMetric(input); + }); + }, + deleteItemsByIds(input) { + guardedMutate(() => { + instance.deleteItemsByIds(input); + }); + }, + commitNodePositions(input) { + guardedMutate(() => { + instance.commitNodePositions(input); }); }, }; diff --git a/libs/@hashintel/petrinaut/src/react/state/mutation-context.ts b/libs/@hashintel/petrinaut/src/react/state/mutation-context.ts index 21a41554cac..74b4e357d11 100644 --- a/libs/@hashintel/petrinaut/src/react/state/mutation-context.ts +++ b/libs/@hashintel/petrinaut/src/react/state/mutation-context.ts @@ -1,89 +1,6 @@ import { createContext } from "react"; -import type { - Color, - DifferentialEquation, - Metric, - Parameter, - Place, - Scenario, - Transition, -} from "../../core/types/sdcpn"; -import type { SelectionMap } from "../../core/types/selection"; - -export type MutationHelperFunctions = { - addPlace: (place: Place) => void; - updatePlace: (placeId: string, updateFn: (place: Place) => void) => void; - updatePlacePosition: ( - placeId: string, - position: { x: number; y: number }, - ) => void; - removePlace: (placeId: string) => void; - addTransition: (transition: Transition) => void; - updateTransition: ( - transitionId: string, - updateFn: (transition: Transition) => void, - ) => void; - updateTransitionPosition: ( - transitionId: string, - position: { x: number; y: number }, - ) => void; - removeTransition: (transitionId: string) => void; - addArc: ( - transitionId: string, - arcDirection: "input" | "output", - placeId: string, - weight: number, - ) => void; - removeArc: ( - transitionId: string, - arcDirection: "input" | "output", - placeId: string, - ) => void; - updateArcWeight: ( - transitionId: string, - arcDirection: "input" | "output", - placeId: string, - weight: number, - ) => void; - updateArcType: ( - transitionId: string, - placeId: string, - type: "standard" | "inhibitor", - ) => void; - addType: (type: Color) => void; - updateType: (typeId: string, updateFn: (type: Color) => void) => void; - removeType: (typeId: string) => void; - addDifferentialEquation: (equation: DifferentialEquation) => void; - updateDifferentialEquation: ( - equationId: string, - updateFn: (equation: DifferentialEquation) => void, - ) => void; - removeDifferentialEquation: (equationId: string) => void; - addParameter: (parameter: Parameter) => void; - updateParameter: ( - parameterId: string, - updateFn: (parameter: Parameter) => void, - ) => void; - removeParameter: (parameterId: string) => void; - addScenario: (scenario: Scenario) => void; - updateScenario: ( - scenarioId: string, - updateFn: (scenario: Scenario) => void, - ) => void; - removeScenario: (scenarioId: string) => void; - addMetric: (metric: Metric) => void; - updateMetric: (metricId: string, updateFn: (metric: Metric) => void) => void; - removeMetric: (metricId: string) => void; - deleteItemsByIds: (items: SelectionMap) => void; - commitNodePositions: ( - commits: Array<{ - id: string; - itemType: "place" | "transition"; - position: { x: number; y: number }; - }>, - ) => void; -}; +import type { MutationHelperFunctions } from "../../core/actions"; export type MutationContextValue = MutationHelperFunctions; @@ -100,9 +17,14 @@ const DEFAULT_CONTEXT_VALUE: MutationContextValue = { removeArc: () => {}, updateArcWeight: () => {}, updateArcType: () => {}, + updateArcPlace: () => {}, addType: () => {}, updateType: () => {}, removeType: () => {}, + addTypeElement: () => {}, + updateTypeElement: () => {}, + removeTypeElement: () => {}, + moveTypeElement: () => {}, addDifferentialEquation: () => {}, updateDifferentialEquation: () => {}, removeDifferentialEquation: () => {}, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts index 188e39744ad..3b3d905f723 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts @@ -150,7 +150,7 @@ export function useKeyboardShortcuts( hasSelection ) { event.preventDefault(); - deleteItemsByIds(selection); + deleteItemsByIds({ items: Array.from(selection.values()) }); clearSelection(); return; } diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx index c93ce936a84..b0ee7e2e6e9 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx @@ -67,7 +67,7 @@ const DiffEqRowMenu: React.FC<{ item: { id: string } }> = ({ item }) => { icon: , destructive: true, disabled: isReadOnly, - onClick: () => removeDifferentialEquation(item.id), + onClick: () => removeDifferentialEquation({ equationId: item.id }), }, ]} /> diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/entities-tree.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/entities-tree.tsx index 35eafdd3fec..4ac6c3539cc 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/entities-tree.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/entities-tree.tsx @@ -66,9 +66,10 @@ const EntityRowMenu: React.FC<{ item: EntityTreeItem }> = ({ item }) => { } const deleteActions: Partial void>> = { - type: () => removeType(item.id), - differentialEquation: () => removeDifferentialEquation(item.id), - parameter: () => removeParameter(item.id), + type: () => removeType({ typeId: item.id }), + differentialEquation: () => + removeDifferentialEquation({ equationId: item.id }), + parameter: () => removeParameter({ parameterId: item.id }), }; const deleteAction = deleteActions[type]; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx index 40ed10a95f8..3c8d04dd647 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx @@ -81,7 +81,7 @@ const ParameterRowMenu: React.FC<{ item: { id: string } }> = ({ item }) => { icon: , destructive: true, disabled: isReadOnly, - onClick: () => removeParameter(item.id), + onClick: () => removeParameter({ parameterId: item.id }), }, ]} /> diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/types-list.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/types-list.tsx index 513553d8908..e9e6400c658 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/types-list.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/types-list.tsx @@ -119,7 +119,7 @@ const TypeRowMenu: React.FC<{ item: { id: string } }> = ({ item }) => { icon: , destructive: true, disabled: isReadOnly, - onClick: () => removeType(item.id), + onClick: () => removeType({ typeId: item.id }), }, ]} /> diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/arc-properties/main.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/arc-properties/main.tsx index e1331afc190..0b5d5e454c4 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/arc-properties/main.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/arc-properties/main.tsx @@ -14,6 +14,7 @@ import { UI_MESSAGES } from "../../../../../constants/ui-messages"; import type { SDCPN } from "../../../../../../core/types/sdcpn"; import { EditorContext } from "../../../../../../react/state/editor-context"; import { parseArcId } from "../../../../../../core/types/selection"; +import type { MutationContextValue } from "../../../../../../react/state/mutation-context"; import { useIsReadOnly } from "../../../../../../react/state/use-is-read-only"; const containerStyle = css({ @@ -38,22 +39,9 @@ interface ArcPropertiesData { targetName: string; weight: number; type: "standard" | "inhibitor"; - updateArcWeight: ( - transitionId: string, - arcDirection: "input" | "output", - placeId: string, - weight: number, - ) => void; - updateArcType: ( - transitionId: string, - placeId: string, - type: "standard" | "inhibitor", - ) => void; - removeArc: ( - transitionId: string, - arcDirection: "input" | "output", - placeId: string, - ) => void; + updateArcWeight: MutationContextValue["updateArcWeight"]; + updateArcType: MutationContextValue["updateArcType"]; + removeArc: MutationContextValue["removeArc"]; } const ArcPropertiesContext = createContext(null); @@ -95,11 +83,11 @@ const ArcMainContent: React.FC = () => { { - updateDifferentialEquation( - differentialEquation.id, - (existingEquation) => { - existingEquation.name = event.target.value; - }, - ); + updateDifferentialEquation({ + equationId: differentialEquation.id, + update: { name: event.target.value }, + }); }} disabled={isReadOnly} tooltip={isReadOnly ? UI_MESSAGES.READ_ONLY_MODE : undefined} @@ -262,12 +256,10 @@ const DiffEqMainContent: React.FC = () => { value={differentialEquation.code} height="100%" onChange={(newCode) => { - updateDifferentialEquation( - differentialEquation.id, - (existingEquation) => { - existingEquation.code = newCode ?? ""; - }, - ); + updateDifferentialEquation({ + equationId: differentialEquation.id, + update: { code: newCode ?? "" }, + }); }} options={{ readOnly: isReadOnly }} tooltip={isReadOnly ? UI_MESSAGES.READ_ONLY_MODE : undefined} @@ -308,14 +300,14 @@ const DiffEqCodeAction: React.FC = () => { (tp) => tp.id === differentialEquation.colorId, ); - updateDifferentialEquation( - differentialEquation.id, - (existingEquation) => { - existingEquation.code = equationType + updateDifferentialEquation({ + equationId: differentialEquation.id, + update: { + code: equationType ? generateDefaultDifferentialEquationCode(equationType) - : DEFAULT_DIFFERENTIAL_EQUATION_CODE; + : DEFAULT_DIFFERENTIAL_EQUATION_CODE, }, - ); + }); }, }, { diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/multi-selection-panel.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/multi-selection-panel.tsx index 8f545412475..8a5570e08e2 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/multi-selection-panel.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/multi-selection-panel.tsx @@ -9,10 +9,8 @@ import type { SubView } from "../../../../components/sub-view/types"; import { VerticalSubViewsContainer } from "../../../../components/sub-view/vertical/vertical-sub-views-container"; import { UI_MESSAGES } from "../../../../constants/ui-messages"; import { EditorContext } from "../../../../../react/state/editor-context"; -import type { - SelectionItem, - SelectionMap, -} from "../../../../../core/types/selection"; +import type { SelectionItem } from "../../../../../core/types/selection"; +import type { MutationContextValue } from "../../../../../react/state/mutation-context"; import { useIsReadOnly } from "../../../../../react/state/use-is-read-only"; const containerStyle = css({ @@ -30,7 +28,7 @@ const summaryStyle = css({ interface MultiSelectionData { items: SelectionItem[]; - deleteItemsByIds: (items: SelectionMap) => void; + deleteItemsByIds: MutationContextValue["deleteItemsByIds"]; } const MultiSelectionContext = createContext(null); @@ -87,7 +85,7 @@ const DeleteSelectionAction: React.FC = () => { iconName="trash" disabled={isReadOnly} onClick={() => { - deleteItemsByIds(new Map(items.map((item) => [item.id, item]))); + deleteItemsByIds({ items }); clearSelection(); }} tooltip={isReadOnly ? UI_MESSAGES.READ_ONLY_MODE : "Delete selected"} @@ -110,7 +108,7 @@ const subViews: SubView[] = [multiSelectionMainSubView]; interface MultiSelectionPanelProps { items: SelectionItem[]; - deleteItemsByIds: (items: SelectionMap) => void; + deleteItemsByIds: MutationContextValue["deleteItemsByIds"]; } export const MultiSelectionPanel: React.FC = ({ diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/panel.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/panel.tsx index e4fd59570da..9fec06d9757 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/panel.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/panel.tsx @@ -71,8 +71,13 @@ export const PropertiesPanel: React.FC = () => { updateTransition, updateArcWeight, updateArcType, + updateArcPlace, removeArc, updateType, + addTypeElement, + updateTypeElement, + removeTypeElement, + moveTypeElement, updateDifferentialEquation, updateParameter, deleteItemsByIds, @@ -134,6 +139,8 @@ export const PropertiesPanel: React.FC = () => { types={petriNetDefinition.types} onArcWeightUpdate={updateArcWeight} updateTransition={updateTransition} + updateArcPlace={updateArcPlace} + removeArc={removeArc} /> ); } @@ -158,7 +165,16 @@ export const PropertiesPanel: React.FC = () => { (type) => type.id === item.id, ); if (typeData) { - content = ; + content = ( + + ); } break; } diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/parameter-properties/context.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/parameter-properties/context.tsx index 2be272a73c6..81797d932cb 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/parameter-properties/context.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/parameter-properties/context.tsx @@ -1,13 +1,11 @@ import { createContext, use } from "react"; import type { Parameter } from "../../../../../../core/types/sdcpn"; +import type { MutationContextValue } from "../../../../../../react/state/mutation-context"; export interface ParameterPropertiesContextValue { parameter: Parameter; - updateParameter: ( - parameterId: string, - updateFn: (parameter: Parameter) => void, - ) => void; + updateParameter: MutationContextValue["updateParameter"]; } export const ParameterPropertiesContext = diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/parameter-properties/main.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/parameter-properties/main.tsx index 9fc302d65d5..a03c293b646 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/parameter-properties/main.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/parameter-properties/main.tsx @@ -3,6 +3,7 @@ import { css } from "@hashintel/ds-helpers/css"; import type { SubView } from "../../../../../components/sub-view/types"; import { VerticalSubViewsContainer } from "../../../../../components/sub-view/vertical/vertical-sub-views-container"; import type { Parameter } from "../../../../../../core/types/sdcpn"; +import type { MutationContextValue } from "../../../../../../react/state/mutation-context"; import { ParameterPropertiesContext } from "./context"; import { parameterMainContentSubView } from "./subviews/main"; @@ -17,10 +18,7 @@ const subViews: SubView[] = [parameterMainContentSubView]; interface ParameterPropertiesProps { parameter: Parameter; - updateParameter: ( - parameterId: string, - updateFn: (parameter: Parameter) => void, - ) => void; + updateParameter: MutationContextValue["updateParameter"]; } export const ParameterProperties: React.FC = ({ diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/parameter-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/parameter-properties/subviews/main.tsx index cd902adaa52..a54cfde3e0b 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/parameter-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/parameter-properties/subviews/main.tsx @@ -28,16 +28,18 @@ const ParameterMainContent: React.FC = () => { }, [parameter.id, parameter.variableName]); const handleUpdateName = (event: React.ChangeEvent) => { - updateParameter(parameter.id, (existingParameter) => { - existingParameter.name = event.target.value; + updateParameter({ + parameterId: parameter.id, + update: { name: event.target.value }, }); }; const handleUpdateDefaultValue = ( event: React.ChangeEvent, ) => { - updateParameter(parameter.id, (existingParameter) => { - existingParameter.defaultValue = event.target.value; + updateParameter({ + parameterId: parameter.id, + update: { defaultValue: event.target.value }, }); }; @@ -71,8 +73,9 @@ const ParameterMainContent: React.FC = () => { setVarNameError(null); if (result.name !== parameter.variableName) { - updateParameter(parameter.id, (existingParameter) => { - existingParameter.variableName = result.name; + updateParameter({ + parameterId: parameter.id, + update: { variableName: result.name }, }); } }} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/context.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/context.tsx index 0869e8a6fb7..a45cb4c5af4 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/context.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/context.tsx @@ -1,6 +1,7 @@ import { createContext, type ReactNode, use } from "react"; import type { Color, Place } from "../../../../../../core/types/sdcpn"; +import type { MutationContextValue } from "../../../../../../react/state/mutation-context"; /** * Context for providing place-specific data to subview components @@ -16,7 +17,7 @@ interface PlacePropertiesContextValue { /** Whether the panel is in read-only mode */ isReadOnly: boolean; /** Function to update the place */ - updatePlace: (placeId: string, updateFn: (place: Place) => void) => void; + updatePlace: MutationContextValue["updatePlace"]; } const PlacePropertiesContext = @@ -41,7 +42,7 @@ interface PlacePropertiesProviderProps { placeType: Color | null; types: Color[]; isReadOnly: boolean; - updatePlace: (placeId: string, updateFn: (place: Place) => void) => void; + updatePlace: MutationContextValue["updatePlace"]; children: ReactNode; } diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/main.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/main.tsx index acceb5024c8..7990b65c7c3 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/main.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/main.tsx @@ -3,6 +3,7 @@ import { css } from "@hashintel/ds-helpers/css"; import type { SubView } from "../../../../../components/sub-view/types"; import { VerticalSubViewsContainer } from "../../../../../components/sub-view/vertical/vertical-sub-views-container"; import type { Color, Place } from "../../../../../../core/types/sdcpn"; +import type { MutationContextValue } from "../../../../../../react/state/mutation-context"; import { useIsReadOnly } from "../../../../../../react/state/use-is-read-only"; import { PlacePropertiesProvider } from "./context"; import { placeMainContentSubView } from "./subviews/main"; @@ -25,7 +26,7 @@ const subViews: SubView[] = [ interface PlacePropertiesProps { place: Place; types: Color[]; - updatePlace: (placeId: string, updateFn: (place: Place) => void) => void; + updatePlace: MutationContextValue["updatePlace"]; } export const PlaceProperties: React.FC = ({ diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx index 84acef02615..05ba5286938 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx @@ -98,8 +98,9 @@ const PlaceMainContent: React.FC = () => { setNameError(null); if (result.name !== place.name) { - updatePlace(place.id, (existingPlace) => { - existingPlace.name = result.name; + updatePlace({ + placeId: place.id, + update: { name: result.name }, }); } }; @@ -147,12 +148,15 @@ const PlaceMainContent: React.FC = () => { value={place.colorId ?? ""} onValueChange={(value) => { const newType = value === "" ? null : value; - updatePlace(place.id, (existingPlace) => { - existingPlace.colorId = newType; - // Disable dynamics if type is being set to null - if (newType === null && existingPlace.dynamicsEnabled) { - existingPlace.dynamicsEnabled = false; - } + updatePlace({ + placeId: place.id, + update: { + colorId: newType, + dynamicsEnabled: + newType === null && place.dynamicsEnabled + ? false + : place.dynamicsEnabled, + }, }); }} options={[ @@ -241,18 +245,24 @@ const PlaceMainContent: React.FC = () => { : undefined } onCheckedChange={(checked) => { - updatePlace(place.id, (existingPlace) => { - existingPlace.dynamicsEnabled = checked; - if (checked) { - // Auto-select first available diff eq if none selected or previous no longer exists - const currentIsValid = availableDiffEqs.some( - (eq) => eq.id === existingPlace.differentialEquationId, - ); - if (!currentIsValid && availableDiffEqs.length > 0) { - existingPlace.differentialEquationId = - availableDiffEqs[0]!.id; - } + const update: { + dynamicsEnabled: boolean; + differentialEquationId?: string | null; + } = { dynamicsEnabled: checked }; + + if (checked) { + // Auto-select first available diff eq if none selected or previous no longer exists + const currentIsValid = availableDiffEqs.some( + (eq) => eq.id === place.differentialEquationId, + ); + if (!currentIsValid && availableDiffEqs.length > 0) { + update.differentialEquationId = availableDiffEqs[0]!.id; } + } + + updatePlace({ + placeId: place.id, + update, }); }} /> @@ -275,8 +285,9 @@ const PlaceMainContent: React.FC = () => { { - updateType(type.id, (existingType) => { - existingType.name = event.target.value; + updateType({ + typeId: type.id, + update: { name: event.target.value }, }); }} disabled={isDisabled} @@ -258,8 +259,9 @@ const TypeMainContent: React.FC = () => { { - updateType(type.id, (existingType) => { - existingType.displayColor = color; + updateType({ + typeId: type.id, + update: { displayColor: color }, }); }} disabled={isDisabled} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/view-metric-drawer.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/view-metric-drawer.tsx index 5cfc4510fbc..7f8b97eb119 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/view-metric-drawer.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/view-metric-drawer.tsx @@ -128,10 +128,13 @@ const ViewMetricContent = ({ if (!result.success) { return; } - updateMetric(metric.id, (draft) => { - draft.name = result.data.name; - draft.description = result.data.description; - draft.code = result.data.code; + updateMetric({ + metricId: metric.id, + update: { + name: result.data.name, + description: result.data.description, + code: result.data.code, + }, }); onClose(); }, @@ -156,7 +159,7 @@ const ViewMetricContent = ({ const metricSessionId = useMetricLspSession(values.code); const handleDelete = () => { - removeMetric(metric.id); + removeMetric({ metricId: metric.id }); onClose(); }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/view-scenario-drawer.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/view-scenario-drawer.tsx index 82a93916532..d4b12a1a336 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/view-scenario-drawer.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/view-scenario-drawer.tsx @@ -155,13 +155,15 @@ const ViewScenarioContent = ({ if (!result.success) { return; } - updateScenario(scenario.id, (draft) => { - // Replace each field on the immer/structuredClone draft. - draft.name = result.data.name; - draft.description = result.data.description; - draft.scenarioParameters = result.data.scenarioParameters; - draft.parameterOverrides = result.data.parameterOverrides; - draft.initialState = result.data.initialState; + updateScenario({ + scenarioId: scenario.id, + update: { + name: result.data.name, + description: result.data.description, + scenarioParameters: result.data.scenarioParameters, + parameterOverrides: result.data.parameterOverrides, + initialState: result.data.initialState, + }, }); onClose(); }, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/run-auto-layout.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/run-auto-layout.ts index 0eb8e18c761..f10c0765002 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/run-auto-layout.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/run-auto-layout.ts @@ -34,8 +34,9 @@ export async function runAutoLayout({ const positions = await calculateGraphLayout(sdcpn, dimensions); - const commits: Parameters[0] = - []; + const commits: Parameters< + MutationContextValue["commitNodePositions"] + >[0]["commits"] = []; for (const place of sdcpn.places) { const position = positions[place.id]; @@ -59,6 +60,6 @@ export async function runAutoLayout({ } if (commits.length > 0) { - commitNodePositions(commits); + commitNodePositions({ commits }); } } diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-apply-node-changes.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-apply-node-changes.ts index e552822d125..d8e6be5aea1 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-apply-node-changes.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-apply-node-changes.ts @@ -139,7 +139,7 @@ export function useApplyNodeChanges() { } if (commits.length > 0) { - commitNodePositions(commits); + commitNodePositions({ commits }); } } }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx index 032de322226..e0c96648f77 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx @@ -200,13 +200,23 @@ export const SDCPNView: React.FC<{ // Determine direction: place->transition or transition->place if (sourceNode.type === "place" && targetNode.type === "transition") { // Input arc: place to transition - addArc(target, "input", source, 1); + addArc({ + transitionId: target, + arcDirection: "input", + placeId: source, + weight: 1, + }); } else if ( sourceNode.type === "transition" && targetNode.type === "place" ) { // Output arc: transition to place - addArc(source, "output", target, 1); + addArc({ + transitionId: source, + arcDirection: "output", + placeId: target, + weight: 1, + }); } }