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"]