"""Workflow execution engine — step processing, conditions, approvals. Processes workflow instances by evaluating steps sequentially. Supports step types: action, approval, notification, condition. Integrates with the event bus for event-triggered workflows. """ from __future__ import annotations import logging import uuid from datetime import UTC, datetime from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.event_bus import get_event_bus from app.models.notification import Notification from app.models.workflow import Workflow, WorkflowInstance from app.services.workflow_service import ( _instance_to_dict, _log_step_history, create_instance, find_workflows_for_event, ) logger = logging.getLogger(__name__) class WorkflowEngine: """Processes workflow instances through their defined steps. Step types: - action: Executes a configured action (e.g. create entity, send notification) - approval: Pauses and waits for user approve/reject via API - notification: Sends a notification to specified users - condition: Evaluates a condition and branches accordingly """ def __init__(self, db: AsyncSession, tenant_id: uuid.UUID): self.db = db self.tenant_id = tenant_id async def process_step(self, instance: WorkflowInstance) -> dict[str, Any]: """Process the current step of a workflow instance. For action/notification/condition steps: executes and advances. For approval steps: sets status to in_progress and waits. Returns the updated instance dict. """ wf_result = await self.db.execute( select(Workflow).where(Workflow.id == instance.workflow_id) ) workflow = wf_result.scalar_one_or_none() if workflow is None: return {"error": "Workflow not found", "status_code": 404} steps = workflow.steps or [] if instance.current_step_index >= len(steps): instance.status = "completed" instance.completed_at = datetime.now(UTC) await self.db.flush() return _instance_to_dict(instance) step = steps[instance.current_step_index] step_type = step.get("type", "action") # Log step entry await _log_step_history( self.db, self.tenant_id, instance.id, step_index=instance.current_step_index, step_type=step_type, action="processing", details={"step_name": step.get("name")}, ) if step_type == "approval": # Approval steps pause and wait for user input if instance.status == "pending": instance.status = "in_progress" await self.db.flush() return _instance_to_dict(instance) elif step_type == "notification": return await self._process_notification(instance, step) elif step_type == "condition": return await self._process_condition(instance, step, steps) elif step_type == "action": return await self._process_action(instance, step, steps) else: return {"error": f"Unknown step type: {step_type}", "status_code": 400} async def _process_action( self, instance: WorkflowInstance, step: dict, steps: list ) -> dict[str, Any]: """Process an action step — executes the configured action and advances.""" config = step.get("config", {}) action_type = config.get("action_type", "noop") # Execute action based on type if action_type == "create_notification": user_id = config.get("user_id") or ( str(instance.initiated_by) if instance.initiated_by else None ) if user_id: notification = Notification( tenant_id=self.tenant_id, user_id=uuid.UUID(user_id), type=config.get("notification_type", "workflow_action"), title=config.get("title", "Workflow notification"), body=config.get("body", ""), ) self.db.add(notification) await self.db.flush() elif action_type == "noop": pass # No operation — just advance # Log action executed await _log_step_history( self.db, self.tenant_id, instance.id, step_index=instance.current_step_index, step_type="action", action="executed", details={"action_type": action_type}, ) # Advance to next step next_idx = instance.current_step_index + 1 if next_idx >= len(steps): instance.status = "completed" instance.completed_at = datetime.now(UTC) else: instance.current_step_index = next_idx instance.status = "in_progress" await self.db.flush() return _instance_to_dict(instance) async def _process_notification(self, instance: WorkflowInstance, step: dict) -> dict[str, Any]: """Process a notification step — sends notification and advances.""" config = step.get("config", {}) user_id = config.get("user_id") or ( str(instance.initiated_by) if instance.initiated_by else None ) if user_id: notification = Notification( tenant_id=self.tenant_id, user_id=uuid.UUID(user_id), type=config.get("notification_type", "workflow_notification"), title=config.get("title", "Workflow notification"), body=config.get("body", ""), ) self.db.add(notification) await self.db.flush() await _log_step_history( self.db, self.tenant_id, instance.id, step_index=instance.current_step_index, step_type="notification", action="sent", details={"user_id": user_id}, ) # Advance — notification steps auto-advance wf_result = await self.db.execute( select(Workflow).where(Workflow.id == instance.workflow_id) ) workflow = wf_result.scalar_one_or_none() steps = workflow.steps if workflow else [] next_idx = instance.current_step_index + 1 if next_idx >= len(steps): instance.status = "completed" instance.completed_at = datetime.now(UTC) else: instance.current_step_index = next_idx await self.db.flush() return _instance_to_dict(instance) async def _process_condition( self, instance: WorkflowInstance, step: dict, steps: list ) -> dict[str, Any]: """Process a condition step — evaluates condition and branches. Config format: { "field": "context_key", "operator": "eq|ne|gt|lt|contains", "value": "expected_value", "on_true_step": optional_index, "on_false_step": optional_index } """ config = step.get("config", {}) field = config.get("field", "") operator = config.get("operator", "eq") expected = config.get("value") actual = instance.context.get(field) condition_met = False if operator == "eq": condition_met = actual == expected elif operator == "ne": condition_met = actual != expected elif operator == "gt": condition_met = actual is not None and expected is not None and actual > expected elif operator == "lt": condition_met = actual is not None and expected is not None and actual < expected elif operator == "contains": condition_met = ( actual is not None and expected in actual if isinstance(actual, str | list) else False ) await _log_step_history( self.db, self.tenant_id, instance.id, step_index=instance.current_step_index, step_type="condition", action="evaluated", details={"field": field, "operator": operator, "condition_met": condition_met}, ) # Branch or advance if condition_met and "on_true_step" in config: instance.current_step_index = config["on_true_step"] elif not condition_met and "on_false_step" in config: instance.current_step_index = config["on_false_step"] else: next_idx = instance.current_step_index + 1 if next_idx >= len(steps): instance.status = "completed" instance.completed_at = datetime.now(UTC) else: instance.current_step_index = next_idx await self.db.flush() return _instance_to_dict(instance) async def handle_event( db: AsyncSession, tenant_id: uuid.UUID, event_name: str, payload: dict[str, Any], ) -> list[dict[str, Any]]: """Handle an event by starting matching workflow instances. Called by the event bus integration. Finds all active workflows with trigger_event matching event_name and creates instances. """ 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, uuid.UUID(payload.get("user_id", str(uuid.uuid4()))) if payload.get("user_id") else None or uuid.uuid4(), str(wf.id), context=payload, ) if inst: instances.append(inst) return instances def register_workflow_event_handlers() -> None: """Register event bus handlers for workflow triggers. Subscribes to the event bus to auto-start workflows when events fire. Should be called during application startup. """ event_bus = get_event_bus() async def _workflow_event_handler(payload: dict[str, Any]) -> None: """Handle events that may trigger workflows.""" from app.core.db import create_db_session tenant_id_str = payload.get("tenant_id") event_name = payload.get("event", "") if not tenant_id_str or not event_name: return tenant_id = uuid.UUID(tenant_id_str) async with create_db_session(tenant_id) as db: await handle_event(db, tenant_id, event_name, payload) # Subscribe to common events for event_name in ("user.created", "company.created", "contact.created"): event_bus.subscribe(event_name, _workflow_event_handler)