feat(G): G-RUN/G-CTX/G-WAIT/G-HTTP/G-MAIL/G-CAL/G-DMS/G-SEARCH/G-AGENT/G-CRM/G-EVT/G-WEB — Durable WorkflowRun, 10 step handlers, resume/wait/lock/retry, SSRF protection, frontend step editor

This commit is contained in:
Agent Zero
2026-08-18 00:29:58 +02:00
parent 4ec2ac9eb5
commit db41e60042
8 changed files with 1783 additions and 154 deletions
@@ -0,0 +1,84 @@
"""Durable WorkflowRun — resume semantics, step state, idempotency (G-RUN, G-CTX).
Extends workflow_instances with resume_at, resume_reason, step_state,
idempotency_key, and lock_owner for durable/resumable workflow execution.
Revision ID: 0125
Revises: 0124
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
revision = "0125"
down_revision = "0124"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ── Add durable/resumable columns to workflow_instances ──────────────
op.add_column(
"workflow_instances",
sa.Column("resume_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"workflow_instances",
sa.Column("resume_reason", sa.String(50), nullable=True),
)
op.add_column(
"workflow_instances",
sa.Column("step_state", JSONB, nullable=False, server_default="{}"),
)
op.add_column(
"workflow_instances",
sa.Column("idempotency_key", sa.String(255), nullable=True),
)
op.add_column(
"workflow_instances",
sa.Column("lock_owner", sa.String(100), nullable=True),
)
op.add_column(
"workflow_instances",
sa.Column("lock_expires_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"workflow_instances",
sa.Column("error_message", sa.Text, nullable=True),
)
op.add_column(
"workflow_instances",
sa.Column("retry_count", sa.Integer, nullable=False, server_default="0"),
)
op.add_column(
"workflow_instances",
sa.Column("max_retries", sa.Integer, nullable=False, server_default="3"),
)
# Index for finding workflows that need to be resumed
op.create_index(
"ix_wf_instances_resume",
"workflow_instances",
["tenant_id", "status", "resume_at"],
)
# Index for idempotency key lookup
op.create_index(
"ix_wf_instances_idempotency",
"workflow_instances",
["tenant_id", "idempotency_key"],
)
def downgrade() -> None:
op.drop_index("ix_wf_instances_idempotency", table_name="workflow_instances")
op.drop_index("ix_wf_instances_resume", table_name="workflow_instances")
op.drop_column("workflow_instances", "max_retries")
op.drop_column("workflow_instances", "retry_count")
op.drop_column("workflow_instances", "error_message")
op.drop_column("workflow_instances", "lock_expires_at")
op.drop_column("workflow_instances", "lock_owner")
op.drop_column("workflow_instances", "idempotency_key")
op.drop_column("workflow_instances", "step_state")
op.drop_column("workflow_instances", "resume_reason")
op.drop_column("workflow_instances", "resume_at")
+10
View File
@@ -65,6 +65,16 @@ class WorkflowInstance(Base, TenantMixin):
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
timeout_hours: Mapped[int | None] = mapped_column(Integer, nullable=True) timeout_hours: Mapped[int | None] = mapped_column(Integer, nullable=True)
timeout_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) timeout_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# G-RUN: Durable/Resume semantics
resume_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
resume_reason: Mapped[str | None] = mapped_column(String(50), nullable=True) # wait, approval, event, webhook, cron
step_state: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, nullable=False, server_default="{}")
idempotency_key: Mapped[str | None] = mapped_column(String(255), nullable=True)
lock_owner: Mapped[str | None] = mapped_column(String(100), nullable=True)
lock_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
max_retries: Mapped[int] = mapped_column(Integer, nullable=False, default=3, server_default="3")
class WorkflowStepHistory(Base, TenantMixin): class WorkflowStepHistory(Base, TenantMixin):
+1 -1
View File
@@ -9,7 +9,7 @@ class WorkflowStep(BaseModel):
"""A single step in a workflow definition.""" """A single step in a workflow definition."""
name: str = Field(..., min_length=1, max_length=200) name: str = Field(..., min_length=1, max_length=200)
type: str = Field(..., pattern="^(action|approval|notification|condition)$") type: str = Field(..., pattern="^(action|approval|notification|condition|wait|http|mail|calendar|dms|search|agent|crm|event|webhook)$")
config: dict = Field(default_factory=dict) config: dict = Field(default_factory=dict)
description: str | None = None description: str | None = None
+287 -12
View File
@@ -1,13 +1,19 @@
"""Workflow execution engine — step processing, conditions, approvals. """Workflow execution engine — step processing, conditions, approvals, wait/resume.
Processes workflow instances by evaluating steps sequentially. Processes workflow instances by evaluating steps sequentially.
Supports step types: action, approval, notification, condition. Supports step types: action, approval, notification, condition, wait,
Integrates with the event bus for event-triggered workflows. http, mail, calendar, dms, search, agent, crm, event, webhook.
G-RUN: Durable/resumable with resume_at, step_state, lock_owner.
G-RETRY: Retry with backoff for failed steps.
G-IDEMP: Idempotency key for side-effect steps.
G-LOG: Execution log per step (input, output, duration, status).
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
import time
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
@@ -24,6 +30,7 @@ from app.services.workflow_service import (
create_instance, create_instance,
find_workflows_for_event, find_workflows_for_event,
) )
from app.workflows.step_handlers import StepResult, get_step_handler
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -31,11 +38,21 @@ logger = logging.getLogger(__name__)
class WorkflowEngine: class WorkflowEngine:
"""Processes workflow instances through their defined steps. """Processes workflow instances through their defined steps.
Step types: Step types (built-in):
- action: Executes a configured action (e.g. create entity, send notification) - action: Executes a configured action (e.g. create entity, send notification)
- approval: Pauses and waits for user approve/reject via API - approval: Pauses and waits for user approve/reject via API
- notification: Sends a notification to specified users - notification: Sends a notification to specified users
- condition: Evaluates a condition and branches accordingly - condition: Evaluates a condition and branches accordingly
- wait: Pauses for a duration or until a specific time (G-WAIT)
- http: Sends an HTTP request with SSRF protection (G-HTTP)
- mail: Sends an email via the mail plugin (G-MAIL)
- calendar: Creates/updates/deletes calendar events (G-CAL)
- dms: Interacts with the document management system (G-DMS)
- search: Runs a unified search query (G-SEARCH)
- agent: Invokes an autonomous AI agent (G-AGENT)
- crm: Creates/updates/deletes contacts and companies (G-CRM)
- event: Publishes an event to the event bus (G-EVT)
- webhook: Sends an outgoing webhook (G-WEB)
""" """
def __init__(self, db: AsyncSession, tenant_id: uuid.UUID): def __init__(self, db: AsyncSession, tenant_id: uuid.UUID):
@@ -45,8 +62,9 @@ class WorkflowEngine:
async def process_step(self, instance: WorkflowInstance) -> dict[str, Any]: async def process_step(self, instance: WorkflowInstance) -> dict[str, Any]:
"""Process the current step of a workflow instance. """Process the current step of a workflow instance.
For action/notification/condition steps: executes and advances. For action/notification/condition/wait/http/mail/etc steps: executes and advances.
For approval steps: sets status to in_progress and waits. For approval steps: sets status to in_progress and waits.
For wait steps: sets resume_at and pauses.
Returns the updated instance dict. Returns the updated instance dict.
""" """
wf_result = await self.db.execute( wf_result = await self.db.execute(
@@ -76,7 +94,8 @@ class WorkflowEngine:
step = steps[instance.current_step_index] step = steps[instance.current_step_index]
step_type = step.get("type", "action") step_type = step.get("type", "action")
# Log step entry # Log step entry (G-LOG)
step_start_time = time.monotonic()
await _log_step_history( await _log_step_history(
self.db, self.db,
self.tenant_id, self.tenant_id,
@@ -84,28 +103,180 @@ class WorkflowEngine:
step_index=instance.current_step_index, step_index=instance.current_step_index,
step_type=step_type, step_type=step_type,
action="processing", action="processing",
details={"step_name": step.get("name")}, details={"step_name": step.get("name"), "config": step.get("config", {})},
) )
# Approval steps pause and wait for user input
if step_type == "approval": if step_type == "approval":
# Approval steps pause and wait for user input
if instance.status == "pending": if instance.status == "pending":
instance.status = "in_progress" instance.status = "in_progress"
instance.resume_reason = "approval"
await self.db.flush() await self.db.flush()
return _instance_to_dict(instance) return _instance_to_dict(instance)
elif step_type == "notification": # Try registered step handlers first (new G step types)
return await self._process_notification(instance, step) handler = get_step_handler(step_type)
if handler is not None:
return await self._process_with_handler(
instance, step, steps, handler, step_start_time
)
# Legacy step types (action, notification, condition)
if step_type == "notification":
return await self._process_notification(instance, step)
elif step_type == "condition": elif step_type == "condition":
return await self._process_condition(instance, step, steps) return await self._process_condition(instance, step, steps)
elif step_type == "action": elif step_type == "action":
return await self._process_action(instance, step, steps) return await self._process_action(instance, step, steps)
else: else:
return {"error": f"Unknown step type: {step_type}", "status_code": 400} return {"error": f"Unknown step type: {step_type}", "status_code": 400}
async def _process_with_handler(
self,
instance: WorkflowInstance,
step: dict,
steps: list,
handler: Any,
step_start_time: float,
) -> dict[str, Any]:
"""Execute a step using a registered step handler (G step types)."""
step_type = step.get("type", "action")
try:
result: StepResult = await handler(
self.db,
self.tenant_id,
instance,
step,
)
except Exception as e:
logger.exception("Step handler %s failed", step_type)
result = StepResult(error=str(e), abort=True)
duration_ms = int((time.monotonic() - step_start_time) * 1000)
# Handle abort
if result.abort:
instance.status = "failed"
instance.error_message = result.error or "Unknown error"
await _log_step_history(
self.db,
self.tenant_id,
instance.id,
step_index=instance.current_step_index,
step_type=step_type,
action="failed",
details={
"error": result.error,
"duration_ms": duration_ms,
"output": result.output,
},
)
await self.db.flush()
return _instance_to_dict(instance)
# Handle wait/resume (G-WAIT)
if result.wait_until is not None:
instance.status = "waiting"
instance.resume_at = result.wait_until
instance.resume_reason = result.wait_reason or "wait"
await _log_step_history(
self.db,
self.tenant_id,
instance.id,
step_index=instance.current_step_index,
step_type=step_type,
action="waiting",
details={
"resume_at": result.wait_until.isoformat(),
"reason": result.wait_reason,
"duration_ms": duration_ms,
},
)
await self.db.flush()
return _instance_to_dict(instance)
# Handle error (non-abort — retryable)
if result.error:
instance.retry_count += 1
if instance.retry_count >= instance.max_retries:
instance.status = "failed"
instance.error_message = result.error
await _log_step_history(
self.db,
self.tenant_id,
instance.id,
step_index=instance.current_step_index,
step_type=step_type,
action="failed",
details={
"error": result.error,
"duration_ms": duration_ms,
"retry_count": instance.retry_count,
},
)
await self.db.flush()
return _instance_to_dict(instance)
else:
# Retry: stay on same step, set resume_at with backoff
import asyncio
backoff = min(2 ** instance.retry_count, 60)
from datetime import timedelta
instance.resume_at = datetime.now(UTC) + timedelta(seconds=backoff)
instance.resume_reason = "retry"
instance.status = "waiting"
await _log_step_history(
self.db,
self.tenant_id,
instance.id,
step_index=instance.current_step_index,
step_type=step_type,
action="retry",
details={
"error": result.error,
"retry_count": instance.retry_count,
"resume_at": instance.resume_at.isoformat(),
"duration_ms": duration_ms,
},
)
await self.db.flush()
return _instance_to_dict(instance)
# Handle branch (next_index override)
if result.next_index is not None:
instance.current_step_index = result.next_index
instance.status = "in_progress"
elif result.advance:
# Advance to next step
next_idx = instance.current_step_index + 1
if next_idx >= len(steps):
instance.status = "completed"
instance.completed_at = datetime.now(UTC)
else:
instance.current_step_index = next_idx
instance.status = "in_progress"
# Store step output in step_state (G-CTX)
step_key = f"step_{instance.current_step_index}_output"
instance.step_state[step_key] = result.output
# Log success (G-LOG)
await _log_step_history(
self.db,
self.tenant_id,
instance.id,
step_index=instance.current_step_index,
step_type=step_type,
action="executed",
details={
"output": result.output,
"duration_ms": duration_ms,
},
)
await self.db.flush()
return _instance_to_dict(instance)
async def _process_action( async def _process_action(
self, instance: WorkflowInstance, step: dict, steps: list self, instance: WorkflowInstance, step: dict, steps: list
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -259,6 +430,90 @@ class WorkflowEngine:
await self.db.flush() await self.db.flush()
return _instance_to_dict(instance) return _instance_to_dict(instance)
async def resume(self, instance: WorkflowInstance) -> dict[str, Any]:
"""Resume a waiting workflow instance.
Called when resume_at has passed, an approval is decided,
or an event/webhook triggers a resume.
Clears resume_at/resume_reason and processes the current step.
"""
if instance.status != "waiting":
return _instance_to_dict(instance)
# Clear resume state
instance.resume_at = None
instance.resume_reason = None
instance.status = "in_progress"
# For wait steps, advance to next step after resume
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):
step = steps[instance.current_step_index]
step_type = step.get("type", "action")
# If it was a wait step, advance to next
if step_type == "wait":
next_idx = instance.current_step_index + 1
if next_idx >= len(steps):
instance.status = "completed"
instance.completed_at = datetime.now(UTC)
else:
instance.current_step_index = next_idx
await self.db.flush()
# Process the next step
return await self.process_step(instance)
async def acquire_lock(
self, instance: WorkflowInstance, owner: str, ttl_seconds: int = 300
) -> bool:
"""Acquire a Redis lock for a workflow instance (G-RUN concurrency).
Prevents two workers from processing the same instance simultaneously.
"""
from app.core.redis import get_redis
import redis.asyncio as aioredis
try:
r = await get_redis()
lock_key = f"workflow_lock:{instance.id}"
acquired = await r.set(
lock_key,
owner,
nx=True,
ex=ttl_seconds,
)
if acquired:
instance.lock_owner = owner
instance.lock_expires_at = datetime.now(UTC) + timedelta(seconds=ttl_seconds)
await self.db.flush()
return True
return False
except Exception as e:
logger.warning("Failed to acquire workflow lock: %s", e)
return True # Fail open — allow processing without lock
async def release_lock(self, instance: WorkflowInstance) -> None:
"""Release the Redis lock for a workflow instance."""
from app.core.redis import get_redis
try:
r = await get_redis()
lock_key = f"workflow_lock:{instance.id}"
await r.delete(lock_key)
instance.lock_owner = None
instance.lock_expires_at = None
await self.db.flush()
except Exception as e:
logger.warning("Failed to release workflow lock: %s", e)
async def handle_event( async def handle_event(
db: AsyncSession, db: AsyncSession,
@@ -314,3 +569,23 @@ def register_workflow_event_handlers() -> None:
# Subscribe to ALL events via wildcard '*' — the handler dynamically # Subscribe to ALL events via wildcard '*' — the handler dynamically
# queries for workflows whose trigger_event matches the published event. # queries for workflows whose trigger_event matches the published event.
event_bus.subscribe('*', _workflow_event_handler) event_bus.subscribe('*', _workflow_event_handler)
async def find_resumable_workflows(
db: AsyncSession,
tenant_id: uuid.UUID,
) -> list[WorkflowInstance]:
"""Find workflow instances that are waiting and their resume_at has passed.
Called by the ARQ cron job to resume waiting workflows.
"""
now = datetime.now(UTC)
result = await db.execute(
select(WorkflowInstance).where(
WorkflowInstance.tenant_id == tenant_id,
WorkflowInstance.status == "waiting",
WorkflowInstance.resume_at.is_not(None),
WorkflowInstance.resume_at <= now,
)
)
return list(result.scalars().all())
+545
View File
@@ -0,0 +1,545 @@
"""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.company_service import create_company
result = await create_company(db, tenant_id, data)
return StepResult(output={"company": result} if result else {})
elif action == "update_company":
from app.services.company_service import update_company
if not entity_id:
return StepResult(error="update_company requires entity_id", abort=True)
result = await update_company(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",
]
+15 -1
View File
@@ -11,9 +11,23 @@ import type { PaginatedResponse } from './types';
// ── Types ── // ── Types ──
export type WorkflowStepType =
| 'action'
| 'approval'
| 'notification'
| 'condition'
| 'wait'
| 'http'
| 'mail'
| 'calendar'
| 'dms'
| 'search'
| 'agent'
| 'crm';
export interface WorkflowStep { export interface WorkflowStep {
name: string; name: string;
type: 'action' | 'approval' | 'notification' | 'condition'; type: WorkflowStepType;
config: Record<string, unknown>; config: Record<string, unknown>;
description?: string | null; description?: string | null;
} }
@@ -1,21 +1,51 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Select } from '@/components/ui/Select'; import { Select } from '@/components/ui/Select';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import type { WorkflowStep } from '@/api/workflows'; import { Code2, FormInput } from 'lucide-react';
import type { WorkflowStep, WorkflowStepType } from '@/api/workflows';
const stepTypeOptions = [ const stepTypeOptions: { value: WorkflowStepType; label: string }[] = [
{ value: 'action', label: 'Action' }, { value: 'action', label: 'Action' },
{ value: 'approval', label: 'Approval' }, { value: 'approval', label: 'Approval' },
{ value: 'notification', label: 'Notification' }, { value: 'notification', label: 'Notification' },
{ value: 'condition', label: 'Condition' }, { value: 'condition', label: 'Condition' },
{ value: 'wait', label: 'Wait / Delay' },
{ value: 'http', label: 'HTTP Request' },
{ value: 'mail', label: 'Mail Send' },
{ value: 'calendar', label: 'Calendar' },
{ value: 'dms', label: 'DMS' },
{ value: 'search', label: 'Search' },
{ value: 'agent', label: 'Agent' },
{ value: 'crm', label: 'CRM Action' },
]; ];
const configHints: Record<string, string> = { const httpMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].map((m) => ({
action: 'Config keys: action_type, target, params', value: m,
approval: 'Config keys: approver_role, timeout_hours', label: m,
notification: 'Config keys: channel, template, recipients', }));
condition: 'Config keys: field, operator, value',
}; const calendarActions = [
{ value: 'create', label: 'Create' },
{ value: 'update', label: 'Update' },
{ value: 'delete', label: 'Delete' },
];
const dmsActions = [
{ value: 'search', label: 'Search' },
{ value: 'download', label: 'Download' },
{ value: 'upload', label: 'Upload' },
];
const crmActions = [
{ value: 'create_contact', label: 'Create Contact' },
{ value: 'update_contact', label: 'Update Contact' },
{ value: 'create_company', label: 'Create Company' },
{ value: 'update_company', label: 'Update Company' },
{ value: 'delete_contact', label: 'Delete Contact' },
{ value: 'delete_company', label: 'Delete Company' },
];
type ConfigMode = 'form' | 'json';
export interface StepConfigPanelProps { export interface StepConfigPanelProps {
step: WorkflowStep; step: WorkflowStep;
@@ -23,6 +53,7 @@ export interface StepConfigPanelProps {
} }
export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) { export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
const [mode, setMode] = useState<ConfigMode>('form');
const [configText, setConfigText] = useState(''); const [configText, setConfigText] = useState('');
const [configError, setConfigError] = useState<string | undefined>(undefined); const [configError, setConfigError] = useState<string | undefined>(undefined);
@@ -31,6 +62,10 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
setConfigError(undefined); setConfigError(undefined);
}, [step.config]); }, [step.config]);
const setConfig = (key: string, value: unknown) => {
onChange({ ...step, config: { ...step.config, [key]: value } });
};
const handleConfigChange = (value: string) => { const handleConfigChange = (value: string) => {
setConfigText(value); setConfigText(value);
try { try {
@@ -42,6 +77,345 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
} }
}; };
const strVal = (key: string): string => {
const v = step.config[key];
return typeof v === 'string' ? v : '';
};
const numVal = (key: string): string => {
const v = step.config[key];
return typeof v === 'number' ? String(v) : '';
};
const boolVal = (key: string): boolean => {
const v = step.config[key];
return typeof v === 'boolean' ? v : false;
};
const jsonVal = (key: string): string => {
const v = step.config[key];
if (v === undefined || v === null) return '';
return JSON.stringify(v, null, 2);
};
const setJsonField = (key: string, value: string) => {
if (!value.trim()) {
setConfig(key, {});
return;
}
try {
setConfig(key, JSON.parse(value));
} catch {
// invalid JSON — JsonField shows the error, keep previous value
}
};
const renderTypeForm = () => {
switch (step.type) {
case 'wait':
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Dauer (Sekunden)"
type="number"
min={0}
value={numVal('duration_seconds')}
onChange={(e) =>
setConfig(
'duration_seconds',
e.target.value === '' ? undefined : Number(e.target.value)
)
}
placeholder="z.B. 3600"
/>
<Input
label="Resume-Zeitpunkt (ISO)"
value={strVal('resume_at')}
onChange={(e) => setConfig('resume_at', e.target.value || undefined)}
placeholder="2026-08-18T09:00:00Z"
/>
</div>
);
case 'http':
return (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Select
label="Methode"
options={httpMethods}
value={strVal('method') || 'GET'}
onChange={(e) => setConfig('method', e.target.value)}
/>
<Input
label="URL"
required
value={strVal('url')}
onChange={(e) => setConfig('url', e.target.value)}
placeholder="https://api.example.com/webhook"
/>
</div>
<JsonField
label="Headers (JSON)"
value={jsonVal('headers')}
onChange={(v) => setJsonField('headers', v)}
/>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
Body
</label>
<textarea
value={strVal('body')}
onChange={(e) => setConfig('body', e.target.value)}
rows={3}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder='{"key": "value"}'
/>
</div>
<Input
label="Timeout (Sekunden)"
type="number"
min={1}
value={numVal('timeout_seconds')}
onChange={(e) =>
setConfig(
'timeout_seconds',
e.target.value === '' ? undefined : Number(e.target.value)
)
}
/>
</div>
);
case 'mail':
return (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="An (E-Mail)"
required
value={strVal('to')}
onChange={(e) => setConfig('to', e.target.value)}
placeholder="empfaenger@example.com"
/>
<Input
label="Betreff"
required
value={strVal('subject')}
onChange={(e) => setConfig('subject', e.target.value)}
placeholder="Betreff"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
Body
</label>
<textarea
value={strVal('body')}
onChange={(e) => setConfig('body', e.target.value)}
rows={4}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Nachrichtentext"
/>
</div>
<Input
label="Account-ID (optional)"
value={strVal('account_id')}
onChange={(e) => setConfig('account_id', e.target.value || undefined)}
placeholder="Standard-Konto wenn leer"
/>
</div>
);
case 'calendar': {
const action = strVal('action') || 'create';
return (
<div className="space-y-4">
<Select
label="Aktion"
options={calendarActions}
value={action}
onChange={(e) => setConfig('action', e.target.value)}
/>
{(action === 'create' || action === 'update') && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Input
label="Titel"
value={strVal('title')}
onChange={(e) => setConfig('title', e.target.value)}
placeholder="Event-Titel"
/>
<Input
label="Start (ISO)"
value={strVal('start')}
onChange={(e) => setConfig('start', e.target.value)}
placeholder="2026-08-18T09:00:00Z"
/>
<Input
label="Ende (ISO)"
value={strVal('end')}
onChange={(e) => setConfig('end', e.target.value)}
placeholder="2026-08-18T10:00:00Z"
/>
</div>
)}
{(action === 'update' || action === 'delete') && (
<Input
label="Event-ID"
value={strVal('event_id')}
onChange={(e) => setConfig('event_id', e.target.value)}
placeholder="Event-UUID"
/>
)}
</div>
);
}
case 'dms': {
const action = strVal('action') || 'search';
return (
<div className="space-y-4">
<Select
label="Aktion"
options={dmsActions}
value={action}
onChange={(e) => setConfig('action', e.target.value)}
/>
{action === 'search' && (
<Input
label="Suchbegriff"
value={strVal('query')}
onChange={(e) => setConfig('query', e.target.value)}
placeholder="Suchbegriff"
/>
)}
{action === 'download' && (
<Input
label="Datei-ID"
value={strVal('file_id')}
onChange={(e) => setConfig('file_id', e.target.value)}
placeholder="Datei-UUID"
/>
)}
{action === 'upload' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Dateiname"
value={strVal('file_name')}
onChange={(e) => setConfig('file_name', e.target.value)}
placeholder="datei.pdf"
/>
<Input
label="Inhalt"
value={strVal('content')}
onChange={(e) => setConfig('content', e.target.value)}
placeholder="Dateiinhalt"
/>
</div>
)}
</div>
);
}
case 'search':
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Suchbegriff"
required
value={strVal('query')}
onChange={(e) => setConfig('query', e.target.value)}
placeholder="Suchbegriff"
/>
<Input
label="Entity-Typ (optional)"
value={strVal('entity_type')}
onChange={(e) => setConfig('entity_type', e.target.value || undefined)}
placeholder="contact, company, file, ..."
/>
<Input
label="Limit"
type="number"
min={1}
value={numVal('limit')}
onChange={(e) =>
setConfig('limit', e.target.value === '' ? undefined : Number(e.target.value))
}
/>
</div>
);
case 'agent':
return (
<div className="space-y-4">
<Input
label="Agent-ID"
required
value={strVal('agent_id')}
onChange={(e) => setConfig('agent_id', e.target.value)}
placeholder="Agent-UUID"
/>
<JsonField
label="Input (JSON)"
value={jsonVal('input')}
onChange={(v) => setJsonField('input', v)}
/>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={boolVal('wait_for_completion')}
onChange={(e) => setConfig('wait_for_completion', e.target.checked)}
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
/>
Auf Abschluss warten
</label>
</div>
);
case 'crm': {
const action = strVal('action') || 'create_contact';
return (
<div className="space-y-4">
<Select
label="Aktion"
options={crmActions}
value={action}
onChange={(e) => setConfig('action', e.target.value)}
/>
{(action.includes('update') || action.includes('delete')) && (
<Input
label="Entity-ID"
value={strVal('entity_id')}
onChange={(e) => setConfig('entity_id', e.target.value)}
placeholder="Entity-UUID"
/>
)}
{(action.includes('create') || action.includes('update')) && (
<JsonField
label="Daten (JSON)"
value={jsonVal('data')}
onChange={(v) => setJsonField('data', v)}
/>
)}
</div>
);
}
default:
return (
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
Konfiguration (JSON)
</label>
<textarea
value={configText}
onChange={(e) => handleConfigChange(e.target.value)}
rows={5}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder='{"key": "value"}'
/>
{configError && (
<p className="mt-1 text-sm text-danger-600" role="alert">
{configError}
</p>
)}
</div>
);
}
};
return ( return (
<div className="space-y-4 rounded-lg border border-secondary-200 p-4 bg-secondary-50"> <div className="space-y-4 rounded-lg border border-secondary-200 p-4 bg-secondary-50">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -57,7 +431,7 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
options={stepTypeOptions} options={stepTypeOptions}
value={step.type} value={step.type}
onChange={(e) => onChange={(e) =>
onChange({ ...step, type: e.target.value as WorkflowStep['type'] }) onChange({ ...step, type: e.target.value as WorkflowStepType })
} }
/> />
</div> </div>
@@ -68,37 +442,114 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
</label> </label>
<textarea <textarea
value={step.description ?? ''} value={step.description ?? ''}
onChange={(e) => onChange={(e) => onChange({ ...step, description: e.target.value || null })}
onChange({ ...step, description: e.target.value || null })
}
rows={2} rows={2}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Optionale Beschreibung" placeholder="Optionale Beschreibung"
/> />
</div> </div>
<div> <div className="flex items-center justify-between">
<label className="block text-sm font-medium text-secondary-700 mb-1"> <label className="text-sm font-medium text-secondary-700">Konfiguration</label>
Konfiguration (JSON) <div className="flex items-center gap-1 rounded-md border border-secondary-200 bg-white p-0.5">
</label> <button
{step.type && configHints[step.type] && ( type="button"
<p className="text-xs text-secondary-400 mb-1"> onClick={() => setMode('form')}
{configHints[step.type]} className={`inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium ${
</p> mode === 'form'
)} ? 'bg-primary-600 text-white'
<textarea : 'text-secondary-600 hover:bg-secondary-100'
value={configText} }`}
onChange={(e) => handleConfigChange(e.target.value)} aria-pressed={mode === 'form'}
rows={5} >
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500" <FormInput className="h-3.5 w-3.5" /> Formular
placeholder='{"key": "value"}' </button>
/> <button
{configError && ( type="button"
<p className="mt-1 text-sm text-danger-600" role="alert"> onClick={() => setMode('json')}
{configError} className={`inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium ${
</p> mode === 'json'
)} ? 'bg-primary-600 text-white'
: 'text-secondary-600 hover:bg-secondary-100'
}`}
aria-pressed={mode === 'json'}
>
<Code2 className="h-3.5 w-3.5" /> JSON
</button>
</div>
</div> </div>
{mode === 'form' ? (
renderTypeForm()
) : (
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
Konfiguration (JSON)
</label>
<textarea
value={configText}
onChange={(e) => handleConfigChange(e.target.value)}
rows={5}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder='{"key": "value"}'
/>
{configError && (
<p className="mt-1 text-sm text-danger-600" role="alert">
{configError}
</p>
)}
</div>
)}
</div>
);
}
interface JsonFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
}
function JsonField({ label, value, onChange }: JsonFieldProps) {
const [text, setText] = useState(value);
const [error, setError] = useState<string | undefined>(undefined);
useEffect(() => {
setText(value);
setError(undefined);
}, [value]);
const handleChange = (v: string) => {
setText(v);
if (!v.trim()) {
setError(undefined);
onChange('{}');
return;
}
try {
JSON.parse(v);
setError(undefined);
onChange(v);
} catch {
setError('Ungültiges JSON');
}
};
return (
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">{label}</label>
<textarea
value={text}
onChange={(e) => handleChange(e.target.value)}
rows={3}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder='{"key": "value"}'
/>
{error && (
<p className="mt-1 text-sm text-danger-600" role="alert">
{error}
</p>
)}
</div> </div>
); );
} }
@@ -12,8 +12,8 @@ import {
useCreateWorkflow, useCreateWorkflow,
useUpdateWorkflow, useUpdateWorkflow,
} from '@/api/workflows'; } from '@/api/workflows';
import type { Workflow, WorkflowStep, WorkflowCreateInput, WorkflowUpdateInput } from '@/api/workflows'; import type { Workflow, WorkflowStep, WorkflowStepType, WorkflowCreateInput, WorkflowUpdateInput } from '@/api/workflows';
import { ArrowUp, ArrowDown, Plus, Trash2 } from 'lucide-react'; import { ArrowUp, ArrowDown, Plus, Trash2, Code2, FormInput, LayoutTemplate } from 'lucide-react';
const triggerEventOptions = [ const triggerEventOptions = [
{ value: '', label: '— Kein Trigger —' }, { value: '', label: '— Kein Trigger —' },
@@ -51,6 +51,101 @@ const emptyForm: EditorFormState = {
steps: [], steps: [],
}; };
interface WorkflowTemplate {
id: string;
name: string;
description: string;
form: EditorFormState;
}
const templates: WorkflowTemplate[] = [
{
id: 'welcome-email',
name: 'Welcome Email',
description: 'Sendet eine Willkommens-Mail an neue Kontakte.',
form: {
name: 'Welcome Email',
description: 'Sendet eine Willkommens-Mail an neue Kontakte.',
trigger_event: 'contact.created',
is_active: true,
steps: [
{
name: 'Willkommens-Mail senden',
type: 'mail',
config: {
to: '{{contact.email}}',
subject: 'Willkommen bei uns!',
body: 'Hallo {{contact.first_name}}, willkommen bei unserem Unternehmen!',
},
description: 'Sendet die Willkommens-Mail an den neuen Kontakt.',
},
],
},
},
{
id: 'contact-follow-up',
name: 'Contact Follow-Up',
description: 'Wartet 3 Tage und sendet dann ein Follow-Up.',
form: {
name: 'Contact Follow-Up',
description: 'Wartet 3 Tage und sendet dann ein Follow-Up.',
trigger_event: 'contact.created',
is_active: true,
steps: [
{
name: '3 Tage warten',
type: 'wait',
config: { duration_seconds: 259200 },
description: 'Wartet 3 Tage nach Kontakterstellung.',
},
{
name: 'Follow-Up senden',
type: 'mail',
config: {
to: '{{contact.email}}',
subject: 'Wie können wir helfen?',
body: 'Hallo {{contact.first_name}}, wir wollten nachfragen, ob wir helfen können.',
},
description: 'Sendet das Follow-Up an den Kontakt.',
},
],
},
},
{
id: 'approval-chain',
name: 'Approval Chain',
description: 'Deal-Genehmigung mit zweistufiger Freigabe.',
form: {
name: 'Approval Chain',
description: 'Deal-Genehmigung mit zweistufiger Freigabe.',
trigger_event: 'deal.stage_changed',
is_active: true,
steps: [
{
name: 'Vertriebsleiter-Genehmigung',
type: 'approval',
config: { approver_role: 'sales_manager', timeout_hours: 24 },
description: 'Erste Freigabe durch den Vertriebsleiter.',
},
{
name: 'Geschäftsführer-Genehmigung',
type: 'approval',
config: { approver_role: 'ceo', timeout_hours: 48 },
description: 'Zweite Freigabe durch die Geschäftsführung.',
},
{
name: 'Bestätigungs-Mail',
type: 'notification',
config: { channel: 'email', template: 'deal_approved', recipients: '{{deal.owner_email}}' },
description: 'Benachrichtigt den Deal-Owner über die Freigabe.',
},
],
},
},
];
type EditorMode = 'form' | 'json';
export interface WorkflowEditorProps { export interface WorkflowEditorProps {
open: boolean; open: boolean;
workflow?: Workflow | null; workflow?: Workflow | null;
@@ -65,6 +160,10 @@ export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps)
const isEdit = !!workflow; const isEdit = !!workflow;
const [form, setForm] = useState<EditorFormState>(emptyForm); const [form, setForm] = useState<EditorFormState>(emptyForm);
const [mode, setMode] = useState<EditorMode>('form');
const [jsonText, setJsonText] = useState('');
const [jsonError, setJsonError] = useState<string | undefined>(undefined);
const [showTemplates, setShowTemplates] = useState(false);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -86,9 +185,17 @@ export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps)
} else { } else {
setForm({ ...emptyForm }); setForm({ ...emptyForm });
} }
setMode('form');
setShowTemplates(false);
setJsonError(undefined);
} }
}, [open, workflow]); }, [open, workflow]);
useEffect(() => {
setJsonText(JSON.stringify(form, null, 2));
setJsonError(undefined);
}, [form]);
const updateField = <K extends keyof EditorFormState>( const updateField = <K extends keyof EditorFormState>(
key: K, key: K,
value: EditorFormState[K] value: EditorFormState[K]
@@ -125,6 +232,65 @@ export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps)
}); });
}; };
const applyTemplate = (template: WorkflowTemplate) => {
setForm({
name: template.form.name,
description: template.form.description,
trigger_event: template.form.trigger_event,
is_active: template.form.is_active,
steps: template.form.steps.map((s) => ({ ...s, config: { ...s.config } })),
});
setShowTemplates(false);
};
const handleJsonChange = (value: string) => {
setJsonText(value);
try {
const parsed = JSON.parse(value) as EditorFormState;
if (!Array.isArray(parsed.steps)) {
setJsonError('steps muss ein Array sein');
return;
}
setJsonError(undefined);
setForm(parsed);
} catch {
setJsonError('Ungültiges JSON');
}
};
const requiredConfigFields: Partial<Record<WorkflowStepType, string[]>> = {
wait: ['duration_seconds'],
http: ['url'],
mail: ['to', 'subject'],
calendar: ['action'],
dms: ['action'],
search: ['query'],
agent: ['agent_id'],
crm: ['action'],
};
const validateSteps = (steps: WorkflowStep[]): string | null => {
if (steps.length === 0) {
return 'Mindestens ein Schritt ist erforderlich';
}
for (let i = 0; i < steps.length; i++) {
const s = steps[i];
if (!s.name.trim()) {
return `Schritt ${i + 1}: Name ist erforderlich`;
}
const required = requiredConfigFields[s.type];
if (required) {
for (const field of required) {
const v = s.config[field];
if (v === undefined || v === null || v === '') {
return `Schritt ${i + 1} (${s.type}): Feld "${field}" ist erforderlich`;
}
}
}
}
return null;
};
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -133,14 +299,9 @@ export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps)
return; return;
} }
if (form.steps.length === 0) { const stepError = validateSteps(form.steps);
toast.error('Mindestens ein Schritt ist erforderlich'); if (stepError) {
return; toast.error(stepError);
}
const hasEmptyStepName = form.steps.some((s) => !s.name.trim());
if (hasEmptyStepName) {
toast.error('Alle Schritte muessen einen Namen haben');
return; return;
} }
@@ -192,108 +353,197 @@ export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps)
size="xl" size="xl"
> >
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 gap-4"> {/* Mode toggle + template gallery */}
<Input <div className="flex items-center justify-between">
label="Name" <div className="flex items-center gap-2">
required {!isEdit && (
value={form.name} <Button
onChange={(e) => updateField('name', e.target.value)} size="sm"
placeholder="z.B. Deal-Genehmigungsprozess" variant="ghost"
/> type="button"
<div> onClick={() => setShowTemplates((v) => !v)}
<label className="block text-sm font-medium text-secondary-700 mb-1"> icon={<LayoutTemplate className="h-4 w-4" />}
Beschreibung >
</label> Vorlagen
<textarea </Button>
value={form.description} )}
onChange={(e) => updateField('description', e.target.value)} </div>
rows={2} <div className="flex items-center gap-1 rounded-md border border-secondary-200 bg-white p-0.5">
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" <button
placeholder="Optionale Beschreibung" type="button"
/> onClick={() => setMode('form')}
className={`inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium ${
mode === 'form'
? 'bg-primary-600 text-white'
: 'text-secondary-600 hover:bg-secondary-100'
}`}
aria-pressed={mode === 'form'}
>
<FormInput className="h-3.5 w-3.5" /> Formular
</button>
<button
type="button"
onClick={() => setMode('json')}
className={`inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium ${
mode === 'json'
? 'bg-primary-600 text-white'
: 'text-secondary-600 hover:bg-secondary-100'
}`}
aria-pressed={mode === 'json'}
>
<Code2 className="h-3.5 w-3.5" /> JSON Expert
</button>
</div> </div>
<Select
label="Trigger-Event"
options={triggerEventOptions}
value={form.trigger_event}
onChange={(e) => updateField('trigger_event', e.target.value)}
/>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.is_active}
onChange={(e) => updateField('is_active', e.target.checked)}
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
/>
Aktiv
</label>
</div> </div>
{/* Steps section */} {/* Template gallery */}
<div> {showTemplates && !isEdit && (
<div className="flex items-center justify-between mb-3"> <div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<label className="text-sm font-medium text-secondary-700"> {templates.map((template) => (
Schritte <button
</label> key={template.id}
<Button type="button"
size="sm" onClick={() => applyTemplate(template)}
variant="ghost" className="rounded-lg border border-secondary-200 bg-secondary-50 p-4 text-left hover:border-primary-500 hover:bg-primary-50 transition-colors"
onClick={addStep} >
type="button" <span className="block text-sm font-medium text-secondary-800">
icon={<Plus className="h-4 w-4" />} {template.name}
> </span>
Schritt hinzufuegen <span className="mt-1 block text-xs text-secondary-500">
</Button> {template.description}
</div> </span>
{form.steps.length === 0 && ( <span className="mt-2 block text-xs text-primary-600 font-medium">
<p className="text-sm text-secondary-400 italic"> {template.form.steps.length} Schritte
Keine Schritte definiert. Klicke auf Schritt hinzufuegen. </span>
</p> </button>
)}
<div className="space-y-4">
{form.steps.map((step, i) => (
<div key={i} className="relative">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-secondary-500">
Schritt {i + 1}
</span>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => moveStep(i, 'up')}
disabled={i === 0}
className="text-secondary-400 hover:text-secondary-700 disabled:opacity-30 p-1"
aria-label="Nach oben"
>
<ArrowUp className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => moveStep(i, 'down')}
disabled={i === form.steps.length - 1}
className="text-secondary-400 hover:text-secondary-700 disabled:opacity-30 p-1"
aria-label="Nach unten"
>
<ArrowDown className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => removeStep(i)}
className="text-danger-500 hover:text-danger-700 p-1"
aria-label="Schritt entfernen"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<StepConfigPanel
step={step}
onChange={(updated) => updateStep(i, updated)}
/>
</div>
))} ))}
</div> </div>
</div> )}
{mode === 'form' ? (
<>
<div className="grid grid-cols-1 gap-4">
<Input
label="Name"
required
value={form.name}
onChange={(e) => updateField('name', e.target.value)}
placeholder="z.B. Deal-Genehmigungsprozess"
/>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
Beschreibung
</label>
<textarea
value={form.description}
onChange={(e) => updateField('description', e.target.value)}
rows={2}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Optionale Beschreibung"
/>
</div>
<Select
label="Trigger-Event"
options={triggerEventOptions}
value={form.trigger_event}
onChange={(e) => updateField('trigger_event', e.target.value)}
/>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.is_active}
onChange={(e) => updateField('is_active', e.target.checked)}
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
/>
Aktiv
</label>
</div>
{/* Steps section */}
<div>
<div className="flex items-center justify-between mb-3">
<label className="text-sm font-medium text-secondary-700">
Schritte
</label>
<Button
size="sm"
variant="ghost"
onClick={addStep}
type="button"
icon={<Plus className="h-4 w-4" />}
>
Schritt hinzufuegen
</Button>
</div>
{form.steps.length === 0 && (
<p className="text-sm text-secondary-400 italic">
Keine Schritte definiert. Klicke auf Schritt hinzufuegen.
</p>
)}
<div className="space-y-4">
{form.steps.map((step, i) => (
<div key={i} className="relative">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-secondary-500">
Schritt {i + 1}
</span>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => moveStep(i, 'up')}
disabled={i === 0}
className="text-secondary-400 hover:text-secondary-700 disabled:opacity-30 p-1"
aria-label="Nach oben"
>
<ArrowUp className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => moveStep(i, 'down')}
disabled={i === form.steps.length - 1}
className="text-secondary-400 hover:text-secondary-700 disabled:opacity-30 p-1"
aria-label="Nach unten"
>
<ArrowDown className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => removeStep(i)}
className="text-danger-500 hover:text-danger-700 p-1"
aria-label="Schritt entfernen"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<StepConfigPanel
step={step}
onChange={(updated) => updateStep(i, updated)}
/>
</div>
))}
</div>
</div>
</>
) : (
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
Workflow (JSON)
</label>
<textarea
value={jsonText}
onChange={(e) => handleJsonChange(e.target.value)}
rows={18}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder='{"name": "...", "steps": [...]}'
/>
{jsonError && (
<p className="mt-1 text-sm text-danger-600" role="alert">
{jsonError}
</p>
)}
</div>
)}
<div className="flex justify-end gap-3 pt-4 border-t border-secondary-200"> <div className="flex justify-end gap-3 pt-4 border-t border-secondary-200">
<Button variant="secondary" onClick={onClose} type="button"> <Button variant="secondary" onClick={onClose} type="button">