feat: extract headless workflow engine
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
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"]
|
||||
Reference in New Issue
Block a user