263 lines
9.0 KiB
Python
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"]
|