Add durable workflow triggers and waits

This commit is contained in:
2026-08-01 20:57:26 +02:00
parent 55f98d1b65
commit a1cea1d162
12 changed files with 2213 additions and 275 deletions
@@ -382,9 +382,7 @@ class WorkflowInstance(Base, TimestampMixin):
index=True,
)
definition: Mapped[WorkflowDefinition] = relationship(
back_populates="instances"
)
definition: Mapped[WorkflowDefinition] = relationship(back_populates="instances")
steps: Mapped[list["WorkflowInstanceStep"]] = relationship(
back_populates="instance",
cascade="all, delete-orphan",
@@ -518,11 +516,185 @@ class WorkflowInstanceEvent(Base):
instance: Mapped[WorkflowInstance] = relationship(back_populates="events")
class WorkflowTrigger(Base, TimestampMixin):
__tablename__ = "workflow_triggers"
__table_args__ = (
UniqueConstraint(
"definition_id",
"node_id",
name="uq_workflow_trigger_definition_node",
),
Index("ix_workflow_triggers_due", "status", "next_fire_at"),
Index(
"ix_workflow_triggers_event",
"tenant_id",
"status",
"event_type",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
definition_id: Mapped[str] = mapped_column(
ForeignKey("workflow_definitions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
definition_revision_id: Mapped[str] = mapped_column(
ForeignKey("workflow_definition_revisions.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
node_id: Mapped[str] = mapped_column(String(120), nullable=False)
kind: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
status: Mapped[str] = mapped_column(
String(30), default="disabled", nullable=False, index=True
)
config_: Mapped[dict[str, Any]] = mapped_column(
"config", JSON, default=dict, nullable=False
)
event_type: Mapped[str | None] = mapped_column(
String(120), nullable=True, index=True
)
next_fire_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
last_fire_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
last_status: Mapped[str | None] = mapped_column(String(30), nullable=True)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
authorization_subject_kind: Mapped[str] = mapped_column(String(30), nullable=False)
authorization_account_id: Mapped[str | None] = mapped_column(
String(36), nullable=True
)
authorization_membership_id: Mapped[str | None] = mapped_column(
String(36), nullable=True
)
authorization_service_account_id: Mapped[str | None] = mapped_column(
String(36), nullable=True
)
authorization_ref: Mapped[str] = mapped_column(
String(255), nullable=False, unique=True
)
grant_scopes: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
created_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
updated_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
class WorkflowTriggerDelivery(Base, TimestampMixin):
__tablename__ = "workflow_trigger_deliveries"
__table_args__ = (
UniqueConstraint(
"trigger_id",
"source_key",
name="uq_workflow_trigger_delivery_source",
),
Index(
"ix_workflow_trigger_deliveries_queue",
"status",
"created_at",
),
Index(
"ix_workflow_trigger_deliveries_tenant_trigger",
"tenant_id",
"trigger_id",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
trigger_id: Mapped[str] = mapped_column(
ForeignKey("workflow_triggers.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
definition_id: Mapped[str] = mapped_column(
ForeignKey("workflow_definitions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
definition_revision_id: Mapped[str] = mapped_column(
ForeignKey("workflow_definition_revisions.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
source_key: Mapped[str] = mapped_column(String(255), nullable=False)
invocation_kind: Mapped[str] = mapped_column(String(30), nullable=False)
status: Mapped[str] = mapped_column(
String(30), default="queued", nullable=False, index=True
)
scheduled_for: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
event_: Mapped[dict[str, Any] | None] = mapped_column("event", JSON, nullable=True)
instance_id: Mapped[str | None] = mapped_column(
ForeignKey("workflow_instances.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
attempts: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
class WorkflowWaitState(Base, TimestampMixin):
__tablename__ = "workflow_wait_states"
__table_args__ = (
UniqueConstraint("step_id", name="uq_workflow_wait_state_step"),
Index("ix_workflow_wait_states_due", "status", "due_at"),
Index(
"ix_workflow_wait_states_event",
"tenant_id",
"status",
"event_type",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
instance_id: Mapped[str] = mapped_column(
ForeignKey("workflow_instances.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
step_id: Mapped[str] = mapped_column(
ForeignKey("workflow_instance_steps.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
mode: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
status: Mapped[str] = mapped_column(
String(30), default="waiting", nullable=False, index=True
)
due_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
event_type: Mapped[str | None] = mapped_column(
String(120), nullable=True, index=True
)
config_: Mapped[dict[str, Any]] = mapped_column(
"config", JSON, default=dict, nullable=False
)
source_event_id: Mapped[str | None] = mapped_column(
String(128), nullable=True, index=True
)
event_: Mapped[dict[str, Any] | None] = mapped_column("event", JSON, nullable=True)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
__all__ = [
"WorkflowDefinition",
"WorkflowDefinitionRevision",
"WorkflowInstance",
"WorkflowInstanceEvent",
"WorkflowInstanceStep",
"WorkflowTrigger",
"WorkflowTriggerDelivery",
"WorkflowWaitState",
"new_uuid",
]
@@ -29,6 +29,13 @@ from govoplan_core.core.notifications import (
NotificationDispatchRequest,
notification_dispatch_provider,
)
from govoplan_core.core.events import (
EventActorRef,
EventObjectRef,
EventTenantRef,
PlatformEvent,
emit_platform_event,
)
from govoplan_core.db.base import utcnow
from govoplan_workflow_engine.backend.db.models import (
WorkflowDefinition,
@@ -89,9 +96,7 @@ def list_instances(
.limit(max(1, min(int(limit), 200)))
)
if definition_id:
statement = statement.where(
WorkflowInstance.definition_id == definition_id
)
statement = statement.where(WorkflowInstance.definition_id == definition_id)
return list(session.scalars(statement))
@@ -160,9 +165,7 @@ def start_instance(
)
normalized_origin = _normalize_start_origin(start_origin)
if normalized_origin != "user" and not definition.allow_automation:
raise WorkflowConflictError(
"This Workflow does not allow automated starts."
)
raise WorkflowConflictError("This Workflow does not allow automated starts.")
if revision.execution_mode == "guided" and normalized_origin != "user":
raise WorkflowConflictError(
"Guided workflows must be started by a user; use hybrid mode "
@@ -189,8 +192,7 @@ def start_instance(
or existing.start_origin != normalized_origin
):
raise WorkflowConflictError(
"The Workflow idempotency key was already used with "
"different input."
"The Workflow idempotency key was already used with different input."
)
return get_instance(
session,
@@ -357,9 +359,7 @@ def reconcile_instance(
step.handoff = {
**dict(step.handoff),
"state": descriptor.status,
"progress_percent": int(
descriptor.metadata.get("progress_percent") or 0
),
"progress_percent": int(descriptor.metadata.get("progress_percent") or 0),
"progress_phase": str(
descriptor.metadata.get("progress_phase") or descriptor.status
),
@@ -434,14 +434,11 @@ def resolve_step(
instance.definition_revision_id,
)
if revision is None:
raise WorkflowConflictError(
"Pinned Workflow revision no longer exists."
)
raise WorkflowConflictError("Pinned Workflow revision no longer exists.")
graph = _runtime_graph(revision)
node = _node(graph, step.node_id)
allowed_actions = {
str(action)
for action in step.handoff.get("allowed_actions") or ()
str(action) for action in step.handoff.get("allowed_actions") or ()
}
if payload.action not in allowed_actions:
raise WorkflowConflictError(
@@ -525,6 +522,10 @@ def resolve_step(
"comment": payload.comment,
"evidence": list(payload.evidence),
}
if step.node_type == "workflow.wait":
from govoplan_workflow_engine.backend.triggers import resolve_wait_state
resolve_wait_state(session, step_id=step.id, status="resumed")
next_node_id = _complete_step(
session,
instance=instance,
@@ -562,28 +563,33 @@ def cancel_instance(
for_update=True,
)
if instance.status in {"completed", "failed", "cancelled"}:
raise WorkflowConflictError(
f"Workflow instance is already {instance.status}."
)
raise WorkflowConflictError(f"Workflow instance is already {instance.status}.")
now = utcnow()
instance.cancellation_requested_at = now
step = _current_step(session, instance)
if step is not None and step.external_ref:
provider = dataflow_run_lifecycle(registry)
if provider is not None:
try:
provider.cancel_run(
session,
principal,
run_ref=step.external_ref,
)
except ValueError as exc:
logger.info(
"Linked Dataflow run could not be cancelled for "
"Workflow instance %s: %s",
instance.id,
exc,
)
if step is not None:
if step.external_ref:
provider = dataflow_run_lifecycle(registry)
if provider is not None:
try:
provider.cancel_run(
session,
principal,
run_ref=step.external_ref,
)
except ValueError as exc:
logger.info(
"Linked Dataflow run could not be cancelled for "
"Workflow instance %s: %s",
instance.id,
exc,
)
if step.node_type == "workflow.wait":
from govoplan_workflow_engine.backend.triggers import (
resolve_wait_state,
)
resolve_wait_state(session, step_id=step.id, status="cancelled")
step.status = "cancelled"
step.finished_at = now
step.completed_by = actor_id
@@ -805,7 +811,6 @@ def _drive_instance(
if node.type in {
"workflow.activity",
"workflow.review",
"workflow.wait",
}:
_set_human_handoff(
session,
@@ -815,6 +820,34 @@ def _drive_instance(
registry=registry,
)
return
if node.type == "workflow.wait":
from govoplan_workflow_engine.backend.triggers import (
register_wait_state,
)
wait_state = register_wait_state(
session,
instance=instance,
step=step,
node=node,
)
if wait_state is None:
_set_human_handoff(
session,
instance=instance,
step=step,
node=node,
registry=registry,
)
else:
_set_automated_wait(
session,
instance=instance,
step=step,
node=node,
wait_state=wait_state,
)
return
if node.type == "workflow.end.completed":
_complete_step(
session,
@@ -1096,9 +1129,7 @@ def _execute_capability_step(
"manual_required",
"compensation_required",
}
announced_effects = {
item.effect_key for item in provider.effect_definitions()
}
announced_effects = {item.effect_key for item in provider.effect_definitions()}
unknown_effects = sorted(
{
effect.effect_key
@@ -1210,25 +1241,19 @@ def _capability_action_definition(
f"Action capability {capability_name!r} is not available."
)
definitions = [
item
for item in provider.action_definitions()
if item.action_key == action_key
item for item in provider.action_definitions() if item.action_key == action_key
]
if len(definitions) != 1:
raise WorkflowConflictError(
f"Action {action_key!r} is not uniquely announced by "
f"{capability_name!r}."
f"Action {action_key!r} is not uniquely announced by {capability_name!r}."
)
definition = definitions[0]
missing_scopes = [
scope
for scope in definition.required_scopes
if not has_scope(principal, scope)
scope for scope in definition.required_scopes if not has_scope(principal, scope)
]
if missing_scopes:
raise WorkflowConflictError(
"Module action requires scopes: "
+ ", ".join(sorted(missing_scopes))
"Module action requires scopes: " + ", ".join(sorted(missing_scopes))
)
missing_capabilities = [
capability
@@ -1244,12 +1269,8 @@ def _capability_action_definition(
"Module action requires capabilities: "
+ ", ".join(sorted(missing_capabilities))
)
effect_keys = {
item.effect_key for item in provider.effect_definitions()
}
missing_effects = sorted(
set(definition.expected_effect_keys) - effect_keys
)
effect_keys = {item.effect_key for item in provider.effect_definitions()}
missing_effects = sorted(set(definition.expected_effect_keys) - effect_keys)
if missing_effects:
raise WorkflowConflictError(
"Action provider does not define its expected effects: "
@@ -1265,9 +1286,7 @@ def _mapped_action_input(
if raw_mapping is None or raw_mapping == "":
return dict(context)
if not isinstance(raw_mapping, Mapping):
raise WorkflowConflictError(
"Module-action input mapping must be an object."
)
raise WorkflowConflictError("Module-action input mapping must be an object.")
return {
str(key): _resolve_action_value(value, context, depth=0)
for key, value in raw_mapping.items()
@@ -1282,9 +1301,7 @@ def _resolve_action_value(
depth: int,
) -> object:
if depth > 10:
raise WorkflowConflictError(
"Module-action input mapping is nested too deeply."
)
raise WorkflowConflictError("Module-action input mapping is nested too deeply.")
if isinstance(value, str) and value.startswith("$"):
path = value[1:].lstrip(".")
current: object = context
@@ -1307,10 +1324,7 @@ def _resolve_action_value(
for key, nested in value.items()
}
if isinstance(value, list):
return [
_resolve_action_value(item, context, depth=depth + 1)
for item in value
]
return [_resolve_action_value(item, context, depth=depth + 1) for item in value]
return value
@@ -1322,9 +1336,7 @@ def _action_idempotency_key(
action_key: str,
context: Mapping[str, object],
) -> str:
expression = str(
node.config.get("idempotency_key") or "workflow-step"
).strip()
expression = str(node.config.get("idempotency_key") or "workflow-step").strip()
if expression == "workflow-step":
return step.idempotency_key
resolved = _resolve_action_value(expression, context, depth=0)
@@ -1345,8 +1357,7 @@ def _action_preview_payload(preview: object) -> dict[str, object]:
"preview_ref": getattr(preview, "preview_ref", None),
"blockers": list(getattr(preview, "blockers", ()) or ()),
"policy_provenance": [
dict(item)
for item in getattr(preview, "policy_provenance", ()) or ()
dict(item) for item in getattr(preview, "policy_provenance", ()) or ()
],
"effects": [
{
@@ -1380,9 +1391,7 @@ def _action_result_payload(
],
"error": result.error,
"retry_after": (
result.retry_after.isoformat()
if result.retry_after is not None
else None
result.retry_after.isoformat() if result.retry_after is not None else None
),
"manual_instructions": result.manual_instructions,
"compensation_action_key": result.compensation_action_key,
@@ -1403,9 +1412,7 @@ def _set_action_handoff(
details: Mapping[str, object] | None = None,
) -> None:
allowed_actions = (
["cancel"]
if state in {"pending", "running"}
else ["retry", "reject", "cancel"]
["cancel"] if state in {"pending", "running"} else ["retry", "reject", "cancel"]
)
previous = dict(step.handoff)
step.status = "waiting"
@@ -1422,10 +1429,7 @@ def _set_action_handoff(
}
instance.status = "waiting"
instance.error = step.error
if (
previous.get("state") != state
or previous.get("message") != message
):
if previous.get("state") != state or previous.get("message") != message:
_record_event(
session,
instance,
@@ -1497,9 +1501,7 @@ def _start_dataflow_step(
message="Dataflow steps require a pipeline and pinned revision.",
)
return
target_ref = str(
node.config.get("publication_target_ref") or ""
).strip()
target_ref = str(node.config.get("publication_target_ref") or "").strip()
try:
run = provider.start_run(
session,
@@ -1509,13 +1511,9 @@ def _start_dataflow_step(
revision=revision,
idempotency_key=step.idempotency_key,
row_limit=row_limit,
environment=str(
node.config.get("environment") or "development"
),
environment=str(node.config.get("environment") or "development"),
publication=(
DataflowPublicationTarget(
target_datasource_ref=target_ref
)
DataflowPublicationTarget(target_datasource_ref=target_ref)
if target_ref
else None
),
@@ -1525,9 +1523,7 @@ def _start_dataflow_step(
causation_id=f"workflow-step:{step.id}",
requested_by=instance.created_by,
metadata={
"workflow_instance_ref": (
f"workflow-instance:{instance.id}"
),
"workflow_instance_ref": (f"workflow-instance:{instance.id}"),
"workflow_step_ref": f"workflow-step:{step.id}",
},
),
@@ -1557,12 +1553,8 @@ def _start_dataflow_step(
"pipeline_revision": revision,
"action_url": _dataflow_action_url(pipeline_ref, run.ref),
"allowed_actions": ["cancel"],
"progress_percent": int(
run.metadata.get("progress_percent") or 0
),
"progress_phase": str(
run.metadata.get("progress_phase") or run.status
),
"progress_percent": int(run.metadata.get("progress_percent") or 0),
"progress_phase": str(run.metadata.get("progress_phase") or run.status),
}
instance.status = "waiting"
_record_event(
@@ -1592,11 +1584,11 @@ def _handle_dataflow_success(
warnings = [
dict(item)
for item in items
if isinstance(item, Mapping)
and str(item.get("severity") or "") == "warning"
if isinstance(item, Mapping) and str(item.get("severity") or "") == "warning"
]
explicit_review = any(
str(item.get("code") or "") in {
str(item.get("code") or "")
in {
"review.required",
"reconciliation.review_required",
}
@@ -1606,8 +1598,7 @@ def _handle_dataflow_success(
output = _dataflow_output(descriptor)
step.output_ = output
if explicit_review or (
warnings
and str(node.config.get("warning_policy") or "review") == "review"
warnings and str(node.config.get("warning_policy") or "review") == "review"
):
step.status = "waiting"
step.handoff = {
@@ -1626,9 +1617,7 @@ def _handle_dataflow_success(
"retry",
"cancel",
],
"suggested_port": (
"review_required" if explicit_review else "warning"
),
"suggested_port": ("review_required" if explicit_review else "warning"),
"warnings": warnings,
"output": output,
}
@@ -1729,11 +1718,8 @@ def _set_human_handoff(
"state": "waiting",
"title": str(node.config.get("title") or node.label or node.type),
"instructions": str(node.config.get("instructions") or ""),
"assignee": node.config.get("reviewer")
or node.config.get("assignee"),
"required_evidence": list(
node.config.get("required_evidence") or []
),
"assignee": node.config.get("reviewer") or node.config.get("assignee"),
"required_evidence": list(node.config.get("required_evidence") or []),
"allowed_actions": actions,
}
instance.status = "waiting"
@@ -1754,6 +1740,38 @@ def _set_human_handoff(
)
def _set_automated_wait(
session: Session,
*,
instance: WorkflowInstance,
step: WorkflowInstanceStep,
node: WorkflowNode,
wait_state: object,
) -> None:
mode = str(getattr(wait_state, "mode"))
due_at = getattr(wait_state, "due_at")
event_type = getattr(wait_state, "event_type")
step.status = "waiting"
step.handoff = {
"kind": "event_wait" if mode == "event" else "timer",
"state": "waiting",
"title": str(node.config.get("title") or node.label or "Wait"),
"mode": mode,
"due_at": due_at.isoformat() if due_at else None,
"event_type": event_type,
"allowed_actions": ["cancel"],
}
instance.status = "waiting"
_record_event(
session,
instance,
step=step,
kind="workflow.wait.registered",
actor_id=instance.created_by,
payload=dict(step.handoff),
)
def _set_dependency_handoff(
session: Session,
*,
@@ -1913,25 +1931,31 @@ def _new_step(
instance: WorkflowInstance,
node: WorkflowNode,
) -> WorkflowInstanceStep:
sequence = int(
session.scalar(
select(func.max(WorkflowInstanceStep.sequence)).where(
WorkflowInstanceStep.instance_id == instance.id
sequence = (
int(
session.scalar(
select(func.max(WorkflowInstanceStep.sequence)).where(
WorkflowInstanceStep.instance_id == instance.id
)
)
or 0
)
or 0
) + 1
attempt = int(
session.scalar(
select(func.count())
.select_from(WorkflowInstanceStep)
.where(
WorkflowInstanceStep.instance_id == instance.id,
WorkflowInstanceStep.node_id == node.id,
+ 1
)
attempt = (
int(
session.scalar(
select(func.count())
.select_from(WorkflowInstanceStep)
.where(
WorkflowInstanceStep.instance_id == instance.id,
WorkflowInstanceStep.node_id == node.id,
)
)
or 0
)
or 0
) + 1
+ 1
)
step = WorkflowInstanceStep(
tenant_id=instance.tenant_id,
instance=instance,
@@ -1940,9 +1964,7 @@ def _new_step(
node_type=node.type,
status="running",
attempt=attempt,
idempotency_key=(
f"workflow:{instance.id}:node:{node.id}:attempt:{attempt}"
),
idempotency_key=(f"workflow:{instance.id}:node:{node.id}:attempt:{attempt}"),
input_=dict(instance.context_),
output_={},
handoff={},
@@ -1962,14 +1984,17 @@ def _record_event(
payload: Mapping[str, object],
step: WorkflowInstanceStep | None = None,
) -> None:
sequence = int(
session.scalar(
select(func.max(WorkflowInstanceEvent.sequence)).where(
WorkflowInstanceEvent.instance_id == instance.id
sequence = (
int(
session.scalar(
select(func.max(WorkflowInstanceEvent.sequence)).where(
WorkflowInstanceEvent.instance_id == instance.id
)
)
or 0
)
or 0
) + 1
+ 1
)
event = WorkflowInstanceEvent(
tenant_id=instance.tenant_id,
instance=instance,
@@ -1982,6 +2007,46 @@ def _record_event(
)
session.add(event)
session.flush()
if kind.startswith("workflow.instance."):
from govoplan_workflow_engine.backend.runtime import get_registry
emit_platform_event(
session,
PlatformEvent(
type=kind,
module_id="workflow_engine",
event_id=event.id,
occurred_at=event.created_at,
correlation_id=instance.correlation_id,
causation_id=(
str(payload.get("event_id")) if payload.get("event_id") else None
),
actor=(
EventActorRef(type="account", id=actor_id)
if actor_id
else EventActorRef(type="system_actor")
),
tenant=EventTenantRef(id=instance.tenant_id),
subject=EventObjectRef(
type="workflow_instance",
id=instance.id,
),
resource=EventObjectRef(
type="workflow_definition",
id=instance.definition_id,
),
classification="internal",
payload={
"instance_id": instance.id,
"definition_id": instance.definition_id,
"definition_revision_id": instance.definition_revision_id,
"step_id": step.id if step else None,
"status": instance.status,
"start_origin": instance.start_origin,
},
),
registry=get_registry(),
)
def _current_step(
@@ -1998,15 +2063,12 @@ def _start_node(graph: WorkflowGraph, *, kind: str) -> WorkflowNode:
node = next((item for item in graph.nodes if item.type == expected), None)
if node is None:
starts = [
item for item in graph.nodes
if item.type.startswith("workflow.start.")
item for item in graph.nodes if item.type.startswith("workflow.start.")
]
if len(starts) == 1:
node = starts[0]
if node is None:
raise WorkflowConflictError(
f"Workflow has no {kind} start node."
)
raise WorkflowConflictError(f"Workflow has no {kind} start node.")
return node
@@ -2024,9 +2086,7 @@ def _normalize_start_origin(value: str) -> str:
"backfill",
}
if normalized not in allowed:
raise WorkflowConflictError(
f"Unsupported Workflow start origin {value!r}."
)
raise WorkflowConflictError(f"Unsupported Workflow start origin {value!r}.")
return normalized
@@ -2048,17 +2108,10 @@ def _instance_view_context(
instance: WorkflowInstance,
revision: WorkflowDefinitionRevision,
) -> WorkflowViewContextResponse | None:
if (
not revision.view_id
or instance.status not in {"running", "waiting"}
):
if not revision.view_id or instance.status not in {"running", "waiting"}:
return None
step = next(
(
item
for item in instance.steps
if item.id == instance.current_step_id
),
(item for item in instance.steps if item.id == instance.current_step_id),
None,
)
node = None
@@ -2089,17 +2142,13 @@ def _instance_view_context(
def _node(graph: WorkflowGraph, node_id: str) -> WorkflowNode:
node = next((item for item in graph.nodes if item.id == node_id), None)
if node is None:
raise WorkflowConflictError(
f"Workflow node {node_id!r} no longer exists."
)
raise WorkflowConflictError(f"Workflow node {node_id!r} no longer exists.")
return node
def _runtime_graph(revision: WorkflowDefinitionRevision) -> WorkflowGraph:
try:
return materialize_runtime_graph(
WorkflowGraph.model_validate(revision.graph)
)
return materialize_runtime_graph(WorkflowGraph.model_validate(revision.graph))
except BpmnGraphError as exc:
raise WorkflowConflictError(str(exc)) from exc
@@ -2157,9 +2206,7 @@ def _dataflow_output(
"output_materialization_ref": descriptor.output_materialization_ref,
"input_row_count": descriptor.input_row_count,
"output_row_count": descriptor.output_row_count,
"diagnostics": list(
descriptor.metadata.get("diagnostics") or []
),
"diagnostics": list(descriptor.metadata.get("diagnostics") or []),
}
@@ -2199,6 +2246,32 @@ def _authorization_payload(
registry: object | None,
) -> dict[str, object]:
principal_ref = principal.to_platform_principal()
scopes = required_instance_scopes(
graph,
principal=principal,
registry=registry,
)
return {
"contract_version": "1",
"subject_kind": (
"service_account" if principal_ref.service_account_id else "delegated_user"
),
"account_id": principal_ref.account_id,
"membership_id": principal_ref.membership_id,
"service_account_id": principal_ref.service_account_id,
"grant_scopes": list(scopes),
"authorization_ref": None,
}
def required_instance_scopes(
graph: WorkflowGraph,
*,
principal: ApiPrincipal,
registry: object | None,
) -> tuple[str, ...]:
"""Return scopes pinned into an instance or trigger authorization artifact."""
scopes = {INSTANCE_START_SCOPE}
if any(node.type == "workflow.dataflow" for node in graph.nodes):
scopes.add(DATAFLOW_RUN_SCOPE)
@@ -2211,19 +2284,7 @@ def _authorization_payload(
registry=registry,
)
scopes.update(definition.required_scopes)
return {
"contract_version": "1",
"subject_kind": (
"service_account"
if principal_ref.service_account_id
else "delegated_user"
),
"account_id": principal_ref.account_id,
"membership_id": principal_ref.membership_id,
"service_account_id": principal_ref.service_account_id,
"grant_scopes": sorted(scopes),
"authorization_ref": None,
}
return tuple(sorted(scopes))
def _resolve_instance_principal(
@@ -2239,25 +2300,18 @@ def _resolve_instance_principal(
common = {
"tenant_id": instance.tenant_id,
"authorization_ref": str(
value.get("authorization_ref")
or f"workflow-instance:{instance.id}"
),
"grant_scopes": tuple(
str(scope) for scope in value.get("grant_scopes") or ()
value.get("authorization_ref") or f"workflow-instance:{instance.id}"
),
"grant_scopes": tuple(str(scope) for scope in value.get("grant_scopes") or ()),
"context": {
"workflow_instance_ref": f"workflow-instance:{instance.id}",
"definition_ref": (
f"workflow-definition:{instance.definition_id}"
),
"definition_ref": (f"workflow-definition:{instance.definition_id}"),
},
}
try:
if value.get("subject_kind") == "service_account":
request = AutomationPrincipalRequest.service_account(
service_account_id=str(
value.get("service_account_id") or ""
),
service_account_id=str(value.get("service_account_id") or ""),
**common,
)
else:
@@ -2287,8 +2341,7 @@ def _resolve_instance_principal(
}
return (
resolution.principal
if resolution.allowed
and isinstance(resolution.principal, ApiPrincipal)
if resolution.allowed and isinstance(resolution.principal, ApiPrincipal)
else None
)
@@ -2302,9 +2355,7 @@ def _notify_handoff(
subject: str,
) -> None:
provider = notification_dispatch_provider(registry)
account_id = str(
instance.authorization_.get("account_id") or ""
).strip()
account_id = str(instance.authorization_.get("account_id") or "").strip()
if provider is None or not account_id:
return
try:
@@ -2320,9 +2371,7 @@ def _notify_handoff(
recipient_id=account_id,
subject=subject,
action_url=(
"/workflow?"
f"definition={instance.definition_id}"
f"&run={instance.id}"
f"/workflow?definition={instance.definition_id}&run={instance.id}"
),
payload={
"instance_id": instance.id,
@@ -2351,7 +2400,6 @@ class SqlWorkflowRuntimeWorker:
now: datetime | None = None,
limit: int = 50,
) -> Mapping[str, object]:
del now
if not isinstance(session, Session):
raise TypeError("Workflow reconciliation requires a Session.")
standards: Mapping[str, object] | None = None
@@ -2370,8 +2418,17 @@ class SqlWorkflowRuntimeWorker:
registry=self._registry,
limit=limit,
)
from govoplan_workflow_engine.backend.triggers import dispatch_due_work
triggers = dispatch_due_work(
session,
registry=self._registry,
now=now,
limit=limit,
)
return {
**runtime,
"triggers": triggers,
**({"standards": standards} if standards is not None else {}),
}
@@ -2384,6 +2441,7 @@ __all__ = [
"list_instances",
"reconcile_instance",
"reconcile_pending_instances",
"required_instance_scopes",
"resolve_step",
"start_instance",
]
@@ -37,6 +37,7 @@ from govoplan_core.core.workflows import (
CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS,
CAPABILITY_WORKFLOW_ORCHESTRATION,
CAPABILITY_WORKFLOW_RUNTIME_WORKER,
CAPABILITY_WORKFLOW_TRIGGER_DISPATCHER,
)
from govoplan_core.db.base import Base
from govoplan_workflow_engine.backend.db import models as workflow_models
@@ -113,7 +114,11 @@ ROLE_TEMPLATES = (
slug="workflow_designer",
name="Workflow designer",
description="Design, validate, and publish workflow definitions.",
permissions=(DEFINITION_READ_SCOPE, DEFINITION_WRITE_SCOPE, INSTANCE_READ_SCOPE),
permissions=(
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
INSTANCE_READ_SCOPE,
),
),
RoleTemplate(
slug="workflow_operator",
@@ -146,6 +151,14 @@ def _runtime_worker(context: ModuleContext):
return SqlWorkflowRuntimeWorker(registry=context.registry)
def _trigger_dispatcher(context: ModuleContext):
from govoplan_workflow_engine.backend.triggers import (
SqlWorkflowTriggerDispatcher,
)
return SqlWorkflowTriggerDispatcher(registry=context.registry)
def _definition_contribution_provider(context: ModuleContext):
from govoplan_workflow_engine.backend.contributions import (
SqlWorkflowDefinitionContributionProvider,
@@ -218,6 +231,10 @@ manifest = ModuleManifest(
version="1.0.0",
),
ModuleInterfaceProvider(name="workflow.runtime_worker", version=MODULE_VERSION),
ModuleInterfaceProvider(
name="workflow.trigger_dispatcher",
version="1.0.0",
),
ModuleInterfaceProvider(name="workflow.bpmn_interchange", version="1.0.0"),
ModuleInterfaceProvider(
name=CAPABILITY_WORKFLOW_SERVICE_LAUNCHER,
@@ -274,6 +291,7 @@ manifest = ModuleManifest(
_definition_contribution_provider
),
CAPABILITY_WORKFLOW_RUNTIME_WORKER: _runtime_worker,
CAPABILITY_WORKFLOW_TRIGGER_DISPATCHER: _trigger_dispatcher,
CAPABILITY_WORKFLOW_ORCHESTRATION: _orchestration_provider,
WORKFLOW_CONFIGURATION_CAPABILITY: _configuration_provider,
CAPABILITY_WORKFLOW_SERVICE_LAUNCHER: _service_launcher,
@@ -291,6 +309,9 @@ manifest = ModuleManifest(
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
workflow_models.WorkflowWaitState,
workflow_models.WorkflowTriggerDelivery,
workflow_models.WorkflowTrigger,
workflow_models.WorkflowInstanceEvent,
workflow_models.WorkflowInstanceStep,
workflow_models.WorkflowInstance,
@@ -310,6 +331,9 @@ manifest = ModuleManifest(
workflow_models.WorkflowInstance,
workflow_models.WorkflowInstanceStep,
workflow_models.WorkflowInstanceEvent,
workflow_models.WorkflowTrigger,
workflow_models.WorkflowTriggerDelivery,
workflow_models.WorkflowWaitState,
label="Workflow",
),
),
@@ -331,7 +355,13 @@ manifest = ModuleManifest(
layer="available",
documentation_types=("admin", "user"),
audience=("operator", "module_admin", "power_user", "product_owner"),
related_modules=("dataflow", "datasources", "tasks", "notifications", "audit"),
related_modules=(
"dataflow",
"datasources",
"tasks",
"notifications",
"audit",
),
order=76,
),
DocumentationTopic(
@@ -368,9 +398,23 @@ manifest = ModuleManifest(
maturity="vertical_slice",
documentation_ref="docs/ENGINE_EDITOR_SPLIT.md",
test_ref="tests/test_instance_service.py",
known_limits=("Execution adapters support declared conformance profiles but do not cover every editable BPMN semantic.",),
owned_concepts=("workflow definition", "workflow revision", "workflow instance", "work transition", "execution adapter binding"),
non_owned_concepts=("visual editor", "domain action", "notification", "dataflow run"),
known_limits=(
"Execution adapters support declared conformance profiles but do not cover every editable BPMN semantic.",
"Native schedules intentionally support one-time and bounded interval starts; cron requires a future governed scheduler adapter.",
),
owned_concepts=(
"workflow definition",
"workflow revision",
"workflow instance",
"work transition",
"execution adapter binding",
),
non_owned_concepts=(
"visual editor",
"domain action",
"notification",
"dataflow run",
),
recovery_docs=("docs/CONCEPT.md",),
security_docs=("docs/CONCEPT.md",),
operations_docs=("README.md",),
@@ -0,0 +1,211 @@
"""v0.1.14 durable Workflow triggers and wait states
Revision ID: b2e4f6a8c0d1
Revises: 0b4e7c9a2d6f
Create Date: 2026-08-01 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "b2e4f6a8c0d1"
down_revision = "0b4e7c9a2d6f"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"workflow_triggers",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("tenant_id", sa.String(36), nullable=False),
sa.Column("definition_id", sa.String(36), nullable=False),
sa.Column("definition_revision_id", sa.String(36), nullable=False),
sa.Column("node_id", sa.String(120), nullable=False),
sa.Column("kind", sa.String(30), nullable=False),
sa.Column("status", sa.String(30), nullable=False),
sa.Column("config", sa.JSON(), nullable=False),
sa.Column("event_type", sa.String(120), nullable=True),
sa.Column("next_fire_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_fire_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_status", sa.String(30), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("authorization_subject_kind", sa.String(30), nullable=False),
sa.Column("authorization_account_id", sa.String(36), nullable=True),
sa.Column("authorization_membership_id", sa.String(36), nullable=True),
sa.Column("authorization_service_account_id", sa.String(36), nullable=True),
sa.Column("authorization_ref", sa.String(255), nullable=False),
sa.Column("grant_scopes", sa.JSON(), nullable=False),
sa.Column("created_by", sa.String(255), nullable=True),
sa.Column("updated_by", sa.String(255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["definition_id"],
["workflow_definitions.id"],
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["definition_revision_id"],
["workflow_definition_revisions.id"],
ondelete="RESTRICT",
),
sa.UniqueConstraint(
"definition_id",
"node_id",
name="uq_workflow_trigger_definition_node",
),
sa.UniqueConstraint(
"authorization_ref",
name="uq_workflow_triggers_authorization_ref",
),
)
op.create_index(
"ix_workflow_triggers_due",
"workflow_triggers",
["status", "next_fire_at"],
)
op.create_index(
"ix_workflow_triggers_event",
"workflow_triggers",
["tenant_id", "status", "event_type"],
)
for name in (
"tenant_id",
"definition_id",
"definition_revision_id",
"kind",
"status",
"event_type",
"next_fire_at",
"created_by",
"updated_by",
):
op.create_index(
f"ix_workflow_triggers_{name}",
"workflow_triggers",
[name],
)
op.create_table(
"workflow_trigger_deliveries",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("tenant_id", sa.String(36), nullable=False),
sa.Column("trigger_id", sa.String(36), nullable=False),
sa.Column("definition_id", sa.String(36), nullable=False),
sa.Column("definition_revision_id", sa.String(36), nullable=False),
sa.Column("source_key", sa.String(255), nullable=False),
sa.Column("invocation_kind", sa.String(30), nullable=False),
sa.Column("status", sa.String(30), nullable=False),
sa.Column("scheduled_for", sa.DateTime(timezone=True), nullable=True),
sa.Column("event", sa.JSON(), nullable=True),
sa.Column("instance_id", sa.String(36), nullable=True),
sa.Column("attempts", sa.Integer(), nullable=False),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["trigger_id"], ["workflow_triggers.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["definition_id"],
["workflow_definitions.id"],
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["definition_revision_id"],
["workflow_definition_revisions.id"],
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["instance_id"], ["workflow_instances.id"], ondelete="SET NULL"
),
sa.UniqueConstraint(
"trigger_id",
"source_key",
name="uq_workflow_trigger_delivery_source",
),
)
op.create_index(
"ix_workflow_trigger_deliveries_queue",
"workflow_trigger_deliveries",
["status", "created_at"],
)
op.create_index(
"ix_workflow_trigger_deliveries_tenant_trigger",
"workflow_trigger_deliveries",
["tenant_id", "trigger_id"],
)
for name in (
"tenant_id",
"trigger_id",
"definition_id",
"definition_revision_id",
"status",
"instance_id",
):
op.create_index(
f"ix_workflow_trigger_deliveries_{name}",
"workflow_trigger_deliveries",
[name],
)
op.create_table(
"workflow_wait_states",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("tenant_id", sa.String(36), nullable=False),
sa.Column("instance_id", sa.String(36), nullable=False),
sa.Column("step_id", sa.String(36), nullable=False),
sa.Column("mode", sa.String(30), nullable=False),
sa.Column("status", sa.String(30), nullable=False),
sa.Column("due_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("event_type", sa.String(120), nullable=True),
sa.Column("config", sa.JSON(), nullable=False),
sa.Column("source_event_id", sa.String(128), nullable=True),
sa.Column("event", sa.JSON(), nullable=True),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["instance_id"], ["workflow_instances.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["step_id"], ["workflow_instance_steps.id"], ondelete="CASCADE"
),
sa.UniqueConstraint("step_id", name="uq_workflow_wait_state_step"),
)
op.create_index(
"ix_workflow_wait_states_due",
"workflow_wait_states",
["status", "due_at"],
)
op.create_index(
"ix_workflow_wait_states_event",
"workflow_wait_states",
["tenant_id", "status", "event_type"],
)
for name in (
"tenant_id",
"instance_id",
"step_id",
"mode",
"status",
"due_at",
"event_type",
"source_event_id",
):
op.create_index(
f"ix_workflow_wait_states_{name}",
"workflow_wait_states",
[name],
)
def downgrade() -> None:
op.drop_table("workflow_wait_states")
op.drop_table("workflow_trigger_deliveries")
op.drop_table("workflow_triggers")
+98 -43
View File
@@ -90,6 +90,8 @@ from govoplan_workflow_engine.backend.schemas import (
WorkflowPortResponse,
WorkflowStandardDiffResponse,
WorkflowStepActionRequest,
WorkflowTriggerListResponse,
WorkflowTriggerResponse,
)
from govoplan_workflow_engine.backend.instance_service import (
cancel_instance,
@@ -122,6 +124,11 @@ from govoplan_workflow_engine.backend.service import (
update_definition,
)
from govoplan_workflow_engine.backend.validation import validate_workflow_graph
from govoplan_workflow_engine.backend.triggers import (
disable_definition_triggers,
list_definition_triggers,
reconcile_definition_triggers,
)
router = APIRouter(prefix="/workflow", tags=["workflow"])
@@ -303,8 +310,7 @@ def _bpmn_inspection_response(
)
)
deduplicated = {
(item.code, item.element_id, item.message): item
for item in diagnostics
(item.code, item.element_id, item.message): item for item in diagnostics
}.values()
diagnostic_items = list(deduplicated)
return BpmnInspectionResponse(
@@ -338,9 +344,7 @@ def _bpmn_inspection_response(
for item in diagnostic_items
],
adapter_id=adapter.profile.id if adapter else adapter_id,
adapter_version=(
adapter.profile.version if adapter else adapter_version
),
adapter_version=(adapter.profile.version if adapter else adapter_version),
runtime_kind=adapter.profile.runtime_kind if adapter else None,
executable=bool(adapter and adapter.profile.executable),
activatable=bool(
@@ -514,7 +518,9 @@ def api_node_types(
WorkflowNodeTypeResponse(
type=definition.type,
category=definition.category,
category_label=WORKFLOW_GRAPH_LIBRARY.category_labels[definition.category],
category_label=WORKFLOW_GRAPH_LIBRARY.category_labels[
definition.category
],
label=definition.label,
description=definition.description,
icon=definition.icon,
@@ -687,10 +693,7 @@ def api_list_instances(
).allowed
]
return WorkflowInstanceListResponse(
instances=[
instance_response(session, instance)
for instance in instances
]
instances=[instance_response(session, instance) for instance in instances]
)
except WorkflowError as exc:
raise _http_error(exc) from exc
@@ -715,11 +718,7 @@ def api_reconcile_standards(
action="workflow.standards.reconciled",
object_type="workflow_standard_catalogue",
object_id="module-contributions",
details={
key: value
for key, value in result.items()
if key != "items"
},
details={key: value for key, value in result.items() if key != "items"},
)
session.commit()
return result
@@ -746,9 +745,7 @@ def api_start_instance(
principal=principal,
registry=get_registry(),
payload=payload,
start_origin=(
"user" if principal.auth_method == "session" else "api"
),
start_origin=("user" if principal.auth_method == "session" else "api"),
)
except WorkflowError as exc:
raise _http_error(exc) from exc
@@ -756,9 +753,7 @@ def api_start_instance(
session,
principal,
action=(
"workflow.instance.replayed"
if replayed
else "workflow.instance.started"
"workflow.instance.replayed" if replayed else "workflow.instance.started"
),
instance_id=instance.id,
details={
@@ -1026,13 +1021,11 @@ def api_create_definition(
) -> WorkflowDefinitionResponse:
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
try:
tenant_id, scope_type, scope_id, _scope_key = (
normalize_definition_scope(
principal,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
administrative=has_scope(principal, ADMIN_SCOPE),
)
tenant_id, scope_type, scope_id, _scope_key = normalize_definition_scope(
principal,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
administrative=has_scope(principal, ADMIN_SCOPE),
)
payload = payload.model_copy(
update={"scope_type": scope_type, "scope_id": scope_id}
@@ -1107,6 +1100,58 @@ def api_get_definition(
raise _http_error(exc) from exc
@router.get(
"/definitions/{definition_id}/triggers",
response_model=WorkflowTriggerListResponse,
)
def api_list_definition_triggers(
definition_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowTriggerListResponse:
_require_any_scope(principal, DEFINITION_READ_SCOPE, ADMIN_SCOPE)
try:
definition = get_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
require_definition_action(
definition,
principal=principal,
registry=get_registry(),
action="view",
)
except PermissionError as exc:
raise _governance_http_error(exc) from exc
except WorkflowError as exc:
raise _http_error(exc) from exc
return WorkflowTriggerListResponse(
triggers=[
WorkflowTriggerResponse(
id=item.id,
definition_id=item.definition_id,
definition_revision_id=item.definition_revision_id,
node_id=item.node_id,
kind=item.kind,
status=item.status,
event_type=item.event_type,
next_fire_at=item.next_fire_at,
last_fire_at=item.last_fire_at,
last_status=item.last_status,
last_error=item.last_error,
authorization_subject_kind=item.authorization_subject_kind,
grant_scopes=list(item.grant_scopes),
)
for item in list_definition_triggers(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
]
)
@router.put(
"/definitions/{definition_id}",
response_model=WorkflowDefinitionResponse,
@@ -1142,9 +1187,7 @@ def api_update_definition(
scope_type=scope_type,
scope_id=scope_id,
preserve_existing=(
existing.scope_id
if existing.scope_type == scope_type
else None
existing.scope_id if existing.scope_type == scope_type else None
),
)
payload = payload.model_copy(
@@ -1189,13 +1232,11 @@ def api_derive_definition(
) -> WorkflowDefinitionResponse:
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
try:
tenant_id, scope_type, scope_id, _scope_key = (
normalize_definition_scope(
principal,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
administrative=has_scope(principal, ADMIN_SCOPE),
)
tenant_id, scope_type, scope_id, _scope_key = normalize_definition_scope(
principal,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
administrative=has_scope(principal, ADMIN_SCOPE),
)
payload = payload.model_copy(
update={"scope_type": scope_type, "scope_id": scope_id}
@@ -1226,9 +1267,7 @@ def api_derive_definition(
action="workflow.definition.derived",
definition_id=definition.id,
details={
"source_definition_id": (
definition.derived_from_definition_id
),
"source_definition_id": (definition.derived_from_definition_id),
"source_revision": definition.derived_from_revision,
"source_hash": definition.derived_from_hash,
"scope_type": definition.scope_type,
@@ -1396,6 +1435,12 @@ def api_activate_definition(
actor_id=_actor_id(principal),
revision=payload.revision,
)
trigger_summary = reconcile_definition_triggers(
session,
definition=definition,
principal=principal,
registry=get_registry(),
)
except PermissionError as exc:
raise _governance_http_error(exc) from exc
except WorkflowError as exc:
@@ -1405,7 +1450,10 @@ def api_activate_definition(
principal,
action="workflow.definition.activated",
definition_id=definition.id,
details={"active_revision": definition.active_revision},
details={
"active_revision": definition.active_revision,
"triggers": trigger_summary,
},
)
response = _definition_response(session, definition, principal)
session.commit()
@@ -1440,6 +1488,10 @@ def api_archive_definition(
definition_id=definition_id,
actor_id=_actor_id(principal),
)
disabled_triggers = disable_definition_triggers(
session,
definition_id=definition.id,
)
except PermissionError as exc:
raise _governance_http_error(exc) from exc
except WorkflowError as exc:
@@ -1449,7 +1501,10 @@ def api_archive_definition(
principal,
action="workflow.definition.archived",
definition_id=definition.id,
details={"active_revision": definition.active_revision},
details={
"active_revision": definition.active_revision,
"disabled_triggers": disabled_triggers,
},
)
response = _definition_response(session, definition, principal)
session.commit()
@@ -501,6 +501,26 @@ class WorkflowDefinitionDeleteResponse(BaseModel):
definition_id: str
class WorkflowTriggerResponse(BaseModel):
id: str
definition_id: str
definition_revision_id: str
node_id: str
kind: Literal["schedule", "event"]
status: str
event_type: str | None = None
next_fire_at: datetime | None = None
last_fire_at: datetime | None = None
last_status: str | None = None
last_error: str | None = None
authorization_subject_kind: Literal["delegated_user", "service_account"]
grant_scopes: list[str] = Field(default_factory=list)
class WorkflowTriggerListResponse(BaseModel):
triggers: list[WorkflowTriggerResponse]
WorkflowInstanceStatus = Literal[
"running",
"waiting",
File diff suppressed because it is too large Load Diff