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:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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