Implement native BPMN workflows and guided modes

This commit is contained in:
2026-07-31 02:48:57 +02:00
parent c505e81006
commit f4974b4949
40 changed files with 8203 additions and 489 deletions
+59 -24
View File
@@ -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
+42
View File
@@ -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,
),
),
)
@@ -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")
@@ -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")
+720 -22
View File
@@ -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",
+303 -44
View File
@@ -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,
+182 -1
View File
@@ -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
+326 -14
View File
@@ -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",
]
+182 -2
View File
@@ -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 == {}