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
+18
View File
@@ -135,3 +135,21 @@ baseline revision inactive when an older revision is active, and fails closed
when required capabilities or interfaces are absent. Baselines are immutable; when required capabilities or interfaces are absent. Baselines are immutable;
editing derives a pinned local override, and reset archives that override editing derives a pinned local override, and reset archives that override
without removing revision or instance history. without removing revision or instance history.
## Common Work Inbox
Workflow Engine also projects the current human handoff of a waiting instance
through Core's versioned work-item provider contract. Tasks may aggregate that
projection when it is enabled, while Workflow Engine remains the sole owner of
the instance, step, allowed actions, and completion state.
Human activities and reviews accept account, group, role, function, and
function-assignment responsibility references. A missing assignee defaults to
the account that started the instance. `due_after` uses the same bounded
duration syntax as Workflow timers (`5m`, `2h`, `1d`, or ISO 8601). The Engine
materializes assignment and due-date columns on the current step so inbox reads
remain tenant-scoped and indexed; historical JSON handoffs are interpreted
conservatively for upgrade compatibility. Automated waits and in-flight
provider calls are not presented as human work. Recovery-required, unknown,
dependency, and failed handoffs appear as blocked work and always link back to
the pinned Workflow instance.
@@ -408,6 +408,13 @@ class WorkflowInstanceStep(Base, TimestampMixin):
"tenant_id", "tenant_id",
"status", "status",
), ),
Index(
"ix_workflow_instance_steps_work_assignment",
"tenant_id",
"status",
"work_assignment_kind",
"work_assignment_id",
),
) )
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
@@ -465,6 +472,25 @@ class WorkflowInstanceStep(Base, TimestampMixin):
String(255), String(255),
nullable=True, nullable=True,
) )
work_assignment_kind: Mapped[str | None] = mapped_column(
String(40),
nullable=True,
index=True,
)
work_assignment_id: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
index=True,
)
work_assignment_label: Mapped[str | None] = mapped_column(
String(500),
nullable=True,
)
work_due_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
index=True,
)
instance: Mapped[WorkflowInstance] = relationship(back_populates="steps") instance: Mapped[WorkflowInstance] = relationship(back_populates="steps")
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from datetime import datetime from datetime import datetime, timedelta
import hashlib import hashlib
import logging import logging
@@ -1294,8 +1294,7 @@ def _execute_capability_step(
action_input=action_input, action_input=action_input,
preview_payload=preview_payload, preview_payload=preview_payload,
backup_reference=( backup_reference=(
str(node.config.get("recovery_backup_reference") or "").strip() str(node.config.get("recovery_backup_reference") or "").strip() or None
or None
), ),
approval_reference=( approval_reference=(
str(node.config.get("recovery_approval_reference") or "").strip() str(node.config.get("recovery_approval_reference") or "").strip()
@@ -1368,11 +1367,7 @@ def _execute_capability_step(
"operation_id": exc.operation_id, "operation_id": exc.operation_id,
"status": recovery_status, "status": recovery_status,
"requires_attention": outcome_unknown, "requires_attention": outcome_unknown,
**( **({"next_call_number": call_number + 1} if safe_to_retry else {}),
{"next_call_number": call_number + 1}
if safe_to_retry
else {}
),
}, },
}, },
) )
@@ -1473,9 +1468,7 @@ def _execute_capability_step(
action_recovery.commit_unknown( action_recovery.commit_unknown(
session, session,
error_type=type(exc).__name__, error_type=type(exc).__name__,
message=( message=("Inspect the provider by stable idempotency key before any retry"),
"Inspect the provider by stable idempotency key before any retry"
),
) )
return True return True
if not isinstance(result, ActionExecutionResult): if not isinstance(result, ActionExecutionResult):
@@ -1608,9 +1601,7 @@ def _execute_capability_step(
session, session,
provider_state=result.state, provider_state=result.state,
result_sha256=canonical_sha256(result_payload), result_sha256=canonical_sha256(result_payload),
observed_effects_sha256=canonical_sha256( observed_effects_sha256=canonical_sha256(result_payload["observed_effects"]),
result_payload["observed_effects"]
),
) )
_drive_instance( _drive_instance(
session, session,
@@ -1915,6 +1906,10 @@ def _set_action_handoff(
"suggested_port": "failure", "suggested_port": "failure",
**details_payload, **details_payload,
} }
if state in {"pending", "running"}:
_clear_work_projection(step)
else:
_apply_work_projection(instance, step)
instance.status = "waiting" instance.status = "waiting"
instance.error = step.error instance.error = step.error
if previous.get("state") != state or previous.get("message") != message: if previous.get("state") != state or previous.get("message") != message:
@@ -2109,6 +2104,7 @@ def _handle_dataflow_success(
"warnings": warnings, "warnings": warnings,
"output": output, "output": output,
} }
_apply_work_projection(instance, step)
instance.status = "waiting" instance.status = "waiting"
_record_event( _record_event(
session, session,
@@ -2165,6 +2161,7 @@ def _complete_step(
step.finished_at = utcnow() step.finished_at = utcnow()
step.completed_by = actor_id step.completed_by = actor_id
step.handoff = {} step.handoff = {}
_clear_work_projection(step)
context = dict(instance.context_) context = dict(instance.context_)
step_values = dict(context.get("steps") or {}) step_values = dict(context.get("steps") or {})
step_values[step.node_id] = dict(output) step_values[step.node_id] = dict(output)
@@ -2183,6 +2180,80 @@ def _complete_step(
return _next_node_id(graph, step.node_id, port) return _next_node_id(graph, step.node_id, port)
_WORK_ASSIGNMENT_KINDS = {
"account",
"group",
"role",
"function",
"function_assignment",
"anyone",
}
def _work_assignment(
instance: WorkflowInstance,
configured: object | None = None,
) -> dict[str, str | None] | None:
if isinstance(configured, Mapping):
kind = str(configured.get("kind") or "").strip()
assignment_id = str(configured.get("id") or "").strip()
label = str(configured.get("label") or "").strip() or None
if kind in _WORK_ASSIGNMENT_KINDS and assignment_id:
if kind == "anyone" and assignment_id != "*":
return None
return {"kind": kind, "id": assignment_id, "label": label}
return None
value = str(configured or "").strip()
if value:
prefix, separator, remainder = value.partition(":")
if separator and prefix in _WORK_ASSIGNMENT_KINDS and remainder.strip():
assignment_id = remainder.strip()
if prefix == "anyone" and assignment_id != "*":
return None
return {"kind": prefix, "id": assignment_id, "label": None}
return {"kind": "account", "id": value, "label": None}
account_id = str(instance.authorization_.get("account_id") or "").strip()
if not account_id:
return None
return {"kind": "account", "id": account_id, "label": None}
def _work_due_at(configured: object | None) -> datetime | None:
if not str(configured or "").strip():
return None
from govoplan_workflow_engine.backend.triggers import duration_seconds
return utcnow() + timedelta(seconds=duration_seconds(configured))
def _apply_work_projection(
instance: WorkflowInstance,
step: WorkflowInstanceStep,
*,
assignment: Mapping[str, object] | None = None,
due_at: datetime | None = None,
) -> None:
normalized = (
dict(assignment) if assignment is not None else _work_assignment(instance)
)
if normalized is None:
_clear_work_projection(step)
return
step.work_assignment_kind = str(normalized.get("kind") or "") or None
step.work_assignment_id = str(normalized.get("id") or "") or None
step.work_assignment_label = str(normalized.get("label") or "") or None
step.work_due_at = due_at
def _clear_work_projection(step: WorkflowInstanceStep) -> None:
step.work_assignment_kind = None
step.work_assignment_id = None
step.work_assignment_label = None
step.work_due_at = None
def _set_human_handoff( def _set_human_handoff(
session: Session, session: Session,
*, *,
@@ -2200,6 +2271,11 @@ def _set_human_handoff(
else: else:
actions = ["complete", "cancel"] actions = ["complete", "cancel"]
kind = "activity" kind = "activity"
assignment = _work_assignment(
instance,
node.config.get("reviewer") or node.config.get("assignee"),
)
due_at = _work_due_at(node.config.get("due_after"))
step.status = "waiting" step.status = "waiting"
step.handoff = { step.handoff = {
"kind": kind, "kind": kind,
@@ -2207,9 +2283,12 @@ def _set_human_handoff(
"title": str(node.config.get("title") or node.label or node.type), "title": str(node.config.get("title") or node.label or node.type),
"instructions": str(node.config.get("instructions") or ""), "instructions": str(node.config.get("instructions") or ""),
"assignee": node.config.get("reviewer") or node.config.get("assignee"), "assignee": node.config.get("reviewer") or node.config.get("assignee"),
"assignment": assignment,
"due_at": due_at.isoformat() if due_at else None,
"required_evidence": list(node.config.get("required_evidence") or []), "required_evidence": list(node.config.get("required_evidence") or []),
"allowed_actions": actions, "allowed_actions": actions,
} }
_apply_work_projection(instance, step, assignment=assignment, due_at=due_at)
instance.status = "waiting" instance.status = "waiting"
_record_event( _record_event(
session, session,
@@ -2249,6 +2328,7 @@ def _set_automated_wait(
"event_type": event_type, "event_type": event_type,
"allowed_actions": ["cancel"], "allowed_actions": ["cancel"],
} }
_clear_work_projection(step)
instance.status = "waiting" instance.status = "waiting"
_record_event( _record_event(
session, session,
@@ -2275,6 +2355,7 @@ def _set_dependency_handoff(
"message": message, "message": message,
"allowed_actions": ["retry", "cancel"], "allowed_actions": ["retry", "cancel"],
} }
_apply_work_projection(instance, step)
instance.status = "waiting" instance.status = "waiting"
instance.error = message instance.error = message
_record_event( _record_event(
@@ -2382,6 +2463,7 @@ def _set_failure_handoff(
"allowed_actions": ["retry", "reject", "cancel"], "allowed_actions": ["retry", "reject", "cancel"],
"suggested_port": "failure", "suggested_port": "failure",
} }
_apply_work_projection(instance, step)
instance.status = "waiting" instance.status = "waiting"
instance.error = message instance.error = message
_record_event( _record_event(
@@ -9,6 +9,7 @@ from govoplan_core.core.access import (
CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
) )
from govoplan_core.core.dataflows import CAPABILITY_DATAFLOW_RUN_LIFECYCLE from govoplan_core.core.dataflows import CAPABILITY_DATAFLOW_RUN_LIFECYCLE
from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY
from govoplan_core.core.module_guards import ( from govoplan_core.core.module_guards import (
drop_table_retirement_provider, drop_table_retirement_provider,
persistent_table_uninstall_guard, persistent_table_uninstall_guard,
@@ -33,6 +34,7 @@ from govoplan_core.core.notifications import (
) )
from govoplan_core.core.references import CAPABILITY_ACCESS_REFERENCE_OPTIONS from govoplan_core.core.references import CAPABILITY_ACCESS_REFERENCE_OPTIONS
from govoplan_core.core.views import CAPABILITY_VIEWS_RESOLVER from govoplan_core.core.views import CAPABILITY_VIEWS_RESOLVER
from govoplan_core.core.tasks import WorkItemProviderRegistration
from govoplan_core.core.workflows import ( from govoplan_core.core.workflows import (
CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS, CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS,
CAPABILITY_WORKFLOW_ORCHESTRATION, CAPABILITY_WORKFLOW_ORCHESTRATION,
@@ -187,6 +189,12 @@ def _service_launcher(context: ModuleContext) -> WorkflowServiceLauncher:
return WorkflowServiceLauncher(registry=context.registry) return WorkflowServiceLauncher(registry=context.registry)
def _work_items(context: ModuleContext):
from govoplan_workflow_engine.backend.work_items import WorkflowWorkItemProvider
return WorkflowWorkItemProvider(registry=context.registry)
manifest = ModuleManifest( manifest = ModuleManifest(
id=MODULE_ID, id=MODULE_ID,
name=MODULE_NAME, name=MODULE_NAME,
@@ -198,6 +206,7 @@ manifest = ModuleManifest(
"audit", "audit",
"dataflow", "dataflow",
"datasources", "datasources",
"idm",
"notifications", "notifications",
"policy", "policy",
"tasks", "tasks",
@@ -210,6 +219,7 @@ manifest = ModuleManifest(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_DATAFLOW_RUN_LIFECYCLE, CAPABILITY_DATAFLOW_RUN_LIFECYCLE,
CAPABILITY_IDM_DIRECTORY,
CAPABILITY_NOTIFICATIONS_DISPATCH, CAPABILITY_NOTIFICATIONS_DISPATCH,
CAPABILITY_POLICY_DEFINITION_GOVERNANCE, CAPABILITY_POLICY_DEFINITION_GOVERNANCE,
CAPABILITY_VIEWS_RESOLVER, CAPABILITY_VIEWS_RESOLVER,
@@ -303,6 +313,13 @@ manifest = ModuleManifest(
contract_version="0.1.0", contract_version="0.1.0",
), ),
}, },
work_item_providers=(
WorkItemProviderRegistration(
id="workflow_engine.handoffs",
factory=_work_items,
order=20,
),
),
migration_spec=MigrationSpec( migration_spec=MigrationSpec(
module_id=MODULE_ID, module_id=MODULE_ID,
metadata=Base.metadata, metadata=Base.metadata,
@@ -350,7 +367,9 @@ manifest = ModuleManifest(
"implementation imports. Definitions are persisted as immutable graph " "implementation imports. Definitions are persisted as immutable graph "
"revisions; activation pins the exact revision used by future instances. " "revisions; activation pins the exact revision used by future instances. "
"The optional service-launch capability starts an authorized active " "The optional service-launch capability starts an authorized active "
"revision from an exact Portal Service binding and records that provenance." "revision from an exact Portal Service binding and records that provenance. "
"When Tasks is enabled, current human handoffs are projected into the common "
"work inbox with typed responsibility, due date, and a resumable source link."
), ),
layer="available", layer="available",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
@@ -0,0 +1,65 @@
"""v0.1.18 Workflow work projections.
Revision ID: 8d5a2f7c1b4e
Revises: e4a1f8c2d7b6
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "8d5a2f7c1b4e"
down_revision = "e4a1f8c2d7b6"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("workflow_instance_steps") as batch_op:
batch_op.add_column(
sa.Column("work_assignment_kind", sa.String(length=40), nullable=True)
)
batch_op.add_column(
sa.Column("work_assignment_id", sa.String(length=255), nullable=True)
)
batch_op.add_column(
sa.Column("work_assignment_label", sa.String(length=500), nullable=True)
)
batch_op.add_column(
sa.Column("work_due_at", sa.DateTime(timezone=True), nullable=True)
)
batch_op.create_index(
"ix_workflow_instance_steps_work_assignment_kind",
["work_assignment_kind"],
)
batch_op.create_index(
"ix_workflow_instance_steps_work_assignment_id",
["work_assignment_id"],
)
batch_op.create_index(
"ix_workflow_instance_steps_work_due_at",
["work_due_at"],
)
batch_op.create_index(
"ix_workflow_instance_steps_work_assignment",
[
"tenant_id",
"status",
"work_assignment_kind",
"work_assignment_id",
],
)
def downgrade() -> None:
with op.batch_alter_table("workflow_instance_steps") as batch_op:
batch_op.drop_index("ix_workflow_instance_steps_work_assignment")
batch_op.drop_index("ix_workflow_instance_steps_work_due_at")
batch_op.drop_index("ix_workflow_instance_steps_work_assignment_id")
batch_op.drop_index("ix_workflow_instance_steps_work_assignment_kind")
batch_op.drop_column("work_due_at")
batch_op.drop_column("work_assignment_label")
batch_op.drop_column("work_assignment_id")
batch_op.drop_column("work_assignment_kind")
@@ -84,8 +84,7 @@ LEGACY_WORKFLOW_NODE_TYPES = (
category="trigger", category="trigger",
label="Parent workflow", label="Parent workflow",
description=( description=(
"Start as a pinned child or dependency of another Workflow " "Start as a pinned child or dependency of another Workflow instance."
"instance."
), ),
icon="git-branch", icon="git-branch",
default_config={ default_config={
@@ -165,8 +164,12 @@ LEGACY_WORKFLOW_NODE_TYPES = (
icon="square-check-big", icon="square-check-big",
input_ports=(DefinitionPort(id="input", label="Input"),), input_ports=(DefinitionPort(id="input", label="Input"),),
config_fields=( config_fields=(
DefinitionConfigField(id="title", label="Title", kind="text", required=True), DefinitionConfigField(
DefinitionConfigField(id="instructions", label="Instructions", kind="textarea"), id="title", label="Title", kind="text", required=True
),
DefinitionConfigField(
id="instructions", label="Instructions", kind="textarea"
),
DefinitionConfigField(id="assignee", label="Assignee", kind="subject"), DefinitionConfigField(id="assignee", label="Assignee", kind="subject"),
DefinitionConfigField(id="due_after", label="Due after", kind="duration"), DefinitionConfigField(id="due_after", label="Due after", kind="duration"),
FOCUSED_VIEW_SURFACES_FIELD, FOCUSED_VIEW_SURFACES_FIELD,
@@ -192,8 +195,11 @@ LEGACY_WORKFLOW_NODE_TYPES = (
DefinitionPort(id="rejected", label="Rejected", required=False), DefinitionPort(id="rejected", label="Rejected", required=False),
), ),
config_fields=( config_fields=(
DefinitionConfigField(id="title", label="Title", kind="text", required=True), DefinitionConfigField(
id="title", label="Title", kind="text", required=True
),
DefinitionConfigField(id="reviewer", label="Reviewer", kind="subject"), DefinitionConfigField(id="reviewer", label="Reviewer", kind="subject"),
DefinitionConfigField(id="due_after", label="Due after", kind="duration"),
DefinitionConfigField( DefinitionConfigField(
id="required_evidence", id="required_evidence",
label="Required evidence", label="Required evidence",
@@ -204,6 +210,7 @@ LEGACY_WORKFLOW_NODE_TYPES = (
default_config={ default_config={
"title": "", "title": "",
"reviewer": "", "reviewer": "",
"due_after": "",
"required_evidence": [], "required_evidence": [],
"view_surface_ids": [], "view_surface_ids": [],
}, },
@@ -292,7 +299,9 @@ LEGACY_WORKFLOW_NODE_TYPES = (
kind="text", kind="text",
required=True, required=True,
), ),
DefinitionConfigField(id="input_mapping", label="Input mapping", kind="mapping"), DefinitionConfigField(
id="input_mapping", label="Input mapping", kind="mapping"
),
DefinitionConfigField( DefinitionConfigField(
id="idempotency_key", id="idempotency_key",
label="Idempotency key", label="Idempotency key",
@@ -373,8 +382,7 @@ LEGACY_WORKFLOW_NODE_TYPES = (
label="Publication datasource", label="Publication datasource",
kind="text", kind="text",
description=( description=(
"Optional stable Datasource target for materialized " "Optional stable Datasource target for materialized output."
"output."
), ),
), ),
DefinitionConfigField( DefinitionConfigField(
@@ -397,7 +405,9 @@ LEGACY_WORKFLOW_NODE_TYPES = (
("continue", "Follow failure path"), ("continue", "Follow failure path"),
), ),
), ),
DefinitionConfigField(id="input_mapping", label="Input mapping", kind="mapping"), DefinitionConfigField(
id="input_mapping", label="Input mapping", kind="mapping"
),
FOCUSED_VIEW_SURFACES_FIELD, FOCUSED_VIEW_SURFACES_FIELD,
), ),
default_config={ default_config={
@@ -428,7 +438,9 @@ LEGACY_WORKFLOW_NODE_TYPES = (
), ),
output_ports=(), output_ports=(),
config_fields=( config_fields=(
DefinitionConfigField(id="output_mapping", label="Output mapping", kind="mapping"), DefinitionConfigField(
id="output_mapping", label="Output mapping", kind="mapping"
),
), ),
default_config={"output_mapping": {}}, default_config={"output_mapping": {}},
), ),
@@ -870,7 +882,9 @@ BPMN_NODE_TYPES = (
shape="activity", shape="activity",
config_fields=( config_fields=(
*_TASK_FIELDS, *_TASK_FIELDS,
DefinitionConfigField(id="message_ref", label="Message reference", kind="text"), DefinitionConfigField(
id="message_ref", label="Message reference", kind="text"
),
FOCUSED_VIEW_SURFACES_FIELD, FOCUSED_VIEW_SURFACES_FIELD,
), ),
default_config={ default_config={
@@ -898,7 +912,13 @@ BPMN_NODE_TYPES = (
"Script task", "Script task",
"A BPMN script task retained as notation; arbitrary scripts are not executed.", "A BPMN script task retained as notation; arbitrary scripts are not executed.",
"file-code-2", "file-code-2",
(*_TASK_FIELDS, DefinitionConfigField(id="script_format", label="Script format", kind="text"), DefinitionConfigField(id="script", label="Script", kind="textarea")), (
*_TASK_FIELDS,
DefinitionConfigField(
id="script_format", label="Script format", kind="text"
),
DefinitionConfigField(id="script", label="Script", kind="textarea"),
),
{"title": "", "instructions": "", "script_format": "", "script": ""}, {"title": "", "instructions": "", "script_format": "", "script": ""},
), ),
( (
@@ -906,7 +926,14 @@ BPMN_NODE_TYPES = (
"Business rule task", "Business rule task",
"Evaluate a governed business-rule implementation.", "Evaluate a governed business-rule implementation.",
"scale", "scale",
(*_TASK_FIELDS, DefinitionConfigField(id="implementation_ref", label="Implementation reference", kind="text")), (
*_TASK_FIELDS,
DefinitionConfigField(
id="implementation_ref",
label="Implementation reference",
kind="text",
),
),
{"title": "", "instructions": "", "implementation_ref": ""}, {"title": "", "instructions": "", "implementation_ref": ""},
), ),
( (
@@ -914,7 +941,15 @@ BPMN_NODE_TYPES = (
"Call activity", "Call activity",
"Call another reusable BPMN process or GovOPlaN workflow.", "Call another reusable BPMN process or GovOPlaN workflow.",
"external-link", "external-link",
(*_TASK_FIELDS, DefinitionConfigField(id="called_element", label="Called element", kind="text", required=True)), (
*_TASK_FIELDS,
DefinitionConfigField(
id="called_element",
label="Called element",
kind="text",
required=True,
),
),
{"title": "", "instructions": "", "called_element": ""}, {"title": "", "instructions": "", "called_element": ""},
), ),
( (
@@ -956,11 +991,41 @@ BPMN_NODE_TYPES = (
runtime_support=runtime_support, runtime_support=runtime_support,
) )
for type_name, label, description, icon, runtime_support in ( for type_name, label, description, icon, runtime_support in (
("exclusiveGateway", "Exclusive gateway", "Choose exactly one matching sequence flow.", "diamond", "native"), (
("parallelGateway", "Parallel gateway", "Split or join concurrent sequence flows.", "plus", "model_only"), "exclusiveGateway",
("inclusiveGateway", "Inclusive gateway", "Choose one or more matching sequence flows.", "circle-plus", "model_only"), "Exclusive gateway",
("eventBasedGateway", "Event-based gateway", "Choose a path according to the first caught event.", "radio-tower", "model_only"), "Choose exactly one matching sequence flow.",
("complexGateway", "Complex gateway", "Apply a complex activation condition.", "asterisk", "model_only"), "diamond",
"native",
),
(
"parallelGateway",
"Parallel gateway",
"Split or join concurrent sequence flows.",
"plus",
"model_only",
),
(
"inclusiveGateway",
"Inclusive gateway",
"Choose one or more matching sequence flows.",
"circle-plus",
"model_only",
),
(
"eventBasedGateway",
"Event-based gateway",
"Choose a path according to the first caught event.",
"radio-tower",
"model_only",
),
(
"complexGateway",
"Complex gateway",
"Apply a complex activation condition.",
"asterisk",
"model_only",
),
) )
), ),
_bpmn_node( _bpmn_node(
@@ -972,8 +1037,12 @@ BPMN_NODE_TYPES = (
shape="data-object", shape="data-object",
input_ports=_OPTIONAL_INCOMING, input_ports=_OPTIONAL_INCOMING,
config_fields=( config_fields=(
DefinitionConfigField(id="data_object_ref", label="Data object reference", kind="text"), DefinitionConfigField(
DefinitionConfigField(id="item_subject_ref", label="Item definition", kind="text"), id="data_object_ref", label="Data object reference", kind="text"
),
DefinitionConfigField(
id="item_subject_ref", label="Item definition", kind="text"
),
), ),
default_config={"data_object_ref": "", "item_subject_ref": ""}, default_config={"data_object_ref": "", "item_subject_ref": ""},
), ),
@@ -986,8 +1055,12 @@ BPMN_NODE_TYPES = (
shape="data-store", shape="data-store",
input_ports=_OPTIONAL_INCOMING, input_ports=_OPTIONAL_INCOMING,
config_fields=( config_fields=(
DefinitionConfigField(id="data_store_ref", label="Data store reference", kind="text"), DefinitionConfigField(
DefinitionConfigField(id="item_subject_ref", label="Item definition", kind="text"), id="data_store_ref", label="Data store reference", kind="text"
),
DefinitionConfigField(
id="item_subject_ref", label="Item definition", kind="text"
),
), ),
default_config={"data_store_ref": "", "item_subject_ref": ""}, default_config={"data_store_ref": "", "item_subject_ref": ""},
), ),
@@ -1000,7 +1073,9 @@ BPMN_NODE_TYPES = (
shape="participant", shape="participant",
input_ports=_OPTIONAL_INCOMING, input_ports=_OPTIONAL_INCOMING,
config_fields=( config_fields=(
DefinitionConfigField(id="process_ref", label="Process reference", kind="text"), DefinitionConfigField(
id="process_ref", label="Process reference", kind="text"
),
), ),
default_config={"process_ref": ""}, default_config={"process_ref": ""},
), ),
@@ -1013,7 +1088,9 @@ BPMN_NODE_TYPES = (
shape="lane", shape="lane",
input_ports=_OPTIONAL_INCOMING, input_ports=_OPTIONAL_INCOMING,
config_fields=( config_fields=(
DefinitionConfigField(id="flow_node_refs", label="Flow node references", kind="string_list"), DefinitionConfigField(
id="flow_node_refs", label="Flow node references", kind="string_list"
),
), ),
default_config={"flow_node_refs": []}, default_config={"flow_node_refs": []},
), ),
@@ -1040,7 +1117,9 @@ BPMN_NODE_TYPES = (
shape="group", shape="group",
input_ports=_OPTIONAL_INCOMING, input_ports=_OPTIONAL_INCOMING,
config_fields=( config_fields=(
DefinitionConfigField(id="category_value_ref", label="Category value", kind="text"), DefinitionConfigField(
id="category_value_ref", label="Category value", kind="text"
),
), ),
default_config={"category_value_ref": ""}, default_config={"category_value_ref": ""},
), ),
@@ -938,6 +938,12 @@ def _duration_seconds(value: object, *, minimum: int) -> int:
return seconds return seconds
def duration_seconds(value: object, *, minimum: int = 1) -> int:
"""Parse the duration syntax shared by timers and human-work due dates."""
return _duration_seconds(value, minimum=minimum)
def _parse_instant(value: object, *, timezone_name: str) -> datetime: def _parse_instant(value: object, *, timezone_name: str) -> datetime:
text = str(value or "").strip() text = str(value or "").strip()
if not text: if not text:
@@ -0,0 +1,367 @@
from __future__ import annotations
from collections.abc import Mapping
from datetime import UTC, datetime
from sqlalchemy import and_, or_, select
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, has_scope
from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, IdmDirectory
from govoplan_core.core.tasks import (
WorkAssignmentRef,
WorkItem,
WorkItemPage,
WorkItemQuery,
WorkSourceRef,
)
from govoplan_workflow_engine.backend.db.models import (
WorkflowDefinition,
WorkflowInstance,
WorkflowInstanceStep,
)
from govoplan_workflow_engine.backend.governance import definition_decision
PROVIDER_ID = "workflow_engine.handoffs"
INSTANCE_READ_SCOPE = "workflow:instance:read"
ADMIN_SCOPE = "workflow:instance:admin"
_NON_HUMAN_KINDS = {"timer", "event_wait", "dataflow_run"}
_BLOCKED_STATES = {
"blocked",
"failed",
"outcome_unknown",
"recovery_required",
"compensation_required",
}
_ASSIGNMENT_KINDS = {
"account",
"group",
"role",
"function",
"function_assignment",
"anyone",
}
class WorkflowWorkItemProvider:
def __init__(self, *, registry: object | None = None) -> None:
self.registry = registry
def list_items(
self,
session: object,
principal: object,
*,
query: WorkItemQuery,
) -> WorkItemPage:
if not isinstance(session, Session):
raise TypeError("Workflow work aggregation requires a SQLAlchemy Session.")
if not isinstance(principal, ApiPrincipal):
return WorkItemPage(items=(), total=0)
if principal.tenant_id != query.tenant_id:
return WorkItemPage(items=(), total=0)
administrative = has_scope(principal, ADMIN_SCOPE)
if not administrative and not has_scope(principal, INSTANCE_READ_SCOPE):
return WorkItemPage(items=(), total=0)
statement = (
select(WorkflowInstance, WorkflowInstanceStep, WorkflowDefinition)
.join(
WorkflowInstanceStep,
WorkflowInstanceStep.id == WorkflowInstance.current_step_id,
)
.join(
WorkflowDefinition,
WorkflowDefinition.id == WorkflowInstance.definition_id,
)
.where(
WorkflowInstance.tenant_id == query.tenant_id,
WorkflowInstance.status == "waiting",
WorkflowInstanceStep.status == "waiting",
)
.order_by(
WorkflowInstanceStep.work_due_at.is_(None),
WorkflowInstanceStep.work_due_at.asc(),
WorkflowInstanceStep.updated_at.desc(),
WorkflowInstanceStep.id.desc(),
)
)
if not administrative:
targets = self._targets(principal, query.tenant_id)
conditions = [
and_(
WorkflowInstanceStep.work_assignment_kind == kind,
WorkflowInstanceStep.work_assignment_id.in_(tuple(values)),
)
for kind, values in targets.items()
if values
]
conditions.append(
and_(
WorkflowInstanceStep.work_assignment_kind.is_(None),
WorkflowInstanceStep.work_assignment_id.is_(None),
)
)
statement = statement.where(or_(*conditions))
items: list[WorkItem] = []
total = 0
decisions: dict[str, bool] = {}
targets = self._targets(principal, query.tenant_id)
for instance, step, definition in session.execute(statement).yield_per(250):
assignment = _step_assignment(instance, step)
if not administrative and not _assignment_matches(assignment, targets):
continue
if not _is_actionable_handoff(step.handoff):
continue
allowed = decisions.get(definition.id)
if allowed is None:
allowed = definition_decision(
definition,
principal=principal,
registry=self.registry,
action="view",
).allowed
decisions[definition.id] = allowed
if not allowed:
continue
item = _work_item(instance, step, definition, assignment)
if query.statuses and item.status not in query.statuses:
continue
if query.priorities and item.priority not in query.priorities:
continue
if query.due_before is not None and (
item.due_at is None or _aware(item.due_at) > _aware(query.due_before)
):
continue
if query.text and query.text.casefold() not in _search_text(item):
continue
total += 1
if len(items) < query.limit:
items.append(item)
return WorkItemPage(
items=tuple(items),
total=total,
truncated=total > len(items),
)
def _targets(self, principal: ApiPrincipal, tenant_id: str) -> dict[str, set[str]]:
result = {
"account": {principal.account_id} if principal.account_id else set(),
"group": set(principal.group_ids),
"role": set(principal.role_ids),
"function_assignment": set(principal.function_assignment_ids),
"function": set(),
"anyone": {"*"},
}
directory = self._idm_directory()
if directory is not None and principal.account_id:
result["function"].update(
item.function_id
for item in directory.organization_function_assignments_for_account(
principal.account_id,
tenant_id=tenant_id,
)
if item.tenant_id == tenant_id and item.status == "active"
)
return result
def _idm_directory(self) -> IdmDirectory | None:
registry = self.registry
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_IDM_DIRECTORY)
):
return None
provider = registry.capability(CAPABILITY_IDM_DIRECTORY)
return provider if isinstance(provider, IdmDirectory) else None
def _step_assignment(
instance: WorkflowInstance,
step: WorkflowInstanceStep,
) -> WorkAssignmentRef | None:
if step.work_assignment_kind and step.work_assignment_id:
return _assignment_ref(
step.work_assignment_kind,
step.work_assignment_id,
step.work_assignment_label,
)
handoff_assignment = step.handoff.get("assignment")
if isinstance(handoff_assignment, Mapping):
kind = str(handoff_assignment.get("kind") or "").strip()
assignment_id = str(handoff_assignment.get("id") or "").strip()
if kind and assignment_id:
return _assignment_ref(
kind,
assignment_id,
str(handoff_assignment.get("label") or "").strip() or None,
)
account_id = str(instance.authorization_.get("account_id") or "").strip()
return WorkAssignmentRef(kind="account", id=account_id) if account_id else None
def _assignment_matches(
assignment: WorkAssignmentRef | None,
targets: Mapping[str, set[str]],
) -> bool:
return bool(
assignment is not None and assignment.id in targets.get(assignment.kind, set())
)
def _is_actionable_handoff(handoff: Mapping[str, object]) -> bool:
kind = str(handoff.get("kind") or "")
state = str(handoff.get("state") or "waiting")
return kind not in _NON_HUMAN_KINDS and state not in {"pending", "running"}
def _work_item(
instance: WorkflowInstance,
step: WorkflowInstanceStep,
definition: WorkflowDefinition,
assignment: WorkAssignmentRef | None,
) -> WorkItem:
handoff = dict(step.handoff or {})
state = str(handoff.get("state") or "waiting")
status = "blocked" if state in _BLOCKED_STATES else "open"
title = str(
handoff.get("title") or handoff.get("message") or f"Continue {definition.name}"
)
required_action = str(
handoff.get("instructions")
or handoff.get("message")
or "Continue the current workflow handoff."
).strip()
action_url = _action_url(
handoff.get("action_url")
or f"/workflow?definition={definition.id}&run={instance.id}"
)
due_at = step.work_due_at or _date(handoff.get("due_at"))
priority = str(handoff.get("priority") or "normal").casefold()
if priority not in {"low", "normal", "high", "urgent"}:
priority = "normal"
updated_at = step.updated_at or instance.updated_at
revision = f"{step.attempt}:{updated_at.isoformat() if updated_at else '1'}"
return WorkItem(
id=step.id,
provider_id=PROVIDER_ID,
owner_module="workflow_engine",
tenant_id=instance.tenant_id,
title=title,
summary=f"{definition.name} · {step.node_type}",
status=status, # type: ignore[arg-type]
priority=priority, # type: ignore[arg-type]
required_action=required_action or None,
action_url=action_url,
due_at=due_at,
assignments=(assignment,) if assignment else (),
sources=(
WorkSourceRef(
module_id="workflow_engine",
resource_type="workflow_instance",
resource_id=instance.id,
revision=instance.definition_revision_id,
url=f"/workflow?definition={definition.id}&run={instance.id}",
label=definition.name,
),
WorkSourceRef(
module_id="workflow_engine",
resource_type="workflow_step",
resource_id=step.id,
revision=str(step.attempt),
),
),
provenance={
"definition_id": definition.id,
"definition_revision_id": instance.definition_revision_id,
"workflow_instance_id": instance.id,
"workflow_step_id": step.id,
},
metadata={
"handoff_kind": handoff.get("kind"),
"handoff_state": state,
"allowed_actions": _allowed_actions(handoff.get("allowed_actions")),
},
revision=revision,
created_at=step.created_at,
updated_at=updated_at,
)
def _assignment_ref(
kind: object,
assignment_id: object,
label: object = None,
) -> WorkAssignmentRef | None:
normalized_kind = str(kind or "").strip()
normalized_id = str(assignment_id or "").strip()
if normalized_kind not in _ASSIGNMENT_KINDS or not normalized_id:
return None
if normalized_kind == "anyone":
normalized_id = "*"
try:
normalized_label = str(label).strip()[:500] if label is not None else ""
return WorkAssignmentRef(
kind=normalized_kind, # type: ignore[arg-type]
id=normalized_id,
label=normalized_label or None,
)
except ValueError:
return None
def _action_url(value: object) -> str:
candidate = str(value or "").strip()
if (
candidate.startswith("/")
and not candidate.startswith("//")
and "\\" not in candidate
and all(
ord(character) >= 32 and ord(character) != 127 for character in candidate
)
):
return candidate[:1_500]
return "/workflow"
def _allowed_actions(value: object) -> list[str]:
if not isinstance(value, (list, tuple, set, frozenset)):
return []
return [normalized for item in value if (normalized := str(item or "").strip())][
:100
]
def _date(value: object) -> datetime | None:
if isinstance(value, datetime):
return value
text = str(value or "").strip()
if not text:
return None
try:
return datetime.fromisoformat(text.replace("Z", "+00:00"))
except ValueError:
return None
def _aware(value: datetime) -> datetime:
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
def _search_text(item: WorkItem) -> str:
return " ".join(
value
for value in (
item.title,
item.summary,
item.required_action,
item.owner_module,
)
if value
).casefold()
__all__ = ["PROVIDER_ID", "WorkflowWorkItemProvider"]
+104 -3
View File
@@ -43,6 +43,7 @@ from govoplan_core.core.runtime_coordination import (
RuntimeIdentity, RuntimeIdentity,
bind_process_runtime_identity, bind_process_runtime_identity,
) )
from govoplan_core.core.tasks import WorkItemQuery
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_core.db.base import utcnow from govoplan_core.db.base import utcnow
from govoplan_workflow_engine.backend.db.models import ( from govoplan_workflow_engine.backend.db.models import (
@@ -84,6 +85,7 @@ from govoplan_workflow_engine.backend.service import (
create_definition, create_definition,
) )
from govoplan_workflow_engine.backend.service_launcher import WorkflowServiceLauncher from govoplan_workflow_engine.backend.service_launcher import WorkflowServiceLauncher
from govoplan_workflow_engine.backend.work_items import WorkflowWorkItemProvider
try: try:
from test_bpmn import NATIVE_BPMN from test_bpmn import NATIVE_BPMN
@@ -687,6 +689,107 @@ class WorkflowInstanceServiceTests(unittest.TestCase):
start_origin="api", 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( def test_module_action_records_effects_and_completes_idempotently(
self, self,
) -> None: ) -> None:
@@ -990,9 +1093,7 @@ class WorkflowInstanceServiceTests(unittest.TestCase):
request=request, request=request,
) )
checkpoint = session.scalar( checkpoint = session.scalar(
select(RecoveryCheckpoint).order_by( select(RecoveryCheckpoint).order_by(RecoveryCheckpoint.sequence)
RecoveryCheckpoint.sequence
)
) )
assert checkpoint is not None assert checkpoint is not None
checkpoint.summary = "tampered provider evidence" checkpoint.summary = "tampered provider evidence"
+43 -5
View File
@@ -29,7 +29,7 @@ class WorkflowMigrationTests(unittest.TestCase):
try: try:
with engine.connect() as connection: with engine.connect() as connection:
self.assertIn( self.assertIn(
"e4a1f8c2d7b6", "8d5a2f7c1b4e",
set(MigrationContext.configure(connection).get_current_heads()), set(MigrationContext.configure(connection).get_current_heads()),
) )
self.assertEqual( self.assertEqual(
@@ -102,7 +102,12 @@ class WorkflowMigrationTests(unittest.TestCase):
) )
for path in current_revisions.glob("*.py"): for path in current_revisions.glob("*.py"):
if path.name.startswith( if path.name.startswith(
("0b4e7c9a2d6f_", "b2e4f6a8c0d1_", "e4a1f8c2d7b6_") (
"0b4e7c9a2d6f_",
"b2e4f6a8c0d1_",
"e4a1f8c2d7b6_",
"8d5a2f7c1b4e_",
)
): ):
continue continue
shutil.copy2(path, legacy_revisions / path.name) shutil.copy2(path, legacy_revisions / path.name)
@@ -146,7 +151,7 @@ class WorkflowMigrationTests(unittest.TestCase):
manifest_factories=(get_manifest,), manifest_factories=(get_manifest,),
) )
self.assertIn("e4a1f8c2d7b6", result.current_revision or "") self.assertIn("8d5a2f7c1b4e", result.current_revision or "")
engine = create_engine(url) engine = create_engine(url)
try: try:
upgraded_tables = set(inspect(engine).get_table_names()) upgraded_tables = set(inspect(engine).get_table_names())
@@ -181,6 +186,25 @@ class WorkflowMigrationTests(unittest.TestCase):
engine = create_engine(url) engine = create_engine(url)
try: try:
with engine.begin() as connection: 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( connection.execute(
text( text(
"ALTER TABLE workflow_definition_revisions " "ALTER TABLE workflow_definition_revisions "
@@ -196,7 +220,7 @@ class WorkflowMigrationTests(unittest.TestCase):
connection.execute( connection.execute(
text( text(
"UPDATE alembic_version SET version_num = " "UPDATE alembic_version SET version_num = "
"'b2e4f6a8c0d1' WHERE version_num = 'e4a1f8c2d7b6'" "'b2e4f6a8c0d1' WHERE version_num = '8d5a2f7c1b4e'"
) )
) )
finally: finally:
@@ -218,9 +242,23 @@ class WorkflowMigrationTests(unittest.TestCase):
} }
self.assertIn("bpmn_runtime_kind", columns) self.assertIn("bpmn_runtime_kind", columns)
self.assertIn("bpmn_executable", 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: with engine.connect() as connection:
self.assertIn( self.assertIn(
"e4a1f8c2d7b6", "8d5a2f7c1b4e",
set(MigrationContext.configure(connection).get_current_heads()), set(MigrationContext.configure(connection).get_current_heads()),
) )
finally: finally: