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:
leocrm-bot
2026-06-29 02:44:13 +02:00
parent 7a5a48fb4c
commit 14bd4e33fb
31 changed files with 5884 additions and 3 deletions
+13
View File
@@ -1,3 +1,16 @@
"""Service layer package."""
from app.services.plugin_service import PluginService, get_plugin_service
from app.services.ai_copilot_service import (
process_query as copilot_process_query,
execute_action as copilot_execute_action,
get_history as copilot_get_history,
)
from app.services.workflow_service import (
create_workflow, list_workflows, get_workflow,
update_workflow, delete_workflow,
create_instance, list_instances, get_instance,
advance_instance, cancel_instance,
check_timeout, auto_reject_timeout,
find_workflows_for_event, start_instance_for_event,
)
+507
View File
@@ -0,0 +1,507 @@
"""AI Copilot service — NL query processing, action execution, RBAC, audit logging."""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import select, func, desc, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.ai.llm_client import get_llm_client
from app.core.audit import log_audit
from app.core.auth import check_permission, filter_fields_by_permission
from app.models.ai_conversation import AIConversation, AIMessage
from app.models.company import Company
from app.models.contact import Contact
from app.models.workflow import Workflow
def _safe_iso(dt) -> str | None:
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):
try:
val = getattr(obj, name)
return val if val is not None else default
except Exception:
return default
def _conversation_to_dict(c: AIConversation) -> dict[str, Any]:
return {
"id": str(c.id),
"title": c.title,
"context": c.context,
"created_at": _safe_iso(_get_attr(c, "created_at")),
"updated_at": _safe_iso(_get_attr(c, "updated_at")),
}
def _message_to_dict(m: AIMessage) -> dict[str, Any]:
return {
"id": str(m.id),
"role": m.role,
"content": m.content,
"proposed_actions": m.proposed_actions,
"executed_action": m.executed_action,
"execution_result": m.execution_result,
"created_at": _safe_iso(_get_attr(m, "created_at")),
}
async def process_query(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
query: str,
conversation_id: str | None = None,
context: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Process a natural language query and return proposed actions.
1. Get or create conversation
2. Store user message
3. Call LLM client (mock or real) to get proposed actions
4. Store assistant message with proposed actions
5. Return response with conversation_id and proposed_actions
"""
context = context or {}
# Get or create conversation
if conversation_id:
conv_uuid = uuid.UUID(conversation_id)
result = await db.execute(
select(AIConversation).where(
AIConversation.id == conv_uuid,
AIConversation.tenant_id == tenant_id,
)
)
conversation = result.scalar_one_or_none()
if conversation is None:
return {"error": "Conversation not found", "status_code": 404}
else:
conversation = AIConversation(
tenant_id=tenant_id,
user_id=user_id,
title=query[:100] if query else "Untitled",
context=context,
)
db.add(conversation)
await db.flush()
await db.refresh(conversation)
# Get next message index
count_q = select(func.count()).select_from(
select(AIMessage).where(AIMessage.conversation_id == conversation.id).subquery()
)
count_result = await db.execute(count_q)
msg_index = count_result.scalar_one()
# Store user message
user_msg = AIMessage(
tenant_id=tenant_id,
conversation_id=conversation.id,
role="user",
content=query,
message_index=msg_index,
)
db.add(user_msg)
await db.flush()
await db.refresh(user_msg)
# Call LLM client
llm = get_llm_client()
llm_response = await llm.generate(query, context)
# Store assistant message
assistant_msg = AIMessage(
tenant_id=tenant_id,
conversation_id=conversation.id,
role="assistant",
content=llm_response.message,
proposed_actions=llm_response.proposed_actions,
message_index=msg_index + 1,
)
db.add(assistant_msg)
await db.flush()
await db.refresh(assistant_msg)
# Log to audit
await log_audit(
db, tenant_id, user_id,
action="query",
entity_type="ai_copilot",
entity_id=conversation.id,
changes={"query": query, "proposed_action_count": len(llm_response.proposed_actions)},
)
return {
"conversation_id": str(conversation.id),
"message": llm_response.message,
"proposed_actions": llm_response.proposed_actions,
}
async def execute_action(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
role: str,
conversation_id: str,
action: dict[str, Any],
) -> dict[str, Any]:
"""Execute a proposed action with RBAC enforcement.
1. Validate conversation belongs to tenant
2. Check RBAC permissions for the action
3. Execute the action (direct DB or API call)
4. Store execution result in message
5. Log to audit
"""
conv_uuid = uuid.UUID(conversation_id)
# Validate conversation ownership
result = await db.execute(
select(AIConversation).where(
AIConversation.id == conv_uuid,
AIConversation.tenant_id == tenant_id,
)
)
conversation = result.scalar_one_or_none()
if conversation is None:
return {"error": "Conversation not found", "status_code": 404}
method = action.get("method", "GET").upper()
path = action.get("path", "")
body = action.get("body") or {}
# Determine module and action_type from path for RBAC
module, action_type = _derive_rbac_from_path(method, path)
if not check_permission(role, module, action_type):
return {
"error": "Insufficient permissions for this action",
"status_code": 403,
"success": False,
}
# Execute the action
try:
exec_result = await _execute_api_action(db, tenant_id, user_id, method, path, body)
except Exception as exc:
exec_result = {"error": str(exc), "status_code": 500}
# Get next message index
count_q = select(func.count()).select_from(
select(AIMessage).where(AIMessage.conversation_id == conversation.id).subquery()
)
count_result = await db.execute(count_q)
msg_index = count_result.scalar_one()
# Store execution message
exec_msg = AIMessage(
tenant_id=tenant_id,
conversation_id=conversation.id,
role="assistant",
content=f"Executed {method} {path}",
executed_action=action,
execution_result=exec_result,
message_index=msg_index,
)
db.add(exec_msg)
await db.flush()
await db.refresh(exec_msg)
# Log to audit
await log_audit(
db, tenant_id, user_id,
action="execute",
entity_type="ai_copilot",
entity_id=conversation.id,
changes={"action": action, "result": exec_result},
)
return {
"conversation_id": str(conversation.id),
"success": exec_result.get("success", True),
"status_code": exec_result.get("status_code", 200),
"data": exec_result.get("data"),
"error": exec_result.get("error"),
}
async def get_history(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
page: int = 1,
page_size: int = 20,
) -> dict[str, Any]:
"""Get paginated conversation history for the current user."""
page = max(1, page)
page_size = max(1, min(100, page_size))
base = select(AIConversation).where(
AIConversation.tenant_id == tenant_id,
AIConversation.user_id == user_id,
)
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(AIConversation.created_at)).offset(offset).limit(page_size)
result = await db.execute(paginated)
conversations = result.scalars().all()
items: list[dict[str, Any]] = []
for conv in conversations:
# Get messages for each conversation
msg_q = select(AIMessage).where(
AIMessage.conversation_id == conv.id,
AIMessage.tenant_id == tenant_id,
).order_by(AIMessage.message_index)
msg_result = await db.execute(msg_q)
messages = msg_result.scalars().all()
items.append({
**_conversation_to_dict(conv),
"messages": [_message_to_dict(m) for m in messages],
})
return {
"items": items,
"total": total,
"page": page,
"page_size": page_size,
}
async def _execute_api_action(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
method: str,
path: str,
body: dict[str, Any],
) -> dict[str, Any]:
"""Execute an API action directly against the database.
Supports companies and contacts CRUD, plus workflow listing.
"""
# Parse path to determine entity and operation
parts = path.replace("/api/v1/", "").strip("/").split("/")
entity = parts[0] if parts else ""
entity_id = parts[1] if len(parts) > 1 else None
if entity == "companies":
return await _exec_companies(db, tenant_id, user_id, method, entity_id, body)
elif entity == "contacts":
return await _exec_contacts(db, tenant_id, user_id, method, entity_id, body)
elif entity == "workflows":
return await _exec_workflows(db, tenant_id, user_id, method, entity_id, body)
else:
return {"error": f"Unsupported entity: {entity}", "status_code": 400, "success": False}
async def _exec_companies(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
method: str,
entity_id: str | None,
body: dict[str, Any],
) -> dict[str, Any]:
"""Execute company operations."""
if method == "GET":
result = await db.execute(
select(Company).where(
Company.tenant_id == tenant_id,
Company.deleted_at.is_(None),
)
)
companies = result.scalars().all()
return {
"success": True,
"status_code": 200,
"data": [
{"id": str(c.id), "name": c.name, "industry": c.industry}
for c in companies
],
}
elif method == "POST":
company = Company(
tenant_id=tenant_id,
name=body.get("name", "Untitled"),
industry=body.get("industry"),
phone=body.get("phone"),
email=body.get("email"),
website=body.get("website"),
description=body.get("description"),
created_by=user_id,
updated_by=user_id,
)
db.add(company)
await db.flush()
return {
"success": True,
"status_code": 201,
"data": {"id": str(company.id), "name": company.name},
}
elif method == "DELETE":
if not entity_id or entity_id == "{id}":
return {"error": "Company ID required", "status_code": 400, "success": False}
comp_uuid = uuid.UUID(entity_id)
result = await db.execute(
select(Company).where(
Company.id == comp_uuid,
Company.tenant_id == tenant_id,
)
)
company = result.scalar_one_or_none()
if company is None:
return {"error": "Company not found", "status_code": 404, "success": False}
company.deleted_at = datetime.now(timezone.utc)
await db.flush()
return {"success": True, "status_code": 200, "data": {"id": str(company.id), "deleted": True}}
elif method == "PATCH":
if not entity_id or entity_id == "{id}":
return {"error": "Company ID required", "status_code": 400, "success": False}
comp_uuid = uuid.UUID(entity_id)
result = await db.execute(
select(Company).where(
Company.id == comp_uuid,
Company.tenant_id == tenant_id,
Company.deleted_at.is_(None),
)
)
company = result.scalar_one_or_none()
if company is None:
return {"error": "Company not found", "status_code": 404, "success": False}
for key in ("name", "industry", "phone", "email", "website", "description"):
if key in body:
setattr(company, key, body[key])
company.updated_by = user_id
await db.flush()
return {
"success": True,
"status_code": 200,
"data": {"id": str(company.id), "name": company.name, "industry": company.industry},
}
return {"error": f"Unsupported method: {method}", "status_code": 400, "success": False}
async def _exec_contacts(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
method: str,
entity_id: str | None,
body: dict[str, Any],
) -> dict[str, Any]:
"""Execute contact operations."""
if method == "GET":
result = await db.execute(
select(Contact).where(
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
)
)
contacts = result.scalars().all()
return {
"success": True,
"status_code": 200,
"data": [
{"id": str(c.id), "name": c.name, "email": c.email}
for c in contacts
],
}
elif method == "POST":
contact = Contact(
tenant_id=tenant_id,
name=body.get("name", "Untitled"),
email=body.get("email"),
phone=body.get("phone"),
created_by=user_id,
updated_by=user_id,
)
db.add(contact)
await db.flush()
return {
"success": True,
"status_code": 201,
"data": {"id": str(contact.id), "name": contact.name},
}
return {"error": f"Unsupported method: {method}", "status_code": 400, "success": False}
async def _exec_workflows(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
method: str,
entity_id: str | None,
body: dict[str, Any],
) -> dict[str, Any]:
"""Execute workflow operations."""
if method == "GET":
result = await db.execute(
select(Workflow).where(
Workflow.tenant_id == tenant_id,
Workflow.is_active.is_(True),
)
)
workflows = result.scalars().all()
return {
"success": True,
"status_code": 200,
"data": [
{"id": str(w.id), "name": w.name, "trigger_event": w.trigger_event}
for w in workflows
],
}
return {"error": f"Unsupported method: {method}", "status_code": 400, "success": False}
def _derive_rbac_from_path(method: str, path: str) -> tuple[str, str]:
"""Derive module and action_type from HTTP method and path for RBAC.
Returns (module, action_type) suitable for check_permission().
"""
parts = path.replace("/api/v1/", "").strip("/").split("/")
entity = parts[0] if parts else ""
method_to_action = {
"GET": "read",
"POST": "create",
"PATCH": "update",
"DELETE": "delete",
}
action_type = method_to_action.get(method, "read")
# Map path entities to permission modules
entity_to_module = {
"companies": "companies",
"contacts": "contacts",
"workflows": "workflows",
"ai": "ai_copilot",
}
module = entity_to_module.get(entity, entity)
return module, action_type
+675
View File
@@ -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