Files
govoplan-workflow-engine/src/govoplan_workflow_engine/backend/validation.py
T

263 lines
9.0 KiB
Python

from __future__ import annotations
from govoplan_core.core.definition_graphs import (
DefinitionDiagnostic,
DefinitionEdge,
DefinitionNode,
validate_definition_graph,
)
from govoplan_workflow_engine.backend.node_library import (
WORKFLOW_GRAPH_LIBRARY,
WORKFLOW_NODE_TYPES_BY_ID,
)
from govoplan_workflow_engine.backend.schemas import WorkflowGraph
def validate_workflow_graph(graph: WorkflowGraph) -> tuple[DefinitionDiagnostic, ...]:
diagnostics = list(
validate_definition_graph(
WORKFLOW_GRAPH_LIBRARY,
nodes=tuple(DefinitionNode(id=node.id, type=node.type) for node in graph.nodes),
edges=tuple(
DefinitionEdge(
id=edge.id,
source=edge.source,
target=edge.target,
source_port=edge.source_port,
target_port=edge.target_port,
)
for edge in graph.edges
),
)
)
outgoing = {node.id: 0 for node in graph.nodes}
for edge in graph.edges:
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(
severity="error",
code="node.config_required",
message=f"{definition.label} requires {field.label.lower()}.",
node_id=node.id,
field=field.id,
)
)
if _requires_outgoing(node.type) and outgoing[node.id] == 0:
diagnostics.append(
DefinitionDiagnostic(
severity="error",
code="node.outgoing_required",
message=f"{definition.label} must lead to another workflow step.",
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 == {}
def _deduplicate(
diagnostics: list[DefinitionDiagnostic],
) -> tuple[DefinitionDiagnostic, ...]:
seen: set[tuple[str, str | None, str | None]] = set()
result: list[DefinitionDiagnostic] = []
for item in diagnostics:
key = (item.code, item.node_id, item.field)
if key in seen:
continue
seen.add(key)
result.append(item)
return tuple(result)
__all__ = ["validate_workflow_graph"]