Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2fc5639db | ||
|
|
4c447671b0 | ||
|
|
d338403dbd | ||
|
|
b0a9dc9739 | ||
|
|
ceb61b5867 | ||
|
|
9174e07118 | ||
|
|
0259eea702 | ||
|
|
2e6ed78bfc |
@@ -1,5 +1,22 @@
|
|||||||
# GovOPlaN Workflow Engine
|
# GovOPlaN Workflow Engine
|
||||||
|
|
||||||
|
## Data-subject requests
|
||||||
|
|
||||||
|
Workflow Engine publishes `privacy.dsar.workflow_engine` for exact definition,
|
||||||
|
revision, instance, step, event, trigger, delivery, and wait references and for
|
||||||
|
structured instance authorization, work assignments, automation authority, and
|
||||||
|
minimized operator attribution. It never exports graphs/BPMN, inputs, context,
|
||||||
|
outputs, handoffs, events/configuration, authorization snapshots, errors,
|
||||||
|
replay keys, external references, hashes, or credentials. Owning service, case,
|
||||||
|
form, and other source modules locate and correct facts inside arbitrary
|
||||||
|
runtime payloads.
|
||||||
|
|
||||||
|
Subject-linked automation can be disabled and revoked, while exact terminal
|
||||||
|
delivery detail can be minimized idempotently without changing its replay key.
|
||||||
|
Definitions, instances, live work, assignments, transition and decision
|
||||||
|
events, waits, and institutional attribution require authorized review or
|
||||||
|
retention.
|
||||||
|
|
||||||
<!-- govoplan-repository-type:start -->
|
<!-- govoplan-repository-type:start -->
|
||||||
**Repository type:** module (platform).
|
**Repository type:** module (platform).
|
||||||
<!-- govoplan-repository-type:end -->
|
<!-- govoplan-repository-type:end -->
|
||||||
@@ -39,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
|
||||||
|
|||||||
@@ -135,3 +135,21 @@ baseline revision inactive when an older revision is active, and fails closed
|
|||||||
when required capabilities or interfaces are absent. Baselines are immutable;
|
when required capabilities or interfaces are absent. Baselines are immutable;
|
||||||
editing derives a pinned local override, and reset archives that override
|
editing derives a pinned local override, and reset archives that override
|
||||||
without removing revision or instance history.
|
without removing revision or instance history.
|
||||||
|
|
||||||
|
## Common Work Inbox
|
||||||
|
|
||||||
|
Workflow Engine also projects the current human handoff of a waiting instance
|
||||||
|
through Core's versioned work-item provider contract. Tasks may aggregate that
|
||||||
|
projection when it is enabled, while Workflow Engine remains the sole owner of
|
||||||
|
the instance, step, allowed actions, and completion state.
|
||||||
|
|
||||||
|
Human activities and reviews accept account, group, role, function, and
|
||||||
|
function-assignment responsibility references. A missing assignee defaults to
|
||||||
|
the account that started the instance. `due_after` uses the same bounded
|
||||||
|
duration syntax as Workflow timers (`5m`, `2h`, `1d`, or ISO 8601). The Engine
|
||||||
|
materializes assignment and due-date columns on the current step so inbox reads
|
||||||
|
remain tenant-scoped and indexed; historical JSON handoffs are interpreted
|
||||||
|
conservatively for upgrade compatibility. Automated waits and in-flight
|
||||||
|
provider calls are not presented as human work. Recovery-required, unknown,
|
||||||
|
dependency, and failed handoffs appear as blocked work and always link back to
|
||||||
|
the pinned Workflow instance.
|
||||||
|
|||||||
+2
-2
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-workflow-engine"
|
name = "govoplan-workflow-engine"
|
||||||
version = "0.1.16"
|
version = "0.1.21"
|
||||||
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.16"]
|
dependencies = ["defusedxml>=0.7,<1", "govoplan-core>=0.1.28"]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""Headless GovOPlaN Workflow Engine module."""
|
"""Headless GovOPlaN Workflow Engine module."""
|
||||||
|
|
||||||
__version__ = "0.1.16"
|
__version__ = "0.1.21"
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
|||||||
@@ -298,6 +298,19 @@ class WorkflowInstance(Base, TimestampMixin):
|
|||||||
"status",
|
"status",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
),
|
),
|
||||||
|
Index(
|
||||||
|
"ix_workflow_instances_tenant_created_id",
|
||||||
|
"tenant_id",
|
||||||
|
"created_at",
|
||||||
|
"id",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_workflow_instances_tenant_definition_created_id",
|
||||||
|
"tenant_id",
|
||||||
|
"definition_id",
|
||||||
|
"created_at",
|
||||||
|
"id",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
@@ -383,6 +396,9 @@ class WorkflowInstance(Base, TimestampMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
definition: Mapped[WorkflowDefinition] = relationship(back_populates="instances")
|
definition: Mapped[WorkflowDefinition] = relationship(back_populates="instances")
|
||||||
|
definition_revision: Mapped[WorkflowDefinitionRevision] = relationship(
|
||||||
|
viewonly=True,
|
||||||
|
)
|
||||||
steps: Mapped[list["WorkflowInstanceStep"]] = relationship(
|
steps: Mapped[list["WorkflowInstanceStep"]] = relationship(
|
||||||
back_populates="instance",
|
back_populates="instance",
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
@@ -408,6 +424,13 @@ class WorkflowInstanceStep(Base, TimestampMixin):
|
|||||||
"tenant_id",
|
"tenant_id",
|
||||||
"status",
|
"status",
|
||||||
),
|
),
|
||||||
|
Index(
|
||||||
|
"ix_workflow_instance_steps_work_assignment",
|
||||||
|
"tenant_id",
|
||||||
|
"status",
|
||||||
|
"work_assignment_kind",
|
||||||
|
"work_assignment_id",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
@@ -465,6 +488,25 @@ class WorkflowInstanceStep(Base, TimestampMixin):
|
|||||||
String(255),
|
String(255),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
)
|
)
|
||||||
|
work_assignment_kind: Mapped[str | None] = mapped_column(
|
||||||
|
String(40),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
work_assignment_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
work_assignment_label: Mapped[str | None] = mapped_column(
|
||||||
|
String(500),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
work_due_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
instance: Mapped[WorkflowInstance] = relationship(back_populates="steps")
|
instance: Mapped[WorkflowInstance] = relationship(back_populates="steps")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,879 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.dsar import (
|
||||||
|
DsarErasureActionRef,
|
||||||
|
DsarExecutionResultRef,
|
||||||
|
DsarRecordRef,
|
||||||
|
DsarSubjectRef,
|
||||||
|
dsar_capability_name,
|
||||||
|
)
|
||||||
|
from govoplan_workflow_engine.backend.db.models import (
|
||||||
|
WorkflowDefinition,
|
||||||
|
WorkflowDefinitionRevision,
|
||||||
|
WorkflowInstance,
|
||||||
|
WorkflowInstanceEvent,
|
||||||
|
WorkflowInstanceStep,
|
||||||
|
WorkflowTrigger,
|
||||||
|
WorkflowTriggerDelivery,
|
||||||
|
WorkflowWaitState,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
WORKFLOW_ENGINE_DSAR_CAPABILITY = dsar_capability_name("workflow_engine")
|
||||||
|
_MAX_RECORDS = 5_000
|
||||||
|
_CONFLICT = object()
|
||||||
|
_DIRECT_ALIASES = {
|
||||||
|
"definition_id": ("workflow_engine.definition", "workflow.definition"),
|
||||||
|
"revision_id": (
|
||||||
|
"workflow_engine.definition_revision",
|
||||||
|
"workflow_engine.revision",
|
||||||
|
),
|
||||||
|
"instance_id": ("workflow_engine.instance", "workflow.instance"),
|
||||||
|
"step_id": ("workflow_engine.step", "workflow.step"),
|
||||||
|
"event_id": ("workflow_engine.event", "workflow.event"),
|
||||||
|
"trigger_id": ("workflow_engine.trigger", "workflow.trigger"),
|
||||||
|
"delivery_id": (
|
||||||
|
"workflow_engine.trigger_delivery",
|
||||||
|
"workflow_engine.delivery",
|
||||||
|
),
|
||||||
|
"wait_id": ("workflow_engine.wait_state", "workflow_engine.wait"),
|
||||||
|
}
|
||||||
|
_RESOURCE_MODELS = {
|
||||||
|
"workflow_definition": WorkflowDefinition,
|
||||||
|
"workflow_definition_revision": WorkflowDefinitionRevision,
|
||||||
|
"workflow_instance": WorkflowInstance,
|
||||||
|
"workflow_instance_step": WorkflowInstanceStep,
|
||||||
|
"workflow_instance_event": WorkflowInstanceEvent,
|
||||||
|
"workflow_trigger": WorkflowTrigger,
|
||||||
|
"workflow_trigger_delivery": WorkflowTriggerDelivery,
|
||||||
|
"workflow_wait_state": WorkflowWaitState,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _Selectors:
|
||||||
|
account_id: str | None
|
||||||
|
identity_id: str | None
|
||||||
|
membership_id: str | None
|
||||||
|
direct: dict[str, str]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subject_ids(self) -> tuple[str, ...]:
|
||||||
|
return tuple(
|
||||||
|
value
|
||||||
|
for value in (self.account_id, self.identity_id, self.membership_id)
|
||||||
|
if value
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _Match:
|
||||||
|
resource_type: str
|
||||||
|
row: Any
|
||||||
|
category: str
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowEngineDsarProvider:
|
||||||
|
provider_id = "workflow_engine"
|
||||||
|
module_id = "workflow_engine"
|
||||||
|
|
||||||
|
def search_subject(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
) -> Sequence[DsarRecordRef]:
|
||||||
|
db = _session(session)
|
||||||
|
selectors = _selectors(subject)
|
||||||
|
if selectors is None or not (selectors.subject_ids or selectors.direct):
|
||||||
|
return ()
|
||||||
|
direct = _direct_matches(db, tenant_id=tenant_id, selectors=selectors)
|
||||||
|
if direct is None:
|
||||||
|
return ()
|
||||||
|
if direct:
|
||||||
|
if selectors.subject_ids and not all(
|
||||||
|
_correlates(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
match=match,
|
||||||
|
subject_ids=selectors.subject_ids,
|
||||||
|
)
|
||||||
|
for match in direct
|
||||||
|
):
|
||||||
|
return ()
|
||||||
|
matches = direct
|
||||||
|
else:
|
||||||
|
matches = _canonical_matches(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
selectors=selectors,
|
||||||
|
)
|
||||||
|
|
||||||
|
records: list[DsarRecordRef] = []
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
for match in matches:
|
||||||
|
key = (match.resource_type, str(match.row.id))
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
if len(records) >= _MAX_RECORDS:
|
||||||
|
raise ValueError(
|
||||||
|
"Workflow Engine DSAR result limit exceeded; narrow the selectors."
|
||||||
|
)
|
||||||
|
seen.add(key)
|
||||||
|
records.append(_record(match))
|
||||||
|
return tuple(records)
|
||||||
|
|
||||||
|
def plan_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
records: Sequence[DsarRecordRef],
|
||||||
|
) -> Sequence[DsarErasureActionRef]:
|
||||||
|
del tenant_id
|
||||||
|
_session(session)
|
||||||
|
if _selectors(subject) is None:
|
||||||
|
raise ValueError("Workflow Engine DSAR subject selectors conflict.")
|
||||||
|
actions: list[DsarErasureActionRef] = []
|
||||||
|
for record in records:
|
||||||
|
_validate_record(record)
|
||||||
|
kind = _planned_kind(record)
|
||||||
|
executable = kind in {"anonymize", "revoke"}
|
||||||
|
actions.append(
|
||||||
|
DsarErasureActionRef(
|
||||||
|
action_id=(
|
||||||
|
f"workflow_engine:{kind}:{record.resource_type}:"
|
||||||
|
f"{record.resource_id}"
|
||||||
|
),
|
||||||
|
provider_id=self.provider_id,
|
||||||
|
module_id=self.module_id,
|
||||||
|
kind=kind,
|
||||||
|
resource_type=record.resource_type,
|
||||||
|
resource_id=record.resource_id,
|
||||||
|
title=(
|
||||||
|
f"Minimize {record.title}"
|
||||||
|
if kind == "anonymize"
|
||||||
|
else f"{kind.replace('_', ' ').title()} {record.title}"
|
||||||
|
),
|
||||||
|
rationale=_rationale(record, kind=kind),
|
||||||
|
executable=executable,
|
||||||
|
irreversible=kind == "anonymize",
|
||||||
|
metadata={"record_category": record.category},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(actions)
|
||||||
|
|
||||||
|
def execute_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
actions: Sequence[DsarErasureActionRef],
|
||||||
|
request_id: str,
|
||||||
|
) -> Sequence[DsarExecutionResultRef]:
|
||||||
|
db = _session(session)
|
||||||
|
selectors = _selectors(subject)
|
||||||
|
if selectors is None:
|
||||||
|
raise ValueError("Workflow Engine DSAR subject selectors conflict.")
|
||||||
|
results: list[DsarExecutionResultRef] = []
|
||||||
|
for action in actions:
|
||||||
|
_validate_action(action)
|
||||||
|
if not action.executable:
|
||||||
|
results.append(
|
||||||
|
DsarExecutionResultRef(
|
||||||
|
action_id=action.action_id,
|
||||||
|
status="blocked",
|
||||||
|
summary=(
|
||||||
|
"Review live work, third parties, decision evidence, "
|
||||||
|
"retention, and downstream effects before changing state."
|
||||||
|
),
|
||||||
|
evidence={"request_id": request_id},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
model = _RESOURCE_MODELS[action.resource_type]
|
||||||
|
row = (
|
||||||
|
db.query(model)
|
||||||
|
.filter(model.tenant_id == tenant_id, model.id == action.resource_id)
|
||||||
|
.with_for_update()
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
status = "unchanged"
|
||||||
|
summary = "Workflow row was already absent or minimized."
|
||||||
|
else:
|
||||||
|
match = _Match(action.resource_type, row, "execution")
|
||||||
|
if not (
|
||||||
|
_directly_targets(selectors, match)
|
||||||
|
or _correlates(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
match=match,
|
||||||
|
subject_ids=selectors.subject_ids,
|
||||||
|
)
|
||||||
|
or _already_revoked(action.resource_type, row)
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Workflow Engine DSAR action is not corroborated by the subject."
|
||||||
|
)
|
||||||
|
status, summary = _execute_action(
|
||||||
|
db,
|
||||||
|
resource_type=action.resource_type,
|
||||||
|
row=row,
|
||||||
|
kind=action.kind,
|
||||||
|
)
|
||||||
|
results.append(
|
||||||
|
DsarExecutionResultRef(
|
||||||
|
action_id=action.action_id,
|
||||||
|
status=status,
|
||||||
|
summary=summary,
|
||||||
|
evidence={"request_id": request_id},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(results)
|
||||||
|
|
||||||
|
|
||||||
|
def _direct_matches(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
selectors: _Selectors,
|
||||||
|
) -> list[_Match] | None:
|
||||||
|
matches: list[_Match] = []
|
||||||
|
for selector, raw_value in selectors.direct.items():
|
||||||
|
value = _strip_prefix(raw_value)
|
||||||
|
if selector == "definition_id":
|
||||||
|
definition = _one(session, WorkflowDefinition, tenant_id, value)
|
||||||
|
if definition is None:
|
||||||
|
return None
|
||||||
|
current = _definition_package(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
definition=definition,
|
||||||
|
)
|
||||||
|
elif selector == "instance_id":
|
||||||
|
instance = _one(session, WorkflowInstance, tenant_id, value)
|
||||||
|
if instance is None:
|
||||||
|
return None
|
||||||
|
current = _instance_package(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
instance=instance,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
model, resource_type = {
|
||||||
|
"revision_id": (
|
||||||
|
WorkflowDefinitionRevision,
|
||||||
|
"workflow_definition_revision",
|
||||||
|
),
|
||||||
|
"step_id": (WorkflowInstanceStep, "workflow_instance_step"),
|
||||||
|
"event_id": (WorkflowInstanceEvent, "workflow_instance_event"),
|
||||||
|
"trigger_id": (WorkflowTrigger, "workflow_trigger"),
|
||||||
|
"delivery_id": (
|
||||||
|
WorkflowTriggerDelivery,
|
||||||
|
"workflow_trigger_delivery",
|
||||||
|
),
|
||||||
|
"wait_id": (WorkflowWaitState, "workflow_wait_state"),
|
||||||
|
}[selector]
|
||||||
|
row = _one(session, model, tenant_id, value)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
current = [_Match(resource_type, row, _direct_category(resource_type, row))]
|
||||||
|
matches.extend(current)
|
||||||
|
if len(matches) > _MAX_RECORDS:
|
||||||
|
raise ValueError(
|
||||||
|
"Workflow Engine DSAR result limit exceeded; narrow the selectors."
|
||||||
|
)
|
||||||
|
return matches
|
||||||
|
|
||||||
|
|
||||||
|
def _definition_package(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
definition: WorkflowDefinition,
|
||||||
|
) -> list[_Match]:
|
||||||
|
matches = [_Match("workflow_definition", definition, "workflow_definition_package")]
|
||||||
|
specs = (
|
||||||
|
(WorkflowDefinitionRevision, "workflow_definition_revision"),
|
||||||
|
(WorkflowInstance, "workflow_instance"),
|
||||||
|
(WorkflowTrigger, "workflow_trigger"),
|
||||||
|
(WorkflowTriggerDelivery, "workflow_trigger_delivery"),
|
||||||
|
)
|
||||||
|
instances: list[WorkflowInstance] = []
|
||||||
|
for model, resource_type in specs:
|
||||||
|
rows = (
|
||||||
|
session.query(model)
|
||||||
|
.filter(model.tenant_id == tenant_id, model.definition_id == definition.id)
|
||||||
|
.order_by(model.id)
|
||||||
|
.limit(_MAX_RECORDS + 1)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if model is WorkflowInstance:
|
||||||
|
instances = rows
|
||||||
|
matches.extend(
|
||||||
|
_Match(resource_type, row, "workflow_definition_package") for row in rows
|
||||||
|
)
|
||||||
|
instance_ids = {row.id for row in instances}
|
||||||
|
if instance_ids:
|
||||||
|
for model, resource_type in (
|
||||||
|
(WorkflowInstanceStep, "workflow_instance_step"),
|
||||||
|
(WorkflowInstanceEvent, "workflow_instance_event"),
|
||||||
|
(WorkflowWaitState, "workflow_wait_state"),
|
||||||
|
):
|
||||||
|
rows = (
|
||||||
|
session.query(model)
|
||||||
|
.filter(
|
||||||
|
model.tenant_id == tenant_id,
|
||||||
|
model.instance_id.in_(instance_ids),
|
||||||
|
)
|
||||||
|
.order_by(model.id)
|
||||||
|
.limit(_MAX_RECORDS + 1)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
matches.extend(
|
||||||
|
_Match(resource_type, row, "workflow_definition_package")
|
||||||
|
for row in rows
|
||||||
|
)
|
||||||
|
return matches
|
||||||
|
|
||||||
|
|
||||||
|
def _instance_package(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
instance: WorkflowInstance,
|
||||||
|
) -> list[_Match]:
|
||||||
|
matches = [_Match("workflow_instance", instance, "workflow_instance_package")]
|
||||||
|
specs = (
|
||||||
|
(WorkflowInstanceStep, "workflow_instance_step"),
|
||||||
|
(WorkflowInstanceEvent, "workflow_instance_event"),
|
||||||
|
(WorkflowWaitState, "workflow_wait_state"),
|
||||||
|
)
|
||||||
|
for model, resource_type in specs:
|
||||||
|
rows = (
|
||||||
|
session.query(model)
|
||||||
|
.filter(model.tenant_id == tenant_id, model.instance_id == instance.id)
|
||||||
|
.order_by(model.id)
|
||||||
|
.limit(_MAX_RECORDS + 1)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
matches.extend(
|
||||||
|
_Match(resource_type, row, "workflow_instance_package") for row in rows
|
||||||
|
)
|
||||||
|
return matches
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_matches(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
selectors: _Selectors,
|
||||||
|
) -> list[_Match]:
|
||||||
|
subject_ids = selectors.subject_ids
|
||||||
|
if not subject_ids:
|
||||||
|
return []
|
||||||
|
triggers = (
|
||||||
|
session.query(WorkflowTrigger)
|
||||||
|
.filter(
|
||||||
|
WorkflowTrigger.tenant_id == tenant_id,
|
||||||
|
or_(
|
||||||
|
WorkflowTrigger.authorization_account_id.in_(subject_ids),
|
||||||
|
WorkflowTrigger.authorization_membership_id.in_(subject_ids),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.order_by(WorkflowTrigger.id)
|
||||||
|
.limit(_MAX_RECORDS + 1)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
matches = [
|
||||||
|
_Match("workflow_trigger", row, "workflow_automation_authority")
|
||||||
|
for row in triggers
|
||||||
|
]
|
||||||
|
instances = (
|
||||||
|
session.query(WorkflowInstance)
|
||||||
|
.filter(WorkflowInstance.tenant_id == tenant_id)
|
||||||
|
.order_by(WorkflowInstance.id)
|
||||||
|
.limit(_MAX_RECORDS + 1)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if len(instances) > _MAX_RECORDS:
|
||||||
|
raise ValueError(
|
||||||
|
"Workflow Engine DSAR instance scan limit exceeded; use an exact instance reference."
|
||||||
|
)
|
||||||
|
for row in instances:
|
||||||
|
authorization = dict(row.authorization_ or {})
|
||||||
|
if any(
|
||||||
|
str(authorization.get(key) or "") == value
|
||||||
|
for key, value in (
|
||||||
|
("account_id", selectors.account_id),
|
||||||
|
("identity_id", selectors.identity_id),
|
||||||
|
("membership_id", selectors.membership_id),
|
||||||
|
)
|
||||||
|
if value
|
||||||
|
):
|
||||||
|
matches.append(
|
||||||
|
_Match(
|
||||||
|
"workflow_instance",
|
||||||
|
row,
|
||||||
|
"workflow_subject_authorization",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
steps = (
|
||||||
|
session.query(WorkflowInstanceStep)
|
||||||
|
.filter(
|
||||||
|
WorkflowInstanceStep.tenant_id == tenant_id,
|
||||||
|
WorkflowInstanceStep.work_assignment_id.in_(subject_ids),
|
||||||
|
)
|
||||||
|
.order_by(WorkflowInstanceStep.id)
|
||||||
|
.limit(_MAX_RECORDS + 1)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
matches.extend(
|
||||||
|
_Match("workflow_instance_step", row, "workflow_subject_work_assignment")
|
||||||
|
for row in steps
|
||||||
|
)
|
||||||
|
if len(matches) > _MAX_RECORDS:
|
||||||
|
raise ValueError(
|
||||||
|
"Workflow Engine DSAR result limit exceeded; narrow the selectors."
|
||||||
|
)
|
||||||
|
specs = (
|
||||||
|
(
|
||||||
|
WorkflowDefinition,
|
||||||
|
or_(
|
||||||
|
WorkflowDefinition.created_by.in_(subject_ids),
|
||||||
|
WorkflowDefinition.updated_by.in_(subject_ids),
|
||||||
|
),
|
||||||
|
"workflow_definition",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
WorkflowDefinitionRevision,
|
||||||
|
WorkflowDefinitionRevision.created_by.in_(subject_ids),
|
||||||
|
"workflow_definition_revision",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
WorkflowInstance,
|
||||||
|
WorkflowInstance.created_by.in_(subject_ids),
|
||||||
|
"workflow_instance",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
WorkflowInstanceStep,
|
||||||
|
WorkflowInstanceStep.completed_by.in_(subject_ids),
|
||||||
|
"workflow_instance_step",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
WorkflowInstanceEvent,
|
||||||
|
WorkflowInstanceEvent.actor_id.in_(subject_ids),
|
||||||
|
"workflow_instance_event",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
WorkflowTrigger,
|
||||||
|
or_(
|
||||||
|
WorkflowTrigger.created_by.in_(subject_ids),
|
||||||
|
WorkflowTrigger.updated_by.in_(subject_ids),
|
||||||
|
),
|
||||||
|
"workflow_trigger",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for model, condition, resource_type in specs:
|
||||||
|
rows = (
|
||||||
|
session.query(model)
|
||||||
|
.filter(model.tenant_id == tenant_id, condition)
|
||||||
|
.order_by(model.id)
|
||||||
|
.limit(_MAX_RECORDS + 1)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
matches.extend(
|
||||||
|
_Match(resource_type, row, "workflow_operator_attribution") for row in rows
|
||||||
|
)
|
||||||
|
if len(matches) > _MAX_RECORDS:
|
||||||
|
raise ValueError(
|
||||||
|
"Workflow Engine DSAR result limit exceeded; narrow the selectors."
|
||||||
|
)
|
||||||
|
return matches
|
||||||
|
|
||||||
|
|
||||||
|
def _one(session: Session, model: Any, tenant_id: str, row_id: str) -> Any | None:
|
||||||
|
return (
|
||||||
|
session.query(model)
|
||||||
|
.filter(model.tenant_id == tenant_id, model.id == row_id)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _direct_category(resource_type: str, row: Any) -> str:
|
||||||
|
if resource_type == "workflow_trigger_delivery":
|
||||||
|
return (
|
||||||
|
"terminal_workflow_delivery"
|
||||||
|
if row.status in {"succeeded", "failed", "skipped", "cancelled"}
|
||||||
|
else "active_workflow_delivery"
|
||||||
|
)
|
||||||
|
return "workflow_governed_state"
|
||||||
|
|
||||||
|
|
||||||
|
def _correlates(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
match: _Match,
|
||||||
|
subject_ids: tuple[str, ...],
|
||||||
|
) -> bool:
|
||||||
|
row = match.row
|
||||||
|
if any(
|
||||||
|
str(getattr(row, field, "") or "") in subject_ids
|
||||||
|
for field in (
|
||||||
|
"created_by",
|
||||||
|
"updated_by",
|
||||||
|
"completed_by",
|
||||||
|
"actor_id",
|
||||||
|
"work_assignment_id",
|
||||||
|
"authorization_account_id",
|
||||||
|
"authorization_membership_id",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
if match.resource_type == "workflow_instance":
|
||||||
|
authorization = dict(row.authorization_ or {})
|
||||||
|
if any(
|
||||||
|
str(authorization.get(key) or "") in subject_ids
|
||||||
|
for key in ("account_id", "identity_id", "membership_id")
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
instance_id = getattr(row, "instance_id", None)
|
||||||
|
if instance_id:
|
||||||
|
instance = session.get(WorkflowInstance, instance_id)
|
||||||
|
if instance and instance.tenant_id == tenant_id:
|
||||||
|
return _correlates(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
match=_Match("workflow_instance", instance, "related"),
|
||||||
|
subject_ids=subject_ids,
|
||||||
|
)
|
||||||
|
trigger_id = getattr(row, "trigger_id", None)
|
||||||
|
if trigger_id:
|
||||||
|
trigger = session.get(WorkflowTrigger, trigger_id)
|
||||||
|
if trigger and trigger.tenant_id == tenant_id:
|
||||||
|
return _correlates(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
match=_Match("workflow_trigger", trigger, "related"),
|
||||||
|
subject_ids=subject_ids,
|
||||||
|
)
|
||||||
|
definition_id = getattr(row, "definition_id", None)
|
||||||
|
if definition_id:
|
||||||
|
definition = session.get(WorkflowDefinition, definition_id)
|
||||||
|
return bool(
|
||||||
|
definition
|
||||||
|
and definition.tenant_id == tenant_id
|
||||||
|
and (
|
||||||
|
definition.created_by in subject_ids
|
||||||
|
or definition.updated_by in subject_ids
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _directly_targets(selectors: _Selectors, match: _Match) -> bool:
|
||||||
|
selector = {
|
||||||
|
"workflow_definition": "definition_id",
|
||||||
|
"workflow_definition_revision": "revision_id",
|
||||||
|
"workflow_instance": "instance_id",
|
||||||
|
"workflow_instance_step": "step_id",
|
||||||
|
"workflow_instance_event": "event_id",
|
||||||
|
"workflow_trigger": "trigger_id",
|
||||||
|
"workflow_trigger_delivery": "delivery_id",
|
||||||
|
"workflow_wait_state": "wait_id",
|
||||||
|
}[match.resource_type]
|
||||||
|
return _strip_prefix(selectors.direct.get(selector, "")) == str(match.row.id)
|
||||||
|
|
||||||
|
|
||||||
|
def _record(match: _Match) -> DsarRecordRef:
|
||||||
|
row = match.row
|
||||||
|
immutable = match.category == "workflow_operator_attribution"
|
||||||
|
return DsarRecordRef(
|
||||||
|
provider_id="workflow_engine",
|
||||||
|
module_id="workflow_engine",
|
||||||
|
resource_type=match.resource_type,
|
||||||
|
resource_id=str(row.id),
|
||||||
|
category=match.category,
|
||||||
|
title=match.resource_type.removeprefix("workflow_").replace("_", " ").title(),
|
||||||
|
data={
|
||||||
|
key: value
|
||||||
|
for key, value in _record_data(match.resource_type, row).items()
|
||||||
|
if value is not None
|
||||||
|
},
|
||||||
|
observed_at=_observed_at(row),
|
||||||
|
immutable_evidence=immutable,
|
||||||
|
retention_reason=(
|
||||||
|
"Institutional Workflow authorship, assignments, transitions, and "
|
||||||
|
"decision events remain attributable for governance and audit."
|
||||||
|
if immutable
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
source_path="/workflow",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_data(resource_type: str, row: Any) -> dict[str, object]:
|
||||||
|
if resource_type == "workflow_definition":
|
||||||
|
return {
|
||||||
|
"scope_type": row.scope_type,
|
||||||
|
"definition_kind": row.definition_kind,
|
||||||
|
"status": row.status,
|
||||||
|
"current_revision": row.current_revision,
|
||||||
|
"active_revision": row.active_revision,
|
||||||
|
"allow_start": row.allow_start,
|
||||||
|
"allow_reuse": row.allow_reuse,
|
||||||
|
"allow_automation": row.allow_automation,
|
||||||
|
"deleted_at": _iso(row.deleted_at),
|
||||||
|
}
|
||||||
|
if resource_type == "workflow_definition_revision":
|
||||||
|
return {
|
||||||
|
"revision": row.revision,
|
||||||
|
"schema_version": row.schema_version,
|
||||||
|
"execution_mode": row.execution_mode,
|
||||||
|
"bpmn_runtime_kind": row.bpmn_runtime_kind,
|
||||||
|
"bpmn_executable": row.bpmn_executable,
|
||||||
|
"created_at": _iso(row.created_at),
|
||||||
|
}
|
||||||
|
if resource_type == "workflow_instance":
|
||||||
|
return {
|
||||||
|
"status": row.status,
|
||||||
|
"start_origin": row.start_origin,
|
||||||
|
"started_at": _iso(row.started_at),
|
||||||
|
"finished_at": _iso(row.finished_at),
|
||||||
|
"cancellation_requested_at": _iso(row.cancellation_requested_at),
|
||||||
|
}
|
||||||
|
if resource_type == "workflow_instance_step":
|
||||||
|
return {
|
||||||
|
"sequence": row.sequence,
|
||||||
|
"node_type": row.node_type,
|
||||||
|
"status": row.status,
|
||||||
|
"attempt": row.attempt,
|
||||||
|
"work_assignment_kind": row.work_assignment_kind,
|
||||||
|
"work_due_at": _iso(row.work_due_at),
|
||||||
|
"started_at": _iso(row.started_at),
|
||||||
|
"finished_at": _iso(row.finished_at),
|
||||||
|
}
|
||||||
|
if resource_type == "workflow_instance_event":
|
||||||
|
return {
|
||||||
|
"sequence": row.sequence,
|
||||||
|
"kind": row.kind,
|
||||||
|
"created_at": _iso(row.created_at),
|
||||||
|
}
|
||||||
|
if resource_type == "workflow_trigger":
|
||||||
|
return {
|
||||||
|
"kind": row.kind,
|
||||||
|
"status": row.status,
|
||||||
|
"event_type": row.event_type,
|
||||||
|
"authorization_subject_kind": row.authorization_subject_kind,
|
||||||
|
"next_fire_at": _iso(row.next_fire_at),
|
||||||
|
"last_fire_at": _iso(row.last_fire_at),
|
||||||
|
}
|
||||||
|
if resource_type == "workflow_trigger_delivery":
|
||||||
|
return {
|
||||||
|
"invocation_kind": row.invocation_kind,
|
||||||
|
"status": row.status,
|
||||||
|
"attempts": row.attempts,
|
||||||
|
"scheduled_for": _iso(row.scheduled_for),
|
||||||
|
"created_at": _iso(row.created_at),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"mode": row.mode,
|
||||||
|
"status": row.status,
|
||||||
|
"due_at": _iso(row.due_at),
|
||||||
|
"event_type": row.event_type,
|
||||||
|
"revision": row.revision,
|
||||||
|
"created_at": _iso(row.created_at),
|
||||||
|
"updated_at": _iso(row.updated_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _planned_kind(record: DsarRecordRef) -> str:
|
||||||
|
if record.category == "terminal_workflow_delivery":
|
||||||
|
return "anonymize"
|
||||||
|
if record.category == "workflow_automation_authority":
|
||||||
|
return "revoke"
|
||||||
|
if record.category == "workflow_operator_attribution":
|
||||||
|
return "retain"
|
||||||
|
return "manual_review"
|
||||||
|
|
||||||
|
|
||||||
|
def _rationale(record: DsarRecordRef, *, kind: str) -> str:
|
||||||
|
if kind == "anonymize":
|
||||||
|
return (
|
||||||
|
"Clear terminal trigger event and error detail while preserving the "
|
||||||
|
"replay identity and minimal delivery evidence."
|
||||||
|
)
|
||||||
|
if kind == "revoke":
|
||||||
|
return (
|
||||||
|
"Disable automation and remove subject-linked delegated authority "
|
||||||
|
"without deleting historical workflow evidence."
|
||||||
|
)
|
||||||
|
if kind == "retain":
|
||||||
|
return record.retention_reason or "Retain institutional attribution evidence."
|
||||||
|
return (
|
||||||
|
"A Workflow owner must review live work, assignments, third parties, "
|
||||||
|
"decision evidence, source authority, and retention."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_action(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
resource_type: str,
|
||||||
|
row: Any,
|
||||||
|
kind: str,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
if resource_type == "workflow_trigger_delivery" and kind == "anonymize":
|
||||||
|
if row.status not in {"succeeded", "failed", "skipped", "cancelled"}:
|
||||||
|
raise ValueError("Active Workflow deliveries require manual review.")
|
||||||
|
changed = _replace_fields(row, {"event_": None, "error": None})
|
||||||
|
elif resource_type == "workflow_trigger" and kind == "revoke":
|
||||||
|
changed = _replace_fields(
|
||||||
|
row,
|
||||||
|
{
|
||||||
|
"status": "disabled",
|
||||||
|
"config_": {},
|
||||||
|
"last_error": None,
|
||||||
|
"authorization_account_id": None,
|
||||||
|
"authorization_membership_id": None,
|
||||||
|
"authorization_service_account_id": None,
|
||||||
|
"authorization_ref": f"revoked:{row.id}",
|
||||||
|
"grant_scopes": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError("Workflow Engine DSAR executable action is unsupported.")
|
||||||
|
if changed:
|
||||||
|
session.flush()
|
||||||
|
return "executed", "Personal Workflow detail minimized."
|
||||||
|
return "unchanged", "Personal Workflow detail was already minimized."
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_fields(row: Any, values: dict[str, object]) -> bool:
|
||||||
|
changed = False
|
||||||
|
for field, value in values.items():
|
||||||
|
if getattr(row, field) != value:
|
||||||
|
setattr(row, field, value)
|
||||||
|
changed = True
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def _already_revoked(resource_type: str, row: Any) -> bool:
|
||||||
|
return bool(
|
||||||
|
resource_type == "workflow_trigger"
|
||||||
|
and row.status == "disabled"
|
||||||
|
and row.authorization_account_id is None
|
||||||
|
and row.authorization_membership_id is None
|
||||||
|
and row.authorization_service_account_id is None
|
||||||
|
and row.authorization_ref == f"revoked:{row.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _selectors(subject: DsarSubjectRef) -> _Selectors | None:
|
||||||
|
refs = subject.external_references
|
||||||
|
canonical = (
|
||||||
|
_coalesce(
|
||||||
|
subject.account_id,
|
||||||
|
refs.get("workflow_engine.account"),
|
||||||
|
refs.get("access.account"),
|
||||||
|
),
|
||||||
|
_coalesce(
|
||||||
|
subject.identity_id,
|
||||||
|
refs.get("workflow_engine.identity"),
|
||||||
|
refs.get("identity.id"),
|
||||||
|
),
|
||||||
|
_coalesce(
|
||||||
|
subject.membership_id,
|
||||||
|
refs.get("workflow_engine.membership"),
|
||||||
|
refs.get("tenancy.membership"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if any(value is _CONFLICT for value in canonical):
|
||||||
|
return None
|
||||||
|
direct: dict[str, str] = {}
|
||||||
|
for selector, aliases in _DIRECT_ALIASES.items():
|
||||||
|
value = _coalesce(*(refs.get(alias) for alias in aliases))
|
||||||
|
if value is _CONFLICT:
|
||||||
|
return None
|
||||||
|
if value:
|
||||||
|
direct[selector] = str(value)
|
||||||
|
return _Selectors(
|
||||||
|
account_id=_optional(canonical[0]),
|
||||||
|
identity_id=_optional(canonical[1]),
|
||||||
|
membership_id=_optional(canonical[2]),
|
||||||
|
direct=direct,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _coalesce(*values: str | None) -> str | None | object:
|
||||||
|
normalized = {str(value).strip() for value in values if str(value or "").strip()}
|
||||||
|
if len(normalized) > 1:
|
||||||
|
return _CONFLICT
|
||||||
|
return next(iter(normalized), None)
|
||||||
|
|
||||||
|
|
||||||
|
def _optional(value: object) -> str | None:
|
||||||
|
return value if isinstance(value, str) and value else None
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_prefix(value: str) -> str:
|
||||||
|
return value.partition(":")[2] if ":" in value else value
|
||||||
|
|
||||||
|
|
||||||
|
def _observed_at(row: Any) -> datetime | None:
|
||||||
|
for field in ("finished_at", "created_at", "updated_at", "started_at"):
|
||||||
|
value = getattr(row, field, None)
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return _aware(value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(value: datetime | None) -> str | None:
|
||||||
|
aware = _aware(value)
|
||||||
|
return aware.isoformat() if aware else None
|
||||||
|
|
||||||
|
|
||||||
|
def _aware(value: datetime | None) -> datetime | None:
|
||||||
|
if value is None or value.tzinfo is not None:
|
||||||
|
return value
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _session(value: object) -> Session:
|
||||||
|
if not isinstance(value, Session):
|
||||||
|
raise TypeError("Workflow Engine DSAR requires a SQLAlchemy Session.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_record(record: DsarRecordRef) -> None:
|
||||||
|
if record.provider_id != "workflow_engine" or record.module_id != "workflow_engine":
|
||||||
|
raise ValueError("Workflow Engine DSAR cannot plan a foreign provider record.")
|
||||||
|
if record.resource_type not in _RESOURCE_MODELS or not record.resource_id:
|
||||||
|
raise ValueError("Workflow Engine DSAR record identity is invalid.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_action(action: DsarErasureActionRef) -> None:
|
||||||
|
if action.provider_id != "workflow_engine" or action.module_id != "workflow_engine":
|
||||||
|
raise ValueError(
|
||||||
|
"Workflow Engine DSAR cannot execute a foreign provider action."
|
||||||
|
)
|
||||||
|
if action.resource_type not in _RESOURCE_MODELS or not action.action_id.startswith(
|
||||||
|
"workflow_engine:"
|
||||||
|
):
|
||||||
|
raise ValueError("Workflow Engine DSAR action identity is invalid.")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["WORKFLOW_ENGINE_DSAR_CAPABILITY", "WorkflowEngineDsarProvider"]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""German translations for public structured documentation metadata."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'workflow.external-campaign-handoffs': {'limitations': ['Der Kampagnenanbieter muss installiert '
|
||||||
|
'sein, um eine Kampagnenübergabe zu '
|
||||||
|
'starten oder wieder aufzunehmen.',
|
||||||
|
'Die fokussierte View-Projektion ist '
|
||||||
|
'optional; der sichere '
|
||||||
|
'Kampagnen-Aktionslink bleibt ohne sie '
|
||||||
|
'verfügbar.']}}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
"""Authorized, bounded projections for workflow progress and retained history."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
|
||||||
|
from sqlalchemy import and_, func, or_, select
|
||||||
|
from sqlalchemy.orm import Session, joinedload, load_only, raiseload
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal, has_scope
|
||||||
|
from govoplan_workflow_engine.backend.db.models import (
|
||||||
|
WorkflowDefinitionRevision,
|
||||||
|
WorkflowInstance,
|
||||||
|
WorkflowInstanceEvent,
|
||||||
|
WorkflowInstanceStep,
|
||||||
|
)
|
||||||
|
from govoplan_workflow_engine.backend.governance import (
|
||||||
|
definition_decision,
|
||||||
|
require_definition_action,
|
||||||
|
)
|
||||||
|
from govoplan_workflow_engine.backend.instance_service import (
|
||||||
|
event_response,
|
||||||
|
step_response,
|
||||||
|
)
|
||||||
|
from govoplan_workflow_engine.backend.schemas import (
|
||||||
|
WorkflowInstanceEventPageResponse,
|
||||||
|
WorkflowInstanceStepPageResponse,
|
||||||
|
WorkflowInstanceSummaryListResponse,
|
||||||
|
WorkflowInstanceSummaryResponse,
|
||||||
|
)
|
||||||
|
from govoplan_workflow_engine.backend.service import WorkflowNotFoundError
|
||||||
|
|
||||||
|
|
||||||
|
MAX_PAGE_SIZE = 200
|
||||||
|
MAX_HISTORY_SEQUENCE = 2_147_483_647
|
||||||
|
_CANDIDATE_BATCH_SIZE = 250
|
||||||
|
|
||||||
|
|
||||||
|
def _require_read(principal: ApiPrincipal) -> None:
|
||||||
|
if not any(
|
||||||
|
has_scope(principal, scope)
|
||||||
|
for scope in ("workflow:instance:read", "workflow:instance:admin")
|
||||||
|
):
|
||||||
|
raise PermissionError("Workflow instance read permission is required.")
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_statement():
|
||||||
|
# Raiseload makes accidental history/payload expansion fail visibly. Only the
|
||||||
|
# pinned revision metadata is needed, never its graph or BPMN document.
|
||||||
|
return (
|
||||||
|
select(WorkflowInstance)
|
||||||
|
.options(
|
||||||
|
load_only(
|
||||||
|
WorkflowInstance.tenant_id,
|
||||||
|
WorkflowInstance.definition_id,
|
||||||
|
WorkflowInstance.definition_revision_id,
|
||||||
|
WorkflowInstance.status,
|
||||||
|
WorkflowInstance.start_origin,
|
||||||
|
WorkflowInstance.current_step_id,
|
||||||
|
WorkflowInstance.started_at,
|
||||||
|
WorkflowInstance.finished_at,
|
||||||
|
WorkflowInstance.created_at,
|
||||||
|
WorkflowInstance.updated_at,
|
||||||
|
raiseload=True,
|
||||||
|
),
|
||||||
|
joinedload(WorkflowInstance.definition),
|
||||||
|
joinedload(WorkflowInstance.definition_revision).load_only(
|
||||||
|
WorkflowDefinitionRevision.tenant_id,
|
||||||
|
WorkflowDefinitionRevision.definition_id,
|
||||||
|
WorkflowDefinitionRevision.revision,
|
||||||
|
WorkflowDefinitionRevision.content_hash,
|
||||||
|
WorkflowDefinitionRevision.execution_mode,
|
||||||
|
raiseload=True,
|
||||||
|
),
|
||||||
|
raiseload("*"),
|
||||||
|
)
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_evidence(instance: WorkflowInstance) -> None:
|
||||||
|
definition = instance.definition
|
||||||
|
revision = instance.definition_revision
|
||||||
|
if (
|
||||||
|
definition is None
|
||||||
|
or revision is None
|
||||||
|
or revision.id != instance.definition_revision_id
|
||||||
|
or definition.id != instance.definition_id
|
||||||
|
or definition.tenant_id not in {None, instance.tenant_id}
|
||||||
|
or revision.definition_id != definition.id
|
||||||
|
or revision.tenant_id != definition.tenant_id
|
||||||
|
):
|
||||||
|
raise WorkflowNotFoundError(
|
||||||
|
"Workflow instance definition evidence is incomplete."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _summary(instance: WorkflowInstance) -> WorkflowInstanceSummaryResponse:
|
||||||
|
_require_evidence(instance)
|
||||||
|
return WorkflowInstanceSummaryResponse(
|
||||||
|
id=instance.id,
|
||||||
|
definition_id=instance.definition_id,
|
||||||
|
definition_name=instance.definition.name,
|
||||||
|
definition_revision=instance.definition_revision.revision,
|
||||||
|
definition_hash=instance.definition_revision.content_hash,
|
||||||
|
execution_mode=instance.definition_revision.execution_mode,
|
||||||
|
start_origin=instance.start_origin,
|
||||||
|
status=instance.status,
|
||||||
|
current_step_id=instance.current_step_id,
|
||||||
|
started_at=instance.started_at,
|
||||||
|
finished_at=instance.finished_at,
|
||||||
|
created_at=instance.created_at,
|
||||||
|
updated_at=instance.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cursor(instance: WorkflowInstance) -> str:
|
||||||
|
return base64.urlsafe_b64encode(
|
||||||
|
json.dumps([instance.created_at.isoformat(), instance.id]).encode("utf-8")
|
||||||
|
).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _cursor_boundary(cursor: str) -> tuple[datetime, str]:
|
||||||
|
try:
|
||||||
|
if len(cursor) > 256:
|
||||||
|
raise ValueError
|
||||||
|
payload = json.loads(base64.b64decode(cursor, altchars=b"-_", validate=True))
|
||||||
|
if (
|
||||||
|
not isinstance(payload, list)
|
||||||
|
or len(payload) != 2
|
||||||
|
or not all(isinstance(value, str) for value in payload)
|
||||||
|
or not 1 <= len(payload[1]) <= 36
|
||||||
|
):
|
||||||
|
raise ValueError
|
||||||
|
return datetime.fromisoformat(payload[0]), payload[1]
|
||||||
|
except (ValueError, TypeError, UnicodeError, binascii.Error) as exc:
|
||||||
|
raise ValueError("Invalid workflow instance cursor.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def list_instance_summaries(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
registry: object | None,
|
||||||
|
definition_id: str | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
cursor: str | None = None,
|
||||||
|
) -> WorkflowInstanceSummaryListResponse:
|
||||||
|
_require_read(principal)
|
||||||
|
if not 1 <= limit <= MAX_PAGE_SIZE:
|
||||||
|
raise ValueError("Workflow page size must be between 1 and 200.")
|
||||||
|
boundary = _cursor_boundary(cursor) if cursor is not None else None
|
||||||
|
statement = _summary_statement().where(
|
||||||
|
WorkflowInstance.tenant_id == principal.tenant_id,
|
||||||
|
)
|
||||||
|
if definition_id is not None:
|
||||||
|
statement = statement.where(WorkflowInstance.definition_id == definition_id)
|
||||||
|
statement = statement.order_by(
|
||||||
|
WorkflowInstance.created_at.desc(), WorkflowInstance.id.desc()
|
||||||
|
)
|
||||||
|
items: list[WorkflowInstanceSummaryResponse] = []
|
||||||
|
last_cursor: str | None = None
|
||||||
|
# Authorization can depend on a Core policy provider, so a raw SQL count or
|
||||||
|
# SQL limit before authorization cannot define an authorized page. Scan in
|
||||||
|
# bounded batches until the page and one authorized lookahead are found.
|
||||||
|
while True:
|
||||||
|
decisions: dict[str, bool] = {}
|
||||||
|
batch_statement = statement
|
||||||
|
if boundary is not None:
|
||||||
|
created_at, instance_id = boundary
|
||||||
|
batch_statement = batch_statement.where(
|
||||||
|
or_(
|
||||||
|
WorkflowInstance.created_at < created_at,
|
||||||
|
and_(
|
||||||
|
WorkflowInstance.created_at == created_at,
|
||||||
|
WorkflowInstance.id < instance_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
candidates = list(session.scalars(batch_statement.limit(_CANDIDATE_BATCH_SIZE)))
|
||||||
|
for instance in candidates:
|
||||||
|
_require_evidence(instance)
|
||||||
|
allowed = decisions.get(instance.definition_id)
|
||||||
|
if allowed is None:
|
||||||
|
allowed = definition_decision(
|
||||||
|
instance.definition,
|
||||||
|
principal=principal,
|
||||||
|
registry=registry,
|
||||||
|
action="view",
|
||||||
|
).allowed
|
||||||
|
decisions[instance.definition_id] = allowed
|
||||||
|
if not allowed:
|
||||||
|
continue
|
||||||
|
if len(items) == limit:
|
||||||
|
return WorkflowInstanceSummaryListResponse(
|
||||||
|
instances=items, next_cursor=last_cursor
|
||||||
|
)
|
||||||
|
items.append(_summary(instance))
|
||||||
|
last_cursor = _cursor(instance)
|
||||||
|
if len(candidates) < _CANDIDATE_BATCH_SIZE:
|
||||||
|
return WorkflowInstanceSummaryListResponse(instances=items)
|
||||||
|
last = candidates[-1]
|
||||||
|
boundary = (last.created_at, last.id)
|
||||||
|
|
||||||
|
|
||||||
|
def _authorized_instance(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
registry: object | None,
|
||||||
|
instance_id: str,
|
||||||
|
) -> WorkflowInstance:
|
||||||
|
_require_read(principal)
|
||||||
|
instance = session.scalar(
|
||||||
|
_summary_statement().where(
|
||||||
|
WorkflowInstance.tenant_id == principal.tenant_id,
|
||||||
|
WorkflowInstance.id == instance_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if instance is None:
|
||||||
|
raise WorkflowNotFoundError("Workflow instance not found.")
|
||||||
|
_require_evidence(instance)
|
||||||
|
require_definition_action(
|
||||||
|
instance.definition, principal=principal, registry=registry, action="view"
|
||||||
|
)
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
def get_instance_summary(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
registry: object | None,
|
||||||
|
instance_id: str,
|
||||||
|
) -> WorkflowInstanceSummaryResponse:
|
||||||
|
return _summary(
|
||||||
|
_authorized_instance(
|
||||||
|
session, principal=principal, registry=registry, instance_id=instance_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def instance_history_page(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
registry: object | None,
|
||||||
|
instance_id: str,
|
||||||
|
kind: str,
|
||||||
|
limit: int = 100,
|
||||||
|
after_sequence: int = 0,
|
||||||
|
through_sequence: int | None = None,
|
||||||
|
) -> WorkflowInstanceStepPageResponse | WorkflowInstanceEventPageResponse:
|
||||||
|
if kind not in {"steps", "events"}:
|
||||||
|
raise ValueError("Workflow history kind must be steps or events.")
|
||||||
|
if (
|
||||||
|
not 1 <= limit <= MAX_PAGE_SIZE
|
||||||
|
or not 0 <= after_sequence <= MAX_HISTORY_SEQUENCE
|
||||||
|
or (
|
||||||
|
through_sequence is not None
|
||||||
|
and not 0 <= through_sequence <= MAX_HISTORY_SEQUENCE
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ValueError("Invalid workflow history page bounds.")
|
||||||
|
_authorized_instance(
|
||||||
|
session, principal=principal, registry=registry, instance_id=instance_id
|
||||||
|
)
|
||||||
|
model = WorkflowInstanceStep if kind == "steps" else WorkflowInstanceEvent
|
||||||
|
predicates = [
|
||||||
|
model.tenant_id == principal.tenant_id,
|
||||||
|
model.instance_id == instance_id,
|
||||||
|
]
|
||||||
|
if through_sequence is not None:
|
||||||
|
predicates.append(model.sequence <= through_sequence)
|
||||||
|
total, last_sequence = session.execute(
|
||||||
|
select(func.count(), func.max(model.sequence)).where(*predicates)
|
||||||
|
).one()
|
||||||
|
upper = int(last_sequence or 0)
|
||||||
|
rows = list(
|
||||||
|
session.scalars(
|
||||||
|
select(model)
|
||||||
|
.where(
|
||||||
|
*predicates, model.sequence > after_sequence, model.sequence <= upper
|
||||||
|
)
|
||||||
|
.order_by(model.sequence.asc())
|
||||||
|
.limit(limit + 1)
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
next_after = rows[limit - 1].sequence if len(rows) > limit else None
|
||||||
|
common = {
|
||||||
|
"total": int(total),
|
||||||
|
"through_sequence": upper,
|
||||||
|
"next_after_sequence": next_after,
|
||||||
|
}
|
||||||
|
if kind == "steps":
|
||||||
|
return WorkflowInstanceStepPageResponse(
|
||||||
|
steps=[step_response(row) for row in rows[:limit]], **common
|
||||||
|
)
|
||||||
|
return WorkflowInstanceEventPageResponse(
|
||||||
|
events=[event_response(row) for row in rows[:limit]], **common
|
||||||
|
)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -38,7 +38,6 @@ from govoplan_core.core.events import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.db.base import utcnow
|
from govoplan_core.db.base import utcnow
|
||||||
from govoplan_workflow_engine.backend.db.models import (
|
from govoplan_workflow_engine.backend.db.models import (
|
||||||
WorkflowDefinition,
|
|
||||||
WorkflowDefinitionRevision,
|
WorkflowDefinitionRevision,
|
||||||
WorkflowInstance,
|
WorkflowInstance,
|
||||||
WorkflowInstanceEvent,
|
WorkflowInstanceEvent,
|
||||||
@@ -100,6 +99,7 @@ def list_instances(
|
|||||||
selectinload(WorkflowInstance.steps),
|
selectinload(WorkflowInstance.steps),
|
||||||
selectinload(WorkflowInstance.events),
|
selectinload(WorkflowInstance.events),
|
||||||
selectinload(WorkflowInstance.definition),
|
selectinload(WorkflowInstance.definition),
|
||||||
|
selectinload(WorkflowInstance.definition_revision),
|
||||||
)
|
)
|
||||||
.order_by(
|
.order_by(
|
||||||
WorkflowInstance.updated_at.desc(),
|
WorkflowInstance.updated_at.desc(),
|
||||||
@@ -129,6 +129,7 @@ def get_instance(
|
|||||||
selectinload(WorkflowInstance.steps),
|
selectinload(WorkflowInstance.steps),
|
||||||
selectinload(WorkflowInstance.events),
|
selectinload(WorkflowInstance.events),
|
||||||
selectinload(WorkflowInstance.definition),
|
selectinload(WorkflowInstance.definition),
|
||||||
|
selectinload(WorkflowInstance.definition_revision),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if for_update:
|
if for_update:
|
||||||
@@ -751,7 +752,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,
|
||||||
)
|
)
|
||||||
@@ -861,12 +862,17 @@ def instance_response(
|
|||||||
*,
|
*,
|
||||||
replayed: bool = False,
|
replayed: bool = False,
|
||||||
) -> WorkflowInstanceResponse:
|
) -> WorkflowInstanceResponse:
|
||||||
revision = session.get(
|
revision = instance.definition_revision
|
||||||
WorkflowDefinitionRevision,
|
definition = instance.definition
|
||||||
instance.definition_revision_id,
|
if (
|
||||||
)
|
revision is None
|
||||||
definition = session.get(WorkflowDefinition, instance.definition_id)
|
or definition is None
|
||||||
if revision is None or definition is None:
|
or revision.id != instance.definition_revision_id
|
||||||
|
or definition.id != instance.definition_id
|
||||||
|
or definition.tenant_id not in {None, instance.tenant_id}
|
||||||
|
or revision.definition_id != definition.id
|
||||||
|
or revision.tenant_id != definition.tenant_id
|
||||||
|
):
|
||||||
raise WorkflowNotFoundError(
|
raise WorkflowNotFoundError(
|
||||||
"Workflow instance definition evidence is incomplete."
|
"Workflow instance definition evidence is incomplete."
|
||||||
)
|
)
|
||||||
@@ -894,8 +900,14 @@ def instance_response(
|
|||||||
created_by=instance.created_by,
|
created_by=instance.created_by,
|
||||||
created_at=instance.created_at,
|
created_at=instance.created_at,
|
||||||
updated_at=instance.updated_at,
|
updated_at=instance.updated_at,
|
||||||
steps=[
|
steps=[step_response(step) for step in instance.steps],
|
||||||
WorkflowInstanceStepResponse(
|
events=[event_response(event) for event in instance.events],
|
||||||
|
replayed=replayed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def step_response(step: WorkflowInstanceStep) -> WorkflowInstanceStepResponse:
|
||||||
|
return WorkflowInstanceStepResponse(
|
||||||
id=step.id,
|
id=step.id,
|
||||||
sequence=step.sequence,
|
sequence=step.sequence,
|
||||||
node_id=step.node_id,
|
node_id=step.node_id,
|
||||||
@@ -913,10 +925,10 @@ def instance_response(
|
|||||||
created_at=step.created_at,
|
created_at=step.created_at,
|
||||||
updated_at=step.updated_at,
|
updated_at=step.updated_at,
|
||||||
)
|
)
|
||||||
for step in instance.steps
|
|
||||||
],
|
|
||||||
events=[
|
def event_response(event: WorkflowInstanceEvent) -> WorkflowInstanceEventResponse:
|
||||||
WorkflowInstanceEventResponse(
|
return WorkflowInstanceEventResponse(
|
||||||
id=event.id,
|
id=event.id,
|
||||||
sequence=event.sequence,
|
sequence=event.sequence,
|
||||||
step_id=event.step_id,
|
step_id=event.step_id,
|
||||||
@@ -925,10 +937,6 @@ def instance_response(
|
|||||||
payload=dict(event.payload),
|
payload=dict(event.payload),
|
||||||
created_at=event.created_at,
|
created_at=event.created_at,
|
||||||
)
|
)
|
||||||
for event in instance.events
|
|
||||||
],
|
|
||||||
replayed=replayed,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _drive_instance(
|
def _drive_instance(
|
||||||
@@ -1010,6 +1018,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,
|
||||||
@@ -1294,8 +1321,7 @@ def _execute_capability_step(
|
|||||||
action_input=action_input,
|
action_input=action_input,
|
||||||
preview_payload=preview_payload,
|
preview_payload=preview_payload,
|
||||||
backup_reference=(
|
backup_reference=(
|
||||||
str(node.config.get("recovery_backup_reference") or "").strip()
|
str(node.config.get("recovery_backup_reference") or "").strip() or None
|
||||||
or None
|
|
||||||
),
|
),
|
||||||
approval_reference=(
|
approval_reference=(
|
||||||
str(node.config.get("recovery_approval_reference") or "").strip()
|
str(node.config.get("recovery_approval_reference") or "").strip()
|
||||||
@@ -1368,11 +1394,7 @@ def _execute_capability_step(
|
|||||||
"operation_id": exc.operation_id,
|
"operation_id": exc.operation_id,
|
||||||
"status": recovery_status,
|
"status": recovery_status,
|
||||||
"requires_attention": outcome_unknown,
|
"requires_attention": outcome_unknown,
|
||||||
**(
|
**({"next_call_number": call_number + 1} if safe_to_retry else {}),
|
||||||
{"next_call_number": call_number + 1}
|
|
||||||
if safe_to_retry
|
|
||||||
else {}
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1473,9 +1495,7 @@ def _execute_capability_step(
|
|||||||
action_recovery.commit_unknown(
|
action_recovery.commit_unknown(
|
||||||
session,
|
session,
|
||||||
error_type=type(exc).__name__,
|
error_type=type(exc).__name__,
|
||||||
message=(
|
message=("Inspect the provider by stable idempotency key before any retry"),
|
||||||
"Inspect the provider by stable idempotency key before any retry"
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
if not isinstance(result, ActionExecutionResult):
|
if not isinstance(result, ActionExecutionResult):
|
||||||
@@ -1608,9 +1628,7 @@ def _execute_capability_step(
|
|||||||
session,
|
session,
|
||||||
provider_state=result.state,
|
provider_state=result.state,
|
||||||
result_sha256=canonical_sha256(result_payload),
|
result_sha256=canonical_sha256(result_payload),
|
||||||
observed_effects_sha256=canonical_sha256(
|
observed_effects_sha256=canonical_sha256(result_payload["observed_effects"]),
|
||||||
result_payload["observed_effects"]
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
_drive_instance(
|
_drive_instance(
|
||||||
session,
|
session,
|
||||||
@@ -1915,6 +1933,10 @@ def _set_action_handoff(
|
|||||||
"suggested_port": "failure",
|
"suggested_port": "failure",
|
||||||
**details_payload,
|
**details_payload,
|
||||||
}
|
}
|
||||||
|
if state in {"pending", "running"}:
|
||||||
|
_clear_work_projection(step)
|
||||||
|
else:
|
||||||
|
_apply_work_projection(instance, step)
|
||||||
instance.status = "waiting"
|
instance.status = "waiting"
|
||||||
instance.error = step.error
|
instance.error = step.error
|
||||||
if previous.get("state") != state or previous.get("message") != message:
|
if previous.get("state") != state or previous.get("message") != message:
|
||||||
@@ -2109,6 +2131,7 @@ def _handle_dataflow_success(
|
|||||||
"warnings": warnings,
|
"warnings": warnings,
|
||||||
"output": output,
|
"output": output,
|
||||||
}
|
}
|
||||||
|
_apply_work_projection(instance, step)
|
||||||
instance.status = "waiting"
|
instance.status = "waiting"
|
||||||
_record_event(
|
_record_event(
|
||||||
session,
|
session,
|
||||||
@@ -2165,6 +2188,7 @@ def _complete_step(
|
|||||||
step.finished_at = utcnow()
|
step.finished_at = utcnow()
|
||||||
step.completed_by = actor_id
|
step.completed_by = actor_id
|
||||||
step.handoff = {}
|
step.handoff = {}
|
||||||
|
_clear_work_projection(step)
|
||||||
context = dict(instance.context_)
|
context = dict(instance.context_)
|
||||||
step_values = dict(context.get("steps") or {})
|
step_values = dict(context.get("steps") or {})
|
||||||
step_values[step.node_id] = dict(output)
|
step_values[step.node_id] = dict(output)
|
||||||
@@ -2183,6 +2207,80 @@ def _complete_step(
|
|||||||
return _next_node_id(graph, step.node_id, port)
|
return _next_node_id(graph, step.node_id, port)
|
||||||
|
|
||||||
|
|
||||||
|
_WORK_ASSIGNMENT_KINDS = {
|
||||||
|
"account",
|
||||||
|
"group",
|
||||||
|
"role",
|
||||||
|
"function",
|
||||||
|
"function_assignment",
|
||||||
|
"anyone",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _work_assignment(
|
||||||
|
instance: WorkflowInstance,
|
||||||
|
configured: object | None = None,
|
||||||
|
) -> dict[str, str | None] | None:
|
||||||
|
if isinstance(configured, Mapping):
|
||||||
|
kind = str(configured.get("kind") or "").strip()
|
||||||
|
assignment_id = str(configured.get("id") or "").strip()
|
||||||
|
label = str(configured.get("label") or "").strip() or None
|
||||||
|
if kind in _WORK_ASSIGNMENT_KINDS and assignment_id:
|
||||||
|
if kind == "anyone" and assignment_id != "*":
|
||||||
|
return None
|
||||||
|
return {"kind": kind, "id": assignment_id, "label": label}
|
||||||
|
return None
|
||||||
|
|
||||||
|
value = str(configured or "").strip()
|
||||||
|
if value:
|
||||||
|
prefix, separator, remainder = value.partition(":")
|
||||||
|
if separator and prefix in _WORK_ASSIGNMENT_KINDS and remainder.strip():
|
||||||
|
assignment_id = remainder.strip()
|
||||||
|
if prefix == "anyone" and assignment_id != "*":
|
||||||
|
return None
|
||||||
|
return {"kind": prefix, "id": assignment_id, "label": None}
|
||||||
|
return {"kind": "account", "id": value, "label": None}
|
||||||
|
|
||||||
|
account_id = str(instance.authorization_.get("account_id") or "").strip()
|
||||||
|
if not account_id:
|
||||||
|
return None
|
||||||
|
return {"kind": "account", "id": account_id, "label": None}
|
||||||
|
|
||||||
|
|
||||||
|
def _work_due_at(configured: object | None) -> datetime | None:
|
||||||
|
if not str(configured or "").strip():
|
||||||
|
return None
|
||||||
|
from govoplan_workflow_engine.backend.triggers import duration_seconds
|
||||||
|
|
||||||
|
return utcnow() + timedelta(seconds=duration_seconds(configured))
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_work_projection(
|
||||||
|
instance: WorkflowInstance,
|
||||||
|
step: WorkflowInstanceStep,
|
||||||
|
*,
|
||||||
|
assignment: Mapping[str, object] | None = None,
|
||||||
|
due_at: datetime | None = None,
|
||||||
|
) -> None:
|
||||||
|
normalized = (
|
||||||
|
dict(assignment) if assignment is not None else _work_assignment(instance)
|
||||||
|
)
|
||||||
|
if normalized is None:
|
||||||
|
_clear_work_projection(step)
|
||||||
|
return
|
||||||
|
step.work_assignment_kind = str(normalized.get("kind") or "") or None
|
||||||
|
step.work_assignment_id = str(normalized.get("id") or "") or None
|
||||||
|
step.work_assignment_label = str(normalized.get("label") or "") or None
|
||||||
|
step.work_due_at = due_at
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_work_projection(step: WorkflowInstanceStep) -> None:
|
||||||
|
step.work_assignment_kind = None
|
||||||
|
step.work_assignment_id = None
|
||||||
|
step.work_assignment_label = None
|
||||||
|
step.work_due_at = None
|
||||||
|
|
||||||
|
|
||||||
def _set_human_handoff(
|
def _set_human_handoff(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -2200,6 +2298,11 @@ def _set_human_handoff(
|
|||||||
else:
|
else:
|
||||||
actions = ["complete", "cancel"]
|
actions = ["complete", "cancel"]
|
||||||
kind = "activity"
|
kind = "activity"
|
||||||
|
assignment = _work_assignment(
|
||||||
|
instance,
|
||||||
|
node.config.get("reviewer") or node.config.get("assignee"),
|
||||||
|
)
|
||||||
|
due_at = _work_due_at(node.config.get("due_after"))
|
||||||
step.status = "waiting"
|
step.status = "waiting"
|
||||||
step.handoff = {
|
step.handoff = {
|
||||||
"kind": kind,
|
"kind": kind,
|
||||||
@@ -2207,9 +2310,12 @@ def _set_human_handoff(
|
|||||||
"title": str(node.config.get("title") or node.label or node.type),
|
"title": str(node.config.get("title") or node.label or node.type),
|
||||||
"instructions": str(node.config.get("instructions") or ""),
|
"instructions": str(node.config.get("instructions") or ""),
|
||||||
"assignee": node.config.get("reviewer") or node.config.get("assignee"),
|
"assignee": node.config.get("reviewer") or node.config.get("assignee"),
|
||||||
|
"assignment": assignment,
|
||||||
|
"due_at": due_at.isoformat() if due_at else None,
|
||||||
"required_evidence": list(node.config.get("required_evidence") or []),
|
"required_evidence": list(node.config.get("required_evidence") or []),
|
||||||
"allowed_actions": actions,
|
"allowed_actions": actions,
|
||||||
}
|
}
|
||||||
|
_apply_work_projection(instance, step, assignment=assignment, due_at=due_at)
|
||||||
instance.status = "waiting"
|
instance.status = "waiting"
|
||||||
_record_event(
|
_record_event(
|
||||||
session,
|
session,
|
||||||
@@ -2249,6 +2355,7 @@ def _set_automated_wait(
|
|||||||
"event_type": event_type,
|
"event_type": event_type,
|
||||||
"allowed_actions": ["cancel"],
|
"allowed_actions": ["cancel"],
|
||||||
}
|
}
|
||||||
|
_clear_work_projection(step)
|
||||||
instance.status = "waiting"
|
instance.status = "waiting"
|
||||||
_record_event(
|
_record_event(
|
||||||
session,
|
session,
|
||||||
@@ -2260,6 +2367,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,
|
||||||
*,
|
*,
|
||||||
@@ -2275,6 +2427,7 @@ def _set_dependency_handoff(
|
|||||||
"message": message,
|
"message": message,
|
||||||
"allowed_actions": ["retry", "cancel"],
|
"allowed_actions": ["retry", "cancel"],
|
||||||
}
|
}
|
||||||
|
_apply_work_projection(instance, step)
|
||||||
instance.status = "waiting"
|
instance.status = "waiting"
|
||||||
instance.error = message
|
instance.error = message
|
||||||
_record_event(
|
_record_event(
|
||||||
@@ -2382,6 +2535,7 @@ def _set_failure_handoff(
|
|||||||
"allowed_actions": ["retry", "reject", "cancel"],
|
"allowed_actions": ["retry", "reject", "cancel"],
|
||||||
"suggested_port": "failure",
|
"suggested_port": "failure",
|
||||||
}
|
}
|
||||||
|
_apply_work_projection(instance, step)
|
||||||
instance.status = "waiting"
|
instance.status = "waiting"
|
||||||
instance.error = message
|
instance.error = message
|
||||||
_record_event(
|
_record_event(
|
||||||
@@ -2725,6 +2879,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(
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import with_documentation_structured_translations
|
||||||
|
from govoplan_workflow_engine.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from govoplan_core.core.access import (
|
from govoplan_core.core.access import (
|
||||||
@@ -9,12 +12,15 @@ 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.module_guards import (
|
from govoplan_core.core.module_guards import (
|
||||||
drop_table_retirement_provider,
|
drop_table_retirement_provider,
|
||||||
persistent_table_uninstall_guard,
|
persistent_table_uninstall_guard,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
CapabilityDocumentation,
|
CapabilityDocumentation,
|
||||||
|
DocumentationCondition,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
MigrationSpec,
|
MigrationSpec,
|
||||||
ModuleContext,
|
ModuleContext,
|
||||||
@@ -33,6 +39,7 @@ from govoplan_core.core.notifications import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.core.references import CAPABILITY_ACCESS_REFERENCE_OPTIONS
|
from govoplan_core.core.references import CAPABILITY_ACCESS_REFERENCE_OPTIONS
|
||||||
from govoplan_core.core.views import CAPABILITY_VIEWS_RESOLVER
|
from govoplan_core.core.views import CAPABILITY_VIEWS_RESOLVER
|
||||||
|
from govoplan_core.core.tasks import WorkItemProviderRegistration
|
||||||
from govoplan_core.core.workflows import (
|
from govoplan_core.core.workflows import (
|
||||||
CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS,
|
CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS,
|
||||||
CAPABILITY_WORKFLOW_ORCHESTRATION,
|
CAPABILITY_WORKFLOW_ORCHESTRATION,
|
||||||
@@ -41,6 +48,10 @@ from govoplan_core.core.workflows import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_workflow_engine.backend.db import models as workflow_models
|
from govoplan_workflow_engine.backend.db import models as workflow_models
|
||||||
|
from govoplan_workflow_engine.backend.dsar_provider import (
|
||||||
|
WORKFLOW_ENGINE_DSAR_CAPABILITY,
|
||||||
|
WorkflowEngineDsarProvider,
|
||||||
|
)
|
||||||
from govoplan_workflow_engine.backend.configuration_provider import (
|
from govoplan_workflow_engine.backend.configuration_provider import (
|
||||||
WORKFLOW_CONFIGURATION_CAPABILITY,
|
WORKFLOW_CONFIGURATION_CAPABILITY,
|
||||||
)
|
)
|
||||||
@@ -52,7 +63,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.16"
|
MODULE_VERSION = "0.1.21"
|
||||||
|
|
||||||
DEFINITION_READ_SCOPE = "workflow:definition:read"
|
DEFINITION_READ_SCOPE = "workflow:definition:read"
|
||||||
DEFINITION_WRITE_SCOPE = "workflow:definition:write"
|
DEFINITION_WRITE_SCOPE = "workflow:definition:write"
|
||||||
@@ -187,6 +198,17 @@ def _service_launcher(context: ModuleContext) -> WorkflowServiceLauncher:
|
|||||||
return WorkflowServiceLauncher(registry=context.registry)
|
return WorkflowServiceLauncher(registry=context.registry)
|
||||||
|
|
||||||
|
|
||||||
|
def _dsar_provider(context: ModuleContext) -> WorkflowEngineDsarProvider:
|
||||||
|
del context
|
||||||
|
return WorkflowEngineDsarProvider()
|
||||||
|
|
||||||
|
|
||||||
|
def _work_items(context: ModuleContext):
|
||||||
|
from govoplan_workflow_engine.backend.work_items import WorkflowWorkItemProvider
|
||||||
|
|
||||||
|
return WorkflowWorkItemProvider(registry=context.registry)
|
||||||
|
|
||||||
|
|
||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id=MODULE_ID,
|
id=MODULE_ID,
|
||||||
name=MODULE_NAME,
|
name=MODULE_NAME,
|
||||||
@@ -196,8 +218,10 @@ manifest = ModuleManifest(
|
|||||||
optional_dependencies=(
|
optional_dependencies=(
|
||||||
"access",
|
"access",
|
||||||
"audit",
|
"audit",
|
||||||
|
"campaigns",
|
||||||
"dataflow",
|
"dataflow",
|
||||||
"datasources",
|
"datasources",
|
||||||
|
"idm",
|
||||||
"notifications",
|
"notifications",
|
||||||
"policy",
|
"policy",
|
||||||
"tasks",
|
"tasks",
|
||||||
@@ -209,7 +233,9 @@ 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_NOTIFICATIONS_DISPATCH,
|
CAPABILITY_NOTIFICATIONS_DISPATCH,
|
||||||
CAPABILITY_POLICY_DEFINITION_GOVERNANCE,
|
CAPABILITY_POLICY_DEFINITION_GOVERNANCE,
|
||||||
CAPABILITY_VIEWS_RESOLVER,
|
CAPABILITY_VIEWS_RESOLVER,
|
||||||
@@ -244,8 +270,18 @@ manifest = ModuleManifest(
|
|||||||
name="workflow.bpmn_execution_adapters",
|
name="workflow.bpmn_execution_adapters",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
),
|
),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name=WORKFLOW_ENGINE_DSAR_CAPABILITY,
|
||||||
|
version="0.1.0",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
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",
|
||||||
@@ -295,6 +331,7 @@ manifest = ModuleManifest(
|
|||||||
CAPABILITY_WORKFLOW_ORCHESTRATION: _orchestration_provider,
|
CAPABILITY_WORKFLOW_ORCHESTRATION: _orchestration_provider,
|
||||||
WORKFLOW_CONFIGURATION_CAPABILITY: _configuration_provider,
|
WORKFLOW_CONFIGURATION_CAPABILITY: _configuration_provider,
|
||||||
CAPABILITY_WORKFLOW_SERVICE_LAUNCHER: _service_launcher,
|
CAPABILITY_WORKFLOW_SERVICE_LAUNCHER: _service_launcher,
|
||||||
|
WORKFLOW_ENGINE_DSAR_CAPABILITY: _dsar_provider,
|
||||||
},
|
},
|
||||||
capability_documentation={
|
capability_documentation={
|
||||||
CAPABILITY_WORKFLOW_SERVICE_LAUNCHER: CapabilityDocumentation(
|
CAPABILITY_WORKFLOW_SERVICE_LAUNCHER: CapabilityDocumentation(
|
||||||
@@ -302,7 +339,21 @@ manifest = ModuleManifest(
|
|||||||
summary="Starts an authorized active Workflow from an exact available Service revision.",
|
summary="Starts an authorized active Workflow from an exact available Service revision.",
|
||||||
contract_version="0.1.0",
|
contract_version="0.1.0",
|
||||||
),
|
),
|
||||||
|
WORKFLOW_ENGINE_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||||
|
label="Workflow Engine data-subject request provider",
|
||||||
|
summary="Finds subject-linked workflow state while preserving live work and decision evidence.",
|
||||||
|
contract_version="0.1.0",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("privacy_officer", "workflow_operator", "user"),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
work_item_providers=(
|
||||||
|
WorkItemProviderRegistration(
|
||||||
|
id="workflow_engine.handoffs",
|
||||||
|
factory=_work_items,
|
||||||
|
order=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
migration_spec=MigrationSpec(
|
migration_spec=MigrationSpec(
|
||||||
module_id=MODULE_ID,
|
module_id=MODULE_ID,
|
||||||
metadata=Base.metadata,
|
metadata=Base.metadata,
|
||||||
@@ -338,6 +389,210 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
documentation=(
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="workflow.instance-history",
|
||||||
|
title="Read workflow progress and paged history",
|
||||||
|
summary="Inspect pinned execution evidence with explicit summary and history pages.",
|
||||||
|
body=(
|
||||||
|
"The Workflow Engine API keeps full steps and events on the existing "
|
||||||
|
"GET /instances and GET /instances/{id} responses; no history is silently "
|
||||||
|
"truncated. Integrators handling long-running processes can use "
|
||||||
|
"GET /instances/summaries and GET /instances/{id}/summary for identity, "
|
||||||
|
"pinned definition revision and hash, execution mode, status, current step "
|
||||||
|
"identifier, and timestamps without runtime payloads or history. Summary "
|
||||||
|
"lists accept definition_id, limit (default 100, maximum 200), and cursor. "
|
||||||
|
"They order by creation time and identifier, newest first. Continue with "
|
||||||
|
"next_cursor until it is null, keeping the same filter. Pages include only "
|
||||||
|
"currently visible definitions and do not expose a global total. Newer "
|
||||||
|
"instances require a fresh first page; updates do not reorder older pages. "
|
||||||
|
"GET /instances/{id}/steps and /events accept limit (default 100, maximum "
|
||||||
|
"200), after_sequence (initially 0), and through_sequence (sequence bounds "
|
||||||
|
"range from 0 to 2147483647). Continue using "
|
||||||
|
"next_after_sequence and the returned through_sequence until the next "
|
||||||
|
"position is null. Rows are ordered by increasing sequence; total counts "
|
||||||
|
"all retained rows up to that boundary, including earlier pages. Keeping "
|
||||||
|
"the boundary excludes subsequently appended history; omit it for a fresh "
|
||||||
|
"pass. Step fields reflect their current state, not a historical database "
|
||||||
|
"snapshot. Each request requires workflow:instance:read or "
|
||||||
|
"workflow:instance:admin and current definition view authorization in "
|
||||||
|
"the active tenant. A cursor and the authorization recorded at start "
|
||||||
|
"never grant access after permission changes. Administrators should use "
|
||||||
|
"summary and history pages for large runs: revision metadata is loaded "
|
||||||
|
"in batches, history rows are bounded, and summary discovery checks "
|
||||||
|
"candidates in batches of 250 until an authorized page is filled. Highly "
|
||||||
|
"restricted catalogues can still require scanning many candidates. "
|
||||||
|
"The module migration adds tenant and definition creation-order indexes; "
|
||||||
|
"building them on a large installation needs disk capacity and may "
|
||||||
|
"temporarily block writes during the normal database upgrade. "
|
||||||
|
"Work inbox totals remain exact after current assignment, governance, "
|
||||||
|
"and search filters; counting them still streams all matching candidates "
|
||||||
|
"in batches of 250 and can take longer for large inboxes. These read "
|
||||||
|
"contracts require no editor, Tasks installation, or new permission."
|
||||||
|
),
|
||||||
|
layer="available",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("operator", "module_admin", "integrator", "auditor"),
|
||||||
|
order=74,
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Workflow-Fortschritt und paginierten Verlauf lesen",
|
||||||
|
"summary": "Revisionsgebundene Ausführungsnachweise mit expliziten Übersichts- und Verlaufsseiten prüfen.",
|
||||||
|
"body": (
|
||||||
|
"Die Workflow-Engine-API liefert bei GET /instances und GET "
|
||||||
|
"/instances/{id} weiterhin vollständige Schritte und Ereignisse; "
|
||||||
|
"Verläufe werden nicht stillschweigend gekürzt. Für lange Prozesse "
|
||||||
|
"liefern GET /instances/summaries und GET /instances/{id}/summary "
|
||||||
|
"Identität, gebundene Definitionsrevision und Hash, Ausführungsmodus, "
|
||||||
|
"Status, aktuelle Schrittkennung und Zeitstempel ohne Laufzeitnutzdaten "
|
||||||
|
"oder Verlauf. Übersichtslisten akzeptieren definition_id, limit "
|
||||||
|
"(Standard 100, höchstens 200) und cursor. Sie sortieren nach "
|
||||||
|
"Erstellungszeit und Kennung, neueste zuerst. Mit unverändertem Filter "
|
||||||
|
"und next_cursor fortfahren, bis dieser null ist. Seiten enthalten nur "
|
||||||
|
"aktuell sichtbare Definitionen und keine globale Gesamtzahl. Neue "
|
||||||
|
"Instanzen erfordern eine neue erste Seite; Aktualisierungen verschieben "
|
||||||
|
"ältere Seiten nicht. GET /instances/{id}/steps und /events akzeptieren "
|
||||||
|
"limit (Standard 100, höchstens 200), after_sequence (anfangs 0) und "
|
||||||
|
"through_sequence (Sequenzgrenzen von 0 bis 2147483647). Mit "
|
||||||
|
"next_after_sequence und der zurückgegebenen "
|
||||||
|
"through_sequence fortfahren, bis die nächste Position null ist. Zeilen "
|
||||||
|
"sind nach aufsteigender Sequenz sortiert; total zählt alle erhaltenen "
|
||||||
|
"Zeilen bis zur Grenze einschließlich vorheriger Seiten. Eine feste "
|
||||||
|
"Grenze schließt später angehängte Einträge aus; für einen neuen "
|
||||||
|
"Durchlauf wird sie weggelassen. Schrittfelder zeigen ihren aktuellen "
|
||||||
|
"Zustand, keinen historischen Datenbank-Snapshot. Jede Anfrage benötigt "
|
||||||
|
"workflow:instance:read oder workflow:instance:admin und die aktuelle "
|
||||||
|
"Sichtberechtigung für die Definition im aktiven Mandanten. Weder ein "
|
||||||
|
"Cursor noch die beim Start gespeicherte Autorisierung gewährt Zugriff "
|
||||||
|
"nach Berechtigungsänderungen. Administratoren sollten für große Läufe "
|
||||||
|
"Übersichts- und Verlaufsseiten verwenden: Revisionsmetadaten werden "
|
||||||
|
"gebündelt geladen, Verlaufszeilen sind begrenzt, und die Übersicht "
|
||||||
|
"prüft Kandidaten in Gruppen von 250, bis eine berechtigte Seite gefüllt "
|
||||||
|
"ist. Stark eingeschränkte Kataloge können weiterhin viele "
|
||||||
|
"Kandidatenprüfungen erfordern. Die Modulmigration ergänzt nach "
|
||||||
|
"Erstellungszeit geordnete Mandanten- und Definitionsindizes. Deren "
|
||||||
|
"Aufbau benötigt bei großen Installationen Speicherplatz und kann "
|
||||||
|
"während der regulären Datenbankaktualisierung Schreibzugriffe "
|
||||||
|
"vorübergehend blockieren. Gesamtzahlen im Arbeitskorb bleiben "
|
||||||
|
"nach aktuellen Zuweisungs-, Governance- und Suchfiltern exakt. Dafür "
|
||||||
|
"werden weiterhin alle passenden Kandidaten in Gruppen von 250 "
|
||||||
|
"durchlaufen; große Arbeitskörbe können länger dauern. Diese "
|
||||||
|
"Leseverträge benötigen weder Editor noch Tasks-Installation noch "
|
||||||
|
"eine neue Berechtigung."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Workflow aus verantwortlicher Campaign-Arbeit fortsetzen",
|
||||||
|
"summary": (
|
||||||
|
"Revisionsgebundene Campaign-Arbeit öffnen, die relevante Oberfläche fokussieren und ohne Polling aus deklarierten "
|
||||||
|
"Lebenszyklusereignissen fortfahren."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Der externe Übergabebaustein folgt einer Modulaktion, die eine Campaign anlegt oder referenziert und genau eine "
|
||||||
|
"verantwortliche Arbeitszuweisung öffnet. Workflow speichert exakte Campaign-Version und Zuweisungsrevision, einen "
|
||||||
|
"sicheren Aktionslink, Korrelations- und Idempotenzherkunft sowie ein dauerhaftes Ereignisabonnement. Zugewiesen-, "
|
||||||
|
"angenommen-, begonnen- und neu-zugewiesen-Ereignisse aktualisieren den Wartezustand; abgeschlossene, abgelehnte, "
|
||||||
|
"abgebrochene und abgelaufene Ergebnisse nehmen getrennte Graphpfade. Doppelte Ereignisse werden ignoriert. Vor der "
|
||||||
|
"Fortsetzung durch ein Abschlussereignis löst Workflow seine Befugnis erneut auf und Campaign prüft Zugriff und "
|
||||||
|
"Ereignisrevision. Eine Zuweisung gewährt niemals Campaign-Zugriff. Fehlende Campaign- oder Views-Fähigkeiten halten die "
|
||||||
|
"Instanz prüfbar, aber blockiert. Fehlende Tasks- oder Notifications-Integration wird als eingeschränkt gemeldet und "
|
||||||
|
"ändert das Campaign-Ergebnis nicht."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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",
|
||||||
|
summary="Review structured subject links without exposing or guessing arbitrary process payload content.",
|
||||||
|
body=(
|
||||||
|
"Workflow Engine matches exact tenant-scoped definition, revision, instance, step, event, trigger, delivery, and wait identifiers. It also matches structured instance authorization, work assignment, automation authority, and minimized staff attribution for account, identity, and membership selectors. DSAR results never copy graphs or BPMN, inputs, context, outputs, handoffs, event/configuration payloads, authorization snapshots, errors, replay keys, external references, hashes, or credentials. Arbitrary runtime payloads are not scanned for identifiers because the owning service, case, form, or other source module remains authoritative. "
|
||||||
|
"Subject-linked automation can be disabled and revoked, and explicitly selected terminal trigger delivery detail can be minimized idempotently while its replay key remains. Definitions, instances, live work, assignments, immutable transition/decision events, waits, and operator attribution require workflow-owner, retention, third-party, and source-authority review."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "operator", "module_admin", "auditor"),
|
||||||
|
related_modules=("core", "cases", "forms_runtime", "services", "tasks"),
|
||||||
|
order=75,
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Betroffenenanfragen für Workflows",
|
||||||
|
"summary": (
|
||||||
|
"Strukturierte Betroffenenverknüpfungen prüfen, ohne beliebige Prozessnutzdaten offenzulegen oder zu erraten."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Workflow Engine gleicht exakte mandantenbezogene Kennungen für Definition, Revision, Instanz, Schritt, Ereignis, "
|
||||||
|
"Auslöser, Lieferung und Wartezustand ab. Zusätzlich werden strukturierte Instanzautorisierung, Arbeitszuweisung, "
|
||||||
|
"Automatisierungsbefugnis und minimierte Personalzuordnung für Konto-, Identitäts- und Mitgliedschaftsselektoren "
|
||||||
|
"abgeglichen. DSAR-Ergebnisse kopieren niemals Graphen oder BPMN, Eingaben, Kontext, Ausgaben, Übergaben, Ereignis-/"
|
||||||
|
"Konfigurationsnutzdaten, Autorisierungs-Snapshots, Fehler, Wiederholungsschlüssel, externe Verweise, Hashes oder "
|
||||||
|
"Zugangsdaten. Beliebige Laufzeitnutzdaten werden nicht nach Kennungen durchsucht, weil der zuständige Service, Vorgang, "
|
||||||
|
"das Formular oder ein anderes Quellmodul maßgeblich bleibt. Betroffenenbezogene Automatisierung kann deaktiviert und "
|
||||||
|
"widerrufen werden; ausdrücklich ausgewählte Details abgeschlossener Auslöserlieferungen können idempotent minimiert "
|
||||||
|
"werden, während ihr Wiederholungsschlüssel erhalten bleibt. Definitionen, Instanzen, laufende Arbeit, Zuweisungen, "
|
||||||
|
"unveränderliche Übergangs-/Entscheidungsereignisse, Wartezustände und Betriebszuordnung erfordern die Prüfung durch "
|
||||||
|
"Workflow-Owner sowie Zuständige für Aufbewahrung, Dritte und Quellhoheit."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"help_contexts": [
|
||||||
|
"workflow.data-subject-requests",
|
||||||
|
"workflow.instances",
|
||||||
|
"workflow.triggers",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="workflow.definition-graphs",
|
id="workflow.definition-graphs",
|
||||||
title="Workflow definition graphs",
|
title="Workflow definition graphs",
|
||||||
@@ -350,7 +605,9 @@ manifest = ModuleManifest(
|
|||||||
"implementation imports. Definitions are persisted as immutable graph "
|
"implementation imports. Definitions are persisted as immutable graph "
|
||||||
"revisions; activation pins the exact revision used by future instances. "
|
"revisions; activation pins the exact revision used by future instances. "
|
||||||
"The optional service-launch capability starts an authorized active "
|
"The optional service-launch capability starts an authorized active "
|
||||||
"revision from an exact Portal Service binding and records that provenance."
|
"revision from an exact Portal Service binding and records that provenance. "
|
||||||
|
"When Tasks is enabled, current human handoffs are projected into the common "
|
||||||
|
"work inbox with typed responsibility, due date, and a resumable source link."
|
||||||
),
|
),
|
||||||
layer="available",
|
layer="available",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
@@ -363,6 +620,21 @@ manifest = ModuleManifest(
|
|||||||
"audit",
|
"audit",
|
||||||
),
|
),
|
||||||
order=76,
|
order=76,
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Workflow-Definitionsgraphen",
|
||||||
|
"summary": "Gesteuerte Prozessgraphen unabhängig von einem Editor bereitstellen.",
|
||||||
|
"body": (
|
||||||
|
"Workflow Engine stellt eine Bibliothek aus Auslöser-, Aktivitäts-, Entscheidungs-, Warte-, Integrations- und "
|
||||||
|
"Ergebnisknoten auf Cores domänenneutralem Graphvertrag bereit. Anders als Dataflow erlaubt Workflow Zyklen für "
|
||||||
|
"Korrektur- und Wiederholungspfade. Modulaktionen werden über versionierte Fähigkeiten statt Implementierungsimporte "
|
||||||
|
"adressiert. Definitionen werden als unveränderliche Graphrevisionen gespeichert; die Aktivierung legt die exakte "
|
||||||
|
"Revision für zukünftige Instanzen fest. Die optionale Service-Startfähigkeit startet eine berechtigte aktive Revision "
|
||||||
|
"aus einer exakten Portal-Servicebindung und zeichnet diese Herkunft auf. Ist Tasks aktiviert, werden aktuelle menschliche "
|
||||||
|
"Übergaben mit typisierter Verantwortung, Fälligkeit und fortsetzbarem Quelllink in den gemeinsamen Arbeitskorb projiziert."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="workflow.bpmn-interchange",
|
id="workflow.bpmn-interchange",
|
||||||
@@ -390,6 +662,21 @@ manifest = ModuleManifest(
|
|||||||
audience=("operator", "module_admin", "power_user", "product_owner"),
|
audience=("operator", "module_admin", "power_user", "product_owner"),
|
||||||
related_modules=("audit", "policy", "views"),
|
related_modules=("audit", "policy", "views"),
|
||||||
order=77,
|
order=77,
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "BPMN-Modellierungs- und Ausführungsprofile",
|
||||||
|
"summary": "Verlustfreie BPMN-2.0-Revisionen mit ausdrücklicher, sicher schließender Ausführungskonformität.",
|
||||||
|
"body": (
|
||||||
|
"BPMN 2.0 ist die kanonische native Graphsprache von Workflow. Der gemeinsame Grapheditor modelliert BPMN-Knoten, "
|
||||||
|
"Flüsse, Verschachtelung und Diagrammgeometrie direkt, ohne getrennten browserseitigen Modeler. Importiertes XML wird in "
|
||||||
|
"den Graphen normalisiert und jede unveränderliche Revision legt ein deterministisches XML-Artefakt fest. Die Aktivierung "
|
||||||
|
"verlangt einen festgelegten Ausführungsadapter und eine Version, deren erklärtes Profil jede modellierte "
|
||||||
|
"Laufzeitsemantik akzeptiert. Nicht unterstützte Laufzeitkonstrukte bleiben bearbeitbar und exportierbar. Adapterpakete "
|
||||||
|
"integrieren über die Entry-Point-Gruppe govoplan.workflow.bpmn_adapters und müssen kanonischen Workflow-Laufzeitzustand "
|
||||||
|
"materialisieren; Workflow Engine importiert niemals ein konkretes Engine-Modul."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="workflow.runtime-recovery",
|
id="workflow.runtime-recovery",
|
||||||
@@ -414,6 +701,49 @@ manifest = ModuleManifest(
|
|||||||
audience=("operator", "module_admin", "power_user"),
|
audience=("operator", "module_admin", "power_user"),
|
||||||
related_modules=("core", "dataflow", "audit"),
|
related_modules=("core", "dataflow", "audit"),
|
||||||
order=78,
|
order=78,
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Workflow-Laufzeit wiederherstellen",
|
||||||
|
"summary": (
|
||||||
|
"Abgesicherte Modulaktionen, Zeitgeber und nachweisbasierte Wiederherstellung bei unbekannten Provider-Ergebnissen."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Workflow Engine zeichnet vor jeder folgenreichen Modulaktion einen Core-Wiederherstellungsvorgang auf und schreibt ein "
|
||||||
|
"abschließendes Provider-Ergebnis gemeinsam mit der lokalen Workflow-Projektion fest. Ein Timeout oder verlorene "
|
||||||
|
"Bestätigung nach nicht atomarer Auslieferung wird zu einem unbekannten Ergebnis und deaktiviert Wiederholen. "
|
||||||
|
"Betriebspersonen prüfen den Provider, halten Nachweise fest und wählen Wirkung bestätigt, um ohne Wiederholung fortzufahren, "
|
||||||
|
"oder Wirkung ausgeblieben, um eine bewusste Wiederholung zu erlauben. Instanz-Worker, Auslöserlieferungen und Zeitgeber "
|
||||||
|
"verwenden verteilte Sperren und werden vor der Zustandsübernahme nach Mandanten-Modulberechtigung partitioniert. Das "
|
||||||
|
"Deaktivieren von Workflow Engine bewahrt angenommene Instanzen, Wartezustände und Auslöserlieferungen für die betriebliche "
|
||||||
|
"Klärung. Verknüpfte Dataflow-Wiederherstellung bleibt blockiert, bis ihr Ergebnis eindeutig ist."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"help_contexts": ["workflow.instances", "workflow.runtime-recovery"],
|
||||||
|
"consequence_classes": {
|
||||||
|
"effect_confirmed": "Continues from retained evidence without replaying the provider effect.",
|
||||||
|
"effect_absent": "Enables one deliberate retry after evidence confirms that the provider effect did not occur.",
|
||||||
|
"disable_module": "Preserves accepted instances, waits, and trigger deliveries for operator resolution.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
structured_translation_version="1",
|
||||||
|
structured_translations={
|
||||||
|
"de": {
|
||||||
|
"consequence_classes": {
|
||||||
|
"effect_confirmed": (
|
||||||
|
"Setzt den Ablauf anhand aufbewahrter Nachweise fort, ohne die Provider-Wirkung zu wiederholen."
|
||||||
|
),
|
||||||
|
"effect_absent": (
|
||||||
|
"Erlaubt genau eine bewusste Wiederholung, nachdem Nachweise bestätigen, dass die Provider-Wirkung ausblieb."
|
||||||
|
),
|
||||||
|
"disable_module": (
|
||||||
|
"Bewahrt angenommene Instanzen, Wartezustände und Auslöserlieferungen für die betriebliche Klärung."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
architecture=declared_module_architecture(
|
architecture=declared_module_architecture(
|
||||||
@@ -431,6 +761,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=(
|
||||||
@@ -446,6 +777,11 @@ manifest = ModuleManifest(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
manifest = with_documentation_structured_translations(
|
||||||
|
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_manifest() -> ModuleManifest:
|
def get_manifest() -> ModuleManifest:
|
||||||
return manifest
|
return manifest
|
||||||
|
|
||||||
|
|||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
"""v0.1.18 Workflow work projections.
|
||||||
|
|
||||||
|
Revision ID: 8d5a2f7c1b4e
|
||||||
|
Revises: e4a1f8c2d7b6
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "8d5a2f7c1b4e"
|
||||||
|
down_revision = "e4a1f8c2d7b6"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("workflow_instance_steps") as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column("work_assignment_kind", sa.String(length=40), nullable=True)
|
||||||
|
)
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column("work_assignment_id", sa.String(length=255), nullable=True)
|
||||||
|
)
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column("work_assignment_label", sa.String(length=500), nullable=True)
|
||||||
|
)
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column("work_due_at", sa.DateTime(timezone=True), nullable=True)
|
||||||
|
)
|
||||||
|
batch_op.create_index(
|
||||||
|
"ix_workflow_instance_steps_work_assignment_kind",
|
||||||
|
["work_assignment_kind"],
|
||||||
|
)
|
||||||
|
batch_op.create_index(
|
||||||
|
"ix_workflow_instance_steps_work_assignment_id",
|
||||||
|
["work_assignment_id"],
|
||||||
|
)
|
||||||
|
batch_op.create_index(
|
||||||
|
"ix_workflow_instance_steps_work_due_at",
|
||||||
|
["work_due_at"],
|
||||||
|
)
|
||||||
|
batch_op.create_index(
|
||||||
|
"ix_workflow_instance_steps_work_assignment",
|
||||||
|
[
|
||||||
|
"tenant_id",
|
||||||
|
"status",
|
||||||
|
"work_assignment_kind",
|
||||||
|
"work_assignment_id",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("workflow_instance_steps") as batch_op:
|
||||||
|
batch_op.drop_index("ix_workflow_instance_steps_work_assignment")
|
||||||
|
batch_op.drop_index("ix_workflow_instance_steps_work_due_at")
|
||||||
|
batch_op.drop_index("ix_workflow_instance_steps_work_assignment_id")
|
||||||
|
batch_op.drop_index("ix_workflow_instance_steps_work_assignment_kind")
|
||||||
|
batch_op.drop_column("work_due_at")
|
||||||
|
batch_op.drop_column("work_assignment_label")
|
||||||
|
batch_op.drop_column("work_assignment_id")
|
||||||
|
batch_op.drop_column("work_assignment_kind")
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
"""Index tenant-scoped workflow summary pagination.
|
||||||
|
|
||||||
|
Revision ID: 9e6b3f8a2c7d
|
||||||
|
Revises: 8d5a2f7c1b4e
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision = "9e6b3f8a2c7d"
|
||||||
|
down_revision = "8d5a2f7c1b4e"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_index(
|
||||||
|
"ix_workflow_instances_tenant_created_id",
|
||||||
|
"workflow_instances",
|
||||||
|
["tenant_id", "created_at", "id"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_workflow_instances_tenant_definition_created_id",
|
||||||
|
"workflow_instances",
|
||||||
|
["tenant_id", "definition_id", "created_at", "id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(
|
||||||
|
"ix_workflow_instances_tenant_definition_created_id",
|
||||||
|
table_name="workflow_instances",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"ix_workflow_instances_tenant_created_id",
|
||||||
|
table_name="workflow_instances",
|
||||||
|
)
|
||||||
@@ -84,8 +84,7 @@ LEGACY_WORKFLOW_NODE_TYPES = (
|
|||||||
category="trigger",
|
category="trigger",
|
||||||
label="Parent workflow",
|
label="Parent workflow",
|
||||||
description=(
|
description=(
|
||||||
"Start as a pinned child or dependency of another Workflow "
|
"Start as a pinned child or dependency of another Workflow instance."
|
||||||
"instance."
|
|
||||||
),
|
),
|
||||||
icon="git-branch",
|
icon="git-branch",
|
||||||
default_config={
|
default_config={
|
||||||
@@ -165,8 +164,12 @@ LEGACY_WORKFLOW_NODE_TYPES = (
|
|||||||
icon="square-check-big",
|
icon="square-check-big",
|
||||||
input_ports=(DefinitionPort(id="input", label="Input"),),
|
input_ports=(DefinitionPort(id="input", label="Input"),),
|
||||||
config_fields=(
|
config_fields=(
|
||||||
DefinitionConfigField(id="title", label="Title", kind="text", required=True),
|
DefinitionConfigField(
|
||||||
DefinitionConfigField(id="instructions", label="Instructions", kind="textarea"),
|
id="title", label="Title", kind="text", required=True
|
||||||
|
),
|
||||||
|
DefinitionConfigField(
|
||||||
|
id="instructions", label="Instructions", kind="textarea"
|
||||||
|
),
|
||||||
DefinitionConfigField(id="assignee", label="Assignee", kind="subject"),
|
DefinitionConfigField(id="assignee", label="Assignee", kind="subject"),
|
||||||
DefinitionConfigField(id="due_after", label="Due after", kind="duration"),
|
DefinitionConfigField(id="due_after", label="Due after", kind="duration"),
|
||||||
FOCUSED_VIEW_SURFACES_FIELD,
|
FOCUSED_VIEW_SURFACES_FIELD,
|
||||||
@@ -192,8 +195,11 @@ LEGACY_WORKFLOW_NODE_TYPES = (
|
|||||||
DefinitionPort(id="rejected", label="Rejected", required=False),
|
DefinitionPort(id="rejected", label="Rejected", required=False),
|
||||||
),
|
),
|
||||||
config_fields=(
|
config_fields=(
|
||||||
DefinitionConfigField(id="title", label="Title", kind="text", required=True),
|
DefinitionConfigField(
|
||||||
|
id="title", label="Title", kind="text", required=True
|
||||||
|
),
|
||||||
DefinitionConfigField(id="reviewer", label="Reviewer", kind="subject"),
|
DefinitionConfigField(id="reviewer", label="Reviewer", kind="subject"),
|
||||||
|
DefinitionConfigField(id="due_after", label="Due after", kind="duration"),
|
||||||
DefinitionConfigField(
|
DefinitionConfigField(
|
||||||
id="required_evidence",
|
id="required_evidence",
|
||||||
label="Required evidence",
|
label="Required evidence",
|
||||||
@@ -204,6 +210,7 @@ LEGACY_WORKFLOW_NODE_TYPES = (
|
|||||||
default_config={
|
default_config={
|
||||||
"title": "",
|
"title": "",
|
||||||
"reviewer": "",
|
"reviewer": "",
|
||||||
|
"due_after": "",
|
||||||
"required_evidence": [],
|
"required_evidence": [],
|
||||||
"view_surface_ids": [],
|
"view_surface_ids": [],
|
||||||
},
|
},
|
||||||
@@ -262,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",
|
||||||
@@ -292,7 +379,9 @@ LEGACY_WORKFLOW_NODE_TYPES = (
|
|||||||
kind="text",
|
kind="text",
|
||||||
required=True,
|
required=True,
|
||||||
),
|
),
|
||||||
DefinitionConfigField(id="input_mapping", label="Input mapping", kind="mapping"),
|
DefinitionConfigField(
|
||||||
|
id="input_mapping", label="Input mapping", kind="mapping"
|
||||||
|
),
|
||||||
DefinitionConfigField(
|
DefinitionConfigField(
|
||||||
id="idempotency_key",
|
id="idempotency_key",
|
||||||
label="Idempotency key",
|
label="Idempotency key",
|
||||||
@@ -373,8 +462,7 @@ LEGACY_WORKFLOW_NODE_TYPES = (
|
|||||||
label="Publication datasource",
|
label="Publication datasource",
|
||||||
kind="text",
|
kind="text",
|
||||||
description=(
|
description=(
|
||||||
"Optional stable Datasource target for materialized "
|
"Optional stable Datasource target for materialized output."
|
||||||
"output."
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
DefinitionConfigField(
|
DefinitionConfigField(
|
||||||
@@ -397,7 +485,9 @@ LEGACY_WORKFLOW_NODE_TYPES = (
|
|||||||
("continue", "Follow failure path"),
|
("continue", "Follow failure path"),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
DefinitionConfigField(id="input_mapping", label="Input mapping", kind="mapping"),
|
DefinitionConfigField(
|
||||||
|
id="input_mapping", label="Input mapping", kind="mapping"
|
||||||
|
),
|
||||||
FOCUSED_VIEW_SURFACES_FIELD,
|
FOCUSED_VIEW_SURFACES_FIELD,
|
||||||
),
|
),
|
||||||
default_config={
|
default_config={
|
||||||
@@ -428,7 +518,9 @@ LEGACY_WORKFLOW_NODE_TYPES = (
|
|||||||
),
|
),
|
||||||
output_ports=(),
|
output_ports=(),
|
||||||
config_fields=(
|
config_fields=(
|
||||||
DefinitionConfigField(id="output_mapping", label="Output mapping", kind="mapping"),
|
DefinitionConfigField(
|
||||||
|
id="output_mapping", label="Output mapping", kind="mapping"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
default_config={"output_mapping": {}},
|
default_config={"output_mapping": {}},
|
||||||
),
|
),
|
||||||
@@ -870,7 +962,9 @@ BPMN_NODE_TYPES = (
|
|||||||
shape="activity",
|
shape="activity",
|
||||||
config_fields=(
|
config_fields=(
|
||||||
*_TASK_FIELDS,
|
*_TASK_FIELDS,
|
||||||
DefinitionConfigField(id="message_ref", label="Message reference", kind="text"),
|
DefinitionConfigField(
|
||||||
|
id="message_ref", label="Message reference", kind="text"
|
||||||
|
),
|
||||||
FOCUSED_VIEW_SURFACES_FIELD,
|
FOCUSED_VIEW_SURFACES_FIELD,
|
||||||
),
|
),
|
||||||
default_config={
|
default_config={
|
||||||
@@ -898,7 +992,13 @@ BPMN_NODE_TYPES = (
|
|||||||
"Script task",
|
"Script task",
|
||||||
"A BPMN script task retained as notation; arbitrary scripts are not executed.",
|
"A BPMN script task retained as notation; arbitrary scripts are not executed.",
|
||||||
"file-code-2",
|
"file-code-2",
|
||||||
(*_TASK_FIELDS, DefinitionConfigField(id="script_format", label="Script format", kind="text"), DefinitionConfigField(id="script", label="Script", kind="textarea")),
|
(
|
||||||
|
*_TASK_FIELDS,
|
||||||
|
DefinitionConfigField(
|
||||||
|
id="script_format", label="Script format", kind="text"
|
||||||
|
),
|
||||||
|
DefinitionConfigField(id="script", label="Script", kind="textarea"),
|
||||||
|
),
|
||||||
{"title": "", "instructions": "", "script_format": "", "script": ""},
|
{"title": "", "instructions": "", "script_format": "", "script": ""},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -906,7 +1006,14 @@ BPMN_NODE_TYPES = (
|
|||||||
"Business rule task",
|
"Business rule task",
|
||||||
"Evaluate a governed business-rule implementation.",
|
"Evaluate a governed business-rule implementation.",
|
||||||
"scale",
|
"scale",
|
||||||
(*_TASK_FIELDS, DefinitionConfigField(id="implementation_ref", label="Implementation reference", kind="text")),
|
(
|
||||||
|
*_TASK_FIELDS,
|
||||||
|
DefinitionConfigField(
|
||||||
|
id="implementation_ref",
|
||||||
|
label="Implementation reference",
|
||||||
|
kind="text",
|
||||||
|
),
|
||||||
|
),
|
||||||
{"title": "", "instructions": "", "implementation_ref": ""},
|
{"title": "", "instructions": "", "implementation_ref": ""},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -914,7 +1021,15 @@ BPMN_NODE_TYPES = (
|
|||||||
"Call activity",
|
"Call activity",
|
||||||
"Call another reusable BPMN process or GovOPlaN workflow.",
|
"Call another reusable BPMN process or GovOPlaN workflow.",
|
||||||
"external-link",
|
"external-link",
|
||||||
(*_TASK_FIELDS, DefinitionConfigField(id="called_element", label="Called element", kind="text", required=True)),
|
(
|
||||||
|
*_TASK_FIELDS,
|
||||||
|
DefinitionConfigField(
|
||||||
|
id="called_element",
|
||||||
|
label="Called element",
|
||||||
|
kind="text",
|
||||||
|
required=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
{"title": "", "instructions": "", "called_element": ""},
|
{"title": "", "instructions": "", "called_element": ""},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -956,11 +1071,41 @@ BPMN_NODE_TYPES = (
|
|||||||
runtime_support=runtime_support,
|
runtime_support=runtime_support,
|
||||||
)
|
)
|
||||||
for type_name, label, description, icon, runtime_support in (
|
for type_name, label, description, icon, runtime_support in (
|
||||||
("exclusiveGateway", "Exclusive gateway", "Choose exactly one matching sequence flow.", "diamond", "native"),
|
(
|
||||||
("parallelGateway", "Parallel gateway", "Split or join concurrent sequence flows.", "plus", "model_only"),
|
"exclusiveGateway",
|
||||||
("inclusiveGateway", "Inclusive gateway", "Choose one or more matching sequence flows.", "circle-plus", "model_only"),
|
"Exclusive gateway",
|
||||||
("eventBasedGateway", "Event-based gateway", "Choose a path according to the first caught event.", "radio-tower", "model_only"),
|
"Choose exactly one matching sequence flow.",
|
||||||
("complexGateway", "Complex gateway", "Apply a complex activation condition.", "asterisk", "model_only"),
|
"diamond",
|
||||||
|
"native",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"parallelGateway",
|
||||||
|
"Parallel gateway",
|
||||||
|
"Split or join concurrent sequence flows.",
|
||||||
|
"plus",
|
||||||
|
"model_only",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"inclusiveGateway",
|
||||||
|
"Inclusive gateway",
|
||||||
|
"Choose one or more matching sequence flows.",
|
||||||
|
"circle-plus",
|
||||||
|
"model_only",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"eventBasedGateway",
|
||||||
|
"Event-based gateway",
|
||||||
|
"Choose a path according to the first caught event.",
|
||||||
|
"radio-tower",
|
||||||
|
"model_only",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"complexGateway",
|
||||||
|
"Complex gateway",
|
||||||
|
"Apply a complex activation condition.",
|
||||||
|
"asterisk",
|
||||||
|
"model_only",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
_bpmn_node(
|
_bpmn_node(
|
||||||
@@ -972,8 +1117,12 @@ BPMN_NODE_TYPES = (
|
|||||||
shape="data-object",
|
shape="data-object",
|
||||||
input_ports=_OPTIONAL_INCOMING,
|
input_ports=_OPTIONAL_INCOMING,
|
||||||
config_fields=(
|
config_fields=(
|
||||||
DefinitionConfigField(id="data_object_ref", label="Data object reference", kind="text"),
|
DefinitionConfigField(
|
||||||
DefinitionConfigField(id="item_subject_ref", label="Item definition", kind="text"),
|
id="data_object_ref", label="Data object reference", kind="text"
|
||||||
|
),
|
||||||
|
DefinitionConfigField(
|
||||||
|
id="item_subject_ref", label="Item definition", kind="text"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
default_config={"data_object_ref": "", "item_subject_ref": ""},
|
default_config={"data_object_ref": "", "item_subject_ref": ""},
|
||||||
),
|
),
|
||||||
@@ -986,8 +1135,12 @@ BPMN_NODE_TYPES = (
|
|||||||
shape="data-store",
|
shape="data-store",
|
||||||
input_ports=_OPTIONAL_INCOMING,
|
input_ports=_OPTIONAL_INCOMING,
|
||||||
config_fields=(
|
config_fields=(
|
||||||
DefinitionConfigField(id="data_store_ref", label="Data store reference", kind="text"),
|
DefinitionConfigField(
|
||||||
DefinitionConfigField(id="item_subject_ref", label="Item definition", kind="text"),
|
id="data_store_ref", label="Data store reference", kind="text"
|
||||||
|
),
|
||||||
|
DefinitionConfigField(
|
||||||
|
id="item_subject_ref", label="Item definition", kind="text"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
default_config={"data_store_ref": "", "item_subject_ref": ""},
|
default_config={"data_store_ref": "", "item_subject_ref": ""},
|
||||||
),
|
),
|
||||||
@@ -1000,7 +1153,9 @@ BPMN_NODE_TYPES = (
|
|||||||
shape="participant",
|
shape="participant",
|
||||||
input_ports=_OPTIONAL_INCOMING,
|
input_ports=_OPTIONAL_INCOMING,
|
||||||
config_fields=(
|
config_fields=(
|
||||||
DefinitionConfigField(id="process_ref", label="Process reference", kind="text"),
|
DefinitionConfigField(
|
||||||
|
id="process_ref", label="Process reference", kind="text"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
default_config={"process_ref": ""},
|
default_config={"process_ref": ""},
|
||||||
),
|
),
|
||||||
@@ -1013,7 +1168,9 @@ BPMN_NODE_TYPES = (
|
|||||||
shape="lane",
|
shape="lane",
|
||||||
input_ports=_OPTIONAL_INCOMING,
|
input_ports=_OPTIONAL_INCOMING,
|
||||||
config_fields=(
|
config_fields=(
|
||||||
DefinitionConfigField(id="flow_node_refs", label="Flow node references", kind="string_list"),
|
DefinitionConfigField(
|
||||||
|
id="flow_node_refs", label="Flow node references", kind="string_list"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
default_config={"flow_node_refs": []},
|
default_config={"flow_node_refs": []},
|
||||||
),
|
),
|
||||||
@@ -1040,7 +1197,9 @@ BPMN_NODE_TYPES = (
|
|||||||
shape="group",
|
shape="group",
|
||||||
input_ports=_OPTIONAL_INCOMING,
|
input_ports=_OPTIONAL_INCOMING,
|
||||||
config_fields=(
|
config_fields=(
|
||||||
DefinitionConfigField(id="category_value_ref", label="Category value", kind="text"),
|
DefinitionConfigField(
|
||||||
|
id="category_value_ref", label="Category value", kind="text"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
default_config={"category_value_ref": ""},
|
default_config={"category_value_ref": ""},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -83,8 +83,12 @@ from govoplan_workflow_engine.backend.schemas import (
|
|||||||
WorkflowGraphValidationRequest,
|
WorkflowGraphValidationRequest,
|
||||||
WorkflowGraphValidationResponse,
|
WorkflowGraphValidationResponse,
|
||||||
WorkflowInstanceListResponse,
|
WorkflowInstanceListResponse,
|
||||||
|
WorkflowInstanceEventPageResponse,
|
||||||
WorkflowInstanceResponse,
|
WorkflowInstanceResponse,
|
||||||
WorkflowInstanceStartRequest,
|
WorkflowInstanceStartRequest,
|
||||||
|
WorkflowInstanceStepPageResponse,
|
||||||
|
WorkflowInstanceSummaryListResponse,
|
||||||
|
WorkflowInstanceSummaryResponse,
|
||||||
WorkflowNodeLibraryResponse,
|
WorkflowNodeLibraryResponse,
|
||||||
WorkflowNodeTypeResponse,
|
WorkflowNodeTypeResponse,
|
||||||
WorkflowPortResponse,
|
WorkflowPortResponse,
|
||||||
@@ -102,6 +106,12 @@ from govoplan_workflow_engine.backend.instance_service import (
|
|||||||
resolve_step,
|
resolve_step,
|
||||||
start_instance,
|
start_instance,
|
||||||
)
|
)
|
||||||
|
from govoplan_workflow_engine.backend.instance_reads import (
|
||||||
|
MAX_HISTORY_SEQUENCE,
|
||||||
|
get_instance_summary,
|
||||||
|
instance_history_page,
|
||||||
|
list_instance_summaries,
|
||||||
|
)
|
||||||
from govoplan_workflow_engine.backend.runtime import get_registry
|
from govoplan_workflow_engine.backend.runtime import get_registry
|
||||||
from govoplan_workflow_engine.backend.service import (
|
from govoplan_workflow_engine.backend.service import (
|
||||||
WorkflowBpmnValidationError,
|
WorkflowBpmnValidationError,
|
||||||
@@ -699,6 +709,31 @@ def api_list_instances(
|
|||||||
raise _http_error(exc) from exc
|
raise _http_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/instances/summaries", response_model=WorkflowInstanceSummaryListResponse)
|
||||||
|
def api_list_instance_summaries(
|
||||||
|
definition_id: str | None = None,
|
||||||
|
limit: int = Query(default=100, ge=1, le=200),
|
||||||
|
cursor: str | None = Query(default=None, max_length=256),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> WorkflowInstanceSummaryListResponse:
|
||||||
|
try:
|
||||||
|
return list_instance_summaries(
|
||||||
|
session,
|
||||||
|
principal=principal,
|
||||||
|
registry=get_registry(),
|
||||||
|
definition_id=definition_id,
|
||||||
|
limit=limit,
|
||||||
|
cursor=cursor,
|
||||||
|
)
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise _governance_http_error(exc) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
except WorkflowError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.post("/standards/reconcile", response_model=dict[str, object])
|
@router.post("/standards/reconcile", response_model=dict[str, object])
|
||||||
def api_reconcile_standards(
|
def api_reconcile_standards(
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
@@ -875,6 +910,83 @@ def api_get_instance(
|
|||||||
raise _http_error(exc) from exc
|
raise _http_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/instances/{instance_id}/summary", response_model=WorkflowInstanceSummaryResponse
|
||||||
|
)
|
||||||
|
def api_get_instance_summary(
|
||||||
|
instance_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> WorkflowInstanceSummaryResponse:
|
||||||
|
try:
|
||||||
|
return get_instance_summary(
|
||||||
|
session,
|
||||||
|
principal=principal,
|
||||||
|
registry=get_registry(),
|
||||||
|
instance_id=instance_id,
|
||||||
|
)
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise _governance_http_error(exc) from exc
|
||||||
|
except WorkflowError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/instances/{instance_id}/steps", response_model=WorkflowInstanceStepPageResponse
|
||||||
|
)
|
||||||
|
def api_list_instance_steps(
|
||||||
|
instance_id: str,
|
||||||
|
limit: int = Query(default=100, ge=1, le=200),
|
||||||
|
after_sequence: int = Query(default=0, ge=0, le=MAX_HISTORY_SEQUENCE),
|
||||||
|
through_sequence: int | None = Query(default=None, ge=0, le=MAX_HISTORY_SEQUENCE),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> WorkflowInstanceStepPageResponse:
|
||||||
|
try:
|
||||||
|
return instance_history_page(
|
||||||
|
session,
|
||||||
|
principal=principal,
|
||||||
|
registry=get_registry(),
|
||||||
|
instance_id=instance_id,
|
||||||
|
kind="steps",
|
||||||
|
limit=limit,
|
||||||
|
after_sequence=after_sequence,
|
||||||
|
through_sequence=through_sequence,
|
||||||
|
)
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise _governance_http_error(exc) from exc
|
||||||
|
except WorkflowError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/instances/{instance_id}/events", response_model=WorkflowInstanceEventPageResponse
|
||||||
|
)
|
||||||
|
def api_list_instance_events(
|
||||||
|
instance_id: str,
|
||||||
|
limit: int = Query(default=100, ge=1, le=200),
|
||||||
|
after_sequence: int = Query(default=0, ge=0, le=MAX_HISTORY_SEQUENCE),
|
||||||
|
through_sequence: int | None = Query(default=None, ge=0, le=MAX_HISTORY_SEQUENCE),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> WorkflowInstanceEventPageResponse:
|
||||||
|
try:
|
||||||
|
return instance_history_page(
|
||||||
|
session,
|
||||||
|
principal=principal,
|
||||||
|
registry=get_registry(),
|
||||||
|
instance_id=instance_id,
|
||||||
|
kind="events",
|
||||||
|
limit=limit,
|
||||||
|
after_sequence=after_sequence,
|
||||||
|
through_sequence=through_sequence,
|
||||||
|
)
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise _governance_http_error(exc) from exc
|
||||||
|
except WorkflowError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/instances/{instance_id}/reconcile",
|
"/instances/{instance_id}/reconcile",
|
||||||
response_model=WorkflowInstanceResponse,
|
response_model=WorkflowInstanceResponse,
|
||||||
|
|||||||
@@ -628,3 +628,40 @@ class WorkflowInstanceResponse(BaseModel):
|
|||||||
|
|
||||||
class WorkflowInstanceListResponse(BaseModel):
|
class WorkflowInstanceListResponse(BaseModel):
|
||||||
instances: list[WorkflowInstanceResponse]
|
instances: list[WorkflowInstanceResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowInstanceSummaryResponse(BaseModel):
|
||||||
|
"""Instance identity and progress without runtime payloads or history."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
definition_id: str
|
||||||
|
definition_name: str
|
||||||
|
definition_revision: int
|
||||||
|
definition_hash: str
|
||||||
|
execution_mode: WorkflowExecutionMode
|
||||||
|
start_origin: WorkflowStartOrigin
|
||||||
|
status: WorkflowInstanceStatus
|
||||||
|
current_step_id: str | None
|
||||||
|
started_at: datetime
|
||||||
|
finished_at: datetime | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowInstanceSummaryListResponse(BaseModel):
|
||||||
|
instances: list[WorkflowInstanceSummaryResponse]
|
||||||
|
next_cursor: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowInstanceStepPageResponse(BaseModel):
|
||||||
|
steps: list[WorkflowInstanceStepResponse]
|
||||||
|
total: int
|
||||||
|
through_sequence: int
|
||||||
|
next_after_sequence: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowInstanceEventPageResponse(BaseModel):
|
||||||
|
events: list[WorkflowInstanceEventResponse]
|
||||||
|
total: int
|
||||||
|
through_sequence: int
|
||||||
|
next_after_sequence: int | None = None
|
||||||
|
|||||||
@@ -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,
|
||||||
*,
|
*,
|
||||||
@@ -938,6 +1278,12 @@ def _duration_seconds(value: object, *, minimum: int) -> int:
|
|||||||
return seconds
|
return seconds
|
||||||
|
|
||||||
|
|
||||||
|
def duration_seconds(value: object, *, minimum: int = 1) -> int:
|
||||||
|
"""Parse the duration syntax shared by timers and human-work due dates."""
|
||||||
|
|
||||||
|
return _duration_seconds(value, minimum=minimum)
|
||||||
|
|
||||||
|
|
||||||
def _parse_instant(value: object, *, timezone_name: str) -> datetime:
|
def _parse_instant(value: object, *, timezone_name: str) -> datetime:
|
||||||
text = str(value or "").strip()
|
text = str(value or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
@@ -972,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):
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import and_, or_, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal, has_scope
|
||||||
|
from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, IdmDirectory
|
||||||
|
from govoplan_core.core.tasks import (
|
||||||
|
WorkAssignmentRef,
|
||||||
|
WorkItem,
|
||||||
|
WorkItemPage,
|
||||||
|
WorkItemQuery,
|
||||||
|
WorkSourceRef,
|
||||||
|
)
|
||||||
|
from govoplan_workflow_engine.backend.db.models import (
|
||||||
|
WorkflowDefinition,
|
||||||
|
WorkflowInstance,
|
||||||
|
WorkflowInstanceStep,
|
||||||
|
)
|
||||||
|
from govoplan_workflow_engine.backend.governance import definition_decision
|
||||||
|
|
||||||
|
|
||||||
|
PROVIDER_ID = "workflow_engine.handoffs"
|
||||||
|
INSTANCE_READ_SCOPE = "workflow:instance:read"
|
||||||
|
ADMIN_SCOPE = "workflow:instance:admin"
|
||||||
|
_NON_HUMAN_KINDS = {"timer", "event_wait", "dataflow_run"}
|
||||||
|
_BLOCKED_STATES = {
|
||||||
|
"blocked",
|
||||||
|
"failed",
|
||||||
|
"outcome_unknown",
|
||||||
|
"recovery_required",
|
||||||
|
"compensation_required",
|
||||||
|
}
|
||||||
|
_ASSIGNMENT_KINDS = {
|
||||||
|
"account",
|
||||||
|
"group",
|
||||||
|
"role",
|
||||||
|
"function",
|
||||||
|
"function_assignment",
|
||||||
|
"anyone",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowWorkItemProvider:
|
||||||
|
def __init__(self, *, registry: object | None = None) -> None:
|
||||||
|
self.registry = registry
|
||||||
|
|
||||||
|
def list_items(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
query: WorkItemQuery,
|
||||||
|
) -> WorkItemPage:
|
||||||
|
if not isinstance(session, Session):
|
||||||
|
raise TypeError("Workflow work aggregation requires a SQLAlchemy Session.")
|
||||||
|
if not isinstance(principal, ApiPrincipal):
|
||||||
|
return WorkItemPage(items=(), total=0)
|
||||||
|
if principal.tenant_id != query.tenant_id:
|
||||||
|
return WorkItemPage(items=(), total=0)
|
||||||
|
administrative = has_scope(principal, ADMIN_SCOPE)
|
||||||
|
if not administrative and not has_scope(principal, INSTANCE_READ_SCOPE):
|
||||||
|
return WorkItemPage(items=(), total=0)
|
||||||
|
|
||||||
|
statement = (
|
||||||
|
select(WorkflowInstance, WorkflowInstanceStep, WorkflowDefinition)
|
||||||
|
.join(
|
||||||
|
WorkflowInstanceStep,
|
||||||
|
WorkflowInstanceStep.id == WorkflowInstance.current_step_id,
|
||||||
|
)
|
||||||
|
.join(
|
||||||
|
WorkflowDefinition,
|
||||||
|
WorkflowDefinition.id == WorkflowInstance.definition_id,
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
WorkflowInstance.tenant_id == query.tenant_id,
|
||||||
|
WorkflowInstance.status == "waiting",
|
||||||
|
WorkflowInstanceStep.status == "waiting",
|
||||||
|
)
|
||||||
|
.order_by(
|
||||||
|
WorkflowInstanceStep.work_due_at.is_(None),
|
||||||
|
WorkflowInstanceStep.work_due_at.asc(),
|
||||||
|
WorkflowInstanceStep.updated_at.desc(),
|
||||||
|
WorkflowInstanceStep.id.desc(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
targets = self._targets(principal, query.tenant_id)
|
||||||
|
if not administrative:
|
||||||
|
conditions = [
|
||||||
|
and_(
|
||||||
|
WorkflowInstanceStep.work_assignment_kind == kind,
|
||||||
|
WorkflowInstanceStep.work_assignment_id.in_(tuple(values)),
|
||||||
|
)
|
||||||
|
for kind, values in targets.items()
|
||||||
|
if values
|
||||||
|
]
|
||||||
|
conditions.append(
|
||||||
|
and_(
|
||||||
|
WorkflowInstanceStep.work_assignment_kind.is_(None),
|
||||||
|
WorkflowInstanceStep.work_assignment_id.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
statement = statement.where(or_(*conditions))
|
||||||
|
|
||||||
|
items: list[WorkItem] = []
|
||||||
|
total = 0
|
||||||
|
decisions: dict[str, bool] = {}
|
||||||
|
for instance, step, definition in session.execute(statement).yield_per(250):
|
||||||
|
assignment = _step_assignment(instance, step)
|
||||||
|
if not administrative and not _assignment_matches(assignment, targets):
|
||||||
|
continue
|
||||||
|
if not _is_actionable_handoff(step.handoff):
|
||||||
|
continue
|
||||||
|
allowed = decisions.get(definition.id)
|
||||||
|
if allowed is None:
|
||||||
|
allowed = definition_decision(
|
||||||
|
definition,
|
||||||
|
principal=principal,
|
||||||
|
registry=self.registry,
|
||||||
|
action="view",
|
||||||
|
).allowed
|
||||||
|
decisions[definition.id] = allowed
|
||||||
|
if not allowed:
|
||||||
|
continue
|
||||||
|
item = _work_item(instance, step, definition, assignment)
|
||||||
|
if query.statuses and item.status not in query.statuses:
|
||||||
|
continue
|
||||||
|
if query.priorities and item.priority not in query.priorities:
|
||||||
|
continue
|
||||||
|
if query.due_before is not None and (
|
||||||
|
item.due_at is None or _aware(item.due_at) > _aware(query.due_before)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
if query.text and query.text.casefold() not in _search_text(item):
|
||||||
|
continue
|
||||||
|
total += 1
|
||||||
|
if len(items) < query.limit:
|
||||||
|
items.append(item)
|
||||||
|
return WorkItemPage(
|
||||||
|
items=tuple(items),
|
||||||
|
total=total,
|
||||||
|
truncated=total > len(items),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _targets(self, principal: ApiPrincipal, tenant_id: str) -> dict[str, set[str]]:
|
||||||
|
result = {
|
||||||
|
"account": {principal.account_id} if principal.account_id else set(),
|
||||||
|
"group": set(principal.group_ids),
|
||||||
|
"role": set(principal.role_ids),
|
||||||
|
"function_assignment": set(principal.function_assignment_ids),
|
||||||
|
"function": set(),
|
||||||
|
"anyone": {"*"},
|
||||||
|
}
|
||||||
|
directory = self._idm_directory()
|
||||||
|
if directory is not None and principal.account_id:
|
||||||
|
result["function"].update(
|
||||||
|
item.function_id
|
||||||
|
for item in directory.organization_function_assignments_for_account(
|
||||||
|
principal.account_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
)
|
||||||
|
if item.tenant_id == tenant_id and item.status == "active"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _idm_directory(self) -> IdmDirectory | None:
|
||||||
|
registry = self.registry
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not registry.has_capability(CAPABILITY_IDM_DIRECTORY)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
provider = registry.capability(CAPABILITY_IDM_DIRECTORY)
|
||||||
|
return provider if isinstance(provider, IdmDirectory) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _step_assignment(
|
||||||
|
instance: WorkflowInstance,
|
||||||
|
step: WorkflowInstanceStep,
|
||||||
|
) -> WorkAssignmentRef | None:
|
||||||
|
if step.work_assignment_kind and step.work_assignment_id:
|
||||||
|
return _assignment_ref(
|
||||||
|
step.work_assignment_kind,
|
||||||
|
step.work_assignment_id,
|
||||||
|
step.work_assignment_label,
|
||||||
|
)
|
||||||
|
handoff_assignment = step.handoff.get("assignment")
|
||||||
|
if isinstance(handoff_assignment, Mapping):
|
||||||
|
kind = str(handoff_assignment.get("kind") or "").strip()
|
||||||
|
assignment_id = str(handoff_assignment.get("id") or "").strip()
|
||||||
|
if kind and assignment_id:
|
||||||
|
return _assignment_ref(
|
||||||
|
kind,
|
||||||
|
assignment_id,
|
||||||
|
str(handoff_assignment.get("label") or "").strip() or None,
|
||||||
|
)
|
||||||
|
account_id = str(instance.authorization_.get("account_id") or "").strip()
|
||||||
|
return WorkAssignmentRef(kind="account", id=account_id) if account_id else None
|
||||||
|
|
||||||
|
|
||||||
|
def _assignment_matches(
|
||||||
|
assignment: WorkAssignmentRef | None,
|
||||||
|
targets: Mapping[str, set[str]],
|
||||||
|
) -> bool:
|
||||||
|
return bool(
|
||||||
|
assignment is not None and assignment.id in targets.get(assignment.kind, set())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_actionable_handoff(handoff: Mapping[str, object]) -> bool:
|
||||||
|
kind = str(handoff.get("kind") or "")
|
||||||
|
state = str(handoff.get("state") or "waiting")
|
||||||
|
return kind not in _NON_HUMAN_KINDS and state not in {"pending", "running"}
|
||||||
|
|
||||||
|
|
||||||
|
def _work_item(
|
||||||
|
instance: WorkflowInstance,
|
||||||
|
step: WorkflowInstanceStep,
|
||||||
|
definition: WorkflowDefinition,
|
||||||
|
assignment: WorkAssignmentRef | None,
|
||||||
|
) -> WorkItem:
|
||||||
|
handoff = dict(step.handoff or {})
|
||||||
|
state = str(handoff.get("state") or "waiting")
|
||||||
|
status = "blocked" if state in _BLOCKED_STATES else "open"
|
||||||
|
title = str(
|
||||||
|
handoff.get("title") or handoff.get("message") or f"Continue {definition.name}"
|
||||||
|
)
|
||||||
|
required_action = str(
|
||||||
|
handoff.get("instructions")
|
||||||
|
or handoff.get("message")
|
||||||
|
or "Continue the current workflow handoff."
|
||||||
|
).strip()
|
||||||
|
action_url = _action_url(
|
||||||
|
handoff.get("action_url")
|
||||||
|
or f"/workflow?definition={definition.id}&run={instance.id}"
|
||||||
|
)
|
||||||
|
due_at = step.work_due_at or _date(handoff.get("due_at"))
|
||||||
|
priority = str(handoff.get("priority") or "normal").casefold()
|
||||||
|
if priority not in {"low", "normal", "high", "urgent"}:
|
||||||
|
priority = "normal"
|
||||||
|
updated_at = step.updated_at or instance.updated_at
|
||||||
|
revision = f"{step.attempt}:{updated_at.isoformat() if updated_at else '1'}"
|
||||||
|
return WorkItem(
|
||||||
|
id=step.id,
|
||||||
|
provider_id=PROVIDER_ID,
|
||||||
|
owner_module="workflow_engine",
|
||||||
|
tenant_id=instance.tenant_id,
|
||||||
|
title=title,
|
||||||
|
summary=f"{definition.name} · {step.node_type}",
|
||||||
|
status=status, # type: ignore[arg-type]
|
||||||
|
priority=priority, # type: ignore[arg-type]
|
||||||
|
required_action=required_action or None,
|
||||||
|
action_url=action_url,
|
||||||
|
due_at=due_at,
|
||||||
|
assignments=(assignment,) if assignment else (),
|
||||||
|
sources=(
|
||||||
|
WorkSourceRef(
|
||||||
|
module_id="workflow_engine",
|
||||||
|
resource_type="workflow_instance",
|
||||||
|
resource_id=instance.id,
|
||||||
|
revision=instance.definition_revision_id,
|
||||||
|
url=f"/workflow?definition={definition.id}&run={instance.id}",
|
||||||
|
label=definition.name,
|
||||||
|
),
|
||||||
|
WorkSourceRef(
|
||||||
|
module_id="workflow_engine",
|
||||||
|
resource_type="workflow_step",
|
||||||
|
resource_id=step.id,
|
||||||
|
revision=str(step.attempt),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
provenance={
|
||||||
|
"definition_id": definition.id,
|
||||||
|
"definition_revision_id": instance.definition_revision_id,
|
||||||
|
"workflow_instance_id": instance.id,
|
||||||
|
"workflow_step_id": step.id,
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"handoff_kind": handoff.get("kind"),
|
||||||
|
"handoff_state": state,
|
||||||
|
"allowed_actions": _allowed_actions(handoff.get("allowed_actions")),
|
||||||
|
},
|
||||||
|
revision=revision,
|
||||||
|
created_at=step.created_at,
|
||||||
|
updated_at=updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assignment_ref(
|
||||||
|
kind: object,
|
||||||
|
assignment_id: object,
|
||||||
|
label: object = None,
|
||||||
|
) -> WorkAssignmentRef | None:
|
||||||
|
normalized_kind = str(kind or "").strip()
|
||||||
|
normalized_id = str(assignment_id or "").strip()
|
||||||
|
if normalized_kind not in _ASSIGNMENT_KINDS or not normalized_id:
|
||||||
|
return None
|
||||||
|
if normalized_kind == "anyone":
|
||||||
|
normalized_id = "*"
|
||||||
|
try:
|
||||||
|
normalized_label = str(label).strip()[:500] if label is not None else ""
|
||||||
|
return WorkAssignmentRef(
|
||||||
|
kind=normalized_kind, # type: ignore[arg-type]
|
||||||
|
id=normalized_id,
|
||||||
|
label=normalized_label or None,
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _action_url(value: object) -> str:
|
||||||
|
candidate = str(value or "").strip()
|
||||||
|
if (
|
||||||
|
candidate.startswith("/")
|
||||||
|
and not candidate.startswith("//")
|
||||||
|
and "\\" not in candidate
|
||||||
|
and all(
|
||||||
|
ord(character) >= 32 and ord(character) != 127 for character in candidate
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return candidate[:1_500]
|
||||||
|
return "/workflow"
|
||||||
|
|
||||||
|
|
||||||
|
def _allowed_actions(value: object) -> list[str]:
|
||||||
|
if not isinstance(value, (list, tuple, set, frozenset)):
|
||||||
|
return []
|
||||||
|
return [normalized for item in value if (normalized := str(item or "").strip())][
|
||||||
|
:100
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _date(value: object) -> datetime | None:
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _aware(value: datetime) -> datetime:
|
||||||
|
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _search_text(item: WorkItem) -> str:
|
||||||
|
return " ".join(
|
||||||
|
value
|
||||||
|
for value in (
|
||||||
|
item.title,
|
||||||
|
item.summary,
|
||||||
|
item.required_action,
|
||||||
|
item.owner_module,
|
||||||
|
)
|
||||||
|
if value
|
||||||
|
).casefold()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["PROVIDER_ID", "WorkflowWorkItemProvider"]
|
||||||
@@ -0,0 +1,578 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.dsar import (
|
||||||
|
DsarErasureActionRef,
|
||||||
|
DsarProvider,
|
||||||
|
DsarRecordRef,
|
||||||
|
DsarSubjectRef,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.privacy.dsar_workflow import (
|
||||||
|
create_data_subject_request,
|
||||||
|
search_data_subject_request,
|
||||||
|
)
|
||||||
|
from govoplan_workflow_engine.backend.db.models import (
|
||||||
|
WorkflowDefinition,
|
||||||
|
WorkflowDefinitionRevision,
|
||||||
|
WorkflowInstance,
|
||||||
|
WorkflowInstanceEvent,
|
||||||
|
WorkflowInstanceStep,
|
||||||
|
WorkflowTrigger,
|
||||||
|
WorkflowTriggerDelivery,
|
||||||
|
WorkflowWaitState,
|
||||||
|
)
|
||||||
|
from govoplan_workflow_engine.backend.dsar_provider import (
|
||||||
|
WORKFLOW_ENGINE_DSAR_CAPABILITY,
|
||||||
|
WorkflowEngineDsarProvider,
|
||||||
|
)
|
||||||
|
from govoplan_workflow_engine.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 8, 21, 21, 0, tzinfo=UTC)
|
||||||
|
SECRET = "private-workflow-detail-do-not-export"
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
provider: WorkflowEngineDsarProvider,
|
||||||
|
*,
|
||||||
|
active: bool = True,
|
||||||
|
) -> None:
|
||||||
|
self.provider = provider
|
||||||
|
self.active = active
|
||||||
|
|
||||||
|
def capability_names(self):
|
||||||
|
return (WORKFLOW_ENGINE_DSAR_CAPABILITY,)
|
||||||
|
|
||||||
|
def capability_owner(self, name):
|
||||||
|
self._assert_capability(name)
|
||||||
|
return "workflow_engine"
|
||||||
|
|
||||||
|
def tenant_entitlement_resolver(self):
|
||||||
|
active = self.active
|
||||||
|
|
||||||
|
class _Resolver:
|
||||||
|
@staticmethod
|
||||||
|
def resolve(session, tenant_id):
|
||||||
|
del session, tenant_id
|
||||||
|
return type(
|
||||||
|
"State",
|
||||||
|
(),
|
||||||
|
{"effective_modules": ("workflow_engine",) if active else ()},
|
||||||
|
)()
|
||||||
|
|
||||||
|
return _Resolver()
|
||||||
|
|
||||||
|
def require_tenant_capability(self, name, session, **kwargs):
|
||||||
|
del session, kwargs
|
||||||
|
self._assert_capability(name)
|
||||||
|
return self.provider
|
||||||
|
|
||||||
|
def manifests(self):
|
||||||
|
return (type("Manifest", (), {"id": "workflow_engine"})(),)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assert_capability(name: str) -> None:
|
||||||
|
if name != WORKFLOW_ENGINE_DSAR_CAPABILITY:
|
||||||
|
raise KeyError(name)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowEngineDsarProviderTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(self.engine)
|
||||||
|
self.session = Session(self.engine)
|
||||||
|
self.provider = WorkflowEngineDsarProvider()
|
||||||
|
self.assertIsInstance(self.provider, DsarProvider)
|
||||||
|
self._seed()
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def _seed(self) -> None:
|
||||||
|
definition = WorkflowDefinition(
|
||||||
|
id="definition-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id="tenant-1",
|
||||||
|
scope_key="tenant:tenant-1",
|
||||||
|
definition_kind="flow",
|
||||||
|
definition_key="resident-permit",
|
||||||
|
name=SECRET,
|
||||||
|
description=SECRET,
|
||||||
|
status="active",
|
||||||
|
current_revision=1,
|
||||||
|
active_revision=1,
|
||||||
|
metadata_={"secret": SECRET},
|
||||||
|
derivation_provenance={"secret": SECRET},
|
||||||
|
created_by="account-1",
|
||||||
|
updated_by="account-1",
|
||||||
|
)
|
||||||
|
other = WorkflowDefinition(
|
||||||
|
id="definition-other",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id="tenant-2",
|
||||||
|
scope_key="tenant:tenant-2",
|
||||||
|
definition_kind="flow",
|
||||||
|
definition_key="other",
|
||||||
|
name=SECRET,
|
||||||
|
status="active",
|
||||||
|
current_revision=1,
|
||||||
|
created_by="account-1",
|
||||||
|
updated_by="account-1",
|
||||||
|
)
|
||||||
|
self.session.add_all((definition, other))
|
||||||
|
self.session.flush()
|
||||||
|
revision = WorkflowDefinitionRevision(
|
||||||
|
id="revision-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
definition_id=definition.id,
|
||||||
|
revision=1,
|
||||||
|
schema_version=1,
|
||||||
|
graph={"secret": SECRET},
|
||||||
|
content_hash="a" * 64,
|
||||||
|
library_id="bpmn",
|
||||||
|
library_version="1.0.0",
|
||||||
|
execution_mode="hybrid",
|
||||||
|
bpmn_xml=SECRET,
|
||||||
|
bpmn_hash="b" * 64,
|
||||||
|
bpmn_runtime_kind="native_graph",
|
||||||
|
bpmn_executable=True,
|
||||||
|
contribution_metadata={"secret": SECRET},
|
||||||
|
created_by="account-1",
|
||||||
|
)
|
||||||
|
self.session.add(revision)
|
||||||
|
self.session.flush()
|
||||||
|
instance = WorkflowInstance(
|
||||||
|
id="instance-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
definition_id=definition.id,
|
||||||
|
definition_revision_id=revision.id,
|
||||||
|
status="completed",
|
||||||
|
start_origin="service",
|
||||||
|
idempotency_key=SECRET,
|
||||||
|
correlation_id=SECRET,
|
||||||
|
input_={"secret": SECRET},
|
||||||
|
context_={"secret": SECRET},
|
||||||
|
output_={"secret": SECRET},
|
||||||
|
authorization_={
|
||||||
|
"account_id": "account-1",
|
||||||
|
"identity_id": "identity-1",
|
||||||
|
"membership_id": "membership-1",
|
||||||
|
"secret": SECRET,
|
||||||
|
},
|
||||||
|
started_at=NOW,
|
||||||
|
finished_at=NOW,
|
||||||
|
error=SECRET,
|
||||||
|
created_by="account-1",
|
||||||
|
)
|
||||||
|
self.session.add(instance)
|
||||||
|
self.session.flush()
|
||||||
|
step = WorkflowInstanceStep(
|
||||||
|
id="step-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
instance_id=instance.id,
|
||||||
|
sequence=1,
|
||||||
|
node_id="review",
|
||||||
|
node_type="human_task",
|
||||||
|
status="completed",
|
||||||
|
attempt=1,
|
||||||
|
idempotency_key=SECRET,
|
||||||
|
input_={"secret": SECRET},
|
||||||
|
output_={"secret": SECRET},
|
||||||
|
handoff={"secret": SECRET},
|
||||||
|
external_ref=SECRET,
|
||||||
|
started_at=NOW,
|
||||||
|
finished_at=NOW,
|
||||||
|
error=SECRET,
|
||||||
|
completed_by="account-1",
|
||||||
|
work_assignment_kind="membership",
|
||||||
|
work_assignment_id="membership-1",
|
||||||
|
work_assignment_label=SECRET,
|
||||||
|
work_due_at=NOW,
|
||||||
|
)
|
||||||
|
trigger = WorkflowTrigger(
|
||||||
|
id="trigger-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
definition_id=definition.id,
|
||||||
|
definition_revision_id=revision.id,
|
||||||
|
node_id="start-event",
|
||||||
|
kind="event",
|
||||||
|
status="active",
|
||||||
|
config_={"secret": SECRET},
|
||||||
|
event_type="permit.submitted",
|
||||||
|
next_fire_at=NOW + timedelta(hours=1),
|
||||||
|
last_error=SECRET,
|
||||||
|
authorization_subject_kind="delegated_user",
|
||||||
|
authorization_account_id="account-1",
|
||||||
|
authorization_membership_id="membership-1",
|
||||||
|
authorization_ref=SECRET,
|
||||||
|
grant_scopes=[SECRET],
|
||||||
|
created_by="account-1",
|
||||||
|
updated_by="account-1",
|
||||||
|
)
|
||||||
|
self.session.add_all((step, trigger))
|
||||||
|
self.session.flush()
|
||||||
|
self.session.add_all(
|
||||||
|
(
|
||||||
|
WorkflowInstanceEvent(
|
||||||
|
id="event-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
instance_id=instance.id,
|
||||||
|
step_id=step.id,
|
||||||
|
sequence=1,
|
||||||
|
kind="step.completed",
|
||||||
|
actor_id="account-1",
|
||||||
|
payload={"secret": SECRET},
|
||||||
|
created_at=NOW,
|
||||||
|
),
|
||||||
|
WorkflowTriggerDelivery(
|
||||||
|
id="delivery-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
trigger_id=trigger.id,
|
||||||
|
definition_id=definition.id,
|
||||||
|
definition_revision_id=revision.id,
|
||||||
|
source_key=SECRET,
|
||||||
|
invocation_kind="event",
|
||||||
|
status="succeeded",
|
||||||
|
scheduled_for=NOW,
|
||||||
|
event_={"secret": SECRET},
|
||||||
|
instance_id=instance.id,
|
||||||
|
attempts=1,
|
||||||
|
error=SECRET,
|
||||||
|
),
|
||||||
|
WorkflowWaitState(
|
||||||
|
id="wait-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
instance_id=instance.id,
|
||||||
|
step_id=step.id,
|
||||||
|
mode="event",
|
||||||
|
status="completed",
|
||||||
|
due_at=NOW,
|
||||||
|
event_type="permit.approved",
|
||||||
|
config_={"secret": SECRET},
|
||||||
|
source_event_id=SECRET,
|
||||||
|
event_={"secret": SECRET},
|
||||||
|
error=SECRET,
|
||||||
|
revision=1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_canonical_selector_uses_structured_links_and_minimized_output(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
account_id="account-1",
|
||||||
|
identity_id="identity-1",
|
||||||
|
membership_id="membership-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(6, len(records))
|
||||||
|
categories = {record.resource_type: record.category for record in records}
|
||||||
|
self.assertEqual(
|
||||||
|
"workflow_automation_authority",
|
||||||
|
categories["workflow_trigger"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"workflow_subject_authorization",
|
||||||
|
categories["workflow_instance"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"workflow_subject_work_assignment",
|
||||||
|
categories["workflow_instance_step"],
|
||||||
|
)
|
||||||
|
exported = json.dumps([record.to_dict() for record in records])
|
||||||
|
self.assertNotIn(SECRET, exported)
|
||||||
|
self.assertNotIn("account-1", exported)
|
||||||
|
self.assertNotIn("identity-1", exported)
|
||||||
|
self.assertNotIn("membership-1", exported)
|
||||||
|
self.assertNotIn("definition-other", exported)
|
||||||
|
|
||||||
|
def test_exact_instance_and_definition_packages_are_review_only(self) -> None:
|
||||||
|
instance_records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
external_references={"workflow.instance": "instance:instance-1"}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
definition_records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
external_references={"workflow.definition": "definition-1"}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(4, len(instance_records))
|
||||||
|
self.assertEqual(8, len(definition_records))
|
||||||
|
self.assertNotIn(
|
||||||
|
SECRET,
|
||||||
|
json.dumps([record.to_dict() for record in definition_records]),
|
||||||
|
)
|
||||||
|
actions = self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
external_references={"workflow.definition": "definition-1"}
|
||||||
|
),
|
||||||
|
records=definition_records,
|
||||||
|
)
|
||||||
|
self.assertEqual({"manual_review"}, {action.kind for action in actions})
|
||||||
|
|
||||||
|
def test_every_exact_reference_and_conflicts_fail_closed(self) -> None:
|
||||||
|
references = {
|
||||||
|
"workflow_engine.definition_revision": "revision-1",
|
||||||
|
"workflow_engine.step": "step-1",
|
||||||
|
"workflow_engine.event": "event-1",
|
||||||
|
"workflow_engine.trigger": "trigger-1",
|
||||||
|
"workflow_engine.trigger_delivery": "delivery-1",
|
||||||
|
"workflow_engine.wait_state": "wait-1",
|
||||||
|
}
|
||||||
|
for key, value in references.items():
|
||||||
|
with self.subTest(key=key):
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(external_references={key: value}),
|
||||||
|
)
|
||||||
|
self.assertEqual(1, len(records))
|
||||||
|
|
||||||
|
mismatch = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
account_id="account-2",
|
||||||
|
external_references={"workflow.instance": "instance-1"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
wrong_tenant = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
external_references={"workflow.instance": "instance-1"}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conflict = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
external_references={
|
||||||
|
"workflow_engine.revision": "revision-1",
|
||||||
|
"workflow_engine.definition_revision": "different",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual((), mismatch)
|
||||||
|
self.assertEqual((), wrong_tenant)
|
||||||
|
self.assertEqual((), conflict)
|
||||||
|
|
||||||
|
def test_terminal_delivery_minimization_preserves_replay_identity(self) -> None:
|
||||||
|
subject = DsarSubjectRef(
|
||||||
|
external_references={"workflow_engine.delivery": "delivery-1"}
|
||||||
|
)
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
)
|
||||||
|
actions = self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
records=records,
|
||||||
|
)
|
||||||
|
self.assertEqual("anonymize", actions[0].kind)
|
||||||
|
first = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
actions=actions,
|
||||||
|
request_id="dsar-1",
|
||||||
|
)
|
||||||
|
second = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
actions=actions,
|
||||||
|
request_id="dsar-1-retry",
|
||||||
|
)
|
||||||
|
self.assertEqual("executed", first[0].status)
|
||||||
|
self.assertEqual("unchanged", second[0].status)
|
||||||
|
delivery = self.session.get(WorkflowTriggerDelivery, "delivery-1")
|
||||||
|
self.assertIsNone(delivery.event_)
|
||||||
|
self.assertIsNone(delivery.error)
|
||||||
|
self.assertEqual(SECRET, delivery.source_key)
|
||||||
|
|
||||||
|
def test_automation_authority_revocation_is_idempotent(self) -> None:
|
||||||
|
subject = DsarSubjectRef(
|
||||||
|
account_id="account-1",
|
||||||
|
membership_id="membership-1",
|
||||||
|
)
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
)
|
||||||
|
actions = self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
records=records,
|
||||||
|
)
|
||||||
|
trigger_action = next(
|
||||||
|
action for action in actions if action.resource_type == "workflow_trigger"
|
||||||
|
)
|
||||||
|
self.assertEqual("revoke", trigger_action.kind)
|
||||||
|
first = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
actions=(trigger_action,),
|
||||||
|
request_id="dsar-2",
|
||||||
|
)
|
||||||
|
second = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
actions=(trigger_action,),
|
||||||
|
request_id="dsar-2-retry",
|
||||||
|
)
|
||||||
|
self.assertEqual("executed", first[0].status)
|
||||||
|
self.assertEqual("unchanged", second[0].status)
|
||||||
|
trigger = self.session.get(WorkflowTrigger, "trigger-1")
|
||||||
|
self.assertEqual("disabled", trigger.status)
|
||||||
|
self.assertIsNone(trigger.authorization_account_id)
|
||||||
|
self.assertIsNone(trigger.authorization_membership_id)
|
||||||
|
self.assertEqual([], trigger.grant_scopes)
|
||||||
|
self.assertEqual({}, trigger.config_)
|
||||||
|
|
||||||
|
def test_foreign_records_and_actions_are_rejected(self) -> None:
|
||||||
|
subject = DsarSubjectRef(account_id="account-1")
|
||||||
|
with self.assertRaisesRegex(ValueError, "foreign provider record"):
|
||||||
|
self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
records=(
|
||||||
|
DsarRecordRef(
|
||||||
|
provider_id="cases",
|
||||||
|
module_id="cases",
|
||||||
|
resource_type="case",
|
||||||
|
resource_id="case-1",
|
||||||
|
category="case",
|
||||||
|
title="Case",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ValueError, "foreign provider action"):
|
||||||
|
self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
actions=(
|
||||||
|
DsarErasureActionRef(
|
||||||
|
action_id="cases:delete:case:case-1",
|
||||||
|
provider_id="cases",
|
||||||
|
module_id="cases",
|
||||||
|
kind="delete",
|
||||||
|
resource_type="case",
|
||||||
|
resource_id="case-1",
|
||||||
|
title="Delete case",
|
||||||
|
rationale="Foreign",
|
||||||
|
executable=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
request_id="dsar-3",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_core_workflow_reports_active_and_inactive_provider(self) -> None:
|
||||||
|
row = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-WORKFLOW-1",
|
||||||
|
request_kind="access_and_erasure",
|
||||||
|
subject=DsarSubjectRef(account_id="account-1"),
|
||||||
|
purpose="Respond to a verified request.",
|
||||||
|
legal_basis="Article 15 and 17 GDPR",
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="privacy-officer",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider),
|
||||||
|
row=row,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[WORKFLOW_ENGINE_DSAR_CAPABILITY],
|
||||||
|
row.coverage["provider_capabilities"],
|
||||||
|
)
|
||||||
|
self.assertEqual(6, row.search_result["record_count"])
|
||||||
|
|
||||||
|
inactive = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-WORKFLOW-2",
|
||||||
|
request_kind="access",
|
||||||
|
subject=DsarSubjectRef(account_id="account-1"),
|
||||||
|
purpose="Respond to a verified request.",
|
||||||
|
legal_basis="Article 15 GDPR",
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="privacy-officer",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider, active=False),
|
||||||
|
row=inactive,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
self.assertEqual([], inactive.coverage["provider_capabilities"])
|
||||||
|
self.assertEqual(
|
||||||
|
[WORKFLOW_ENGINE_DSAR_CAPABILITY],
|
||||||
|
inactive.coverage["inactive_provider_capabilities"],
|
||||||
|
)
|
||||||
|
self.assertEqual(0, inactive.search_result["record_count"])
|
||||||
|
|
||||||
|
def test_manifest_registers_and_documents_capability(self) -> None:
|
||||||
|
self.assertIn(
|
||||||
|
WORKFLOW_ENGINE_DSAR_CAPABILITY,
|
||||||
|
manifest.capability_factories,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
WORKFLOW_ENGINE_DSAR_CAPABILITY,
|
||||||
|
manifest.capability_documentation,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
WORKFLOW_ENGINE_DSAR_CAPABILITY,
|
||||||
|
{item.name for item in manifest.provides_interfaces},
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
topic.id == "workflow.data-subject-requests"
|
||||||
|
and {"admin", "user"}.issubset(topic.documentation_types)
|
||||||
|
for topic in manifest.documentation
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,519 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import replace
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine, event
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.core.tasks import WorkItemQuery
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
from govoplan_workflow_engine.backend.db.models import (
|
||||||
|
WorkflowDefinition,
|
||||||
|
WorkflowDefinitionRevision,
|
||||||
|
WorkflowInstance,
|
||||||
|
WorkflowInstanceEvent,
|
||||||
|
WorkflowInstanceStep,
|
||||||
|
)
|
||||||
|
from govoplan_workflow_engine.backend.instance_reads import (
|
||||||
|
get_instance_summary,
|
||||||
|
instance_history_page,
|
||||||
|
list_instance_summaries,
|
||||||
|
)
|
||||||
|
from govoplan_workflow_engine.backend.instance_service import (
|
||||||
|
instance_response,
|
||||||
|
list_instances,
|
||||||
|
)
|
||||||
|
from govoplan_workflow_engine.backend.router import router
|
||||||
|
from govoplan_workflow_engine.backend.service import WorkflowNotFoundError
|
||||||
|
from govoplan_workflow_engine.backend.work_items import WorkflowWorkItemProvider
|
||||||
|
|
||||||
|
|
||||||
|
def principal() -> ApiPrincipal:
|
||||||
|
return ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id="account-1",
|
||||||
|
membership_id="membership-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scopes=frozenset({"workflow:instance:read"}),
|
||||||
|
),
|
||||||
|
account=object(),
|
||||||
|
user=object(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowInstanceReadTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine(
|
||||||
|
"sqlite://",
|
||||||
|
poolclass=StaticPool,
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
)
|
||||||
|
Base.metadata.create_all(
|
||||||
|
self.engine,
|
||||||
|
tables=[
|
||||||
|
WorkflowDefinition.__table__,
|
||||||
|
WorkflowDefinitionRevision.__table__,
|
||||||
|
WorkflowInstance.__table__,
|
||||||
|
WorkflowInstanceStep.__table__,
|
||||||
|
WorkflowInstanceEvent.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.session = Session(self.engine)
|
||||||
|
self.actor = principal()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def statements(self):
|
||||||
|
statements = []
|
||||||
|
|
||||||
|
def capture(_connection, _cursor, statement, _parameters, _context, _many):
|
||||||
|
statements.append(statement)
|
||||||
|
|
||||||
|
event.listen(self.engine, "before_cursor_execute", capture)
|
||||||
|
try:
|
||||||
|
yield statements
|
||||||
|
finally:
|
||||||
|
event.remove(self.engine, "before_cursor_execute", capture)
|
||||||
|
|
||||||
|
def seed(self, index: int, *, tenant_id="tenant-1", scope_type="tenant", rows=3):
|
||||||
|
now = datetime(2026, 1, 1, tzinfo=UTC)
|
||||||
|
definition = WorkflowDefinition(
|
||||||
|
id=f"definition-{index}",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
scope_type=scope_type,
|
||||||
|
scope_id=tenant_id if scope_type == "tenant" else "group-other",
|
||||||
|
scope_key=f"{scope_type}:{tenant_id}:{index}",
|
||||||
|
definition_key=f"workflow-{index}",
|
||||||
|
name=f"Workflow {index}",
|
||||||
|
status="active",
|
||||||
|
current_revision=index + 1,
|
||||||
|
active_revision=index + 1,
|
||||||
|
)
|
||||||
|
revision = WorkflowDefinitionRevision(
|
||||||
|
id=f"revision-{index}",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
definition=definition,
|
||||||
|
revision=index + 1,
|
||||||
|
graph={"sensitive_graph": "not needed for summary"},
|
||||||
|
bpmn_xml="sensitive BPMN",
|
||||||
|
content_hash=f"{index:064x}",
|
||||||
|
library_id="workflow",
|
||||||
|
library_version="1",
|
||||||
|
)
|
||||||
|
instance = WorkflowInstance(
|
||||||
|
id=f"instance-{index:05d}",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
definition=definition,
|
||||||
|
definition_revision_id=revision.id,
|
||||||
|
status="waiting",
|
||||||
|
idempotency_key=f"key-{index}",
|
||||||
|
authorization_={"account_id": "account-1"},
|
||||||
|
input_={"sensitive": "input"},
|
||||||
|
context_={"sensitive": "context"},
|
||||||
|
output_={"sensitive": "output"},
|
||||||
|
started_at=now,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
self.session.add_all([definition, revision, instance])
|
||||||
|
for sequence in range(1, rows + 1):
|
||||||
|
step = WorkflowInstanceStep(
|
||||||
|
id=f"step-{index}-{sequence}",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
instance=instance,
|
||||||
|
sequence=sequence,
|
||||||
|
node_id="activity",
|
||||||
|
node_type="workflow.activity",
|
||||||
|
status="waiting",
|
||||||
|
idempotency_key=f"step-key-{index}-{sequence}",
|
||||||
|
handoff={"kind": "activity", "title": "Review application"},
|
||||||
|
work_assignment_kind="account",
|
||||||
|
work_assignment_id="account-1",
|
||||||
|
)
|
||||||
|
self.session.add(step)
|
||||||
|
self.session.add(
|
||||||
|
WorkflowInstanceEvent(
|
||||||
|
id=f"event-{index}-{sequence}",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
instance=instance,
|
||||||
|
step_id=step.id,
|
||||||
|
sequence=sequence,
|
||||||
|
kind="workflow.step.started",
|
||||||
|
payload={"sequence": sequence},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
instance.current_step_id = step.id
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def summaries(self, **kwargs):
|
||||||
|
return list_instance_summaries(
|
||||||
|
self.session, principal=self.actor, registry=None, **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
def history(self, instance_id="instance-00001", **kwargs):
|
||||||
|
return instance_history_page(
|
||||||
|
self.session,
|
||||||
|
principal=self.actor,
|
||||||
|
registry=None,
|
||||||
|
instance_id=instance_id,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_cold_40_distinct_revisions_have_constant_query_count_and_full_history(
|
||||||
|
self,
|
||||||
|
):
|
||||||
|
for index in range(40):
|
||||||
|
self.seed(index)
|
||||||
|
self.seed(100, tenant_id="other-tenant")
|
||||||
|
self.session.commit()
|
||||||
|
self.session.expunge_all()
|
||||||
|
with self.statements() as queries:
|
||||||
|
instances = list_instances(self.session, tenant_id="tenant-1", limit=40)
|
||||||
|
responses = [instance_response(self.session, item) for item in instances]
|
||||||
|
self.assertEqual(5, len(queries))
|
||||||
|
self.assertEqual(40, len(responses))
|
||||||
|
self.assertEqual(
|
||||||
|
set(range(1, 41)), {item.definition_revision for item in responses}
|
||||||
|
)
|
||||||
|
for item in responses:
|
||||||
|
self.assertEqual(
|
||||||
|
f"{item.definition_revision - 1:064x}", item.definition_hash
|
||||||
|
)
|
||||||
|
self.assertEqual([1, 2, 3], [step.sequence for step in item.steps])
|
||||||
|
self.assertEqual([1, 2, 3], [entry.sequence for entry in item.events])
|
||||||
|
|
||||||
|
def test_summary_query_omits_histories_graphs_and_runtime_payloads(self):
|
||||||
|
for index in range(40):
|
||||||
|
self.seed(index)
|
||||||
|
self.session.commit()
|
||||||
|
self.session.expunge_all()
|
||||||
|
with self.statements() as queries:
|
||||||
|
page = self.summaries(limit=40)
|
||||||
|
self.assertEqual(1, len(queries))
|
||||||
|
self.assertEqual(40, len(page.instances))
|
||||||
|
self.assertIsNone(page.next_cursor)
|
||||||
|
sql = queries[0]
|
||||||
|
for forbidden in (
|
||||||
|
"workflow_instance_steps",
|
||||||
|
"workflow_instance_events",
|
||||||
|
".graph",
|
||||||
|
".bpmn_xml",
|
||||||
|
"workflow_instances.input",
|
||||||
|
"workflow_instances.context",
|
||||||
|
"workflow_instances.output",
|
||||||
|
"workflow_instances.authorization",
|
||||||
|
):
|
||||||
|
self.assertNotIn(forbidden, sql)
|
||||||
|
self.assertNotIn("steps", page.instances[0].model_dump())
|
||||||
|
self.assertNotIn("events", page.instances[0].model_dump())
|
||||||
|
self.assertNotIn("total", page.model_dump())
|
||||||
|
|
||||||
|
def test_summary_queries_use_ordered_tenant_indexes(self):
|
||||||
|
self.seed(1, rows=0)
|
||||||
|
self.session.commit()
|
||||||
|
for definition_id, index_name in (
|
||||||
|
(None, "ix_workflow_instances_tenant_created_id"),
|
||||||
|
("definition-1", "ix_workflow_instances_tenant_definition_created_id"),
|
||||||
|
):
|
||||||
|
captured = []
|
||||||
|
|
||||||
|
def capture(_connection, _cursor, sql, parameters, _context, _many):
|
||||||
|
captured.append((sql, parameters))
|
||||||
|
|
||||||
|
event.listen(self.engine, "before_cursor_execute", capture)
|
||||||
|
try:
|
||||||
|
self.summaries(definition_id=definition_id)
|
||||||
|
finally:
|
||||||
|
event.remove(self.engine, "before_cursor_execute", capture)
|
||||||
|
sql, parameters = captured[0]
|
||||||
|
plan = " ".join(
|
||||||
|
row[3]
|
||||||
|
for row in self.session.connection().exec_driver_sql(
|
||||||
|
"EXPLAIN QUERY PLAN " + sql, parameters
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertIn(index_name, plan)
|
||||||
|
self.assertNotIn("TEMP B-TREE", plan)
|
||||||
|
|
||||||
|
def test_summary_pages_fill_after_current_governance_and_preserve_ties(self):
|
||||||
|
for index in range(5):
|
||||||
|
self.seed(index, rows=0)
|
||||||
|
# More inaccessible candidates than one SQL batch precede the visible rows.
|
||||||
|
for index in range(10, 270):
|
||||||
|
self.seed(index, scope_type="group", rows=0)
|
||||||
|
self.seed(300, tenant_id="other-tenant", rows=0)
|
||||||
|
self.session.commit()
|
||||||
|
self.session.expunge_all()
|
||||||
|
with self.statements() as queries:
|
||||||
|
first = self.summaries(limit=2)
|
||||||
|
self.assertEqual(2, len(queries))
|
||||||
|
self.assertEqual(
|
||||||
|
["instance-00004", "instance-00003"], [i.id for i in first.instances]
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(first.next_cursor)
|
||||||
|
# Updating progress must not move an item across the creation-order cursor.
|
||||||
|
self.session.get(WorkflowInstance, "instance-00002").updated_at += timedelta(
|
||||||
|
days=2
|
||||||
|
)
|
||||||
|
second = self.summaries(limit=2, cursor=first.next_cursor)
|
||||||
|
third = self.summaries(limit=2, cursor=second.next_cursor)
|
||||||
|
self.assertEqual(
|
||||||
|
["instance-00002", "instance-00001"], [i.id for i in second.instances]
|
||||||
|
)
|
||||||
|
self.assertEqual(["instance-00000"], [i.id for i in third.instances])
|
||||||
|
self.assertIsNone(third.next_cursor)
|
||||||
|
filtered = self.summaries(definition_id="definition-2", limit=1)
|
||||||
|
self.assertEqual(["instance-00002"], [i.id for i in filtered.instances])
|
||||||
|
self.assertIsNone(filtered.next_cursor)
|
||||||
|
|
||||||
|
def test_summary_and_history_recheck_governance_in_the_same_session(self):
|
||||||
|
self.seed(1)
|
||||||
|
self.session.commit()
|
||||||
|
first = self.summaries(limit=1)
|
||||||
|
self.assertEqual(1, len(first.instances))
|
||||||
|
initial = self.history(kind="events", limit=1)
|
||||||
|
# Simulate governance changing after a prior request, including a warm
|
||||||
|
# identity map. Retained instance authorization is not a read grant.
|
||||||
|
with Session(self.engine) as writer:
|
||||||
|
definition = writer.get(WorkflowDefinition, "definition-1")
|
||||||
|
definition.scope_type = "group"
|
||||||
|
definition.scope_id = "group-other"
|
||||||
|
writer.commit()
|
||||||
|
self.assertEqual([], self.summaries(limit=1).instances)
|
||||||
|
with self.assertRaises(PermissionError):
|
||||||
|
self.history(kind="events", after_sequence=initial.next_after_sequence)
|
||||||
|
with self.assertRaises(PermissionError):
|
||||||
|
get_instance_summary(
|
||||||
|
self.session,
|
||||||
|
principal=self.actor,
|
||||||
|
registry=None,
|
||||||
|
instance_id="instance-00001",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_history_has_exact_total_and_fixed_boundary_without_silent_truncation(self):
|
||||||
|
self.seed(1, rows=405)
|
||||||
|
self.seed(2, tenant_id="other-tenant")
|
||||||
|
self.session.commit()
|
||||||
|
self.session.expunge_all()
|
||||||
|
for kind in ("steps", "events"):
|
||||||
|
with self.subTest(kind=kind):
|
||||||
|
model = (
|
||||||
|
WorkflowInstanceStep if kind == "steps" else WorkflowInstanceEvent
|
||||||
|
)
|
||||||
|
loaded = []
|
||||||
|
|
||||||
|
def record_load(row, _context):
|
||||||
|
loaded.append(row.id)
|
||||||
|
|
||||||
|
event.listen(model, "load", record_load)
|
||||||
|
try:
|
||||||
|
with self.statements() as queries:
|
||||||
|
first = self.history(kind=kind, limit=200)
|
||||||
|
finally:
|
||||||
|
event.remove(model, "load", record_load)
|
||||||
|
self.assertEqual(3, len(queries))
|
||||||
|
self.assertEqual(201, len(loaded))
|
||||||
|
self.assertEqual(405, first.total)
|
||||||
|
self.assertEqual(405, first.through_sequence)
|
||||||
|
self.assertEqual(200, first.next_after_sequence)
|
||||||
|
self.assertEqual(200, len(getattr(first, kind)))
|
||||||
|
second = self.history(
|
||||||
|
kind=kind,
|
||||||
|
limit=200,
|
||||||
|
after_sequence=first.next_after_sequence,
|
||||||
|
through_sequence=first.through_sequence,
|
||||||
|
)
|
||||||
|
third = self.history(
|
||||||
|
kind=kind,
|
||||||
|
limit=200,
|
||||||
|
after_sequence=second.next_after_sequence,
|
||||||
|
through_sequence=first.through_sequence,
|
||||||
|
)
|
||||||
|
self.assertEqual(405, third.total)
|
||||||
|
self.assertIsNone(third.next_after_sequence)
|
||||||
|
sequences = [
|
||||||
|
row.sequence
|
||||||
|
for page in (first, second, third)
|
||||||
|
for row in getattr(page, kind)
|
||||||
|
]
|
||||||
|
self.assertEqual(list(range(1, 406)), sequences)
|
||||||
|
empty = self.history(kind=kind, after_sequence=1000)
|
||||||
|
self.assertEqual([], getattr(empty, kind))
|
||||||
|
self.assertEqual(405, empty.total)
|
||||||
|
self.session.add(
|
||||||
|
WorkflowInstanceEvent(
|
||||||
|
id="later",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
instance_id="instance-00001",
|
||||||
|
sequence=406,
|
||||||
|
kind="workflow.step.completed",
|
||||||
|
payload={},
|
||||||
|
created_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
fixed = self.history(kind="events", after_sequence=400, through_sequence=405)
|
||||||
|
fresh = self.history(kind="events", after_sequence=400)
|
||||||
|
self.assertEqual(405, fixed.total)
|
||||||
|
self.assertEqual(406, fresh.total)
|
||||||
|
self.assertEqual([401, 402, 403, 404, 405], [i.sequence for i in fixed.events])
|
||||||
|
|
||||||
|
def test_pages_enforce_tenant_scope_and_read_permission(self):
|
||||||
|
self.seed(1)
|
||||||
|
self.seed(2, tenant_id="other-tenant")
|
||||||
|
self.session.commit()
|
||||||
|
with self.assertRaises(WorkflowNotFoundError):
|
||||||
|
self.history(instance_id="instance-00002", kind="events")
|
||||||
|
with self.assertRaises(WorkflowNotFoundError):
|
||||||
|
get_instance_summary(
|
||||||
|
self.session,
|
||||||
|
principal=self.actor,
|
||||||
|
registry=None,
|
||||||
|
instance_id="instance-00002",
|
||||||
|
)
|
||||||
|
self.actor = replace(
|
||||||
|
self.actor, principal=replace(self.actor.principal, scopes=frozenset())
|
||||||
|
)
|
||||||
|
with self.assertRaises(PermissionError):
|
||||||
|
self.summaries()
|
||||||
|
with self.assertRaises(PermissionError):
|
||||||
|
self.history(kind="steps")
|
||||||
|
|
||||||
|
def test_history_filters_child_tenant_and_empty_history(self):
|
||||||
|
self.seed(1, rows=0)
|
||||||
|
self.session.add(
|
||||||
|
WorkflowInstanceEvent(
|
||||||
|
tenant_id="other-tenant",
|
||||||
|
instance_id="instance-00001",
|
||||||
|
sequence=1,
|
||||||
|
kind="should-not-be-disclosed",
|
||||||
|
payload={},
|
||||||
|
created_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
empty = self.history(kind="events")
|
||||||
|
self.assertEqual(
|
||||||
|
(0, 0, None, []),
|
||||||
|
(
|
||||||
|
empty.total,
|
||||||
|
empty.through_sequence,
|
||||||
|
empty.next_after_sequence,
|
||||||
|
empty.events,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_inconsistent_revision_evidence_fails_closed(self):
|
||||||
|
instance = self.seed(1)
|
||||||
|
self.seed(2, tenant_id="other-tenant")
|
||||||
|
instance.definition_revision_id = "revision-2"
|
||||||
|
self.session.commit()
|
||||||
|
self.session.expunge_all()
|
||||||
|
with self.assertRaises(WorkflowNotFoundError):
|
||||||
|
self.summaries()
|
||||||
|
with self.assertRaises(WorkflowNotFoundError):
|
||||||
|
self.history(kind="events")
|
||||||
|
loaded = list_instances(self.session, tenant_id="tenant-1")
|
||||||
|
with self.assertRaises(WorkflowNotFoundError):
|
||||||
|
instance_response(self.session, loaded[0])
|
||||||
|
|
||||||
|
def test_work_item_total_remains_exact_after_authorization_and_filters(self):
|
||||||
|
for index in range(40):
|
||||||
|
self.seed(index, rows=1)
|
||||||
|
self.seed(50, scope_type="group", rows=1)
|
||||||
|
self.seed(51, tenant_id="other-tenant", rows=1)
|
||||||
|
self.seed(52, rows=1)
|
||||||
|
self.session.flush()
|
||||||
|
self.session.get(
|
||||||
|
WorkflowInstanceStep, "step-52-1"
|
||||||
|
).work_assignment_id = "someone-else"
|
||||||
|
self.session.commit()
|
||||||
|
self.session.expunge_all()
|
||||||
|
provider = WorkflowWorkItemProvider()
|
||||||
|
with (
|
||||||
|
self.statements() as queries,
|
||||||
|
patch.object(provider, "_targets", wraps=provider._targets) as targets,
|
||||||
|
):
|
||||||
|
page = provider.list_items(
|
||||||
|
self.session,
|
||||||
|
self.actor,
|
||||||
|
query=WorkItemQuery(tenant_id="tenant-1", limit=2),
|
||||||
|
)
|
||||||
|
self.assertEqual(1, targets.call_count)
|
||||||
|
self.assertEqual(1, len(queries))
|
||||||
|
self.assertEqual((40, 2, True), (page.total, len(page.items), page.truncated))
|
||||||
|
filtered = provider.list_items(
|
||||||
|
self.session,
|
||||||
|
self.actor,
|
||||||
|
query=WorkItemQuery(tenant_id="tenant-1", text="not present", limit=2),
|
||||||
|
)
|
||||||
|
self.assertEqual((0, False), (filtered.total, filtered.truncated))
|
||||||
|
|
||||||
|
def test_http_routes_validate_bounds_and_keep_legacy_contract(self):
|
||||||
|
self.seed(1)
|
||||||
|
self.session.commit()
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(router)
|
||||||
|
app.dependency_overrides[get_session] = lambda: self.session
|
||||||
|
app.dependency_overrides[get_api_principal] = lambda: self.actor
|
||||||
|
with (
|
||||||
|
TestClient(app) as client,
|
||||||
|
patch(
|
||||||
|
"govoplan_workflow_engine.backend.router.get_registry",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
prefix = router.prefix
|
||||||
|
summary = client.get(f"{prefix}/instances/summaries")
|
||||||
|
self.assertEqual(200, summary.status_code, summary.text)
|
||||||
|
self.assertNotIn("steps", summary.json()["instances"][0])
|
||||||
|
detail = client.get(f"{prefix}/instances/instance-00001/summary")
|
||||||
|
self.assertEqual(200, detail.status_code, detail.text)
|
||||||
|
legacy = client.get(f"{prefix}/instances/instance-00001")
|
||||||
|
self.assertEqual(200, legacy.status_code, legacy.text)
|
||||||
|
self.assertEqual(3, len(legacy.json()["events"]))
|
||||||
|
for kind in ("steps", "events"):
|
||||||
|
page = client.get(f"{prefix}/instances/instance-00001/{kind}?limit=2")
|
||||||
|
self.assertEqual(200, page.status_code, page.text)
|
||||||
|
self.assertEqual(3, page.json()["total"])
|
||||||
|
self.assertEqual(2, page.json()["next_after_sequence"])
|
||||||
|
self.assertEqual(
|
||||||
|
422,
|
||||||
|
client.get(
|
||||||
|
f"{prefix}/instances/instance-00001/{kind}?limit=201"
|
||||||
|
).status_code,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
422,
|
||||||
|
client.get(
|
||||||
|
f"{prefix}/instances/instance-00001/{kind}?after_sequence=-1"
|
||||||
|
).status_code,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
422,
|
||||||
|
client.get(
|
||||||
|
f"{prefix}/instances/instance-00001/{kind}?through_sequence=999999999999999999999"
|
||||||
|
).status_code,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
400,
|
||||||
|
client.get(f"{prefix}/instances/summaries?cursor=invalid").status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -43,6 +43,7 @@ from govoplan_core.core.runtime_coordination import (
|
|||||||
RuntimeIdentity,
|
RuntimeIdentity,
|
||||||
bind_process_runtime_identity,
|
bind_process_runtime_identity,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.tasks import WorkItemQuery
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_core.db.base import utcnow
|
from govoplan_core.db.base import utcnow
|
||||||
from govoplan_workflow_engine.backend.db.models import (
|
from govoplan_workflow_engine.backend.db.models import (
|
||||||
@@ -84,6 +85,7 @@ from govoplan_workflow_engine.backend.service import (
|
|||||||
create_definition,
|
create_definition,
|
||||||
)
|
)
|
||||||
from govoplan_workflow_engine.backend.service_launcher import WorkflowServiceLauncher
|
from govoplan_workflow_engine.backend.service_launcher import WorkflowServiceLauncher
|
||||||
|
from govoplan_workflow_engine.backend.work_items import WorkflowWorkItemProvider
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from test_bpmn import NATIVE_BPMN
|
from test_bpmn import NATIVE_BPMN
|
||||||
@@ -687,6 +689,107 @@ class WorkflowInstanceServiceTests(unittest.TestCase):
|
|||||||
start_origin="api",
|
start_origin="api",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_human_handoff_projects_typed_due_work_and_disappears_on_completion(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
definition = create_definition(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
actor_id="account-1",
|
||||||
|
payload=WorkflowDefinitionCreateRequest(
|
||||||
|
name="Guided case review",
|
||||||
|
graph=WorkflowGraph(
|
||||||
|
nodes=[
|
||||||
|
WorkflowNode(
|
||||||
|
id="start",
|
||||||
|
type="workflow.start.manual",
|
||||||
|
config={"input_schema_ref": ""},
|
||||||
|
),
|
||||||
|
WorkflowNode(
|
||||||
|
id="activity",
|
||||||
|
type="workflow.activity",
|
||||||
|
config={
|
||||||
|
"title": "Assess the application",
|
||||||
|
"instructions": "Record the assessment evidence.",
|
||||||
|
"assignee": "account:account-1",
|
||||||
|
"due_after": "2h",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
WorkflowNode(
|
||||||
|
id="done",
|
||||||
|
type="workflow.end.completed",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
edges=[
|
||||||
|
WorkflowEdge(
|
||||||
|
id="start-activity", source="start", target="activity"
|
||||||
|
),
|
||||||
|
WorkflowEdge(
|
||||||
|
id="activity-done", source="activity", target="done"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
execution_mode="guided",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
activate_definition(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
definition_id=definition.id,
|
||||||
|
actor_id="account-1",
|
||||||
|
)
|
||||||
|
before = utcnow()
|
||||||
|
instance, _replayed = start_instance(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
definition_id=definition.id,
|
||||||
|
actor_id="account-1",
|
||||||
|
principal=principal(),
|
||||||
|
registry=self.registry,
|
||||||
|
payload=WorkflowInstanceStartRequest(idempotency_key="guided-work-1"),
|
||||||
|
)
|
||||||
|
step = self.session.get(WorkflowInstanceStep, instance.current_step_id)
|
||||||
|
self.assertIsNotNone(step)
|
||||||
|
assert step is not None
|
||||||
|
self.assertEqual("account", step.work_assignment_kind)
|
||||||
|
self.assertEqual("account-1", step.work_assignment_id)
|
||||||
|
self.assertIsNotNone(step.work_due_at)
|
||||||
|
assert step.work_due_at is not None
|
||||||
|
due_at = (
|
||||||
|
step.work_due_at.replace(tzinfo=UTC)
|
||||||
|
if step.work_due_at.tzinfo is None
|
||||||
|
else step.work_due_at
|
||||||
|
)
|
||||||
|
self.assertGreaterEqual(due_at, before + timedelta(hours=1, minutes=59))
|
||||||
|
|
||||||
|
provider = WorkflowWorkItemProvider(registry=self.registry)
|
||||||
|
page = provider.list_items(
|
||||||
|
self.session,
|
||||||
|
principal(),
|
||||||
|
query=WorkItemQuery(tenant_id="tenant-1"),
|
||||||
|
)
|
||||||
|
self.assertEqual(1, page.total)
|
||||||
|
self.assertEqual("Assess the application", page.items[0].title)
|
||||||
|
self.assertEqual("account-1", page.items[0].assignments[0].id)
|
||||||
|
self.assertEqual(step.id, page.items[0].id)
|
||||||
|
|
||||||
|
resolve_step(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
instance_id=instance.id,
|
||||||
|
step_id=step.id,
|
||||||
|
actor_id="account-1",
|
||||||
|
principal=principal(),
|
||||||
|
registry=self.registry,
|
||||||
|
payload=WorkflowStepActionRequest(action="complete"),
|
||||||
|
)
|
||||||
|
closed_page = provider.list_items(
|
||||||
|
self.session,
|
||||||
|
principal(),
|
||||||
|
query=WorkItemQuery(tenant_id="tenant-1"),
|
||||||
|
)
|
||||||
|
self.assertEqual(0, closed_page.total)
|
||||||
|
|
||||||
def test_module_action_records_effects_and_completes_idempotently(
|
def test_module_action_records_effects_and_completes_idempotently(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -990,9 +1093,7 @@ class WorkflowInstanceServiceTests(unittest.TestCase):
|
|||||||
request=request,
|
request=request,
|
||||||
)
|
)
|
||||||
checkpoint = session.scalar(
|
checkpoint = session.scalar(
|
||||||
select(RecoveryCheckpoint).order_by(
|
select(RecoveryCheckpoint).order_by(RecoveryCheckpoint.sequence)
|
||||||
RecoveryCheckpoint.sequence
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
assert checkpoint is not None
|
assert checkpoint is not None
|
||||||
checkpoint.summary = "tampered provider evidence"
|
checkpoint.summary = "tampered provider evidence"
|
||||||
|
|||||||
@@ -17,6 +17,23 @@ from govoplan_core.core.workflows import (
|
|||||||
|
|
||||||
|
|
||||||
class WorkflowManifestTests(unittest.TestCase):
|
class WorkflowManifestTests(unittest.TestCase):
|
||||||
|
def test_all_static_topics_have_complete_german_content(self) -> None:
|
||||||
|
manifest = get_manifest()
|
||||||
|
for topic in manifest.documentation:
|
||||||
|
german = (topic.translations or {}).get("de", {})
|
||||||
|
self.assertEqual({"title", "summary", "body"}, set(german), topic.id)
|
||||||
|
self.assertTrue(
|
||||||
|
all(str(value).strip() for value in german.values()), topic.id
|
||||||
|
)
|
||||||
|
|
||||||
|
recovery = next(
|
||||||
|
topic
|
||||||
|
for topic in manifest.documentation
|
||||||
|
if topic.id == "workflow.runtime-recovery"
|
||||||
|
)
|
||||||
|
self.assertEqual("reference", recovery.metadata["kind"])
|
||||||
|
self.assertTrue(recovery.metadata["consequence_classes"])
|
||||||
|
|
||||||
def test_manifest_exposes_definition_contracts(self) -> None:
|
def test_manifest_exposes_definition_contracts(self) -> None:
|
||||||
manifest = get_manifest()
|
manifest = get_manifest()
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class WorkflowMigrationTests(unittest.TestCase):
|
|||||||
try:
|
try:
|
||||||
with engine.connect() as connection:
|
with engine.connect() as connection:
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"e4a1f8c2d7b6",
|
"9e6b3f8a2c7d",
|
||||||
set(MigrationContext.configure(connection).get_current_heads()),
|
set(MigrationContext.configure(connection).get_current_heads()),
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -75,6 +75,18 @@ class WorkflowMigrationTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
self.assertIn("start_origin", instance_columns)
|
self.assertIn("start_origin", instance_columns)
|
||||||
|
indexes = {
|
||||||
|
item["name"]: item["column_names"]
|
||||||
|
for item in inspect(connection).get_indexes("workflow_instances")
|
||||||
|
}
|
||||||
|
self.assertEqual(
|
||||||
|
["tenant_id", "created_at", "id"],
|
||||||
|
indexes["ix_workflow_instances_tenant_created_id"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
["tenant_id", "definition_id", "created_at", "id"],
|
||||||
|
indexes["ix_workflow_instances_tenant_definition_created_id"],
|
||||||
|
)
|
||||||
definition_columns = {
|
definition_columns = {
|
||||||
item["name"]
|
item["name"]
|
||||||
for item in inspect(connection).get_columns(
|
for item in inspect(connection).get_columns(
|
||||||
@@ -102,7 +114,13 @@ class WorkflowMigrationTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
for path in current_revisions.glob("*.py"):
|
for path in current_revisions.glob("*.py"):
|
||||||
if path.name.startswith(
|
if path.name.startswith(
|
||||||
("0b4e7c9a2d6f_", "b2e4f6a8c0d1_", "e4a1f8c2d7b6_")
|
(
|
||||||
|
"0b4e7c9a2d6f_",
|
||||||
|
"b2e4f6a8c0d1_",
|
||||||
|
"e4a1f8c2d7b6_",
|
||||||
|
"8d5a2f7c1b4e_",
|
||||||
|
"9e6b3f8a2c7d_",
|
||||||
|
)
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
shutil.copy2(path, legacy_revisions / path.name)
|
shutil.copy2(path, legacy_revisions / path.name)
|
||||||
@@ -146,7 +164,7 @@ class WorkflowMigrationTests(unittest.TestCase):
|
|||||||
manifest_factories=(get_manifest,),
|
manifest_factories=(get_manifest,),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertIn("e4a1f8c2d7b6", result.current_revision or "")
|
self.assertIn("9e6b3f8a2c7d", result.current_revision or "")
|
||||||
engine = create_engine(url)
|
engine = create_engine(url)
|
||||||
try:
|
try:
|
||||||
upgraded_tables = set(inspect(engine).get_table_names())
|
upgraded_tables = set(inspect(engine).get_table_names())
|
||||||
@@ -181,6 +199,27 @@ class WorkflowMigrationTests(unittest.TestCase):
|
|||||||
engine = create_engine(url)
|
engine = create_engine(url)
|
||||||
try:
|
try:
|
||||||
with engine.begin() as connection:
|
with engine.begin() as connection:
|
||||||
|
for index_name in (
|
||||||
|
"ix_workflow_instances_tenant_created_id",
|
||||||
|
"ix_workflow_instances_tenant_definition_created_id",
|
||||||
|
"ix_workflow_instance_steps_work_assignment",
|
||||||
|
"ix_workflow_instance_steps_work_due_at",
|
||||||
|
"ix_workflow_instance_steps_work_assignment_id",
|
||||||
|
"ix_workflow_instance_steps_work_assignment_kind",
|
||||||
|
):
|
||||||
|
connection.execute(text(f"DROP INDEX {index_name}"))
|
||||||
|
for column_name in (
|
||||||
|
"work_due_at",
|
||||||
|
"work_assignment_label",
|
||||||
|
"work_assignment_id",
|
||||||
|
"work_assignment_kind",
|
||||||
|
):
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE workflow_instance_steps "
|
||||||
|
f"DROP COLUMN {column_name}"
|
||||||
|
)
|
||||||
|
)
|
||||||
connection.execute(
|
connection.execute(
|
||||||
text(
|
text(
|
||||||
"ALTER TABLE workflow_definition_revisions "
|
"ALTER TABLE workflow_definition_revisions "
|
||||||
@@ -196,7 +235,7 @@ class WorkflowMigrationTests(unittest.TestCase):
|
|||||||
connection.execute(
|
connection.execute(
|
||||||
text(
|
text(
|
||||||
"UPDATE alembic_version SET version_num = "
|
"UPDATE alembic_version SET version_num = "
|
||||||
"'b2e4f6a8c0d1' WHERE version_num = 'e4a1f8c2d7b6'"
|
"'b2e4f6a8c0d1' WHERE version_num = '9e6b3f8a2c7d'"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
@@ -218,9 +257,23 @@ class WorkflowMigrationTests(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
self.assertIn("bpmn_runtime_kind", columns)
|
self.assertIn("bpmn_runtime_kind", columns)
|
||||||
self.assertIn("bpmn_executable", columns)
|
self.assertIn("bpmn_executable", columns)
|
||||||
|
step_columns = {
|
||||||
|
item["name"]
|
||||||
|
for item in inspect(engine).get_columns(
|
||||||
|
"workflow_instance_steps"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
self.assertTrue(
|
||||||
|
{
|
||||||
|
"work_assignment_kind",
|
||||||
|
"work_assignment_id",
|
||||||
|
"work_assignment_label",
|
||||||
|
"work_due_at",
|
||||||
|
}.issubset(step_columns)
|
||||||
|
)
|
||||||
with engine.connect() as connection:
|
with engine.connect() as connection:
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"e4a1f8c2d7b6",
|
"9e6b3f8a2c7d",
|
||||||
set(MigrationContext.configure(connection).get_current_heads()),
|
set(MigrationContext.configure(connection).get_current_heads()),
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
+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