T09: KI-Copilot API + Hybrid Workflow Engine + LLM client + event-triggered workflows
- KI-Copilot: NL query → proposed actions, execute with RBAC, history, audit logging - LLM client: mock mode (no API key) + OpenAI-compatible mode (AI_MODEL/AI_API_KEY) - Action mapper: NL intent → API calls (create/update/delete/search company/contact) - Workflow engine: step types (action/approval/notification/condition), JSONB steps - Workflow lifecycle: pending → in_progress → completed/rejected/cancelled - Event-triggered workflows: event bus → auto-start instances - Code-engine workflows: onboarding on user.created event - Approval timeout: auto-reject after configured hours - 5 new tenant-scoped tables with RLS: ai_conversations, ai_messages, workflows, workflow_instances, workflow_step_history - Migration 0004: all tables + RLS policies + tenant_id + indexes - 238 tests pass (30 AC + 105 coverage + 103 existing), 84.12% T09 module coverage - MissingGreenlet fix: safe accessor helpers for async ORM attribute access
This commit is contained in:
@@ -0,0 +1,675 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user