Project durable workflow handoffs as work items

This commit is contained in:
2026-08-06 16:06:17 +02:00
parent 0259eea702
commit 9174e07118
10 changed files with 850 additions and 49 deletions
+104 -3
View File
@@ -43,6 +43,7 @@ from govoplan_core.core.runtime_coordination import (
RuntimeIdentity,
bind_process_runtime_identity,
)
from govoplan_core.core.tasks import WorkItemQuery
from govoplan_core.db.base import Base
from govoplan_core.db.base import utcnow
from govoplan_workflow_engine.backend.db.models import (
@@ -84,6 +85,7 @@ from govoplan_workflow_engine.backend.service import (
create_definition,
)
from govoplan_workflow_engine.backend.service_launcher import WorkflowServiceLauncher
from govoplan_workflow_engine.backend.work_items import WorkflowWorkItemProvider
try:
from test_bpmn import NATIVE_BPMN
@@ -687,6 +689,107 @@ class WorkflowInstanceServiceTests(unittest.TestCase):
start_origin="api",
)
def test_human_handoff_projects_typed_due_work_and_disappears_on_completion(
self,
) -> None:
definition = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="account-1",
payload=WorkflowDefinitionCreateRequest(
name="Guided case review",
graph=WorkflowGraph(
nodes=[
WorkflowNode(
id="start",
type="workflow.start.manual",
config={"input_schema_ref": ""},
),
WorkflowNode(
id="activity",
type="workflow.activity",
config={
"title": "Assess the application",
"instructions": "Record the assessment evidence.",
"assignee": "account:account-1",
"due_after": "2h",
},
),
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",
),
)
activate_definition(
self.session,
tenant_id="tenant-1",
definition_id=definition.id,
actor_id="account-1",
)
before = utcnow()
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="guided-work-1"),
)
step = self.session.get(WorkflowInstanceStep, instance.current_step_id)
self.assertIsNotNone(step)
assert step is not None
self.assertEqual("account", step.work_assignment_kind)
self.assertEqual("account-1", step.work_assignment_id)
self.assertIsNotNone(step.work_due_at)
assert step.work_due_at is not None
due_at = (
step.work_due_at.replace(tzinfo=UTC)
if step.work_due_at.tzinfo is None
else step.work_due_at
)
self.assertGreaterEqual(due_at, before + timedelta(hours=1, minutes=59))
provider = WorkflowWorkItemProvider(registry=self.registry)
page = provider.list_items(
self.session,
principal(),
query=WorkItemQuery(tenant_id="tenant-1"),
)
self.assertEqual(1, page.total)
self.assertEqual("Assess the application", page.items[0].title)
self.assertEqual("account-1", page.items[0].assignments[0].id)
self.assertEqual(step.id, page.items[0].id)
resolve_step(
self.session,
tenant_id="tenant-1",
instance_id=instance.id,
step_id=step.id,
actor_id="account-1",
principal=principal(),
registry=self.registry,
payload=WorkflowStepActionRequest(action="complete"),
)
closed_page = provider.list_items(
self.session,
principal(),
query=WorkItemQuery(tenant_id="tenant-1"),
)
self.assertEqual(0, closed_page.total)
def test_module_action_records_effects_and_completes_idempotently(
self,
) -> None:
@@ -990,9 +1093,7 @@ class WorkflowInstanceServiceTests(unittest.TestCase):
request=request,
)
checkpoint = session.scalar(
select(RecoveryCheckpoint).order_by(
RecoveryCheckpoint.sequence
)
select(RecoveryCheckpoint).order_by(RecoveryCheckpoint.sequence)
)
assert checkpoint is not None
checkpoint.summary = "tampered provider evidence"
+43 -5
View File
@@ -29,7 +29,7 @@ class WorkflowMigrationTests(unittest.TestCase):
try:
with engine.connect() as connection:
self.assertIn(
"e4a1f8c2d7b6",
"8d5a2f7c1b4e",
set(MigrationContext.configure(connection).get_current_heads()),
)
self.assertEqual(
@@ -102,7 +102,12 @@ class WorkflowMigrationTests(unittest.TestCase):
)
for path in current_revisions.glob("*.py"):
if path.name.startswith(
("0b4e7c9a2d6f_", "b2e4f6a8c0d1_", "e4a1f8c2d7b6_")
(
"0b4e7c9a2d6f_",
"b2e4f6a8c0d1_",
"e4a1f8c2d7b6_",
"8d5a2f7c1b4e_",
)
):
continue
shutil.copy2(path, legacy_revisions / path.name)
@@ -146,7 +151,7 @@ class WorkflowMigrationTests(unittest.TestCase):
manifest_factories=(get_manifest,),
)
self.assertIn("e4a1f8c2d7b6", result.current_revision or "")
self.assertIn("8d5a2f7c1b4e", result.current_revision or "")
engine = create_engine(url)
try:
upgraded_tables = set(inspect(engine).get_table_names())
@@ -181,6 +186,25 @@ class WorkflowMigrationTests(unittest.TestCase):
engine = create_engine(url)
try:
with engine.begin() as connection:
for index_name in (
"ix_workflow_instance_steps_work_assignment",
"ix_workflow_instance_steps_work_due_at",
"ix_workflow_instance_steps_work_assignment_id",
"ix_workflow_instance_steps_work_assignment_kind",
):
connection.execute(text(f"DROP INDEX {index_name}"))
for column_name in (
"work_due_at",
"work_assignment_label",
"work_assignment_id",
"work_assignment_kind",
):
connection.execute(
text(
"ALTER TABLE workflow_instance_steps "
f"DROP COLUMN {column_name}"
)
)
connection.execute(
text(
"ALTER TABLE workflow_definition_revisions "
@@ -196,7 +220,7 @@ class WorkflowMigrationTests(unittest.TestCase):
connection.execute(
text(
"UPDATE alembic_version SET version_num = "
"'b2e4f6a8c0d1' WHERE version_num = 'e4a1f8c2d7b6'"
"'b2e4f6a8c0d1' WHERE version_num = '8d5a2f7c1b4e'"
)
)
finally:
@@ -218,9 +242,23 @@ class WorkflowMigrationTests(unittest.TestCase):
}
self.assertIn("bpmn_runtime_kind", columns)
self.assertIn("bpmn_executable", columns)
step_columns = {
item["name"]
for item in inspect(engine).get_columns(
"workflow_instance_steps"
)
}
self.assertTrue(
{
"work_assignment_kind",
"work_assignment_id",
"work_assignment_label",
"work_due_at",
}.issubset(step_columns)
)
with engine.connect() as connection:
self.assertIn(
"e4a1f8c2d7b6",
"8d5a2f7c1b4e",
set(MigrationContext.configure(connection).get_current_heads()),
)
finally: