feat: extract headless workflow engine
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.modules import ModuleManifest
|
||||
from govoplan_core.core.policy import PolicyDecision
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.core.workflows import WorkflowDefinitionContribution
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_workflow_engine.backend.contributions import (
|
||||
reconcile_workflow_definition_contributions,
|
||||
reset_workflow_override_to_standard,
|
||||
)
|
||||
from govoplan_workflow_engine.backend.db.models import (
|
||||
WorkflowDefinition,
|
||||
WorkflowDefinitionRevision,
|
||||
WorkflowInstance,
|
||||
WorkflowInstanceEvent,
|
||||
WorkflowInstanceStep,
|
||||
)
|
||||
from govoplan_workflow_engine.backend.instance_service import start_instance
|
||||
from govoplan_workflow_engine.backend.schemas import (
|
||||
WorkflowDefinitionDeriveRequest,
|
||||
WorkflowDefinitionUpdateRequest,
|
||||
WorkflowEdge,
|
||||
WorkflowGraph,
|
||||
WorkflowInstanceStartRequest,
|
||||
WorkflowNode,
|
||||
)
|
||||
from govoplan_workflow_engine.backend.service import (
|
||||
WorkflowConflictError,
|
||||
definition_response,
|
||||
derive_definition,
|
||||
update_definition,
|
||||
)
|
||||
|
||||
|
||||
def contribution_graph(*, label: str = "Approve request") -> WorkflowGraph:
|
||||
return WorkflowGraph(
|
||||
nodes=[
|
||||
WorkflowNode(
|
||||
id="start",
|
||||
type="workflow.start.manual",
|
||||
label="Start",
|
||||
config={"input_schema_ref": ""},
|
||||
),
|
||||
WorkflowNode(
|
||||
id="review",
|
||||
type="workflow.activity",
|
||||
label=label,
|
||||
config={
|
||||
"title": label,
|
||||
"instructions": "",
|
||||
"assignee": "",
|
||||
"due_after": "",
|
||||
},
|
||||
),
|
||||
WorkflowNode(
|
||||
id="complete",
|
||||
type="workflow.end.completed",
|
||||
label="Complete",
|
||||
config={"output_mapping": {}},
|
||||
),
|
||||
],
|
||||
edges=[
|
||||
WorkflowEdge(id="start-review", source="start", target="review"),
|
||||
WorkflowEdge(id="review-complete", source="review", target="complete"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def contribution(
|
||||
*,
|
||||
version: str = "1.0.0",
|
||||
label: str = "Approve request",
|
||||
) -> WorkflowDefinitionContribution:
|
||||
return WorkflowDefinitionContribution(
|
||||
origin_module_id="permits",
|
||||
origin_module_version=version,
|
||||
definition_key="application-approval",
|
||||
name="Application approval",
|
||||
description="Module-provided approval baseline.",
|
||||
graph=contribution_graph(label=label).model_dump(mode="json"),
|
||||
allow_automation=False,
|
||||
metadata={"domain": "permits"},
|
||||
)
|
||||
|
||||
|
||||
def registry_for(item: WorkflowDefinitionContribution) -> PlatformRegistry:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="permits",
|
||||
name="Permits",
|
||||
version=item.origin_module_version,
|
||||
workflow_definitions=(item,),
|
||||
)
|
||||
)
|
||||
registry.validate()
|
||||
return registry
|
||||
|
||||
|
||||
def principal() -> ApiPrincipal:
|
||||
return ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id="membership-1",
|
||||
tenant_id="tenant-1",
|
||||
scopes=frozenset(
|
||||
{
|
||||
"workflow:definition:read",
|
||||
"workflow:definition:write",
|
||||
"workflow:instance:admin",
|
||||
}
|
||||
),
|
||||
),
|
||||
account=object(),
|
||||
user=object(),
|
||||
)
|
||||
|
||||
|
||||
class _DefinitionPolicy:
|
||||
def resolve_definition_action(self, *, request):
|
||||
return PolicyDecision(
|
||||
allowed=(
|
||||
request.action in {"view", "derive", "reuse"}
|
||||
and request.allow_reuse
|
||||
),
|
||||
reason=None,
|
||||
)
|
||||
|
||||
|
||||
class _PolicyRegistry:
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name == "policy.definitionGovernance"
|
||||
|
||||
def capability(self, name: str):
|
||||
if not self.has_capability(name):
|
||||
raise KeyError(name)
|
||||
return _DefinitionPolicy()
|
||||
|
||||
|
||||
class WorkflowContributionTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
WorkflowDefinition.__table__,
|
||||
WorkflowDefinitionRevision.__table__,
|
||||
WorkflowInstance.__table__,
|
||||
WorkflowInstanceStep.__table__,
|
||||
WorkflowInstanceEvent.__table__,
|
||||
],
|
||||
)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
self.session: Session = self.Session()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
Base.metadata.drop_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
WorkflowInstanceEvent.__table__,
|
||||
WorkflowInstanceStep.__table__,
|
||||
WorkflowInstance.__table__,
|
||||
WorkflowDefinitionRevision.__table__,
|
||||
WorkflowDefinition.__table__,
|
||||
],
|
||||
)
|
||||
self.engine.dispose()
|
||||
|
||||
def test_reconciliation_is_idempotent_and_does_not_auto_activate_updates(self) -> None:
|
||||
first = reconcile_workflow_definition_contributions(
|
||||
self.session,
|
||||
registry=registry_for(contribution()),
|
||||
)
|
||||
repeated = reconcile_workflow_definition_contributions(
|
||||
self.session,
|
||||
registry=registry_for(contribution()),
|
||||
)
|
||||
upgraded = reconcile_workflow_definition_contributions(
|
||||
self.session,
|
||||
registry=registry_for(
|
||||
contribution(version="1.1.0", label="Approve corrected request")
|
||||
),
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
definition = self.session.scalar(select(WorkflowDefinition))
|
||||
self.assertIsNotNone(definition)
|
||||
assert definition is not None
|
||||
self.assertEqual(1, first["created"])
|
||||
self.assertEqual(1, repeated["unchanged"])
|
||||
self.assertEqual(1, upgraded["updated"])
|
||||
self.assertEqual(2, definition.current_revision)
|
||||
self.assertEqual(1, definition.active_revision)
|
||||
self.assertEqual("permits", definition.standard_origin_module_id)
|
||||
self.assertEqual("1.1.0", definition.standard_origin_module_version)
|
||||
self.assertEqual(2, len(definition.revisions))
|
||||
self.assertNotEqual(
|
||||
definition.revisions[0].contribution_hash,
|
||||
definition.revisions[1].contribution_hash,
|
||||
)
|
||||
|
||||
def test_standard_is_immutable_and_reset_archives_only_the_override(self) -> None:
|
||||
reconcile_workflow_definition_contributions(
|
||||
self.session,
|
||||
registry=registry_for(contribution()),
|
||||
)
|
||||
baseline = self.session.scalar(select(WorkflowDefinition))
|
||||
assert baseline is not None
|
||||
with self.assertRaisesRegex(WorkflowConflictError, "immutable"):
|
||||
update_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=baseline.id,
|
||||
actor_id="account-1",
|
||||
payload=WorkflowDefinitionUpdateRequest(
|
||||
name=baseline.name,
|
||||
graph=contribution_graph(),
|
||||
expected_revision=baseline.current_revision,
|
||||
scope_type="system",
|
||||
),
|
||||
)
|
||||
override = derive_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
actor_id="account-1",
|
||||
principal=principal(),
|
||||
registry=_PolicyRegistry(),
|
||||
source_definition_id=baseline.id,
|
||||
payload=WorkflowDefinitionDeriveRequest(
|
||||
name="Tenant approval override",
|
||||
scope_type="tenant",
|
||||
),
|
||||
)
|
||||
initial = definition_response(
|
||||
self.session,
|
||||
override,
|
||||
principal=principal(),
|
||||
registry=_PolicyRegistry(),
|
||||
)
|
||||
self.assertIsNotNone(initial.standard)
|
||||
assert initial.standard is not None
|
||||
self.assertFalse(initial.standard.update_available)
|
||||
|
||||
reconcile_workflow_definition_contributions(
|
||||
self.session,
|
||||
registry=registry_for(
|
||||
contribution(version="1.1.0", label="Approve corrected request")
|
||||
),
|
||||
)
|
||||
drifted = definition_response(
|
||||
self.session,
|
||||
override,
|
||||
principal=principal(),
|
||||
registry=_PolicyRegistry(),
|
||||
)
|
||||
self.assertIsNotNone(drifted.standard)
|
||||
assert drifted.standard is not None
|
||||
self.assertTrue(drifted.standard.update_available)
|
||||
|
||||
restored = reset_workflow_override_to_standard(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=override.id,
|
||||
actor_id="account-1",
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
self.assertEqual(baseline.id, restored.id)
|
||||
self.assertEqual("archived", override.status)
|
||||
self.assertEqual("active", baseline.status)
|
||||
self.assertEqual(
|
||||
baseline.id,
|
||||
override.metadata_["standard_reset"]["baseline_definition_id"],
|
||||
)
|
||||
|
||||
def test_missing_requirements_fail_closed_without_creating_a_baseline(self) -> None:
|
||||
item = replace(
|
||||
contribution(),
|
||||
required_capabilities=("permits.approvalAction",),
|
||||
)
|
||||
result = reconcile_workflow_definition_contributions(
|
||||
self.session,
|
||||
registry=registry_for(item),
|
||||
)
|
||||
|
||||
self.assertEqual(1, result["blocked"])
|
||||
self.assertIsNone(self.session.scalar(select(WorkflowDefinition)))
|
||||
self.assertEqual(
|
||||
["capability:permits.approvalAction"],
|
||||
result["items"][0]["missing"], # type: ignore[index]
|
||||
)
|
||||
|
||||
def test_domain_contribution_runs_without_the_editor_module(self) -> None:
|
||||
item = replace(
|
||||
contribution(),
|
||||
scope_type="tenant",
|
||||
inherit_to_lower_scopes=False,
|
||||
)
|
||||
registry = registry_for(item)
|
||||
result = reconcile_workflow_definition_contributions(
|
||||
self.session,
|
||||
registry=registry,
|
||||
tenant_ids=("tenant-1",),
|
||||
)
|
||||
definition = self.session.scalar(select(WorkflowDefinition))
|
||||
assert definition is not None
|
||||
|
||||
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="headless-domain-run",
|
||||
input={"request_id": "request-1"},
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(1, result["created"])
|
||||
self.assertFalse(replayed)
|
||||
self.assertEqual("waiting", instance.status)
|
||||
self.assertEqual(definition.revisions[0].id, instance.definition_revision_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user