feat: add resumable external work hand-offs
Module Package Release / publish-packages (push) Successful in 10s

This commit is contained in:
2026-08-22 02:14:35 +02:00
parent ceb61b5867
commit b0a9dc9739
9 changed files with 967 additions and 12 deletions
+7
View File
@@ -56,6 +56,13 @@ human handoffs; event filters and variable mappings are bounded JSON
expressions and never executable code. Cron remains an optional governed
scheduler-adapter concern.
External hand-off waits bind an immutable, revision-bearing domain reference
to a focused action URL and resume only from declared terminal platform events.
The runtime rechecks the provider's current authorization and resource revision
before continuing. Observational events are duplicate-safe, navigation alone
never completes work, and a missing optional projection remains visible without
changing the domain outcome.
Consequential module actions are staged in Core's durable recovery ledger
before provider dispatch. Conclusive effects commit with the Workflow
projection. Lost acknowledgements block continuation and expose evidence-based
+3
View File
@@ -119,6 +119,9 @@ The first executable slice now provides:
- activation-bound API, one-time/interval schedule, platform-event, and
parent-workflow trigger registrations with exact-revision dispatch
- durable duration/deadline/event wait subscriptions and scale-out-safe claims
- resumable external hand-offs with immutable references, focused action URLs,
duplicate-safe observed events, terminal outcome ports, timeout paths, and a
provider authorization/revision recheck before continuation
- a separate transactional platform-event consumer with bounded JSON filters
and variable mappings, idempotent delivery, and current-authority rechecks
- Core recovery-ledger operations for module actions, including canonical
+2 -2
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-workflow-engine"
version = "0.1.18"
version = "0.1.19"
description = "Headless, versioned workflow definition and execution engine for GovOPlaN."
readme = "README.md"
requires-python = ">=3.12"
license = "AGPL-3.0-or-later"
authors = [{ name = "GovOPlaN" }]
dependencies = ["defusedxml>=0.7,<1", "govoplan-core>=0.1.18"]
dependencies = ["defusedxml>=0.7,<1", "govoplan-core>=0.1.28"]
[tool.setuptools.packages.find]
where = ["src"]
@@ -228,6 +228,10 @@ def legacy_graph_to_bpmn(graph: WorkflowGraph) -> WorkflowGraph:
config["wait_mode"] = config.pop("mode", "manual")
config.setdefault("event_definition", "none")
bpmn_type = "bpmn.intermediateCatchEvent"
elif node_type == "workflow.external_handoff":
config["wait_mode"] = "external_handoff"
config.setdefault("event_definition", "message")
bpmn_type = "bpmn.intermediateCatchEvent"
elif node_type == "workflow.capability":
config["implementation"] = "capability"
bpmn_type = "bpmn.serviceTask"
@@ -1002,8 +1006,13 @@ def _runtime_node(node: WorkflowNode) -> WorkflowNode:
elif node.type in {"bpmn.task", "bpmn.userTask", "bpmn.manualTask"}:
node_type = "workflow.activity"
elif node.type in {"bpmn.receiveTask", "bpmn.intermediateCatchEvent"}:
node_type = "workflow.wait"
config["mode"] = config.get("wait_mode") or "event"
wait_mode = str(config.get("wait_mode") or "event")
node_type = (
"workflow.external_handoff"
if wait_mode == "external_handoff"
else "workflow.wait"
)
config["mode"] = wait_mode
elif node.type in {"bpmn.serviceTask", "bpmn.sendTask"}:
implementation = str(config.get("implementation") or "capability")
node_type = (
@@ -751,7 +751,7 @@ def cancel_instance(
instance.id,
exc,
)
if step.node_type == "workflow.wait":
if step.node_type in {"workflow.wait", "workflow.external_handoff"}:
from govoplan_workflow_engine.backend.triggers import (
resolve_wait_state,
)
@@ -1010,6 +1010,25 @@ def _drive_instance(
registry=registry,
)
return
if node.type == "workflow.external_handoff":
from govoplan_workflow_engine.backend.triggers import (
register_external_handoff_state,
)
handoff_state = register_external_handoff_state(
session,
instance=instance,
step=step,
node=node,
)
_set_external_handoff(
session,
instance=instance,
step=step,
node=node,
wait_state=handoff_state,
)
return
if node.type == "workflow.wait":
from govoplan_workflow_engine.backend.triggers import (
register_wait_state,
@@ -2340,6 +2359,51 @@ def _set_automated_wait(
)
def _set_external_handoff(
session: Session,
*,
instance: WorkflowInstance,
step: WorkflowInstanceStep,
node: WorkflowNode,
wait_state: object,
) -> None:
config = dict(getattr(wait_state, "config_"))
due_at = getattr(wait_state, "due_at")
optional_capabilities = {
str(key): bool(value)
for key, value in dict(config.get("optional_capabilities") or {}).items()
}
unavailable = sorted(
name for name, available in optional_capabilities.items() if not available
)
step.status = "waiting"
step.external_ref = str(config.get("immutable_ref") or "") or None
step.handoff = {
"kind": "external_handoff",
"state": "assigned",
"title": str(node.config.get("title") or node.label or "External hand-off"),
"instructions": str(node.config.get("instructions") or ""),
"event_type": getattr(wait_state, "event_type"),
"external_id": config.get("external_id"),
"immutable_ref": config.get("immutable_ref"),
"action_url": config.get("action_url"),
"due_at": due_at.isoformat() if due_at else None,
"optional_capabilities": optional_capabilities,
"unavailable_optional_capabilities": unavailable,
"allowed_actions": [],
}
_clear_work_projection(step)
instance.status = "waiting"
_record_event(
session,
instance,
step=step,
kind="workflow.external_handoff.registered",
actor_id=instance.created_by,
payload=dict(step.handoff),
)
def _set_dependency_handoff(
session: Session,
*,
@@ -2807,6 +2871,26 @@ def _require_runtime_dependencies(
principal=principal,
registry=registry,
)
if node.type == "workflow.external_handoff":
from govoplan_core.core.campaigns import (
CampaignWorkOrchestrationProvider,
)
capability_name = str(
node.config.get("provider_capability") or ""
).strip()
capability = (
registry.capability(capability_name)
if capability_name
and registry is not None
and hasattr(registry, "has_capability")
and registry.has_capability(capability_name)
else None
)
if not isinstance(capability, CampaignWorkOrchestrationProvider):
raise WorkflowConflictError(
f"External hand-off capability {capability_name!r} is not available."
)
def _authorization_payload(
@@ -9,6 +9,7 @@ from govoplan_core.core.access import (
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
)
from govoplan_core.core.dataflows import CAPABILITY_DATAFLOW_RUN_LIFECYCLE
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION
from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
@@ -16,6 +17,7 @@ from govoplan_core.core.module_guards import (
)
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition,
DocumentationTopic,
MigrationSpec,
ModuleContext,
@@ -58,7 +60,7 @@ from govoplan_workflow_engine.backend.service_launcher import (
MODULE_ID = "workflow_engine"
MODULE_NAME = "Workflow Engine"
MODULE_VERSION = "0.1.18"
MODULE_VERSION = "0.1.19"
DEFINITION_READ_SCOPE = "workflow:definition:read"
DEFINITION_WRITE_SCOPE = "workflow:definition:write"
@@ -213,6 +215,7 @@ manifest = ModuleManifest(
optional_dependencies=(
"access",
"audit",
"campaigns",
"dataflow",
"datasources",
"idm",
@@ -227,6 +230,7 @@ manifest = ModuleManifest(
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION,
CAPABILITY_DATAFLOW_RUN_LIFECYCLE,
CAPABILITY_IDM_DIRECTORY,
CAPABILITY_NOTIFICATIONS_DISPATCH,
@@ -269,6 +273,12 @@ manifest = ModuleManifest(
),
),
requires_interfaces=(
ModuleInterfaceRequirement(
name="campaigns.work_orchestration",
version_min="1.0.0",
version_max_exclusive="2.0.0",
optional=True,
),
ModuleInterfaceRequirement(
name=CAPABILITY_ACCESS_REFERENCE_OPTIONS,
version_min="0.1.0",
@@ -376,6 +386,50 @@ manifest = ModuleManifest(
),
),
documentation=(
DocumentationTopic(
id="workflow.external-campaign-handoffs",
title="Resume Workflow from accountable Campaign work",
summary=(
"Open revision-bearing Campaign work, focus the relevant UI, "
"and continue from declared lifecycle events without polling."
),
body=(
"The external hand-off primitive follows a module action that "
"creates or references a Campaign and opens one accountable work "
"assignment. Workflow stores the exact Campaign version and assignment "
"revision, a safe action link, correlation and idempotency provenance, "
"and a durable event subscription. Assigned, accepted, started, and "
"reassigned events update the waiting state; completed, rejected, "
"cancelled, and timed-out outcomes take separate graph paths. Duplicate "
"events are ignored. Before a terminal event resumes execution, Workflow "
"re-resolves its authority and Campaign rechecks both access and the event "
"revision. An assignment never grants Campaign access. Missing Campaign or "
"Views capabilities leave the instance inspectable and blocked; missing "
"Tasks or Notifications is reported as reduced optional integration and "
"does not change the Campaign outcome."
),
layer="available",
documentation_types=("admin", "user"),
audience=("operator", "workflow_designer", "campaign_manager", "module_admin"),
related_modules=("campaigns", "views", "tasks", "notifications", "audit"),
conditions=(
DocumentationCondition(
any_scopes=(INSTANCE_READ_SCOPE, ADMIN_SCOPE),
),
),
order=75,
metadata={
"kind": "workflow",
"help_contexts": [
"workflow.external-handoff",
"workflow.instances",
],
"limitations": [
"The Campaign provider must be installed to start or resume a Campaign hand-off.",
"Focused View projection is optional; the safe Campaign action link remains available without it.",
],
},
),
DocumentationTopic(
id="workflow.data-subject-requests",
title="Workflow data-subject requests",
@@ -492,6 +546,7 @@ manifest = ModuleManifest(
"workflow revision",
"workflow instance",
"work transition",
"external event hand-off",
"execution adapter binding",
),
non_owned_concepts=(
@@ -269,6 +269,86 @@ LEGACY_WORKFLOW_NODE_TYPES = (
"view_surface_ids": [],
},
),
DefinitionNodeType(
type="workflow.external_handoff",
category="integration",
label="External hand-off",
description=(
"Wait for revision-bearing module events without polling, while "
"preserving a focused action link and explicit terminal outcomes."
),
icon="arrow-left-right",
input_ports=(DefinitionPort(id="input", label="Input"),),
output_ports=(
DefinitionPort(id="completed", label="Completed", required=False),
DefinitionPort(id="rejected", label="Rejected", required=False),
DefinitionPort(id="cancelled", label="Cancelled", required=False),
DefinitionPort(id="timed_out", label="Timed out", required=False),
),
config_fields=(
DefinitionConfigField(
id="provider_capability",
label="Hand-off provider",
kind="capability",
required=True,
),
DefinitionConfigField(
id="event_type",
label="Event type",
kind="text",
required=True,
),
DefinitionConfigField(
id="event_filter",
label="Event filter",
kind="mapping",
required=True,
),
DefinitionConfigField(
id="external_id",
label="External reference path",
kind="expression",
required=True,
),
DefinitionConfigField(
id="action_url",
label="Action URL path",
kind="expression",
required=True,
),
DefinitionConfigField(
id="immutable_ref",
label="Immutable reference path",
kind="expression",
required=True,
),
DefinitionConfigField(
id="timeout_after",
label="Timeout after",
kind="duration",
),
FOCUSED_VIEW_SURFACES_FIELD,
),
default_config={
"provider_capability": "",
"event_type": "",
"event_filter": {},
"outcome_path": "payload.outcome",
"terminal_outcomes": {
"completed": "completed",
"rejected": "rejected",
"cancelled": "cancelled",
},
"observed_outcomes": ["assigned", "accepted", "started", "reassigned"],
"external_id": "",
"expected_revision": "",
"action_url": "",
"immutable_ref": "",
"optional_capabilities": {},
"timeout_after": "",
"view_surface_ids": [],
},
),
DefinitionNodeType(
type="workflow.capability",
category="integration",
@@ -264,6 +264,151 @@ def register_wait_state(
return state
def register_external_handoff_state(
session: Session,
*,
instance: WorkflowInstance,
step: WorkflowInstanceStep,
node: WorkflowNode,
now: datetime | None = None,
) -> WorkflowWaitState:
"""Persist one event-driven external hand-off with a bounded timeout."""
current = _as_utc(now or utcnow())
event_type = _validated_event_type(node.config.get("event_type"))
event_filter = _resolved_context_value(
_event_filter(node.config.get("event_filter")),
instance.context_,
)
if not isinstance(event_filter, Mapping) or not event_filter:
raise WorkflowConflictError(
"External hand-offs require a non-empty event filter."
)
terminal = node.config.get("terminal_outcomes")
if not isinstance(terminal, Mapping) or not terminal:
raise WorkflowConflictError(
"External hand-offs require terminal outcome mappings."
)
terminal_outcomes = {
str(key).strip(): str(value).strip()
for key, value in terminal.items()
if str(key).strip() and str(value).strip()
}
allowed_ports = {"completed", "rejected", "cancelled"}
if (
not terminal_outcomes
or any(port not in allowed_ports for port in terminal_outcomes.values())
):
raise WorkflowConflictError(
"External hand-off outcomes must map to completed, rejected, or cancelled."
)
observed = tuple(
dict.fromkeys(
str(value).strip()
for value in node.config.get("observed_outcomes") or ()
if str(value).strip()
)
)
overlap = set(observed) & set(terminal_outcomes)
if overlap:
raise WorkflowConflictError(
"External hand-off outcomes cannot be both observed and terminal: "
+ ", ".join(sorted(overlap))
)
timeout_after = _resolved_context_value(
node.config.get("timeout_after"),
instance.context_,
)
due_at = (
current + timedelta(seconds=_duration_seconds(timeout_after, minimum=1))
if str(timeout_after or "").strip()
else None
)
provider_capability = str(
_resolved_context_value(
node.config.get("provider_capability"),
instance.context_,
)
or ""
).strip()
external_id = str(
_resolved_context_value(
node.config.get("external_id"),
instance.context_,
)
or ""
).strip()
if not provider_capability or not external_id:
raise WorkflowConflictError(
"External hand-offs require a provider capability and external reference."
)
expected_revision_value = _resolved_context_value(
node.config.get("expected_revision"),
instance.context_,
)
expected_revision = (
int(expected_revision_value)
if expected_revision_value not in (None, "")
else None
)
if expected_revision is not None and expected_revision < 1:
raise WorkflowConflictError(
"External hand-off revisions start at one."
)
action_url = _safe_action_url(
_resolved_context_value(
node.config.get("action_url"),
instance.context_,
)
)
immutable_ref = str(
_resolved_context_value(
node.config.get("immutable_ref"),
instance.context_,
)
or ""
).strip()[:1_000]
if not immutable_ref:
raise WorkflowConflictError(
"External hand-offs require an immutable reference."
)
optional_capabilities = _resolved_context_value(
node.config.get("optional_capabilities") or {},
instance.context_,
)
if not isinstance(optional_capabilities, Mapping):
raise WorkflowConflictError(
"External hand-off capability availability must be an object."
)
state = WorkflowWaitState(
tenant_id=instance.tenant_id,
instance_id=instance.id,
step_id=step.id,
mode="external_handoff",
status="waiting",
due_at=due_at,
event_type=event_type,
config_={
"filter": dict(event_filter),
"outcome_path": str(
node.config.get("outcome_path") or "payload.outcome"
).strip(),
"terminal_outcomes": terminal_outcomes,
"observed_outcomes": list(observed),
"observed_event_ids": [],
"provider_capability": provider_capability,
"external_id": external_id,
"expected_revision": expected_revision,
"action_url": action_url,
"immutable_ref": immutable_ref,
"optional_capabilities": dict(optional_capabilities),
},
)
session.add(state)
session.flush()
return state
def resolve_wait_state(
session: Session,
*,
@@ -328,7 +473,7 @@ def ingest_platform_event(
.where(
WorkflowWaitState.tenant_id == tenant_id,
WorkflowWaitState.status == "waiting",
WorkflowWaitState.mode == "event",
WorkflowWaitState.mode.in_(("event", "external_handoff")),
WorkflowWaitState.event_type == event.type,
)
.with_for_update(skip_locked=True)
@@ -338,6 +483,71 @@ def ingest_platform_event(
for state in waits:
if not _matches_filter(envelope, state.config_.get("filter")):
continue
if state.mode == "external_handoff":
observed_ids = [
str(value)
for value in state.config_.get("observed_event_ids") or ()
]
if event.event_id in observed_ids:
continue
outcome = str(
_value_at_path(
envelope,
state.config_.get("outcome_path") or "payload.outcome",
)
or ""
).strip()
terminal = state.config_.get("terminal_outcomes")
terminal_port = (
str(terminal.get(outcome) or "").strip()
if isinstance(terminal, Mapping)
else ""
)
observed_outcomes = {
str(value).strip()
for value in state.config_.get("observed_outcomes") or ()
if str(value).strip()
}
if not terminal_port and outcome not in observed_outcomes:
continue
state.config_ = {
**dict(state.config_),
"observed_event_ids": [*observed_ids[-99:], event.event_id],
"last_outcome": outcome,
**(
{"selected_port": terminal_port}
if terminal_port
else {}
),
}
if not terminal_port:
step = session.get(WorkflowInstanceStep, state.step_id)
instance = session.get(WorkflowInstance, state.instance_id)
if step is not None and instance is not None:
step.handoff = {
**dict(step.handoff),
"state": outcome,
"last_event_id": event.event_id,
}
from govoplan_workflow_engine.backend.instance_service import (
_record_event,
)
_record_event(
session,
instance,
step=step,
kind="workflow.external_handoff.observed",
actor_id=event.actor.id if event.actor else None,
payload={
"event_id": event.event_id,
"event_type": event.type,
"outcome": outcome,
"external_id": state.config_.get("external_id"),
},
)
state.revision += 1
continue
state.status = "triggered"
state.source_event_id = event.event_id
state.event_ = envelope
@@ -700,11 +910,45 @@ def _dispatch_waits(
continue
graph = _runtime_graph(revision)
triggered = state.status == "triggered"
external_inspection: dict[str, object] | None = None
if triggered and state.mode == "external_handoff":
external_inspection = _inspect_external_handoff(
session,
state=state,
step=step,
principal=principal,
registry=registry,
)
if external_inspection.get("allowed") is not True:
reason = str(
external_inspection.get("reason")
or "External hand-off authorization is unavailable."
)
state.error = reason
step.handoff = {
**dict(step.handoff),
"state": "blocked",
"message": reason,
"inspection": external_inspection,
}
skipped += 1
release_workflow_state_fence(session, fence)
continue
state.status = "resumed" if triggered else "timed_out"
state.error = None
state.revision += 1
output = (
{"event": dict(state.event_ or {})}
{
"event": dict(state.event_ or {}),
**(
{
"external_handoff": external_inspection,
"immutable_ref": state.config_.get("immutable_ref"),
}
if external_inspection is not None
else {}
),
}
if triggered
else {"due_at": state.due_at.isoformat() if state.due_at else None}
)
@@ -713,8 +957,12 @@ def _dispatch_waits(
instance,
step=step,
kind=(
"workflow.wait.event_received"
"workflow.external_handoff.completed"
if triggered and state.mode == "external_handoff"
else "workflow.wait.event_received"
if triggered
else "workflow.external_handoff.timed_out"
if state.mode == "external_handoff"
else "workflow.wait.timed_out"
),
actor_id=None,
@@ -725,7 +973,13 @@ def _dispatch_waits(
instance=instance,
step=step,
graph=graph,
port="resumed" if triggered else "timed_out",
port=(
str(state.config_.get("selected_port") or "completed")
if triggered and state.mode == "external_handoff"
else "resumed"
if triggered
else "timed_out"
),
output=output,
actor_id=None,
)
@@ -751,6 +1005,92 @@ def _dispatch_waits(
}
def _inspect_external_handoff(
session: Session,
*,
state: WorkflowWaitState,
step: WorkflowInstanceStep,
principal: ApiPrincipal,
registry: object | None,
) -> dict[str, object]:
from govoplan_core.core.campaigns import CampaignWorkOrchestrationProvider
capability_name = str(
state.config_.get("provider_capability") or ""
).strip()
if (
not capability_name
or registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(capability_name)
):
return {
"allowed": False,
"reason": (
f"External hand-off capability {capability_name!r} is unavailable."
),
"code": "workflow_external_handoff_provider_unavailable",
}
provider = registry.capability(capability_name)
if not isinstance(provider, CampaignWorkOrchestrationProvider):
return {
"allowed": False,
"reason": "External hand-off provider has an incompatible contract.",
"code": "workflow_external_handoff_provider_invalid",
}
event_revision = _value_at_path(
state.event_ or {},
"payload.assignment_revision",
)
try:
expected_revision = (
int(event_revision)
if event_revision not in (None, "")
else None
)
except (TypeError, ValueError):
return {
"allowed": False,
"reason": "External hand-off event has no valid resource revision.",
"code": "workflow_external_handoff_event_revision_invalid",
}
inspection = provider.inspect_handoff(
session,
principal,
tenant_id=state.tenant_id,
assignment_id=str(state.config_.get("external_id") or ""),
expected_revision=expected_revision,
)
payload: dict[str, object] = {
"allowed": inspection.allowed,
"status": inspection.status,
"assignment_revision": inspection.assignment_revision,
"action_url": inspection.action_url,
"assignment_ref": inspection.assignment_ref,
"reason": inspection.reason,
"provenance": dict(inspection.provenance),
}
selected_port = str(state.config_.get("selected_port") or "")
if inspection.allowed and inspection.status != selected_port:
payload.update(
{
"allowed": False,
"reason": (
"External hand-off state does not match the terminal event; "
"reload and reconcile the provider."
),
"code": "workflow_external_handoff_state_mismatch",
}
)
if inspection.allowed and inspection.assignment_ref:
state.config_ = {
**dict(state.config_),
"immutable_ref": inspection.assignment_ref,
}
step.external_ref = inspection.assignment_ref
return payload
def _resolve_trigger_principal(
session: Session,
*,
@@ -978,6 +1318,70 @@ def _event_filter(value: object) -> dict[str, object]:
return parsed
def _resolved_context_value(
value: object,
context: Mapping[str, object],
*,
depth: int = 0,
) -> object:
if depth > 10:
raise WorkflowConflictError(
"External hand-off configuration is nested too deeply."
)
if isinstance(value, str) and value.startswith("$"):
path = value[1:].lstrip(".")
current: object = context
if not path:
return dict(context)
for segment in path.split("."):
if not isinstance(current, Mapping) or segment not in current:
raise WorkflowConflictError(
f"External hand-off input path {value!r} is unavailable."
)
current = current[segment]
return current
if isinstance(value, Mapping):
return {
str(key): _resolved_context_value(
item,
context,
depth=depth + 1,
)
for key, item in value.items()
}
if isinstance(value, list):
return [
_resolved_context_value(item, context, depth=depth + 1)
for item in value
]
return value
def _value_at_path(value: object, path: object) -> object | None:
current = value
for segment in str(path or "").strip().lstrip("$").lstrip(".").split("."):
if not segment:
continue
if not isinstance(current, Mapping) or segment not in current:
return None
current = current[segment]
return current
def _safe_action_url(value: object) -> str:
candidate = str(value or "").strip()
if (
not candidate.startswith("/")
or candidate.startswith("//")
or "\\" in candidate
or any(ord(character) < 32 or ord(character) == 127 for character in candidate)
):
raise WorkflowConflictError(
"External hand-off action URLs must be safe application-relative paths."
)
return candidate[:1_500]
def _validated_event_type(value: object) -> str:
event_type = str(value or "").strip()
if not _EVENT_TYPE.fullmatch(event_type):
+315 -2
View File
@@ -12,6 +12,10 @@ from govoplan_core.core.access import (
PrincipalRef,
)
from govoplan_core.core.automation import AutomationPrincipalResolution
from govoplan_core.core.campaigns import (
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION,
CampaignWorkHandoffInspection,
)
from govoplan_core.core.events import EventTenantRef, PlatformEvent
from govoplan_core.core.recovery import RecoveryCheckpoint, RecoveryOperation
from govoplan_core.core.runtime_coordination import (
@@ -39,6 +43,7 @@ from govoplan_workflow_engine.backend.schemas import (
WorkflowNode,
)
from govoplan_workflow_engine.backend.service import (
WorkflowConflictError,
activate_definition,
create_definition,
)
@@ -82,17 +87,58 @@ class AutomationProvider:
)
class CampaignHandoffProvider:
def __init__(self) -> None:
self.allowed = True
self.status = "completed"
self.revision = 2
self.inspections: list[tuple[str, int | None]] = []
def prepare_handoff(self, _session, _principal, *, request):
raise AssertionError("The external wait must not create Campaign work.")
def inspect_handoff(
self,
_session,
_principal,
*,
tenant_id,
assignment_id,
expected_revision=None,
):
assert tenant_id == "tenant-1"
self.inspections.append((assignment_id, expected_revision))
return CampaignWorkHandoffInspection(
allowed=self.allowed,
status=self.status,
assignment_revision=self.revision,
action_url="/campaigns/campaign-1/work?assignment=assignment-1",
assignment_ref=f"campaign-work-assignment:assignment-1:r{self.revision}",
reason=None if self.allowed else "Campaign access was revoked.",
provenance={"access_rechecked": True},
)
class Registry:
def __init__(self) -> None:
self.provider = AutomationProvider()
self.campaign = CampaignHandoffProvider()
def has_capability(self, name: str) -> bool:
return name == CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER
return (
name == CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER
or (
name == CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION
and self.campaign is not None
)
)
def capability(self, name: str):
if not self.has_capability(name):
raise KeyError(name)
return self.provider
if name == CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER:
return self.provider
return self.campaign
def graph(start_type: str, *, wait: WorkflowNode | None = None) -> WorkflowGraph:
@@ -123,6 +169,83 @@ def graph(start_type: str, *, wait: WorkflowNode | None = None) -> WorkflowGraph
return WorkflowGraph(nodes=nodes, edges=edges)
def external_handoff_graph(*, timeout_after: str = "1h") -> WorkflowGraph:
return WorkflowGraph(
nodes=[
WorkflowNode(id="start", type="workflow.start.manual"),
WorkflowNode(
id="campaign_work",
type="workflow.external_handoff",
label="Complete Campaign review",
config={
"provider_capability": CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION,
"event_type": "campaign.work.changed",
"event_filter": {
"payload": {"assignment_id": "$input.assignment_id"}
},
"outcome_path": "payload.outcome",
"terminal_outcomes": {
"completed": "completed",
"rejected": "rejected",
"cancelled": "cancelled",
},
"observed_outcomes": ["assigned", "accepted", "reassigned"],
"external_id": "$input.assignment_id",
"expected_revision": "$input.assignment_revision",
"action_url": "$input.action_url",
"immutable_ref": "$input.assignment_ref",
"optional_capabilities": "$input.optional_capabilities",
"timeout_after": timeout_after,
"view_surface_ids": ["campaigns.page.work"],
},
),
WorkflowNode(id="completed", type="workflow.end.completed"),
WorkflowNode(
id="rejected",
type="workflow.end.cancelled",
config={"reason": "Campaign work rejected"},
),
WorkflowNode(
id="cancelled",
type="workflow.end.cancelled",
config={"reason": "Campaign work cancelled"},
),
WorkflowNode(
id="timed_out",
type="workflow.end.cancelled",
config={"reason": "Campaign work timed out"},
),
],
edges=[
WorkflowEdge(id="start-work", source="start", target="campaign_work"),
WorkflowEdge(
id="work-completed",
source="campaign_work",
source_port="completed",
target="completed",
),
WorkflowEdge(
id="work-rejected",
source="campaign_work",
source_port="rejected",
target="rejected",
),
WorkflowEdge(
id="work-cancelled",
source="campaign_work",
source_port="cancelled",
target="cancelled",
),
WorkflowEdge(
id="work-timeout",
source="campaign_work",
source_port="timed_out",
target="timed_out",
),
],
)
class WorkflowTriggerTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
@@ -175,6 +298,18 @@ class WorkflowTriggerTests(unittest.TestCase):
)
return definition
def _external_input(self) -> dict[str, object]:
return {
"assignment_id": "assignment-1",
"assignment_revision": 1,
"action_url": "/campaigns/campaign-1/work?assignment=assignment-1",
"assignment_ref": "campaign-work-assignment:assignment-1:r1",
"optional_capabilities": {
"tasks": False,
"notifications": False,
},
}
def test_schedule_registration_dispatch_and_replay_are_durable(self) -> None:
definition = self._definition(
graph("workflow.start.schedule"),
@@ -280,6 +415,184 @@ class WorkflowTriggerTests(unittest.TestCase):
self.assertEqual(1, result["waits_timed_out"])
self.assertEqual("completed", instance.status)
def test_external_handoff_observes_duplicate_safe_events_and_resumes(self) -> None:
definition = self._definition(
external_handoff_graph(),
automation=False,
)
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="campaign-handoff-1",
input=self._external_input(),
),
)
step = self.session.get(WorkflowInstanceStep, instance.current_step_id)
state = self.session.scalar(select(WorkflowWaitState))
assert step is not None and state is not None
self.assertEqual("external_handoff", state.mode)
self.assertEqual("assigned", step.handoff["state"])
self.assertEqual(
["notifications", "tasks"],
step.handoff["unavailable_optional_capabilities"],
)
dispatcher = SqlWorkflowTriggerDispatcher(registry=self.registry)
accepted = PlatformEvent(
type="campaign.work.changed",
module_id="campaigns",
event_id="campaign-event-accepted",
tenant=EventTenantRef(id="tenant-1"),
payload={
"assignment_id": "assignment-1",
"assignment_revision": 2,
"outcome": "accepted",
},
)
observed = dispatcher.ingest_event(self.session, event=accepted)
duplicate = dispatcher.ingest_event(self.session, event=accepted)
self.assertEqual(0, observed["waits_triggered"])
self.assertEqual(0, duplicate["waits_triggered"])
self.assertEqual("accepted", step.handoff["state"])
self.assertEqual(
1,
self.session.query(WorkflowInstanceEvent)
.filter(
WorkflowInstanceEvent.kind
== "workflow.external_handoff.observed"
)
.count(),
)
completed = dispatcher.ingest_event(
self.session,
event=PlatformEvent(
type="campaign.work.changed",
module_id="campaigns",
event_id="campaign-event-completed",
tenant=EventTenantRef(id="tenant-1"),
payload={
"assignment_id": "assignment-1",
"assignment_revision": 2,
"outcome": "completed",
},
),
)
result = dispatcher.dispatch_due(self.session)
self.assertEqual(1, completed["waits_triggered"])
self.assertEqual(1, result["waits_resumed"])
self.assertEqual("completed", instance.status)
self.assertEqual(
[("assignment-1", 2)],
self.registry.campaign.inspections,
)
self.assertEqual(
"campaign-work-assignment:assignment-1:r2",
step.external_ref,
)
def test_external_handoff_revoked_access_blocks_until_rechecked(self) -> None:
definition = self._definition(
external_handoff_graph(),
automation=False,
)
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="campaign-handoff-revoked",
input=self._external_input(),
),
)
dispatcher = SqlWorkflowTriggerDispatcher(registry=self.registry)
dispatcher.ingest_event(
self.session,
event=PlatformEvent(
type="campaign.work.changed",
module_id="campaigns",
tenant=EventTenantRef(id="tenant-1"),
payload={
"assignment_id": "assignment-1",
"assignment_revision": 2,
"outcome": "completed",
},
),
)
self.registry.campaign.allowed = False
blocked = dispatcher.dispatch_due(self.session)
step = self.session.get(WorkflowInstanceStep, instance.current_step_id)
assert step is not None
self.assertEqual(1, blocked["waits_skipped"])
self.assertEqual("waiting", instance.status)
self.assertEqual("blocked", step.handoff["state"])
self.assertIn("revoked", str(step.handoff["message"]))
self.registry.campaign.allowed = True
resumed = dispatcher.dispatch_due(self.session)
self.assertEqual(1, resumed["waits_resumed"])
self.assertEqual("completed", instance.status)
def test_external_handoff_timeout_and_optional_provider_absence(self) -> None:
definition = self._definition(
external_handoff_graph(timeout_after="1s"),
automation=False,
)
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="campaign-handoff-timeout",
input=self._external_input(),
),
)
result = SqlWorkflowTriggerDispatcher(
registry=self.registry
).dispatch_due(
self.session,
now=datetime.now(tz=UTC) + timedelta(seconds=2),
)
self.assertEqual(1, result["waits_timed_out"])
self.assertEqual("cancelled", instance.status)
unavailable = self._definition(
external_handoff_graph(),
automation=False,
)
self.registry.campaign = None # type: ignore[assignment]
with self.assertRaisesRegex(WorkflowConflictError, "is not available"):
start_instance(
self.session,
tenant_id="tenant-1",
definition_id=unavailable.id,
actor_id="account-1",
principal=principal(),
registry=self.registry,
payload=WorkflowInstanceStartRequest(
idempotency_key="campaign-handoff-unavailable",
input=self._external_input(),
),
)
def test_parent_workflow_outcome_starts_pinned_child(self) -> None:
parent = self._definition(
graph("workflow.start.manual"),