851e7999ba
- 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
289 lines
10 KiB
Python
289 lines
10 KiB
Python
"""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 uuid
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
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.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
|
|
from app.models.notification import Notification
|
|
from app.services.workflow_service import (
|
|
_log_step_history,
|
|
_instance_to_dict,
|
|
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(timezone.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(timezone.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(timezone.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(timezone.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)
|