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
+1
View File
@@ -0,0 +1 @@
"""Workflow test package for both discovery and targeted module execution."""
+35
View File
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
id="Definitions_Choreography"
targetNamespace="urn:govoplan:workflow:fixtures">
<bpmn:message id="Message_Approval" name="Approval" />
<bpmn:collaboration id="Collaboration_Choreography">
<bpmn:participant id="Participant_Applicant" name="Applicant" />
<bpmn:participant id="Participant_Authority" name="Authority" />
</bpmn:collaboration>
<bpmn:choreography id="Choreography_1" name="Permit decision">
<bpmn:startEvent id="Choreography_Start" />
<bpmn:choreographyTask
id="Choreography_Task"
initiatingParticipantRef="Participant_Authority">
<bpmn:participantRef>Participant_Authority</bpmn:participantRef>
<bpmn:participantRef>Participant_Applicant</bpmn:participantRef>
<bpmn:messageFlowRef>MessageFlow_Approval</bpmn:messageFlowRef>
</bpmn:choreographyTask>
<bpmn:endEvent id="Choreography_End" />
<bpmn:sequenceFlow
id="Choreography_Flow_1"
sourceRef="Choreography_Start"
targetRef="Choreography_Task" />
<bpmn:sequenceFlow
id="Choreography_Flow_2"
sourceRef="Choreography_Task"
targetRef="Choreography_End" />
</bpmn:choreography>
<bpmn:messageFlow
id="MessageFlow_Approval"
sourceRef="Participant_Authority"
targetRef="Participant_Applicant"
messageRef="Message_Approval" />
</bpmn:definitions>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
id="Definitions_Collaboration"
targetNamespace="urn:govoplan:workflow:fixtures">
<bpmn:message id="Message_Request" name="Request" />
<bpmn:process id="Process_Requester">
<bpmn:startEvent id="Requester_Start" />
<bpmn:sendTask id="Send_Request" messageRef="Message_Request" />
</bpmn:process>
<bpmn:process id="Process_Reviewer">
<bpmn:receiveTask id="Receive_Request" messageRef="Message_Request" />
<bpmn:endEvent id="Reviewer_End" />
</bpmn:process>
<bpmn:collaboration id="Collaboration_1">
<bpmn:participant id="Participant_Requester" processRef="Process_Requester" />
<bpmn:participant id="Participant_Reviewer" processRef="Process_Reviewer" />
<bpmn:messageFlow
id="MessageFlow_1"
sourceRef="Send_Request"
targetRef="Receive_Request"
messageRef="Message_Request" />
</bpmn:collaboration>
</bpmn:definitions>
+91
View File
@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
id="Definitions_Control_Flow"
targetNamespace="urn:govoplan:workflow:fixtures">
<bpmn:signal id="Signal_Escalation" name="Escalation" />
<bpmn:process id="Called_Process" isExecutable="true">
<bpmn:startEvent id="Called_Start" />
<bpmn:userTask id="Called_Human_Task" name="Confirm result" />
<bpmn:endEvent id="Called_End" />
<bpmn:sequenceFlow
id="Called_Flow_1"
sourceRef="Called_Start"
targetRef="Called_Human_Task" />
<bpmn:sequenceFlow
id="Called_Flow_2"
sourceRef="Called_Human_Task"
targetRef="Called_End" />
</bpmn:process>
<bpmn:process id="Control_Process" isExecutable="true">
<bpmn:startEvent id="Control_Start" />
<bpmn:exclusiveGateway id="Control_Decision" />
<bpmn:subProcess id="Review_Subprocess" name="Review">
<bpmn:startEvent id="Subprocess_Start" />
<bpmn:userTask id="Subprocess_Review" name="Review request" />
<bpmn:endEvent id="Subprocess_End" />
<bpmn:sequenceFlow
id="Subprocess_Flow_1"
sourceRef="Subprocess_Start"
targetRef="Subprocess_Review" />
<bpmn:sequenceFlow
id="Subprocess_Flow_2"
sourceRef="Subprocess_Review"
targetRef="Subprocess_End" />
</bpmn:subProcess>
<bpmn:boundaryEvent
id="Review_Escalation"
attachedToRef="Review_Subprocess"
cancelActivity="false">
<bpmn:signalEventDefinition
id="Review_Escalation_Definition"
signalRef="Signal_Escalation" />
</bpmn:boundaryEvent>
<bpmn:parallelGateway id="Control_Join" />
<bpmn:callActivity
id="Call_Confirmation"
name="Confirm"
calledElement="Called_Process" />
<bpmn:intermediateThrowEvent id="Escalation_Thrown">
<bpmn:signalEventDefinition
id="Escalation_Thrown_Definition"
signalRef="Signal_Escalation" />
</bpmn:intermediateThrowEvent>
<bpmn:task
id="Compensation_Handler"
name="Undo review"
isForCompensation="true" />
<bpmn:boundaryEvent
id="Review_Compensation"
attachedToRef="Review_Subprocess">
<bpmn:compensateEventDefinition
id="Review_Compensation_Definition"
activityRef="Compensation_Handler" />
</bpmn:boundaryEvent>
<bpmn:endEvent id="Control_End" />
<bpmn:sequenceFlow
id="Control_Flow_1"
sourceRef="Control_Start"
targetRef="Control_Decision" />
<bpmn:sequenceFlow
id="Control_Flow_2"
sourceRef="Control_Decision"
targetRef="Review_Subprocess" />
<bpmn:sequenceFlow
id="Control_Flow_3"
sourceRef="Review_Subprocess"
targetRef="Control_Join" />
<bpmn:sequenceFlow
id="Control_Flow_4"
sourceRef="Control_Join"
targetRef="Call_Confirmation" />
<bpmn:sequenceFlow
id="Control_Flow_5"
sourceRef="Call_Confirmation"
targetRef="Escalation_Thrown" />
<bpmn:sequenceFlow
id="Control_Flow_6"
sourceRef="Escalation_Thrown"
targetRef="Control_End" />
</bpmn:process>
</bpmn:definitions>
+27
View File
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
id="Definitions_Data"
targetNamespace="urn:govoplan:workflow:fixtures">
<bpmn:dataStore id="DataStore_Archive" name="Archive" />
<bpmn:process id="Process_Data">
<bpmn:dataObject id="DataObject_Request" name="Request" />
<bpmn:dataObjectReference
id="DataObjectReference_Request"
dataObjectRef="DataObject_Request" />
<bpmn:dataStoreReference
id="DataStoreReference_Archive"
dataStoreRef="DataStore_Archive" />
<bpmn:scriptTask id="Transform_Data" name="Transform data">
<bpmn:script>result = input</bpmn:script>
<bpmn:dataInputAssociation id="InputAssociation_1">
<bpmn:sourceRef>DataObjectReference_Request</bpmn:sourceRef>
<bpmn:targetRef>Transform_Data</bpmn:targetRef>
</bpmn:dataInputAssociation>
<bpmn:dataOutputAssociation id="OutputAssociation_1">
<bpmn:sourceRef>Transform_Data</bpmn:sourceRef>
<bpmn:targetRef>DataStoreReference_Archive</bpmn:targetRef>
</bpmn:dataOutputAssociation>
</bpmn:scriptTask>
</bpmn:process>
</bpmn:definitions>
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
id="Definitions_Events"
targetNamespace="urn:govoplan:workflow:fixtures">
<bpmn:message id="Message_Continue" name="Continue" />
<bpmn:error id="Error_Processing" name="Processing failed" errorCode="PROCESSING" />
<bpmn:process id="Process_Transaction" isExecutable="true">
<bpmn:startEvent id="Start_Timer">
<bpmn:timerEventDefinition id="Timer_Start_Definition">
<bpmn:timeCycle>R3/PT1H</bpmn:timeCycle>
</bpmn:timerEventDefinition>
</bpmn:startEvent>
<bpmn:transaction id="Transaction_1">
<bpmn:serviceTask id="Charge_Account" name="Charge account" />
<bpmn:boundaryEvent
id="Charge_Error"
attachedToRef="Charge_Account">
<bpmn:errorEventDefinition
id="Charge_Error_Definition"
errorRef="Error_Processing" />
</bpmn:boundaryEvent>
<bpmn:task
id="Undo_Charge"
name="Undo charge"
isForCompensation="true" />
<bpmn:association
id="Compensation_Association"
sourceRef="Charge_Error"
targetRef="Undo_Charge"
associationDirection="One" />
</bpmn:transaction>
<bpmn:intermediateCatchEvent id="Wait_For_Continue">
<bpmn:messageEventDefinition
id="Wait_Message_Definition"
messageRef="Message_Continue" />
</bpmn:intermediateCatchEvent>
<bpmn:endEvent id="End_Transaction">
<bpmn:terminateEventDefinition id="Terminate_Definition" />
</bpmn:endEvent>
</bpmn:process>
</bpmn:definitions>
+50
View File
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
xmlns:dc="http://www.omg.org/spec/DD/20100524/DC"
xmlns:di="http://www.omg.org/spec/DD/20100524/DI"
xmlns:govoplan="urn:govoplan:workflow:fixture-extension"
id="Definitions_Process"
targetNamespace="urn:govoplan:workflow:fixtures">
<bpmn:process id="Process_Linear" isExecutable="true">
<bpmn:extensionElements>
<govoplan:fixture revision="1">
<govoplan:note>Preserve this extension exactly.</govoplan:note>
</govoplan:fixture>
</bpmn:extensionElements>
<bpmn:startEvent id="Start_1">
<bpmn:outgoing>Flow_1</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:userTask id="Task_1" name="Review">
<bpmn:incoming>Flow_1</bpmn:incoming>
<bpmn:outgoing>Flow_2</bpmn:outgoing>
</bpmn:userTask>
<bpmn:endEvent id="End_1">
<bpmn:incoming>Flow_2</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_1" sourceRef="Start_1" targetRef="Task_1" />
<bpmn:sequenceFlow id="Flow_2" sourceRef="Task_1" targetRef="End_1" />
</bpmn:process>
<bpmndi:BPMNDiagram id="Diagram_1">
<bpmndi:BPMNPlane id="Plane_1" bpmnElement="Process_Linear">
<bpmndi:BPMNShape id="Shape_Start_1" bpmnElement="Start_1">
<dc:Bounds x="80" y="112" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Shape_Task_1" bpmnElement="Task_1">
<dc:Bounds x="220" y="90" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Shape_End_1" bpmnElement="End_1">
<dc:Bounds x="430" y="112" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Edge_Flow_1" bpmnElement="Flow_1">
<di:waypoint x="116" y="130" />
<di:waypoint x="220" y="130" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Edge_Flow_2" bpmnElement="Flow_2">
<di:waypoint x="320" y="130" />
<di:waypoint x="430" y="130" />
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>
+271 -1
View File
@@ -1,10 +1,26 @@
from __future__ import annotations
from collections import Counter
import unittest
from pathlib import Path
from govoplan_workflow.backend.bpmn import (
BPMN_MODEL_NAMESPACE,
BpmnInspectionError,
inspect_bpmn_xml,
parse_bpmn_xml,
)
from govoplan_workflow.backend.bpmn_adapters import (
BpmnAdapterError,
INTERCHANGE_ADAPTER_ID,
NATIVE_LINEAR_ADAPTER_ID,
bpmn_adapter_registry,
compile_bpmn_to_graph,
)
from govoplan_workflow.backend.bpmn_graph import (
NATIVE_BPMN_ADAPTER_ID,
export_bpmn_graph,
import_bpmn_graph,
)
@@ -28,8 +44,91 @@ BPMN = """<?xml version="1.0" encoding="UTF-8"?>
</bpmn:definitions>
"""
NATIVE_BPMN = """<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
xmlns:dc="http://www.omg.org/spec/DD/20100524/DC"
id="Definitions_native"
targetNamespace="https://govoplan.example.test/workflow/native">
<bpmn:process id="Process_native" isExecutable="true">
<bpmn:startEvent id="Start_native" />
<bpmn:userTask id="Review_native" name="Review request" />
<bpmn:endEvent id="End_native" />
<bpmn:sequenceFlow id="Flow_start_review" sourceRef="Start_native" targetRef="Review_native" />
<bpmn:sequenceFlow id="Flow_review_end" sourceRef="Review_native" targetRef="End_native" />
</bpmn:process>
<bpmndi:BPMNDiagram id="Diagram_native">
<bpmndi:BPMNPlane id="Plane_native" bpmnElement="Process_native">
<bpmndi:BPMNShape id="Shape_start" bpmnElement="Start_native">
<dc:Bounds x="40" y="120" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Shape_review" bpmnElement="Review_native">
<dc:Bounds x="220" y="90" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Shape_end" bpmnElement="End_native">
<dc:Bounds x="460" y="120" width="36" height="36" />
</bpmndi:BPMNShape>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>
"""
class BpmnInspectionTests(unittest.TestCase):
def test_notation_fixtures_are_safe_and_fully_inventoried(self) -> None:
fixture_directory = Path(__file__).parent / "fixtures" / "bpmn"
results = {
path.stem: inspect_bpmn_xml(path.read_text(encoding="utf-8"))
for path in sorted(fixture_directory.glob("*.bpmn"))
}
self.assertEqual(
{
"choreography",
"collaboration",
"control-flow",
"data",
"events-transaction-compensation",
"process",
},
set(results),
)
self.assertEqual(1, results["choreography"].choreography_count)
self.assertEqual(1, results["collaboration"].collaboration_count)
self.assertEqual(
1,
results["events-transaction-compensation"].element_counts[
"transaction"
],
)
self.assertEqual(
1,
results["data"].element_counts["dataStoreReference"],
)
self.assertEqual(
1,
results["control-flow"].element_counts["exclusiveGateway"],
)
self.assertEqual(
1,
results["control-flow"].element_counts["subProcess"],
)
self.assertEqual(
1,
results["control-flow"].element_counts["callActivity"],
)
self.assertEqual(
1,
results["control-flow"].element_counts[
"compensateEventDefinition"
],
)
self.assertEqual(
2,
results["control-flow"].element_counts["signalEventDefinition"],
)
def test_inventory_classifies_native_and_interchange_elements(self) -> None:
result = inspect_bpmn_xml(BPMN)
@@ -47,7 +146,7 @@ class BpmnInspectionTests(unittest.TestCase):
if item.element_id == "Collaboration_1"
)
self.assertEqual("native_execution", review.support_level)
self.assertEqual("interchange_only", collaboration.support_level)
self.assertEqual("native_mapping", collaboration.support_level)
def test_dangling_references_are_reported(self) -> None:
result = inspect_bpmn_xml(
@@ -80,6 +179,177 @@ class BpmnInspectionTests(unittest.TestCase):
):
inspect_bpmn_xml("<definitions />")
def test_profiles_are_versioned_and_native_compilation_is_stable(self) -> None:
profiles = {
item.id: item for item in bpmn_adapter_registry().profiles()
}
self.assertFalse(profiles[INTERCHANGE_ADAPTER_ID].executable)
self.assertTrue(profiles[NATIVE_LINEAR_ADAPTER_ID].executable)
self.assertTrue(profiles[NATIVE_BPMN_ADAPTER_ID].executable)
adapter, inspection, graph = compile_bpmn_to_graph(
NATIVE_BPMN,
adapter_id=NATIVE_LINEAR_ADAPTER_ID,
)
self.assertEqual("1.0.0", adapter.profile.version)
self.assertTrue(inspection.valid_xml)
self.assertIsNotNone(graph)
assert graph is not None
self.assertEqual(
[
"workflow.start.manual",
"workflow.activity",
"workflow.end.completed",
],
[item.type for item in graph.nodes],
)
self.assertEqual(220, graph.nodes[1].position.x)
self.assertEqual(
["Flow_start_review", "Flow_review_end"],
[item.id for item in graph.edges],
)
def test_native_profile_rejects_semantics_it_cannot_execute(self) -> None:
with self.assertRaisesRegex(
BpmnAdapterError,
"exclusiveGateway is not supported",
):
compile_bpmn_to_graph(
BPMN,
adapter_id=NATIVE_LINEAR_ADAPTER_ID,
)
def test_model_only_profile_remains_read_compatible(self) -> None:
_adapter, _inspection, graph = compile_bpmn_to_graph(
BPMN,
adapter_id=INTERCHANGE_ADAPTER_ID,
)
self.assertIsNone(graph)
def test_native_bpmn_graph_imports_full_notation_and_round_trips(self) -> None:
graph = import_bpmn_graph(BPMN)
self.assertEqual(
[
"bpmn.startEvent",
"bpmn.userTask",
"bpmn.exclusiveGateway",
"bpmn.endEvent",
"bpmn.participant",
],
[node.type for node in graph.nodes],
)
self.assertTrue(
all(edge.type == "bpmn.sequenceFlow" for edge in graph.edges)
)
rendered = export_bpmn_graph(graph, name="Round trip")
imported = import_bpmn_graph(rendered)
self.assertEqual(
[(node.id, node.type) for node in graph.nodes],
[(node.id, node.type) for node in imported.nodes],
)
self.assertEqual(
[(edge.id, edge.type, edge.source, edge.target) for edge in graph.edges],
[
(edge.id, edge.type, edge.source, edge.target)
for edge in imported.edges
],
)
def test_all_bpmn_fixtures_round_trip_through_the_native_graph(self) -> None:
fixture_directory = Path(__file__).parent / "fixtures" / "bpmn"
for path in sorted(fixture_directory.glob("*.bpmn")):
with self.subTest(path=path.name):
graph = import_bpmn_graph(path.read_text(encoding="utf-8"))
imported = import_bpmn_graph(
export_bpmn_graph(graph, name=path.stem)
)
self.assertEqual(
Counter(node.type for node in graph.nodes),
Counter(node.type for node in imported.nodes),
)
self.assertEqual(
Counter(edge.type for edge in graph.edges),
Counter(edge.type for edge in imported.edges),
)
self.assertEqual(len(graph.nodes), len(imported.nodes))
self.assertEqual(len(graph.edges), len(imported.edges))
def test_nested_flows_remain_in_their_bpmn_container(self) -> None:
fixture = (
Path(__file__).parent
/ "fixtures"
/ "bpmn"
/ "events-transaction-compensation.bpmn"
)
rendered = export_bpmn_graph(
import_bpmn_graph(fixture.read_text(encoding="utf-8"))
)
root = parse_bpmn_xml(rendered)
transaction = next(
item
for item in root.iter()
if item.tag == f"{{{BPMN_MODEL_NAMESPACE}}}transaction"
)
self.assertTrue(
any(
child.tag == f"{{{BPMN_MODEL_NAMESPACE}}}association"
and child.attrib.get("id") == "Compensation_Association"
for child in transaction
)
)
def test_default_flow_is_an_editable_edge_property(self) -> None:
source = BPMN.replace(
'<bpmn:exclusiveGateway id="Decision_1" />',
'<bpmn:exclusiveGateway id="Decision_1" default="Flow_3" />',
)
graph = import_bpmn_graph(source)
default_edge = next(edge for edge in graph.edges if edge.id == "Flow_3")
self.assertIs(default_edge.config.get("default"), True)
rendered = export_bpmn_graph(graph)
self.assertIn('default="Flow_3"', rendered)
graph.edges = [
edge.model_copy(update={"config": {**edge.config, "default": False}})
if edge.id == "Flow_3"
else edge
for edge in graph.edges
]
rendered_without_default = export_bpmn_graph(graph)
self.assertNotIn('default="Flow_3"', rendered_without_default)
def test_native_graph_import_is_separate_from_runtime_support(self) -> None:
_adapter, _inspection, graph = compile_bpmn_to_graph(
BPMN,
adapter_id=NATIVE_BPMN_ADAPTER_ID,
)
self.assertIsNotNone(graph)
with self.assertRaisesRegex(
BpmnAdapterError,
"exclusive gateway",
):
compile_bpmn_to_graph(
BPMN,
adapter_id=NATIVE_BPMN_ADAPTER_ID,
activation=True,
)
def test_adapter_versions_are_resolved_exactly_when_pinned(self) -> None:
with self.assertRaisesRegex(BpmnAdapterError, "is not installed"):
compile_bpmn_to_graph(
NATIVE_BPMN,
adapter_id=NATIVE_LINEAR_ADAPTER_ID,
adapter_version="9.0.0",
)
if __name__ == "__main__":
unittest.main()
+6 -1
View File
@@ -27,7 +27,12 @@ from govoplan_workflow.backend.service import (
derive_definition,
update_definition,
)
from test_service import sample_graph
try:
from test_service import sample_graph
except ModuleNotFoundError as exc:
if exc.name != "test_service":
raise
from tests.test_service import sample_graph
POLICY_CAPABILITY = "policy.definitionGovernance"
+506 -1
View File
@@ -11,7 +11,15 @@ from govoplan_core.core.access import (
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
PrincipalRef,
)
from govoplan_core.core.automation import AutomationPrincipalResolution
from govoplan_core.core.automation import (
ActionDefinition,
ActionExecutionResult,
ActionPreview,
AutomationPrincipalResolution,
EffectDefinition,
EffectPreview,
ObservedEffect,
)
from govoplan_core.core.dataflows import (
CAPABILITY_DATAFLOW_RUN_LIFECYCLE,
DataflowRunDescriptor,
@@ -33,6 +41,7 @@ from govoplan_workflow.backend.instance_service import (
start_instance,
)
from govoplan_workflow.backend.schemas import (
BpmnRevisionInput,
WorkflowDefinitionCreateRequest,
WorkflowEdge,
WorkflowGraph,
@@ -40,11 +49,18 @@ from govoplan_workflow.backend.schemas import (
WorkflowNode,
WorkflowStepActionRequest,
)
from govoplan_workflow.backend.bpmn_adapters import NATIVE_LINEAR_ADAPTER_ID
from govoplan_workflow.backend.service import (
WorkflowConflictError,
activate_definition,
create_definition,
)
try:
from test_bpmn import NATIVE_BPMN
except ModuleNotFoundError as exc:
if exc.name != "test_bpmn":
raise
from tests.test_bpmn import NATIVE_BPMN
def principal() -> ApiPrincipal:
@@ -89,6 +105,10 @@ def runtime_graph() -> WorkflowGraph:
"publication_target_ref": "",
"warning_policy": "review",
"input_mapping": {},
"view_surface_ids": [
"dataflow.module",
"dataflow.route.pipelines",
],
},
),
WorkflowNode(
@@ -138,6 +158,58 @@ def runtime_graph() -> WorkflowGraph:
)
def action_graph() -> WorkflowGraph:
return WorkflowGraph(
nodes=[
WorkflowNode(
id="start",
type="workflow.start.manual",
config={"input_schema_ref": ""},
),
WorkflowNode(
id="action",
type="workflow.capability",
config={
"capability": "test.actions",
"operation": "test.case.record",
"input_mapping": {"case_id": "$input.case_id"},
"idempotency_key": "$input.case_id",
"failure_policy": "manual",
},
),
WorkflowNode(
id="complete",
type="workflow.end.completed",
config={"output_mapping": {}},
),
WorkflowNode(
id="failed",
type="workflow.end.cancelled",
config={"reason": "Action rejected"},
),
],
edges=[
WorkflowEdge(
id="start-action",
source="start",
target="action",
),
WorkflowEdge(
id="action-complete",
source="action",
source_port="success",
target="complete",
),
WorkflowEdge(
id="action-failed",
source="action",
source_port="failure",
target="failed",
),
],
)
class FakeDataflowLifecycle:
def __init__(self) -> None:
self.runs: dict[str, DataflowRunDescriptor] = {}
@@ -208,19 +280,87 @@ class FakeAutomationProvider:
)
class FakeActionProvider:
action = ActionDefinition(
action_key="test.case.record",
owner_module="test",
description="Record a test case.",
input_schema_ref="schema:test.case.record@1",
expected_effect_keys=("test.case.recorded",),
)
effect = EffectDefinition(
effect_key="test.case.recorded",
owner_module="test",
operation="created",
description="A test case was recorded.",
)
def __init__(self, *states: str) -> None:
self.states = list(states or ("completed",))
self.requests = []
def action_definitions(self):
return (self.action,)
def effect_definitions(self):
return (self.effect,)
def preview_action(self, _session, _principal, *, request):
return ActionPreview(
action_key=request.action_key,
allowed=True,
summary="Record one case.",
risk_level=self.action.risk_level,
reversibility=self.action.reversibility,
effects=(
EffectPreview(
effect_key=self.effect.effect_key,
summary="Record case.",
),
),
preview_ref="preview:test",
)
def execute_action(self, _session, _principal, *, request):
self.requests.append(request)
state = self.states.pop(0) if self.states else "completed"
if state != "completed":
return ActionExecutionResult(
state=state,
error="Temporary action failure.",
)
return ActionExecutionResult(
state="completed",
output={"case_ref": "case:1"},
observed_effects=(
ObservedEffect(
effect_key=self.effect.effect_key,
operation="created",
resource_ref="case:1",
),
),
audit_event_refs=("audit:1",),
)
class Registry:
def __init__(
self,
dataflow: FakeDataflowLifecycle,
automation: FakeAutomationProvider | None = None,
action: FakeActionProvider | None = None,
) -> None:
self.dataflow = dataflow
self.automation = automation
self.action = action
def has_capability(self, name: str) -> bool:
return name == CAPABILITY_DATAFLOW_RUN_LIFECYCLE or (
name == CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER
and self.automation is not None
) or (
name == "test.actions"
and self.action is not None
)
def capability(self, name: str):
@@ -231,6 +371,8 @@ class Registry:
and self.automation is not None
):
return self.automation
if name == "test.actions" and self.action is not None:
return self.action
raise KeyError(name)
@@ -258,6 +400,9 @@ class WorkflowInstanceServiceTests(unittest.TestCase):
payload=WorkflowDefinitionCreateRequest(
name="Monthly governed processing",
graph=runtime_graph(),
execution_mode="hybrid",
view_id="view-1",
view_revision_id="view-revision-1",
),
)
activate_definition(
@@ -319,8 +464,19 @@ class WorkflowInstanceServiceTests(unittest.TestCase):
self.assertTrue(was_replayed)
self.assertEqual(instance.id, replayed.id)
self.assertEqual("waiting", instance.status)
self.assertEqual("user", instance.start_origin)
self.assertEqual(1, len(self.dataflow.requests))
response = instance_response(self.session, instance)
self.assertEqual("hybrid", response.execution_mode)
self.assertEqual("user", response.start_origin)
self.assertIsNotNone(response.view_context)
self.assertEqual(
[
"dataflow.module",
"dataflow.route.pipelines",
],
response.view_context.visible_surface_ids,
)
self.assertEqual([1, 2], [step.sequence for step in response.steps])
self.assertEqual("run:1", response.steps[-1].external_ref)
self.assertGreaterEqual(len(response.events), 4)
@@ -340,6 +496,306 @@ class WorkflowInstanceServiceTests(unittest.TestCase):
),
)
def test_guided_workflow_rejects_automated_start_origin(self) -> None:
definition = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="account-1",
payload=WorkflowDefinitionCreateRequest(
name="Guided review",
graph=WorkflowGraph(
nodes=[
WorkflowNode(
id="start",
type="workflow.start.api",
config={
"input_schema_ref": "schema:input",
"authorization_policy_ref": "policy:start",
},
),
WorkflowNode(
id="activity",
type="workflow.activity",
config={"title": "Review"},
),
WorkflowNode(
id="done",
type="workflow.end.completed",
),
],
edges=[
WorkflowEdge(
id="start-activity",
source="start",
target="activity",
),
WorkflowEdge(
id="activity-done",
source="activity",
target="done",
),
],
),
execution_mode="guided",
allow_automation=True,
),
)
activate_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="account-1",
)
with self.assertRaisesRegex(
WorkflowConflictError,
"Guided workflows must be started by a user",
):
start_instance(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="account-1",
principal=principal(),
registry=self.registry,
payload=WorkflowInstanceStartRequest(
idempotency_key="automated-guided",
),
start_origin="api",
)
def test_module_action_records_effects_and_completes_idempotently(
self,
) -> None:
action = FakeActionProvider()
registry = Registry(self.dataflow, action=action)
definition = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="account-1",
payload=WorkflowDefinitionCreateRequest(
name="Action workflow",
graph=action_graph(),
),
)
activate_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="account-1",
)
instance, replayed = start_instance(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="account-1",
principal=principal(),
registry=registry,
payload=WorkflowInstanceStartRequest(
idempotency_key="action-instance",
input={"case_id": "case-1"},
),
)
response = instance_response(self.session, instance)
self.assertFalse(replayed)
self.assertEqual("completed", response.status)
self.assertEqual(1, len(action.requests))
self.assertEqual(
"test.actions:test.case.record:case-1",
action.requests[0].idempotency_key,
)
self.assertEqual(
"case:1",
response.steps[1].output["execution"]["observed_effects"][0][
"resource_ref"
],
)
self.assertIn(
"workflow.action.completed",
{event.kind for event in response.events},
)
def test_retryable_module_action_reuses_the_idempotency_key(self) -> None:
action = FakeActionProvider("retryable", "completed")
registry = Registry(self.dataflow, action=action)
definition = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="account-1",
payload=WorkflowDefinitionCreateRequest(
name="Retry action",
graph=action_graph(),
),
)
activate_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="account-1",
)
instance, _replayed = start_instance(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="account-1",
principal=principal(),
registry=registry,
payload=WorkflowInstanceStartRequest(
idempotency_key="retry-action-instance",
input={"case_id": "case-2"},
),
)
waiting = instance_response(self.session, instance)
current = waiting.steps[-1]
self.assertEqual("retryable", current.handoff["state"])
resolved = resolve_step(
self.session,
tenant_id="tenant-1",
instance_id=instance.id,
step_id=current.id,
actor_id="account-1",
principal=principal(),
registry=registry,
payload=WorkflowStepActionRequest(action="retry"),
)
self.assertEqual("completed", resolved.status)
self.assertEqual(
action.requests[0].idempotency_key,
action.requests[1].idempotency_key,
)
def test_automated_dataflow_failure_policy_fails_without_handoff(
self,
) -> None:
graph = runtime_graph()
flow = next(node for node in graph.nodes if node.id == "flow")
flow.config["warning_policy"] = "continue"
flow.config["failure_policy"] = "fail"
definition = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="account-1",
payload=WorkflowDefinitionCreateRequest(
name="Automated Dataflow",
graph=graph,
execution_mode="automated",
),
)
activate_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="account-1",
)
instance, _replayed = start_instance(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="account-1",
principal=principal(),
registry=self.registry,
payload=WorkflowInstanceStartRequest(
idempotency_key="automated-dataflow",
),
)
self.dataflow.fail("run:1")
changed = reconcile_instance(
self.session,
instance=instance,
principal=principal(),
registry=self.registry,
)
self.assertTrue(changed)
self.assertEqual("failed", instance.status)
self.assertIsNone(instance.current_step_id)
self.assertFalse(
any(
step.handoff.get("kind") == "dataflow_failure"
for step in instance.steps
)
)
def test_native_bpmn_profile_runs_through_canonical_instance_state(self) -> None:
definition = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="account-1",
payload=WorkflowDefinitionCreateRequest(
name="BPMN governed review",
graph=runtime_graph(),
bpmn=BpmnRevisionInput(
xml=NATIVE_BPMN,
adapter_id=NATIVE_LINEAR_ADAPTER_ID,
),
),
)
activate_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="account-1",
)
instance, replayed = start_instance(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="account-1",
principal=principal(),
registry=self.registry,
payload=WorkflowInstanceStartRequest(
idempotency_key="bpmn-request-1",
input={"case_id": "case-bpmn"},
),
)
self.assertFalse(replayed)
self.assertEqual("waiting", instance.status)
waiting = instance_response(self.session, instance).steps[-1]
self.assertEqual("workflow.activity", waiting.node_type)
self.assertEqual("Review request", waiting.handoff["title"])
replay, was_replayed = start_instance(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="account-1",
principal=principal(),
registry=self.registry,
payload=WorkflowInstanceStartRequest(
idempotency_key="bpmn-request-1",
input={"case_id": "case-bpmn"},
),
)
self.assertTrue(was_replayed)
self.assertEqual(instance.id, replay.id)
resolved = resolve_step(
self.session,
tenant_id="tenant-1",
instance_id=instance.id,
step_id=waiting.id,
actor_id="account-1",
principal=principal(),
registry=self.registry,
payload=WorkflowStepActionRequest(
action="complete",
comment="Reviewed.",
evidence=["case:case-bpmn"],
),
)
response = instance_response(self.session, resolved)
self.assertEqual("completed", response.status)
self.assertEqual(
"workflow.instance.completed",
response.events[-1].kind,
)
def test_reconcile_completes_with_stable_dataflow_output_refs(self) -> None:
instance = self._start()
self.dataflow.finish("run:1")
@@ -477,6 +933,55 @@ class WorkflowInstanceServiceTests(unittest.TestCase):
instance.authorization_["last_resolution"]["status"],
)
def test_worker_reconciles_pending_module_action(self) -> None:
action = FakeActionProvider("pending", "completed")
automation = FakeAutomationProvider()
registry = Registry(
self.dataflow,
automation=automation,
action=action,
)
definition = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="account-1",
payload=WorkflowDefinitionCreateRequest(
name="Asynchronous action",
graph=action_graph(),
),
)
activate_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="account-1",
)
instance, _replayed = start_instance(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="account-1",
principal=principal(),
registry=registry,
payload=WorkflowInstanceStartRequest(
idempotency_key="pending-action",
input={"case_id": "case-3"},
),
)
self.session.commit()
worker = SqlWorkflowRuntimeWorker(registry=registry)
summary = worker.reconcile_pending(self.session)
self.assertEqual(1, summary["advanced"])
self.assertEqual("completed", instance.status)
self.assertEqual(2, len(action.requests))
self.assertEqual(
action.requests[0].idempotency_key,
action.requests[1].idempotency_key,
)
self.assertEqual(1, len(automation.requests))
if __name__ == "__main__":
unittest.main()
+27 -1
View File
@@ -26,7 +26,7 @@ class WorkflowMigrationTests(unittest.TestCase):
try:
with engine.connect() as connection:
self.assertIn(
"d8f2a5c7e1b4",
"f1b7d3e5a9c2",
set(MigrationContext.configure(connection).get_current_heads()),
)
self.assertEqual(
@@ -43,6 +43,32 @@ class WorkflowMigrationTests(unittest.TestCase):
if name.startswith("workflow_")
},
)
revision_columns = {
item["name"]
for item in inspect(connection).get_columns(
"workflow_definition_revisions"
)
}
self.assertTrue(
{
"bpmn_xml",
"bpmn_hash",
"bpmn_adapter_id",
"bpmn_adapter_version",
"bpmn_runtime_kind",
"bpmn_executable",
"execution_mode",
"view_id",
"view_revision_id",
}.issubset(revision_columns)
)
instance_columns = {
item["name"]
for item in inspect(connection).get_columns(
"workflow_instances"
)
}
self.assertIn("start_origin", instance_columns)
finally:
engine.dispose()
+50 -1
View File
@@ -2,7 +2,10 @@ from __future__ import annotations
import unittest
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.schemas import WorkflowEdge, WorkflowGraph, WorkflowNode
from govoplan_workflow.backend.validation import validate_workflow_graph
@@ -91,6 +94,52 @@ class WorkflowNodeLibraryTests(unittest.TestCase):
def test_library_has_domain_specific_cycle_policy(self) -> None:
self.assertTrue(WORKFLOW_GRAPH_LIBRARY.constraints.allow_cycles)
self.assertEqual(WORKFLOW_GRAPH_LIBRARY.id, "workflow")
self.assertEqual("1.0.0", WORKFLOW_GRAPH_LIBRARY.version)
activity = WORKFLOW_GRAPH_LIBRARY.node_type("workflow.activity")
self.assertIn(
"view_surface_ids",
{field.id for field in activity.config_fields},
)
def test_native_palette_uses_standard_bpmn_vocabulary(self) -> None:
node_types = {item.type for item in BPMN_NODE_TYPES}
self.assertIn("bpmn.startEvent", node_types)
self.assertIn("bpmn.userTask", node_types)
self.assertIn("bpmn.exclusiveGateway", node_types)
self.assertIn("bpmn.participant", node_types)
self.assertIn("bpmn.textAnnotation", node_types)
self.assertTrue(all(item.startswith("bpmn.") for item in node_types))
def test_bpmn_rejects_multiple_default_flows(self) -> None:
graph = WorkflowGraph(
nodes=[
WorkflowNode(id="start", type="bpmn.startEvent"),
WorkflowNode(id="choice", type="bpmn.exclusiveGateway"),
WorkflowNode(id="end-a", type="bpmn.endEvent"),
WorkflowNode(id="end-b", type="bpmn.endEvent"),
],
edges=[
WorkflowEdge(id="to-choice", source="start", target="choice"),
WorkflowEdge(
id="default-a",
source="choice",
target="end-a",
config={"default": True},
),
WorkflowEdge(
id="default-b",
source="choice",
target="end-b",
config={"default": True},
),
],
)
self.assertIn(
"bpmn.multiple_default_flows",
{item.code for item in validate_workflow_graph(graph)},
)
if __name__ == "__main__":
+202
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import unittest
from pathlib import Path
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
@@ -11,6 +12,7 @@ from govoplan_workflow.backend.db.models import (
WorkflowDefinitionRevision,
)
from govoplan_workflow.backend.schemas import (
BpmnRevisionInput,
WorkflowDefinitionCreateRequest,
WorkflowDefinitionUpdateRequest,
WorkflowEdge,
@@ -19,6 +21,7 @@ from govoplan_workflow.backend.schemas import (
WorkflowPosition,
)
from govoplan_workflow.backend.service import (
WorkflowBpmnValidationError,
WorkflowConflictError,
WorkflowNotFoundError,
activate_definition,
@@ -29,6 +32,18 @@ from govoplan_workflow.backend.service import (
list_definitions,
update_definition,
)
from govoplan_workflow.backend.bpmn_adapters import (
INTERCHANGE_ADAPTER_ID,
NATIVE_LINEAR_ADAPTER_ID,
)
from govoplan_workflow.backend.bpmn import inspect_bpmn_xml
from govoplan_workflow.backend.bpmn_graph import NATIVE_BPMN_ADAPTER_ID
try:
from test_bpmn import BPMN, NATIVE_BPMN
except ModuleNotFoundError as exc:
if exc.name != "test_bpmn":
raise
from tests.test_bpmn import BPMN, NATIVE_BPMN
def sample_graph(*, title: str = "Review request") -> WorkflowGraph:
@@ -174,6 +189,70 @@ class WorkflowServiceTests(unittest.TestCase):
),
)
def test_execution_mode_and_view_pin_are_immutable_revision_content(
self,
) -> None:
definition = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="user-1",
payload=WorkflowDefinitionCreateRequest(
name="Guided review",
graph=sample_graph(),
execution_mode="guided",
view_id="view-1",
view_revision_id="view-revision-1",
),
)
updated = update_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="user-2",
payload=WorkflowDefinitionUpdateRequest(
name="Guided review",
graph=sample_graph(),
expected_revision=1,
execution_mode="hybrid",
view_id="view-1",
view_revision_id="view-revision-1",
),
)
revisions = list_definition_revisions(
self.session,
definition=updated,
)
self.assertEqual(2, updated.current_revision)
self.assertEqual("hybrid", revisions[0].execution_mode)
self.assertEqual("guided", revisions[1].execution_mode)
self.assertEqual("view-revision-1", revisions[1].view_revision_id)
self.assertNotEqual(revisions[0].content_hash, revisions[1].content_hash)
def test_automated_mode_rejects_human_handoff_paths(self) -> None:
definition = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="user-1",
payload=WorkflowDefinitionCreateRequest(
name="Invalid automation",
graph=sample_graph(),
execution_mode="automated",
allow_automation=True,
),
)
with self.assertRaisesRegex(
WorkflowConflictError,
"human handoff paths",
):
activate_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="user-1",
)
def test_stale_update_and_cross_tenant_access_are_rejected(self) -> None:
definition = self._create()
with self.assertRaises(WorkflowConflictError):
@@ -220,6 +299,129 @@ class WorkflowServiceTests(unittest.TestCase):
),
)
def test_bpmn_xml_and_adapter_are_pinned_to_immutable_revisions(self) -> None:
definition = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="user-1",
payload=WorkflowDefinitionCreateRequest(
name="BPMN review",
graph=sample_graph(),
bpmn=BpmnRevisionInput(
xml=NATIVE_BPMN,
adapter_id=NATIVE_LINEAR_ADAPTER_ID,
adapter_version="1.0.0",
),
),
)
self.session.commit()
first = list_definition_revisions(
self.session,
definition=definition,
)[0]
self.assertTrue(inspect_bpmn_xml(first.bpmn_xml or "").valid_xml)
self.assertEqual(NATIVE_BPMN_ADAPTER_ID, first.bpmn_adapter_id)
self.assertEqual("1.0.0", first.bpmn_adapter_version)
self.assertEqual("native_graph", first.bpmn_runtime_kind)
self.assertEqual(
"Review request",
first.graph["nodes"][1]["config"]["title"],
)
changed_xml = NATIVE_BPMN.replace(
"Review request",
"Review corrected request",
)
updated = update_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="user-2",
payload=WorkflowDefinitionUpdateRequest(
name="BPMN review",
graph=sample_graph(),
bpmn=BpmnRevisionInput(
xml=changed_xml,
adapter_id=NATIVE_LINEAR_ADAPTER_ID,
),
expected_revision=1,
),
)
activate_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="user-2",
revision=2,
)
self.session.commit()
revisions = list_definition_revisions(
self.session,
definition=updated,
)
self.assertEqual(2, updated.current_revision)
self.assertEqual(2, updated.active_revision)
self.assertIn("Review corrected request", revisions[0].bpmn_xml or "")
self.assertNotEqual(revisions[0].content_hash, revisions[1].content_hash)
def test_model_only_bpmn_revision_fails_closed_on_activation(self) -> None:
definition = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="user-1",
payload=WorkflowDefinitionCreateRequest(
name="Interchange model",
graph=sample_graph(),
bpmn=BpmnRevisionInput(
xml=BPMN,
adapter_id=INTERCHANGE_ADAPTER_ID,
),
),
)
self.session.commit()
with self.assertRaisesRegex(
WorkflowBpmnValidationError,
"exclusive gateway",
):
activate_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="user-1",
)
def test_interchange_revision_preserves_extension_xml_exactly(self) -> None:
xml = (
Path(__file__).parent
/ "fixtures"
/ "bpmn"
/ "process.bpmn"
).read_text(encoding="utf-8")
definition = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="user-1",
payload=WorkflowDefinitionCreateRequest(
name="Extended interchange model",
graph=sample_graph(),
bpmn=BpmnRevisionInput(
xml=xml,
adapter_id=INTERCHANGE_ADAPTER_ID,
adapter_version="1.0.0",
),
),
)
revision = list_definition_revisions(
self.session,
definition=definition,
)[0]
self.assertTrue(inspect_bpmn_xml(revision.bpmn_xml or "").valid_xml)
self.assertIn("fixture revision=\"1\"", revision.bpmn_xml or "")
if __name__ == "__main__":
unittest.main()