feat: add resumable external work hand-offs
Module Package Release / publish-packages (push) Successful in 10s
Module Package Release / publish-packages (push) Successful in 10s
This commit is contained in:
@@ -56,6 +56,13 @@ human handoffs; event filters and variable mappings are bounded JSON
|
|||||||
expressions and never executable code. Cron remains an optional governed
|
expressions and never executable code. Cron remains an optional governed
|
||||||
scheduler-adapter concern.
|
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
|
Consequential module actions are staged in Core's durable recovery ledger
|
||||||
before provider dispatch. Conclusive effects commit with the Workflow
|
before provider dispatch. Conclusive effects commit with the Workflow
|
||||||
projection. Lost acknowledgements block continuation and expose evidence-based
|
projection. Lost acknowledgements block continuation and expose evidence-based
|
||||||
|
|||||||
@@ -119,6 +119,9 @@ The first executable slice now provides:
|
|||||||
- activation-bound API, one-time/interval schedule, platform-event, and
|
- activation-bound API, one-time/interval schedule, platform-event, and
|
||||||
parent-workflow trigger registrations with exact-revision dispatch
|
parent-workflow trigger registrations with exact-revision dispatch
|
||||||
- durable duration/deadline/event wait subscriptions and scale-out-safe claims
|
- 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
|
- a separate transactional platform-event consumer with bounded JSON filters
|
||||||
and variable mappings, idempotent delivery, and current-authority rechecks
|
and variable mappings, idempotent delivery, and current-authority rechecks
|
||||||
- Core recovery-ledger operations for module actions, including canonical
|
- Core recovery-ledger operations for module actions, including canonical
|
||||||
|
|||||||
+2
-2
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-workflow-engine"
|
name = "govoplan-workflow-engine"
|
||||||
version = "0.1.18"
|
version = "0.1.19"
|
||||||
description = "Headless, versioned workflow definition and execution engine for GovOPlaN."
|
description = "Headless, versioned workflow definition and execution engine for GovOPlaN."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = "AGPL-3.0-or-later"
|
license = "AGPL-3.0-or-later"
|
||||||
authors = [{ name = "GovOPlaN" }]
|
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]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|||||||
@@ -228,6 +228,10 @@ def legacy_graph_to_bpmn(graph: WorkflowGraph) -> WorkflowGraph:
|
|||||||
config["wait_mode"] = config.pop("mode", "manual")
|
config["wait_mode"] = config.pop("mode", "manual")
|
||||||
config.setdefault("event_definition", "none")
|
config.setdefault("event_definition", "none")
|
||||||
bpmn_type = "bpmn.intermediateCatchEvent"
|
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":
|
elif node_type == "workflow.capability":
|
||||||
config["implementation"] = "capability"
|
config["implementation"] = "capability"
|
||||||
bpmn_type = "bpmn.serviceTask"
|
bpmn_type = "bpmn.serviceTask"
|
||||||
@@ -1002,8 +1006,13 @@ def _runtime_node(node: WorkflowNode) -> WorkflowNode:
|
|||||||
elif node.type in {"bpmn.task", "bpmn.userTask", "bpmn.manualTask"}:
|
elif node.type in {"bpmn.task", "bpmn.userTask", "bpmn.manualTask"}:
|
||||||
node_type = "workflow.activity"
|
node_type = "workflow.activity"
|
||||||
elif node.type in {"bpmn.receiveTask", "bpmn.intermediateCatchEvent"}:
|
elif node.type in {"bpmn.receiveTask", "bpmn.intermediateCatchEvent"}:
|
||||||
node_type = "workflow.wait"
|
wait_mode = str(config.get("wait_mode") or "event")
|
||||||
config["mode"] = 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"}:
|
elif node.type in {"bpmn.serviceTask", "bpmn.sendTask"}:
|
||||||
implementation = str(config.get("implementation") or "capability")
|
implementation = str(config.get("implementation") or "capability")
|
||||||
node_type = (
|
node_type = (
|
||||||
|
|||||||
@@ -751,7 +751,7 @@ def cancel_instance(
|
|||||||
instance.id,
|
instance.id,
|
||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
if step.node_type == "workflow.wait":
|
if step.node_type in {"workflow.wait", "workflow.external_handoff"}:
|
||||||
from govoplan_workflow_engine.backend.triggers import (
|
from govoplan_workflow_engine.backend.triggers import (
|
||||||
resolve_wait_state,
|
resolve_wait_state,
|
||||||
)
|
)
|
||||||
@@ -1010,6 +1010,25 @@ def _drive_instance(
|
|||||||
registry=registry,
|
registry=registry,
|
||||||
)
|
)
|
||||||
return
|
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":
|
if node.type == "workflow.wait":
|
||||||
from govoplan_workflow_engine.backend.triggers import (
|
from govoplan_workflow_engine.backend.triggers import (
|
||||||
register_wait_state,
|
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(
|
def _set_dependency_handoff(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -2807,6 +2871,26 @@ def _require_runtime_dependencies(
|
|||||||
principal=principal,
|
principal=principal,
|
||||||
registry=registry,
|
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(
|
def _authorization_payload(
|
||||||
|
|||||||
@@ -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.campaigns import CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION
|
||||||
from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY
|
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,
|
||||||
@@ -16,6 +17,7 @@ from govoplan_core.core.module_guards import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
CapabilityDocumentation,
|
CapabilityDocumentation,
|
||||||
|
DocumentationCondition,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
MigrationSpec,
|
MigrationSpec,
|
||||||
ModuleContext,
|
ModuleContext,
|
||||||
@@ -58,7 +60,7 @@ from govoplan_workflow_engine.backend.service_launcher import (
|
|||||||
|
|
||||||
MODULE_ID = "workflow_engine"
|
MODULE_ID = "workflow_engine"
|
||||||
MODULE_NAME = "Workflow Engine"
|
MODULE_NAME = "Workflow Engine"
|
||||||
MODULE_VERSION = "0.1.18"
|
MODULE_VERSION = "0.1.19"
|
||||||
|
|
||||||
DEFINITION_READ_SCOPE = "workflow:definition:read"
|
DEFINITION_READ_SCOPE = "workflow:definition:read"
|
||||||
DEFINITION_WRITE_SCOPE = "workflow:definition:write"
|
DEFINITION_WRITE_SCOPE = "workflow:definition:write"
|
||||||
@@ -213,6 +215,7 @@ manifest = ModuleManifest(
|
|||||||
optional_dependencies=(
|
optional_dependencies=(
|
||||||
"access",
|
"access",
|
||||||
"audit",
|
"audit",
|
||||||
|
"campaigns",
|
||||||
"dataflow",
|
"dataflow",
|
||||||
"datasources",
|
"datasources",
|
||||||
"idm",
|
"idm",
|
||||||
@@ -227,6 +230,7 @@ manifest = ModuleManifest(
|
|||||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
|
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION,
|
||||||
CAPABILITY_DATAFLOW_RUN_LIFECYCLE,
|
CAPABILITY_DATAFLOW_RUN_LIFECYCLE,
|
||||||
CAPABILITY_IDM_DIRECTORY,
|
CAPABILITY_IDM_DIRECTORY,
|
||||||
CAPABILITY_NOTIFICATIONS_DISPATCH,
|
CAPABILITY_NOTIFICATIONS_DISPATCH,
|
||||||
@@ -269,6 +273,12 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
requires_interfaces=(
|
requires_interfaces=(
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name="campaigns.work_orchestration",
|
||||||
|
version_min="1.0.0",
|
||||||
|
version_max_exclusive="2.0.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
ModuleInterfaceRequirement(
|
ModuleInterfaceRequirement(
|
||||||
name=CAPABILITY_ACCESS_REFERENCE_OPTIONS,
|
name=CAPABILITY_ACCESS_REFERENCE_OPTIONS,
|
||||||
version_min="0.1.0",
|
version_min="0.1.0",
|
||||||
@@ -376,6 +386,50 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
documentation=(
|
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(
|
DocumentationTopic(
|
||||||
id="workflow.data-subject-requests",
|
id="workflow.data-subject-requests",
|
||||||
title="Workflow data-subject requests",
|
title="Workflow data-subject requests",
|
||||||
@@ -492,6 +546,7 @@ manifest = ModuleManifest(
|
|||||||
"workflow revision",
|
"workflow revision",
|
||||||
"workflow instance",
|
"workflow instance",
|
||||||
"work transition",
|
"work transition",
|
||||||
|
"external event hand-off",
|
||||||
"execution adapter binding",
|
"execution adapter binding",
|
||||||
),
|
),
|
||||||
non_owned_concepts=(
|
non_owned_concepts=(
|
||||||
|
|||||||
@@ -269,6 +269,86 @@ LEGACY_WORKFLOW_NODE_TYPES = (
|
|||||||
"view_surface_ids": [],
|
"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(
|
DefinitionNodeType(
|
||||||
type="workflow.capability",
|
type="workflow.capability",
|
||||||
category="integration",
|
category="integration",
|
||||||
|
|||||||
@@ -264,6 +264,151 @@ def register_wait_state(
|
|||||||
return 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(
|
def resolve_wait_state(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -328,7 +473,7 @@ def ingest_platform_event(
|
|||||||
.where(
|
.where(
|
||||||
WorkflowWaitState.tenant_id == tenant_id,
|
WorkflowWaitState.tenant_id == tenant_id,
|
||||||
WorkflowWaitState.status == "waiting",
|
WorkflowWaitState.status == "waiting",
|
||||||
WorkflowWaitState.mode == "event",
|
WorkflowWaitState.mode.in_(("event", "external_handoff")),
|
||||||
WorkflowWaitState.event_type == event.type,
|
WorkflowWaitState.event_type == event.type,
|
||||||
)
|
)
|
||||||
.with_for_update(skip_locked=True)
|
.with_for_update(skip_locked=True)
|
||||||
@@ -338,6 +483,71 @@ def ingest_platform_event(
|
|||||||
for state in waits:
|
for state in waits:
|
||||||
if not _matches_filter(envelope, state.config_.get("filter")):
|
if not _matches_filter(envelope, state.config_.get("filter")):
|
||||||
continue
|
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.status = "triggered"
|
||||||
state.source_event_id = event.event_id
|
state.source_event_id = event.event_id
|
||||||
state.event_ = envelope
|
state.event_ = envelope
|
||||||
@@ -700,11 +910,45 @@ def _dispatch_waits(
|
|||||||
continue
|
continue
|
||||||
graph = _runtime_graph(revision)
|
graph = _runtime_graph(revision)
|
||||||
triggered = state.status == "triggered"
|
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.status = "resumed" if triggered else "timed_out"
|
||||||
state.error = None
|
state.error = None
|
||||||
state.revision += 1
|
state.revision += 1
|
||||||
output = (
|
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
|
if triggered
|
||||||
else {"due_at": state.due_at.isoformat() if state.due_at else None}
|
else {"due_at": state.due_at.isoformat() if state.due_at else None}
|
||||||
)
|
)
|
||||||
@@ -713,8 +957,12 @@ def _dispatch_waits(
|
|||||||
instance,
|
instance,
|
||||||
step=step,
|
step=step,
|
||||||
kind=(
|
kind=(
|
||||||
"workflow.wait.event_received"
|
"workflow.external_handoff.completed"
|
||||||
|
if triggered and state.mode == "external_handoff"
|
||||||
|
else "workflow.wait.event_received"
|
||||||
if triggered
|
if triggered
|
||||||
|
else "workflow.external_handoff.timed_out"
|
||||||
|
if state.mode == "external_handoff"
|
||||||
else "workflow.wait.timed_out"
|
else "workflow.wait.timed_out"
|
||||||
),
|
),
|
||||||
actor_id=None,
|
actor_id=None,
|
||||||
@@ -725,7 +973,13 @@ def _dispatch_waits(
|
|||||||
instance=instance,
|
instance=instance,
|
||||||
step=step,
|
step=step,
|
||||||
graph=graph,
|
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,
|
output=output,
|
||||||
actor_id=None,
|
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(
|
def _resolve_trigger_principal(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -978,6 +1318,70 @@ def _event_filter(value: object) -> dict[str, object]:
|
|||||||
return parsed
|
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:
|
def _validated_event_type(value: object) -> str:
|
||||||
event_type = str(value or "").strip()
|
event_type = str(value or "").strip()
|
||||||
if not _EVENT_TYPE.fullmatch(event_type):
|
if not _EVENT_TYPE.fullmatch(event_type):
|
||||||
|
|||||||
+314
-1
@@ -12,6 +12,10 @@ from govoplan_core.core.access import (
|
|||||||
PrincipalRef,
|
PrincipalRef,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.automation import AutomationPrincipalResolution
|
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.events import EventTenantRef, PlatformEvent
|
||||||
from govoplan_core.core.recovery import RecoveryCheckpoint, RecoveryOperation
|
from govoplan_core.core.recovery import RecoveryCheckpoint, RecoveryOperation
|
||||||
from govoplan_core.core.runtime_coordination import (
|
from govoplan_core.core.runtime_coordination import (
|
||||||
@@ -39,6 +43,7 @@ from govoplan_workflow_engine.backend.schemas import (
|
|||||||
WorkflowNode,
|
WorkflowNode,
|
||||||
)
|
)
|
||||||
from govoplan_workflow_engine.backend.service import (
|
from govoplan_workflow_engine.backend.service import (
|
||||||
|
WorkflowConflictError,
|
||||||
activate_definition,
|
activate_definition,
|
||||||
create_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:
|
class Registry:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.provider = AutomationProvider()
|
self.provider = AutomationProvider()
|
||||||
|
self.campaign = CampaignHandoffProvider()
|
||||||
|
|
||||||
def has_capability(self, name: str) -> bool:
|
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):
|
def capability(self, name: str):
|
||||||
if not self.has_capability(name):
|
if not self.has_capability(name):
|
||||||
raise KeyError(name)
|
raise KeyError(name)
|
||||||
|
if name == CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER:
|
||||||
return self.provider
|
return self.provider
|
||||||
|
return self.campaign
|
||||||
|
|
||||||
|
|
||||||
def graph(start_type: str, *, wait: WorkflowNode | None = None) -> WorkflowGraph:
|
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)
|
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):
|
class WorkflowTriggerTests(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.engine = create_engine("sqlite:///:memory:")
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
@@ -175,6 +298,18 @@ class WorkflowTriggerTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
return definition
|
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:
|
def test_schedule_registration_dispatch_and_replay_are_durable(self) -> None:
|
||||||
definition = self._definition(
|
definition = self._definition(
|
||||||
graph("workflow.start.schedule"),
|
graph("workflow.start.schedule"),
|
||||||
@@ -280,6 +415,184 @@ class WorkflowTriggerTests(unittest.TestCase):
|
|||||||
self.assertEqual(1, result["waits_timed_out"])
|
self.assertEqual(1, result["waits_timed_out"])
|
||||||
self.assertEqual("completed", instance.status)
|
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:
|
def test_parent_workflow_outcome_starts_pinned_child(self) -> None:
|
||||||
parent = self._definition(
|
parent = self._definition(
|
||||||
graph("workflow.start.manual"),
|
graph("workflow.start.manual"),
|
||||||
|
|||||||
Reference in New Issue
Block a user