From f4974b4949919b614fe721136cfb9ac6fbbbdf83 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 31 Jul 2026 02:48:57 +0200 Subject: [PATCH] Implement native BPMN workflows and guided modes --- README.md | 15 +- docs/BPMN_INTEROPERABILITY.md | 73 +- src/govoplan_workflow/backend/bpmn.py | 83 +- .../backend/bpmn_adapters.py | 721 +++++++ src/govoplan_workflow/backend/bpmn_graph.py | 1741 +++++++++++++++++ src/govoplan_workflow/backend/db/models.py | 38 + .../backend/instance_service.py | 939 ++++++++- src/govoplan_workflow/backend/manifest.py | 42 + ...4c6b8d2f1_v0114_bpmn_revision_artifacts.py | 68 + ...d3e5a9c2_v0114_workflow_modes_and_views.py | 62 + src/govoplan_workflow/backend/node_library.py | 742 ++++++- src/govoplan_workflow/backend/router.py | 347 +++- src/govoplan_workflow/backend/schemas.py | 183 +- src/govoplan_workflow/backend/service.py | 340 +++- src/govoplan_workflow/backend/validation.py | 184 +- tests/__init__.py | 1 + tests/fixtures/bpmn/choreography.bpmn | 35 + tests/fixtures/bpmn/collaboration.bpmn | 24 + tests/fixtures/bpmn/control-flow.bpmn | 91 + tests/fixtures/bpmn/data.bpmn | 27 + .../bpmn/events-transaction-compensation.bpmn | 42 + tests/fixtures/bpmn/process.bpmn | 50 + tests/test_bpmn.py | 272 ++- tests/test_governance.py | 7 +- tests/test_instance_service.py | 507 ++++- tests/test_migrations.py | 28 +- tests/test_node_library.py | 51 +- tests/test_service.py | 202 ++ webui/package.json | 6 +- webui/src/api/workflow.ts | 191 +- .../src/features/workflow/WorkflowCanvas.tsx | 188 +- .../features/workflow/WorkflowInspector.tsx | 153 +- webui/src/features/workflow/WorkflowNode.tsx | 62 +- .../workflow/WorkflowOpenWorkWidget.tsx | 139 ++ webui/src/features/workflow/WorkflowPage.tsx | 445 +++-- .../features/workflow/WorkflowRunsDialog.tsx | 123 +- webui/src/features/workflow/model.ts | 173 +- webui/src/module.ts | 70 +- webui/src/styles/workflow.css | 224 ++- webui/tsconfig.json | 3 +- 40 files changed, 8203 insertions(+), 489 deletions(-) create mode 100644 src/govoplan_workflow/backend/bpmn_adapters.py create mode 100644 src/govoplan_workflow/backend/bpmn_graph.py create mode 100644 src/govoplan_workflow/backend/migrations/versions/e9a4c6b8d2f1_v0114_bpmn_revision_artifacts.py create mode 100644 src/govoplan_workflow/backend/migrations/versions/f1b7d3e5a9c2_v0114_workflow_modes_and_views.py create mode 100644 tests/__init__.py create mode 100644 tests/fixtures/bpmn/choreography.bpmn create mode 100644 tests/fixtures/bpmn/collaboration.bpmn create mode 100644 tests/fixtures/bpmn/control-flow.bpmn create mode 100644 tests/fixtures/bpmn/data.bpmn create mode 100644 tests/fixtures/bpmn/events-transaction-compensation.bpmn create mode 100644 tests/fixtures/bpmn/process.bpmn create mode 100644 webui/src/features/workflow/WorkflowOpenWorkWidget.tsx diff --git a/README.md b/README.md index fee1131..bcd8cda 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,14 @@ source path shown in the editor. Derivation copies an immutable graph revision and records its hash, node-library version, source scope, actor, Policy decision, and effective ancestor limits. +BPMN 2.0 is Workflow's canonical graph language. The existing native graph +editor models BPMN events, activities, gateways, data, collaborations, and +artifacts directly; there is no separate modeler or browser-side BPMN library. +XML import projects BPMN semantics and DI geometry into that graph, while XML +export renders a deterministic interchange document from the graph. Immutable +revisions pin both representations and the native profile version. Unsupported +runtime semantics remain editable and portable, but activation fails closed. + The start-node library distinguishes explicit user, API, scheduled, event, and parent-workflow starts. Manual starts and Dataflow/human handoffs are operational. The other trigger and generic capability nodes remain explicit @@ -42,8 +50,5 @@ definition contracts until their event/schedule dispatchers and versioned operation providers are implemented. See [docs/CONCEPT.md](docs/CONCEPT.md) for the complete module concept. - -There is a BPMN component playing a major role here. Maybe this needs to -become a separate module. It is quite viable to think about workflow -modelling (and consequently import and export) in terms of BPMN, permitting -a standardized configuration of the system. +See [docs/BPMN_INTEROPERABILITY.md](docs/BPMN_INTEROPERABILITY.md) for the +notation, conformance, and adapter boundary. diff --git a/docs/BPMN_INTEROPERABILITY.md b/docs/BPMN_INTEROPERABILITY.md index 78f3d5f..cc59aef 100644 --- a/docs/BPMN_INTEROPERABILITY.md +++ b/docs/BPMN_INTEROPERABILITY.md @@ -1,24 +1,51 @@ -# BPMN Interoperability +# Native BPMN Graph -GovOPlaN distinguishes BPMN notation and XML interchange from executable -workflow semantics. +GovOPlaN uses BPMN 2.0 as Workflow's canonical graph language while keeping +notation support distinct from executable runtime support. ## Current Contract -- `GET /api/v1/workflow/bpmn/profile` publishes the exact native support - profile. +- The native Workflow graph stores BPMN element and flow types, process + membership, containment, geometry, properties, and preserved extension + content. There is one editor and one graph representation. +- BPMN XML import maps standard elements and BPMN DI into the native graph. + Export deterministically renders XML and DI from the current graph. The + normalized XML artifact, its hash, and the native profile version are pinned + with every immutable revision. +- No browser-side BPMN modeler is required. The WebUI uses the same graph + surface and shared controls as the rest of GovOPlaN. +- `GET /api/v1/workflow/bpmn/profile` publishes all installed, versioned + conformance profiles. - `POST /api/v1/workflow/bpmn/inspect` safely parses bounded BPMN 2.0 XML, inventories every BPMN model element, detects duplicate IDs and selected dangling references, and classifies elements as interchange-only, natively mappable, or natively executable. +- `POST /api/v1/workflow/bpmn/compile` imports a bounded BPMN document into the + canonical native graph. +- `POST /api/v1/workflow/bpmn/render` exports a native graph as normalized BPMN + XML with BPMN DI geometry. +- `GET /api/v1/workflow/definitions/{id}/revisions/{revision}/bpmn` returns + the exact pinned document and its current availability/conformance + assessment. - XML entities, DTD-based expansion, oversized documents, and malformed roots are rejected. -- The native GovOPlaN graph remains the authoritative executable definition. -Inspection is not XML Schema validation and does not claim that every BPMN -semantic construct can be executed. A future `bpmn-js`/`bpmn-moddle` adapter -can provide complete visual notation and XML round-tripping without forcing -unsupported elements into the native runner. +Inspection is not XML Schema validation and does not claim that every editable +BPMN construct can be executed. Notation and interchange remain available when +the native runtime cannot activate the document. + +## Built-In Profiles + +- `govoplan.native.bpmn@1.0.0` is the canonical graph and interchange profile. + It maps the supported BPMN vocabulary into native nodes and edges. Activation + separately validates whether every execution semantic is implemented. +- `govoplan.native.linear@1.0.0` and `bpmn.interchange@1.0.0` remain registered + for historical revision compatibility; new editor revisions use the native + BPMN profile. + +Gateways, subprocesses, event definitions, transactions, compensation, +collaboration, and choreography remain editable and exportable even when their +token or lifecycle semantics are not yet implemented. ## Execution Boundary @@ -32,6 +59,26 @@ behavior. Each executable mapping therefore needs: 4. migration and round-trip fixtures; 5. a declared fallback when the installed runtime cannot execute it. -Unsupported constructs remain visible and preserved by the future interchange -adapter, but activation must remain blocked until an execution adapter declares -support. +Unsupported execution constructs remain visible in the native graph, but +activation remains blocked until an execution adapter declares support. + +## Adapter Boundary + +Adapter packages register through the +`govoplan.workflow.bpmn_adapters` Python entry-point group. Workflow discovers +them without importing a concrete module. An adapter publishes a stable ID, +version, runtime kind, conformance statement, supported elements and event +definitions, operational requirements, validation, and canonical graph +materialization. + +Revisions pin the exact adapter version. If that version is unavailable after +an installation change, the document remains readable and exportable but +cannot activate. External-engine adapters must still materialize lifecycle, +handoff, retry, cancellation, and audit evidence through the canonical +Workflow instance contract; a remote engine's private state is not the +platform record. + +The conformance fixtures under `tests/fixtures/bpmn` cover processes, +collaboration, choreography, events, transactions, compensation, and data +elements. Every fixture must import and export through the native graph without +losing modeled nodes or flows; activation has its own narrower test matrix. diff --git a/src/govoplan_workflow/backend/bpmn.py b/src/govoplan_workflow/backend/bpmn.py index 1361d31..162343c 100644 --- a/src/govoplan_workflow/backend/bpmn.py +++ b/src/govoplan_workflow/backend/bpmn.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections import Counter from dataclasses import dataclass from typing import Literal -from xml.etree.ElementTree import ParseError +from xml.etree.ElementTree import Element, ParseError from defusedxml.ElementTree import fromstring from defusedxml.common import DefusedXmlException @@ -27,33 +27,45 @@ NATIVE_EXECUTION_ELEMENTS = frozenset( { "startEvent", "endEvent", + "task", "manualTask", "userTask", "serviceTask", - "businessRuleTask", - "receiveTask", "sendTask", - "exclusiveGateway", - "parallelGateway", - "sequenceFlow", + "receiveTask", "intermediateCatchEvent", - "intermediateThrowEvent", + "sequenceFlow", } ) NATIVE_MAPPING_ELEMENTS = NATIVE_EXECUTION_ELEMENTS | frozenset( { - "task", + "definitions", + "process", + "collaboration", + "documentation", + "extensionElements", + "incoming", + "outgoing", + "conditionExpression", "scriptTask", + "serviceTask", + "businessRuleTask", + "receiveTask", + "sendTask", "callActivity", "subProcess", "transaction", "adHocSubProcess", + "exclusiveGateway", + "parallelGateway", "inclusiveGateway", "eventBasedGateway", "complexGateway", "boundaryEvent", "eventSubProcess", + "intermediateCatchEvent", + "intermediateThrowEvent", "dataObject", "dataObjectReference", "dataStoreReference", @@ -64,6 +76,16 @@ NATIVE_MAPPING_ELEMENTS = NATIVE_EXECUTION_ELEMENTS | frozenset( "textAnnotation", "association", "group", + "dataInputAssociation", + "dataOutputAssociation", + "choreography", + "choreographyTask", + "callChoreography", + "subChoreography", + "conversation", + "callConversation", + "subConversation", + "conversationLink", } ) @@ -116,7 +138,7 @@ def bpmn_support_level(element_type: str) -> SupportLevel: return "interchange_only" -def inspect_bpmn_xml(xml: str) -> BpmnInspection: +def parse_bpmn_xml(xml: str) -> Element: encoded = xml.encode("utf-8") if not encoded: raise BpmnInspectionError("BPMN XML is empty") @@ -134,6 +156,15 @@ def inspect_bpmn_xml(xml: str) -> BpmnInspection: raise BpmnInspectionError( "BPMN document root must be bpmn:definitions in the BPMN 2.0 model namespace" ) + if sum(1 for _item in root.iter()) > MAX_BPMN_ELEMENTS: + raise BpmnInspectionError( + f"BPMN document exceeds the {MAX_BPMN_ELEMENTS}-element inspection limit" + ) + return root + + +def inspect_bpmn_xml(xml: str) -> BpmnInspection: + root = parse_bpmn_xml(xml) diagnostics: list[BpmnDiagnostic] = [] elements: list[BpmnElementInventoryItem] = [] @@ -149,10 +180,6 @@ def inspect_bpmn_xml(xml: str) -> BpmnInspection: while stack: element, parent_type, parent_id = stack.pop() visited += 1 - if visited > MAX_BPMN_ELEMENTS: - raise BpmnInspectionError( - f"BPMN document exceeds the {MAX_BPMN_ELEMENTS}-element inspection limit" - ) element_namespace, element_type = _qualified_name(element.tag) element_id = _bounded_attribute(element.attrib.get("id"), 255) if element_namespace == BPMN_MODEL_NAMESPACE: @@ -268,17 +295,24 @@ def _collect_references( attributes: dict[str, str], references: list[tuple[str, str | None, str]], ) -> None: - fields: tuple[str, ...] - if element_type == "sequenceFlow": - fields = ("sourceRef", "targetRef") - elif element_type == "messageFlow": - fields = ("sourceRef", "targetRef", "messageRef") - elif element_type == "participant": - fields = ("processRef",) - elif element_type == "lane": - fields = ("partitionElementRef",) - else: - fields = () + fields_by_element = { + "sequenceFlow": ("sourceRef", "targetRef"), + "messageFlow": ("sourceRef", "targetRef", "messageRef"), + "association": ("sourceRef", "targetRef"), + "participant": ("processRef",), + "lane": ("partitionElementRef",), + "boundaryEvent": ("attachedToRef",), + "dataInputAssociation": ("sourceRef", "targetRef"), + "dataOutputAssociation": ("sourceRef", "targetRef"), + "dataObjectReference": ("dataObjectRef",), + "dataStoreReference": ("dataStoreRef",), + "messageEventDefinition": ("messageRef", "operationRef"), + "signalEventDefinition": ("signalRef",), + "errorEventDefinition": ("errorRef",), + "escalationEventDefinition": ("escalationRef",), + "compensateEventDefinition": ("activityRef",), + } + fields = fields_by_element.get(element_type, ()) for field in fields: value = attributes.get(field) if value: @@ -306,4 +340,5 @@ __all__ = [ "NATIVE_MAPPING_ELEMENTS", "bpmn_support_level", "inspect_bpmn_xml", + "parse_bpmn_xml", ] diff --git a/src/govoplan_workflow/backend/bpmn_adapters.py b/src/govoplan_workflow/backend/bpmn_adapters.py new file mode 100644 index 0000000..3894304 --- /dev/null +++ b/src/govoplan_workflow/backend/bpmn_adapters.py @@ -0,0 +1,721 @@ +from __future__ import annotations + +import hashlib +import logging +from collections import Counter +from dataclasses import dataclass +from importlib.metadata import entry_points +from threading import Lock +from typing import Literal, Protocol, runtime_checkable +from xml.etree.ElementTree import Element + +from govoplan_workflow.backend.bpmn import ( + BPMN_DI_NAMESPACE, + BPMN_MODEL_NAMESPACE, + OMG_DC_NAMESPACE, + BpmnDiagnostic, + BpmnInspection, + inspect_bpmn_xml, + parse_bpmn_xml, +) +from govoplan_workflow.backend.bpmn_graph import ( + BPMN_EDGE_LOCAL_NAMES, + BPMN_NODE_LOCAL_NAMES, + BpmnGraphError, + NATIVE_BPMN_ADAPTER_ID, + NATIVE_BPMN_ADAPTER_VERSION, + import_bpmn_graph, + runtime_diagnostics, +) +from govoplan_workflow.backend.schemas import ( + WorkflowEdge, + WorkflowGraph, + WorkflowNode, + WorkflowPosition, +) + + +BPMN_ADAPTER_ENTRY_POINT_GROUP = "govoplan.workflow.bpmn_adapters" +INTERCHANGE_ADAPTER_ID = "bpmn.interchange" +NATIVE_LINEAR_ADAPTER_ID = "govoplan.native.linear" + +RuntimeKind = Literal["model_only", "native_graph", "external"] +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True, slots=True) +class BpmnExecutionProfile: + id: str + version: str + label: str + description: str + conformance: str + runtime_kind: RuntimeKind + executable: bool + supported_elements: tuple[str, ...] + supported_event_definitions: tuple[str, ...] = () + requirements: tuple[str, ...] = () + + +@runtime_checkable +class BpmnExecutionAdapter(Protocol): + profile: BpmnExecutionProfile + + def diagnostics( + self, + xml: str, + inspection: BpmnInspection, + *, + activation: bool, + ) -> tuple[BpmnDiagnostic, ...]: + ... + + def compile( + self, + xml: str, + inspection: BpmnInspection, + ) -> WorkflowGraph | None: + ... + + +class BpmnAdapterError(ValueError): + def __init__(self, diagnostics: tuple[BpmnDiagnostic, ...]) -> None: + self.diagnostics = diagnostics + first = next( + (item for item in diagnostics if item.severity == "error"), + diagnostics[0] if diagnostics else None, + ) + super().__init__( + first.message if first is not None else "BPMN adapter validation failed." + ) + + +class BpmnExecutionAdapterRegistry: + def __init__(self) -> None: + self._adapters: dict[tuple[str, str], BpmnExecutionAdapter] = {} + + def register(self, adapter: BpmnExecutionAdapter) -> None: + if not isinstance(adapter, BpmnExecutionAdapter): + raise TypeError("BPMN adapter does not implement BpmnExecutionAdapter.") + profile = adapter.profile + if not profile.id.strip() or not profile.version.strip(): + raise ValueError("BPMN adapters require a stable id and version.") + key = (profile.id, profile.version) + if key in self._adapters: + raise ValueError( + f"Duplicate BPMN execution adapter {profile.id}@{profile.version}." + ) + self._adapters[key] = adapter + + def profiles(self) -> tuple[BpmnExecutionProfile, ...]: + return tuple( + adapter.profile + for _key, adapter in sorted( + self._adapters.items(), + key=lambda item: ( + not item[1].profile.executable, + item[1].profile.label.casefold(), + item[1].profile.id, + item[1].profile.version, + ), + ) + ) + + def resolve( + self, + adapter_id: str, + version: str | None = None, + ) -> BpmnExecutionAdapter | None: + if version is not None: + return self._adapters.get((adapter_id, version)) + matches = [ + adapter + for (candidate_id, _candidate_version), adapter in self._adapters.items() + if candidate_id == adapter_id + ] + return max(matches, key=lambda item: item.profile.version) if matches else None + + def require( + self, + adapter_id: str, + version: str | None = None, + ) -> BpmnExecutionAdapter: + adapter = self.resolve(adapter_id, version) + if adapter is None: + suffix = f"@{version}" if version else "" + raise BpmnAdapterError( + ( + BpmnDiagnostic( + severity="error", + code="adapter.unavailable", + message=( + f"BPMN execution adapter {adapter_id}{suffix} is not " + "installed." + ), + ), + ) + ) + return adapter + + +class InterchangeOnlyAdapter: + profile = BpmnExecutionProfile( + id=INTERCHANGE_ADAPTER_ID, + version="1.0.0", + label="Historical model-only revision", + description=( + "Read historical BPMN 2.0 revisions that were stored without a " + "native graph or executable semantics." + ), + conformance="BPMN 2.0 interchange", + runtime_kind="model_only", + executable=False, + supported_elements=("*",), + ) + + def diagnostics( + self, + xml: str, + inspection: BpmnInspection, + *, + activation: bool, + ) -> tuple[BpmnDiagnostic, ...]: + del xml, inspection + if not activation: + return () + return ( + BpmnDiagnostic( + severity="error", + code="adapter.model_only", + message=( + "This BPMN revision is model-only. Select an executable " + "conformance profile and save a new revision before activation." + ), + ), + ) + + def compile( + self, + xml: str, + inspection: BpmnInspection, + ) -> WorkflowGraph | None: + del xml, inspection + return None + + +class NativeBpmnGraphAdapter: + profile = BpmnExecutionProfile( + id=NATIVE_BPMN_ADAPTER_ID, + version=NATIVE_BPMN_ADAPTER_VERSION, + label="GovOPlaN native BPMN", + description=( + "Use BPMN 2.0 as the canonical GovOPlaN graph language. The " + "native graph preserves standard notation while activation " + "fails closed for runtime semantics that are not implemented." + ), + conformance="BPMN 2.0 native graph profile 1", + runtime_kind="native_graph", + executable=True, + supported_elements=tuple( + sorted( + { + "definitions", + "process", + "collaboration", + "choreography", + "documentation", + "extensionElements", + "incoming", + "outgoing", + "conditionExpression", + "laneSet", + "flowNodeRef", + *BPMN_NODE_LOCAL_NAMES, + *BPMN_EDGE_LOCAL_NAMES, + } + ) + ), + supported_event_definitions=( + "message", + "timer", + "conditional", + "signal", + "error", + "escalation", + "compensation", + "link", + "cancel", + "terminate", + ), + requirements=( + "BPMN is stored as the canonical native graph", + "Imported XML is parsed with bounded, entity-safe inspection", + "Unsupported execution semantics remain editable but block activation", + ), + ) + + def diagnostics( + self, + xml: str, + inspection: BpmnInspection, + *, + activation: bool, + ) -> tuple[BpmnDiagnostic, ...]: + diagnostics = [ + item for item in inspection.diagnostics if item.severity == "error" + ] + try: + graph = import_bpmn_graph(xml) + except BpmnGraphError as exc: + diagnostics.extend(exc.diagnostics) + return _deduplicate_diagnostics(diagnostics) + if activation: + diagnostics.extend(runtime_diagnostics(graph)) + return _deduplicate_diagnostics(diagnostics) + + def compile( + self, + xml: str, + inspection: BpmnInspection, + ) -> WorkflowGraph | None: + del inspection + return import_bpmn_graph(xml) + + +class NativeLinearAdapter: + _supported_elements = frozenset( + { + "definitions", + "process", + "documentation", + "extensionElements", + "incoming", + "outgoing", + "startEvent", + "endEvent", + "task", + "manualTask", + "userTask", + "sequenceFlow", + } + ) + _node_types = frozenset( + {"startEvent", "endEvent", "task", "manualTask", "userTask"} + ) + + profile = BpmnExecutionProfile( + id=NATIVE_LINEAR_ADAPTER_ID, + version="1.0.0", + label="GovOPlaN native linear", + description=( + "Execute a single linear process containing a plain start event, " + "human tasks, and one or more plain end events." + ), + conformance="GovOPlaN native linear BPMN profile 1", + runtime_kind="native_graph", + executable=True, + supported_elements=tuple(sorted(_supported_elements)), + requirements=( + "Exactly one executable process", + "Exactly one plain start event", + "No gateways, subprocesses, boundary events, or event definitions", + "Every activity has one incoming and one outgoing sequence flow", + ), + ) + + def diagnostics( + self, + xml: str, + inspection: BpmnInspection, + *, + activation: bool, + ) -> tuple[BpmnDiagnostic, ...]: + del activation + diagnostics = list( + item + for item in inspection.diagnostics + if item.severity == "error" + ) + unsupported = [ + item + for item in inspection.elements + if item.element_type not in self._supported_elements + ] + for item in unsupported: + diagnostics.append( + BpmnDiagnostic( + severity="error", + code="adapter.unsupported_element", + message=( + f"{item.element_type} is not supported by the " + f"{self.profile.label} profile." + ), + element_id=item.element_id, + ) + ) + if inspection.process_count != 1: + diagnostics.append( + BpmnDiagnostic( + severity="error", + code="adapter.process_count", + message="The native linear profile requires exactly one process.", + ) + ) + if inspection.executable_process_count != 1: + diagnostics.append( + BpmnDiagnostic( + severity="error", + code="adapter.executable_process", + message=( + "The native linear profile requires one process with " + "isExecutable=\"true\"." + ), + ) + ) + if diagnostics: + return _deduplicate_diagnostics(diagnostics) + + root = parse_bpmn_xml(xml) + process = next( + ( + item + for item in root + if _qualified_name(item.tag) + == (BPMN_MODEL_NAMESPACE, "process") + ), + None, + ) + if process is None: + return ( + BpmnDiagnostic( + severity="error", + code="adapter.process_missing", + message="The executable BPMN process is missing.", + ), + ) + node_ids = { + item.attrib.get("id", "") + for item in process + if _qualified_name(item.tag)[1] in self._node_types + and item.attrib.get("id") + } + starts = [ + item + for item in process + if _qualified_name(item.tag)[1] == "startEvent" + ] + if len(starts) != 1: + diagnostics.append( + BpmnDiagnostic( + severity="error", + code="adapter.start_count", + message="The native linear profile requires exactly one start event.", + ) + ) + incoming: Counter[str] = Counter() + outgoing: Counter[str] = Counter() + for item in process: + if _qualified_name(item.tag)[1] != "sequenceFlow": + continue + source = item.attrib.get("sourceRef", "") + target = item.attrib.get("targetRef", "") + if source in node_ids: + outgoing[source] += 1 + if target in node_ids: + incoming[target] += 1 + for item in process: + _namespace, element_type = _qualified_name(item.tag) + if element_type not in self._node_types: + continue + element_id = item.attrib.get("id") + if not element_id: + continue + if element_type != "startEvent" and incoming[element_id] != 1: + diagnostics.append( + BpmnDiagnostic( + severity="error", + code="adapter.incoming_count", + message=( + f"{element_type} requires exactly one incoming " + "sequence flow in the native linear profile." + ), + element_id=element_id, + ) + ) + if element_type != "endEvent" and outgoing[element_id] != 1: + diagnostics.append( + BpmnDiagnostic( + severity="error", + code="adapter.outgoing_count", + message=( + f"{element_type} requires exactly one outgoing " + "sequence flow in the native linear profile." + ), + element_id=element_id, + ) + ) + return _deduplicate_diagnostics(diagnostics) + + def compile( + self, + xml: str, + inspection: BpmnInspection, + ) -> WorkflowGraph | None: + diagnostics = self.diagnostics(xml, inspection, activation=True) + if any(item.severity == "error" for item in diagnostics): + raise BpmnAdapterError(diagnostics) + root = parse_bpmn_xml(xml) + process = next( + item + for item in root + if _qualified_name(item.tag) == (BPMN_MODEL_NAMESPACE, "process") + ) + positions = _diagram_positions(root) + raw_nodes = [ + item + for item in process + if _qualified_name(item.tag)[1] in self._node_types + ] + id_map = { + item.attrib["id"]: _graph_id(item.attrib["id"]) + for item in raw_nodes + } + fallback_positions = { + item.attrib["id"]: WorkflowPosition(x=80 + index * 240, y=140) + for index, item in enumerate(_linear_node_order(process, raw_nodes)) + } + nodes: list[WorkflowNode] = [] + for item in raw_nodes: + element_type = _qualified_name(item.tag)[1] + bpmn_id = item.attrib["id"] + label = item.attrib.get("name", "").strip() + if element_type == "startEvent": + node_type = "workflow.start.manual" + config = {"input_schema_ref": ""} + label = label or "Start" + elif element_type == "endEvent": + node_type = "workflow.end.completed" + config = {"output_mapping": {}} + label = label or "Completed" + else: + node_type = "workflow.activity" + label = label or "Activity" + config = { + "title": label, + "instructions": "", + "assignee": "", + "due_after": "", + } + nodes.append( + WorkflowNode( + id=id_map[bpmn_id], + type=node_type, + label=label, + position=positions.get(bpmn_id, fallback_positions[bpmn_id]), + config=config, + ) + ) + edges = [ + WorkflowEdge( + id=_graph_id(item.attrib["id"]), + source=id_map[item.attrib["sourceRef"]], + target=id_map[item.attrib["targetRef"]], + ) + for item in process + if _qualified_name(item.tag)[1] == "sequenceFlow" + ] + return WorkflowGraph(nodes=nodes, edges=edges) + + +def assess_bpmn_adapter( + xml: str, + *, + adapter_id: str, + adapter_version: str | None = None, + activation: bool = False, + registry: BpmnExecutionAdapterRegistry | None = None, +) -> tuple[ + BpmnExecutionAdapter, + BpmnInspection, + tuple[BpmnDiagnostic, ...], +]: + active_registry = registry or bpmn_adapter_registry() + adapter = active_registry.require(adapter_id, adapter_version) + inspection = inspect_bpmn_xml(xml) + diagnostics = adapter.diagnostics( + xml, + inspection, + activation=activation, + ) + return adapter, inspection, diagnostics + + +def compile_bpmn_to_graph( + xml: str, + *, + adapter_id: str, + adapter_version: str | None = None, + activation: bool = False, + registry: BpmnExecutionAdapterRegistry | None = None, +) -> tuple[BpmnExecutionAdapter, BpmnInspection, WorkflowGraph | None]: + adapter, inspection, diagnostics = assess_bpmn_adapter( + xml, + adapter_id=adapter_id, + adapter_version=adapter_version, + activation=activation, + registry=registry, + ) + if any(item.severity == "error" for item in diagnostics): + raise BpmnAdapterError(diagnostics) + graph = adapter.compile(xml, inspection) + if adapter.profile.executable and graph is None: + raise BpmnAdapterError( + ( + BpmnDiagnostic( + severity="error", + code="adapter.no_runtime_materialization", + message=( + f"{adapter.profile.label} did not produce executable " + "Workflow runtime materialization." + ), + ), + ) + ) + return adapter, inspection, graph + + +_registry: BpmnExecutionAdapterRegistry | None = None +_registry_lock = Lock() + + +def bpmn_adapter_registry() -> BpmnExecutionAdapterRegistry: + global _registry + if _registry is not None: + return _registry + with _registry_lock: + if _registry is not None: + return _registry + registry = BpmnExecutionAdapterRegistry() + registry.register(NativeBpmnGraphAdapter()) + registry.register(NativeLinearAdapter()) + registry.register(InterchangeOnlyAdapter()) + for entry_point in entry_points(group=BPMN_ADAPTER_ENTRY_POINT_GROUP): + try: + loaded = entry_point.load() + candidate = ( + loaded() + if callable(loaded) and not hasattr(loaded, "profile") + else loaded + ) + registry.register(candidate) + except Exception: + logger.exception( + "Could not register BPMN execution adapter %s", + entry_point.name, + ) + _registry = registry + return registry + + +def _diagram_positions(root: Element) -> dict[str, WorkflowPosition]: + positions: dict[str, WorkflowPosition] = {} + for item in root.iter(): + if _qualified_name(item.tag) != (BPMN_DI_NAMESPACE, "BPMNShape"): + continue + element_id = item.attrib.get("bpmnElement") + if not element_id: + continue + bounds = next( + ( + child + for child in item + if _qualified_name(child.tag) == (OMG_DC_NAMESPACE, "Bounds") + ), + None, + ) + if bounds is None: + continue + try: + positions[element_id] = WorkflowPosition( + x=float(bounds.attrib.get("x", "0")), + y=float(bounds.attrib.get("y", "0")), + ) + except ValueError: + continue + return positions + + +def _linear_node_order( + process: Element, + nodes: list[Element], +) -> list[Element]: + by_id = {item.attrib["id"]: item for item in nodes} + target_by_source = { + item.attrib.get("sourceRef"): item.attrib.get("targetRef") + for item in process + if _qualified_name(item.tag)[1] == "sequenceFlow" + } + start = next( + ( + item + for item in nodes + if _qualified_name(item.tag)[1] == "startEvent" + ), + None, + ) + ordered: list[Element] = [] + seen: set[str] = set() + current = start + while current is not None: + current_id = current.attrib["id"] + if current_id in seen: + break + seen.add(current_id) + ordered.append(current) + current = by_id.get(target_by_source.get(current_id, "")) + ordered.extend(item for item in nodes if item.attrib["id"] not in seen) + return ordered + + +def _graph_id(value: str) -> str: + if len(value) <= 120: + return value + digest = hashlib.sha256(value.encode("utf-8")).hexdigest()[:16] + return f"{value[:103]}-{digest}" + + +def _qualified_name(tag: str) -> tuple[str | None, str]: + if tag.startswith("{") and "}" in tag: + namespace, local_name = tag[1:].split("}", 1) + return namespace, local_name + return None, tag + + +def _deduplicate_diagnostics( + diagnostics: list[BpmnDiagnostic], +) -> tuple[BpmnDiagnostic, ...]: + seen: set[tuple[str, str | None, str]] = set() + result: list[BpmnDiagnostic] = [] + for item in diagnostics: + key = (item.code, item.element_id, item.message) + if key in seen: + continue + seen.add(key) + result.append(item) + return tuple(result) + + +__all__ = [ + "BPMN_ADAPTER_ENTRY_POINT_GROUP", + "INTERCHANGE_ADAPTER_ID", + "NATIVE_BPMN_ADAPTER_ID", + "NATIVE_LINEAR_ADAPTER_ID", + "BpmnAdapterError", + "BpmnExecutionAdapter", + "BpmnExecutionAdapterRegistry", + "BpmnExecutionProfile", + "RuntimeKind", + "assess_bpmn_adapter", + "bpmn_adapter_registry", + "compile_bpmn_to_graph", +] diff --git a/src/govoplan_workflow/backend/bpmn_graph.py b/src/govoplan_workflow/backend/bpmn_graph.py new file mode 100644 index 0000000..d4cb2de --- /dev/null +++ b/src/govoplan_workflow/backend/bpmn_graph.py @@ -0,0 +1,1741 @@ +from __future__ import annotations + +import hashlib +import json +import re +from collections import defaultdict +from collections.abc import Iterable +from copy import deepcopy +from dataclasses import dataclass +from xml.etree import ElementTree +from xml.etree.ElementTree import Element + +from govoplan_workflow.backend.bpmn import ( + BPMN_DI_NAMESPACE, + BPMN_MODEL_NAMESPACE, + OMG_DC_NAMESPACE, + OMG_DI_NAMESPACE, + BpmnDiagnostic, + inspect_bpmn_xml, + parse_bpmn_xml, +) +from govoplan_workflow.backend.schemas import ( + WorkflowEdge, + WorkflowGraph, + WorkflowNode, + WorkflowPosition, + WorkflowSize, + WorkflowWaypoint, +) + + +NATIVE_BPMN_ADAPTER_ID = "govoplan.native.bpmn" +NATIVE_BPMN_ADAPTER_VERSION = "1.0.0" +GOVOPLAN_EXTENSION_NAMESPACE = "https://govoplan.add-ideas.de/ns/workflow" +XSI_NAMESPACE = "http://www.w3.org/2001/XMLSchema-instance" + +BPMN_NODE_LOCAL_NAMES = frozenset( + { + "startEvent", + "intermediateCatchEvent", + "intermediateThrowEvent", + "boundaryEvent", + "endEvent", + "task", + "userTask", + "manualTask", + "serviceTask", + "scriptTask", + "businessRuleTask", + "sendTask", + "receiveTask", + "callActivity", + "subProcess", + "transaction", + "adHocSubProcess", + "exclusiveGateway", + "parallelGateway", + "inclusiveGateway", + "eventBasedGateway", + "complexGateway", + "dataObjectReference", + "dataStoreReference", + "participant", + "lane", + "textAnnotation", + "group", + "choreographyTask", + "callChoreography", + "subChoreography", + "conversation", + "callConversation", + "subConversation", + } +) +BPMN_EDGE_LOCAL_NAMES = frozenset( + { + "sequenceFlow", + "messageFlow", + "association", + "dataInputAssociation", + "dataOutputAssociation", + "conversationLink", + } +) +BPMN_EVENT_DEFINITION_LOCAL_NAMES = frozenset( + { + "messageEventDefinition", + "timerEventDefinition", + "conditionalEventDefinition", + "signalEventDefinition", + "errorEventDefinition", + "escalationEventDefinition", + "compensateEventDefinition", + "linkEventDefinition", + "cancelEventDefinition", + "terminateEventDefinition", + } +) +_EVENT_DEFINITION_NAMES = { + "messageEventDefinition": "message", + "timerEventDefinition": "timer", + "conditionalEventDefinition": "conditional", + "signalEventDefinition": "signal", + "errorEventDefinition": "error", + "escalationEventDefinition": "escalation", + "compensateEventDefinition": "compensation", + "linkEventDefinition": "link", + "cancelEventDefinition": "cancel", + "terminateEventDefinition": "terminate", +} +_EVENT_DEFINITION_ELEMENTS = { + value: key for key, value in _EVENT_DEFINITION_NAMES.items() +} +_DEFAULT_NODE_SIZE = { + "startEvent": (36.0, 36.0), + "intermediateCatchEvent": (36.0, 36.0), + "intermediateThrowEvent": (36.0, 36.0), + "boundaryEvent": (36.0, 36.0), + "endEvent": (36.0, 36.0), + "exclusiveGateway": (50.0, 50.0), + "parallelGateway": (50.0, 50.0), + "inclusiveGateway": (50.0, 50.0), + "eventBasedGateway": (50.0, 50.0), + "complexGateway": (50.0, 50.0), + "participant": (600.0, 180.0), + "lane": (560.0, 140.0), + "textAnnotation": (120.0, 60.0), + "group": (300.0, 180.0), + "dataObjectReference": (36.0, 50.0), + "dataStoreReference": (50.0, 50.0), +} +_MODEL_ATTRIBUTES_EXCLUDED = { + "id", + "name", + "sourceRef", + "targetRef", + "default", + "attachedToRef", + "processRef", +} +_FLOW_NODE_NAMES = BPMN_NODE_LOCAL_NAMES - { + "participant", + "lane", + "textAnnotation", + "group", + "conversation", + "callConversation", + "subConversation", +} +_RUNTIME_NODE_TYPES = frozenset( + { + "bpmn.startEvent", + "bpmn.endEvent", + "bpmn.task", + "bpmn.userTask", + "bpmn.manualTask", + "bpmn.serviceTask", + "bpmn.sendTask", + "bpmn.receiveTask", + "bpmn.intermediateCatchEvent", + "bpmn.exclusiveGateway", + } +) + + +class BpmnGraphError(ValueError): + def __init__(self, diagnostics: Iterable[BpmnDiagnostic]) -> None: + self.diagnostics = tuple(diagnostics) + first = next( + (item for item in self.diagnostics if item.severity == "error"), + self.diagnostics[0] if self.diagnostics else None, + ) + super().__init__( + first.message if first is not None else "BPMN graph conversion failed." + ) + + +@dataclass(frozen=True, slots=True) +class _DiagramShape: + position: WorkflowPosition + size: WorkflowSize + + +def is_native_bpmn_graph(graph: WorkflowGraph) -> bool: + return bool(graph.nodes) and all( + node.type.startswith("bpmn.") for node in graph.nodes + ) + + +def canonical_bpmn_graph(graph: WorkflowGraph) -> WorkflowGraph: + if not graph.nodes or is_native_bpmn_graph(graph): + return graph.model_copy(deep=True) + if any(node.type.startswith("bpmn.") for node in graph.nodes): + raise BpmnGraphError( + ( + BpmnDiagnostic( + severity="error", + code="graph.mixed_notation", + message=( + "A Workflow revision cannot mix legacy workflow nodes " + "with canonical BPMN nodes." + ), + ), + ) + ) + return legacy_graph_to_bpmn(graph) + + +def legacy_graph_to_bpmn(graph: WorkflowGraph) -> WorkflowGraph: + nodes: list[WorkflowNode] = [] + for node in graph.nodes: + config = deepcopy(node.config) + node_type = node.type + if node_type.startswith("workflow.start."): + start_kind = node_type.removeprefix("workflow.start.") + if start_kind == "workflow": + start_kind = "parent_workflow" + config["start_kind"] = start_kind + config.setdefault("event_definition", "none") + bpmn_type = "bpmn.startEvent" + elif node_type == "workflow.activity": + config.setdefault("task_mode", "activity") + bpmn_type = "bpmn.userTask" + elif node_type == "workflow.review": + config["task_mode"] = "review" + bpmn_type = "bpmn.userTask" + elif node_type == "workflow.wait": + config["wait_mode"] = config.pop("mode", "manual") + config.setdefault("event_definition", "none") + bpmn_type = "bpmn.intermediateCatchEvent" + elif node_type == "workflow.capability": + config["implementation"] = "capability" + bpmn_type = "bpmn.serviceTask" + elif node_type == "workflow.dataflow": + config["implementation"] = "dataflow" + bpmn_type = "bpmn.serviceTask" + elif node_type == "workflow.decision": + bpmn_type = "bpmn.exclusiveGateway" + elif node_type == "workflow.end.completed": + config["outcome"] = "completed" + config.setdefault("event_definition", "none") + bpmn_type = "bpmn.endEvent" + elif node_type == "workflow.end.cancelled": + config["outcome"] = "cancelled" + config.setdefault("event_definition", "cancel") + bpmn_type = "bpmn.endEvent" + else: + raise BpmnGraphError( + ( + BpmnDiagnostic( + severity="error", + code="graph.legacy_node_unsupported", + message=f"Legacy node type {node_type!r} cannot be projected to BPMN.", + element_id=node.id, + ), + ) + ) + config.setdefault("documentation", "") + config.setdefault("bpmn_id", node.id) + nodes.append( + WorkflowNode( + id=node.id, + type=bpmn_type, + label=node.label, + position=node.position, + size=node.size or _default_size(bpmn_type.removeprefix("bpmn.")), + parent_id=node.parent_id, + process_id=node.process_id or "Process_1", + config=config, + ) + ) + + edges: list[WorkflowEdge] = [] + by_id = {node.id: node for node in graph.nodes} + for edge in graph.edges: + source_type = by_id.get(edge.source).type if edge.source in by_id else "" + config = deepcopy(edge.config) + if source_type == "workflow.decision": + if edge.source_port == "true": + decision = by_id[edge.source] + config.setdefault("condition", decision.config.get("expression", "")) + elif edge.source_port == "false": + config.setdefault("default", True) + elif edge.source_port not in {"output", "outgoing"}: + config.setdefault("outcome", edge.source_port) + config.setdefault("bpmn_id", edge.id) + edges.append( + WorkflowEdge( + id=edge.id, + type="bpmn.sequenceFlow", + label=edge.label, + source=edge.source, + target=edge.target, + source_port="outgoing", + target_port="incoming", + config=config, + waypoints=edge.waypoints, + ) + ) + + metadata = deepcopy(graph.metadata) + metadata.setdefault( + "bpmn", + { + "definitions_id": "Definitions_1", + "target_namespace": "urn:govoplan:workflow", + "processes": [ + { + "id": "Process_1", + "name": "", + "is_executable": True, + "attributes": {}, + } + ], + "collaborations": [], + "choreographies": [], + "root_elements_xml": [], + }, + ) + metadata["notation"] = "bpmn-2.0" + return WorkflowGraph(nodes=nodes, edges=edges, metadata=metadata) + + +def import_bpmn_graph(xml: str) -> WorkflowGraph: + inspection = inspect_bpmn_xml(xml) + errors = tuple( + item for item in inspection.diagnostics if item.severity == "error" + ) + if errors: + raise BpmnGraphError(errors) + root = parse_bpmn_xml(xml) + shape_by_id, waypoints_by_id = _diagram_geometry(root) + + original_node_ids = [ + item.attrib["id"] + for item in root.iter() + if _qualified_name(item.tag) + == (BPMN_MODEL_NAMESPACE, _qualified_name(item.tag)[1]) + and _qualified_name(item.tag)[1] in BPMN_NODE_LOCAL_NAMES + and item.attrib.get("id") + ] + id_map = _graph_id_map(original_node_ids) + nodes: list[WorkflowNode] = [] + edge_elements: list[tuple[Element, str | None, str | None]] = [] + processes: list[dict[str, object]] = [] + collaborations: list[dict[str, object]] = [] + choreographies: list[dict[str, object]] = [] + process_elements_xml: dict[str, list[str]] = defaultdict(list) + root_elements_xml: list[str] = [] + + def walk( + parent: Element, + *, + process_id: str | None = None, + parent_node_id: str | None = None, + container_kind: str | None = None, + ) -> None: + for child in parent: + namespace, local_name = _qualified_name(child.tag) + if namespace != BPMN_MODEL_NAMESPACE: + continue + if local_name == "process": + child_process_id = child.attrib.get("id") or f"Process_{len(processes) + 1}" + processes.append(_container_metadata(child, child_process_id)) + walk( + child, + process_id=child_process_id, + container_kind="process", + ) + continue + if local_name == "collaboration": + collaboration_id = child.attrib.get("id") or ( + f"Collaboration_{len(collaborations) + 1}" + ) + collaborations.append(_container_metadata(child, collaboration_id)) + walk(child, container_kind="collaboration") + continue + if local_name == "choreography": + choreography_id = child.attrib.get("id") or ( + f"Choreography_{len(choreographies) + 1}" + ) + choreographies.append(_container_metadata(child, choreography_id)) + walk( + child, + process_id=choreography_id, + container_kind="choreography", + ) + continue + if local_name in BPMN_NODE_LOCAL_NAMES and child.attrib.get("id"): + original_id = child.attrib["id"] + graph_id = id_map[original_id] + effective_process_id = ( + child.attrib.get("processRef") + if local_name == "participant" + else process_id + ) + shape = shape_by_id.get(original_id) + if shape is None: + shape = _fallback_shape(local_name, len(nodes)) + nodes.append( + WorkflowNode( + id=graph_id, + type=f"bpmn.{local_name}", + label=child.attrib.get("name", "").strip(), + position=shape.position, + size=shape.size, + parent_id=parent_node_id, + process_id=effective_process_id, + config=_node_config(child, original_id), + ) + ) + for nested in child: + nested_namespace, nested_name = _qualified_name(nested.tag) + if ( + nested_namespace == BPMN_MODEL_NAMESPACE + and nested_name + in {"dataInputAssociation", "dataOutputAssociation"} + and nested.attrib.get("id") + ): + edge_elements.append((nested, process_id, graph_id)) + if local_name in { + "subProcess", + "transaction", + "adHocSubProcess", + "subChoreography", + "subConversation", + }: + walk( + child, + process_id=process_id, + parent_node_id=graph_id, + container_kind=container_kind, + ) + continue + if local_name in BPMN_EDGE_LOCAL_NAMES and child.attrib.get("id"): + edge_elements.append((child, process_id, parent_node_id)) + continue + if local_name in {"laneSet", "childLaneSet"}: + walk( + child, + process_id=process_id, + parent_node_id=parent_node_id, + container_kind=container_kind, + ) + continue + if local_name in { + "documentation", + "extensionElements", + "incoming", + "outgoing", + }: + continue + if container_kind == "process" and process_id: + process_elements_xml[process_id].append(_serialize_element(child)) + elif parent is root: + root_elements_xml.append(_serialize_element(child)) + + walk(root) + edge_id_map = _graph_id_map( + element.attrib["id"] + for element, _process_id, _parent_node_id in edge_elements + ) + normalized_nodes: list[WorkflowNode] = [] + for node in nodes: + config = deepcopy(node.config) + if node.type == "bpmn.boundaryEvent": + attached_to_ref = str(config.get("attached_to_ref") or "") + if attached_to_ref: + config["attached_to_ref"] = id_map.get( + attached_to_ref, + _graph_id(attached_to_ref), + ) + if node.type == "bpmn.lane": + config["flow_node_refs"] = [ + id_map.get(str(item), _graph_id(str(item))) + for item in config.get("flow_node_refs") or () + ] + default_flow_ref = str(config.get("default_flow_ref") or "") + if default_flow_ref: + config["default_flow_ref"] = edge_id_map.get( + default_flow_ref, + _graph_id(default_flow_ref), + ) + normalized_nodes.append(node.model_copy(update={"config": config})) + nodes = normalized_nodes + node_ids = {node.id for node in nodes} + edges: list[WorkflowEdge] = [] + for element, process_id, parent_node_id in edge_elements: + local_name = _qualified_name(element.tag)[1] + original_id = element.attrib["id"] + source_ref, target_ref = _edge_references(element, local_name) + if local_name == "dataInputAssociation" and parent_node_id: + target_ref = parent_node_id + elif local_name == "dataOutputAssociation" and parent_node_id: + source_ref = parent_node_id + source = id_map.get(source_ref or "", _graph_id(source_ref or "")) + target = id_map.get(target_ref or "", _graph_id(target_ref or "")) + if source not in node_ids or target not in node_ids: + continue + config = _edge_config(element, original_id, process_id, parent_node_id) + edges.append( + WorkflowEdge( + id=edge_id_map[original_id], + type=f"bpmn.{local_name}", + label=element.attrib.get("name", "").strip(), + source=source, + target=target, + source_port="outgoing", + target_port="incoming", + config=config, + waypoints=waypoints_by_id.get(original_id, []), + ) + ) + default_flow_by_source = { + node.id: str(node.config.get("default_flow_ref")) + for node in nodes + if node.config.get("default_flow_ref") + } + edges = [ + edge.model_copy( + update={ + "config": { + **edge.config, + **( + {"default": True} + if default_flow_by_source.get(edge.source) == edge.id + else {} + ), + } + } + ) + for edge in edges + ] + + metadata = { + "notation": "bpmn-2.0", + "bpmn": { + "definitions_id": root.attrib.get("id") or "Definitions_1", + "target_namespace": ( + root.attrib.get("targetNamespace") or "urn:govoplan:workflow" + ), + "definitions_attributes": _extra_attributes( + root, + excluded={"id", "targetNamespace"}, + ), + "extension_elements_xml": _foreign_extension_xml(root), + "processes": processes, + "collaborations": collaborations, + "choreographies": choreographies, + "root_elements_xml": root_elements_xml, + "process_elements_xml": dict(process_elements_xml), + }, + } + return WorkflowGraph(nodes=nodes, edges=edges, metadata=metadata) + + +def export_bpmn_graph(graph: WorkflowGraph, *, name: str = "") -> str: + canonical = canonical_bpmn_graph(graph) + ElementTree.register_namespace("bpmn", BPMN_MODEL_NAMESPACE) + ElementTree.register_namespace("bpmndi", BPMN_DI_NAMESPACE) + ElementTree.register_namespace("dc", OMG_DC_NAMESPACE) + ElementTree.register_namespace("di", OMG_DI_NAMESPACE) + ElementTree.register_namespace("xsi", XSI_NAMESPACE) + ElementTree.register_namespace("govoplan", GOVOPLAN_EXTENSION_NAMESPACE) + + bpmn_metadata = dict(canonical.metadata.get("bpmn") or {}) + root = Element( + _tag(BPMN_MODEL_NAMESPACE, "definitions"), + { + "id": str(bpmn_metadata.get("definitions_id") or "Definitions_1"), + "targetNamespace": str( + bpmn_metadata.get("target_namespace") + or "urn:govoplan:workflow" + ), + }, + ) + _apply_attributes( + root, + bpmn_metadata.get("definitions_attributes"), + excluded={"id", "targetNamespace"}, + ) + _append_extension_elements( + root, + bpmn_metadata.get("extension_elements_xml"), + ) + _append_preserved_elements(root, bpmn_metadata.get("root_elements_xml")) + + process_metadata = { + str(item.get("id")): dict(item) + for item in bpmn_metadata.get("processes") or () + if isinstance(item, dict) and item.get("id") + } + process_ids = { + node.process_id + for node in canonical.nodes + if node.process_id and node.type != "bpmn.participant" + } + process_ids.update(process_metadata) + choreography_ids = { + str(item.get("id")) + for item in bpmn_metadata.get("choreographies") or () + if isinstance(item, dict) and item.get("id") + } + process_ids.difference_update(choreography_ids) + if not process_ids: + process_ids.add("Process_1") + process_by_id: dict[str, Element] = {} + for process_id in sorted(process_ids): + metadata = process_metadata.get(process_id, {}) + attributes = { + "id": process_id, + "isExecutable": ( + "true" if metadata.get("is_executable", True) else "false" + ), + } + process_name = str(metadata.get("name") or "") + if process_name: + attributes["name"] = process_name + process = Element(_tag(BPMN_MODEL_NAMESPACE, "process"), attributes) + _apply_attributes( + process, + metadata.get("attributes"), + excluded={"id", "name", "isExecutable"}, + ) + _append_extension_elements( + process, + metadata.get("extension_elements_xml"), + ) + root.append(process) + process_by_id[process_id] = process + + collaboration_metadata = [ + dict(item) + for item in bpmn_metadata.get("collaborations") or () + if isinstance(item, dict) + ] + needs_collaboration = any( + node.type == "bpmn.participant" for node in canonical.nodes + ) or any(edge.type == "bpmn.messageFlow" for edge in canonical.edges) + collaboration: Element | None = None + if collaboration_metadata or needs_collaboration: + metadata = collaboration_metadata[0] if collaboration_metadata else {} + collaboration = Element( + _tag(BPMN_MODEL_NAMESPACE, "collaboration"), + {"id": str(metadata.get("id") or "Collaboration_1")}, + ) + if metadata.get("name"): + collaboration.set("name", str(metadata["name"])) + _apply_attributes( + collaboration, + metadata.get("attributes"), + excluded={"id", "name"}, + ) + _append_extension_elements( + collaboration, + metadata.get("extension_elements_xml"), + ) + root.append(collaboration) + + choreography_metadata = [ + dict(item) + for item in bpmn_metadata.get("choreographies") or () + if isinstance(item, dict) + ] + choreography_by_id: dict[str, Element] = {} + for index, metadata in enumerate(choreography_metadata, start=1): + choreography_id = str(metadata.get("id") or f"Choreography_{index}") + choreography = Element( + _tag(BPMN_MODEL_NAMESPACE, "choreography"), + {"id": choreography_id}, + ) + if metadata.get("name"): + choreography.set("name", str(metadata["name"])) + root.append(choreography) + choreography_by_id[choreography_id] = choreography + + node_xml_ids = _xml_id_map(canonical.nodes, prefix="Node") + edge_xml_ids = _xml_id_map(canonical.edges, prefix="Flow") + default_edge_by_source = { + edge.source: edge.id + for edge in canonical.edges + if edge.type == "bpmn.sequenceFlow" + and edge.config.get("default") is True + } + node_element_by_id: dict[str, Element] = {} + lane_nodes: list[WorkflowNode] = [] + for node in _nodes_parent_first(canonical.nodes): + local_name = node.type.removeprefix("bpmn.") + if local_name == "lane": + lane_nodes.append(node) + continue + parent: Element + if local_name == "participant": + if collaboration is None: + collaboration = Element( + _tag(BPMN_MODEL_NAMESPACE, "collaboration"), + {"id": "Collaboration_1"}, + ) + root.append(collaboration) + parent = collaboration + elif node.process_id in choreography_by_id: + parent = choreography_by_id[node.process_id] + elif local_name in { + "choreographyTask", + "callChoreography", + "subChoreography", + }: + parent = choreography_by_id.get(node.process_id or "") + if parent is None: + choreography_id = node.process_id or "Choreography_1" + parent = Element( + _tag(BPMN_MODEL_NAMESPACE, "choreography"), + {"id": choreography_id}, + ) + root.append(parent) + choreography_by_id[choreography_id] = parent + elif node.parent_id and node.parent_id in node_element_by_id: + parent = node_element_by_id[node.parent_id] + else: + parent = process_by_id.get(node.process_id or "") + if parent is None: + parent = next(iter(process_by_id.values())) + node_config = deepcopy(node.config) + node_config.pop("default_flow_ref", None) + if node.id in default_edge_by_source: + node_config["default_flow_ref"] = default_edge_by_source[node.id] + export_node = node.model_copy(update={"config": node_config}) + element = _node_element( + export_node, + node_xml_ids[node.id], + edge_xml_ids, + ) + parent.append(element) + node_element_by_id[node.id] = element + + lanes_by_process: dict[str, list[WorkflowNode]] = defaultdict(list) + for lane in lane_nodes: + lanes_by_process[lane.process_id or next(iter(process_by_id))].append(lane) + for process_id, lanes in lanes_by_process.items(): + process = process_by_id.get(process_id) + if process is None: + process = next(iter(process_by_id.values())) + lane_set = Element( + _tag(BPMN_MODEL_NAMESPACE, "laneSet"), + {"id": _xml_id("LaneSet", f"LaneSet_{process_id}")}, + ) + process.insert(0, lane_set) + for lane in lanes: + lane_element = _node_element( + lane, + node_xml_ids[lane.id], + edge_xml_ids, + ) + for flow_node_ref in lane.config.get("flow_node_refs") or (): + target_id = node_xml_ids.get(str(flow_node_ref), str(flow_node_ref)) + ref_element = Element(_tag(BPMN_MODEL_NAMESPACE, "flowNodeRef")) + ref_element.text = target_id + lane_element.append(ref_element) + lane_set.append(lane_element) + node_element_by_id[lane.id] = lane_element + + for edge in canonical.edges: + local_name = edge.type.removeprefix("bpmn.") + element = _edge_element( + edge, + edge_xml_ids[edge.id], + node_xml_ids, + ) + if local_name in {"messageFlow", "conversationLink"}: + if collaboration is None: + collaboration = Element( + _tag(BPMN_MODEL_NAMESPACE, "collaboration"), + {"id": "Collaboration_1"}, + ) + root.append(collaboration) + collaboration.append(element) + elif local_name == "dataInputAssociation": + node_element_by_id.get(edge.target, next(iter(process_by_id.values()))).append( + element + ) + elif local_name == "dataOutputAssociation": + node_element_by_id.get(edge.source, next(iter(process_by_id.values()))).append( + element + ) + else: + parent_node_id = str(edge.config.get("parent_node_id") or "") + if parent_node_id in node_element_by_id: + node_element_by_id[parent_node_id].append(element) + continue + process_id = _edge_process_id(edge, canonical.nodes) + if process_id in choreography_by_id: + choreography_by_id[process_id].append(element) + else: + process = process_by_id.get(process_id) + if process is None: + process = next(iter(process_by_id.values())) + process.append(element) + + process_extras = bpmn_metadata.get("process_elements_xml") + if isinstance(process_extras, dict): + for process_id, snippets in process_extras.items(): + process = process_by_id.get(str(process_id)) + if process is not None: + _append_preserved_elements(process, snippets) + + diagram = Element( + _tag(BPMN_DI_NAMESPACE, "BPMNDiagram"), + {"id": "BPMNDiagram_1", "name": name or "GovOPlaN Workflow"}, + ) + plane_target = ( + collaboration.attrib["id"] + if collaboration is not None + else next(iter(process_by_id)) + ) + plane = Element( + _tag(BPMN_DI_NAMESPACE, "BPMNPlane"), + {"id": "BPMNPlane_1", "bpmnElement": plane_target}, + ) + diagram.append(plane) + root.append(diagram) + for node in canonical.nodes: + shape = Element( + _tag(BPMN_DI_NAMESPACE, "BPMNShape"), + { + "id": _xml_id("Shape", f"Shape_{node_xml_ids[node.id]}"), + "bpmnElement": node_xml_ids[node.id], + }, + ) + if node.type == "bpmn.participant": + shape.set("isHorizontal", "true") + size = node.size or _default_size(node.type.removeprefix("bpmn.")) + shape.append( + Element( + _tag(OMG_DC_NAMESPACE, "Bounds"), + { + "x": _number(node.position.x), + "y": _number(node.position.y), + "width": _number(size.width), + "height": _number(size.height), + }, + ) + ) + plane.append(shape) + node_by_id = {node.id: node for node in canonical.nodes} + for edge in canonical.edges: + edge_element = Element( + _tag(BPMN_DI_NAMESPACE, "BPMNEdge"), + { + "id": _xml_id("Edge", f"Edge_{edge_xml_ids[edge.id]}"), + "bpmnElement": edge_xml_ids[edge.id], + }, + ) + waypoints = edge.waypoints or _fallback_waypoints( + node_by_id.get(edge.source), + node_by_id.get(edge.target), + ) + for waypoint in waypoints: + edge_element.append( + Element( + _tag(OMG_DI_NAMESPACE, "waypoint"), + {"x": _number(waypoint.x), "y": _number(waypoint.y)}, + ) + ) + plane.append(edge_element) + + ElementTree.indent(root, space=" ") + return ( + '\n' + + ElementTree.tostring(root, encoding="unicode") + ) + + +def materialize_runtime_graph(graph: WorkflowGraph) -> WorkflowGraph: + canonical = canonical_bpmn_graph(graph) + diagnostics: list[BpmnDiagnostic] = [] + runtime_nodes: list[WorkflowNode] = [] + ignored_node_ids = { + node.id + for node in canonical.nodes + if node.type.removeprefix("bpmn.") + in { + "participant", + "lane", + "textAnnotation", + "group", + "dataObjectReference", + "dataStoreReference", + "conversation", + "callConversation", + "subConversation", + } + } + for node in canonical.nodes: + if node.id in ignored_node_ids: + continue + if node.type not in _RUNTIME_NODE_TYPES: + diagnostics.append( + BpmnDiagnostic( + severity="error", + code="runtime.unsupported_bpmn_element", + message=( + f"{node.type.removeprefix('bpmn.')} is valid BPMN " + "notation but is not executable by the native runtime." + ), + element_id=node.id, + ) + ) + continue + runtime_nodes.append(_runtime_node(node)) + + runtime_node_ids = {node.id for node in runtime_nodes} + runtime_edges: list[WorkflowEdge] = [] + for edge in canonical.edges: + if edge.type != "bpmn.sequenceFlow": + continue + if edge.source not in runtime_node_ids or edge.target not in runtime_node_ids: + continue + source_node = next(node for node in runtime_nodes if node.id == edge.source) + if source_node.type == "workflow.decision": + condition = str(edge.config.get("condition") or "") + if condition: + source_node.config["expression"] = condition + runtime_edges.append( + WorkflowEdge( + id=edge.id, + source=edge.source, + target=edge.target, + source_port=_runtime_source_port(source_node, edge), + target_port="input", + ) + ) + + starts = [node for node in runtime_nodes if node.type.startswith("workflow.start.")] + if len(starts) != 1: + diagnostics.append( + BpmnDiagnostic( + severity="error", + code="runtime.start_event_count", + message=( + "The current native runtime requires exactly one executable " + "start event per Workflow revision." + ), + ) + ) + if not any(node.type.startswith("workflow.end.") for node in runtime_nodes): + diagnostics.append( + BpmnDiagnostic( + severity="error", + code="runtime.end_event_missing", + message="The executable process requires at least one end event.", + ) + ) + for node in runtime_nodes: + if node.type != "workflow.decision": + continue + outgoing = [edge for edge in runtime_edges if edge.source == node.id] + if ( + len(outgoing) != 2 + or sum(edge.source_port == "true" for edge in outgoing) != 1 + or sum(edge.source_port == "false" for edge in outgoing) != 1 + ): + diagnostics.append( + BpmnDiagnostic( + severity="error", + code="runtime.exclusive_gateway_shape", + message=( + "The current native runtime supports an exclusive " + "gateway with one conditional and one default flow." + ), + element_id=node.id, + ) + ) + if diagnostics: + raise BpmnGraphError(diagnostics) + return WorkflowGraph(nodes=runtime_nodes, edges=runtime_edges) + + +def runtime_diagnostics(graph: WorkflowGraph) -> tuple[BpmnDiagnostic, ...]: + try: + materialize_runtime_graph(graph) + except BpmnGraphError as exc: + return exc.diagnostics + return () + + +def _runtime_node(node: WorkflowNode) -> WorkflowNode: + config = deepcopy(node.config) + if node.type == "bpmn.startEvent": + start_kind = str(config.get("start_kind") or "manual") + if start_kind == "parent_workflow": + start_kind = "workflow" + node_type = f"workflow.start.{start_kind}" + elif node.type == "bpmn.endEvent": + outcome = str(config.get("outcome") or "completed") + node_type = ( + "workflow.end.cancelled" + if outcome == "cancelled" + else "workflow.end.completed" + ) + elif node.type == "bpmn.userTask" and config.get("task_mode") == "review": + node_type = "workflow.review" + elif node.type in {"bpmn.task", "bpmn.userTask", "bpmn.manualTask"}: + node_type = "workflow.activity" + elif node.type in {"bpmn.receiveTask", "bpmn.intermediateCatchEvent"}: + node_type = "workflow.wait" + config["mode"] = config.get("wait_mode") or "event" + elif node.type in {"bpmn.serviceTask", "bpmn.sendTask"}: + implementation = str(config.get("implementation") or "capability") + node_type = ( + "workflow.dataflow" + if implementation == "dataflow" + else "workflow.capability" + ) + elif node.type == "bpmn.exclusiveGateway": + node_type = "workflow.decision" + config.setdefault("expression", "") + else: + raise AssertionError(f"Unsupported runtime node {node.type}") + return WorkflowNode( + id=node.id, + type=node_type, + label=node.label, + position=node.position, + config=config, + ) + + +def _runtime_source_port(node: WorkflowNode, edge: WorkflowEdge) -> str: + configured = str(edge.config.get("outcome") or "") + if configured: + return configured + if node.type in {"workflow.capability", "workflow.dataflow"}: + return "success" + if node.type == "workflow.review": + return "approved" + if node.type == "workflow.wait": + return "resumed" + if node.type == "workflow.decision": + return "false" if edge.config.get("default") is True else "true" + return "output" + + +def _node_config(element: Element, original_id: str) -> dict[str, object]: + local_name = _qualified_name(element.tag)[1] + config: dict[str, object] = { + "bpmn_id": original_id, + "bpmn_attributes": _extra_attributes( + element, + excluded=_MODEL_ATTRIBUTES_EXCLUDED, + ), + "documentation": _documentation(element), + } + extension_xml = _foreign_extension_xml(element) + if extension_xml: + config["bpmn_extension_elements_xml"] = extension_xml + event_definition = next( + ( + child + for child in element + if _qualified_name(child.tag)[0] == BPMN_MODEL_NAMESPACE + and _qualified_name(child.tag)[1] in BPMN_EVENT_DEFINITION_LOCAL_NAMES + ), + None, + ) + if event_definition is not None: + event_name = _qualified_name(event_definition.tag)[1] + config["event_definition"] = _EVENT_DEFINITION_NAMES.get( + event_name, + event_name.removesuffix("EventDefinition"), + ) + config["event_definition_xml"] = _serialize_element(event_definition) + elif local_name.endswith("Event"): + config["event_definition"] = "none" + if local_name == "startEvent": + config["start_kind"] = _start_kind(element, config) + elif local_name == "boundaryEvent": + config["attached_to_ref"] = element.attrib.get("attachedToRef", "") + config["cancel_activity"] = element.attrib.get("cancelActivity", "true") + elif local_name == "participant": + config["process_ref"] = element.attrib.get("processRef", "") + elif local_name == "lane": + config["flow_node_refs"] = [ + (child.text or "").strip() + for child in element + if _qualified_name(child.tag) + == (BPMN_MODEL_NAMESPACE, "flowNodeRef") + and (child.text or "").strip() + ] + elif local_name == "textAnnotation": + text_element = next( + ( + child + for child in element + if _qualified_name(child.tag) + == (BPMN_MODEL_NAMESPACE, "text") + ), + None, + ) + config["text"] = (text_element.text or "") if text_element is not None else "" + config["text_format"] = element.attrib.get("textFormat", "text/plain") + elif local_name == "dataObjectReference": + config["data_object_ref"] = element.attrib.get("dataObjectRef", "") + elif local_name == "dataStoreReference": + config["data_store_ref"] = element.attrib.get("dataStoreRef", "") + elif local_name == "callActivity": + config["called_element"] = element.attrib.get("calledElement", "") + if element.attrib.get("default"): + config["default_flow_ref"] = element.attrib["default"] + if local_name in { + "task", + "userTask", + "manualTask", + "serviceTask", + "scriptTask", + "businessRuleTask", + "sendTask", + "receiveTask", + }: + config.setdefault("title", element.attrib.get("name", "").strip()) + config.setdefault("instructions", "") + govoplan_extension = next( + ( + child + for extension in element + if _qualified_name(extension.tag) + == (BPMN_MODEL_NAMESPACE, "extensionElements") + for child in extension + if _qualified_name(child.tag)[0] == GOVOPLAN_EXTENSION_NAMESPACE + and _qualified_name(child.tag)[1] == "config" + ), + None, + ) + if govoplan_extension is not None: + for item in govoplan_extension: + if _qualified_name(item.tag)[0] != GOVOPLAN_EXTENSION_NAMESPACE: + continue + key = item.attrib.get("key") + if key: + raw_value = item.text or "" + if item.attrib.get("format") == "json": + try: + config[key] = json.loads(raw_value) + except (TypeError, ValueError): + config[key] = raw_value + else: + config[key] = raw_value + preserved = [ + _serialize_element(child) + for child in element + if not _known_node_child(child) + ] + if preserved: + config["bpmn_child_elements_xml"] = preserved + return config + + +def _edge_config( + element: Element, + original_id: str, + process_id: str | None, + parent_node_id: str | None, +) -> dict[str, object]: + config: dict[str, object] = { + "bpmn_id": original_id, + "process_id": process_id or "", + "parent_node_id": parent_node_id or "", + "bpmn_attributes": _extra_attributes( + element, + excluded=_MODEL_ATTRIBUTES_EXCLUDED, + ), + } + local_name = _qualified_name(element.tag)[1] + if local_name == "sequenceFlow": + condition = next( + ( + child + for child in element + if _qualified_name(child.tag) + == (BPMN_MODEL_NAMESPACE, "conditionExpression") + ), + None, + ) + if condition is not None: + config["condition"] = condition.text or "" + config["condition_attributes"] = dict(condition.attrib) + return config + + +def _node_element( + node: WorkflowNode, + xml_id: str, + edge_xml_ids: dict[str, str], +) -> Element: + local_name = node.type.removeprefix("bpmn.") + attributes = {"id": xml_id} + if node.label: + attributes["name"] = node.label + if local_name == "boundaryEvent": + attached = str(node.config.get("attached_to_ref") or "") + if attached: + attributes["attachedToRef"] = attached + attributes["cancelActivity"] = str( + node.config.get("cancel_activity") or "true" + ).lower() + elif local_name == "participant": + process_ref = str(node.config.get("process_ref") or node.process_id or "") + if process_ref: + attributes["processRef"] = process_ref + elif local_name == "textAnnotation": + attributes["textFormat"] = str( + node.config.get("text_format") or "text/plain" + ) + elif local_name == "dataObjectReference" and node.config.get("data_object_ref"): + attributes["dataObjectRef"] = str(node.config["data_object_ref"]) + elif local_name == "dataStoreReference" and node.config.get("data_store_ref"): + attributes["dataStoreRef"] = str(node.config["data_store_ref"]) + elif local_name == "callActivity" and node.config.get("called_element"): + attributes["calledElement"] = str(node.config["called_element"]) + if node.config.get("default_flow_ref"): + attributes["default"] = edge_xml_ids.get( + str(node.config["default_flow_ref"]), + str(node.config["default_flow_ref"]), + ) + element = Element(_tag(BPMN_MODEL_NAMESPACE, local_name), attributes) + _apply_attributes( + element, + node.config.get("bpmn_attributes"), + excluded=set(attributes), + ) + documentation = str(node.config.get("documentation") or "") + if documentation: + documentation_element = Element( + _tag(BPMN_MODEL_NAMESPACE, "documentation") + ) + documentation_element.text = documentation + element.append(documentation_element) + if local_name == "textAnnotation": + text = Element(_tag(BPMN_MODEL_NAMESPACE, "text")) + text.text = str(node.config.get("text") or "") + element.append(text) + _append_event_definition(element, node.config) + _append_govoplan_config(element, node.config) + _append_extension_elements( + element, + node.config.get("bpmn_extension_elements_xml"), + ) + _append_preserved_elements( + element, + node.config.get("bpmn_child_elements_xml"), + ) + return element + + +def _edge_element( + edge: WorkflowEdge, + xml_id: str, + node_xml_ids: dict[str, str], +) -> Element: + local_name = edge.type.removeprefix("bpmn.") + attributes = {"id": xml_id} + if edge.label: + attributes["name"] = edge.label + source_ref = node_xml_ids[edge.source] + target_ref = node_xml_ids[edge.target] + if local_name not in {"dataInputAssociation", "dataOutputAssociation"}: + attributes["sourceRef"] = source_ref + attributes["targetRef"] = target_ref + element = Element(_tag(BPMN_MODEL_NAMESPACE, local_name), attributes) + _apply_attributes( + element, + edge.config.get("bpmn_attributes"), + excluded=set(attributes), + ) + if local_name in {"dataInputAssociation", "dataOutputAssociation"}: + source = Element(_tag(BPMN_MODEL_NAMESPACE, "sourceRef")) + source.text = source_ref + target = Element(_tag(BPMN_MODEL_NAMESPACE, "targetRef")) + target.text = target_ref + element.extend((source, target)) + condition = str(edge.config.get("condition") or "") + if local_name == "sequenceFlow" and condition: + condition_element = Element( + _tag(BPMN_MODEL_NAMESPACE, "conditionExpression"), + {_tag(XSI_NAMESPACE, "type"): "bpmn:tFormalExpression"}, + ) + _apply_attributes( + condition_element, + edge.config.get("condition_attributes"), + excluded={_tag(XSI_NAMESPACE, "type")}, + ) + condition_element.text = condition + element.append(condition_element) + return element + + +def _append_govoplan_config(element: Element, config: dict[str, object]) -> None: + excluded = { + "bpmn_id", + "bpmn_attributes", + "bpmn_child_elements_xml", + "bpmn_extension_elements_xml", + "documentation", + "event_definition", + "event_definition_xml", + "attached_to_ref", + "cancel_activity", + "process_ref", + "flow_node_refs", + "text", + "text_format", + "data_object_ref", + "data_store_ref", + "called_element", + "default_flow_ref", + } + values = { + key: value + for key, value in config.items() + if key not in excluded + and value is not None + and value != "" + } + if not values: + return + extension = next( + ( + child + for child in element + if _qualified_name(child.tag) + == (BPMN_MODEL_NAMESPACE, "extensionElements") + ), + None, + ) + if extension is None: + extension = Element(_tag(BPMN_MODEL_NAMESPACE, "extensionElements")) + element.append(extension) + root = Element(_tag(GOVOPLAN_EXTENSION_NAMESPACE, "config")) + for key, value in sorted(values.items()): + item = Element( + _tag(GOVOPLAN_EXTENSION_NAMESPACE, "property"), + {"key": key, "format": "json"}, + ) + item.text = json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ) + root.append(item) + extension.append(root) + + +def _append_event_definition(element: Element, config: dict[str, object]) -> None: + event_definition = str(config.get("event_definition") or "none") + if event_definition == "none": + return + preserved = config.get("event_definition_xml") + if isinstance(preserved, str): + try: + preserved_element = parse_bpmn_xml( + '{preserved}' + ) + if len(preserved_element): + element.append(deepcopy(preserved_element[0])) + return + except Exception: + pass + local_name = _EVENT_DEFINITION_ELEMENTS.get(event_definition) + if local_name: + element.append(Element(_tag(BPMN_MODEL_NAMESPACE, local_name))) + + +def _append_preserved_elements(parent: Element, snippets: object) -> None: + if not isinstance(snippets, (list, tuple)): + return + for snippet in snippets: + if not isinstance(snippet, str) or not snippet.strip(): + continue + try: + wrapper = parse_bpmn_xml( + '' + f"{snippet}" + ) + except Exception: + continue + for child in wrapper: + parent.append(deepcopy(child)) + + +def _append_extension_elements(parent: Element, snippets: object) -> None: + if not isinstance(snippets, (list, tuple)) or not snippets: + return + extension = next( + ( + child + for child in parent + if _qualified_name(child.tag) + == (BPMN_MODEL_NAMESPACE, "extensionElements") + ), + None, + ) + if extension is None: + extension = Element(_tag(BPMN_MODEL_NAMESPACE, "extensionElements")) + parent.insert(0, extension) + wrapper = Element(_tag(BPMN_MODEL_NAMESPACE, "definitions")) + _append_preserved_elements(wrapper, snippets) + for child in wrapper: + extension.append(deepcopy(child)) + + +def _diagram_geometry( + root: Element, +) -> tuple[dict[str, _DiagramShape], dict[str, list[WorkflowWaypoint]]]: + shapes: dict[str, _DiagramShape] = {} + waypoints: dict[str, list[WorkflowWaypoint]] = {} + for item in root.iter(): + namespace, local_name = _qualified_name(item.tag) + if namespace == BPMN_DI_NAMESPACE and local_name == "BPMNShape": + element_id = item.attrib.get("bpmnElement") + bounds = next( + ( + child + for child in item + if _qualified_name(child.tag) + == (OMG_DC_NAMESPACE, "Bounds") + ), + None, + ) + if not element_id or bounds is None: + continue + try: + shapes[element_id] = _DiagramShape( + position=WorkflowPosition( + x=float(bounds.attrib.get("x", "0")), + y=float(bounds.attrib.get("y", "0")), + ), + size=WorkflowSize( + width=float(bounds.attrib.get("width", "100")), + height=float(bounds.attrib.get("height", "80")), + ), + ) + except ValueError: + continue + elif namespace == BPMN_DI_NAMESPACE and local_name == "BPMNEdge": + element_id = item.attrib.get("bpmnElement") + if not element_id: + continue + points: list[WorkflowWaypoint] = [] + for child in item: + if _qualified_name(child.tag) != (OMG_DI_NAMESPACE, "waypoint"): + continue + try: + points.append( + WorkflowWaypoint( + x=float(child.attrib.get("x", "0")), + y=float(child.attrib.get("y", "0")), + ) + ) + except ValueError: + continue + if points: + waypoints[element_id] = points + return shapes, waypoints + + +def _container_metadata(element: Element, fallback_id: str) -> dict[str, object]: + return { + "id": element.attrib.get("id") or fallback_id, + "name": element.attrib.get("name", ""), + "is_executable": element.attrib.get("isExecutable", "false").lower() + == "true", + "attributes": _extra_attributes( + element, + excluded={"id", "name", "isExecutable"}, + ), + "documentation": _documentation(element), + "extension_elements_xml": _foreign_extension_xml(element), + } + + +def _extra_attributes( + element: Element, + *, + excluded: set[str], +) -> dict[str, str]: + return { + key: value + for key, value in element.attrib.items() + if key not in excluded + } + + +def _apply_attributes( + element: Element, + values: object, + *, + excluded: set[str], +) -> None: + if not isinstance(values, dict): + return + for key, value in values.items(): + if str(key) not in excluded and value is not None: + element.set(str(key), str(value)) + + +def _documentation(element: Element) -> str: + return "\n".join( + (child.text or "").strip() + for child in element + if _qualified_name(child.tag) + == (BPMN_MODEL_NAMESPACE, "documentation") + and (child.text or "").strip() + ) + + +def _known_node_child(element: Element) -> bool: + namespace, local_name = _qualified_name(element.tag) + return namespace == BPMN_MODEL_NAMESPACE and ( + local_name + in { + "documentation", + "extensionElements", + "incoming", + "outgoing", + "flowNodeRef", + "text", + *BPMN_EDGE_LOCAL_NAMES, + *BPMN_NODE_LOCAL_NAMES, + } + or local_name in BPMN_EVENT_DEFINITION_LOCAL_NAMES + ) + + +def _foreign_extension_xml(element: Element) -> list[str]: + result: list[str] = [] + for extension in element: + if _qualified_name(extension.tag) != ( + BPMN_MODEL_NAMESPACE, + "extensionElements", + ): + continue + for child in extension: + if _qualified_name(child.tag) == ( + GOVOPLAN_EXTENSION_NAMESPACE, + "config", + ): + continue + result.append(_serialize_element(child)) + return result + + +def _start_kind(element: Element, config: dict[str, object]) -> str: + event_definition = str(config.get("event_definition") or "none") + if event_definition == "timer": + return "schedule" + if event_definition in {"message", "signal", "conditional"}: + return "event" + for child in element: + if _qualified_name(child.tag) != ( + BPMN_MODEL_NAMESPACE, + "extensionElements", + ): + continue + for extension in child.iter(): + if _qualified_name(extension.tag) == ( + GOVOPLAN_EXTENSION_NAMESPACE, + "property", + ) and extension.attrib.get("key") == "start_kind": + return (extension.text or "manual").strip() + return "manual" + + +def _edge_references( + element: Element, + local_name: str, +) -> tuple[str | None, str | None]: + if local_name not in {"dataInputAssociation", "dataOutputAssociation"}: + return element.attrib.get("sourceRef"), element.attrib.get("targetRef") + source_ref = next( + ( + (child.text or "").strip() + for child in element + if _qualified_name(child.tag) + == (BPMN_MODEL_NAMESPACE, "sourceRef") + and (child.text or "").strip() + ), + None, + ) + target_ref = next( + ( + (child.text or "").strip() + for child in element + if _qualified_name(child.tag) + == (BPMN_MODEL_NAMESPACE, "targetRef") + and (child.text or "").strip() + ), + None, + ) + return source_ref, target_ref + + +def _edge_process_id(edge: WorkflowEdge, nodes: list[WorkflowNode]) -> str: + configured = str(edge.config.get("process_id") or "") + if configured: + return configured + source = next((node for node in nodes if node.id == edge.source), None) + target = next((node for node in nodes if node.id == edge.target), None) + return (source and source.process_id) or (target and target.process_id) or "Process_1" + + +def _nodes_parent_first(nodes: list[WorkflowNode]) -> list[WorkflowNode]: + remaining = list(nodes) + ordered: list[WorkflowNode] = [] + emitted: set[str] = set() + while remaining: + progressed = False + for node in list(remaining): + if not node.parent_id or node.parent_id in emitted: + ordered.append(node) + emitted.add(node.id) + remaining.remove(node) + progressed = True + if not progressed: + ordered.extend(remaining) + break + return ordered + + +def _fallback_shape(local_name: str, index: int) -> _DiagramShape: + size = _default_size(local_name) + return _DiagramShape( + position=WorkflowPosition( + x=80 + (index % 5) * 190, + y=100 + (index // 5) * 140, + ), + size=size, + ) + + +def _default_size(local_name: str) -> WorkflowSize: + width, height = _DEFAULT_NODE_SIZE.get(local_name, (120.0, 80.0)) + return WorkflowSize(width=width, height=height) + + +def _fallback_waypoints( + source: WorkflowNode | None, + target: WorkflowNode | None, +) -> list[WorkflowWaypoint]: + if source is None or target is None: + return [] + source_size = source.size or _default_size( + source.type.removeprefix("bpmn.") + ) + target_size = target.size or _default_size( + target.type.removeprefix("bpmn.") + ) + return [ + WorkflowWaypoint( + x=source.position.x + source_size.width, + y=source.position.y + source_size.height / 2, + ), + WorkflowWaypoint( + x=target.position.x, + y=target.position.y + target_size.height / 2, + ), + ] + + +def _graph_id_map(values: Iterable[str]) -> dict[str, str]: + return {value: _graph_id(value) for value in values} + + +def _graph_id(value: str) -> str: + if len(value) <= 120: + return value + digest = hashlib.sha256(value.encode("utf-8")).hexdigest()[:16] + return f"{value[:103]}-{digest}" + + +def _xml_id_map( + values: Iterable[WorkflowNode] | Iterable[WorkflowEdge], + *, + prefix: str, +) -> dict[str, str]: + result: dict[str, str] = {} + used: set[str] = set() + for item in values: + configured = str(item.config.get("bpmn_id") or item.id) + candidate = _xml_id(prefix, configured) + if candidate in used: + candidate = _xml_id( + prefix, + f"{candidate}_{hashlib.sha256(item.id.encode()).hexdigest()[:8]}", + ) + used.add(candidate) + result[item.id] = candidate + return result + + +def _xml_id(prefix: str, value: str) -> str: + cleaned = re.sub(r"[^A-Za-z0-9_.-]", "_", value.strip()) + if not cleaned or not re.match(r"[A-Za-z_]", cleaned): + cleaned = f"{prefix}_{cleaned}" + return cleaned + + +def _serialize_element(element: Element) -> str: + return ElementTree.tostring(element, encoding="unicode") + + +def _qualified_name(tag: str) -> tuple[str | None, str]: + if tag.startswith("{") and "}" in tag: + namespace, local_name = tag[1:].split("}", 1) + return namespace, local_name + return None, tag + + +def _tag(namespace: str, local_name: str) -> str: + return f"{{{namespace}}}{local_name}" + + +def _number(value: float) -> str: + return f"{value:.3f}".rstrip("0").rstrip(".") + + +__all__ = [ + "BPMN_EDGE_LOCAL_NAMES", + "BPMN_NODE_LOCAL_NAMES", + "BpmnGraphError", + "NATIVE_BPMN_ADAPTER_ID", + "NATIVE_BPMN_ADAPTER_VERSION", + "canonical_bpmn_graph", + "export_bpmn_graph", + "import_bpmn_graph", + "is_native_bpmn_graph", + "legacy_graph_to_bpmn", + "materialize_runtime_graph", + "runtime_diagnostics", +] diff --git a/src/govoplan_workflow/backend/db/models.py b/src/govoplan_workflow/backend/db/models.py index fca752c..cfa2c3f 100644 --- a/src/govoplan_workflow/backend/db/models.py +++ b/src/govoplan_workflow/backend/db/models.py @@ -184,6 +184,38 @@ class WorkflowDefinitionRevision(Base, TimestampMixin): content_hash: Mapped[str] = mapped_column(String(64), nullable=False) library_id: Mapped[str] = mapped_column(String(100), nullable=False) library_version: Mapped[str] = mapped_column(String(40), nullable=False) + execution_mode: Mapped[str] = mapped_column( + String(20), + default="hybrid", + nullable=False, + ) + view_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + view_revision_id: Mapped[str | None] = mapped_column( + String(36), + nullable=True, + ) + bpmn_xml: Mapped[str | None] = mapped_column(Text, nullable=True) + bpmn_hash: Mapped[str | None] = mapped_column( + String(64), + nullable=True, + index=True, + ) + bpmn_adapter_id: Mapped[str | None] = mapped_column( + String(120), + nullable=True, + ) + bpmn_adapter_version: Mapped[str | None] = mapped_column( + String(40), + nullable=True, + ) + bpmn_runtime_kind: Mapped[str | None] = mapped_column( + String(20), + nullable=True, + ) + bpmn_executable: Mapped[bool | None] = mapped_column( + Boolean, + nullable=True, + ) created_by: Mapped[str | None] = mapped_column( String(255), nullable=True, @@ -232,6 +264,12 @@ class WorkflowInstance(Base, TimestampMixin): nullable=False, index=True, ) + start_origin: Mapped[str] = mapped_column( + String(30), + default="user", + nullable=False, + index=True, + ) idempotency_key: Mapped[str] = mapped_column( String(255), nullable=False, diff --git a/src/govoplan_workflow/backend/instance_service.py b/src/govoplan_workflow/backend/instance_service.py index 7602825..0fedc79 100644 --- a/src/govoplan_workflow/backend/instance_service.py +++ b/src/govoplan_workflow/backend/instance_service.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Mapping from datetime import datetime +import hashlib import logging from sqlalchemy import func, select @@ -9,8 +10,13 @@ from sqlalchemy.orm import Session, selectinload from govoplan_core.auth import ApiPrincipal, has_scope from govoplan_core.core.automation import ( + ActionDefinition, + ActionExecutionRequest, + ActionExecutionResult, + ActionPreview, AutomationInvocation, AutomationPrincipalRequest, + action_effect_provider, automation_principal_provider, ) from govoplan_core.core.dataflows import ( @@ -32,6 +38,10 @@ from govoplan_workflow.backend.db.models import ( WorkflowInstanceStep, ) from govoplan_workflow.backend.governance import require_definition_action +from govoplan_workflow.backend.bpmn_graph import ( + BpmnGraphError, + materialize_runtime_graph, +) from govoplan_workflow.backend.schemas import ( WorkflowGraph, WorkflowInstanceEventResponse, @@ -40,6 +50,7 @@ from govoplan_workflow.backend.schemas import ( WorkflowInstanceStepResponse, WorkflowNode, WorkflowStepActionRequest, + WorkflowViewContextResponse, ) from govoplan_workflow.backend.service import ( WorkflowConflictError, @@ -120,6 +131,7 @@ def start_instance( principal: ApiPrincipal, registry: object | None, payload: WorkflowInstanceStartRequest, + start_origin: str = "user", ) -> tuple[WorkflowInstance, bool]: definition = get_definition( session, @@ -146,7 +158,17 @@ def start_instance( definition=definition, revision=definition.active_revision, ) - graph = WorkflowGraph.model_validate(revision.graph) + normalized_origin = _normalize_start_origin(start_origin) + if normalized_origin != "user" and not definition.allow_automation: + raise WorkflowConflictError( + "This Workflow does not allow automated starts." + ) + if revision.execution_mode == "guided" and normalized_origin != "user": + raise WorkflowConflictError( + "Guided workflows must be started by a user; use hybrid mode " + "for triggered workflows with human handoffs." + ) + graph = _runtime_graph(revision) _require_runtime_dependencies( graph, principal=principal, @@ -164,6 +186,7 @@ def start_instance( if ( dict(existing.input_) != dict(payload.input) or existing.correlation_id != payload.correlation_id + or existing.start_origin != normalized_origin ): raise WorkflowConflictError( "The Workflow idempotency key was already used with " @@ -174,13 +197,17 @@ def start_instance( tenant_id=tenant_id, instance_id=existing.id, ), True - start_node = _start_node(graph, kind="manual") + start_node = _start_node( + graph, + kind=_start_kind_for_origin(normalized_origin), + ) now = utcnow() instance = WorkflowInstance( tenant_id=tenant_id, definition_id=definition.id, definition_revision_id=revision.id, status="running", + start_origin=normalized_origin, idempotency_key=key, correlation_id=payload.correlation_id, input_=dict(payload.input), @@ -189,6 +216,7 @@ def start_instance( authorization_=_authorization_payload( principal, graph=graph, + registry=registry, ), started_at=now, created_by=actor_id, @@ -208,6 +236,8 @@ def start_instance( "definition_ref": f"workflow-definition:{definition.id}", "revision": revision.revision, "definition_hash": revision.content_hash, + "execution_mode": revision.execution_mode, + "start_origin": normalized_origin, "input": dict(payload.input), }, ) @@ -235,7 +265,7 @@ def reconcile_instance( if instance.status not in {"running", "waiting"}: return False step = _current_step(session, instance) - if step is None or step.node_type != "workflow.dataflow": + if step is None: return False revision = session.get( WorkflowDefinitionRevision, @@ -248,8 +278,28 @@ def reconcile_instance( message="Pinned Workflow revision no longer exists.", ) return True - graph = WorkflowGraph.model_validate(revision.graph) + graph = _runtime_graph(revision) node = _node(graph, step.node_id) + if step.node_type == "workflow.capability": + if str(step.handoff.get("state") or "") not in { + "", + "pending", + "running", + "retrying", + }: + return False + return _execute_capability_step( + session, + instance=instance, + step=step, + node=node, + graph=graph, + principal=principal, + registry=registry, + actor_id=actor_id, + ) + if step.node_type != "workflow.dataflow": + return False if not step.external_ref: _start_dataflow_step( session, @@ -258,6 +308,8 @@ def reconcile_instance( node=node, principal=principal, registry=registry, + graph=graph, + actor_id=actor_id, ) return True provider = dataflow_run_lifecycle(registry) @@ -276,18 +328,28 @@ def reconcile_instance( run_ref=step.external_ref, ) except ValueError as exc: - _set_failure_handoff( + _handle_dataflow_failure( session, instance=instance, step=step, + node=node, + graph=graph, + principal=principal, + registry=registry, + actor_id=actor_id, message=str(exc), ) return True if descriptor is None: - _set_failure_handoff( + _handle_dataflow_failure( session, instance=instance, step=step, + node=node, + graph=graph, + principal=principal, + registry=registry, + actor_id=actor_id, message="The linked Dataflow run no longer exists.", ) return True @@ -316,18 +378,28 @@ def reconcile_instance( actor_id=actor_id, ) if descriptor.status == "cancelled": - _set_failure_handoff( + _handle_dataflow_failure( session, instance=instance, step=step, + node=node, + graph=graph, + principal=principal, + registry=registry, + actor_id=actor_id, message="The linked Dataflow run was cancelled.", state="cancelled", ) return True - _set_failure_handoff( + _handle_dataflow_failure( session, instance=instance, step=step, + node=node, + graph=graph, + principal=principal, + registry=registry, + actor_id=actor_id, message=descriptor.error or "The linked Dataflow run failed.", ) return True @@ -365,7 +437,7 @@ def resolve_step( raise WorkflowConflictError( "Pinned Workflow revision no longer exists." ) - graph = WorkflowGraph.model_validate(revision.graph) + graph = _runtime_graph(revision) node = _node(graph, step.node_id) allowed_actions = { str(action) @@ -405,9 +477,29 @@ def resolve_step( } return instance if payload.action == "retry": + if step.node_type == "workflow.capability": + step.status = "running" + step.error = None + step.handoff = { + **dict(step.handoff), + "state": "retrying", + } + instance.status = "running" + instance.error = None + _execute_capability_step( + session, + instance=instance, + step=step, + node=node, + graph=graph, + principal=principal, + registry=registry, + actor_id=actor_id, + ) + return instance if step.node_type != "workflow.dataflow": raise WorkflowConflictError( - "Only failed Dataflow handoffs can be retried." + "Only failed module-action or Dataflow handoffs can be retried." ) step.status = "superseded" step.finished_at = utcnow() @@ -537,7 +629,10 @@ def reconcile_pending_instances( } for instance in instances: step = _current_step(session, instance) - if step is None or step.node_type != "workflow.dataflow": + if step is None or step.node_type not in { + "workflow.capability", + "workflow.dataflow", + }: summary["waiting"] = int(summary["waiting"]) + 1 continue principal = _resolve_instance_principal( @@ -579,12 +674,16 @@ def instance_response( raise WorkflowNotFoundError( "Workflow instance definition evidence is incomplete." ) + view_context = _instance_view_context(instance, revision) return WorkflowInstanceResponse( id=instance.id, definition_id=instance.definition_id, definition_name=definition.name, definition_revision=revision.revision, definition_hash=revision.content_hash, + execution_mode=revision.execution_mode, # type: ignore[arg-type] + start_origin=instance.start_origin, # type: ignore[arg-type] + view_context=view_context, status=instance.status, # type: ignore[arg-type] idempotency_key=instance.idempotency_key, correlation_id=instance.correlation_id, @@ -687,6 +786,20 @@ def _drive_instance( node=node, principal=principal, registry=registry, + graph=graph, + actor_id=actor_id, + ) + return + if node.type == "workflow.capability": + _execute_capability_step( + session, + instance=instance, + step=step, + node=node, + graph=graph, + principal=principal, + registry=registry, + actor_id=actor_id, ) return if node.type in { @@ -765,6 +878,572 @@ def _drive_instance( ) +def _execute_capability_step( + session: Session, + *, + instance: WorkflowInstance, + step: WorkflowInstanceStep, + node: WorkflowNode, + graph: WorkflowGraph, + principal: ApiPrincipal, + registry: object | None, + actor_id: str | None, +) -> bool: + try: + provider, definition = _capability_action_definition( + node, + principal=principal, + registry=registry, + ) + execution_context = { + **dict(instance.context_), + "instance": { + "id": instance.id, + "definition_id": instance.definition_id, + "correlation_id": instance.correlation_id, + }, + "step": { + "id": step.id, + "node_id": step.node_id, + "sequence": step.sequence, + "attempt": step.attempt, + }, + } + action_input = _mapped_action_input( + node.config.get("input_mapping"), + execution_context, + ) + request = ActionExecutionRequest( + tenant_id=instance.tenant_id, + action_key=definition.action_key, + input=action_input, + idempotency_key=_action_idempotency_key( + node, + step=step, + capability_name=str(node.config.get("capability") or ""), + action_key=definition.action_key, + context=execution_context, + ), + invocation=AutomationInvocation( + kind="workflow", + trigger_ref=f"workflow-instance:{instance.id}", + correlation_id=instance.correlation_id, + causation_id=f"workflow-step:{step.id}", + requested_by=instance.created_by, + metadata={ + "workflow_definition_ref": ( + f"workflow-definition:{instance.definition_id}" + ), + "workflow_node_id": node.id, + }, + ), + actor_ref=instance.created_by, + metadata={ + "workflow_instance_ref": f"workflow-instance:{instance.id}", + "workflow_step_ref": f"workflow-step:{step.id}", + }, + ) + preview = provider.preview_action( + session, + principal, + request=request, + ) + except WorkflowConflictError as exc: + _set_action_handoff( + session, + instance=instance, + step=step, + state="blocked", + message=str(exc), + action_key=str(node.config.get("operation") or ""), + capability_name=str(node.config.get("capability") or ""), + registry=registry, + ) + return True + except ValueError as exc: + _set_action_handoff( + session, + instance=instance, + step=step, + state="blocked", + message=str(exc), + action_key=str(node.config.get("operation") or ""), + capability_name=str(node.config.get("capability") or ""), + registry=registry, + ) + return True + except Exception as exc: + logger.exception( + "Workflow action preview failed for instance %s step %s", + instance.id, + step.id, + ) + _set_action_handoff( + session, + instance=instance, + step=step, + state="quarantined", + message=( + "The module action preview failed unexpectedly. No action " + "execution was attempted." + ), + action_key=str(node.config.get("operation") or ""), + capability_name=str(node.config.get("capability") or ""), + registry=registry, + details={"error_type": type(exc).__name__}, + ) + return True + if not isinstance(preview, ActionPreview): + _set_action_handoff( + session, + instance=instance, + step=step, + state="quarantined", + message="The action provider returned an invalid preview.", + action_key=definition.action_key, + capability_name=str(node.config.get("capability") or ""), + registry=registry, + ) + return True + preview_payload = _action_preview_payload(preview) + if preview.action_key != definition.action_key: + _set_action_handoff( + session, + instance=instance, + step=step, + state="quarantined", + message="The action provider returned a preview for another action.", + action_key=definition.action_key, + capability_name=str(node.config.get("capability") or ""), + registry=registry, + details={"preview": preview_payload}, + ) + return True + if not preview.allowed: + _set_action_handoff( + session, + instance=instance, + step=step, + state="blocked", + message=preview.summary or "The module action is not allowed.", + action_key=definition.action_key, + capability_name=str(node.config.get("capability") or ""), + registry=registry, + details={"preview": preview_payload}, + ) + return True + request = ActionExecutionRequest( + tenant_id=request.tenant_id, + action_key=request.action_key, + input=request.input, + idempotency_key=request.idempotency_key, + invocation=request.invocation, + actor_ref=request.actor_ref, + preview_ref=preview.preview_ref, + metadata=request.metadata, + ) + try: + result = provider.execute_action( + session, + principal, + request=request, + ) + except Exception as exc: + logger.exception( + "Workflow action execution failed for instance %s step %s", + instance.id, + step.id, + ) + _set_action_handoff( + session, + instance=instance, + step=step, + state="quarantined", + message=( + "The module action outcome is unknown. Inspect the provider " + "before retrying to avoid a duplicate effect." + ), + action_key=definition.action_key, + capability_name=str(node.config.get("capability") or ""), + registry=registry, + details={ + "preview": preview_payload, + "error_type": type(exc).__name__, + "outcome_unknown": True, + }, + ) + return True + if not isinstance(result, ActionExecutionResult): + _set_action_handoff( + session, + instance=instance, + step=step, + state="quarantined", + message="The action provider returned an invalid execution result.", + action_key=definition.action_key, + capability_name=str(node.config.get("capability") or ""), + registry=registry, + details={"preview": preview_payload}, + ) + return True + allowed_states = { + "pending", + "running", + "completed", + "blocked", + "retryable", + "quarantined", + "manual_required", + "compensation_required", + } + announced_effects = { + item.effect_key for item in provider.effect_definitions() + } + unknown_effects = sorted( + { + effect.effect_key + for effect in result.observed_effects + if effect.effect_key not in announced_effects + } + ) + if result.state not in allowed_states or unknown_effects: + _set_action_handoff( + session, + instance=instance, + step=step, + state="quarantined", + message=( + "The action provider returned an unsupported state." + if result.state not in allowed_states + else "The action provider reported unannounced effects." + ), + action_key=definition.action_key, + capability_name=str(node.config.get("capability") or ""), + registry=registry, + details={ + "state": result.state, + "unknown_effects": unknown_effects, + }, + ) + return True + result_payload = _action_result_payload(result) + step.output_ = { + "action_key": definition.action_key, + "capability": str(node.config.get("capability") or ""), + "idempotency_key": request.idempotency_key, + "preview": preview_payload, + "execution": result_payload, + } + if result.state != "completed": + _set_action_handoff( + session, + instance=instance, + step=step, + state=result.state, + message=( + result.error + or result.manual_instructions + or f"Module action is {result.state.replace('_', ' ')}." + ), + action_key=definition.action_key, + capability_name=str(node.config.get("capability") or ""), + registry=registry, + details={ + "preview": preview_payload, + "execution": result_payload, + }, + ) + return True + _record_event( + session, + instance, + step=step, + kind="workflow.action.completed", + actor_id=actor_id, + payload={ + "action_key": definition.action_key, + "capability": str(node.config.get("capability") or ""), + "idempotency_key": request.idempotency_key, + "observed_effects": result_payload["observed_effects"], + "audit_event_refs": result_payload["audit_event_refs"], + }, + ) + suggested_port = str(result.output.get("outcome") or "success") + if suggested_port not in {"success", "warning"}: + suggested_port = "success" + next_node_id = _complete_step( + session, + instance=instance, + step=step, + graph=graph, + port=suggested_port, + output=dict(step.output_), + actor_id=actor_id, + ) + _drive_instance( + session, + instance=instance, + graph=graph, + next_node_id=next_node_id, + principal=principal, + registry=registry, + actor_id=actor_id, + ) + return True + + +def _capability_action_definition( + node: WorkflowNode, + *, + principal: ApiPrincipal, + registry: object | None, +) -> tuple[object, ActionDefinition]: + capability_name = str(node.config.get("capability") or "").strip() + action_key = str(node.config.get("operation") or "").strip() + if not capability_name or not action_key: + raise WorkflowConflictError( + "Module-action steps require a capability and operation." + ) + provider = action_effect_provider(registry, capability_name) + if provider is None: + raise WorkflowConflictError( + f"Action capability {capability_name!r} is not available." + ) + definitions = [ + item + for item in provider.action_definitions() + if item.action_key == action_key + ] + if len(definitions) != 1: + raise WorkflowConflictError( + f"Action {action_key!r} is not uniquely announced by " + f"{capability_name!r}." + ) + definition = definitions[0] + missing_scopes = [ + scope + for scope in definition.required_scopes + if not has_scope(principal, scope) + ] + if missing_scopes: + raise WorkflowConflictError( + "Module action requires scopes: " + + ", ".join(sorted(missing_scopes)) + ) + missing_capabilities = [ + capability + for capability in definition.required_capabilities + if ( + registry is None + or not hasattr(registry, "has_capability") + or not registry.has_capability(capability) + ) + ] + if missing_capabilities: + raise WorkflowConflictError( + "Module action requires capabilities: " + + ", ".join(sorted(missing_capabilities)) + ) + effect_keys = { + item.effect_key for item in provider.effect_definitions() + } + missing_effects = sorted( + set(definition.expected_effect_keys) - effect_keys + ) + if missing_effects: + raise WorkflowConflictError( + "Action provider does not define its expected effects: " + + ", ".join(missing_effects) + ) + return provider, definition + + +def _mapped_action_input( + raw_mapping: object, + context: Mapping[str, object], +) -> dict[str, object]: + if raw_mapping is None or raw_mapping == "": + return dict(context) + if not isinstance(raw_mapping, Mapping): + raise WorkflowConflictError( + "Module-action input mapping must be an object." + ) + return { + str(key): _resolve_action_value(value, context, depth=0) + for key, value in raw_mapping.items() + if str(key).strip() + } + + +def _resolve_action_value( + value: object, + context: Mapping[str, object], + *, + depth: int, +) -> object: + if depth > 10: + raise WorkflowConflictError( + "Module-action input mapping is nested too deeply." + ) + if isinstance(value, str) and value.startswith("$"): + path = value[1:].lstrip(".") + current: object = context + if not path: + return dict(context) + for segment in path.split("."): + if not isinstance(current, Mapping) or segment not in current: + raise WorkflowConflictError( + f"Module-action input path {value!r} is unavailable." + ) + current = current[segment] + return current + if isinstance(value, Mapping): + return { + str(key): _resolve_action_value( + nested, + context, + depth=depth + 1, + ) + for key, nested in value.items() + } + if isinstance(value, list): + return [ + _resolve_action_value(item, context, depth=depth + 1) + for item in value + ] + return value + + +def _action_idempotency_key( + node: WorkflowNode, + *, + step: WorkflowInstanceStep, + capability_name: str, + action_key: str, + context: Mapping[str, object], +) -> str: + expression = str( + node.config.get("idempotency_key") or "workflow-step" + ).strip() + if expression == "workflow-step": + return step.idempotency_key + resolved = _resolve_action_value(expression, context, depth=0) + key = f"{capability_name}:{action_key}:{resolved}" + if len(key) <= 255: + return key + digest = hashlib.sha256(key.encode("utf-8")).hexdigest() + return f"{capability_name[:80]}:{action_key[:80]}:{digest}" + + +def _action_preview_payload(preview: object) -> dict[str, object]: + return { + "action_key": str(getattr(preview, "action_key", "")), + "allowed": bool(getattr(preview, "allowed", False)), + "summary": str(getattr(preview, "summary", "")), + "risk_level": str(getattr(preview, "risk_level", "")), + "reversibility": str(getattr(preview, "reversibility", "")), + "preview_ref": getattr(preview, "preview_ref", None), + "blockers": list(getattr(preview, "blockers", ()) or ()), + "policy_provenance": [ + dict(item) + for item in getattr(preview, "policy_provenance", ()) or () + ], + "effects": [ + { + "effect_key": item.effect_key, + "summary": item.summary, + "resource_refs": list(item.resource_refs), + "external_system_refs": list(item.external_system_refs), + } + for item in getattr(preview, "effects", ()) or () + ], + } + + +def _action_result_payload( + result: ActionExecutionResult, +) -> dict[str, object]: + return { + "state": result.state, + "output": dict(result.output), + "observed_effects": [ + { + "effect_key": effect.effect_key, + "operation": effect.operation, + "resource_ref": effect.resource_ref, + "external_system_ref": effect.external_system_ref, + "audit_event_ref": effect.audit_event_ref, + "summary": effect.summary, + "metadata": dict(effect.metadata), + } + for effect in result.observed_effects + ], + "error": result.error, + "retry_after": ( + result.retry_after.isoformat() + if result.retry_after is not None + else None + ), + "manual_instructions": result.manual_instructions, + "compensation_action_key": result.compensation_action_key, + "audit_event_refs": list(result.audit_event_refs), + } + + +def _set_action_handoff( + session: Session, + *, + instance: WorkflowInstance, + step: WorkflowInstanceStep, + state: str, + message: str, + action_key: str, + capability_name: str, + registry: object | None, + details: Mapping[str, object] | None = None, +) -> None: + allowed_actions = ( + ["cancel"] + if state in {"pending", "running"} + else ["retry", "reject", "cancel"] + ) + previous = dict(step.handoff) + step.status = "waiting" + step.error = message if state not in {"pending", "running"} else None + step.handoff = { + "kind": "module_action", + "state": state, + "message": message, + "action_key": action_key, + "capability": capability_name, + "allowed_actions": allowed_actions, + "suggested_port": "failure", + **dict(details or {}), + } + instance.status = "waiting" + instance.error = step.error + if ( + previous.get("state") != state + or previous.get("message") != message + ): + _record_event( + session, + instance, + step=step, + kind=f"workflow.action.{state}", + actor_id=None, + payload=dict(step.handoff), + ) + if state not in {"pending", "running"}: + _notify_handoff( + session, + registry=registry, + instance=instance, + step=step, + subject=f"Workflow action requires attention: {action_key}", + ) + + def _start_dataflow_step( session: Session, *, @@ -773,6 +1452,8 @@ def _start_dataflow_step( node: WorkflowNode, principal: ApiPrincipal, registry: object | None, + graph: WorkflowGraph, + actor_id: str | None, ) -> None: provider = dataflow_run_lifecycle(registry) if provider is None: @@ -791,18 +1472,28 @@ def _start_dataflow_step( min(int(node.config.get("row_limit") or 500), 10_000), ) except (TypeError, ValueError): - _set_failure_handoff( + _handle_dataflow_failure( session, instance=instance, step=step, + node=node, + graph=graph, + principal=principal, + registry=registry, + actor_id=actor_id, message="Dataflow revision and row limit must be integers.", ) return if not pipeline_ref or revision < 1: - _set_failure_handoff( + _handle_dataflow_failure( session, instance=instance, step=step, + node=node, + graph=graph, + principal=principal, + registry=registry, + actor_id=actor_id, message="Dataflow steps require a pipeline and pinned revision.", ) return @@ -843,10 +1534,15 @@ def _start_dataflow_step( ), ) except ValueError as exc: - _set_failure_handoff( + _handle_dataflow_failure( session, instance=instance, step=step, + node=node, + graph=graph, + principal=principal, + registry=registry, + actor_id=actor_id, message=str(exc), ) return @@ -1085,6 +1781,83 @@ def _set_dependency_handoff( ) +def _handle_dataflow_failure( + session: Session, + *, + instance: WorkflowInstance, + step: WorkflowInstanceStep, + node: WorkflowNode, + graph: WorkflowGraph, + principal: ApiPrincipal, + registry: object | None, + actor_id: str | None, + message: str, + state: str = "failed", +) -> None: + policy = str(node.config.get("failure_policy") or "manual") + if policy == "manual": + _set_failure_handoff( + session, + instance=instance, + step=step, + message=message, + state=state, + ) + return + step.error = message + step.output_ = { + **dict(step.output_), + "status": state, + "error": message, + } + if policy == "continue": + _record_event( + session, + instance, + step=step, + kind="workflow.dataflow.failure_continued", + actor_id=actor_id, + payload={ + "error": message, + "external_ref": step.external_ref, + }, + ) + next_node_id = _complete_step( + session, + instance=instance, + step=step, + graph=graph, + port="failure", + output=dict(step.output_), + actor_id=actor_id, + ) + _drive_instance( + session, + instance=instance, + graph=graph, + next_node_id=next_node_id, + principal=principal, + registry=registry, + actor_id=actor_id, + ) + return + step.status = "failed" + step.finished_at = utcnow() + _record_event( + session, + instance, + step=step, + kind="workflow.dataflow.failed", + actor_id=actor_id, + payload={ + "error": message, + "external_ref": step.external_ref, + "failure_policy": "fail", + }, + ) + _fail_instance(session, instance, message=message) + + def _set_failure_handoff( session: Session, *, @@ -1223,6 +1996,13 @@ def _current_step( def _start_node(graph: WorkflowGraph, *, kind: str) -> WorkflowNode: expected = f"workflow.start.{kind}" node = next((item for item in graph.nodes if item.type == expected), None) + if node is None: + starts = [ + item for item in graph.nodes + if item.type.startswith("workflow.start.") + ] + if len(starts) == 1: + node = starts[0] if node is None: raise WorkflowConflictError( f"Workflow has no {kind} start node." @@ -1230,6 +2010,82 @@ def _start_node(graph: WorkflowGraph, *, kind: str) -> WorkflowNode: return node +def _normalize_start_origin(value: str) -> str: + normalized = value.strip().lower() + allowed = { + "user", + "api", + "schedule", + "event", + "parent_workflow", + "dependency", + "retry", + "replay", + "backfill", + } + if normalized not in allowed: + raise WorkflowConflictError( + f"Unsupported Workflow start origin {value!r}." + ) + return normalized + + +def _start_kind_for_origin(origin: str) -> str: + return { + "user": "manual", + "api": "api", + "schedule": "schedule", + "event": "event", + "parent_workflow": "workflow", + "dependency": "workflow", + "retry": "api", + "replay": "api", + "backfill": "api", + }[origin] + + +def _instance_view_context( + instance: WorkflowInstance, + revision: WorkflowDefinitionRevision, +) -> WorkflowViewContextResponse | None: + if ( + not revision.view_id + or instance.status not in {"running", "waiting"} + ): + return None + step = next( + ( + item + for item in instance.steps + if item.id == instance.current_step_id + ), + None, + ) + node = None + if step is not None: + graph = _runtime_graph(revision) + node = next( + (item for item in graph.nodes if item.id == step.node_id), + None, + ) + surface_ids = ( + [ + str(surface_id).strip() + for surface_id in node.config.get("view_surface_ids") or () + if str(surface_id).strip() + ] + if node is not None + else [] + ) + return WorkflowViewContextResponse( + view_id=revision.view_id, + revision_id=revision.view_revision_id, + visible_surface_ids=list(dict.fromkeys(surface_ids)), + step_id=step.id if step is not None else None, + node_id=node.id if node is not None else None, + ) + + def _node(graph: WorkflowGraph, node_id: str) -> WorkflowNode: node = next((item for item in graph.nodes if item.id == node_id), None) if node is None: @@ -1239,6 +2095,15 @@ def _node(graph: WorkflowGraph, node_id: str) -> WorkflowNode: return node +def _runtime_graph(revision: WorkflowDefinitionRevision) -> WorkflowGraph: + try: + return materialize_runtime_graph( + WorkflowGraph.model_validate(revision.graph) + ) + except BpmnGraphError as exc: + raise WorkflowConflictError(str(exc)) from exc + + def _next_node_id( graph: WorkflowGraph, source_id: str, @@ -1273,6 +2138,10 @@ def _action_port( if action == "reject": return "failure" return str(step.handoff.get("suggested_port") or "success") + if step.node_type == "workflow.capability": + if action == "reject": + return "failure" + return str(step.handoff.get("suggested_port") or "success") return "output" @@ -1305,27 +2174,43 @@ def _require_runtime_dependencies( principal: ApiPrincipal, registry: object | None, ) -> None: - if not any(node.type == "workflow.dataflow" for node in graph.nodes): - return - if dataflow_run_lifecycle(registry) is None: - raise WorkflowConflictError( - "This Workflow requires the optional Dataflow module." - ) - if not has_scope(principal, DATAFLOW_RUN_SCOPE): - raise WorkflowConflictError( - "Starting this Workflow requires dataflow:pipeline:run." - ) + if any(node.type == "workflow.dataflow" for node in graph.nodes): + if dataflow_run_lifecycle(registry) is None: + raise WorkflowConflictError( + "This Workflow requires the optional Dataflow module." + ) + if not has_scope(principal, DATAFLOW_RUN_SCOPE): + raise WorkflowConflictError( + "Starting this Workflow requires dataflow:pipeline:run." + ) + for node in graph.nodes: + if node.type == "workflow.capability": + _capability_action_definition( + node, + principal=principal, + registry=registry, + ) def _authorization_payload( principal: ApiPrincipal, *, graph: WorkflowGraph, + registry: object | None, ) -> dict[str, object]: principal_ref = principal.to_platform_principal() scopes = {INSTANCE_START_SCOPE} if any(node.type == "workflow.dataflow" for node in graph.nodes): scopes.add(DATAFLOW_RUN_SCOPE) + for node in graph.nodes: + if node.type != "workflow.capability": + continue + _provider, definition = _capability_action_definition( + node, + principal=principal, + registry=registry, + ) + scopes.update(definition.required_scopes) return { "contract_version": "1", "subject_kind": ( @@ -1434,7 +2319,11 @@ def _notify_handoff( recipient_type="account", recipient_id=account_id, subject=subject, - action_url="/workflow", + action_url=( + "/workflow?" + f"definition={instance.definition_id}" + f"&run={instance.id}" + ), payload={ "instance_id": instance.id, "step_id": step.id, diff --git a/src/govoplan_workflow/backend/manifest.py b/src/govoplan_workflow/backend/manifest.py index badf67a..6b997ef 100644 --- a/src/govoplan_workflow/backend/manifest.py +++ b/src/govoplan_workflow/backend/manifest.py @@ -25,6 +25,7 @@ from govoplan_core.core.modules import ( NavItem, PermissionDefinition, RoleTemplate, + ViewSurface, ) from govoplan_core.core.policy import ( CAPABILITY_POLICY_DEFINITION_GOVERNANCE, @@ -169,6 +170,11 @@ manifest = ModuleManifest( ModuleInterfaceProvider(name="workflow.node_library", version="0.1.0"), ModuleInterfaceProvider(name="workflow.definition_catalogue", version="0.1.0"), ModuleInterfaceProvider(name="workflow.runtime_worker", version=MODULE_VERSION), + ModuleInterfaceProvider(name="workflow.bpmn_interchange", version="1.0.0"), + ModuleInterfaceProvider( + name="workflow.bpmn_execution_adapters", + version="1.0.0", + ), ), requires_interfaces=( ModuleInterfaceRequirement( @@ -239,6 +245,15 @@ manifest = ModuleManifest( order=74, ), ), + view_surfaces=( + ViewSurface( + id="workflow.widget.open-work", + module_id=MODULE_ID, + kind="section", + label="Open workflow work widget", + order=76, + ), + ), ), route_factory=_router, capability_factories={ @@ -291,6 +306,33 @@ manifest = ModuleManifest( related_modules=("dataflow", "datasources", "tasks", "notifications", "audit"), order=76, ), + DocumentationTopic( + id="workflow.bpmn-interchange", + title="BPMN modeling and execution profiles", + summary=( + "Lossless BPMN 2.0 revisions with explicit, fail-closed " + "execution conformance." + ), + body=( + "BPMN 2.0 is Workflow's canonical native graph language. The " + "shared graph editor models BPMN nodes, flows, containment, and " + "diagram geometry directly without a separate browser-side " + "modeler. Imported XML is normalized into the graph and every " + "immutable revision pins a deterministic XML artifact. Activation " + "requires a pinned execution adapter and version whose declared " + "profile accepts every modeled runtime semantic. Unsupported " + "runtime constructs remain editable and exportable. " + "Adapter packages integrate through the " + "govoplan.workflow.bpmn_adapters entry-point group and must " + "materialize canonical Workflow runtime state; Workflow never " + "imports a concrete engine module." + ), + layer="available", + documentation_types=("admin", "user"), + audience=("operator", "module_admin", "power_user", "product_owner"), + related_modules=("audit", "policy", "views"), + order=77, + ), ), ) diff --git a/src/govoplan_workflow/backend/migrations/versions/e9a4c6b8d2f1_v0114_bpmn_revision_artifacts.py b/src/govoplan_workflow/backend/migrations/versions/e9a4c6b8d2f1_v0114_bpmn_revision_artifacts.py new file mode 100644 index 0000000..27ccdcf --- /dev/null +++ b/src/govoplan_workflow/backend/migrations/versions/e9a4c6b8d2f1_v0114_bpmn_revision_artifacts.py @@ -0,0 +1,68 @@ +"""v0.1.14 normalized BPMN revision artifacts + +Revision ID: e9a4c6b8d2f1 +Revises: d8f2a5c7e1b4 +Create Date: 2026-07-30 00:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "e9a4c6b8d2f1" +down_revision = "d8f2a5c7e1b4" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("workflow_definition_revisions") as batch_op: + batch_op.add_column(sa.Column("bpmn_xml", sa.Text(), nullable=True)) + batch_op.add_column( + sa.Column("bpmn_hash", sa.String(length=64), nullable=True) + ) + batch_op.add_column( + sa.Column( + "bpmn_adapter_id", + sa.String(length=120), + nullable=True, + ) + ) + batch_op.add_column( + sa.Column( + "bpmn_adapter_version", + sa.String(length=40), + nullable=True, + ) + ) + batch_op.add_column( + sa.Column( + "bpmn_runtime_kind", + sa.String(length=20), + nullable=True, + ) + ) + batch_op.add_column( + sa.Column("bpmn_executable", sa.Boolean(), nullable=True) + ) + op.create_index( + op.f("ix_workflow_definition_revisions_bpmn_hash"), + "workflow_definition_revisions", + ["bpmn_hash"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + op.f("ix_workflow_definition_revisions_bpmn_hash"), + table_name="workflow_definition_revisions", + ) + with op.batch_alter_table("workflow_definition_revisions") as batch_op: + batch_op.drop_column("bpmn_executable") + batch_op.drop_column("bpmn_runtime_kind") + batch_op.drop_column("bpmn_adapter_version") + batch_op.drop_column("bpmn_adapter_id") + batch_op.drop_column("bpmn_hash") + batch_op.drop_column("bpmn_xml") diff --git a/src/govoplan_workflow/backend/migrations/versions/f1b7d3e5a9c2_v0114_workflow_modes_and_views.py b/src/govoplan_workflow/backend/migrations/versions/f1b7d3e5a9c2_v0114_workflow_modes_and_views.py new file mode 100644 index 0000000..f0ccbc5 --- /dev/null +++ b/src/govoplan_workflow/backend/migrations/versions/f1b7d3e5a9c2_v0114_workflow_modes_and_views.py @@ -0,0 +1,62 @@ +"""v0.1.14 workflow execution modes and focused Views + +Revision ID: f1b7d3e5a9c2 +Revises: e9a4c6b8d2f1 +Create Date: 2026-07-30 00:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "f1b7d3e5a9c2" +down_revision = "e9a4c6b8d2f1" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("workflow_definition_revisions") as batch_op: + batch_op.add_column( + sa.Column( + "execution_mode", + sa.String(length=20), + nullable=False, + server_default="hybrid", + ) + ) + batch_op.add_column( + sa.Column("view_id", sa.String(length=36), nullable=True) + ) + batch_op.add_column( + sa.Column( + "view_revision_id", + sa.String(length=36), + nullable=True, + ) + ) + with op.batch_alter_table("workflow_instances") as batch_op: + batch_op.add_column( + sa.Column( + "start_origin", + sa.String(length=30), + nullable=False, + server_default="user", + ) + ) + batch_op.create_index( + "ix_workflow_instances_start_origin", + ["start_origin"], + unique=False, + ) + + +def downgrade() -> None: + with op.batch_alter_table("workflow_instances") as batch_op: + batch_op.drop_index("ix_workflow_instances_start_origin") + batch_op.drop_column("start_origin") + with op.batch_alter_table("workflow_definition_revisions") as batch_op: + batch_op.drop_column("view_revision_id") + batch_op.drop_column("view_id") + batch_op.drop_column("execution_mode") diff --git a/src/govoplan_workflow/backend/node_library.py b/src/govoplan_workflow/backend/node_library.py index 05fc761..92ce1c3 100644 --- a/src/govoplan_workflow/backend/node_library.py +++ b/src/govoplan_workflow/backend/node_library.py @@ -4,13 +4,18 @@ from govoplan_core.core.definition_graphs import ( DefinitionConfigField, DefinitionGraphConstraints, DefinitionGraphLibrary, - DefinitionNodeCountConstraint, DefinitionNodeType, DefinitionPort, ) CATEGORY_LABELS = { + "bpmn_event": "Events", + "bpmn_activity": "Activities", + "bpmn_gateway": "Gateways", + "bpmn_data": "Data", + "bpmn_collaboration": "Collaboration", + "bpmn_artifact": "Artifacts", "trigger": "Start", "activity": "Activities", "decision": "Decisions", @@ -19,7 +24,17 @@ CATEGORY_LABELS = { "outcome": "Outcomes", } -WORKFLOW_NODE_TYPES = ( +FOCUSED_VIEW_SURFACES_FIELD = DefinitionConfigField( + id="view_surface_ids", + label="Focused View surfaces", + kind="view_surfaces", + description=( + "Optionally narrow the workflow View while this step requires " + "attention. Administrator limits and protected surfaces still apply." + ), +) + +LEGACY_WORKFLOW_NODE_TYPES = ( DefinitionNodeType( type="workflow.start.manual", category="trigger", @@ -154,12 +169,14 @@ WORKFLOW_NODE_TYPES = ( DefinitionConfigField(id="instructions", label="Instructions", kind="textarea"), DefinitionConfigField(id="assignee", label="Assignee", kind="subject"), DefinitionConfigField(id="due_after", label="Due after", kind="duration"), + FOCUSED_VIEW_SURFACES_FIELD, ), default_config={ "title": "", "instructions": "", "assignee": "", "due_after": "", + "view_surface_ids": [], }, ), DefinitionNodeType( @@ -182,8 +199,14 @@ WORKFLOW_NODE_TYPES = ( label="Required evidence", kind="string_list", ), + FOCUSED_VIEW_SURFACES_FIELD, ), - default_config={"title": "", "reviewer": "", "required_evidence": []}, + default_config={ + "title": "", + "reviewer": "", + "required_evidence": [], + "view_surface_ids": [], + }, ), DefinitionNodeType( type="workflow.decision", @@ -231,8 +254,13 @@ WORKFLOW_NODE_TYPES = ( ), ), DefinitionConfigField(id="value", label="Value", kind="text"), + FOCUSED_VIEW_SURFACES_FIELD, ), - default_config={"mode": "manual", "value": ""}, + default_config={ + "mode": "manual", + "value": "", + "view_surface_ids": [], + }, ), DefinitionNodeType( type="workflow.capability", @@ -282,13 +310,15 @@ WORKFLOW_NODE_TYPES = ( ("continue", "Continue on failure"), ), ), + FOCUSED_VIEW_SURFACES_FIELD, ), default_config={ "capability": "", "operation": "", "input_mapping": {}, - "idempotency_key": "", + "idempotency_key": "workflow-step", "failure_policy": "manual", + "view_surface_ids": [], }, ), DefinitionNodeType( @@ -357,7 +387,18 @@ WORKFLOW_NODE_TYPES = ( ("continue", "Continue"), ), ), + DefinitionConfigField( + id="failure_policy", + label="Failures", + kind="select", + options=( + ("manual", "Require intervention"), + ("fail", "Fail the workflow"), + ("continue", "Follow failure path"), + ), + ), DefinitionConfigField(id="input_mapping", label="Input mapping", kind="mapping"), + FOCUSED_VIEW_SURFACES_FIELD, ), default_config={ "pipeline_ref": "", @@ -366,7 +407,9 @@ WORKFLOW_NODE_TYPES = ( "row_limit": 500, "publication_target_ref": "", "warning_policy": "review", + "failure_policy": "manual", "input_mapping": {}, + "view_surface_ids": [], }, ), DefinitionNodeType( @@ -411,31 +454,684 @@ WORKFLOW_NODE_TYPES = ( ), ) +_INCOMING = ( + DefinitionPort( + id="incoming", + label="Incoming", + required=False, + multiple=True, + minimum_connections=0, + ), +) +_OPTIONAL_INCOMING = ( + DefinitionPort( + id="incoming", + label="Incoming", + required=False, + multiple=True, + minimum_connections=0, + ), +) +_OUTGOING = ( + DefinitionPort( + id="outgoing", + label="Outgoing", + required=False, + multiple=True, + minimum_connections=0, + ), +) +_DOCUMENTATION_FIELD = DefinitionConfigField( + id="documentation", + label="Documentation", + kind="textarea", +) +_EVENT_DEFINITION_FIELD = DefinitionConfigField( + id="event_definition", + label="Event definition", + kind="select", + options=( + ("none", "None"), + ("message", "Message"), + ("timer", "Timer"), + ("conditional", "Conditional"), + ("signal", "Signal"), + ("error", "Error"), + ("escalation", "Escalation"), + ("compensation", "Compensation"), + ("link", "Link"), + ("cancel", "Cancel"), + ("terminate", "Terminate"), + ("multiple", "Multiple"), + ("parallelMultiple", "Parallel multiple"), + ), +) + + +def _bpmn_node( + *, + type_id: str, + category: str, + label: str, + description: str, + icon: str, + shape: str, + input_ports: tuple[DefinitionPort, ...] = _INCOMING, + output_ports: tuple[DefinitionPort, ...] = _OUTGOING, + config_fields: tuple[DefinitionConfigField, ...] = (), + default_config: dict[str, object] | None = None, + runtime_support: str = "model_only", +) -> DefinitionNodeType: + return DefinitionNodeType( + type=type_id, + category=category, + label=label, + description=description, + icon=icon, + input_ports=input_ports, + output_ports=output_ports, + config_fields=(*config_fields, _DOCUMENTATION_FIELD), + default_config={ + **(default_config or {}), + "documentation": "", + }, + metadata={ + "notation": "bpmn-2.0", + "shape": shape, + "runtime_support": runtime_support, + }, + ) + + +_TASK_FIELDS = ( + DefinitionConfigField(id="title", label="Task title", kind="text"), + DefinitionConfigField(id="instructions", label="Instructions", kind="textarea"), +) +_HUMAN_TASK_FIELDS = ( + *_TASK_FIELDS, + DefinitionConfigField(id="assignee", label="Assignee", kind="subject"), + DefinitionConfigField(id="due_after", label="Due after", kind="duration"), + FOCUSED_VIEW_SURFACES_FIELD, +) +_SERVICE_TASK_FIELDS = ( + *_TASK_FIELDS, + DefinitionConfigField( + id="implementation", + label="Implementation", + kind="select", + options=( + ("capability", "Module capability"), + ("dataflow", "Dataflow"), + ), + ), + DefinitionConfigField(id="capability", label="Capability", kind="capability"), + DefinitionConfigField(id="operation", label="Operation", kind="text"), + DefinitionConfigField(id="pipeline_ref", label="Dataflow", kind="dataflow"), + DefinitionConfigField(id="input_mapping", label="Input mapping", kind="mapping"), + DefinitionConfigField( + id="idempotency_key", + label="Idempotency key", + kind="expression", + ), + DefinitionConfigField( + id="failure_policy", + label="On failure", + kind="select", + options=( + ("retry", "Retry"), + ("manual", "Require intervention"), + ("continue", "Continue"), + ("fail", "Fail the workflow"), + ), + ), + FOCUSED_VIEW_SURFACES_FIELD, +) + +BPMN_NODE_TYPES = ( + _bpmn_node( + type_id="bpmn.startEvent", + category="bpmn_event", + label="Start event", + description="Start a BPMN process from a user, API, schedule, event, or parent flow.", + icon="circle-play", + shape="event-start", + input_ports=(), + config_fields=( + _EVENT_DEFINITION_FIELD, + DefinitionConfigField( + id="start_kind", + label="GovOPlaN start", + kind="select", + required=True, + options=( + ("manual", "Manual"), + ("api", "API"), + ("schedule", "Schedule"), + ("event", "Platform event"), + ("parent_workflow", "Parent workflow"), + ), + ), + DefinitionConfigField( + id="input_schema_ref", + label="Input schema", + kind="text", + ), + DefinitionConfigField( + id="authorization_policy_ref", + label="Authorization policy", + kind="text", + ), + DefinitionConfigField(id="schedule", label="Schedule", kind="schedule"), + DefinitionConfigField(id="timezone", label="Time zone", kind="timezone"), + DefinitionConfigField(id="event_type", label="Event type", kind="text"), + DefinitionConfigField(id="filter", label="Event filter", kind="expression"), + ), + default_config={ + "event_definition": "none", + "start_kind": "manual", + "input_schema_ref": "", + "authorization_policy_ref": "", + "schedule": "", + "timezone": "Europe/Berlin", + "event_type": "", + "filter": "", + }, + runtime_support="native", + ), + _bpmn_node( + type_id="bpmn.intermediateCatchEvent", + category="bpmn_event", + label="Intermediate catch event", + description="Wait until the configured BPMN event is caught.", + icon="circle-dot", + shape="event-intermediate-catch", + config_fields=( + _EVENT_DEFINITION_FIELD, + DefinitionConfigField( + id="wait_mode", + label="Wait for", + kind="select", + options=( + ("duration", "Duration"), + ("deadline", "Deadline"), + ("event", "Event"), + ("manual", "Manual resume"), + ), + ), + DefinitionConfigField(id="value", label="Value", kind="text"), + DefinitionConfigField(id="event_ref", label="Event reference", kind="text"), + FOCUSED_VIEW_SURFACES_FIELD, + ), + default_config={ + "event_definition": "none", + "wait_mode": "manual", + "value": "", + "event_ref": "", + "view_surface_ids": [], + }, + runtime_support="native", + ), + _bpmn_node( + type_id="bpmn.intermediateThrowEvent", + category="bpmn_event", + label="Intermediate throw event", + description="Emit an intermediate BPMN event.", + icon="circle-dot-dashed", + shape="event-intermediate-throw", + config_fields=( + _EVENT_DEFINITION_FIELD, + DefinitionConfigField(id="event_ref", label="Event reference", kind="text"), + ), + default_config={"event_definition": "none", "event_ref": ""}, + ), + _bpmn_node( + type_id="bpmn.boundaryEvent", + category="bpmn_event", + label="Boundary event", + description="Catch an interrupting or non-interrupting event attached to an activity.", + icon="circle-dashed", + shape="event-boundary", + input_ports=(), + config_fields=( + _EVENT_DEFINITION_FIELD, + DefinitionConfigField( + id="attached_to_ref", + label="Attached activity", + kind="text", + required=True, + ), + DefinitionConfigField( + id="cancel_activity", + label="Activity behavior", + kind="select", + options=( + ("true", "Interrupt activity"), + ("false", "Keep activity running"), + ), + ), + ), + default_config={ + "event_definition": "none", + "attached_to_ref": "", + "cancel_activity": "true", + }, + ), + _bpmn_node( + type_id="bpmn.endEvent", + category="bpmn_event", + label="End event", + description="End a BPMN process with an optional result event.", + icon="circle-stop", + shape="event-end", + output_ports=(), + config_fields=( + _EVENT_DEFINITION_FIELD, + DefinitionConfigField( + id="outcome", + label="GovOPlaN outcome", + kind="select", + options=( + ("completed", "Completed"), + ("cancelled", "Cancelled"), + ), + ), + DefinitionConfigField( + id="output_mapping", + label="Output mapping", + kind="mapping", + ), + ), + default_config={ + "event_definition": "none", + "outcome": "completed", + "output_mapping": {}, + }, + runtime_support="native", + ), + _bpmn_node( + type_id="bpmn.task", + category="bpmn_activity", + label="Task", + description="A generic BPMN task completed as a governed human activity.", + icon="square", + shape="activity", + config_fields=_HUMAN_TASK_FIELDS, + default_config={ + "title": "", + "instructions": "", + "assignee": "", + "due_after": "", + "view_surface_ids": [], + }, + runtime_support="native", + ), + _bpmn_node( + type_id="bpmn.userTask", + category="bpmn_activity", + label="User task", + description="A task requiring a user action, review, or evidence.", + icon="user-round-check", + shape="activity", + config_fields=( + *_HUMAN_TASK_FIELDS, + DefinitionConfigField( + id="task_mode", + label="Task mode", + kind="select", + options=( + ("activity", "Activity"), + ("review", "Review"), + ), + ), + DefinitionConfigField( + id="required_evidence", + label="Required evidence", + kind="string_list", + ), + ), + default_config={ + "title": "", + "instructions": "", + "assignee": "", + "due_after": "", + "task_mode": "activity", + "required_evidence": [], + "view_surface_ids": [], + }, + runtime_support="native", + ), + _bpmn_node( + type_id="bpmn.manualTask", + category="bpmn_activity", + label="Manual task", + description="A task performed outside the automated runtime.", + icon="hand", + shape="activity", + config_fields=_HUMAN_TASK_FIELDS, + default_config={ + "title": "", + "instructions": "", + "assignee": "", + "due_after": "", + "view_surface_ids": [], + }, + runtime_support="native", + ), + _bpmn_node( + type_id="bpmn.serviceTask", + category="bpmn_activity", + label="Service task", + description="Invoke a versioned module capability or published Dataflow.", + icon="cog", + shape="activity", + config_fields=_SERVICE_TASK_FIELDS, + default_config={ + "title": "", + "instructions": "", + "implementation": "capability", + "capability": "", + "operation": "", + "pipeline_ref": "", + "input_mapping": {}, + "idempotency_key": "workflow-step", + "failure_policy": "manual", + "view_surface_ids": [], + }, + runtime_support="native", + ), + _bpmn_node( + type_id="bpmn.sendTask", + category="bpmn_activity", + label="Send task", + description="Send a message through a configured implementation.", + icon="send", + shape="activity", + config_fields=_SERVICE_TASK_FIELDS, + default_config={ + "title": "", + "instructions": "", + "implementation": "capability", + "capability": "", + "operation": "", + "pipeline_ref": "", + "input_mapping": {}, + "idempotency_key": "workflow-step", + "failure_policy": "manual", + "view_surface_ids": [], + }, + runtime_support="native", + ), + _bpmn_node( + type_id="bpmn.receiveTask", + category="bpmn_activity", + label="Receive task", + description="Wait until a matching message is received.", + icon="inbox", + shape="activity", + config_fields=( + *_TASK_FIELDS, + DefinitionConfigField(id="message_ref", label="Message reference", kind="text"), + FOCUSED_VIEW_SURFACES_FIELD, + ), + default_config={ + "title": "", + "instructions": "", + "message_ref": "", + "view_surface_ids": [], + }, + runtime_support="native", + ), + *( + _bpmn_node( + type_id=f"bpmn.{type_name}", + category="bpmn_activity", + label=label, + description=description, + icon=icon, + shape="activity", + config_fields=fields, + default_config=defaults, + ) + for type_name, label, description, icon, fields, defaults in ( + ( + "scriptTask", + "Script task", + "A BPMN script task retained as notation; arbitrary scripts are not executed.", + "file-code-2", + (*_TASK_FIELDS, DefinitionConfigField(id="script_format", label="Script format", kind="text"), DefinitionConfigField(id="script", label="Script", kind="textarea")), + {"title": "", "instructions": "", "script_format": "", "script": ""}, + ), + ( + "businessRuleTask", + "Business rule task", + "Evaluate a governed business-rule implementation.", + "scale", + (*_TASK_FIELDS, DefinitionConfigField(id="implementation_ref", label="Implementation reference", kind="text")), + {"title": "", "instructions": "", "implementation_ref": ""}, + ), + ( + "callActivity", + "Call activity", + "Call another reusable BPMN process or GovOPlaN workflow.", + "external-link", + (*_TASK_FIELDS, DefinitionConfigField(id="called_element", label="Called element", kind="text", required=True)), + {"title": "", "instructions": "", "called_element": ""}, + ), + ( + "subProcess", + "Sub-process", + "Contain a nested BPMN process.", + "box-select", + _TASK_FIELDS, + {"title": "", "instructions": ""}, + ), + ( + "transaction", + "Transaction", + "Contain transaction-scoped BPMN activity semantics.", + "badge-dollar-sign", + _TASK_FIELDS, + {"title": "", "instructions": ""}, + ), + ( + "adHocSubProcess", + "Ad-hoc sub-process", + "Contain activities whose order is governed at runtime.", + "shuffle", + _TASK_FIELDS, + {"title": "", "instructions": ""}, + ), + ) + ), + *( + _bpmn_node( + type_id=f"bpmn.{type_name}", + category="bpmn_gateway", + label=label, + description=description, + icon=icon, + shape="gateway", + config_fields=(), + default_config={}, + runtime_support=runtime_support, + ) + for type_name, label, description, icon, runtime_support in ( + ("exclusiveGateway", "Exclusive gateway", "Choose exactly one matching sequence flow.", "diamond", "native"), + ("parallelGateway", "Parallel gateway", "Split or join concurrent sequence flows.", "plus", "model_only"), + ("inclusiveGateway", "Inclusive gateway", "Choose one or more matching sequence flows.", "circle-plus", "model_only"), + ("eventBasedGateway", "Event-based gateway", "Choose a path according to the first caught event.", "radio-tower", "model_only"), + ("complexGateway", "Complex gateway", "Apply a complex activation condition.", "asterisk", "model_only"), + ) + ), + _bpmn_node( + type_id="bpmn.dataObjectReference", + category="bpmn_data", + label="Data object", + description="Reference data produced or consumed by an activity.", + icon="file", + shape="data-object", + input_ports=_OPTIONAL_INCOMING, + config_fields=( + DefinitionConfigField(id="data_object_ref", label="Data object reference", kind="text"), + DefinitionConfigField(id="item_subject_ref", label="Item definition", kind="text"), + ), + default_config={"data_object_ref": "", "item_subject_ref": ""}, + ), + _bpmn_node( + type_id="bpmn.dataStoreReference", + category="bpmn_data", + label="Data store", + description="Reference persistent data used by the process.", + icon="database", + shape="data-store", + input_ports=_OPTIONAL_INCOMING, + config_fields=( + DefinitionConfigField(id="data_store_ref", label="Data store reference", kind="text"), + DefinitionConfigField(id="item_subject_ref", label="Item definition", kind="text"), + ), + default_config={"data_store_ref": "", "item_subject_ref": ""}, + ), + _bpmn_node( + type_id="bpmn.participant", + category="bpmn_collaboration", + label="Participant / pool", + description="Represent a BPMN collaboration participant and its process.", + icon="rectangle-horizontal", + shape="participant", + input_ports=_OPTIONAL_INCOMING, + config_fields=( + DefinitionConfigField(id="process_ref", label="Process reference", kind="text"), + ), + default_config={"process_ref": ""}, + ), + _bpmn_node( + type_id="bpmn.lane", + category="bpmn_collaboration", + label="Lane", + description="Group flow nodes by role or responsibility.", + icon="rows-3", + shape="lane", + input_ports=_OPTIONAL_INCOMING, + config_fields=( + DefinitionConfigField(id="flow_node_refs", label="Flow node references", kind="string_list"), + ), + default_config={"flow_node_refs": []}, + ), + _bpmn_node( + type_id="bpmn.textAnnotation", + category="bpmn_artifact", + label="Text annotation", + description="Attach explanatory text without changing process execution.", + icon="text-quote", + shape="text-annotation", + input_ports=_OPTIONAL_INCOMING, + config_fields=( + DefinitionConfigField(id="text", label="Text", kind="textarea"), + DefinitionConfigField(id="text_format", label="Text format", kind="text"), + ), + default_config={"text": "", "text_format": "text/plain"}, + ), + _bpmn_node( + type_id="bpmn.group", + category="bpmn_artifact", + label="Group", + description="Visually group BPMN elements without changing execution.", + icon="box-select", + shape="group", + input_ports=_OPTIONAL_INCOMING, + config_fields=( + DefinitionConfigField(id="category_value_ref", label="Category value", kind="text"), + ), + default_config={"category_value_ref": ""}, + ), + *( + _bpmn_node( + type_id=f"bpmn.{type_name}", + category="bpmn_collaboration", + label=label, + description=description, + icon=icon, + shape=shape, + input_ports=ports, + config_fields=( + DefinitionConfigField( + id="participant_refs", + label="Participants", + kind="string_list", + ), + DefinitionConfigField( + id="called_element", + label="Called element", + kind="text", + ), + ), + default_config={"participant_refs": [], "called_element": ""}, + ) + for type_name, label, description, icon, shape, ports in ( + ( + "choreographyTask", + "Choreography task", + "Model a message exchange between two or more participants.", + "messages-square", + "choreography", + _INCOMING, + ), + ( + "callChoreography", + "Call choreography", + "Call a reusable choreography.", + "external-link", + "choreography", + _INCOMING, + ), + ( + "subChoreography", + "Sub-choreography", + "Contain a nested choreography.", + "box-select", + "choreography", + _INCOMING, + ), + ( + "conversation", + "Conversation", + "Group logically related message exchanges.", + "messages-square", + "conversation", + _OPTIONAL_INCOMING, + ), + ( + "callConversation", + "Call conversation", + "Call a reusable global conversation.", + "external-link", + "conversation", + _OPTIONAL_INCOMING, + ), + ( + "subConversation", + "Sub-conversation", + "Contain a nested conversation.", + "box-select", + "conversation", + _OPTIONAL_INCOMING, + ), + ) + ), +) + +WORKFLOW_NODE_TYPES = (*BPMN_NODE_TYPES, *LEGACY_WORKFLOW_NODE_TYPES) + WORKFLOW_GRAPH_LIBRARY = DefinitionGraphLibrary( id="workflow", - version="0.1.0", + version="1.0.0", category_labels=CATEGORY_LABELS, node_types=WORKFLOW_NODE_TYPES, constraints=DefinitionGraphConstraints( max_nodes=150, max_edges=300, allow_cycles=True, - require_connected=True, - node_counts=( - DefinitionNodeCountConstraint( - code="graph.trigger_count", - label="start", - minimum=1, - maximum=1, - categories=("trigger",), - ), - DefinitionNodeCountConstraint( - code="graph.outcome_count", - label="outcome", - minimum=1, - categories=("outcome",), - ), - ), + require_connected=False, ), ) @@ -445,7 +1141,9 @@ WORKFLOW_NODE_TYPES_BY_ID = { __all__ = [ + "BPMN_NODE_TYPES", "CATEGORY_LABELS", + "LEGACY_WORKFLOW_NODE_TYPES", "WORKFLOW_GRAPH_LIBRARY", "WORKFLOW_NODE_TYPES", "WORKFLOW_NODE_TYPES_BY_ID", diff --git a/src/govoplan_workflow/backend/router.py b/src/govoplan_workflow/backend/router.py index 44f941c..bc4ae14 100644 --- a/src/govoplan_workflow/backend/router.py +++ b/src/govoplan_workflow/backend/router.py @@ -28,19 +28,41 @@ from govoplan_workflow.backend.manifest import ( INSTANCE_START_SCOPE, INSTANCE_TRANSITION_SCOPE, ) -from govoplan_workflow.backend.node_library import WORKFLOW_GRAPH_LIBRARY +from govoplan_workflow.backend.node_library import ( + BPMN_NODE_TYPES, + WORKFLOW_GRAPH_LIBRARY, +) from govoplan_workflow.backend.bpmn import ( BPMN_MODEL_NAMESPACE, + BpmnDiagnostic, BpmnInspectionError, NATIVE_EXECUTION_ELEMENTS, NATIVE_MAPPING_ELEMENTS, inspect_bpmn_xml, ) +from govoplan_workflow.backend.bpmn_adapters import ( + BpmnAdapterError, + bpmn_adapter_registry, + compile_bpmn_to_graph, +) +from govoplan_workflow.backend.bpmn_graph import ( + BpmnGraphError, + NATIVE_BPMN_ADAPTER_ID, + NATIVE_BPMN_ADAPTER_VERSION, + canonical_bpmn_graph, + export_bpmn_graph, +) from govoplan_workflow.backend.schemas import ( + BpmnAdapterProfileResponse, + BpmnCompileRequest, + BpmnCompileResponse, BpmnDiagnosticResponse, BpmnElementSupportResponse, BpmnInspectionRequest, BpmnInspectionResponse, + BpmnRevisionDocumentResponse, + BpmnRenderRequest, + BpmnRenderResponse, BpmnSupportProfileResponse, WorkflowConfigFieldResponse, WorkflowDefinitionActivateRequest, @@ -74,6 +96,7 @@ from govoplan_workflow.backend.instance_service import ( ) from govoplan_workflow.backend.runtime import get_registry from govoplan_workflow.backend.service import ( + WorkflowBpmnValidationError, WorkflowConflictError, WorkflowError, WorkflowNotFoundError, @@ -89,6 +112,7 @@ from govoplan_workflow.backend.service import ( list_definition_revisions, list_definitions, revision_response, + revision_bpmn_summary, update_definition, ) from govoplan_workflow.backend.validation import validate_workflow_graph @@ -111,6 +135,22 @@ def _http_error(exc: WorkflowError) -> HTTPException: return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) if isinstance(exc, WorkflowConflictError): return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) + if isinstance(exc, WorkflowBpmnValidationError): + return HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail={ + "message": str(exc), + "diagnostics": [ + { + "severity": item.severity, + "code": item.code, + "message": item.message, + "element_id": item.element_id, + } + for item in exc.diagnostics + ], + }, + ) if isinstance(exc, WorkflowValidationError): return HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, @@ -216,52 +256,51 @@ def _require_instance_view(instance, principal: ApiPrincipal) -> None: ) -@router.get("/bpmn/profile", response_model=BpmnSupportProfileResponse) -def api_bpmn_support_profile( - principal: ApiPrincipal = Depends(get_api_principal), -) -> BpmnSupportProfileResponse: - _require_any_scope( - principal, - DEFINITION_READ_SCOPE, - DEFINITION_WRITE_SCOPE, - ADMIN_SCOPE, - ) - return BpmnSupportProfileResponse( - specification="BPMN 2.0.2", - model_namespace=BPMN_MODEL_NAMESPACE, - interchange=( - "Secure XML inventory and diagnostics are available. Visual " - "round-trip modeling requires the planned bpmn-js adapter." - ), - native_runtime=( - "Only the explicitly listed executable subset maps to current " - "native runtime semantics; all other elements are interchange-only." - ), - native_execution_elements=sorted(NATIVE_EXECUTION_ELEMENTS), - native_mapping_elements=sorted( - NATIVE_MAPPING_ELEMENTS - NATIVE_EXECUTION_ELEMENTS - ), +def _adapter_profile_response(profile) -> BpmnAdapterProfileResponse: + return BpmnAdapterProfileResponse( + id=profile.id, + version=profile.version, + label=profile.label, + description=profile.description, + conformance=profile.conformance, + runtime_kind=profile.runtime_kind, + executable=profile.executable, + supported_elements=list(profile.supported_elements), + supported_event_definitions=list(profile.supported_event_definitions), + requirements=list(profile.requirements), ) -@router.post("/bpmn/inspect", response_model=BpmnInspectionResponse) -def api_inspect_bpmn( - payload: BpmnInspectionRequest, - principal: ApiPrincipal = Depends(get_api_principal), +def _bpmn_inspection_response( + xml: str, + *, + adapter_id: str, + adapter_version: str | None = None, + activation: bool, ) -> BpmnInspectionResponse: - _require_any_scope( - principal, - DEFINITION_READ_SCOPE, - DEFINITION_WRITE_SCOPE, - ADMIN_SCOPE, + result = inspect_bpmn_xml(xml) + adapter = bpmn_adapter_registry().resolve(adapter_id, adapter_version) + adapter_diagnostics = ( + adapter.diagnostics(xml, result, activation=activation) + if adapter is not None + else () ) - try: - result = inspect_bpmn_xml(payload.xml) - except BpmnInspectionError as exc: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail=str(exc), - ) from exc + diagnostics = list(result.diagnostics) + diagnostics.extend(adapter_diagnostics) + if adapter is None: + suffix = f"@{adapter_version}" if adapter_version else "" + diagnostics.append( + BpmnDiagnostic( + severity="error", + code="adapter.unavailable", + message=f"BPMN execution adapter {adapter_id}{suffix} is not installed.", + ) + ) + deduplicated = { + (item.code, item.element_id, item.message): item + for item in diagnostics + }.values() + diagnostic_items = list(deduplicated) return BpmnInspectionResponse( valid_xml=result.valid_xml, definitions_id=result.definitions_id, @@ -290,11 +329,167 @@ def api_inspect_bpmn( message=item.message, element_id=item.element_id, ) - for item in result.diagnostics + for item in diagnostic_items + ], + adapter_id=adapter.profile.id if adapter else adapter_id, + adapter_version=( + adapter.profile.version if adapter else adapter_version + ), + runtime_kind=adapter.profile.runtime_kind if adapter else None, + executable=bool(adapter and adapter.profile.executable), + activatable=bool( + activation + and adapter + and adapter.profile.executable + and not any(item.severity == "error" for item in diagnostic_items) + ), + ) + + +@router.get("/bpmn/profile", response_model=BpmnSupportProfileResponse) +def api_bpmn_support_profile( + principal: ApiPrincipal = Depends(get_api_principal), +) -> BpmnSupportProfileResponse: + _require_any_scope( + principal, + DEFINITION_READ_SCOPE, + DEFINITION_WRITE_SCOPE, + ADMIN_SCOPE, + ) + return BpmnSupportProfileResponse( + specification="BPMN 2.0.2", + model_namespace=BPMN_MODEL_NAMESPACE, + interchange=( + "BPMN 2.0 is the native Workflow graph language. XML import and " + "export preserve standard notation and diagram geometry without " + "a browser-side BPMN modeler." + ), + native_runtime=( + "Activation is fail-closed and requires a pinned adapter whose " + "declared conformance profile covers the complete document." + ), + native_execution_elements=sorted(NATIVE_EXECUTION_ELEMENTS), + native_mapping_elements=sorted( + NATIVE_MAPPING_ELEMENTS - NATIVE_EXECUTION_ELEMENTS + ), + adapters=[ + _adapter_profile_response(profile) + for profile in bpmn_adapter_registry().profiles() ], ) +@router.post("/bpmn/inspect", response_model=BpmnInspectionResponse) +def api_inspect_bpmn( + payload: BpmnInspectionRequest, + principal: ApiPrincipal = Depends(get_api_principal), +) -> BpmnInspectionResponse: + _require_any_scope( + principal, + DEFINITION_READ_SCOPE, + DEFINITION_WRITE_SCOPE, + ADMIN_SCOPE, + ) + try: + return _bpmn_inspection_response( + payload.xml, + adapter_id=payload.adapter_id, + adapter_version=payload.adapter_version, + activation=payload.activation, + ) + except (BpmnAdapterError, BpmnInspectionError) as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc + + +@router.post("/bpmn/compile", response_model=BpmnCompileResponse) +def api_compile_bpmn( + payload: BpmnCompileRequest, + principal: ApiPrincipal = Depends(get_api_principal), +) -> BpmnCompileResponse: + _require_any_scope( + principal, + DEFINITION_WRITE_SCOPE, + ADMIN_SCOPE, + ) + try: + adapter, _inspection, graph = compile_bpmn_to_graph( + payload.xml, + adapter_id=payload.adapter_id, + adapter_version=payload.adapter_version, + ) + if graph is None: + raise BpmnAdapterError( + ( + BpmnDiagnostic( + severity="error", + code="adapter.no_runtime_materialization", + message="The selected adapter does not compile to a native graph.", + ), + ) + ) + inspection_response = _bpmn_inspection_response( + payload.xml, + adapter_id=adapter.profile.id, + adapter_version=adapter.profile.version, + activation=False, + ) + except (BpmnAdapterError, BpmnInspectionError) as exc: + diagnostics = getattr(exc, "diagnostics", ()) + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail={ + "message": str(exc), + "diagnostics": [ + { + "severity": item.severity, + "code": item.code, + "message": item.message, + "element_id": item.element_id, + } + for item in diagnostics + ], + }, + ) from exc + return BpmnCompileResponse( + adapter=_adapter_profile_response(adapter.profile), + graph=graph, + inspection=inspection_response, + ) + + +@router.post("/bpmn/render", response_model=BpmnRenderResponse) +def api_render_bpmn( + payload: BpmnRenderRequest, + principal: ApiPrincipal = Depends(get_api_principal), +) -> BpmnRenderResponse: + _require_any_scope( + principal, + DEFINITION_READ_SCOPE, + DEFINITION_WRITE_SCOPE, + ADMIN_SCOPE, + ) + try: + xml = export_bpmn_graph( + canonical_bpmn_graph(payload.graph), + name=payload.name, + ) + inspection = _bpmn_inspection_response( + xml, + adapter_id=NATIVE_BPMN_ADAPTER_ID, + adapter_version=NATIVE_BPMN_ADAPTER_VERSION, + activation=False, + ) + except (BpmnGraphError, BpmnInspectionError) as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc + return BpmnRenderResponse(xml=xml, inspection=inspection) + + @router.get("/node-types", response_model=WorkflowNodeLibraryResponse) def api_node_types( principal: ApiPrincipal = Depends(get_api_principal), @@ -349,8 +544,9 @@ def api_node_types( for field in definition.config_fields ], default_config=dict(definition.default_config), + metadata=dict(definition.metadata), ) - for definition in WORKFLOW_GRAPH_LIBRARY.node_types + for definition in BPMN_NODE_TYPES ], ) @@ -515,6 +711,9 @@ def api_start_instance( principal=principal, registry=get_registry(), payload=payload, + start_origin=( + "user" if principal.auth_method == "session" else "api" + ), ) except WorkflowError as exc: raise _http_error(exc) from exc @@ -531,6 +730,7 @@ def api_start_instance( "definition_id": instance.definition_id, "definition_revision_id": instance.definition_revision_id, "idempotency_key": instance.idempotency_key, + "start_origin": instance.start_origin, }, ) response = instance_response(session, instance, replayed=replayed) @@ -989,6 +1189,65 @@ def api_get_definition_revision( return revision_response(item) +@router.get( + "/definitions/{definition_id}/revisions/{revision}/bpmn", + response_model=BpmnRevisionDocumentResponse, +) +def api_get_definition_revision_bpmn( + definition_id: str, + revision: int, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> BpmnRevisionDocumentResponse: + _require_any_scope(principal, DEFINITION_READ_SCOPE, ADMIN_SCOPE) + try: + definition = get_definition( + session, + tenant_id=principal.tenant_id, + definition_id=definition_id, + ) + require_definition_action( + definition, + principal=principal, + registry=get_registry(), + action="view", + ) + item = get_definition_revision( + session, + definition=definition, + revision=revision, + ) + except PermissionError as exc: + raise _governance_http_error(exc) from exc + except WorkflowError as exc: + raise _http_error(exc) from exc + summary = revision_bpmn_summary(item) + if summary is None or not item.bpmn_xml: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="This Workflow revision has no BPMN document.", + ) + try: + inspection = _bpmn_inspection_response( + item.bpmn_xml, + adapter_id=summary.adapter_id, + adapter_version=summary.adapter_version, + activation=True, + ) + except BpmnInspectionError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc + return BpmnRevisionDocumentResponse( + **summary.model_dump(), + definition_id=definition.id, + revision=item.revision, + xml=item.bpmn_xml, + inspection=inspection, + ) + + @router.post( "/definitions/{definition_id}/activate", response_model=WorkflowDefinitionResponse, diff --git a/src/govoplan_workflow/backend/schemas.py b/src/govoplan_workflow/backend/schemas.py index c693d25..ac4c492 100644 --- a/src/govoplan_workflow/backend/schemas.py +++ b/src/govoplan_workflow/backend/schemas.py @@ -4,12 +4,25 @@ import math from datetime import datetime from typing import Any, Literal -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, field_validator, model_validator WorkflowDefinitionStatus = Literal["draft", "active", "archived"] DefinitionScopeType = Literal["system", "tenant", "group", "user"] DefinitionKind = Literal["flow", "template"] +BpmnRuntimeKind = Literal["model_only", "native_graph", "external"] +WorkflowExecutionMode = Literal["guided", "automated", "hybrid"] +WorkflowStartOrigin = Literal[ + "user", + "api", + "schedule", + "event", + "parent_workflow", + "dependency", + "retry", + "replay", + "backfill", +] BpmnSupportLevel = Literal[ "interchange_only", "native_mapping", @@ -29,26 +42,58 @@ class WorkflowPosition(BaseModel): return value +class WorkflowSize(BaseModel): + width: float = Field(default=100, gt=0, le=10_000) + height: float = Field(default=80, gt=0, le=10_000) + + class WorkflowNode(BaseModel): id: str = Field(min_length=1, max_length=120) type: str = Field(min_length=1, max_length=120) label: str = Field(default="", max_length=300) position: WorkflowPosition = Field(default_factory=WorkflowPosition) + size: WorkflowSize | None = None + parent_id: str | None = Field(default=None, max_length=120) + process_id: str | None = Field(default=None, max_length=120) config: dict[str, Any] = Field(default_factory=dict) +class WorkflowWaypoint(BaseModel): + x: float + y: float + + @field_validator("x", "y") + @classmethod + def finite_coordinate(cls, value: float) -> float: + if not math.isfinite(value): + raise ValueError("Edge coordinates must be finite.") + return value + + class WorkflowEdge(BaseModel): id: str = Field(min_length=1, max_length=120) + type: Literal[ + "bpmn.sequenceFlow", + "bpmn.messageFlow", + "bpmn.association", + "bpmn.dataInputAssociation", + "bpmn.dataOutputAssociation", + "bpmn.conversationLink", + ] = "bpmn.sequenceFlow" + label: str = Field(default="", max_length=300) source: str = Field(min_length=1, max_length=120) target: str = Field(min_length=1, max_length=120) source_port: str = Field(default="output", min_length=1, max_length=120) target_port: str = Field(default="input", min_length=1, max_length=120) + config: dict[str, Any] = Field(default_factory=dict) + waypoints: list[WorkflowWaypoint] = Field(default_factory=list, max_length=500) class WorkflowGraph(BaseModel): schema_version: Literal[1] = 1 nodes: list[WorkflowNode] = Field(default_factory=list, max_length=150) edges: list[WorkflowEdge] = Field(default_factory=list, max_length=300) + metadata: dict[str, Any] = Field(default_factory=dict) class WorkflowGraphValidationRequest(BaseModel): @@ -96,6 +141,7 @@ class WorkflowNodeTypeResponse(BaseModel): output_ports: list[WorkflowPortResponse] config_fields: list[WorkflowConfigFieldResponse] default_config: dict[str, Any] + metadata: dict[str, Any] = Field(default_factory=dict) class WorkflowNodeLibraryResponse(BaseModel): @@ -107,6 +153,13 @@ class WorkflowNodeLibraryResponse(BaseModel): class BpmnInspectionRequest(BaseModel): xml: str = Field(min_length=1, max_length=1_048_576) + adapter_id: str = Field( + default="govoplan.native.bpmn", + min_length=1, + max_length=120, + ) + adapter_version: str | None = Field(default=None, max_length=40) + activation: bool = False class BpmnElementSupportResponse(BaseModel): @@ -137,6 +190,24 @@ class BpmnInspectionResponse(BaseModel): support_counts: dict[str, int] elements: list[BpmnElementSupportResponse] diagnostics: list[BpmnDiagnosticResponse] + adapter_id: str | None = None + adapter_version: str | None = None + runtime_kind: BpmnRuntimeKind | None = None + executable: bool = False + activatable: bool = False + + +class BpmnAdapterProfileResponse(BaseModel): + id: str + version: str + label: str + description: str + conformance: str + runtime_kind: BpmnRuntimeKind + executable: bool + supported_elements: list[str] + supported_event_definitions: list[str] + requirements: list[str] class BpmnSupportProfileResponse(BaseModel): @@ -146,6 +217,60 @@ class BpmnSupportProfileResponse(BaseModel): native_runtime: str native_execution_elements: list[str] native_mapping_elements: list[str] + adapters: list[BpmnAdapterProfileResponse] = Field(default_factory=list) + + +class BpmnRevisionInput(BaseModel): + xml: str = Field(min_length=1, max_length=1_048_576) + adapter_id: str = Field( + default="govoplan.native.bpmn", + min_length=1, + max_length=120, + ) + adapter_version: str | None = Field(default=None, max_length=40) + + +class BpmnRevisionSummaryResponse(BaseModel): + format: Literal["bpmn-2.0"] = "bpmn-2.0" + content_hash: str + adapter_id: str + adapter_version: str + runtime_kind: BpmnRuntimeKind + executable: bool + adapter_available: bool + + +class BpmnRevisionDocumentResponse(BpmnRevisionSummaryResponse): + definition_id: str + revision: int + xml: str + inspection: BpmnInspectionResponse + + +class BpmnCompileRequest(BaseModel): + xml: str = Field(min_length=1, max_length=1_048_576) + adapter_id: str = Field( + default="govoplan.native.bpmn", + min_length=1, + max_length=120, + ) + adapter_version: str | None = Field(default=None, max_length=40) + + +class BpmnCompileResponse(BaseModel): + adapter: BpmnAdapterProfileResponse + graph: WorkflowGraph + inspection: BpmnInspectionResponse + + +class BpmnRenderRequest(BaseModel): + graph: WorkflowGraph + name: str = Field(default="", max_length=300) + + +class BpmnRenderResponse(BaseModel): + xml: str + inspection: BpmnInspectionResponse class WorkflowDefinitionRevisionResponse(BaseModel): @@ -156,6 +281,10 @@ class WorkflowDefinitionRevisionResponse(BaseModel): content_hash: str library_id: str library_version: str + execution_mode: WorkflowExecutionMode + view_id: str | None = None + view_revision_id: str | None = None + bpmn: BpmnRevisionSummaryResponse | None = None created_by: str | None created_at: datetime @@ -221,6 +350,7 @@ class WorkflowDefinitionCreateRequest(BaseModel): name: str = Field(min_length=1, max_length=300) description: str | None = Field(default=None, max_length=4_000) graph: WorkflowGraph + bpmn: BpmnRevisionInput | None = None metadata: dict[str, Any] = Field(default_factory=dict) scope_type: DefinitionScopeType = "tenant" scope_id: str | None = Field(default=None, max_length=36) @@ -229,12 +359,26 @@ class WorkflowDefinitionCreateRequest(BaseModel): allow_start: bool = True allow_reuse: bool = False allow_automation: bool = False + execution_mode: WorkflowExecutionMode = "hybrid" + view_id: str | None = Field(default=None, min_length=1, max_length=36) + view_revision_id: str | None = Field( + default=None, + min_length=1, + max_length=36, + ) + + @model_validator(mode="after") + def validate_view_pin(self): + if self.view_revision_id and not self.view_id: + raise ValueError("A pinned View revision requires a View") + return self class WorkflowDefinitionUpdateRequest(BaseModel): name: str = Field(min_length=1, max_length=300) description: str | None = Field(default=None, max_length=4_000) graph: WorkflowGraph + bpmn: BpmnRevisionInput | None = None metadata: dict[str, Any] = Field(default_factory=dict) expected_revision: int = Field(ge=1) scope_type: DefinitionScopeType = "tenant" @@ -244,6 +388,19 @@ class WorkflowDefinitionUpdateRequest(BaseModel): allow_start: bool = True allow_reuse: bool = False allow_automation: bool = False + execution_mode: WorkflowExecutionMode = "hybrid" + view_id: str | None = Field(default=None, min_length=1, max_length=36) + view_revision_id: str | None = Field( + default=None, + min_length=1, + max_length=36, + ) + + @model_validator(mode="after") + def validate_view_pin(self): + if self.view_revision_id and not self.view_id: + raise ValueError("A pinned View revision requires a View") + return self class WorkflowDefinitionDeriveRequest(BaseModel): @@ -264,6 +421,19 @@ class WorkflowDefinitionDeriveRequest(BaseModel): allow_start: bool = True allow_reuse: bool = False allow_automation: bool = False + execution_mode: WorkflowExecutionMode | None = None + view_id: str | None = Field(default=None, min_length=1, max_length=36) + view_revision_id: str | None = Field( + default=None, + min_length=1, + max_length=36, + ) + + @model_validator(mode="after") + def validate_view_pin(self): + if self.view_revision_id and not self.view_id: + raise ValueError("A pinned View revision requires a View") + return self class WorkflowDefinitionActivateRequest(BaseModel): @@ -332,6 +502,14 @@ class WorkflowInstanceStepResponse(BaseModel): updated_at: datetime +class WorkflowViewContextResponse(BaseModel): + view_id: str + revision_id: str | None = None + visible_surface_ids: list[str] = Field(default_factory=list) + step_id: str | None = None + node_id: str | None = None + + class WorkflowInstanceEventResponse(BaseModel): id: str sequence: int @@ -348,6 +526,9 @@ class WorkflowInstanceResponse(BaseModel): definition_name: str definition_revision: int definition_hash: str + execution_mode: WorkflowExecutionMode + start_origin: WorkflowStartOrigin + view_context: WorkflowViewContextResponse | None = None status: WorkflowInstanceStatus idempotency_key: str correlation_id: str | None diff --git a/src/govoplan_workflow/backend/service.py b/src/govoplan_workflow/backend/service.py index 0f4660d..75d1216 100644 --- a/src/govoplan_workflow/backend/service.py +++ b/src/govoplan_workflow/backend/service.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Mapping +from dataclasses import dataclass import hashlib import json import re @@ -16,11 +17,31 @@ from govoplan_workflow.backend.db.models import ( WorkflowDefinitionRevision, ) from govoplan_workflow.backend.node_library import WORKFLOW_GRAPH_LIBRARY +from govoplan_workflow.backend.bpmn import ( + BpmnDiagnostic, + BpmnInspectionError, +) +from govoplan_workflow.backend.bpmn_adapters import ( + RuntimeKind, + bpmn_adapter_registry, +) +from govoplan_workflow.backend.bpmn_graph import ( + BpmnGraphError, + NATIVE_BPMN_ADAPTER_ID, + NATIVE_BPMN_ADAPTER_VERSION, + canonical_bpmn_graph, + export_bpmn_graph, + import_bpmn_graph, + materialize_runtime_graph, + runtime_diagnostics, +) from govoplan_workflow.backend.governance import ( definition_governance_payload, require_definition_action, ) from govoplan_workflow.backend.schemas import ( + BpmnRevisionInput, + BpmnRevisionSummaryResponse, WorkflowDefinitionCreateRequest, WorkflowDefinitionDeriveRequest, WorkflowDefinitionResponse, @@ -52,6 +73,28 @@ class WorkflowValidationError(WorkflowError): self.diagnostics = diagnostics +class WorkflowBpmnValidationError(WorkflowError): + def __init__(self, diagnostics: tuple[BpmnDiagnostic, ...]) -> None: + first = next( + (item for item in diagnostics if item.severity == "error"), + diagnostics[0] if diagnostics else None, + ) + super().__init__( + first.message if first is not None else "BPMN validation failed." + ) + self.diagnostics = diagnostics + + +@dataclass(frozen=True, slots=True) +class _BpmnArtifact: + xml: str + content_hash: str + adapter_id: str + adapter_version: str + runtime_kind: RuntimeKind + executable: bool + + def list_definitions( session: Session, *, @@ -139,7 +182,10 @@ def create_definition( actor_id: str | None, payload: WorkflowDefinitionCreateRequest, ) -> WorkflowDefinition: - graph = _validated_graph(payload.graph) + graph, bpmn = _prepare_revision_content( + graph=payload.graph, + bpmn=payload.bpmn, + ) stored_tenant_id = ( None if payload.scope_type == "system" else tenant_id ) @@ -185,6 +231,10 @@ def create_definition( tenant_id=stored_tenant_id, revision=1, graph=graph, + bpmn=bpmn, + execution_mode=payload.execution_mode, + view_id=payload.view_id, + view_revision_id=payload.view_revision_id, actor_id=actor_id, ) ) @@ -227,9 +277,11 @@ def update_definition( raise WorkflowConflictError( "Definition kind is immutable; derive a flow or template instead." ) - graph = _validated_graph(payload.graph) current = get_definition_revision(session, definition=definition) - graph_hash = _content_hash(graph) + graph, bpmn = _prepare_revision_content( + graph=payload.graph, + bpmn=payload.bpmn, + ) definition.name = payload.name.strip() definition.description = _clean_optional(payload.description) definition.metadata_ = dict(payload.metadata) @@ -250,13 +302,24 @@ def update_definition( payload.allow_automation and ancestor_limits["allow_automation"] ) definition.updated_by = actor_id - if current.content_hash != graph_hash: + if not _revision_matches( + current, + graph=graph, + bpmn=bpmn, + execution_mode=payload.execution_mode, + view_id=payload.view_id, + view_revision_id=payload.view_revision_id, + ): definition.current_revision += 1 definition.revisions.append( _new_revision( tenant_id=definition.tenant_id, revision=definition.current_revision, graph=graph, + bpmn=bpmn, + execution_mode=payload.execution_mode, + view_id=payload.view_id, + view_revision_id=payload.view_revision_id, actor_id=actor_id, ) ) @@ -341,6 +404,30 @@ def derive_definition( "derived_by": actor_id, "derived_at": utcnow().isoformat(), } + execution_mode = ( + payload.execution_mode + if "execution_mode" in payload.model_fields_set + else source_revision.execution_mode + ) + view_id = ( + payload.view_id + if "view_id" in payload.model_fields_set + else source_revision.view_id + ) + view_revision_id = ( + payload.view_revision_id + if ( + "view_revision_id" in payload.model_fields_set + or "view_id" in payload.model_fields_set + ) + else source_revision.view_revision_id + ) + if view_id is None: + view_revision_id = None + source_graph, source_bpmn = _prepare_revision_content( + graph=WorkflowGraph.model_validate(source_revision.graph), + bpmn=None, + ) definition = WorkflowDefinition( tenant_id=stored_tenant_id, scope_type=payload.scope_type, @@ -375,10 +462,25 @@ def derive_definition( tenant_id=stored_tenant_id, revision=1, schema_version=source_revision.schema_version, - graph=dict(source_revision.graph), - content_hash=source_revision.content_hash, + graph=_canonical_graph(source_graph), + content_hash=_content_hash( + source_graph, + bpmn=source_bpmn, + execution_mode=execution_mode, + view_id=view_id, + view_revision_id=view_revision_id, + ), library_id=source_revision.library_id, library_version=source_revision.library_version, + execution_mode=execution_mode, + view_id=view_id, + view_revision_id=view_revision_id, + bpmn_xml=source_bpmn.xml, + bpmn_hash=source_bpmn.content_hash, + bpmn_adapter_id=source_bpmn.adapter_id, + bpmn_adapter_version=source_bpmn.adapter_version, + bpmn_runtime_kind=source_bpmn.runtime_kind, + bpmn_executable=source_bpmn.executable, created_by=actor_id, ) ) @@ -410,6 +512,8 @@ def activate_definition( "Workflow templates cannot be activated or started." ) _validated_graph(WorkflowGraph.model_validate(selected.graph)) + _validate_bpmn_activation(selected) + _validate_execution_mode_activation(selected) definition.active_revision = selected.revision definition.status = "active" definition.updated_by = actor_id @@ -496,10 +600,16 @@ def revision_response( id=revision.id, revision=revision.revision, schema_version=revision.schema_version, - graph=WorkflowGraph.model_validate(revision.graph), + graph=canonical_bpmn_graph( + WorkflowGraph.model_validate(revision.graph) + ), content_hash=revision.content_hash, library_id=revision.library_id, library_version=revision.library_version, + execution_mode=revision.execution_mode, + view_id=revision.view_id, + view_revision_id=revision.view_revision_id, + bpmn=revision_bpmn_summary(revision), created_by=revision.created_by, created_at=revision.created_at, ) @@ -510,6 +620,10 @@ def _new_revision( tenant_id: str | None, revision: int, graph: WorkflowGraph, + bpmn: _BpmnArtifact | None, + execution_mode: str, + view_id: str | None, + view_revision_id: str | None, actor_id: str | None, ) -> WorkflowDefinitionRevision: return WorkflowDefinitionRevision( @@ -517,14 +631,132 @@ def _new_revision( revision=revision, schema_version=graph.schema_version, graph=_canonical_graph(graph), - content_hash=_content_hash(graph), + content_hash=_content_hash( + graph, + bpmn=bpmn, + execution_mode=execution_mode, + view_id=view_id, + view_revision_id=view_revision_id, + ), library_id=WORKFLOW_GRAPH_LIBRARY.id, library_version=WORKFLOW_GRAPH_LIBRARY.version, + execution_mode=execution_mode, + view_id=view_id, + view_revision_id=view_revision_id, + bpmn_xml=bpmn.xml if bpmn else None, + bpmn_hash=bpmn.content_hash if bpmn else None, + bpmn_adapter_id=bpmn.adapter_id if bpmn else None, + bpmn_adapter_version=bpmn.adapter_version if bpmn else None, + bpmn_runtime_kind=bpmn.runtime_kind if bpmn else None, + bpmn_executable=bpmn.executable if bpmn else None, created_by=actor_id, ) +def revision_bpmn_summary( + revision: WorkflowDefinitionRevision, +) -> BpmnRevisionSummaryResponse | None: + if not revision.bpmn_xml: + return None + adapter_id = revision.bpmn_adapter_id or "bpmn.interchange" + adapter_version = revision.bpmn_adapter_version or "1.0.0" + adapter = bpmn_adapter_registry().resolve(adapter_id, adapter_version) + runtime_kind: RuntimeKind = "model_only" + if revision.bpmn_runtime_kind in {"model_only", "native_graph", "external"}: + runtime_kind = revision.bpmn_runtime_kind + return BpmnRevisionSummaryResponse( + content_hash=revision.bpmn_hash + or hashlib.sha256(revision.bpmn_xml.encode("utf-8")).hexdigest(), + adapter_id=adapter_id, + adapter_version=adapter_version, + runtime_kind=runtime_kind, + executable=bool(revision.bpmn_executable), + adapter_available=adapter is not None, + ) + + +def _prepare_revision_content( + *, + graph: WorkflowGraph, + bpmn: BpmnRevisionInput | None, +) -> tuple[WorkflowGraph, _BpmnArtifact | None]: + try: + effective_graph = ( + import_bpmn_graph(bpmn.xml) + if bpmn is not None + else canonical_bpmn_graph(graph) + ) + except WorkflowBpmnValidationError: + raise + except (BpmnGraphError, BpmnInspectionError) as exc: + diagnostics = getattr(exc, "diagnostics", None) + raise WorkflowBpmnValidationError( + tuple(diagnostics) + if diagnostics + else ( + BpmnDiagnostic( + severity="error", + code="bpmn.invalid", + message=str(exc), + ), + ) + ) from exc + effective_graph = _validated_graph(effective_graph) + xml = export_bpmn_graph(effective_graph) + executable = not any( + item.severity == "error" + for item in runtime_diagnostics(effective_graph) + ) + if executable: + try: + runtime_graph = materialize_runtime_graph(effective_graph) + except BpmnGraphError: + executable = False + else: + executable = not any( + item.severity == "error" + for item in validate_workflow_graph(runtime_graph) + ) + return effective_graph, _BpmnArtifact( + xml=xml, + content_hash=hashlib.sha256(xml.encode("utf-8")).hexdigest(), + adapter_id=NATIVE_BPMN_ADAPTER_ID, + adapter_version=NATIVE_BPMN_ADAPTER_VERSION, + runtime_kind="native_graph", + executable=executable, + ) + + +def _validate_bpmn_activation( + revision: WorkflowDefinitionRevision, +) -> None: + try: + runtime_graph = materialize_runtime_graph( + WorkflowGraph.model_validate(revision.graph) + ) + except BpmnGraphError as exc: + diagnostics = getattr(exc, "diagnostics", None) + raise WorkflowBpmnValidationError( + tuple(diagnostics) + if diagnostics + else ( + BpmnDiagnostic( + severity="error", + code="bpmn.activation_invalid", + message=str(exc), + ), + ) + ) from exc + runtime_validation = validate_workflow_graph(runtime_graph) + if any(item.severity == "error" for item in runtime_validation): + raise WorkflowValidationError(runtime_validation) + + def _validated_graph(graph: WorkflowGraph) -> WorkflowGraph: + try: + graph = canonical_bpmn_graph(graph) + except BpmnGraphError as exc: + raise WorkflowBpmnValidationError(exc.diagnostics) from exc diagnostics = validate_workflow_graph(graph) if any(item.severity == "error" for item in diagnostics): raise WorkflowValidationError(diagnostics) @@ -535,15 +767,93 @@ def _canonical_graph(graph: WorkflowGraph) -> dict[str, object]: return graph.model_dump(mode="json") -def _content_hash(graph: WorkflowGraph) -> str: - encoded = json.dumps( - _canonical_graph(graph), - sort_keys=True, - separators=(",", ":"), - ) +def _content_hash( + graph: WorkflowGraph, + *, + bpmn: _BpmnArtifact | None = None, + execution_mode: str = "hybrid", + view_id: str | None = None, + view_revision_id: str | None = None, +) -> str: + content: dict[str, object] = { + "graph": _canonical_graph(graph), + "execution": { + "mode": execution_mode, + "view_id": view_id, + "view_revision_id": view_revision_id, + }, + } + if bpmn is not None: + content["bpmn"] = { + "xml": bpmn.xml, + "adapter_id": bpmn.adapter_id, + "adapter_version": bpmn.adapter_version, + } + encoded = json.dumps(content, sort_keys=True, separators=(",", ":")) return hashlib.sha256(encoded.encode("utf-8")).hexdigest() +def _revision_matches( + revision: WorkflowDefinitionRevision, + *, + graph: WorkflowGraph, + bpmn: _BpmnArtifact | None, + execution_mode: str, + view_id: str | None, + view_revision_id: str | None, +) -> bool: + return ( + revision.graph == _canonical_graph(graph) + and revision.execution_mode == execution_mode + and revision.view_id == view_id + and revision.view_revision_id == view_revision_id + and revision.bpmn_xml == (bpmn.xml if bpmn else None) + and revision.bpmn_adapter_id + == (bpmn.adapter_id if bpmn else None) + and revision.bpmn_adapter_version + == (bpmn.adapter_version if bpmn else None) + ) + + +def _validate_execution_mode_activation( + revision: WorkflowDefinitionRevision, +) -> None: + if revision.execution_mode != "automated": + return + try: + graph = materialize_runtime_graph( + WorkflowGraph.model_validate(revision.graph) + ) + except BpmnGraphError as exc: + raise WorkflowBpmnValidationError(exc.diagnostics) from exc + human_nodes = [ + node.id + for node in graph.nodes + if node.type in {"workflow.activity", "workflow.review"} + or ( + node.type == "workflow.wait" + and str(node.config.get("mode") or "manual") == "manual" + ) + or ( + node.type == "workflow.capability" + and str(node.config.get("failure_policy") or "manual") == "manual" + ) + or ( + node.type == "workflow.dataflow" + and ( + str(node.config.get("warning_policy") or "review") == "review" + or str(node.config.get("failure_policy") or "manual") + == "manual" + ) + ) + ] + if human_nodes: + raise WorkflowConflictError( + "Automated workflows cannot activate with human handoff paths: " + + ", ".join(sorted(human_nodes)) + ) + + def _available_key( session: Session, *, @@ -639,6 +949,7 @@ def _effective_governance_limits( __all__ = [ + "WorkflowBpmnValidationError", "WorkflowConflictError", "WorkflowError", "WorkflowNotFoundError", @@ -654,5 +965,6 @@ __all__ = [ "list_definition_revisions", "list_definitions", "revision_response", + "revision_bpmn_summary", "update_definition", ] diff --git a/src/govoplan_workflow/backend/validation.py b/src/govoplan_workflow/backend/validation.py index 93a97cc..d5a75d7 100644 --- a/src/govoplan_workflow/backend/validation.py +++ b/src/govoplan_workflow/backend/validation.py @@ -32,13 +32,15 @@ def validate_workflow_graph(graph: WorkflowGraph) -> tuple[DefinitionDiagnostic, ) outgoing = {node.id: 0 for node in graph.nodes} for edge in graph.edges: - if edge.source in outgoing: + if edge.type == "bpmn.sequenceFlow" and edge.source in outgoing: outgoing[edge.source] += 1 for node in graph.nodes: definition = WORKFLOW_NODE_TYPES_BY_ID.get(node.type) if definition is None: continue for field in definition.config_fields: + if node.type.startswith("bpmn."): + continue if field.required and _empty(node.config.get(field.id)): diagnostics.append( DefinitionDiagnostic( @@ -49,7 +51,7 @@ def validate_workflow_graph(graph: WorkflowGraph) -> tuple[DefinitionDiagnostic, field=field.id, ) ) - if definition.output_ports and outgoing[node.id] == 0: + if _requires_outgoing(node.type) and outgoing[node.id] == 0: diagnostics.append( DefinitionDiagnostic( severity="error", @@ -58,9 +60,187 @@ def validate_workflow_graph(graph: WorkflowGraph) -> tuple[DefinitionDiagnostic, node_id=node.id, ) ) + diagnostics.extend(_bpmn_semantic_diagnostics(graph)) + diagnostics.extend(_legacy_semantic_diagnostics(graph)) return _deduplicate(diagnostics) +def _requires_outgoing(node_type: str) -> bool: + if not node_type.startswith("bpmn."): + return bool( + WORKFLOW_NODE_TYPES_BY_ID.get(node_type) + and WORKFLOW_NODE_TYPES_BY_ID[node_type].output_ports + ) + return False + + +def _bpmn_semantic_diagnostics( + graph: WorkflowGraph, +) -> list[DefinitionDiagnostic]: + if not graph.nodes or not any( + node.type.startswith("bpmn.") for node in graph.nodes + ): + return [] + diagnostics: list[DefinitionDiagnostic] = [] + if any(not node.type.startswith("bpmn.") for node in graph.nodes): + diagnostics.append( + DefinitionDiagnostic( + severity="warning", + code="graph.mixed_notation", + message=( + "A Workflow revision cannot mix canonical BPMN and " + "legacy Workflow nodes." + ), + ) + ) + return diagnostics + flow_nodes = { + node.id + for node in graph.nodes + if node.type + not in { + "bpmn.participant", + "bpmn.lane", + "bpmn.dataObjectReference", + "bpmn.dataStoreReference", + "bpmn.textAnnotation", + "bpmn.group", + "bpmn.conversation", + "bpmn.callConversation", + "bpmn.subConversation", + } + } + starts = [ + node for node in graph.nodes if node.type == "bpmn.startEvent" + ] + ends = [node for node in graph.nodes if node.type == "bpmn.endEvent"] + if not starts: + diagnostics.append( + DefinitionDiagnostic( + severity="warning", + code="bpmn.start_event_missing", + message="A Workflow process needs at least one BPMN start event.", + ) + ) + if not ends: + diagnostics.append( + DefinitionDiagnostic( + severity="warning", + code="bpmn.end_event_missing", + message="A Workflow process needs at least one BPMN end event.", + ) + ) + node_by_id = {node.id: node for node in graph.nodes} + default_flows_by_source: dict[str, list[str]] = {} + for edge in graph.edges: + source = node_by_id.get(edge.source) + target = node_by_id.get(edge.target) + if source is None or target is None: + continue + if edge.type == "bpmn.sequenceFlow" and ( + edge.source not in flow_nodes or edge.target not in flow_nodes + ): + diagnostics.append( + DefinitionDiagnostic( + severity="error", + code="bpmn.sequence_flow_endpoint", + message=( + "BPMN sequence flows may only connect process flow " + "nodes. Use an association or data association here." + ), + node_id=edge.source, + ) + ) + if edge.type == "bpmn.messageFlow": + same_process = ( + source.process_id + and target.process_id + and source.process_id == target.process_id + ) + if same_process: + diagnostics.append( + DefinitionDiagnostic( + severity="error", + code="bpmn.message_flow_same_process", + message=( + "BPMN message flows connect different participants; " + "use a sequence flow inside one process." + ), + node_id=edge.source, + ) + ) + if edge.config.get("default") is True: + if edge.type != "bpmn.sequenceFlow": + diagnostics.append( + DefinitionDiagnostic( + severity="error", + code="bpmn.default_flow_type", + message="Only a BPMN sequence flow can be a default flow.", + node_id=edge.source, + ) + ) + else: + default_flows_by_source.setdefault(edge.source, []).append(edge.id) + for source_id, edge_ids in default_flows_by_source.items(): + if len(edge_ids) > 1: + diagnostics.append( + DefinitionDiagnostic( + severity="error", + code="bpmn.multiple_default_flows", + message="A BPMN flow node can have at most one default flow.", + node_id=source_id, + ) + ) + for node in graph.nodes: + if node.type != "bpmn.boundaryEvent": + continue + attached_to = str(node.config.get("attached_to_ref") or "") + if attached_to not in node_by_id: + diagnostics.append( + DefinitionDiagnostic( + severity="error", + code="bpmn.boundary_attachment", + message="A boundary event must reference an activity in this graph.", + node_id=node.id, + field="attached_to_ref", + ) + ) + return diagnostics + + +def _legacy_semantic_diagnostics( + graph: WorkflowGraph, +) -> list[DefinitionDiagnostic]: + if not graph.nodes or any( + node.type.startswith("bpmn.") for node in graph.nodes + ): + return [] + diagnostics: list[DefinitionDiagnostic] = [] + starts = [ + node for node in graph.nodes if node.type.startswith("workflow.start.") + ] + outcomes = [ + node for node in graph.nodes if node.type.startswith("workflow.end.") + ] + if len(starts) != 1: + diagnostics.append( + DefinitionDiagnostic( + severity="error", + code="graph.trigger_count", + message="A definition requires exactly one start node.", + ) + ) + if not outcomes: + diagnostics.append( + DefinitionDiagnostic( + severity="error", + code="graph.outcome_count", + message="A definition requires at least one outcome node.", + ) + ) + return diagnostics + + def _empty(value: object) -> bool: return value is None or value == "" or value == [] or value == {} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..c6e6ab3 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Workflow test package for both discovery and targeted module execution.""" diff --git a/tests/fixtures/bpmn/choreography.bpmn b/tests/fixtures/bpmn/choreography.bpmn new file mode 100644 index 0000000..b518b4c --- /dev/null +++ b/tests/fixtures/bpmn/choreography.bpmn @@ -0,0 +1,35 @@ + + + + + + + + + + + Participant_Authority + Participant_Applicant + MessageFlow_Approval + + + + + + + diff --git a/tests/fixtures/bpmn/collaboration.bpmn b/tests/fixtures/bpmn/collaboration.bpmn new file mode 100644 index 0000000..b33e5fb --- /dev/null +++ b/tests/fixtures/bpmn/collaboration.bpmn @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/bpmn/control-flow.bpmn b/tests/fixtures/bpmn/control-flow.bpmn new file mode 100644 index 0000000..2742616 --- /dev/null +++ b/tests/fixtures/bpmn/control-flow.bpmn @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/bpmn/data.bpmn b/tests/fixtures/bpmn/data.bpmn new file mode 100644 index 0000000..eca5fc1 --- /dev/null +++ b/tests/fixtures/bpmn/data.bpmn @@ -0,0 +1,27 @@ + + + + + + + + + result = input + + DataObjectReference_Request + Transform_Data + + + Transform_Data + DataStoreReference_Archive + + + + diff --git a/tests/fixtures/bpmn/events-transaction-compensation.bpmn b/tests/fixtures/bpmn/events-transaction-compensation.bpmn new file mode 100644 index 0000000..0dc3b19 --- /dev/null +++ b/tests/fixtures/bpmn/events-transaction-compensation.bpmn @@ -0,0 +1,42 @@ + + + + + + + + R3/PT1H + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/bpmn/process.bpmn b/tests/fixtures/bpmn/process.bpmn new file mode 100644 index 0000000..203f774 --- /dev/null +++ b/tests/fixtures/bpmn/process.bpmn @@ -0,0 +1,50 @@ + + + + + + Preserve this extension exactly. + + + + Flow_1 + + + Flow_1 + Flow_2 + + + Flow_2 + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_bpmn.py b/tests/test_bpmn.py index 7221bab..944b665 100644 --- a/tests/test_bpmn.py +++ b/tests/test_bpmn.py @@ -1,10 +1,26 @@ from __future__ import annotations +from collections import Counter import unittest +from pathlib import Path from govoplan_workflow.backend.bpmn import ( + BPMN_MODEL_NAMESPACE, BpmnInspectionError, inspect_bpmn_xml, + parse_bpmn_xml, +) +from govoplan_workflow.backend.bpmn_adapters import ( + BpmnAdapterError, + INTERCHANGE_ADAPTER_ID, + NATIVE_LINEAR_ADAPTER_ID, + bpmn_adapter_registry, + compile_bpmn_to_graph, +) +from govoplan_workflow.backend.bpmn_graph import ( + NATIVE_BPMN_ADAPTER_ID, + export_bpmn_graph, + import_bpmn_graph, ) @@ -28,8 +44,91 @@ BPMN = """ """ +NATIVE_BPMN = """ + + + + + + + + + + + + + + + + + + + + + + +""" + class BpmnInspectionTests(unittest.TestCase): + def test_notation_fixtures_are_safe_and_fully_inventoried(self) -> None: + fixture_directory = Path(__file__).parent / "fixtures" / "bpmn" + results = { + path.stem: inspect_bpmn_xml(path.read_text(encoding="utf-8")) + for path in sorted(fixture_directory.glob("*.bpmn")) + } + + self.assertEqual( + { + "choreography", + "collaboration", + "control-flow", + "data", + "events-transaction-compensation", + "process", + }, + set(results), + ) + self.assertEqual(1, results["choreography"].choreography_count) + self.assertEqual(1, results["collaboration"].collaboration_count) + self.assertEqual( + 1, + results["events-transaction-compensation"].element_counts[ + "transaction" + ], + ) + self.assertEqual( + 1, + results["data"].element_counts["dataStoreReference"], + ) + self.assertEqual( + 1, + results["control-flow"].element_counts["exclusiveGateway"], + ) + self.assertEqual( + 1, + results["control-flow"].element_counts["subProcess"], + ) + self.assertEqual( + 1, + results["control-flow"].element_counts["callActivity"], + ) + self.assertEqual( + 1, + results["control-flow"].element_counts[ + "compensateEventDefinition" + ], + ) + self.assertEqual( + 2, + results["control-flow"].element_counts["signalEventDefinition"], + ) + def test_inventory_classifies_native_and_interchange_elements(self) -> None: result = inspect_bpmn_xml(BPMN) @@ -47,7 +146,7 @@ class BpmnInspectionTests(unittest.TestCase): if item.element_id == "Collaboration_1" ) self.assertEqual("native_execution", review.support_level) - self.assertEqual("interchange_only", collaboration.support_level) + self.assertEqual("native_mapping", collaboration.support_level) def test_dangling_references_are_reported(self) -> None: result = inspect_bpmn_xml( @@ -80,6 +179,177 @@ class BpmnInspectionTests(unittest.TestCase): ): inspect_bpmn_xml("") + def test_profiles_are_versioned_and_native_compilation_is_stable(self) -> None: + profiles = { + item.id: item for item in bpmn_adapter_registry().profiles() + } + self.assertFalse(profiles[INTERCHANGE_ADAPTER_ID].executable) + self.assertTrue(profiles[NATIVE_LINEAR_ADAPTER_ID].executable) + self.assertTrue(profiles[NATIVE_BPMN_ADAPTER_ID].executable) + + adapter, inspection, graph = compile_bpmn_to_graph( + NATIVE_BPMN, + adapter_id=NATIVE_LINEAR_ADAPTER_ID, + ) + + self.assertEqual("1.0.0", adapter.profile.version) + self.assertTrue(inspection.valid_xml) + self.assertIsNotNone(graph) + assert graph is not None + self.assertEqual( + [ + "workflow.start.manual", + "workflow.activity", + "workflow.end.completed", + ], + [item.type for item in graph.nodes], + ) + self.assertEqual(220, graph.nodes[1].position.x) + self.assertEqual( + ["Flow_start_review", "Flow_review_end"], + [item.id for item in graph.edges], + ) + + def test_native_profile_rejects_semantics_it_cannot_execute(self) -> None: + with self.assertRaisesRegex( + BpmnAdapterError, + "exclusiveGateway is not supported", + ): + compile_bpmn_to_graph( + BPMN, + adapter_id=NATIVE_LINEAR_ADAPTER_ID, + ) + + def test_model_only_profile_remains_read_compatible(self) -> None: + _adapter, _inspection, graph = compile_bpmn_to_graph( + BPMN, + adapter_id=INTERCHANGE_ADAPTER_ID, + ) + + self.assertIsNone(graph) + + def test_native_bpmn_graph_imports_full_notation_and_round_trips(self) -> None: + graph = import_bpmn_graph(BPMN) + + self.assertEqual( + [ + "bpmn.startEvent", + "bpmn.userTask", + "bpmn.exclusiveGateway", + "bpmn.endEvent", + "bpmn.participant", + ], + [node.type for node in graph.nodes], + ) + self.assertTrue( + all(edge.type == "bpmn.sequenceFlow" for edge in graph.edges) + ) + rendered = export_bpmn_graph(graph, name="Round trip") + imported = import_bpmn_graph(rendered) + self.assertEqual( + [(node.id, node.type) for node in graph.nodes], + [(node.id, node.type) for node in imported.nodes], + ) + self.assertEqual( + [(edge.id, edge.type, edge.source, edge.target) for edge in graph.edges], + [ + (edge.id, edge.type, edge.source, edge.target) + for edge in imported.edges + ], + ) + + def test_all_bpmn_fixtures_round_trip_through_the_native_graph(self) -> None: + fixture_directory = Path(__file__).parent / "fixtures" / "bpmn" + + for path in sorted(fixture_directory.glob("*.bpmn")): + with self.subTest(path=path.name): + graph = import_bpmn_graph(path.read_text(encoding="utf-8")) + imported = import_bpmn_graph( + export_bpmn_graph(graph, name=path.stem) + ) + + self.assertEqual( + Counter(node.type for node in graph.nodes), + Counter(node.type for node in imported.nodes), + ) + self.assertEqual( + Counter(edge.type for edge in graph.edges), + Counter(edge.type for edge in imported.edges), + ) + self.assertEqual(len(graph.nodes), len(imported.nodes)) + self.assertEqual(len(graph.edges), len(imported.edges)) + + def test_nested_flows_remain_in_their_bpmn_container(self) -> None: + fixture = ( + Path(__file__).parent + / "fixtures" + / "bpmn" + / "events-transaction-compensation.bpmn" + ) + rendered = export_bpmn_graph( + import_bpmn_graph(fixture.read_text(encoding="utf-8")) + ) + root = parse_bpmn_xml(rendered) + transaction = next( + item + for item in root.iter() + if item.tag == f"{{{BPMN_MODEL_NAMESPACE}}}transaction" + ) + + self.assertTrue( + any( + child.tag == f"{{{BPMN_MODEL_NAMESPACE}}}association" + and child.attrib.get("id") == "Compensation_Association" + for child in transaction + ) + ) + + def test_default_flow_is_an_editable_edge_property(self) -> None: + source = BPMN.replace( + '', + '', + ) + graph = import_bpmn_graph(source) + default_edge = next(edge for edge in graph.edges if edge.id == "Flow_3") + + self.assertIs(default_edge.config.get("default"), True) + rendered = export_bpmn_graph(graph) + self.assertIn('default="Flow_3"', rendered) + + graph.edges = [ + edge.model_copy(update={"config": {**edge.config, "default": False}}) + if edge.id == "Flow_3" + else edge + for edge in graph.edges + ] + rendered_without_default = export_bpmn_graph(graph) + self.assertNotIn('default="Flow_3"', rendered_without_default) + + def test_native_graph_import_is_separate_from_runtime_support(self) -> None: + _adapter, _inspection, graph = compile_bpmn_to_graph( + BPMN, + adapter_id=NATIVE_BPMN_ADAPTER_ID, + ) + self.assertIsNotNone(graph) + + with self.assertRaisesRegex( + BpmnAdapterError, + "exclusive gateway", + ): + compile_bpmn_to_graph( + BPMN, + adapter_id=NATIVE_BPMN_ADAPTER_ID, + activation=True, + ) + + def test_adapter_versions_are_resolved_exactly_when_pinned(self) -> None: + with self.assertRaisesRegex(BpmnAdapterError, "is not installed"): + compile_bpmn_to_graph( + NATIVE_BPMN, + adapter_id=NATIVE_LINEAR_ADAPTER_ID, + adapter_version="9.0.0", + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_governance.py b/tests/test_governance.py index 119c54c..337744d 100644 --- a/tests/test_governance.py +++ b/tests/test_governance.py @@ -27,7 +27,12 @@ from govoplan_workflow.backend.service import ( derive_definition, update_definition, ) -from test_service import sample_graph +try: + from test_service import sample_graph +except ModuleNotFoundError as exc: + if exc.name != "test_service": + raise + from tests.test_service import sample_graph POLICY_CAPABILITY = "policy.definitionGovernance" diff --git a/tests/test_instance_service.py b/tests/test_instance_service.py index 25204ef..5011b0f 100644 --- a/tests/test_instance_service.py +++ b/tests/test_instance_service.py @@ -11,7 +11,15 @@ from govoplan_core.core.access import ( CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER, PrincipalRef, ) -from govoplan_core.core.automation import AutomationPrincipalResolution +from govoplan_core.core.automation import ( + ActionDefinition, + ActionExecutionResult, + ActionPreview, + AutomationPrincipalResolution, + EffectDefinition, + EffectPreview, + ObservedEffect, +) from govoplan_core.core.dataflows import ( CAPABILITY_DATAFLOW_RUN_LIFECYCLE, DataflowRunDescriptor, @@ -33,6 +41,7 @@ from govoplan_workflow.backend.instance_service import ( start_instance, ) from govoplan_workflow.backend.schemas import ( + BpmnRevisionInput, WorkflowDefinitionCreateRequest, WorkflowEdge, WorkflowGraph, @@ -40,11 +49,18 @@ from govoplan_workflow.backend.schemas import ( WorkflowNode, WorkflowStepActionRequest, ) +from govoplan_workflow.backend.bpmn_adapters import NATIVE_LINEAR_ADAPTER_ID from govoplan_workflow.backend.service import ( WorkflowConflictError, activate_definition, create_definition, ) +try: + from test_bpmn import NATIVE_BPMN +except ModuleNotFoundError as exc: + if exc.name != "test_bpmn": + raise + from tests.test_bpmn import NATIVE_BPMN def principal() -> ApiPrincipal: @@ -89,6 +105,10 @@ def runtime_graph() -> WorkflowGraph: "publication_target_ref": "", "warning_policy": "review", "input_mapping": {}, + "view_surface_ids": [ + "dataflow.module", + "dataflow.route.pipelines", + ], }, ), WorkflowNode( @@ -138,6 +158,58 @@ def runtime_graph() -> WorkflowGraph: ) +def action_graph() -> WorkflowGraph: + return WorkflowGraph( + nodes=[ + WorkflowNode( + id="start", + type="workflow.start.manual", + config={"input_schema_ref": ""}, + ), + WorkflowNode( + id="action", + type="workflow.capability", + config={ + "capability": "test.actions", + "operation": "test.case.record", + "input_mapping": {"case_id": "$input.case_id"}, + "idempotency_key": "$input.case_id", + "failure_policy": "manual", + }, + ), + WorkflowNode( + id="complete", + type="workflow.end.completed", + config={"output_mapping": {}}, + ), + WorkflowNode( + id="failed", + type="workflow.end.cancelled", + config={"reason": "Action rejected"}, + ), + ], + edges=[ + WorkflowEdge( + id="start-action", + source="start", + target="action", + ), + WorkflowEdge( + id="action-complete", + source="action", + source_port="success", + target="complete", + ), + WorkflowEdge( + id="action-failed", + source="action", + source_port="failure", + target="failed", + ), + ], + ) + + class FakeDataflowLifecycle: def __init__(self) -> None: self.runs: dict[str, DataflowRunDescriptor] = {} @@ -208,19 +280,87 @@ class FakeAutomationProvider: ) +class FakeActionProvider: + action = ActionDefinition( + action_key="test.case.record", + owner_module="test", + description="Record a test case.", + input_schema_ref="schema:test.case.record@1", + expected_effect_keys=("test.case.recorded",), + ) + effect = EffectDefinition( + effect_key="test.case.recorded", + owner_module="test", + operation="created", + description="A test case was recorded.", + ) + + def __init__(self, *states: str) -> None: + self.states = list(states or ("completed",)) + self.requests = [] + + def action_definitions(self): + return (self.action,) + + def effect_definitions(self): + return (self.effect,) + + def preview_action(self, _session, _principal, *, request): + return ActionPreview( + action_key=request.action_key, + allowed=True, + summary="Record one case.", + risk_level=self.action.risk_level, + reversibility=self.action.reversibility, + effects=( + EffectPreview( + effect_key=self.effect.effect_key, + summary="Record case.", + ), + ), + preview_ref="preview:test", + ) + + def execute_action(self, _session, _principal, *, request): + self.requests.append(request) + state = self.states.pop(0) if self.states else "completed" + if state != "completed": + return ActionExecutionResult( + state=state, + error="Temporary action failure.", + ) + return ActionExecutionResult( + state="completed", + output={"case_ref": "case:1"}, + observed_effects=( + ObservedEffect( + effect_key=self.effect.effect_key, + operation="created", + resource_ref="case:1", + ), + ), + audit_event_refs=("audit:1",), + ) + + class Registry: def __init__( self, dataflow: FakeDataflowLifecycle, automation: FakeAutomationProvider | None = None, + action: FakeActionProvider | None = None, ) -> None: self.dataflow = dataflow self.automation = automation + self.action = action def has_capability(self, name: str) -> bool: return name == CAPABILITY_DATAFLOW_RUN_LIFECYCLE or ( name == CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER and self.automation is not None + ) or ( + name == "test.actions" + and self.action is not None ) def capability(self, name: str): @@ -231,6 +371,8 @@ class Registry: and self.automation is not None ): return self.automation + if name == "test.actions" and self.action is not None: + return self.action raise KeyError(name) @@ -258,6 +400,9 @@ class WorkflowInstanceServiceTests(unittest.TestCase): payload=WorkflowDefinitionCreateRequest( name="Monthly governed processing", graph=runtime_graph(), + execution_mode="hybrid", + view_id="view-1", + view_revision_id="view-revision-1", ), ) activate_definition( @@ -319,8 +464,19 @@ class WorkflowInstanceServiceTests(unittest.TestCase): self.assertTrue(was_replayed) self.assertEqual(instance.id, replayed.id) self.assertEqual("waiting", instance.status) + self.assertEqual("user", instance.start_origin) self.assertEqual(1, len(self.dataflow.requests)) response = instance_response(self.session, instance) + self.assertEqual("hybrid", response.execution_mode) + self.assertEqual("user", response.start_origin) + self.assertIsNotNone(response.view_context) + self.assertEqual( + [ + "dataflow.module", + "dataflow.route.pipelines", + ], + response.view_context.visible_surface_ids, + ) self.assertEqual([1, 2], [step.sequence for step in response.steps]) self.assertEqual("run:1", response.steps[-1].external_ref) self.assertGreaterEqual(len(response.events), 4) @@ -340,6 +496,306 @@ class WorkflowInstanceServiceTests(unittest.TestCase): ), ) + def test_guided_workflow_rejects_automated_start_origin(self) -> None: + definition = create_definition( + self.session, + tenant_id="tenant-1", + actor_id="account-1", + payload=WorkflowDefinitionCreateRequest( + name="Guided review", + graph=WorkflowGraph( + nodes=[ + WorkflowNode( + id="start", + type="workflow.start.api", + config={ + "input_schema_ref": "schema:input", + "authorization_policy_ref": "policy:start", + }, + ), + WorkflowNode( + id="activity", + type="workflow.activity", + config={"title": "Review"}, + ), + WorkflowNode( + id="done", + type="workflow.end.completed", + ), + ], + edges=[ + WorkflowEdge( + id="start-activity", + source="start", + target="activity", + ), + WorkflowEdge( + id="activity-done", + source="activity", + target="done", + ), + ], + ), + execution_mode="guided", + allow_automation=True, + ), + ) + activate_definition( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="account-1", + ) + + with self.assertRaisesRegex( + WorkflowConflictError, + "Guided workflows must be started by a user", + ): + start_instance( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="account-1", + principal=principal(), + registry=self.registry, + payload=WorkflowInstanceStartRequest( + idempotency_key="automated-guided", + ), + start_origin="api", + ) + + def test_module_action_records_effects_and_completes_idempotently( + self, + ) -> None: + action = FakeActionProvider() + registry = Registry(self.dataflow, action=action) + definition = create_definition( + self.session, + tenant_id="tenant-1", + actor_id="account-1", + payload=WorkflowDefinitionCreateRequest( + name="Action workflow", + graph=action_graph(), + ), + ) + activate_definition( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="account-1", + ) + + instance, replayed = start_instance( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="account-1", + principal=principal(), + registry=registry, + payload=WorkflowInstanceStartRequest( + idempotency_key="action-instance", + input={"case_id": "case-1"}, + ), + ) + response = instance_response(self.session, instance) + + self.assertFalse(replayed) + self.assertEqual("completed", response.status) + self.assertEqual(1, len(action.requests)) + self.assertEqual( + "test.actions:test.case.record:case-1", + action.requests[0].idempotency_key, + ) + self.assertEqual( + "case:1", + response.steps[1].output["execution"]["observed_effects"][0][ + "resource_ref" + ], + ) + self.assertIn( + "workflow.action.completed", + {event.kind for event in response.events}, + ) + + def test_retryable_module_action_reuses_the_idempotency_key(self) -> None: + action = FakeActionProvider("retryable", "completed") + registry = Registry(self.dataflow, action=action) + definition = create_definition( + self.session, + tenant_id="tenant-1", + actor_id="account-1", + payload=WorkflowDefinitionCreateRequest( + name="Retry action", + graph=action_graph(), + ), + ) + activate_definition( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="account-1", + ) + instance, _replayed = start_instance( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="account-1", + principal=principal(), + registry=registry, + payload=WorkflowInstanceStartRequest( + idempotency_key="retry-action-instance", + input={"case_id": "case-2"}, + ), + ) + waiting = instance_response(self.session, instance) + current = waiting.steps[-1] + self.assertEqual("retryable", current.handoff["state"]) + + resolved = resolve_step( + self.session, + tenant_id="tenant-1", + instance_id=instance.id, + step_id=current.id, + actor_id="account-1", + principal=principal(), + registry=registry, + payload=WorkflowStepActionRequest(action="retry"), + ) + + self.assertEqual("completed", resolved.status) + self.assertEqual( + action.requests[0].idempotency_key, + action.requests[1].idempotency_key, + ) + + def test_automated_dataflow_failure_policy_fails_without_handoff( + self, + ) -> None: + graph = runtime_graph() + flow = next(node for node in graph.nodes if node.id == "flow") + flow.config["warning_policy"] = "continue" + flow.config["failure_policy"] = "fail" + definition = create_definition( + self.session, + tenant_id="tenant-1", + actor_id="account-1", + payload=WorkflowDefinitionCreateRequest( + name="Automated Dataflow", + graph=graph, + execution_mode="automated", + ), + ) + activate_definition( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="account-1", + ) + instance, _replayed = start_instance( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="account-1", + principal=principal(), + registry=self.registry, + payload=WorkflowInstanceStartRequest( + idempotency_key="automated-dataflow", + ), + ) + self.dataflow.fail("run:1") + + changed = reconcile_instance( + self.session, + instance=instance, + principal=principal(), + registry=self.registry, + ) + + self.assertTrue(changed) + self.assertEqual("failed", instance.status) + self.assertIsNone(instance.current_step_id) + self.assertFalse( + any( + step.handoff.get("kind") == "dataflow_failure" + for step in instance.steps + ) + ) + + def test_native_bpmn_profile_runs_through_canonical_instance_state(self) -> None: + definition = create_definition( + self.session, + tenant_id="tenant-1", + actor_id="account-1", + payload=WorkflowDefinitionCreateRequest( + name="BPMN governed review", + graph=runtime_graph(), + bpmn=BpmnRevisionInput( + xml=NATIVE_BPMN, + adapter_id=NATIVE_LINEAR_ADAPTER_ID, + ), + ), + ) + activate_definition( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="account-1", + ) + instance, replayed = start_instance( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="account-1", + principal=principal(), + registry=self.registry, + payload=WorkflowInstanceStartRequest( + idempotency_key="bpmn-request-1", + input={"case_id": "case-bpmn"}, + ), + ) + + self.assertFalse(replayed) + self.assertEqual("waiting", instance.status) + waiting = instance_response(self.session, instance).steps[-1] + self.assertEqual("workflow.activity", waiting.node_type) + self.assertEqual("Review request", waiting.handoff["title"]) + + replay, was_replayed = start_instance( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="account-1", + principal=principal(), + registry=self.registry, + payload=WorkflowInstanceStartRequest( + idempotency_key="bpmn-request-1", + input={"case_id": "case-bpmn"}, + ), + ) + self.assertTrue(was_replayed) + self.assertEqual(instance.id, replay.id) + + resolved = resolve_step( + self.session, + tenant_id="tenant-1", + instance_id=instance.id, + step_id=waiting.id, + actor_id="account-1", + principal=principal(), + registry=self.registry, + payload=WorkflowStepActionRequest( + action="complete", + comment="Reviewed.", + evidence=["case:case-bpmn"], + ), + ) + response = instance_response(self.session, resolved) + self.assertEqual("completed", response.status) + self.assertEqual( + "workflow.instance.completed", + response.events[-1].kind, + ) + def test_reconcile_completes_with_stable_dataflow_output_refs(self) -> None: instance = self._start() self.dataflow.finish("run:1") @@ -477,6 +933,55 @@ class WorkflowInstanceServiceTests(unittest.TestCase): instance.authorization_["last_resolution"]["status"], ) + def test_worker_reconciles_pending_module_action(self) -> None: + action = FakeActionProvider("pending", "completed") + automation = FakeAutomationProvider() + registry = Registry( + self.dataflow, + automation=automation, + action=action, + ) + definition = create_definition( + self.session, + tenant_id="tenant-1", + actor_id="account-1", + payload=WorkflowDefinitionCreateRequest( + name="Asynchronous action", + graph=action_graph(), + ), + ) + activate_definition( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="account-1", + ) + instance, _replayed = start_instance( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="account-1", + principal=principal(), + registry=registry, + payload=WorkflowInstanceStartRequest( + idempotency_key="pending-action", + input={"case_id": "case-3"}, + ), + ) + self.session.commit() + worker = SqlWorkflowRuntimeWorker(registry=registry) + + summary = worker.reconcile_pending(self.session) + + self.assertEqual(1, summary["advanced"]) + self.assertEqual("completed", instance.status) + self.assertEqual(2, len(action.requests)) + self.assertEqual( + action.requests[0].idempotency_key, + action.requests[1].idempotency_key, + ) + self.assertEqual(1, len(automation.requests)) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_migrations.py b/tests/test_migrations.py index e17e56c..fd3ff20 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -26,7 +26,7 @@ class WorkflowMigrationTests(unittest.TestCase): try: with engine.connect() as connection: self.assertIn( - "d8f2a5c7e1b4", + "f1b7d3e5a9c2", set(MigrationContext.configure(connection).get_current_heads()), ) self.assertEqual( @@ -43,6 +43,32 @@ class WorkflowMigrationTests(unittest.TestCase): if name.startswith("workflow_") }, ) + revision_columns = { + item["name"] + for item in inspect(connection).get_columns( + "workflow_definition_revisions" + ) + } + self.assertTrue( + { + "bpmn_xml", + "bpmn_hash", + "bpmn_adapter_id", + "bpmn_adapter_version", + "bpmn_runtime_kind", + "bpmn_executable", + "execution_mode", + "view_id", + "view_revision_id", + }.issubset(revision_columns) + ) + instance_columns = { + item["name"] + for item in inspect(connection).get_columns( + "workflow_instances" + ) + } + self.assertIn("start_origin", instance_columns) finally: engine.dispose() diff --git a/tests/test_node_library.py b/tests/test_node_library.py index b477bf8..dd91e3c 100644 --- a/tests/test_node_library.py +++ b/tests/test_node_library.py @@ -2,7 +2,10 @@ from __future__ import annotations import unittest -from govoplan_workflow.backend.node_library import WORKFLOW_GRAPH_LIBRARY +from govoplan_workflow.backend.node_library import ( + BPMN_NODE_TYPES, + WORKFLOW_GRAPH_LIBRARY, +) from govoplan_workflow.backend.schemas import WorkflowEdge, WorkflowGraph, WorkflowNode from govoplan_workflow.backend.validation import validate_workflow_graph @@ -91,6 +94,52 @@ class WorkflowNodeLibraryTests(unittest.TestCase): def test_library_has_domain_specific_cycle_policy(self) -> None: self.assertTrue(WORKFLOW_GRAPH_LIBRARY.constraints.allow_cycles) self.assertEqual(WORKFLOW_GRAPH_LIBRARY.id, "workflow") + self.assertEqual("1.0.0", WORKFLOW_GRAPH_LIBRARY.version) + activity = WORKFLOW_GRAPH_LIBRARY.node_type("workflow.activity") + self.assertIn( + "view_surface_ids", + {field.id for field in activity.config_fields}, + ) + + def test_native_palette_uses_standard_bpmn_vocabulary(self) -> None: + node_types = {item.type for item in BPMN_NODE_TYPES} + + self.assertIn("bpmn.startEvent", node_types) + self.assertIn("bpmn.userTask", node_types) + self.assertIn("bpmn.exclusiveGateway", node_types) + self.assertIn("bpmn.participant", node_types) + self.assertIn("bpmn.textAnnotation", node_types) + self.assertTrue(all(item.startswith("bpmn.") for item in node_types)) + + def test_bpmn_rejects_multiple_default_flows(self) -> None: + graph = WorkflowGraph( + nodes=[ + WorkflowNode(id="start", type="bpmn.startEvent"), + WorkflowNode(id="choice", type="bpmn.exclusiveGateway"), + WorkflowNode(id="end-a", type="bpmn.endEvent"), + WorkflowNode(id="end-b", type="bpmn.endEvent"), + ], + edges=[ + WorkflowEdge(id="to-choice", source="start", target="choice"), + WorkflowEdge( + id="default-a", + source="choice", + target="end-a", + config={"default": True}, + ), + WorkflowEdge( + id="default-b", + source="choice", + target="end-b", + config={"default": True}, + ), + ], + ) + + self.assertIn( + "bpmn.multiple_default_flows", + {item.code for item in validate_workflow_graph(graph)}, + ) if __name__ == "__main__": diff --git a/tests/test_service.py b/tests/test_service.py index 07a1457..4388699 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -1,6 +1,7 @@ from __future__ import annotations import unittest +from pathlib import Path from sqlalchemy import create_engine, select from sqlalchemy.orm import Session, sessionmaker @@ -11,6 +12,7 @@ from govoplan_workflow.backend.db.models import ( WorkflowDefinitionRevision, ) from govoplan_workflow.backend.schemas import ( + BpmnRevisionInput, WorkflowDefinitionCreateRequest, WorkflowDefinitionUpdateRequest, WorkflowEdge, @@ -19,6 +21,7 @@ from govoplan_workflow.backend.schemas import ( WorkflowPosition, ) from govoplan_workflow.backend.service import ( + WorkflowBpmnValidationError, WorkflowConflictError, WorkflowNotFoundError, activate_definition, @@ -29,6 +32,18 @@ from govoplan_workflow.backend.service import ( list_definitions, update_definition, ) +from govoplan_workflow.backend.bpmn_adapters import ( + INTERCHANGE_ADAPTER_ID, + NATIVE_LINEAR_ADAPTER_ID, +) +from govoplan_workflow.backend.bpmn import inspect_bpmn_xml +from govoplan_workflow.backend.bpmn_graph import NATIVE_BPMN_ADAPTER_ID +try: + from test_bpmn import BPMN, NATIVE_BPMN +except ModuleNotFoundError as exc: + if exc.name != "test_bpmn": + raise + from tests.test_bpmn import BPMN, NATIVE_BPMN def sample_graph(*, title: str = "Review request") -> WorkflowGraph: @@ -174,6 +189,70 @@ class WorkflowServiceTests(unittest.TestCase): ), ) + def test_execution_mode_and_view_pin_are_immutable_revision_content( + self, + ) -> None: + definition = create_definition( + self.session, + tenant_id="tenant-1", + actor_id="user-1", + payload=WorkflowDefinitionCreateRequest( + name="Guided review", + graph=sample_graph(), + execution_mode="guided", + view_id="view-1", + view_revision_id="view-revision-1", + ), + ) + updated = update_definition( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="user-2", + payload=WorkflowDefinitionUpdateRequest( + name="Guided review", + graph=sample_graph(), + expected_revision=1, + execution_mode="hybrid", + view_id="view-1", + view_revision_id="view-revision-1", + ), + ) + + revisions = list_definition_revisions( + self.session, + definition=updated, + ) + self.assertEqual(2, updated.current_revision) + self.assertEqual("hybrid", revisions[0].execution_mode) + self.assertEqual("guided", revisions[1].execution_mode) + self.assertEqual("view-revision-1", revisions[1].view_revision_id) + self.assertNotEqual(revisions[0].content_hash, revisions[1].content_hash) + + def test_automated_mode_rejects_human_handoff_paths(self) -> None: + definition = create_definition( + self.session, + tenant_id="tenant-1", + actor_id="user-1", + payload=WorkflowDefinitionCreateRequest( + name="Invalid automation", + graph=sample_graph(), + execution_mode="automated", + allow_automation=True, + ), + ) + + with self.assertRaisesRegex( + WorkflowConflictError, + "human handoff paths", + ): + activate_definition( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="user-1", + ) + def test_stale_update_and_cross_tenant_access_are_rejected(self) -> None: definition = self._create() with self.assertRaises(WorkflowConflictError): @@ -220,6 +299,129 @@ class WorkflowServiceTests(unittest.TestCase): ), ) + def test_bpmn_xml_and_adapter_are_pinned_to_immutable_revisions(self) -> None: + definition = create_definition( + self.session, + tenant_id="tenant-1", + actor_id="user-1", + payload=WorkflowDefinitionCreateRequest( + name="BPMN review", + graph=sample_graph(), + bpmn=BpmnRevisionInput( + xml=NATIVE_BPMN, + adapter_id=NATIVE_LINEAR_ADAPTER_ID, + adapter_version="1.0.0", + ), + ), + ) + self.session.commit() + + first = list_definition_revisions( + self.session, + definition=definition, + )[0] + self.assertTrue(inspect_bpmn_xml(first.bpmn_xml or "").valid_xml) + self.assertEqual(NATIVE_BPMN_ADAPTER_ID, first.bpmn_adapter_id) + self.assertEqual("1.0.0", first.bpmn_adapter_version) + self.assertEqual("native_graph", first.bpmn_runtime_kind) + self.assertEqual( + "Review request", + first.graph["nodes"][1]["config"]["title"], + ) + + changed_xml = NATIVE_BPMN.replace( + "Review request", + "Review corrected request", + ) + updated = update_definition( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="user-2", + payload=WorkflowDefinitionUpdateRequest( + name="BPMN review", + graph=sample_graph(), + bpmn=BpmnRevisionInput( + xml=changed_xml, + adapter_id=NATIVE_LINEAR_ADAPTER_ID, + ), + expected_revision=1, + ), + ) + activate_definition( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="user-2", + revision=2, + ) + self.session.commit() + + revisions = list_definition_revisions( + self.session, + definition=updated, + ) + self.assertEqual(2, updated.current_revision) + self.assertEqual(2, updated.active_revision) + self.assertIn("Review corrected request", revisions[0].bpmn_xml or "") + self.assertNotEqual(revisions[0].content_hash, revisions[1].content_hash) + + def test_model_only_bpmn_revision_fails_closed_on_activation(self) -> None: + definition = create_definition( + self.session, + tenant_id="tenant-1", + actor_id="user-1", + payload=WorkflowDefinitionCreateRequest( + name="Interchange model", + graph=sample_graph(), + bpmn=BpmnRevisionInput( + xml=BPMN, + adapter_id=INTERCHANGE_ADAPTER_ID, + ), + ), + ) + self.session.commit() + + with self.assertRaisesRegex( + WorkflowBpmnValidationError, + "exclusive gateway", + ): + activate_definition( + self.session, + tenant_id="tenant-1", + definition_id=definition.id, + actor_id="user-1", + ) + + def test_interchange_revision_preserves_extension_xml_exactly(self) -> None: + xml = ( + Path(__file__).parent + / "fixtures" + / "bpmn" + / "process.bpmn" + ).read_text(encoding="utf-8") + definition = create_definition( + self.session, + tenant_id="tenant-1", + actor_id="user-1", + payload=WorkflowDefinitionCreateRequest( + name="Extended interchange model", + graph=sample_graph(), + bpmn=BpmnRevisionInput( + xml=xml, + adapter_id=INTERCHANGE_ADAPTER_ID, + adapter_version="1.0.0", + ), + ), + ) + + revision = list_definition_revisions( + self.session, + definition=definition, + )[0] + self.assertTrue(inspect_bpmn_xml(revision.bpmn_xml or "").valid_xml) + self.assertIn("fixture revision=\"1\"", revision.bpmn_xml or "") + if __name__ == "__main__": unittest.main() diff --git a/webui/package.json b/webui/package.json index f85240c..e981089 100644 --- a/webui/package.json +++ b/webui/package.json @@ -19,9 +19,9 @@ "@govoplan/core-webui": "^0.1.14", "@xyflow/react": "^12.11.2", "lucide-react": "^1.23.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "react-router-dom": ">=7.18.2 <8", + "react": ">=19.2.7 <20", + "react-dom": ">=19.2.7 <20", + "react-router": ">=8.3.0 <9", "typescript": "^5.7.2" }, "peerDependenciesMeta": { diff --git a/webui/src/api/workflow.ts b/webui/src/api/workflow.ts index d288bb4..0488a81 100644 --- a/webui/src/api/workflow.ts +++ b/webui/src/api/workflow.ts @@ -6,6 +6,7 @@ import { } from "@govoplan/core-webui"; import type { DefinitionGraph, + DefinitionGraphEdge, DefinitionGraphNode, DefinitionGraphNodeType } from "@govoplan/core-webui/definition-graph"; @@ -13,10 +14,39 @@ import type { export type WorkflowStatus = "draft" | "active" | "archived"; export type DefinitionScopeType = "system" | "tenant" | "group" | "user"; export type DefinitionKind = "flow" | "template"; -export type WorkflowGraphNode = DefinitionGraphNode; -export type WorkflowGraph = DefinitionGraph & { +export type WorkflowExecutionMode = "guided" | "automated" | "hybrid"; +export type WorkflowStartOrigin = + | "user" + | "api" + | "schedule" + | "event" + | "parent_workflow" + | "dependency" + | "retry" + | "replay" + | "backfill"; +export type WorkflowGraphNode = DefinitionGraphNode & { + size?: { width: number; height: number } | null; + parent_id?: string | null; + process_id?: string | null; +}; +export type WorkflowGraphEdge = DefinitionGraphEdge & { + type: + | "bpmn.sequenceFlow" + | "bpmn.messageFlow" + | "bpmn.association" + | "bpmn.dataInputAssociation" + | "bpmn.dataOutputAssociation" + | "bpmn.conversationLink"; + label: string; + config: Record; + waypoints: Array<{ x: number; y: number }>; +}; +export type WorkflowGraph = Omit & { schema_version: 1; nodes: WorkflowGraphNode[]; + edges: WorkflowGraphEdge[]; + metadata: Record; }; export type WorkflowDiagnostic = { @@ -29,6 +59,71 @@ export type WorkflowDiagnostic = { export type WorkflowNodeType = DefinitionGraphNodeType; +export type BpmnRuntimeKind = "model_only" | "native_graph" | "external"; + +export type BpmnDiagnostic = { + severity: "error" | "warning" | "info"; + code: string; + message: string; + element_id?: string | null; +}; + +export type BpmnAdapterProfile = { + id: string; + version: string; + label: string; + description: string; + conformance: string; + runtime_kind: BpmnRuntimeKind; + executable: boolean; + supported_elements: string[]; + supported_event_definitions: string[]; + requirements: string[]; +}; + +export type BpmnInspection = { + valid_xml: boolean; + definitions_id?: string | null; + target_namespace?: string | null; + process_count: number; + executable_process_count: number; + collaboration_count: number; + choreography_count: number; + element_counts: Record; + support_counts: Record; + elements: Array<{ + element_type: string; + element_id?: string | null; + name?: string | null; + parent_type?: string | null; + parent_id?: string | null; + support_level: "interchange_only" | "native_mapping" | "native_execution"; + }>; + diagnostics: BpmnDiagnostic[]; + adapter_id?: string | null; + adapter_version?: string | null; + runtime_kind?: BpmnRuntimeKind | null; + executable: boolean; + activatable: boolean; +}; + +export type BpmnRevisionSummary = { + format: "bpmn-2.0"; + content_hash: string; + adapter_id: string; + adapter_version: string; + runtime_kind: BpmnRuntimeKind; + executable: boolean; + adapter_available: boolean; +}; + +export type BpmnRevisionDocument = BpmnRevisionSummary & { + definition_id: string; + revision: number; + xml: string; + inspection: BpmnInspection; +}; + export type WorkflowRevision = { id: string; revision: number; @@ -37,6 +132,10 @@ export type WorkflowRevision = { content_hash: string; library_id: string; library_version: string; + execution_mode: WorkflowExecutionMode; + view_id?: string | null; + view_revision_id?: string | null; + bpmn?: BpmnRevisionSummary | null; created_by?: string | null; created_at: string; }; @@ -134,6 +233,15 @@ export type WorkflowInstance = { definition_name: string; definition_revision: number; definition_hash: string; + execution_mode: WorkflowExecutionMode; + start_origin: WorkflowStartOrigin; + view_context?: { + view_id: string; + revision_id?: string | null; + visible_surface_ids: string[]; + step_id?: string | null; + node_id?: string | null; + } | null; status: WorkflowInstanceStatus; idempotency_key: string; correlation_id?: string | null; @@ -157,6 +265,11 @@ export type WorkflowDefinitionPayload = { name: string; description?: string | null; graph: WorkflowGraph; + bpmn?: { + xml: string; + adapter_id: string; + adapter_version?: string | null; + } | null; metadata: Record; scope_type: DefinitionScopeType; scope_id?: string | null; @@ -165,8 +278,68 @@ export type WorkflowDefinitionPayload = { allow_start: boolean; allow_reuse: boolean; allow_automation: boolean; + execution_mode: WorkflowExecutionMode; + view_id?: string | null; + view_revision_id?: string | null; }; +export async function getBpmnSupportProfile( + settings: ApiSettings +): Promise<{ + specification: string; + model_namespace: string; + interchange: string; + native_runtime: string; + native_execution_elements: string[]; + native_mapping_elements: string[]; + adapters: BpmnAdapterProfile[]; +}> { + return apiFetch(settings, "/api/v1/workflow/bpmn/profile"); +} + +export function inspectWorkflowBpmn( + settings: ApiSettings, + payload: { + xml: string; + adapter_id: string; + adapter_version?: string | null; + activation?: boolean; + } +): Promise { + return apiFetch(settings, "/api/v1/workflow/bpmn/inspect", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export function compileWorkflowBpmn( + settings: ApiSettings, + payload: { + xml: string; + adapter_id: string; + adapter_version?: string | null; + } +): Promise<{ + adapter: BpmnAdapterProfile; + graph: WorkflowGraph; + inspection: BpmnInspection; +}> { + return apiFetch(settings, "/api/v1/workflow/bpmn/compile", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export function renderWorkflowBpmn( + settings: ApiSettings, + payload: { graph: WorkflowGraph; name?: string } +): Promise<{ xml: string; inspection: BpmnInspection }> { + return apiFetch(settings, "/api/v1/workflow/bpmn/render", { + method: "POST", + body: JSON.stringify(payload) + }); +} + export async function listWorkflowNodeTypes( settings: ApiSettings ): Promise<{ id: string; version: string; allows_cycles: boolean; nodes: WorkflowNodeType[] }> { @@ -224,6 +397,9 @@ export function deriveWorkflowDefinition( allow_start: boolean; allow_reuse: boolean; allow_automation: boolean; + execution_mode?: WorkflowExecutionMode | null; + view_id?: string | null; + view_revision_id?: string | null; } ): Promise { return apiFetch( @@ -244,6 +420,17 @@ export async function listWorkflowRevisions( return response.revisions; } +export function getWorkflowRevisionBpmn( + settings: ApiSettings, + definitionId: string, + revision: number +): Promise { + return apiFetch( + settings, + `/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/revisions/${revision}/bpmn` + ); +} + export function activateWorkflowDefinition( settings: ApiSettings, definitionId: string, diff --git a/webui/src/features/workflow/WorkflowCanvas.tsx b/webui/src/features/workflow/WorkflowCanvas.tsx index 040b49c..8e526fc 100644 --- a/webui/src/features/workflow/WorkflowCanvas.tsx +++ b/webui/src/features/workflow/WorkflowCanvas.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, type DragEvent } from "react"; +import { useMemo, useRef, useState, type DragEvent } from "react"; import { addEdge, applyEdgeChanges, @@ -7,8 +7,10 @@ import { BackgroundVariant, ConnectionLineType, Controls, + MarkerType, MiniMap, ReactFlow, + reconnectEdge, type Connection, type Edge, type ReactFlowInstance @@ -17,6 +19,7 @@ import { definitionConnectionError } from "@govoplan/core-webui/definition-graph import type { WorkflowDiagnostic, WorkflowGraph, + WorkflowGraphEdge, WorkflowGraphNode, WorkflowNodeType } from "../../api/workflow"; @@ -30,23 +33,29 @@ export default function WorkflowCanvas({ diagnostics, nodeLibrary, selectedNodeId, + selectedEdgeId, readOnly, allowsCycles, onGraphChange, - onSelectNode + onSelectNode, + onSelectEdge }: { graph: WorkflowGraph; diagnostics: WorkflowDiagnostic[]; nodeLibrary: WorkflowNodeType[]; selectedNodeId: string | null; + selectedEdgeId: string | null; readOnly: boolean; allowsCycles: boolean; onGraphChange: (graph: WorkflowGraph) => void; onSelectNode: (nodeId: string | null) => void; + onSelectEdge: (edgeId: string | null) => void; }) { const [instance, setInstance] = useState< ReactFlowInstance | null >(null); + const reconnectSuccessful = useRef(true); + const reconnectingEdgeId = useRef(null); const definitions = useMemo( () => new Map(nodeLibrary.map((item) => [item.type, item])), [nodeLibrary] @@ -67,8 +76,8 @@ export default function WorkflowCanvas({ id: node.id, type: "workflow" as const, position: node.position, - initialWidth: 190, - initialHeight: 56, + initialWidth: canvasNodeSize(node, definition).width, + initialHeight: canvasNodeSize(node, definition).height, selected: node.id === selectedNodeId, data: { label: node.label, @@ -88,13 +97,34 @@ export default function WorkflowCanvas({ sourceHandle: edge.source_port ?? "output", targetHandle: edge.target_port ?? "input", type: "smoothstep", - className: "workflow-edge" + label: edge.label || undefined, + className: `workflow-edge workflow-edge-${edge.type.replace(".", "-")}`, + selected: edge.id === selectedEdgeId, + animated: edge.type === "bpmn.messageFlow", + markerEnd: edgeMarkerEnd(edge), + style: edge.type === "bpmn.association" + ? { strokeDasharray: "4 4" } + : edge.type === "bpmn.messageFlow" + ? { strokeDasharray: "8 5" } + : undefined })), - [graph.edges] + [graph.edges, selectedEdgeId] ); const updateNodes = (nextNodes: WorkflowFlowNode[]) => { const ids = new Set(nextNodes.map((node) => node.id)); + const movedIds = new Set( + nextNodes + .filter((flowNode) => { + const current = graph.nodes.find((node) => node.id === flowNode.id); + return current + && ( + current.position.x !== flowNode.position.x + || current.position.y !== flowNode.position.y + ); + }) + .map((node) => node.id) + ); onGraphChange({ ...graph, nodes: nextNodes.map((flowNode) => { @@ -104,6 +134,10 @@ export default function WorkflowCanvas({ }), edges: graph.edges.filter( (edge) => ids.has(edge.source) && ids.has(edge.target) + ).map((edge) => + movedIds.has(edge.source) || movedIds.has(edge.target) + ? { ...edge, waypoints: [] } + : edge ) }); }; @@ -111,20 +145,43 @@ export default function WorkflowCanvas({ const updateEdges = (nextEdges: Edge[]) => { onGraphChange({ ...graph, - edges: nextEdges.map((edge) => ({ - id: edge.id, - source: edge.source, - target: edge.target, - source_port: edge.sourceHandle ?? "output", - target_port: edge.targetHandle ?? "input" - })) + edges: nextEdges.map((edge) => { + const current = graph.edges.find((item) => item.id === edge.id); + const endpointsChanged = Boolean( + current + && ( + current.source !== edge.source + || current.target !== edge.target + ) + ); + return { + id: edge.id, + type: current?.type ?? "bpmn.sequenceFlow", + label: current?.label ?? "", + source: edge.source, + target: edge.target, + source_port: edge.sourceHandle ?? "outgoing", + target_port: edge.targetHandle ?? "incoming", + config: structuredClone(current?.config ?? {}), + waypoints: endpointsChanged + ? [] + : structuredClone(current?.waypoints ?? []) + } satisfies WorkflowGraphEdge; + }) }); }; const isValidConnection = (connection: Connection | Edge): boolean => { if (readOnly || !connection.source || !connection.target) return false; return definitionConnectionError( - graph, + reconnectingEdgeId.current + ? { + ...graph, + edges: graph.edges.filter( + (edge) => edge.id !== reconnectingEdgeId.current + ) + } + : graph, nodeLibrary, { source: connection.source, @@ -182,21 +239,66 @@ export default function WorkflowCanvas({ } }} onEdgesChange={(changes) => { - if (!readOnly) updateEdges(applyEdgeChanges(changes, edges)); + if (readOnly) return; + const selectedChange = changes.find( + (change) => change.type === "select" && change.selected + ); + if (selectedChange?.type === "select") { + onSelectEdge(selectedChange.id); + } + const graphChanges = changes.filter( + (change) => change.type !== "select" + ); + if (graphChanges.length) { + updateEdges(applyEdgeChanges(graphChanges, edges)); + } }} onConnect={(connection) => { if (!isValidConnection(connection)) return; updateEdges(addEdge({ ...connection, - id: `edge-${crypto.randomUUID()}`, - type: "smoothstep" - }, edges)); + id: `edge-${crypto.randomUUID()}`, + type: "smoothstep" + }, edges)); + }} + onReconnect={(oldEdge, connection) => { + if (readOnly || !isValidConnection(connection)) return; + reconnectSuccessful.current = true; + updateEdges(reconnectEdge( + oldEdge, + connection, + edges, + { shouldReplaceId: false } + )); + }} + onReconnectStart={(_event, edge) => { + reconnectSuccessful.current = false; + reconnectingEdgeId.current = edge.id; + }} + onReconnectEnd={(_event, edge) => { + if (!reconnectSuccessful.current && !readOnly) { + updateEdges(edges.filter((candidate) => candidate.id !== edge.id)); + onSelectEdge(null); + } + reconnectSuccessful.current = true; + reconnectingEdgeId.current = null; }} isValidConnection={isValidConnection} - onNodeClick={(_event, node) => onSelectNode(node.id)} - onPaneClick={() => onSelectNode(null)} + onNodeClick={(_event, node) => { + onSelectEdge(null); + onSelectNode(node.id); + }} + onEdgeClick={(_event, edge) => { + onSelectNode(null); + onSelectEdge(edge.id); + }} + onPaneClick={() => { + onSelectNode(null); + onSelectEdge(null); + }} nodesDraggable={!readOnly} nodesConnectable={!readOnly} + edgesReconnectable={!readOnly} deleteKeyCode={readOnly ? null : ["Backspace", "Delete"]} connectionLineType={ConnectionLineType.SmoothStep} connectionLineStyle={{ stroke: "var(--accent)", strokeWidth: 3 }} @@ -224,6 +326,52 @@ export default function WorkflowCanvas({ ); } +function edgeMarkerEnd(edge: WorkflowGraphEdge) { + if (edge.type === "bpmn.sequenceFlow") { + return { + type: MarkerType.ArrowClosed, + width: 16, + height: 16, + color: "var(--line-dark)" + }; + } + if ( + edge.type === "bpmn.messageFlow" + || edge.type === "bpmn.dataInputAssociation" + || edge.type === "bpmn.dataOutputAssociation" + ) { + return { + type: MarkerType.Arrow, + width: 16, + height: 16, + color: "var(--line-dark)" + }; + } + return undefined; +} + +function canvasNodeSize( + node: WorkflowGraphNode, + definition: WorkflowNodeType +): { width: number; height: number } { + const shape = String(definition.metadata?.shape ?? "activity"); + if (shape.startsWith("event")) return { width: 124, height: 70 }; + if (shape === "gateway") return { width: 124, height: 82 }; + if (shape === "participant" || shape === "lane") { + return { + width: Math.max(240, Math.min(node.size?.width ?? 360, 720)), + height: Math.max(100, Math.min(node.size?.height ?? 160, 360)) + }; + } + if (shape === "group") { + return { + width: Math.max(220, node.size?.width ?? 300), + height: Math.max(120, node.size?.height ?? 180) + }; + } + return { width: 190, height: 64 }; +} + export function updateWorkflowGraphNode( graph: WorkflowGraph, updatedNode: WorkflowGraphNode diff --git a/webui/src/features/workflow/WorkflowInspector.tsx b/webui/src/features/workflow/WorkflowInspector.tsx index 55420d6..12cbb9c 100644 --- a/webui/src/features/workflow/WorkflowInspector.tsx +++ b/webui/src/features/workflow/WorkflowInspector.tsx @@ -1,30 +1,64 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Trash2 } from "lucide-react"; import { Button, DismissibleAlert, - FormField + FormField, + ReferenceMultiSelect, + useViewSurfaces, + type ReferenceOptionProvider } from "@govoplan/core-webui"; import type { + WorkflowGraphEdge, WorkflowGraphNode, WorkflowNodeType } from "../../api/workflow"; export default function WorkflowInspector({ node, + edge, nodeLibrary, readOnly, onChange, - onDelete + onDelete, + onEdgeChange, + onEdgeDelete }: { node: WorkflowGraphNode | null; + edge: WorkflowGraphEdge | null; nodeLibrary: WorkflowNodeType[]; readOnly: boolean; onChange: (node: WorkflowGraphNode) => void; onDelete: (nodeId: string) => void; + onEdgeChange: (edge: WorkflowGraphEdge) => void; + onEdgeDelete: (edgeId: string) => void; }) { const [jsonDrafts, setJsonDrafts] = useState>({}); const [error, setError] = useState(""); + const viewSurfaces = useViewSurfaces(); + const viewSurfaceProvider = useMemo(() => { + const options = viewSurfaces.map((surface) => ({ + value: surface.id, + label: surface.label, + description: `${surface.moduleId} · ${surface.kind}`, + searchText: `${surface.label} ${surface.moduleId} ${surface.kind} ${surface.id}` + })); + const byId = new Map(options.map((option) => [option.value, option])); + return { + search: async (query, context) => { + const normalized = query.trim().toLowerCase(); + return options + .filter((option) => ( + !normalized + || option.searchText.toLowerCase().includes(normalized) + )) + .slice(0, context.limit); + }, + resolve: async (values) => values + .map((value) => byId.get(value)) + .filter((option): option is (typeof options)[number] => Boolean(option)) + }; + }, [viewSurfaces]); useEffect(() => { if (!node) { @@ -44,6 +78,109 @@ export default function WorkflowInspector({ setError(""); }, [node?.id, nodeLibrary]); + if (edge) { + const updateEdgeConfig = (field: string, value: unknown) => { + onEdgeChange({ + ...edge, + config: { ...edge.config, [field]: value } + }); + }; + return ( +