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
@@ -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):