676 lines
20 KiB
Python
676 lines
20 KiB
Python
|
|
"""Workflow service — CRUD, instance lifecycle, step transitions, event triggers."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import uuid
|
||
|
|
from datetime import datetime, timezone, timedelta
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
from sqlalchemy import select, func, desc, and_, or_
|
||
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
|
|
||
|
|
from app.core.audit import log_audit
|
||
|
|
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
|
||
|
|
from app.models.notification import Notification
|
||
|
|
|
||
|
|
|
||
|
|
def _safe_iso(dt) -> str | None:
|
||
|
|
"""Safely convert datetime to ISO string, handling unloaded attributes."""
|
||
|
|
if dt is None:
|
||
|
|
return None
|
||
|
|
try:
|
||
|
|
return dt.isoformat() if hasattr(dt, "isoformat") else None
|
||
|
|
except Exception:
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def _get_attr(obj, name, default=None):
|
||
|
|
"""Safely get an attribute that might be expired/unloaded in async context."""
|
||
|
|
try:
|
||
|
|
val = getattr(obj, name)
|
||
|
|
return val if val is not None else default
|
||
|
|
except Exception:
|
||
|
|
return default
|
||
|
|
|
||
|
|
|
||
|
|
def _workflow_to_dict(w: Workflow) -> dict[str, Any]:
|
||
|
|
return {
|
||
|
|
"id": str(w.id),
|
||
|
|
"name": w.name,
|
||
|
|
"description": w.description,
|
||
|
|
"trigger_event": w.trigger_event,
|
||
|
|
"steps": w.steps,
|
||
|
|
"is_active": w.is_active,
|
||
|
|
"created_by": str(w.created_by) if w.created_by else None,
|
||
|
|
"created_at": _safe_iso(_get_attr(w, "created_at")),
|
||
|
|
"updated_at": _safe_iso(_get_attr(w, "updated_at")),
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def _instance_to_dict(i: WorkflowInstance, include_history: bool = False, history: list | None = None, workflow_name: str | None = None) -> dict[str, Any]:
|
||
|
|
data = {
|
||
|
|
"id": str(i.id),
|
||
|
|
"workflow_id": str(i.workflow_id),
|
||
|
|
"status": i.status,
|
||
|
|
"current_step_index": i.current_step_index,
|
||
|
|
"context": i.context,
|
||
|
|
"initiated_by": str(i.initiated_by) if i.initiated_by else None,
|
||
|
|
"completed_at": _safe_iso(_get_attr(i, "completed_at")),
|
||
|
|
"timeout_hours": i.timeout_hours,
|
||
|
|
"timeout_at": _safe_iso(_get_attr(i, "timeout_at")),
|
||
|
|
"created_at": _safe_iso(_get_attr(i, "created_at")),
|
||
|
|
"updated_at": _safe_iso(_get_attr(i, "updated_at")),
|
||
|
|
}
|
||
|
|
if include_history:
|
||
|
|
data["history"] = history or []
|
||
|
|
data["workflow_name"] = workflow_name
|
||
|
|
return data
|
||
|
|
|
||
|
|
|
||
|
|
def _history_to_dict(h: WorkflowStepHistory) -> dict[str, Any]:
|
||
|
|
return {
|
||
|
|
"id": str(h.id),
|
||
|
|
"instance_id": str(h.instance_id),
|
||
|
|
"step_index": h.step_index,
|
||
|
|
"step_type": h.step_type,
|
||
|
|
"action": h.action,
|
||
|
|
"actor_id": str(h.actor_id) if h.actor_id else None,
|
||
|
|
"details": h.details,
|
||
|
|
"created_at": _safe_iso(_get_attr(h, "created_at")),
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
async def _log_step_history(
|
||
|
|
db: AsyncSession,
|
||
|
|
tenant_id: uuid.UUID,
|
||
|
|
instance_id: uuid.UUID,
|
||
|
|
step_index: int,
|
||
|
|
step_type: str,
|
||
|
|
action: str,
|
||
|
|
actor_id: uuid.UUID | None = None,
|
||
|
|
details: dict[str, Any] | None = None,
|
||
|
|
) -> WorkflowStepHistory:
|
||
|
|
"""Create a workflow step history entry."""
|
||
|
|
entry = WorkflowStepHistory(
|
||
|
|
tenant_id=tenant_id,
|
||
|
|
instance_id=instance_id,
|
||
|
|
step_index=step_index,
|
||
|
|
step_type=step_type,
|
||
|
|
action=action,
|
||
|
|
actor_id=actor_id,
|
||
|
|
details=details,
|
||
|
|
)
|
||
|
|
db.add(entry)
|
||
|
|
await db.flush()
|
||
|
|
return entry
|
||
|
|
|
||
|
|
|
||
|
|
# ─── Workflow CRUD ───
|
||
|
|
|
||
|
|
async def create_workflow(
|
||
|
|
db: AsyncSession,
|
||
|
|
tenant_id: uuid.UUID,
|
||
|
|
user_id: uuid.UUID,
|
||
|
|
data: dict[str, Any],
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
"""Create a new workflow definition."""
|
||
|
|
steps_raw = data.get("steps", [])
|
||
|
|
# Validate steps
|
||
|
|
if not steps_raw:
|
||
|
|
raise ValueError("Workflow must have at least one step")
|
||
|
|
|
||
|
|
steps_json = [s if isinstance(s, dict) else s.model_dump() for s in steps_raw]
|
||
|
|
|
||
|
|
workflow = Workflow(
|
||
|
|
tenant_id=tenant_id,
|
||
|
|
name=data["name"],
|
||
|
|
description=data.get("description"),
|
||
|
|
trigger_event=data.get("trigger_event"),
|
||
|
|
steps=steps_json,
|
||
|
|
is_active=data.get("is_active", True),
|
||
|
|
created_by=user_id,
|
||
|
|
)
|
||
|
|
db.add(workflow)
|
||
|
|
await db.flush()
|
||
|
|
|
||
|
|
await log_audit(
|
||
|
|
db, tenant_id, user_id,
|
||
|
|
action="create",
|
||
|
|
entity_type="workflow",
|
||
|
|
entity_id=workflow.id,
|
||
|
|
changes={"name": workflow.name},
|
||
|
|
)
|
||
|
|
|
||
|
|
return _workflow_to_dict(workflow)
|
||
|
|
|
||
|
|
|
||
|
|
async def list_workflows(
|
||
|
|
db: AsyncSession,
|
||
|
|
tenant_id: uuid.UUID,
|
||
|
|
page: int = 1,
|
||
|
|
page_size: int = 20,
|
||
|
|
is_active: bool | None = None,
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
"""List workflows with pagination."""
|
||
|
|
page = max(1, page)
|
||
|
|
page_size = max(1, min(100, page_size))
|
||
|
|
|
||
|
|
base = select(Workflow).where(Workflow.tenant_id == tenant_id)
|
||
|
|
if is_active is not None:
|
||
|
|
base = base.where(Workflow.is_active == is_active)
|
||
|
|
|
||
|
|
count_q = select(func.count()).select_from(base.subquery())
|
||
|
|
total_result = await db.execute(count_q)
|
||
|
|
total = total_result.scalar_one()
|
||
|
|
|
||
|
|
offset = (page - 1) * page_size
|
||
|
|
paginated = base.order_by(desc(Workflow.created_at)).offset(offset).limit(page_size)
|
||
|
|
result = await db.execute(paginated)
|
||
|
|
workflows = result.scalars().all()
|
||
|
|
|
||
|
|
return {
|
||
|
|
"items": [_workflow_to_dict(w) for w in workflows],
|
||
|
|
"total": total,
|
||
|
|
"page": page,
|
||
|
|
"page_size": page_size,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
async def get_workflow(
|
||
|
|
db: AsyncSession,
|
||
|
|
tenant_id: uuid.UUID,
|
||
|
|
workflow_id: str,
|
||
|
|
) -> dict[str, Any] | None:
|
||
|
|
"""Get a single workflow by ID."""
|
||
|
|
wf_uuid = uuid.UUID(workflow_id)
|
||
|
|
result = await db.execute(
|
||
|
|
select(Workflow).where(
|
||
|
|
Workflow.id == wf_uuid,
|
||
|
|
Workflow.tenant_id == tenant_id,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
workflow = result.scalar_one_or_none()
|
||
|
|
if workflow is None:
|
||
|
|
return None
|
||
|
|
return _workflow_to_dict(workflow)
|
||
|
|
|
||
|
|
|
||
|
|
async def update_workflow(
|
||
|
|
db: AsyncSession,
|
||
|
|
tenant_id: uuid.UUID,
|
||
|
|
user_id: uuid.UUID,
|
||
|
|
workflow_id: str,
|
||
|
|
data: dict[str, Any],
|
||
|
|
) -> dict[str, Any] | None:
|
||
|
|
"""Update a workflow definition."""
|
||
|
|
wf_uuid = uuid.UUID(workflow_id)
|
||
|
|
result = await db.execute(
|
||
|
|
select(Workflow).where(
|
||
|
|
Workflow.id == wf_uuid,
|
||
|
|
Workflow.tenant_id == tenant_id,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
workflow = result.scalar_one_or_none()
|
||
|
|
if workflow is None:
|
||
|
|
return None
|
||
|
|
|
||
|
|
if "name" in data:
|
||
|
|
workflow.name = data["name"]
|
||
|
|
if "description" in data:
|
||
|
|
workflow.description = data["description"]
|
||
|
|
if "trigger_event" in data:
|
||
|
|
workflow.trigger_event = data["trigger_event"]
|
||
|
|
if "steps" in data:
|
||
|
|
steps_raw = data["steps"]
|
||
|
|
workflow.steps = [s if isinstance(s, dict) else s.model_dump() for s in steps_raw]
|
||
|
|
if "is_active" in data:
|
||
|
|
workflow.is_active = data["is_active"]
|
||
|
|
|
||
|
|
await db.flush()
|
||
|
|
|
||
|
|
await log_audit(
|
||
|
|
db, tenant_id, user_id,
|
||
|
|
action="update",
|
||
|
|
entity_type="workflow",
|
||
|
|
entity_id=workflow.id,
|
||
|
|
changes={"updated_fields": list(data.keys())},
|
||
|
|
)
|
||
|
|
|
||
|
|
return _workflow_to_dict(workflow)
|
||
|
|
|
||
|
|
|
||
|
|
async def delete_workflow(
|
||
|
|
db: AsyncSession,
|
||
|
|
tenant_id: uuid.UUID,
|
||
|
|
user_id: uuid.UUID,
|
||
|
|
workflow_id: str,
|
||
|
|
) -> bool:
|
||
|
|
"""Delete a workflow definition."""
|
||
|
|
wf_uuid = uuid.UUID(workflow_id)
|
||
|
|
result = await db.execute(
|
||
|
|
select(Workflow).where(
|
||
|
|
Workflow.id == wf_uuid,
|
||
|
|
Workflow.tenant_id == tenant_id,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
workflow = result.scalar_one_or_none()
|
||
|
|
if workflow is None:
|
||
|
|
return False
|
||
|
|
|
||
|
|
await db.delete(workflow)
|
||
|
|
await db.flush()
|
||
|
|
|
||
|
|
await log_audit(
|
||
|
|
db, tenant_id, user_id,
|
||
|
|
action="delete",
|
||
|
|
entity_type="workflow",
|
||
|
|
entity_id=wf_uuid,
|
||
|
|
)
|
||
|
|
|
||
|
|
return True
|
||
|
|
|
||
|
|
|
||
|
|
# ─── Instance Lifecycle ───
|
||
|
|
|
||
|
|
async def create_instance(
|
||
|
|
db: AsyncSession,
|
||
|
|
tenant_id: uuid.UUID,
|
||
|
|
user_id: uuid.UUID,
|
||
|
|
workflow_id: str,
|
||
|
|
context: dict[str, Any] | None = None,
|
||
|
|
timeout_hours: int | None = None,
|
||
|
|
) -> dict[str, Any] | None:
|
||
|
|
"""Create a new workflow instance with status=pending."""
|
||
|
|
wf_uuid = uuid.UUID(workflow_id)
|
||
|
|
result = await db.execute(
|
||
|
|
select(Workflow).where(
|
||
|
|
Workflow.id == wf_uuid,
|
||
|
|
Workflow.tenant_id == tenant_id,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
workflow = result.scalar_one_or_none()
|
||
|
|
if workflow is None:
|
||
|
|
return None
|
||
|
|
|
||
|
|
timeout_at = None
|
||
|
|
if timeout_hours:
|
||
|
|
timeout_at = datetime.now(timezone.utc) + timedelta(hours=timeout_hours)
|
||
|
|
|
||
|
|
instance = WorkflowInstance(
|
||
|
|
tenant_id=tenant_id,
|
||
|
|
workflow_id=wf_uuid,
|
||
|
|
status="pending",
|
||
|
|
current_step_index=0,
|
||
|
|
context=context or {},
|
||
|
|
initiated_by=user_id,
|
||
|
|
timeout_hours=timeout_hours,
|
||
|
|
timeout_at=timeout_at,
|
||
|
|
)
|
||
|
|
db.add(instance)
|
||
|
|
await db.flush()
|
||
|
|
await db.refresh(instance)
|
||
|
|
|
||
|
|
# Log initial step entry
|
||
|
|
steps = workflow.steps or []
|
||
|
|
first_step = steps[0] if steps else {}
|
||
|
|
await _log_step_history(
|
||
|
|
db, tenant_id, instance.id,
|
||
|
|
step_index=0,
|
||
|
|
step_type=first_step.get("type", "action"),
|
||
|
|
action="entered",
|
||
|
|
actor_id=user_id,
|
||
|
|
details={"workflow_name": workflow.name},
|
||
|
|
)
|
||
|
|
|
||
|
|
await log_audit(
|
||
|
|
db, tenant_id, user_id,
|
||
|
|
action="create",
|
||
|
|
entity_type="workflow_instance",
|
||
|
|
entity_id=instance.id,
|
||
|
|
changes={"workflow_id": str(wf_uuid), "status": "pending"},
|
||
|
|
)
|
||
|
|
|
||
|
|
return _instance_to_dict(instance)
|
||
|
|
|
||
|
|
|
||
|
|
async def list_instances(
|
||
|
|
db: AsyncSession,
|
||
|
|
tenant_id: uuid.UUID,
|
||
|
|
page: int = 1,
|
||
|
|
page_size: int = 20,
|
||
|
|
status_filter: str | None = None,
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
"""List workflow instances with optional status filter."""
|
||
|
|
page = max(1, page)
|
||
|
|
page_size = max(1, min(100, page_size))
|
||
|
|
|
||
|
|
base = select(WorkflowInstance).where(WorkflowInstance.tenant_id == tenant_id)
|
||
|
|
if status_filter:
|
||
|
|
base = base.where(WorkflowInstance.status == status_filter)
|
||
|
|
|
||
|
|
count_q = select(func.count()).select_from(base.subquery())
|
||
|
|
total_result = await db.execute(count_q)
|
||
|
|
total = total_result.scalar_one()
|
||
|
|
|
||
|
|
offset = (page - 1) * page_size
|
||
|
|
paginated = base.order_by(desc(WorkflowInstance.created_at)).offset(offset).limit(page_size)
|
||
|
|
result = await db.execute(paginated)
|
||
|
|
instances = result.scalars().all()
|
||
|
|
|
||
|
|
return {
|
||
|
|
"items": [_instance_to_dict(i) for i in instances],
|
||
|
|
"total": total,
|
||
|
|
"page": page,
|
||
|
|
"page_size": page_size,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
async def get_instance(
|
||
|
|
db: AsyncSession,
|
||
|
|
tenant_id: uuid.UUID,
|
||
|
|
instance_id: str,
|
||
|
|
) -> dict[str, Any] | None:
|
||
|
|
"""Get a single workflow instance with step history."""
|
||
|
|
inst_uuid = uuid.UUID(instance_id)
|
||
|
|
result = await db.execute(
|
||
|
|
select(WorkflowInstance).where(
|
||
|
|
WorkflowInstance.id == inst_uuid,
|
||
|
|
WorkflowInstance.tenant_id == tenant_id,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
instance = result.scalar_one_or_none()
|
||
|
|
if instance is None:
|
||
|
|
return None
|
||
|
|
|
||
|
|
# Get workflow name
|
||
|
|
wf_result = await db.execute(
|
||
|
|
select(Workflow.name).where(Workflow.id == instance.workflow_id)
|
||
|
|
)
|
||
|
|
workflow_name = wf_result.scalar_one_or_none()
|
||
|
|
|
||
|
|
# Get step history
|
||
|
|
hist_q = select(WorkflowStepHistory).where(
|
||
|
|
WorkflowStepHistory.instance_id == inst_uuid,
|
||
|
|
WorkflowStepHistory.tenant_id == tenant_id,
|
||
|
|
).order_by(WorkflowStepHistory.created_at)
|
||
|
|
hist_result = await db.execute(hist_q)
|
||
|
|
history = hist_result.scalars().all()
|
||
|
|
|
||
|
|
return _instance_to_dict(
|
||
|
|
instance,
|
||
|
|
include_history=True,
|
||
|
|
history=[_history_to_dict(h) for h in history],
|
||
|
|
workflow_name=workflow_name,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
async def advance_instance(
|
||
|
|
db: AsyncSession,
|
||
|
|
tenant_id: uuid.UUID,
|
||
|
|
user_id: uuid.UUID,
|
||
|
|
instance_id: str,
|
||
|
|
decision: str,
|
||
|
|
comment: str | None = None,
|
||
|
|
) -> dict[str, Any] | None:
|
||
|
|
"""Advance or reject a workflow instance step.
|
||
|
|
|
||
|
|
decision: "approve" or "reject"
|
||
|
|
- approve: move to next step or complete if last step
|
||
|
|
- reject: set status=rejected, notify initiator
|
||
|
|
"""
|
||
|
|
inst_uuid = uuid.UUID(instance_id)
|
||
|
|
result = await db.execute(
|
||
|
|
select(WorkflowInstance).where(
|
||
|
|
WorkflowInstance.id == inst_uuid,
|
||
|
|
WorkflowInstance.tenant_id == tenant_id,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
instance = result.scalar_one_or_none()
|
||
|
|
if instance is None:
|
||
|
|
return None
|
||
|
|
|
||
|
|
if instance.status not in ("pending", "in_progress"):
|
||
|
|
return {"error": f"Cannot advance instance with status {instance.status}", "status_code": 400}
|
||
|
|
|
||
|
|
# Get workflow definition
|
||
|
|
wf_result = await db.execute(
|
||
|
|
select(Workflow).where(Workflow.id == instance.workflow_id)
|
||
|
|
)
|
||
|
|
workflow = wf_result.scalar_one_or_none()
|
||
|
|
if workflow is None:
|
||
|
|
return {"error": "Workflow definition not found", "status_code": 404}
|
||
|
|
|
||
|
|
steps = workflow.steps or []
|
||
|
|
current_idx = instance.current_step_index
|
||
|
|
current_step = steps[current_idx] if current_idx < len(steps) else None
|
||
|
|
step_type = current_step.get("type", "action") if current_step else "action"
|
||
|
|
|
||
|
|
if decision == "reject":
|
||
|
|
instance.status = "rejected"
|
||
|
|
instance.completed_at = datetime.now(timezone.utc)
|
||
|
|
await _log_step_history(
|
||
|
|
db, tenant_id, instance.id,
|
||
|
|
step_index=current_idx,
|
||
|
|
step_type=step_type,
|
||
|
|
action="rejected",
|
||
|
|
actor_id=user_id,
|
||
|
|
details={"comment": comment},
|
||
|
|
)
|
||
|
|
|
||
|
|
# Notify initiator
|
||
|
|
if instance.initiated_by:
|
||
|
|
notification = Notification(
|
||
|
|
tenant_id=tenant_id,
|
||
|
|
user_id=instance.initiated_by,
|
||
|
|
type="workflow_rejected",
|
||
|
|
title=f"Workflow '{workflow.name}' rejected",
|
||
|
|
body=comment or f"Step {current_idx + 1} was rejected",
|
||
|
|
)
|
||
|
|
db.add(notification)
|
||
|
|
await db.flush()
|
||
|
|
|
||
|
|
await log_audit(
|
||
|
|
db, tenant_id, user_id,
|
||
|
|
action="reject",
|
||
|
|
entity_type="workflow_instance",
|
||
|
|
entity_id=instance.id,
|
||
|
|
changes={"step": current_idx, "comment": comment},
|
||
|
|
)
|
||
|
|
|
||
|
|
return _instance_to_dict(instance)
|
||
|
|
|
||
|
|
# decision == approve
|
||
|
|
# Move to in_progress if was pending
|
||
|
|
if instance.status == "pending":
|
||
|
|
instance.status = "in_progress"
|
||
|
|
|
||
|
|
# Log approval
|
||
|
|
await _log_step_history(
|
||
|
|
db, tenant_id, instance.id,
|
||
|
|
step_index=current_idx,
|
||
|
|
step_type=step_type,
|
||
|
|
action="approved",
|
||
|
|
actor_id=user_id,
|
||
|
|
details={"comment": comment},
|
||
|
|
)
|
||
|
|
|
||
|
|
next_idx = current_idx + 1
|
||
|
|
if next_idx >= len(steps):
|
||
|
|
# Workflow complete
|
||
|
|
instance.status = "completed"
|
||
|
|
instance.completed_at = datetime.now(timezone.utc)
|
||
|
|
await _log_step_history(
|
||
|
|
db, tenant_id, instance.id,
|
||
|
|
step_index=current_idx,
|
||
|
|
step_type="complete",
|
||
|
|
action="completed",
|
||
|
|
actor_id=user_id,
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
instance.current_step_index = next_idx
|
||
|
|
next_step = steps[next_idx]
|
||
|
|
await _log_step_history(
|
||
|
|
db, tenant_id, instance.id,
|
||
|
|
step_index=next_idx,
|
||
|
|
step_type=next_step.get("type", "action"),
|
||
|
|
action="entered",
|
||
|
|
actor_id=user_id,
|
||
|
|
)
|
||
|
|
|
||
|
|
await db.flush()
|
||
|
|
|
||
|
|
await log_audit(
|
||
|
|
db, tenant_id, user_id,
|
||
|
|
action="advance",
|
||
|
|
entity_type="workflow_instance",
|
||
|
|
entity_id=instance.id,
|
||
|
|
changes={"decision": decision, "step": current_idx, "next_step": next_idx if next_idx < len(steps) else None},
|
||
|
|
)
|
||
|
|
|
||
|
|
return _instance_to_dict(instance)
|
||
|
|
|
||
|
|
|
||
|
|
async def cancel_instance(
|
||
|
|
db: AsyncSession,
|
||
|
|
tenant_id: uuid.UUID,
|
||
|
|
user_id: uuid.UUID,
|
||
|
|
instance_id: str,
|
||
|
|
) -> dict[str, Any] | None:
|
||
|
|
"""Cancel a workflow instance."""
|
||
|
|
inst_uuid = uuid.UUID(instance_id)
|
||
|
|
result = await db.execute(
|
||
|
|
select(WorkflowInstance).where(
|
||
|
|
WorkflowInstance.id == inst_uuid,
|
||
|
|
WorkflowInstance.tenant_id == tenant_id,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
instance = result.scalar_one_or_none()
|
||
|
|
if instance is None:
|
||
|
|
return None
|
||
|
|
|
||
|
|
if instance.status in ("completed", "rejected", "cancelled"):
|
||
|
|
return {"error": f"Cannot cancel instance with status {instance.status}", "status_code": 400}
|
||
|
|
|
||
|
|
instance.status = "cancelled"
|
||
|
|
instance.completed_at = datetime.now(timezone.utc)
|
||
|
|
|
||
|
|
# Get current step for history
|
||
|
|
wf_result = await db.execute(
|
||
|
|
select(Workflow).where(Workflow.id == instance.workflow_id)
|
||
|
|
)
|
||
|
|
workflow = wf_result.scalar_one_or_none()
|
||
|
|
steps = workflow.steps if workflow else []
|
||
|
|
current_step = steps[instance.current_step_index] if instance.current_step_index < len(steps) else {}
|
||
|
|
|
||
|
|
await _log_step_history(
|
||
|
|
db, tenant_id, instance.id,
|
||
|
|
step_index=instance.current_step_index,
|
||
|
|
step_type=current_step.get("type", "action"),
|
||
|
|
action="cancelled",
|
||
|
|
actor_id=user_id,
|
||
|
|
)
|
||
|
|
|
||
|
|
await db.flush()
|
||
|
|
|
||
|
|
await log_audit(
|
||
|
|
db, tenant_id, user_id,
|
||
|
|
action="cancel",
|
||
|
|
entity_type="workflow_instance",
|
||
|
|
entity_id=instance.id,
|
||
|
|
)
|
||
|
|
|
||
|
|
return _instance_to_dict(instance)
|
||
|
|
|
||
|
|
|
||
|
|
async def check_timeout(instance: WorkflowInstance) -> bool:
|
||
|
|
"""Check if an instance has timed out (for approval steps).
|
||
|
|
|
||
|
|
Returns True if the instance should be auto-rejected.
|
||
|
|
"""
|
||
|
|
if instance.timeout_at is None:
|
||
|
|
return False
|
||
|
|
if instance.status not in ("pending", "in_progress"):
|
||
|
|
return False
|
||
|
|
return datetime.now(timezone.utc) > instance.timeout_at
|
||
|
|
|
||
|
|
|
||
|
|
async def auto_reject_timeout(
|
||
|
|
db: AsyncSession,
|
||
|
|
tenant_id: uuid.UUID,
|
||
|
|
instance: WorkflowInstance,
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
"""Auto-reject a timed-out instance."""
|
||
|
|
instance.status = "rejected"
|
||
|
|
instance.completed_at = datetime.now(timezone.utc)
|
||
|
|
|
||
|
|
wf_result = await db.execute(
|
||
|
|
select(Workflow).where(Workflow.id == instance.workflow_id)
|
||
|
|
)
|
||
|
|
workflow = wf_result.scalar_one_or_none()
|
||
|
|
steps = workflow.steps if workflow else []
|
||
|
|
current_step = steps[instance.current_step_index] if instance.current_step_index < len(steps) else {}
|
||
|
|
|
||
|
|
await _log_step_history(
|
||
|
|
db, tenant_id, instance.id,
|
||
|
|
step_index=instance.current_step_index,
|
||
|
|
step_type=current_step.get("type", "approval"),
|
||
|
|
action="auto_rejected",
|
||
|
|
details={"reason": "timeout"},
|
||
|
|
)
|
||
|
|
|
||
|
|
# Notify initiator
|
||
|
|
if instance.initiated_by:
|
||
|
|
notification = Notification(
|
||
|
|
tenant_id=tenant_id,
|
||
|
|
user_id=instance.initiated_by,
|
||
|
|
type="workflow_timeout",
|
||
|
|
title=f"Workflow '{workflow.name if workflow else 'Unknown'}' auto-rejected",
|
||
|
|
body="The approval step timed out and was automatically rejected.",
|
||
|
|
)
|
||
|
|
db.add(notification)
|
||
|
|
|
||
|
|
await db.flush()
|
||
|
|
await db.refresh(instance)
|
||
|
|
return _instance_to_dict(instance)
|
||
|
|
|
||
|
|
|
||
|
|
async def find_workflows_for_event(
|
||
|
|
db: AsyncSession,
|
||
|
|
tenant_id: uuid.UUID,
|
||
|
|
event_name: str,
|
||
|
|
) -> list[Workflow]:
|
||
|
|
"""Find active workflows that trigger on a specific event."""
|
||
|
|
result = await db.execute(
|
||
|
|
select(Workflow).where(
|
||
|
|
Workflow.tenant_id == tenant_id,
|
||
|
|
Workflow.is_active.is_(True),
|
||
|
|
Workflow.trigger_event == event_name,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
return list(result.scalars().all())
|
||
|
|
|
||
|
|
|
||
|
|
async def start_instance_for_event(
|
||
|
|
db: AsyncSession,
|
||
|
|
tenant_id: uuid.UUID,
|
||
|
|
user_id: uuid.UUID | None,
|
||
|
|
event_name: str,
|
||
|
|
context: dict[str, Any] | None = None,
|
||
|
|
) -> list[dict[str, Any]]:
|
||
|
|
"""Start workflow instances for all workflows matching an event trigger.
|
||
|
|
|
||
|
|
Called by the event bus when an event is published.
|
||
|
|
"""
|
||
|
|
workflows = await find_workflows_for_event(db, tenant_id, event_name)
|
||
|
|
instances: list[dict[str, Any]] = []
|
||
|
|
for wf in workflows:
|
||
|
|
inst = await create_instance(
|
||
|
|
db, tenant_id,
|
||
|
|
user_id or uuid.uuid4(),
|
||
|
|
str(wf.id),
|
||
|
|
context=context,
|
||
|
|
)
|
||
|
|
if inst:
|
||
|
|
instances.append(inst)
|
||
|
|
return instances
|