Project durable workflow handoffs as work items
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user