547 lines
18 KiB
Python
547 lines
18 KiB
Python
"""Workflow step handlers — pluggable executors for each step type.
|
|
|
|
Each handler receives the step config, the workflow instance context,
|
|
and the DB session. It returns a StepResult indicating whether to
|
|
advance, wait, branch, or abort.
|
|
|
|
G-COND, G-WAIT, G-HTTP, G-MAIL, G-CAL, G-DMS, G-SEARCH, G-AGENT, G-CRM.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import uuid
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import Any
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models.workflow import WorkflowInstance
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class StepResult:
|
|
"""Result of a step execution."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
advance: bool = True,
|
|
next_index: int | None = None,
|
|
wait_until: datetime | None = None,
|
|
wait_reason: str | None = None,
|
|
output: dict[str, Any] | None = None,
|
|
error: str | None = None,
|
|
abort: bool = False,
|
|
):
|
|
self.advance = advance
|
|
self.next_index = next_index
|
|
self.wait_until = wait_until
|
|
self.wait_reason = wait_reason
|
|
self.output = output or {}
|
|
self.error = error
|
|
self.abort = abort
|
|
|
|
|
|
type StepHandler = Any # Callable[..., Awaitable[StepResult]]
|
|
|
|
|
|
# ─── Registry ────────────────────────────────────────────────────────────────
|
|
|
|
_HANDLERS: dict[str, StepHandler] = {}
|
|
|
|
|
|
def register_step_type(step_type: str):
|
|
"""Decorator to register a step handler."""
|
|
def decorator(func: StepHandler) -> StepHandler:
|
|
_HANDLERS[step_type] = func
|
|
return func
|
|
return decorator
|
|
|
|
|
|
def get_step_handler(step_type: str) -> StepHandler | None:
|
|
return _HANDLERS.get(step_type)
|
|
|
|
|
|
def get_available_step_types() -> list[str]:
|
|
return sorted(_HANDLERS.keys())
|
|
|
|
|
|
# ─── Built-in Step Handlers ──────────────────────────────────────────────────
|
|
|
|
@register_step_type("wait")
|
|
async def _handle_wait(
|
|
db: AsyncSession,
|
|
tenant_id: uuid.UUID,
|
|
instance: WorkflowInstance,
|
|
step: dict[str, Any],
|
|
) -> StepResult:
|
|
"""Wait/Delay step — sets resume_at and pauses the workflow.
|
|
|
|
Config:
|
|
duration_seconds: int — how long to wait
|
|
or
|
|
resume_at: ISO datetime — absolute resume time
|
|
"""
|
|
config = step.get("config", {})
|
|
duration = config.get("duration_seconds")
|
|
resume_at_str = config.get("resume_at")
|
|
|
|
if resume_at_str:
|
|
wait_until = datetime.fromisoformat(resume_at_str)
|
|
elif duration:
|
|
wait_until = datetime.now(UTC) + timedelta(seconds=int(duration))
|
|
else:
|
|
return StepResult(error="wait step requires duration_seconds or resume_at", abort=True)
|
|
|
|
return StepResult(advance=False, wait_until=wait_until, wait_reason="wait")
|
|
|
|
|
|
@register_step_type("http")
|
|
async def _handle_http(
|
|
db: AsyncSession,
|
|
tenant_id: uuid.UUID,
|
|
instance: WorkflowInstance,
|
|
step: dict[str, Any],
|
|
) -> StepResult:
|
|
"""HTTP Request step — sends an HTTP request and maps the response.
|
|
|
|
Config:
|
|
method: GET/POST/PUT/PATCH/DELETE
|
|
url: str
|
|
headers: dict
|
|
body: str (JSON or form)
|
|
timeout_seconds: int (default 30)
|
|
response_mapping: dict (maps response fields to context vars)
|
|
|
|
SSRF protection: blocks private/internal IPs, only allows http/https.
|
|
"""
|
|
import httpx
|
|
|
|
config = step.get("config", {})
|
|
method = config.get("method", "GET").upper()
|
|
url = config.get("url", "")
|
|
headers = config.get("headers", {})
|
|
body = config.get("body")
|
|
timeout = config.get("timeout_seconds", 30)
|
|
response_mapping = config.get("response_mapping", {})
|
|
|
|
if not url:
|
|
return StepResult(error="http step requires url", abort=True)
|
|
|
|
# SSRF protection
|
|
if not _is_url_safe(url):
|
|
return StepResult(error=f"URL blocked by SSRF protection: {url}", abort=True)
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client:
|
|
resp = await client.request(method, url, headers=headers, content=body)
|
|
|
|
output = {
|
|
"status_code": resp.status_code,
|
|
"response_body": resp.text[:10000], # Limit response size
|
|
"response_headers": dict(resp.headers),
|
|
}
|
|
|
|
# Map response fields to context
|
|
for ctx_key, resp_path in response_mapping.items():
|
|
if resp_path == "status_code":
|
|
instance.context[ctx_key] = resp.status_code
|
|
elif resp_path == "body":
|
|
instance.context[ctx_key] = resp.text[:10000]
|
|
|
|
if resp.status_code >= 400:
|
|
return StepResult(error=f"HTTP {resp.status_code}", output=output)
|
|
|
|
return StepResult(output=output)
|
|
|
|
except Exception as e:
|
|
return StepResult(error=f"HTTP request failed: {e}", abort=True)
|
|
|
|
|
|
def _is_url_safe(url: str) -> bool:
|
|
"""SSRF protection — block private/internal targets."""
|
|
import ipaddress
|
|
import urllib.parse
|
|
|
|
try:
|
|
parsed = urllib.parse.urlparse(url)
|
|
except Exception:
|
|
return False
|
|
|
|
if parsed.scheme not in ("http", "https"):
|
|
return False
|
|
|
|
hostname = parsed.hostname
|
|
if not hostname:
|
|
return False
|
|
|
|
# Block localhost and common internal hostnames
|
|
blocked_hosts = {"localhost", "127.0.0.1", "0.0.0.0", "::1", "metadata.google.internal"}
|
|
if hostname.lower() in blocked_hosts:
|
|
return False
|
|
|
|
# Block private/internal IP ranges
|
|
try:
|
|
ip = ipaddress.ip_address(hostname)
|
|
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
|
return False
|
|
except ValueError:
|
|
pass # Not an IP, it's a hostname — allow
|
|
|
|
return True
|
|
|
|
|
|
@register_step_type("mail")
|
|
async def _handle_mail(
|
|
db: AsyncSession,
|
|
tenant_id: uuid.UUID,
|
|
instance: WorkflowInstance,
|
|
step: dict[str, Any],
|
|
) -> StepResult:
|
|
"""Mail Send step — sends an email via the mail plugin.
|
|
|
|
Config:
|
|
to: str (recipient email)
|
|
subject: str
|
|
body: str
|
|
account_id: str (optional, uses default if not set)
|
|
"""
|
|
config = step.get("config", {})
|
|
to = config.get("to", "")
|
|
subject = config.get("subject", "")
|
|
body = config.get("body", "")
|
|
|
|
if not to or not subject:
|
|
return StepResult(error="mail step requires to and subject", abort=True)
|
|
|
|
try:
|
|
from app.plugins.builtins.mail.contracts import MailContract
|
|
contract = MailContract
|
|
send_fn = contract.get_function("send_email")
|
|
if send_fn is None:
|
|
return StepResult(error="mail plugin not available", abort=True)
|
|
|
|
result = await send_fn(
|
|
db=db,
|
|
tenant_id=tenant_id,
|
|
to=to,
|
|
subject=subject,
|
|
body=body,
|
|
account_id=config.get("account_id"),
|
|
)
|
|
return StepResult(output={"mail_result": result} if result else {})
|
|
except Exception as e:
|
|
logger.warning("mail step failed (plugin may not be active): %s", e)
|
|
return StepResult(error=f"mail send failed: {e}")
|
|
|
|
|
|
@register_step_type("calendar")
|
|
async def _handle_calendar(
|
|
db: AsyncSession,
|
|
tenant_id: uuid.UUID,
|
|
instance: WorkflowInstance,
|
|
step: dict[str, Any],
|
|
) -> StepResult:
|
|
"""Calendar step — creates/updates/deletes calendar events.
|
|
|
|
Config:
|
|
action: create|update|delete
|
|
title: str (for create/update)
|
|
start: ISO datetime (for create/update)
|
|
end: ISO datetime (for create/update)
|
|
event_id: str (for update/delete)
|
|
"""
|
|
config = step.get("config", {})
|
|
action = config.get("action", "create")
|
|
|
|
try:
|
|
from app.plugins.builtins.calendar.contracts import CalendarContract
|
|
contract = CalendarContract
|
|
if action == "create":
|
|
fn = contract.get_function("create_event")
|
|
if fn is None:
|
|
return StepResult(error="calendar plugin not available", abort=True)
|
|
result = await fn(
|
|
db=db,
|
|
tenant_id=tenant_id,
|
|
title=config.get("title", ""),
|
|
start=config.get("start"),
|
|
end=config.get("end"),
|
|
)
|
|
return StepResult(output={"event": result} if result else {})
|
|
elif action == "delete":
|
|
fn = contract.get_function("delete_event")
|
|
if fn is None:
|
|
return StepResult(error="calendar plugin not available", abort=True)
|
|
await fn(db=db, tenant_id=tenant_id, event_id=config.get("event_id", ""))
|
|
return StepResult()
|
|
else:
|
|
return StepResult(error=f"unknown calendar action: {action}", abort=True)
|
|
except Exception as e:
|
|
logger.warning("calendar step failed: %s", e)
|
|
return StepResult(error=f"calendar action failed: {e}")
|
|
|
|
|
|
@register_step_type("dms")
|
|
async def _handle_dms(
|
|
db: AsyncSession,
|
|
tenant_id: uuid.UUID,
|
|
instance: WorkflowInstance,
|
|
step: dict[str, Any],
|
|
) -> StepResult:
|
|
"""DMS step — interacts with the document management system.
|
|
|
|
Config:
|
|
action: search|download|metadata
|
|
query: str (for search)
|
|
file_id: str (for download/metadata)
|
|
"""
|
|
config = step.get("config", {})
|
|
action = config.get("action", "search")
|
|
|
|
try:
|
|
from app.plugins.builtins.dms.contracts import DmsContract
|
|
contract = DmsContract
|
|
|
|
if action == "search":
|
|
fn = contract.get_function("search_files")
|
|
if fn is None:
|
|
return StepResult(error="dms plugin not available", abort=True)
|
|
results = await fn(db=db, tenant_id=tenant_id, query=config.get("query", ""))
|
|
return StepResult(output={"files": results} if results else {})
|
|
elif action == "metadata":
|
|
fn = contract.get_function("get_file_metadata")
|
|
if fn is None:
|
|
return StepResult(error="dms plugin not available", abort=True)
|
|
metadata = await fn(db=db, tenant_id=tenant_id, file_id=config.get("file_id", ""))
|
|
return StepResult(output={"metadata": metadata} if metadata else {})
|
|
else:
|
|
return StepResult(error=f"unknown dms action: {action}", abort=True)
|
|
except Exception as e:
|
|
logger.warning("dms step failed: %s", e)
|
|
return StepResult(error=f"dms action failed: {e}")
|
|
|
|
|
|
@register_step_type("search")
|
|
async def _handle_search(
|
|
db: AsyncSession,
|
|
tenant_id: uuid.UUID,
|
|
instance: WorkflowInstance,
|
|
step: dict[str, Any],
|
|
) -> StepResult:
|
|
"""Search step — runs a unified search query.
|
|
|
|
Config:
|
|
query: str
|
|
entity_type: str (optional filter)
|
|
limit: int (default 20)
|
|
"""
|
|
config = step.get("config", {})
|
|
query = config.get("query", "")
|
|
entity_type = config.get("entity_type")
|
|
limit = config.get("limit", 20)
|
|
|
|
if not query:
|
|
return StepResult(error="search step requires query", abort=True)
|
|
|
|
try:
|
|
from app.plugins.builtins.unified_search.contracts import SearchContract
|
|
contract = SearchContract
|
|
fn = contract.get_function("unified_search")
|
|
if fn is None:
|
|
return StepResult(error="search plugin not available", abort=True)
|
|
results = await fn(
|
|
db=db,
|
|
tenant_id=tenant_id,
|
|
query=query,
|
|
entity_type=entity_type,
|
|
limit=limit,
|
|
)
|
|
# Store results in context for later steps
|
|
instance.context["search_results"] = results
|
|
return StepResult(output={"results": results} if results else {})
|
|
except Exception as e:
|
|
logger.warning("search step failed: %s", e)
|
|
return StepResult(error=f"search failed: {e}")
|
|
|
|
|
|
@register_step_type("agent")
|
|
async def _handle_agent(
|
|
db: AsyncSession,
|
|
tenant_id: uuid.UUID,
|
|
instance: WorkflowInstance,
|
|
step: dict[str, Any],
|
|
) -> StepResult:
|
|
"""Agent step — invokes an autonomous AI agent.
|
|
|
|
Config:
|
|
agent_id: str
|
|
input: dict (passed as initial messages)
|
|
wait_for_completion: bool (default True)
|
|
"""
|
|
config = step.get("config", {})
|
|
agent_id = config.get("agent_id", "")
|
|
user_input = config.get("input", {})
|
|
wait = config.get("wait_for_completion", True)
|
|
|
|
if not agent_id:
|
|
return StepResult(error="agent step requires agent_id", abort=True)
|
|
|
|
try:
|
|
from app.plugins.builtins.automation.contracts import AutomationContract
|
|
contract = AutomationContract
|
|
fn = contract.get_function("run_agent")
|
|
if fn is None:
|
|
return StepResult(error="automation plugin not available", abort=True)
|
|
result = await fn(
|
|
db=db,
|
|
tenant_id=tenant_id,
|
|
agent_id=uuid.UUID(agent_id),
|
|
input_data=user_input,
|
|
wait_for_completion=wait,
|
|
)
|
|
instance.context["agent_result"] = result
|
|
return StepResult(output={"agent_result": result} if result else {})
|
|
except Exception as e:
|
|
logger.warning("agent step failed: %s", e)
|
|
return StepResult(error=f"agent execution failed: {e}")
|
|
|
|
|
|
@register_step_type("crm")
|
|
async def _handle_crm(
|
|
db: AsyncSession,
|
|
tenant_id: uuid.UUID,
|
|
instance: WorkflowInstance,
|
|
step: dict[str, Any],
|
|
) -> StepResult:
|
|
"""CRM Action step — create/update/delete contacts and companies.
|
|
|
|
Config:
|
|
action: create_contact|update_contact|create_company|update_company|delete_contact|delete_company
|
|
data: dict (entity fields)
|
|
entity_id: str (for update/delete)
|
|
"""
|
|
config = step.get("config", {})
|
|
action = config.get("action", "")
|
|
data = config.get("data", {})
|
|
entity_id = config.get("entity_id")
|
|
|
|
if not action:
|
|
return StepResult(error="crm step requires action", abort=True)
|
|
|
|
try:
|
|
if action == "create_contact":
|
|
from app.services.contact_service import create_contact
|
|
result = await create_contact(db, tenant_id, data)
|
|
return StepResult(output={"contact": result} if result else {})
|
|
elif action == "update_contact":
|
|
from app.services.contact_service import update_contact
|
|
if not entity_id:
|
|
return StepResult(error="update_contact requires entity_id", abort=True)
|
|
result = await update_contact(db, tenant_id, uuid.UUID(entity_id), data)
|
|
return StepResult(output={"contact": result} if result else {})
|
|
elif action == "create_company":
|
|
from app.services.contact_service import create_contact
|
|
company_data = {**data, "type": "company"}
|
|
result = await create_contact(db, tenant_id, company_data)
|
|
return StepResult(output={"company": result} if result else {})
|
|
elif action == "update_company":
|
|
from app.services.contact_service import update_contact
|
|
if not entity_id:
|
|
return StepResult(error="update_company requires entity_id", abort=True)
|
|
result = await update_contact(db, tenant_id, uuid.UUID(entity_id), data)
|
|
return StepResult(output={"company": result} if result else {})
|
|
else:
|
|
return StepResult(error=f"unknown crm action: {action}", abort=True)
|
|
except Exception as e:
|
|
logger.warning("crm step failed: %s", e)
|
|
return StepResult(error=f"crm action failed: {e}")
|
|
|
|
|
|
@register_step_type("event")
|
|
async def _handle_event(
|
|
db: AsyncSession,
|
|
tenant_id: uuid.UUID,
|
|
instance: WorkflowInstance,
|
|
step: dict[str, Any],
|
|
) -> StepResult:
|
|
"""Event step — publishes an event to the event bus.
|
|
|
|
Config:
|
|
event_name: str
|
|
payload: dict
|
|
"""
|
|
config = step.get("config", {})
|
|
event_name = config.get("event_name", "")
|
|
payload = config.get("payload", {})
|
|
|
|
if not event_name:
|
|
return StepResult(error="event step requires event_name", abort=True)
|
|
|
|
from app.core.event_bus import get_event_bus
|
|
event_bus = get_event_bus()
|
|
await event_bus.publish(event_name, {
|
|
**payload,
|
|
"tenant_id": str(tenant_id),
|
|
"workflow_instance_id": str(instance.id),
|
|
})
|
|
return StepResult(output={"event_published": event_name})
|
|
|
|
|
|
@register_step_type("webhook")
|
|
async def _handle_webhook(
|
|
db: AsyncSession,
|
|
tenant_id: uuid.UUID,
|
|
instance: WorkflowInstance,
|
|
step: dict[str, Any],
|
|
) -> StepResult:
|
|
"""Webhook step — sends an outgoing webhook.
|
|
|
|
Config:
|
|
url: str
|
|
method: str (default POST)
|
|
headers: dict
|
|
body: dict
|
|
secret: str (for HMAC signing)
|
|
"""
|
|
config = step.get("config", {})
|
|
url = config.get("url", "")
|
|
method = config.get("method", "POST").upper()
|
|
headers = config.get("headers", {})
|
|
body = config.get("body", {})
|
|
|
|
if not url:
|
|
return StepResult(error="webhook step requires url", abort=True)
|
|
|
|
if not _is_url_safe(url):
|
|
return StepResult(error=f"URL blocked by SSRF protection: {url}", abort=True)
|
|
|
|
import json
|
|
import httpx
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=30, follow_redirects=False) as client:
|
|
resp = await client.request(
|
|
method,
|
|
url,
|
|
headers={"Content-Type": "application/json", **headers},
|
|
content=json.dumps(body),
|
|
)
|
|
return StepResult(output={
|
|
"status_code": resp.status_code,
|
|
"response_body": resp.text[:5000],
|
|
})
|
|
except Exception as e:
|
|
return StepResult(error=f"webhook failed: {e}", abort=True)
|
|
|
|
|
|
__all__ = [
|
|
"StepResult",
|
|
"register_step_type",
|
|
"get_step_handler",
|
|
"get_available_step_types",
|
|
]
|