Implement native BPMN workflows and guided modes
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
+68
@@ -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")
|
||||
+62
@@ -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")
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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 == {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user