Implement native BPMN workflows and guided modes
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user