feat: eigent server trigger implementation

This commit is contained in:
a7m-1st 2026-02-06 05:03:13 +03:00
parent d08d77f324
commit ddc1d04cbb
39 changed files with 7944 additions and 397 deletions

View file

@ -1,7 +1,35 @@
debug=false
url_prefix=/api
secret_key=postgres
database_url=postgresql://postgres:123456@localhost:5432/postgres
database_url=postgresql://postgres:123456@localhost:5432/eigent
# Chat Share Secret Key
CHAT_SHARE_SECRET_KEY=put-your-secret-key-here
CHAT_SHARE_SALT=put-your-encode-salt-here
# Redis
redis_url=redis://localhost:6379/0
# Celery
celery_broker_url=redis://localhost:6379/0
celery_result_url=redis://localhost:6379/0
# Websocket user sessions
SESSION_REDIS_URL=redis://localhost:6379/1
# Trigger Schedule Poller Configuration
# ENABLE_TRIGGER_SCHEDULE_POLLER_TASK: Enable/disable scheduled trigger polling
ENABLE_TRIGGER_SCHEDULE_POLLER_TASK=true
# TRIGGER_SCHEDULE_POLLER_INTERVAL: Polling interval in minutes
TRIGGER_SCHEDULE_POLLER_INTERVAL=1
# TRIGGER_SCHEDULE_POLLER_BATCH_SIZE: Number of triggers to fetch per poll
TRIGGER_SCHEDULE_POLLER_BATCH_SIZE=100
# TRIGGER_SCHEDULE_MAX_DISPATCH_PER_TICK: Max triggers to dispatch per tick (0 = unlimited)
TRIGGER_SCHEDULE_MAX_DISPATCH_PER_TICK=0
# ENABLE_EXECUTION_TIMEOUT_CHECKER: Enable/disable execution timeout checking
ENABLE_EXECUTION_TIMEOUT_CHECKER=true
# EXECUTION_TIMEOUT_CHECKER_INTERVAL: check_execution_timeouts interval in minutes
EXECUTION_TIMEOUT_CHECKER_INTERVAL=1
# EXECUTION_PENDING_TIMEOUT_SECONDS: Timeout for pending executions (default 60 seconds)
EXECUTION_PENDING_TIMEOUT_SECONDS=60
# EXECUTION_RUNNING_TIMEOUT_SECONDS: Timeout for running executions (default 600 seconds / 10 minutes)
EXECUTION_RUNNING_TIMEOUT_SECONDS=600

View file

@ -4,6 +4,19 @@ FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
# Install the project into `/app`
WORKDIR /app
# Install Git and build dependencies (required for git-based dependencies and Rust packages like tiktoken)
RUN apt-get update -o Acquire::Retries=3 && apt-get install -y --no-install-recommends \
git \
curl \
build-essential \
gcc \
python3-dev \
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \
&& rm -rf /var/lib/apt/lists/*
# Add Rust to PATH
ENV PATH="/root/.cargo/bin:$PATH"
# Disable bytecode transfer during compilation to avoid EMFILE during build on low nofile limits
ENV UV_COMPILE_BYTECODE=0
@ -15,11 +28,6 @@ ENV UV_PYTHON_INSTALL_MIRROR=https://registry.npmmirror.com/-/binary/python-buil
ARG database_url
ENV database_url=$database_url
RUN apt-get update -o Acquire::Retries=3 && apt-get install -y --no-install-recommends \
gcc \
python3-dev \
&& rm -rf /var/lib/apt/lists/*
# Copy dependency files first
COPY server/pyproject.toml server/uv.lock ./
@ -49,6 +57,10 @@ ENV PATH="/app/.venv/bin:$PATH"
COPY server/start.sh /app/start.sh
RUN sed -i 's/\r$//' /app/start.sh && chmod +x /app/start.sh
# Make Celery scripts executable
RUN sed -i 's/\r$//' /app/app/celery/worker/start && chmod +x /app/app/celery/worker/start
RUN sed -i 's/\r$//' /app/app/celery/beat/start && chmod +x /app/app/celery/beat/start
# Reset the entrypoint, don't invoke `uv`
ENTRYPOINT []

View file

@ -44,6 +44,7 @@ auto_import("app.model.user")
auto_import("app.model.config")
auto_import("app.model.chat")
auto_import("app.model.provider")
auto_import("app.model.trigger")
# target_metadata = mymodel.Base.metadata
target_metadata = SQLModel.metadata
@ -97,7 +98,7 @@ def run_migrations_offline() -> None:
script output.
"""
url = config.get_main_option("sqlalchemy.url")
url = env_not_empty("database_url")
context.configure(
url=url,
target_metadata=target_metadata,

View file

@ -0,0 +1,113 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
"""feat-trigger
Revision ID: 9464b9d89de7
Revises: add_timestamp_to_chat_step
Create Date: 2026-02-06 04:40:17.623286
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel.sql.sqltypes
from app.type.trigger_types import ExecutionStatus
from app.type.trigger_types import ExecutionType
from app.type.trigger_types import ListenerType
from app.type.trigger_types import RequestType
from app.type.trigger_types import TriggerStatus
from app.type.trigger_types import TriggerType
from sqlalchemy_utils.types import ChoiceType
# revision identifiers, used by Alembic.
revision: str = '9464b9d89de7'
down_revision: Union[str, None] = 'add_timestamp_to_chat_step'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('trigger',
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=True),
sa.Column('updated_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=True),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('project_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False),
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(length=1000), nullable=False),
sa.Column('trigger_type', ChoiceType(choices=TriggerType, impl=sa.String()), nullable=True),
sa.Column('status', ChoiceType(choices=TriggerStatus, impl=sa.String()), nullable=True),
sa.Column('webhook_url', sa.String(length=1024), nullable=True),
sa.Column('webhook_method', ChoiceType(choices=RequestType, impl=sa.String()), nullable=True),
sa.Column('custom_cron_expression', sa.String(length=100), nullable=True),
sa.Column('listener_type', ChoiceType(choices=ListenerType, impl=sa.String()), nullable=True),
sa.Column('agent_model', sa.String(length=100), nullable=True),
sa.Column('task_prompt', sqlmodel.sql.sqltypes.AutoString(length=1500), nullable=True),
sa.Column('config', sa.JSON(), nullable=True),
sa.Column('max_executions_per_hour', sa.Integer(), nullable=True),
sa.Column('max_executions_per_day', sa.Integer(), nullable=True),
sa.Column('is_single_execution', sa.Boolean(), nullable=False),
sa.Column('last_executed_at', sa.DateTime(), nullable=True),
sa.Column('next_run_at', sa.DateTime(), nullable=True),
sa.Column('last_execution_status', sa.String(length=50), nullable=True),
sa.Column('consecutive_failures', sa.Integer(), nullable=False),
sa.Column('auto_disabled_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_trigger_next_run_at'), 'trigger', ['next_run_at'], unique=False)
op.create_index(op.f('ix_trigger_project_id'), 'trigger', ['project_id'], unique=False)
op.create_index(op.f('ix_trigger_user_id'), 'trigger', ['user_id'], unique=False)
op.create_table('trigger_execution',
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=True),
sa.Column('updated_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=True),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('trigger_id', sa.Integer(), nullable=False),
sa.Column('execution_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('execution_type', ChoiceType(choices=ExecutionType, impl=sa.String()), nullable=True),
sa.Column('status', ChoiceType(choices=ExecutionStatus, impl=sa.String()), nullable=True),
sa.Column('started_at', sa.DateTime(), nullable=True),
sa.Column('completed_at', sa.DateTime(), nullable=True),
sa.Column('duration_seconds', sa.Float(), nullable=True),
sa.Column('input_data', sa.JSON(), nullable=True),
sa.Column('output_data', sa.JSON(), nullable=True),
sa.Column('error_message', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('attempts', sa.Integer(), nullable=False),
sa.Column('max_retries', sa.Integer(), nullable=False),
sa.Column('tokens_used', sa.Integer(), nullable=True),
sa.Column('tools_executed', sa.JSON(), nullable=True),
sa.ForeignKeyConstraint(['trigger_id'], ['trigger.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_trigger_execution_execution_id'), 'trigger_execution', ['execution_id'], unique=True)
op.create_index(op.f('ix_trigger_execution_trigger_id'), 'trigger_execution', ['trigger_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_trigger_execution_trigger_id'), table_name='trigger_execution')
op.drop_index(op.f('ix_trigger_execution_execution_id'), table_name='trigger_execution')
op.drop_table('trigger_execution')
op.drop_index(op.f('ix_trigger_user_id'), table_name='trigger')
op.drop_index(op.f('ix_trigger_project_id'), table_name='trigger')
op.drop_index(op.f('ix_trigger_next_run_at'), table_name='trigger')
op.drop_table('trigger')
# ### end Alembic commands ###

View file

@ -10,10 +10,36 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
from fastapi import FastAPI
from fastapi_pagination import add_pagination
api = FastAPI(swagger_ui_parameters={"persistAuthorization": True})
add_pagination(api)
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi_pagination import add_pagination
from fastapi_limiter import FastAPILimiter
from app.component.environment import env_or_fail
from redis import asyncio as aioredis
import logging
logger = logging.getLogger("server_app")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan manager for startup/shutdown events."""
# Startup: Initialize rate limiter with Redis
redis_url = env_or_fail("redis_url")
redis_connection = aioredis.from_url(redis_url, encoding="utf-8", decode_responses=True)
await FastAPILimiter.init(redis_connection)
logger.info("FastAPI Limiter initialized with Redis")
yield
# Shutdown: Close Redis connection
await FastAPILimiter.close()
logger.info("FastAPI Limiter closed")
# Add lifespan for ratelimiter setup
api = FastAPI(
swagger_ui_parameters={"persistAuthorization": True},
lifespan=lifespan
)
add_pagination(api)

View file

@ -0,0 +1,7 @@
#!/bin/bash
set -o errexit
set -o nounset
rm -f './celerybeat.pid'
celery -A app.component.celery beat -l info

View file

@ -0,0 +1,6 @@
#!/bin/bash
set -o errexit
set -o nounset
celery -A app.component.celery worker --loglevel=info --queues=celery,poll_trigger_schedules,check_execution_timeouts

View file

@ -0,0 +1,52 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
from celery import Celery
from app.component.environment import env_or_fail, env
celery = Celery(
__name__,
broker=env_or_fail("celery_broker_url"),
backend=env_or_fail("celery_result_url")
)
# Configure Celery to autodiscover tasks
celery.conf.imports = [
"app.schedule.trigger_schedule_task",
]
# Configure Celery Beat schedule
ENABLE_TRIGGER_SCHEDULE_POLLER = env("ENABLE_TRIGGER_SCHEDULE_POLLER_TASK", "true").lower() == "true"
TRIGGER_SCHEDULE_POLLER_INTERVAL = int(env("TRIGGER_SCHEDULE_POLLER_INTERVAL", "1")) # in minutes
ENABLE_EXECUTION_TIMEOUT_CHECKER = env("ENABLE_EXECUTION_TIMEOUT_CHECKER", "true").lower() == "true"
EXECUTION_TIMEOUT_CHECKER_INTERVAL = int(env("EXECUTION_TIMEOUT_CHECKER_INTERVAL", "1")) # in minutes
celery.conf.beat_schedule = {}
if ENABLE_TRIGGER_SCHEDULE_POLLER:
celery.conf.beat_schedule["poll-trigger-schedules"] = {
"task": "app.schedule.trigger_schedule_task.poll_trigger_schedules",
"schedule": TRIGGER_SCHEDULE_POLLER_INTERVAL * 60.0, # Convert minutes to seconds
"options": {"queue": "poll_trigger_schedules"},
}
if ENABLE_EXECUTION_TIMEOUT_CHECKER:
celery.conf.beat_schedule["check-execution-timeouts"] = {
"task": "app.schedule.trigger_schedule_task.check_execution_timeouts",
"schedule": EXECUTION_TIMEOUT_CHECKER_INTERVAL * 60.0, # Convert minutes to seconds
"options": {"queue": "check_execution_timeouts"},
}
celery.conf.timezone = "UTC"

View file

@ -0,0 +1,498 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
"""Redis utilities for managing WebSocket sessions and real-time data."""
import redis
from redis import Redis
from typing import Optional, Dict, Any, Set, Callable
from datetime import datetime, timezone
import json
import logging
import os
import asyncio
class RedisSessionManager:
"""Manages WebSocket sessions in Redis for scalability and persistence."""
def __init__(self, redis_url: Optional[str] = None):
"""Initialize Redis connection.
Args:
redis_url: Redis connection URL. If None, reads from environment.
"""
self.redis_url = redis_url or os.getenv("SESSION_REDIS_URL", "redis://localhost:6379/0")
self._client: Optional[Redis] = None
# Key prefixes
self.SESSION_PREFIX = "ws:session:"
self.USER_SESSIONS_PREFIX = "ws:user:sessions:"
self.PENDING_PREFIX = "ws:pending:"
self.PUBSUB_CHANNEL = "ws:executions"
self.DELIVERY_CONFIRMATION_PREFIX = "ws:delivery:"
# TTL for sessions (24 hours)
self.SESSION_TTL = 86400
# TTL for delivery confirmations (5 minutes)
self.DELIVERY_TTL = 300
# Pub/Sub
self._pubsub = None
self._pubsub_client: Optional[Redis] = None
@property
def client(self) -> Redis:
"""Get or create Redis client."""
if self._client is None:
try:
self._client = redis.from_url(
self.redis_url,
decode_responses=True,
socket_connect_timeout=5,
socket_timeout=5
)
# Test connection
self._client.ping()
logger.info("Redis connection established", extra={"url": self.redis_url})
except Exception as e:
logger.error("Failed to connect to Redis", extra={"error": str(e)}, exc_info=True)
raise
return self._client
def store_session(
self,
session_id: str,
user_id: str,
metadata: Optional[Dict[str, Any]] = None
) -> bool:
"""Store a WebSocket session in Redis.
Args:
session_id: Unique session identifier
user_id: User ID associated with the session
metadata: Additional metadata to store
Returns:
True if successful, False otherwise
"""
try:
session_data = {
"user_id": user_id,
"session_id": session_id,
"connected_at": datetime.now(timezone.utc).isoformat(),
**(metadata or {})
}
session_key = f"{self.SESSION_PREFIX}{session_id}"
user_sessions_key = f"{self.USER_SESSIONS_PREFIX}{user_id}"
# Store session data
self.client.setex(
session_key,
self.SESSION_TTL,
json.dumps(session_data)
)
# Add session to user's session set
self.client.sadd(user_sessions_key, session_id)
self.client.expire(user_sessions_key, self.SESSION_TTL)
logger.debug("Session stored in Redis", extra={
"session_id": session_id,
"user_id": user_id
})
return True
except Exception as e:
logger.error("Failed to store session in Redis", extra={
"session_id": session_id,
"error": str(e)
}, exc_info=True)
return False
def get_session(self, session_id: str) -> Optional[Dict[str, Any]]:
"""Get session data from Redis.
Args:
session_id: Session identifier
Returns:
Session data dictionary or None if not found
"""
try:
session_key = f"{self.SESSION_PREFIX}{session_id}"
data = self.client.get(session_key)
if data:
return json.loads(data)
return None
except Exception as e:
logger.error("Failed to get session from Redis", extra={
"session_id": session_id,
"error": str(e)
})
return None
def remove_session(self, session_id: str) -> bool:
"""Remove a session from Redis.
Args:
session_id: Session identifier
Returns:
True if successful, False otherwise
"""
try:
# Get session data to find user_id
session = self.get_session(session_id)
if not session:
return False
user_id = session.get("user_id")
# Remove session data
session_key = f"{self.SESSION_PREFIX}{session_id}"
self.client.delete(session_key)
# Remove from user's session set
if user_id:
user_sessions_key = f"{self.USER_SESSIONS_PREFIX}{user_id}"
self.client.srem(user_sessions_key, session_id)
# Remove pending executions
pending_key = f"{self.PENDING_PREFIX}{session_id}"
self.client.delete(pending_key)
logger.debug("Session removed from Redis", extra={
"session_id": session_id,
"user_id": user_id
})
return True
except Exception as e:
logger.error("Failed to remove session from Redis", extra={
"session_id": session_id,
"error": str(e)
}, exc_info=True)
return False
def get_user_sessions(self, user_id: str) -> Set[str]:
"""Get all active session IDs for a user.
Args:
user_id: User identifier
Returns:
Set of session IDs
"""
try:
user_sessions_key = f"{self.USER_SESSIONS_PREFIX}{user_id}"
sessions = self.client.smembers(user_sessions_key)
return sessions if sessions else set()
except Exception as e:
logger.error("Failed to get user sessions from Redis", extra={
"user_id": user_id,
"error": str(e)
})
return set()
def add_pending_execution(self, session_id: str, execution_id: str) -> bool:
"""Add a pending execution to a session.
Args:
session_id: Session identifier
execution_id: Execution identifier
Returns:
True if successful, False otherwise
"""
try:
pending_key = f"{self.PENDING_PREFIX}{session_id}"
self.client.sadd(pending_key, execution_id)
self.client.expire(pending_key, self.SESSION_TTL)
return True
except Exception as e:
logger.error("Failed to add pending execution", extra={
"session_id": session_id,
"execution_id": execution_id,
"error": str(e)
})
return False
def remove_pending_execution(self, session_id: str, execution_id: str) -> bool:
"""Remove a pending execution from a session.
Args:
session_id: Session identifier
execution_id: Execution identifier
Returns:
True if successful, False otherwise
"""
try:
pending_key = f"{self.PENDING_PREFIX}{session_id}"
self.client.srem(pending_key, execution_id)
return True
except Exception as e:
logger.error("Failed to remove pending execution", extra={
"session_id": session_id,
"execution_id": execution_id,
"error": str(e)
})
return False
def get_pending_executions(self, session_id: str) -> Set[str]:
"""Get all pending executions for a session.
Args:
session_id: Session identifier
Returns:
Set of execution IDs
"""
try:
pending_key = f"{self.PENDING_PREFIX}{session_id}"
pending = self.client.smembers(pending_key)
return pending if pending else set()
except Exception as e:
logger.error("Failed to get pending executions", extra={
"session_id": session_id,
"error": str(e)
})
return set()
def update_session_ttl(self, session_id: str) -> bool:
"""Refresh the TTL for a session.
Args:
session_id: Session identifier
Returns:
True if successful, False otherwise
"""
try:
session_key = f"{self.SESSION_PREFIX}{session_id}"
self.client.expire(session_key, self.SESSION_TTL)
pending_key = f"{self.PENDING_PREFIX}{session_id}"
self.client.expire(pending_key, self.SESSION_TTL)
return True
except Exception as e:
logger.error("Failed to update session TTL", extra={
"session_id": session_id,
"error": str(e)
})
return False
def confirm_delivery(self, execution_id: str, session_id: str) -> bool:
"""Confirm that a message was delivered to a WebSocket client.
Args:
execution_id: The execution ID that was delivered
session_id: The session ID that received the message
Returns:
True if confirmation was stored, False otherwise
"""
try:
confirmation_key = f"{self.DELIVERY_CONFIRMATION_PREFIX}{execution_id}"
confirmation_data = json.dumps({
"execution_id": execution_id,
"session_id": session_id,
"delivered_at": datetime.now(timezone.utc).isoformat()
})
self.client.setex(confirmation_key, self.DELIVERY_TTL, confirmation_data)
logger.debug("Delivery confirmed", extra={
"execution_id": execution_id,
"session_id": session_id
})
return True
except Exception as e:
logger.error("Failed to confirm delivery", extra={
"execution_id": execution_id,
"session_id": session_id,
"error": str(e)
})
return False
async def wait_for_delivery(
self,
execution_id: str,
timeout: float = 10.0,
poll_interval: float = 0.1
) -> Optional[Dict[str, Any]]:
"""Wait for delivery confirmation of an execution.
Args:
execution_id: The execution ID to wait for
timeout: Maximum time to wait in seconds
poll_interval: Time between checks in seconds
Returns:
Confirmation data if delivered, None if timeout
"""
confirmation_key = f"{self.DELIVERY_CONFIRMATION_PREFIX}{execution_id}"
elapsed = 0.0
while elapsed < timeout:
try:
data = self.client.get(confirmation_key)
if data:
# Clean up the confirmation key
self.client.delete(confirmation_key)
return json.loads(data)
except Exception as e:
logger.error("Error checking delivery confirmation", extra={
"execution_id": execution_id,
"error": str(e)
})
await asyncio.sleep(poll_interval)
elapsed += poll_interval
logger.warning("Delivery confirmation timeout", extra={
"execution_id": execution_id,
"timeout": timeout
})
return None
def has_active_sessions_for_user(self, user_id: str) -> bool:
"""Check if a user has any active WebSocket sessions.
Args:
user_id: User identifier
Returns:
True if user has active sessions, False otherwise
"""
try:
sessions = self.get_user_sessions(user_id)
return len(sessions) > 0
except Exception as e:
logger.error("Failed to check user sessions", extra={
"user_id": user_id,
"error": str(e)
})
return False
def close(self):
"""Close Redis connection."""
if self._pubsub:
self._pubsub.close()
self._pubsub = None
if self._pubsub_client:
self._pubsub_client.close()
self._pubsub_client = None
if self._client:
self._client.close()
self._client = None
def publish_execution_event(self, event_data: Dict[str, Any]) -> bool:
"""Publish an execution event to all workers via Redis pub/sub.
Args:
event_data: Event data to broadcast
Returns:
True if successful, False otherwise
"""
try:
message = json.dumps(event_data)
self.client.publish(self.PUBSUB_CHANNEL, message)
logger.debug("Published execution event to Redis", extra={
"execution_id": event_data.get("execution_id"),
"type": event_data.get("type")
})
return True
except Exception as e:
logger.error("Failed to publish execution event", extra={
"error": str(e)
}, exc_info=True)
return False
async def subscribe_to_execution_events(self, callback: Callable[[Dict[str, Any]], None]):
"""Subscribe to execution events from Redis pub/sub.
This should be run in a background task. It will call the callback
for each message received on the pub/sub channel.
Args:
callback: Async function to call with each event
"""
try:
# Create separate Redis client for pub/sub (can't use the same one)
if self._pubsub_client is None:
self._pubsub_client = redis.from_url(
self.redis_url,
decode_responses=True,
socket_connect_timeout=5,
socket_timeout=5
)
self._pubsub = self._pubsub_client.pubsub()
await asyncio.get_event_loop().run_in_executor(
None,
self._pubsub.subscribe,
self.PUBSUB_CHANNEL
)
logger.info("Subscribed to execution events", extra={
"channel": self.PUBSUB_CHANNEL
})
# Listen for messages
while True:
message = await asyncio.get_event_loop().run_in_executor(
None,
self._pubsub.get_message,
True, # ignore_subscribe_messages
1.0 # timeout
)
if message and message['type'] == 'message':
try:
event_data = json.loads(message['data'])
await callback(event_data)
except Exception as e:
logger.error("Error processing pub/sub message", extra={
"error": str(e)
}, exc_info=True)
# Small sleep to prevent tight loop
await asyncio.sleep(0.01)
except Exception as e:
logger.error("Pub/sub subscription error", extra={
"error": str(e)
}, exc_info=True)
# Global instance
_redis_manager: Optional[RedisSessionManager] = None
def get_redis_manager() -> RedisSessionManager:
"""Get or create the global Redis session manager."""
global _redis_manager
if _redis_manager is None:
_redis_manager = RedisSessionManager()
return _redis_manager

View file

@ -0,0 +1,175 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
from celery import shared_task
import logging
from datetime import datetime, timezone
from sqlmodel import select, or_
from app.component.database import session_make
from app.component.environment import env
from app.service.trigger.trigger_schedule_service import TriggerScheduleService
from app.service.trigger.trigger_service import TriggerService
from app.component.trigger_utils import MAX_DISPATCH_PER_TICK
from app.component.redis_utils import get_redis_manager
from app.model.trigger.trigger_execution import TriggerExecution
from app.model.trigger.trigger import Trigger
from app.type.trigger_types import ExecutionStatus
# Timeout configuration from environment variables
EXECUTION_PENDING_TIMEOUT_SECONDS = int(env("EXECUTION_PENDING_TIMEOUT_SECONDS", "60"))
EXECUTION_RUNNING_TIMEOUT_SECONDS = int(env("EXECUTION_RUNNING_TIMEOUT_SECONDS", "600")) # 10 minutes
@shared_task(queue="poll_trigger_schedules")
def poll_trigger_schedules() -> None:
"""
Celery task to poll and execute scheduled triggers.
This runs periodically to check for triggers that are due for execution.
This is a lightweight wrapper around TriggerScheduleService that handles
session management and delegates all business logic to the service layer.
"""
logger.info("Starting poll_trigger_schedules task")
session = session_make()
try:
# Create service instance with session
schedule_service = TriggerScheduleService(session)
# Delegate all logic to the service
schedule_service.poll_and_execute_due_triggers(
max_dispatch_per_tick=MAX_DISPATCH_PER_TICK
)
finally:
session.close()
@shared_task(queue="check_execution_timeouts")
def check_execution_timeouts() -> None:
"""
Celery task to check for timed-out pending and running executions.
This runs periodically to find:
- Pending executions that haven't been acknowledged within EXECUTION_PENDING_TIMEOUT_SECONDS
- Running executions that have been stuck for more than EXECUTION_RUNNING_TIMEOUT_SECONDS
These are marked as missed/failed respectively.
"""
logger.info("Starting check_execution_timeouts task", extra={
"pending_timeout": EXECUTION_PENDING_TIMEOUT_SECONDS,
"running_timeout": EXECUTION_RUNNING_TIMEOUT_SECONDS
})
session = session_make()
redis_manager = get_redis_manager()
trigger_service = TriggerService(session)
try:
now = datetime.now(timezone.utc)
# Find all pending and running executions
executions = session.exec(
select(TriggerExecution).where(
or_(
TriggerExecution.status == ExecutionStatus.pending,
TriggerExecution.status == ExecutionStatus.running
)
)
).all()
timed_out_pending_count = 0
timed_out_running_count = 0
for execution in executions:
is_pending = execution.status == ExecutionStatus.pending
is_running = execution.status == ExecutionStatus.running
# Determine the reference time and timeout based on status
if is_pending:
reference_time = execution.created_at
timeout_seconds = EXECUTION_PENDING_TIMEOUT_SECONDS
else: # running
reference_time = execution.started_at or execution.created_at
timeout_seconds = EXECUTION_RUNNING_TIMEOUT_SECONDS
if reference_time.tzinfo is None:
reference_time = reference_time.replace(tzinfo=timezone.utc)
time_elapsed = (now - reference_time).total_seconds()
if time_elapsed > timeout_seconds:
# Determine the new status and error message
if is_pending:
new_status = ExecutionStatus.missed
error_message = f"Execution acknowledgment timeout ({timeout_seconds} seconds)"
timed_out_pending_count += 1
else:
new_status = ExecutionStatus.failed
error_message = f"Execution running timeout ({timeout_seconds} seconds) - no completion received"
timed_out_running_count += 1
# Use TriggerService.update_execution_status for proper failure tracking
trigger_service.update_execution_status(
execution=execution,
status=new_status,
error_message=error_message
)
# Remove from Redis pending list (best effort, may not exist)
try:
# Get all sessions for this execution's user
trigger = session.get(Trigger, execution.trigger_id)
if trigger and trigger.user_id:
user_session_ids = redis_manager.get_user_sessions(trigger.user_id)
for session_id in user_session_ids:
redis_manager.remove_pending_execution(session_id, execution.execution_id)
elif not trigger:
logger.warning("Trigger not found for execution", extra={
"execution_id": execution.execution_id,
"trigger_id": execution.trigger_id
})
except Exception as e:
logger.warning("Failed to remove execution from Redis", extra={
"execution_id": execution.execution_id,
"trigger_id": execution.trigger_id,
"error": str(e)
})
logger.info("Execution timed out", extra={
"execution_id": execution.execution_id,
"trigger_id": execution.trigger_id,
"original_status": "pending" if is_pending else "running",
"new_status": new_status.value,
"time_elapsed": time_elapsed
})
total_timed_out = timed_out_pending_count + timed_out_running_count
if total_timed_out > 0:
logger.info("Marked executions as timed out", extra={
"timed_out_pending_count": timed_out_pending_count,
"timed_out_running_count": timed_out_running_count,
"total_timed_out": total_timed_out
})
else:
logger.debug("No timed-out executions found")
except Exception as e:
logger.error("Error checking execution timeouts", extra={
"error": str(e),
"error_type": type(e).__name__
}, exc_info=True)
session.rollback()
finally:
session.close()

View file

@ -0,0 +1,54 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
"""
Trigger Service Package
Contains services for managing triggers including:
- TriggerService: Main service for trigger operations
- TriggerScheduleService: Service for scheduled trigger operations
- App Handlers: Handlers for different trigger types (Slack, Webhook, Schedule)
"""
from app.service.trigger.trigger_service import TriggerService, get_trigger_service
from app.service.trigger.trigger_schedule_service import TriggerScheduleService
from app.service.trigger.app_handler_service import (
BaseAppHandler,
SlackAppHandler,
DefaultWebhookHandler,
ScheduleAppHandler,
AppHandlerResult,
get_app_handler,
get_schedule_handler,
register_app_handler,
get_supported_trigger_types,
)
__all__ = [
# Services
"TriggerService",
"get_trigger_service",
"TriggerScheduleService",
# Handlers
"BaseAppHandler",
"SlackAppHandler",
"DefaultWebhookHandler",
"ScheduleAppHandler",
"AppHandlerResult",
# Handler functions
"get_app_handler",
"get_schedule_handler",
"register_app_handler",
"get_supported_trigger_types",
]

View file

@ -0,0 +1,446 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
"""
Trigger App Handler Service
Modular service for handling app-specific webhook authentication,
filtering, and payload normalization based on trigger_type.
"""
import re
from typing import Optional
from dataclasses import dataclass
from fastapi import Request
from sqlmodel import Session, select, and_
import logging
from app.model.trigger.trigger import Trigger
from app.model.config.config import Config
from app.model.trigger.app_configs import SlackTriggerConfig, WebhookTriggerConfig, ScheduleTriggerConfig
from app.type.trigger_types import TriggerType, ExecutionType, TriggerStatus
from app.type.config_group import ConfigGroup
@dataclass
class AppHandlerResult:
"""Result from app handler operations."""
success: bool
data: Optional[dict] = None
reason: Optional[str] = None
class BaseAppHandler:
"""Base class for app-specific handlers."""
trigger_type: TriggerType
execution_type: ExecutionType = ExecutionType.webhook
config_group: Optional[str] = None
async def get_credentials(self, session: Session, user_id: str) -> dict:
"""Get user credentials from config table."""
if not self.config_group:
return {}
configs = session.exec(
select(Config).where(
and_(
Config.user_id == int(user_id),
Config.config_group == self.config_group
)
)
).all()
return {config.config_name: config.config_value for config in configs}
async def authenticate(
self,
request: Request,
body: bytes,
trigger: Trigger,
session: Session
) -> AppHandlerResult:
"""
Authenticate the incoming webhook request.
Returns (success, challenge_response or None)
"""
return AppHandlerResult(success=True)
async def filter_event(
self,
payload: dict,
trigger: Trigger
) -> AppHandlerResult:
"""
Filter events based on trigger configuration.
Returns (should_process, reason)
"""
return AppHandlerResult(success=True, reason="ok")
def normalize_payload(
self,
payload: dict,
trigger: Trigger,
request_meta: dict = None
) -> dict:
"""Normalize the payload for execution input."""
return payload
class SlackAppHandler(BaseAppHandler):
"""Handler for Slack triggers."""
trigger_type = TriggerType.slack_trigger
execution_type = ExecutionType.slack
config_group = ConfigGroup.SLACK.value
async def authenticate(
self,
request: Request,
body: bytes,
trigger: Trigger,
session: Session
) -> AppHandlerResult:
"""Handle Slack authentication and URL verification."""
from camel.auth.slack_auth import SlackAuth
credentials = await self.get_credentials(session, trigger.user_id)
slack_auth = SlackAuth(
signing_secret=credentials.get("SLACK_SIGNING_SECRET"),
bot_token=credentials.get("SLACK_BOT_TOKEN"),
api_token=credentials.get("SLACK_API_TOKEN"),
)
# Check for URL verification challenge
challenge_response = slack_auth.get_verification_response(request, body)
if challenge_response:
# Return the challenge response (already in correct format: {"challenge": "..."})
logger.info(f"Slack URL verification - challenge_response: {challenge_response}")
return AppHandlerResult(success=True, data=challenge_response)
# Verify webhook signature
if not slack_auth.verify_webhook_request(request, body):
logger.warning("Invalid Slack webhook signature", extra={
"trigger_id": trigger.id
})
return AppHandlerResult(success=False, reason="invalid_signature")
return AppHandlerResult(success=True)
async def filter_event(
self,
payload: dict,
trigger: Trigger
) -> AppHandlerResult:
"""Filter Slack events based on trigger config."""
# Prefer 'config' field
config_data = trigger.config or {}
config = SlackTriggerConfig(**config_data)
event = payload.get("event", {})
event_type = event.get("type", "")
# Check event type
if not config.should_trigger(event_type):
return AppHandlerResult(success=False, reason="event_type_not_configured")
# Check channel filter (if channel_id is set, only trigger for that channel)
if config.channel_id:
if event.get("channel") != config.channel_id:
return AppHandlerResult(success=False, reason="channel_not_matched")
# Check bot message filter
if config.ignore_bot_messages:
if event.get("bot_id") or event.get("subtype") == "bot_message":
return AppHandlerResult(success=False, reason="bot_message_ignored")
# Check user filter
if config.ignore_users and event.get("user") in config.ignore_users:
return AppHandlerResult(success=False, reason="user_filtered")
# Check message filter regex
if config.message_filter and event.get("text"):
if not re.search(config.message_filter, event.get("text", ""), re.IGNORECASE):
return AppHandlerResult(success=False, reason="message_filter_not_matched")
return AppHandlerResult(success=True, reason="ok")
def normalize_payload(
self,
payload: dict,
trigger: Trigger,
request_meta: dict = None
) -> dict:
"""Normalize Slack event payload."""
logger.info("Normalizing payload", extra={"payload": payload})
# Prefer 'config' field
config_data = trigger.config or {}
config = SlackTriggerConfig(**config_data)
event = payload.get("event", {})
normalized = {
"event_type": event.get("type"),
"event_ts": event.get("event_ts"),
"team_id": payload.get("team_id"),
"user_id": event.get("user"),
"channel_id": event.get("channel"),
"text": event.get("text"),
"message_ts": event.get("ts"),
"thread_ts": event.get("thread_ts"),
"reaction": event.get("reaction"),
"files": event.get("files"),
"event_id": payload.get("event_id") or payload.get("id")
}
# if config.include_raw_payload:
# normalized["raw_payload"] = payload
return normalized
class DefaultWebhookHandler(BaseAppHandler):
"""Default handler for generic webhooks with config-based filtering."""
trigger_type = TriggerType.webhook
execution_type = ExecutionType.webhook
async def filter_event(
self,
payload: dict,
trigger: Trigger,
headers: dict = None,
body_raw: str = None
) -> AppHandlerResult:
"""Filter webhook events based on trigger config."""
config_data = trigger.config or {}
config = WebhookTriggerConfig(**config_data)
# Get text content for message_filter (check body for text field or stringify)
text = None
if isinstance(payload, dict):
text = payload.get("text") or payload.get("message") or payload.get("content")
if text is None and body_raw:
text = body_raw
# Use the config's should_trigger method
should_trigger, reason = config.should_trigger(
body=body_raw or "",
headers=headers or {},
text=text
)
if not should_trigger:
return AppHandlerResult(success=False, reason=reason)
return AppHandlerResult(success=True, reason="ok")
def normalize_payload(
self,
payload: dict,
trigger: Trigger,
request_meta: dict = None
) -> dict:
"""Normalize generic webhook payload with full request metadata."""
config_data = trigger.config or {}
config = WebhookTriggerConfig(**config_data)
result = {"body": payload}
if request_meta:
# Include headers if configured
if config.include_headers and "headers" in request_meta:
result["headers"] = request_meta["headers"]
# Include query params if configured
if config.include_query_params and "query_params" in request_meta:
result["query_params"] = request_meta["query_params"]
# Include request metadata if configured
if config.include_request_metadata:
if "method" in request_meta:
result["method"] = request_meta["method"]
if "url" in request_meta:
result["url"] = request_meta["url"]
if "client_ip" in request_meta:
result["client_ip"] = request_meta["client_ip"]
return result
class ScheduleAppHandler(BaseAppHandler):
"""
Handler for scheduled triggers.
Manages schedule-specific logic including:
- Expiration checking (expirationDate for recurring schedules)
- Date validation for one-time executions (date field)
"""
trigger_type = TriggerType.schedule
execution_type = ExecutionType.scheduled
async def filter_event(
self,
payload: dict,
trigger: Trigger
) -> AppHandlerResult:
"""
Filter scheduled events based on trigger config.
Checks:
- If one-time (date set) and date has passed
- If recurring with expirationDate and it has passed
"""
config_data = trigger.config or {}
try:
config = ScheduleTriggerConfig(**config_data)
except Exception as e:
logger.warning(
"Invalid schedule config",
extra={"trigger_id": trigger.id, "error": str(e)}
)
# Allow execution if config is missing/invalid (backwards compatibility)
return AppHandlerResult(success=True, reason="ok")
# Check if schedule should execute
should_execute, reason = config.should_execute()
if not should_execute:
return AppHandlerResult(success=False, reason=reason)
return AppHandlerResult(success=True, reason="ok")
def normalize_payload(
self,
payload: dict,
trigger: Trigger,
request_meta: dict = None
) -> dict:
"""Normalize scheduled trigger payload."""
config_data = trigger.config or {}
normalized = {
"scheduled_at": payload.get("scheduled_at"),
"trigger_id": trigger.id,
"trigger_name": trigger.name,
"is_single_execution": trigger.is_single_execution,
}
# Include config details if present
if config_data:
if config_data.get("date"):
normalized["date"] = config_data.get("date")
if config_data.get("expirationDate"):
normalized["expirationDate"] = config_data.get("expirationDate")
return normalized
def check_and_handle_expiration(
self,
trigger: Trigger,
session: Session
) -> bool:
"""
Check if a schedule has expired and handle accordingly.
Args:
trigger: The trigger to check
session: Database session for updates
Returns:
True if trigger is expired and was deactivated, False otherwise
"""
config_data = trigger.config or {}
try:
config = ScheduleTriggerConfig(**config_data)
except Exception as e:
logger.warning(
"Invalid schedule config during expiration check",
extra={"trigger_id": trigger.id, "error": str(e)}
)
return False
if config.is_expired():
# Deactivate the trigger
trigger.status = TriggerStatus.completed
session.add(trigger)
session.commit()
logger.info(
"Schedule trigger expired and deactivated",
extra={
"trigger_id": trigger.id,
"trigger_name": trigger.name,
"expiration_date": config.expirationDate or config.date
}
)
return True
return False
def validate_schedule_for_execution(
self,
trigger: Trigger
) -> tuple[bool, str]:
"""
Validate that a scheduled trigger is valid for execution.
Args:
trigger: The trigger to validate
Returns:
Tuple of (is_valid, reason)
"""
config_data = trigger.config or {}
try:
config = ScheduleTriggerConfig(**config_data)
except Exception as e:
return False, f"invalid_config: {str(e)}"
# Check expiration
if config.is_expired():
return False, "schedule_expired"
return True, "ok"
# Registry of handlers by trigger_type
_HANDLERS: dict[TriggerType, BaseAppHandler] = {
TriggerType.slack_trigger: SlackAppHandler(),
TriggerType.webhook: DefaultWebhookHandler(),
TriggerType.schedule: ScheduleAppHandler(),
}
def get_app_handler(trigger_type: TriggerType) -> Optional[BaseAppHandler]:
"""Get the handler for a trigger type."""
return _HANDLERS.get(trigger_type)
def register_app_handler(trigger_type: TriggerType, handler: BaseAppHandler):
"""Register a new app handler."""
_HANDLERS[trigger_type] = handler
def get_supported_trigger_types() -> list[TriggerType]:
"""Get list of trigger types with webhook support."""
return list(_HANDLERS.keys())
def get_schedule_handler() -> ScheduleAppHandler:
"""Get the schedule handler instance."""
return _HANDLERS.get(TriggerType.schedule)

View file

@ -0,0 +1,428 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
from datetime import datetime, timedelta, timezone
from typing import List, Tuple, Optional
import logging
from croniter import croniter
from uuid import uuid4
import asyncio
from sqlmodel import select
from app.model.trigger.trigger import Trigger
from app.model.trigger.trigger_execution import TriggerExecution
from app.type.trigger_types import TriggerStatus, ExecutionType, ExecutionStatus, TriggerType
from app.component.trigger_utils import check_rate_limits, MAX_DISPATCH_PER_TICK
from app.model.trigger.app_configs import ScheduleTriggerConfig
class TriggerScheduleService:
"""Service for managing scheduled trigger operations.
This service mainly delegates schedule business logic
from the main trigger_service.py.
Handles tasks from the Celery beat scheduler.
Mainly handles:
- Polling for due schedules
- Dispatching scheduled triggers
- Calculating next run times based on cron expressions
"""
def __init__(self, session):
"""
Initialize the schedule service with a database session.
Args:
session: SQLModel session for database operations
"""
self.session = session
def fetch_due_schedules(self, limit: Optional[int] = 100) -> List[Trigger]:
"""
Fetch triggers that are due for execution.
Args:
limit: Maximum number of triggers to fetch
Returns:
List of triggers that need to be executed
"""
now = datetime.now(timezone.utc)
try:
statement = (
select(Trigger)
.where(Trigger.trigger_type == TriggerType.schedule)
.where(Trigger.status == TriggerStatus.active)
.where(Trigger.next_run_at <= now)
.order_by(Trigger.next_run_at)
.limit(limit)
)
results = self.session.exec(statement).all()
logger.debug(
"Fetched due schedules",
extra={
"count": len(results),
"current_time": now.isoformat()
}
)
return list(results)
except Exception as e:
logger.error(
"Failed to fetch due schedules",
extra={"error": str(e)},
exc_info=True
)
return []
def calculate_next_run_at(
self,
trigger: Trigger,
base_time: Optional[datetime] = None
) -> datetime:
"""
Calculate the next run time for a trigger based on its cron expression.
Args:
trigger: The trigger to calculate next run time for
base_time: Base time to calculate from (defaults to now)
Returns:
The next scheduled run time
Raises:
ValueError: If trigger has no cron expression or invalid expression
"""
if not trigger.custom_cron_expression:
raise ValueError(f"Trigger {trigger.id} has no cron expression")
if base_time is None:
base_time = datetime.now(timezone.utc)
try:
cron = croniter(trigger.custom_cron_expression, base_time)
next_run = cron.get_next(datetime)
return next_run
except Exception as e:
logger.error(
"Failed to calculate next run time",
extra={
"trigger_id": trigger.id,
"cron_expression": trigger.custom_cron_expression,
"error": str(e)
}
)
raise
def dispatch_trigger(self, trigger: Trigger) -> bool:
"""
Dispatch a trigger for execution.
Args:
trigger: The trigger to dispatch
Returns:
True if dispatched successfully, False otherwise
"""
try:
# Check schedule expiration before dispatching
if not self._check_schedule_valid(trigger):
logger.info(
"Schedule trigger expired, skipping dispatch",
extra={"trigger_id": trigger.id, "trigger_name": trigger.name}
)
return False
# Create execution record
execution_id = str(uuid4())
execution = TriggerExecution(
trigger_id=trigger.id,
execution_id=execution_id,
execution_type=ExecutionType.scheduled,
status=ExecutionStatus.pending,
input_data={"scheduled_at": datetime.now(timezone.utc).isoformat()},
started_at=datetime.now(timezone.utc)
)
self.session.add(execution)
# Update trigger statistics
trigger.last_executed_at = datetime.now(timezone.utc)
trigger.last_execution_status = "pending"
# Calculate and set next run time
try:
trigger.next_run_at = self.calculate_next_run_at(trigger, datetime.now(timezone.utc))
except Exception as e:
logger.error(
"Failed to calculate next run time, trigger will be skipped",
extra={"trigger_id": trigger.id, "error": str(e)}
)
# Set next_run_at far in the future to prevent immediate re-execution
trigger.next_run_at = datetime.now(timezone.utc) + timedelta(days=365)
# If single execution, deactivate the trigger
if trigger.is_single_execution:
trigger.status = TriggerStatus.inactive
logger.info(
"Trigger deactivated after single execution",
extra={"trigger_id": trigger.id}
)
self.session.add(trigger)
self.session.commit()
# TODO: Queue the actual task execution
# This would integrate with a task queue (e.g., Celery) to execute the trigger's action
# For now event is sent to client for execution
logger.info(
"Trigger dispatched successfully",
extra={
"trigger_id": trigger.id,
"trigger_name": trigger.name,
"execution_id": execution_id,
"next_run_at": trigger.next_run_at.isoformat() if trigger.next_run_at else None
}
)
# Notify WebSocket subscribers
# Using asyncio.run() to run async code from sync Celery worker context
try:
# Notify WebSocket subscribers via Redis pub/sub
from app.component.redis_utils import get_redis_manager
redis_manager = get_redis_manager()
redis_manager.publish_execution_event({
"type": "execution_created",
"execution_id": execution_id,
"trigger_id": trigger.id,
"trigger_type": "schedule",
"status": "pending",
"input_data": execution.input_data,
"task_prompt": trigger.task_prompt,
"execution_type": "schedule",
"user_id": str(trigger.user_id),
"project_id": str(trigger.project_id)
})
logger.debug("WebSocket notification sent", extra={
"execution_id": execution_id,
"trigger_id": trigger.id
})
except Exception as e:
# Don't fail the trigger dispatch if notification fails
logger.warning("Failed to send WebSocket notification", extra={
"trigger_id": trigger.id,
"execution_id": execution_id,
"error": str(e)
})
return True
except Exception as e:
logger.error(
"Failed to dispatch trigger",
extra={
"trigger_id": trigger.id,
"error": str(e)
},
exc_info=True
)
self.session.rollback()
return False
def process_schedules(self, due_schedules: List[Trigger]) -> Tuple[int, int]:
"""
Process due schedules, checking rate limits and dispatching.
Args:
due_schedules: List of triggers that are due for execution
Returns:
Tuple of (dispatched_count, rate_limited_count)
"""
dispatched_count = 0
rate_limited_count = 0
for trigger in due_schedules:
# Check rate limits
if not check_rate_limits(self.session, trigger):
rate_limited_count += 1
# Still update next_run_at even if rate limited, so we don't keep checking
try:
trigger.next_run_at = self.calculate_next_run_at(trigger, datetime.now(timezone.utc))
self.session.add(trigger)
self.session.commit()
except Exception as e:
logger.error(
"Failed to update next_run_at for rate limited trigger",
extra={"trigger_id": trigger.id, "error": str(e)}
)
continue
# Dispatch the trigger
if self.dispatch_trigger(trigger):
dispatched_count += 1
return dispatched_count, rate_limited_count
def poll_and_execute_due_triggers(
self,
max_dispatch_per_tick: Optional[int] = None
) -> Tuple[int, int]:
"""
Poll for due triggers and execute them in batches.
Args:
max_dispatch_per_tick: Maximum number of triggers to dispatch in this tick
(defaults to MAX_DISPATCH_PER_TICK)
Returns:
Tuple of (total_dispatched, total_rate_limited)
"""
max_dispatch = max_dispatch_per_tick or MAX_DISPATCH_PER_TICK
total_dispatched = 0
total_rate_limited = 0
# Process in batches until we've handled all due schedules or hit the limit
while True:
due_schedules = self.fetch_due_schedules()
if not due_schedules:
break
dispatched_count, rate_limited_count = self.process_schedules(due_schedules)
total_dispatched += dispatched_count
total_rate_limited += rate_limited_count
logger.debug(
"Batch processed",
extra={
"dispatched": dispatched_count,
"rate_limited": rate_limited_count
}
)
# Check if we've hit the per-tick limit (if enabled)
if max_dispatch > 0 and total_dispatched >= max_dispatch:
logger.warning(
"Circuit breaker activated: reached dispatch limit, will continue next tick",
extra={"limit": max_dispatch}
)
break
if total_dispatched > 0 or total_rate_limited > 0:
logger.info(
"Trigger schedule poll completed",
extra={
"total_dispatched": total_dispatched,
"total_rate_limited": total_rate_limited
}
)
return total_dispatched, total_rate_limited
def _check_schedule_valid(self, trigger: Trigger) -> bool:
"""
Check if a scheduled trigger is valid for execution.
Validates:
- For one-time (date set): Checks if the scheduled date has passed
- For recurring (expirationDate set): Checks if expirationDate has passed
If expired, the trigger will be marked as completed.
Args:
trigger: The trigger to check
Returns:
True if trigger is valid for execution, False if expired
"""
config_data = trigger.config or {}
# If no config or empty config, allow execution (no expiration)
if not config_data:
return True
try:
config = ScheduleTriggerConfig(**config_data)
except Exception as e:
logger.warning(
"Invalid schedule config",
extra={"trigger_id": trigger.id, "error": str(e)}
)
return False
# Check if schedule has expired
if config.is_expired():
# Mark trigger as completed
trigger.status = TriggerStatus.completed
self.session.add(trigger)
self.session.commit()
logger.info(
"Schedule trigger expired and marked as completed",
extra={
"trigger_id": trigger.id,
"trigger_name": trigger.name,
"expiration_info": config.expirationDate or config.date
}
)
return False
return True
def update_trigger_next_run(self, trigger: Trigger) -> None:
"""
Update a trigger's next_run_at based on its cron expression.
Args:
trigger: The trigger to update
"""
try:
# Check if schedule is expired before updating next run
if not self._check_schedule_valid(trigger):
logger.info(
"Trigger expired, not updating next_run_at",
extra={"trigger_id": trigger.id}
)
return
trigger.next_run_at = self.calculate_next_run_at(trigger)
self.session.add(trigger)
self.session.commit()
logger.info(
"Trigger next_run_at updated",
extra={
"trigger_id": trigger.id,
"next_run_at": trigger.next_run_at.isoformat()
}
)
except Exception as e:
logger.error(
"Failed to update trigger next_run_at",
extra={
"trigger_id": trigger.id,
"error": str(e)
}
)
self.session.rollback()

View file

@ -0,0 +1,391 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
from datetime import datetime, timedelta, timezone
from typing import Optional, List, Dict, Any
from sqlmodel import select, and_, or_
from uuid import uuid4
import logging
from app.model.trigger.trigger import Trigger
from app.model.trigger.trigger_execution import TriggerExecution
from app.type.trigger_types import TriggerType, TriggerStatus, ExecutionType, ExecutionStatus
from app.component.database import session_make
from app.service.trigger.trigger_schedule_service import TriggerScheduleService
from app.component.trigger_utils import SCHEDULED_FETCH_BATCH_SIZE, check_rate_limits
from app.model.trigger.app_configs import ScheduleTriggerConfig, WebhookTriggerConfig
from app.model.trigger.app_configs.base_config import BaseTriggerConfig
class TriggerService:
"""Service for managing trigger operations and scheduling."""
def __init__(self, session=None):
self.session = session or session_make()
self.schedule_service = TriggerScheduleService(self.session)
def create_execution(
self,
trigger: Trigger,
execution_type: ExecutionType,
input_data: Optional[Dict[str, Any]] = None
) -> TriggerExecution:
"""Create a new trigger execution."""
execution_id = str(uuid4())
execution = TriggerExecution(
trigger_id=trigger.id,
execution_id=execution_id,
execution_type=execution_type,
status=ExecutionStatus.pending,
input_data=input_data or {},
started_at=datetime.now(timezone.utc)
)
self.session.add(execution)
self.session.commit()
self.session.refresh(execution)
# Update trigger statistics
trigger.last_executed_at = datetime.now(timezone.utc)
trigger.last_execution_status = "pending"
self.session.add(trigger)
self.session.commit()
logger.info("Execution created", extra={
"trigger_id": trigger.id,
"execution_id": execution_id,
"execution_type": execution_type.value
})
return execution
def update_execution_status(
self,
execution: TriggerExecution,
status: ExecutionStatus,
output_data: Optional[Dict[str, Any]] = None,
error_message: Optional[str] = None,
tokens_used: Optional[int] = None,
tools_executed: Optional[Dict[str, Any]] = None
) -> TriggerExecution:
"""Update execution status and metadata."""
execution.status = status
# Set completed_at and duration for terminal statuses
if status in [ExecutionStatus.completed, ExecutionStatus.failed, ExecutionStatus.cancelled, ExecutionStatus.missed]:
execution.completed_at = datetime.now(timezone.utc)
if execution.started_at:
# Ensure started_at is timezone-aware for subtraction
started_at = execution.started_at
if started_at.tzinfo is None:
started_at = started_at.replace(tzinfo=timezone.utc)
execution.duration_seconds = (execution.completed_at - started_at).total_seconds()
if output_data:
execution.output_data = output_data
if error_message:
execution.error_message = error_message
if tokens_used:
execution.tokens_used = tokens_used
if tools_executed:
execution.tools_executed = tools_executed
self.session.add(execution)
self.session.commit()
# Update trigger status and handle auto-disable logic
trigger = self.session.get(Trigger, execution.trigger_id)
if trigger:
if status == ExecutionStatus.failed:
trigger.last_execution_status = "failed"
trigger.consecutive_failures += 1
# Check for auto-disable based on max_failure_count in config
self._check_auto_disable(trigger)
elif status == ExecutionStatus.completed:
trigger.last_execution_status = "completed"
# Reset consecutive failures on success
trigger.consecutive_failures = 0
elif status == ExecutionStatus.cancelled:
trigger.last_execution_status = "cancelled"
elif status == ExecutionStatus.missed:
trigger.last_execution_status = "missed"
self.session.add(trigger)
self.session.commit()
logger.info("Execution status updated", extra={
"execution_id": execution.execution_id,
"status": status.name,
"duration": execution.duration_seconds
})
return execution
def _check_auto_disable(self, trigger: Trigger) -> bool:
"""
Check if trigger should be auto-disabled based on consecutive failures.
Args:
trigger: The trigger to check
Returns:
True if trigger was auto-disabled, False otherwise
"""
if not trigger.config:
return False
try:
# Get the appropriate config class based on trigger type
config: BaseTriggerConfig
if trigger.trigger_type == TriggerType.schedule:
config = ScheduleTriggerConfig(**trigger.config)
elif trigger.trigger_type == TriggerType.webhook:
config = WebhookTriggerConfig(**trigger.config)
else:
# For other trigger types, use base config
config = BaseTriggerConfig(**trigger.config)
# Check if auto-disable should happen
if config.should_auto_disable(trigger.consecutive_failures):
trigger.status = TriggerStatus.inactive
trigger.auto_disabled_at = datetime.now(timezone.utc)
logger.warning(
"Trigger auto-disabled due to max failures",
extra={
"trigger_id": trigger.id,
"trigger_name": trigger.name,
"consecutive_failures": trigger.consecutive_failures,
"max_failure_count": config.max_failure_count
}
)
return True
except Exception as e:
logger.error(
"Failed to check auto-disable for trigger",
extra={
"trigger_id": trigger.id,
"error": str(e)
}
)
return False
def get_pending_executions(self) -> List[TriggerExecution]:
"""Get all pending executions that need to be processed."""
executions = self.session.exec(
select(TriggerExecution).where(
TriggerExecution.status == ExecutionStatus.pending
).order_by(TriggerExecution.created_at)
).all()
return list(executions)
def get_failed_executions_for_retry(self) -> List[TriggerExecution]:
"""Get failed executions that can be retried."""
executions = self.session.exec(
select(TriggerExecution).where(
and_(
TriggerExecution.status == ExecutionStatus.failed,
TriggerExecution.attempts < TriggerExecution.max_retries
)
).order_by(TriggerExecution.created_at)
).all()
return list(executions)
def get_due_scheduled_triggers(self, limit: Optional[int] = None) -> List[Trigger]:
"""
Fetch scheduled triggers that are due for execution.
Args:
limit: Maximum number of triggers to fetch (defaults to SCHEDULED_FETCH_BATCH_SIZE)
Returns:
List of triggers that are due for execution
"""
current_time = datetime.now(timezone.utc)
limit = limit or SCHEDULED_FETCH_BATCH_SIZE
# Query triggers that:
# 1. Are scheduled type
# 2. Are active
# 3. Have a cron expression
# 4. next_run_at is null (never run) or next_run_at <= now
triggers = self.session.exec(
select(Trigger)
.where(
and_(
Trigger.trigger_type == TriggerType.schedule,
Trigger.status == TriggerStatus.active,
Trigger.custom_cron_expression.is_not(None),
or_(
Trigger.next_run_at.is_(None),
Trigger.next_run_at <= current_time
)
)
)
.limit(limit)
).all()
return list(triggers)
def execute_scheduled_triggers(self) -> int:
"""
Execute all due scheduled triggers.
Uses TriggerScheduleService for the actual execution logic.
"""
due_triggers = self.get_due_scheduled_triggers()
if not due_triggers:
return 0
dispatched_count, rate_limited_count = self.schedule_service.process_schedules(due_triggers)
logger.info(
"Scheduled triggers execution completed",
extra={
"dispatched": dispatched_count,
"rate_limited": rate_limited_count
}
)
return dispatched_count
def process_slack_trigger(
self,
trigger: Trigger,
slack_data: Dict[str, Any]
) -> Optional[TriggerExecution]:
"""Process a Slack trigger event."""
if trigger.trigger_type != TriggerType.slack_trigger:
raise ValueError("Trigger is not a Slack trigger")
if trigger.status != TriggerStatus.active:
logger.warning("Slack trigger is not active", extra={
"trigger_id": trigger.id
})
return None
if not check_rate_limits(self.session, trigger):
logger.warning("Slack trigger execution skipped due to rate limits", extra={
"trigger_id": trigger.id
})
return None
try:
execution = self.create_execution(
trigger=trigger,
execution_type=ExecutionType.slack,
input_data=slack_data
)
# TODO: Queue the actual task execution
logger.info("Slack trigger executed", extra={
"trigger_id": trigger.id,
"execution_id": execution.execution_id
})
return execution
except Exception as e:
logger.error("Slack trigger execution failed", extra={
"trigger_id": trigger.id,
"error": str(e)
}, exc_info=True)
return None
def cleanup_old_executions(self, days_to_keep: int = 30) -> int:
"""Clean up old execution records."""
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days_to_keep)
old_executions = self.session.exec(
select(TriggerExecution).where(
and_(
TriggerExecution.created_at < cutoff_date,
TriggerExecution.status.in_([
ExecutionStatus.completed,
ExecutionStatus.failed,
ExecutionStatus.cancelled
])
)
)
).all()
count = len(old_executions)
for execution in old_executions:
self.session.delete(execution)
self.session.commit()
logger.info("Old executions cleaned up", extra={
"count": count,
"days_to_keep": days_to_keep
})
return count
def get_trigger_statistics(self, trigger_id: int) -> Dict[str, Any]:
"""Get statistics for a specific trigger."""
trigger = self.session.get(Trigger, trigger_id)
if not trigger:
raise ValueError("Trigger not found")
# Get execution counts by status
executions = self.session.exec(
select(TriggerExecution).where(
TriggerExecution.trigger_id == trigger_id
)
).all()
stats = {
"trigger_id": trigger_id,
"name": trigger.name,
"trigger_type": trigger.trigger_type.value,
"status": trigger.status.name,
"total_executions": len(executions),
"successful_executions": len([e for e in executions if e.status == ExecutionStatus.completed]),
"failed_executions": len([e for e in executions if e.status == ExecutionStatus.failed]),
"pending_executions": len([e for e in executions if e.status == ExecutionStatus.pending]),
"cancelled_executions": len([e for e in executions if e.status == ExecutionStatus.cancelled]),
"last_executed_at": trigger.last_executed_at.isoformat() if trigger.last_executed_at else None,
"created_at": trigger.created_at.isoformat() if trigger.created_at else None
}
# Calculate average execution time for completed executions
completed_executions = [e for e in executions if e.status == ExecutionStatus.completed and e.duration_seconds]
if completed_executions:
avg_duration = sum(e.duration_seconds for e in completed_executions) / len(completed_executions)
stats["average_execution_time_seconds"] = round(avg_duration, 2)
# Calculate total tokens used
total_tokens = sum(e.tokens_used for e in executions if e.tokens_used)
if total_tokens:
stats["total_tokens_used"] = total_tokens
return stats
def get_trigger_service(session=None) -> TriggerService:
"""Factory function to create a TriggerService instance with a fresh session."""
return TriggerService(session)

View file

@ -0,0 +1,95 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
"""Rate limiting utilities for triggers."""
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING
import logging
from sqlmodel import select, and_
from app.model.trigger.trigger_execution import TriggerExecution
from app.component.environment import env
logger = logging.getLogger("server_trigger_utils")
if TYPE_CHECKING:
from sqlmodel import Session
from app.model.trigger.trigger import Trigger
# Environment variable configuration with defaults
MAX_DISPATCH_PER_TICK = int(env("TRIGGER_SCHEDULE_MAX_DISPATCH_PER_TICK", "0")) # Max triggers to dispatch per tick
SCHEDULED_FETCH_BATCH_SIZE = int(env("TRIGGER_SCHEDULE_POLLER_BATCH_SIZE", "100")) # Fetch batch size
def check_rate_limits(session: "Session", trigger: "Trigger") -> bool:
"""
Check if trigger execution is within rate limits.
Args:
session: Database session
trigger: The trigger to check rate limits for
Returns:
True if execution is allowed, False if rate limited
"""
current_time = datetime.now(timezone.utc)
# Check hourly limit
if trigger.max_executions_per_hour:
hour_ago = current_time - timedelta(hours=1)
hourly_count = session.exec(
select(TriggerExecution).where(
and_(
TriggerExecution.trigger_id == trigger.id,
TriggerExecution.created_at >= hour_ago
)
)
).all()
if len(hourly_count) >= trigger.max_executions_per_hour:
logger.warning(
"Trigger hourly rate limit exceeded",
extra={
"trigger_id": trigger.id,
"limit": trigger.max_executions_per_hour,
"current_count": len(hourly_count)
}
)
return False
# Check daily limit
if trigger.max_executions_per_day:
day_ago = current_time - timedelta(days=1)
daily_count = session.exec(
select(TriggerExecution).where(
and_(
TriggerExecution.trigger_id == trigger.id,
TriggerExecution.created_at >= day_ago
)
)
).all()
if len(daily_count) >= trigger.max_executions_per_day:
logger.warning(
"Trigger daily rate limit exceeded",
extra={
"trigger_id": trigger.id,
"limit": trigger.max_executions_per_day,
"current_count": len(daily_count)
}
)
return False
return True

View file

@ -0,0 +1,14 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========

View file

@ -0,0 +1,135 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select, and_
from typing import Optional, List
import logging
from pydantic import BaseModel
from app.model.config.config import Config
from app.type.config_group import ConfigGroup
from app.component.auth import Auth, auth_must
from app.component.database import session
logger = logging.getLogger("server_slack_controller")
class SlackChannelOut(BaseModel):
"""Output model for Slack channels."""
id: str
name: str
is_private: bool = False
is_member: bool = False
num_members: Optional[int] = None
class SlackChannelsResponse(BaseModel):
"""Response model for Slack channels list."""
channels: List[SlackChannelOut]
has_credentials: bool
router = APIRouter(prefix="/trigger/slack", tags=["Slack Integration"])
@router.get("/channels", name="get slack channels")
def get_slack_channels(
session: Session = Depends(session),
auth: Auth = Depends(auth_must)
) -> SlackChannelsResponse:
"""
Get list of Slack channels for the authenticated user.
This endpoint fetches channels from the user's Slack workspace using their
stored credentials. Requires SLACK_BOT_TOKEN to be configured in user configs.
"""
user_id = auth.user.id
# Get Slack credentials from config
configs = session.exec(
select(Config).where(
and_(
Config.user_id == int(user_id),
Config.config_group == ConfigGroup.SLACK.value
)
)
).all()
credentials = {config.config_name: config.config_value for config in configs}
bot_token = credentials.get("SLACK_BOT_TOKEN")
if not bot_token:
logger.warning("Slack credentials not found", extra={"user_id": user_id})
return SlackChannelsResponse(channels=[], has_credentials=False)
try:
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
client = WebClient(token=bot_token)
# Fetch all channels (public and private the bot has access to)
channels = []
cursor = None
while True:
response = client.conversations_list(
types="public_channel,private_channel",
cursor=cursor,
limit=200
)
for channel in response.get("channels", []):
channels.append(SlackChannelOut(
id=channel.get("id"),
name=channel.get("name"),
is_private=channel.get("is_private", False),
is_member=channel.get("is_member", False),
num_members=channel.get("num_members")
))
# Check for pagination
cursor = response.get("response_metadata", {}).get("next_cursor")
if not cursor:
break
logger.info("Slack channels fetched", extra={
"user_id": user_id,
"channel_count": len(channels)
})
return SlackChannelsResponse(channels=channels, has_credentials=True)
except ImportError:
logger.error("slack_sdk not installed")
raise HTTPException(
status_code=500,
detail="Slack SDK not installed on server"
)
except SlackApiError as e:
logger.error("Slack API error", extra={
"user_id": user_id,
"error": str(e)
})
raise HTTPException(
status_code=400,
detail=f"Slack API error: {e.response.get('error', 'Unknown error')}"
)
except Exception as e:
logger.error("Error fetching Slack channels", extra={
"user_id": user_id,
"error": str(e)
}, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to fetch Slack channels")

View file

@ -0,0 +1,688 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
from fastapi import APIRouter, Depends, HTTPException, Response, Query
from fastapi_pagination import Page
from fastapi_pagination.ext.sqlmodel import paginate
from sqlmodel import Session, select, desc, and_, delete
from typing import Optional
from uuid import uuid4
import logging
from pydantic import ValidationError
from app.model.trigger.trigger import Trigger, TriggerIn, TriggerOut, TriggerUpdate, TriggerConfigSchemaOut
from app.model.trigger.trigger_execution import TriggerExecution, TriggerExecutionOut
from app.model.trigger.app_configs import (
get_config_schema,
validate_config,
has_config,
validate_activation,
ActivationError,
)
from app.model.trigger.app_configs.config_registry import requires_authentication
from app.model.chat.chat_history import ChatHistory
from app.type.trigger_types import TriggerType, TriggerStatus
from app.component.auth import Auth, auth_must
from app.component.database import session
from app.component.redis_utils import get_redis_manager
from app.service.trigger.trigger_schedule_service import TriggerScheduleService
from fastapi_babel import _
from sqlalchemy import func
logger = logging.getLogger("server_trigger_controller")
def get_execution_counts(session: Session, trigger_ids: list[int]) -> dict[int, int]:
"""Get execution counts for multiple triggers in a single query."""
if not trigger_ids:
return {}
result = session.exec(
select(TriggerExecution.trigger_id, func.count(TriggerExecution.id))
.where(TriggerExecution.trigger_id.in_(trigger_ids))
.group_by(TriggerExecution.trigger_id)
).all()
return {trigger_id: count for trigger_id, count in result}
def trigger_to_out(trigger: Trigger, execution_count: int = 0) -> TriggerOut:
"""Convert Trigger model to TriggerOut with execution count."""
return TriggerOut(
id=trigger.id,
user_id=trigger.user_id,
project_id=trigger.project_id,
name=trigger.name,
description=trigger.description,
trigger_type=trigger.trigger_type,
status=trigger.status,
execution_count=execution_count,
webhook_url=trigger.webhook_url,
webhook_method=trigger.webhook_method,
custom_cron_expression=trigger.custom_cron_expression,
listener_type=trigger.listener_type,
agent_model=trigger.agent_model,
task_prompt=trigger.task_prompt,
config=trigger.config,
max_executions_per_hour=trigger.max_executions_per_hour,
max_executions_per_day=trigger.max_executions_per_day,
is_single_execution=trigger.is_single_execution,
last_executed_at=trigger.last_executed_at,
next_run_at=trigger.next_run_at,
last_execution_status=trigger.last_execution_status,
created_at=trigger.created_at,
updated_at=trigger.updated_at,
)
router = APIRouter(prefix="/trigger", tags=["Triggers"])
@router.post("/", name="create trigger", response_model=TriggerOut)
def create_trigger(
data: TriggerIn,
session: Session = Depends(session),
auth: Auth = Depends(auth_must)
):
"""Create a new trigger."""
user_id = auth.user.id
try:
# Check user trigger limit (max 25 triggers per user)
user_trigger_count = session.exec(
select(func.count(Trigger.id)).where(Trigger.user_id == str(user_id))
).one()
if user_trigger_count >= 25:
logger.warning("User trigger limit reached", extra={
"user_id": user_id,
"current_count": user_trigger_count,
"limit": 25
})
raise HTTPException(
status_code=400,
detail="Maximum number of triggers (25) reached for this user"
)
# Check project trigger limit (max 5 triggers per project)
if data.project_id:
project_trigger_count = session.exec(
select(func.count(Trigger.id)).where(
and_(
Trigger.user_id == str(user_id),
Trigger.project_id == data.project_id
)
)
).one()
if project_trigger_count >= 5:
logger.warning("Project trigger limit reached", extra={
"user_id": user_id,
"project_id": data.project_id,
"current_count": project_trigger_count,
"limit": 5
})
raise HTTPException(
status_code=400,
detail="Maximum number of triggers (5) reached for this project"
)
# Check if project_id exists in chat_history, if not create one
if data.project_id:
existing_chat = session.exec(
select(ChatHistory).where(ChatHistory.project_id == data.project_id)
).first()
if not existing_chat:
# Create a new chat_history for this project
chat_history = ChatHistory(
user_id=user_id,
task_id=data.project_id, # Using project_id as task_id
project_id=data.project_id,
question=f"Project created via trigger: {data.name}",
language="en",
model_platform=data.agent_model or "none",
model_type=data.agent_model or "none",
installed_mcp="none", #Expects String
api_key="",
api_url="",
max_retries=3,
project_name=data.name,
summary=data.description or "",
tokens=0,
spend=0,
status=2 # completed status
)
session.add(chat_history)
session.commit()
session.refresh(chat_history)
logger.info("Chat history created for new project", extra={
"user_id": user_id,
"project_id": data.project_id,
"chat_history_id": chat_history.id
})
# Send WebSocket notification about new project
try:
redis_manager = get_redis_manager()
redis_manager.publish_execution_event({
"type": "project_created",
"project_id": data.project_id,
"project_name": data.name,
"chat_history_id": chat_history.id,
"trigger_name": data.name,
"user_id": str(user_id),
"created_at": chat_history.created_at.isoformat() if chat_history.created_at else None
})
logger.debug("WebSocket notification sent for new project", extra={
"user_id": user_id,
"project_id": data.project_id
})
except Exception as e:
logger.warning("Failed to send WebSocket notification for new project", extra={
"user_id": user_id,
"project_id": data.project_id,
"error": str(e)
})
# Generate webhook URL for webhook-based triggers
webhook_url = None
if data.trigger_type in (TriggerType.webhook, TriggerType.slack_trigger):
webhook_url = f"/webhook/trigger/{uuid4()}"
# Validate trigger-type specific config
if data.config and has_config(data.trigger_type):
try:
validate_config(data.trigger_type, data.config)
except ValidationError as e:
logger.warning("Invalid trigger config", extra={
"user_id": user_id,
"trigger_type": data.trigger_type.value,
"errors": e.errors()
})
raise HTTPException(
status_code=400,
detail=f"Invalid config for {data.trigger_type.value}: {e.errors()}"
)
# Create trigger instance
trigger_data = data.model_dump()
trigger_data["user_id"] = str(user_id)
trigger_data["webhook_url"] = webhook_url
# Check if authentication is required - set initial status accordingly
if has_config(data.trigger_type) and data.config and requires_authentication(data.trigger_type, data.config):
trigger_data["status"] = TriggerStatus.pending_verification
else:
trigger_data["status"] = TriggerStatus.active
trigger = Trigger(**trigger_data)
session.add(trigger)
session.commit()
session.refresh(trigger)
# Calculate next_run_at for scheduled triggers
if trigger.trigger_type == TriggerType.schedule and trigger.custom_cron_expression:
schedule_service = TriggerScheduleService(session)
trigger.next_run_at = schedule_service.calculate_next_run_at(trigger)
session.add(trigger)
session.commit()
session.refresh(trigger)
logger.info("Trigger created", extra={
"user_id": user_id,
"trigger_id": trigger.id,
"trigger_type": data.trigger_type.value,
"next_run_at": trigger.next_run_at.isoformat() if trigger.next_run_at else None
})
return trigger_to_out(trigger, 0) # New trigger has 0 executions
except Exception as e:
session.rollback()
logger.error("Trigger creation failed", extra={
"user_id": user_id,
"error": str(e)
}, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/", name="list triggers")
def list_triggers(
trigger_type: Optional[TriggerType] = Query(None, description="Filter by trigger type"),
status: Optional[TriggerStatus] = Query(None, description="Filter by status"),
project_id: Optional[str] = Query(None, description="Filter by project ID"),
session: Session = Depends(session),
auth: Auth = Depends(auth_must)
) -> Page[TriggerOut]:
"""List triggers for current user."""
user_id = auth.user.id
# Build query with filters
conditions = [Trigger.user_id == str(user_id)]
if trigger_type:
conditions.append(Trigger.trigger_type == trigger_type)
if status is not None:
conditions.append(Trigger.status == status)
if project_id:
conditions.append(Trigger.project_id == project_id)
stmt = (
select(Trigger)
.where(and_(*conditions))
.order_by(desc(Trigger.created_at))
)
result = paginate(session, stmt)
total = result.total if hasattr(result, 'total') else 0
# Get execution counts for all triggers in the result
trigger_ids = [t.id for t in result.items]
counts = get_execution_counts(session, trigger_ids)
# Convert triggers to TriggerOut with execution counts
result.items = [trigger_to_out(t, counts.get(t.id, 0)) for t in result.items]
logger.debug("Triggers listed", extra={
"user_id": user_id,
"total": total,
"filters": {
"trigger_type": trigger_type.value if trigger_type else None,
"status": status.value if status is not None else None,
"project_id": project_id
}
})
return result
@router.get("/{trigger_id}", name="get trigger", response_model=TriggerOut)
def get_trigger(
trigger_id: int,
session: Session = Depends(session),
auth: Auth = Depends(auth_must)
):
"""Get a specific trigger by ID."""
user_id = auth.user.id
trigger = session.exec(
select(Trigger).where(
and_(Trigger.id == trigger_id, Trigger.user_id == str(user_id))
)
).first()
if not trigger:
logger.warning("Trigger not found", extra={
"user_id": user_id,
"trigger_id": trigger_id
})
raise HTTPException(status_code=404, detail="Trigger not found")
# Get execution count
counts = get_execution_counts(session, [trigger_id])
execution_count = counts.get(trigger_id, 0)
logger.debug("Trigger retrieved", extra={
"user_id": user_id,
"trigger_id": trigger_id
})
return trigger_to_out(trigger, execution_count)
@router.put("/{trigger_id}", name="update trigger", response_model=TriggerOut)
def update_trigger(
trigger_id: int,
data: TriggerUpdate,
session: Session = Depends(session),
auth: Auth = Depends(auth_must)
):
"""Update a trigger."""
user_id = auth.user.id
trigger = session.exec(
select(Trigger).where(
and_(Trigger.id == trigger_id, Trigger.user_id == str(user_id))
)
).first()
if not trigger:
logger.warning("Trigger not found for update", extra={
"user_id": user_id,
"trigger_id": trigger_id
})
raise HTTPException(status_code=404, detail="Trigger not found")
try:
update_data = data.model_dump(exclude_unset=True)
# Validate config if being updated
if "config" in update_data and update_data["config"] is not None:
if has_config(trigger.trigger_type):
try:
validate_config(trigger.trigger_type, update_data["config"])
except ValidationError as e:
logger.warning("Invalid trigger config on update", extra={
"user_id": user_id,
"trigger_id": trigger_id,
"trigger_type": trigger.trigger_type.value,
"errors": e.errors()
})
raise HTTPException(
status_code=400,
detail=f"Invalid config for {trigger.trigger_type.value}: {e.errors()}"
)
for key, value in update_data.items():
setattr(trigger, key, value)
# Recalculate next_run_at if cron expression or status changed for scheduled triggers
if trigger.trigger_type == TriggerType.schedule:
if "custom_cron_expression" in update_data or "status" in update_data:
if trigger.status == TriggerStatus.active and trigger.custom_cron_expression:
schedule_service = TriggerScheduleService(session)
trigger.next_run_at = schedule_service.calculate_next_run_at(trigger)
session.add(trigger)
session.commit()
session.refresh(trigger)
# Get execution count
counts = get_execution_counts(session, [trigger_id])
execution_count = counts.get(trigger_id, 0)
logger.info("Trigger updated", extra={
"user_id": user_id,
"trigger_id": trigger_id,
"fields_updated": list(update_data.keys()),
"next_run_at": trigger.next_run_at.isoformat() if trigger.next_run_at else None
})
return trigger_to_out(trigger, execution_count)
except Exception as e:
session.rollback()
logger.error("Trigger update failed", extra={
"user_id": user_id,
"trigger_id": trigger_id,
"error": str(e)
}, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/{trigger_id}", name="delete trigger")
def delete_trigger(
trigger_id: int,
session: Session = Depends(session),
auth: Auth = Depends(auth_must)
):
"""Delete a trigger."""
user_id = auth.user.id
trigger = session.exec(
select(Trigger).where(
and_(Trigger.id == trigger_id, Trigger.user_id == str(user_id))
)
).first()
if not trigger:
logger.warning("Trigger not found for deletion", extra={
"user_id": user_id,
"trigger_id": trigger_id
})
raise HTTPException(status_code=404, detail="Trigger not found")
try:
# Delete execution logs first (bulk delete)
session.exec(
delete(TriggerExecution).where(
TriggerExecution.trigger_id == trigger_id
)
)
# Then delete the trigger
session.delete(trigger)
session.commit()
logger.info("Trigger deleted", extra={
"user_id": user_id,
"trigger_id": trigger_id
})
return Response(status_code=204)
except Exception as e:
session.rollback()
logger.error("Trigger deletion failed", extra={
"user_id": user_id,
"trigger_id": trigger_id,
"error": str(e)
}, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/{trigger_id}/activate", name="activate trigger", response_model=TriggerOut)
def activate_trigger(
trigger_id: int,
session: Session = Depends(session),
auth: Auth = Depends(auth_must)
):
"""Activate a trigger."""
user_id = auth.user.id
trigger = session.exec(
select(Trigger).where(
and_(Trigger.id == trigger_id, Trigger.user_id == str(user_id))
)
).first()
if not trigger:
logger.warning("Trigger not found for activation", extra={
"user_id": user_id,
"trigger_id": trigger_id
})
raise HTTPException(status_code=404, detail="Trigger not found")
try:
# Check activation requirements for trigger types with configs
if has_config(trigger.trigger_type):
try:
validate_activation(
trigger_type=trigger.trigger_type,
config_data=trigger.config,
user_id=int(user_id),
session=session
)
except ActivationError as e:
logger.warning("Trigger activation requirements not met", extra={
"user_id": user_id,
"trigger_id": trigger_id,
"trigger_type": trigger.trigger_type.value,
"missing_requirements": e.missing_requirements
})
raise HTTPException(
status_code=400,
detail={
"message": e.message,
"missing_requirements": e.missing_requirements,
"trigger_type": trigger.trigger_type.value
}
)
# Check if authentication is required - set to pending_verification if so
if has_config(trigger.trigger_type) and requires_authentication(trigger.trigger_type, trigger.config):
trigger.status = TriggerStatus.pending_verification
logger.info("Trigger set to pending verification (authentication required)", extra={
"user_id": user_id,
"trigger_id": trigger_id,
"trigger_type": trigger.trigger_type.value
})
# Save the status change before raising the exception
session.add(trigger)
session.commit()
session.refresh(trigger)
raise HTTPException(
status_code=401,
detail={
"message": "Authentication required for this trigger type",
"missing_requirements": ["authentication"],
"trigger_type": trigger.trigger_type.value
}
)
else:
trigger.status = TriggerStatus.active
session.add(trigger)
session.commit()
session.refresh(trigger)
# Get execution count
counts = get_execution_counts(session, [trigger_id])
execution_count = counts.get(trigger_id, 0)
logger.info("Trigger status updated", extra={
"user_id": user_id,
"trigger_id": trigger_id,
"status": trigger.status.value
})
return trigger_to_out(trigger, execution_count)
except HTTPException:
raise
except Exception as e:
session.rollback()
logger.error("Trigger activation failed", extra={
"user_id": user_id,
"trigger_id": trigger_id,
"error": str(e)
}, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/{trigger_id}/deactivate", name="deactivate trigger", response_model=TriggerOut)
def deactivate_trigger(
trigger_id: int,
session: Session = Depends(session),
auth: Auth = Depends(auth_must)
):
"""Deactivate a trigger."""
user_id = auth.user.id
trigger = session.exec(
select(Trigger).where(
and_(Trigger.id == trigger_id, Trigger.user_id == str(user_id))
)
).first()
if not trigger:
logger.warning("Trigger not found for deactivation", extra={
"user_id": user_id,
"trigger_id": trigger_id
})
raise HTTPException(status_code=404, detail="Trigger not found")
try:
trigger.status = TriggerStatus.inactive
session.add(trigger)
session.commit()
session.refresh(trigger)
# Get execution count
counts = get_execution_counts(session, [trigger_id])
execution_count = counts.get(trigger_id, 0)
logger.info("Trigger deactivated", extra={
"user_id": user_id,
"trigger_id": trigger_id
})
return trigger_to_out(trigger, execution_count)
except Exception as e:
session.rollback()
logger.error("Trigger deactivation failed", extra={
"user_id": user_id,
"trigger_id": trigger_id,
"error": str(e)
}, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/{trigger_id}/executions", name="list trigger executions")
def list_trigger_executions(
trigger_id: int,
session: Session = Depends(session),
auth: Auth = Depends(auth_must)
) -> Page[TriggerExecutionOut]:
"""List executions for a specific trigger."""
user_id = auth.user.id
# First verify the trigger belongs to the user
trigger = session.exec(
select(Trigger).where(
and_(Trigger.id == trigger_id, Trigger.user_id == str(user_id))
)
).first()
if not trigger:
logger.warning("Trigger not found for executions list", extra={
"user_id": user_id,
"trigger_id": trigger_id
})
raise HTTPException(status_code=404, detail="Trigger not found")
# Get executions for this trigger
stmt = (
select(TriggerExecution)
.where(TriggerExecution.trigger_id == trigger_id)
.order_by(desc(TriggerExecution.created_at))
)
result = paginate(session, stmt)
total = result.total if hasattr(result, 'total') else 0
logger.debug("Trigger executions listed", extra={
"user_id": user_id,
"trigger_id": trigger_id,
"total": total
})
return result
# ============================================================================
# Trigger Config Endpoints
# ============================================================================
@router.get("/{trigger_type}/config", name="get trigger type config schema")
def get_trigger_type_config(
trigger_type: TriggerType,
auth: Auth = Depends(auth_must)
) -> TriggerConfigSchemaOut:
"""
Get the configuration schema for a specific trigger type.
This endpoint returns the JSON schema for the trigger type's config field,
which can be used by the frontend to dynamically render configuration forms.
"""
schema = get_config_schema(trigger_type)
return TriggerConfigSchemaOut(
trigger_type=trigger_type.value,
has_config=has_config(trigger_type),
schema_=schema
)

View file

@ -0,0 +1,783 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
from fastapi import APIRouter, Depends, HTTPException, Response, WebSocket, WebSocketDisconnect
from fastapi_pagination import Page
from fastapi_pagination.ext.sqlmodel import paginate
from sqlmodel import Session, select, desc, and_
from typing import Optional, Dict, Any
from datetime import datetime, timezone
from uuid import uuid4
import logging
import asyncio
from app.model.trigger.trigger_execution import (
TriggerExecution,
TriggerExecutionIn,
TriggerExecutionOut,
TriggerExecutionUpdate
)
from app.model.trigger.trigger import Trigger
from app.model.user.user import User
from app.type.trigger_types import ExecutionStatus, ExecutionType
from app.component.auth import Auth, auth_must
from app.component.database import session
from app.component.redis_utils import get_redis_manager
from app.service.trigger.trigger_service import TriggerService
logger = logging.getLogger("server_trigger_execution_controller")
# Store active WebSocket connections per session (WebSocket objects only, metadata in Redis)
# Format: {session_id: WebSocket}
# This is per-worker, and Redis pub/sub is used to broadcast across workers
active_websockets: Dict[str, WebSocket] = {}
# Background task for Redis pub/sub
_pubsub_task = None
router = APIRouter(prefix="/execution", tags=["Trigger Executions"])
@router.post("/", name="create trigger execution", response_model=TriggerExecutionOut)
async def create_trigger_execution(
data: TriggerExecutionIn,
session: Session = Depends(session),
auth: Auth = Depends(auth_must)
):
"""Create a new trigger execution."""
user_id = auth.user.id
# Verify the trigger exists and belongs to the user
trigger = session.exec(
select(Trigger).where(
and_(Trigger.id == data.trigger_id, Trigger.user_id == str(user_id))
)
).first()
if not trigger:
logger.warning("Trigger not found for execution creation", extra={
"user_id": user_id,
"trigger_id": data.trigger_id
})
raise HTTPException(status_code=404, detail="Trigger not found")
try:
execution_data = data.model_dump()
execution = TriggerExecution(**execution_data)
session.add(execution)
session.commit()
session.refresh(execution)
# Update trigger last executed timestamp
trigger.last_executed_at = datetime.now(timezone.utc)
session.add(trigger)
session.commit()
logger.info("Trigger execution created", extra={
"user_id": user_id,
"trigger_id": data.trigger_id,
"execution_id": execution.execution_id,
"execution_type": data.execution_type.value
})
# Publish to Redis pub/sub (broadcasts to all workers)
redis_manager = get_redis_manager()
redis_manager.publish_execution_event({
"type": "execution_created",
"execution_id": execution.execution_id,
"trigger_id": trigger.id,
"trigger_type": trigger.trigger_type.value if trigger.trigger_type else "unknown",
"task_prompt": trigger.task_prompt,
"status": execution.status.value,
"input_data": execution.input_data,
"execution_type": data.execution_type.value,
"user_id": str(user_id),
"timestamp": datetime.now(timezone.utc).isoformat(),
"project_id": str(trigger.project_id)
})
return execution
except Exception as e:
session.rollback()
logger.error("Trigger execution creation failed", extra={
"user_id": user_id,
"trigger_id": data.trigger_id,
"error": str(e)
}, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/", name="list executions")
def list_executions(
trigger_id: Optional[int] = None,
status: Optional[ExecutionStatus] = None,
execution_type: Optional[ExecutionType] = None,
session: Session = Depends(session),
auth: Auth = Depends(auth_must)
) -> Page[TriggerExecutionOut]:
"""List trigger executions for current user."""
user_id = auth.user.id
# Get all trigger IDs that belong to the user
user_trigger_ids = session.exec(
select(Trigger.id).where(Trigger.user_id == str(user_id))
).all()
if not user_trigger_ids:
# User has no triggers, return empty result
return Page(items=[], total=0, page=1, size=50, pages=0)
# Build conditions
conditions = [TriggerExecution.trigger_id.in_(user_trigger_ids)]
if trigger_id:
if trigger_id not in user_trigger_ids:
raise HTTPException(status_code=404, detail="Trigger not found")
conditions.append(TriggerExecution.trigger_id == trigger_id)
if status is not None:
conditions.append(TriggerExecution.status == status)
if execution_type:
conditions.append(TriggerExecution.execution_type == execution_type)
stmt = (
select(TriggerExecution)
.where(and_(*conditions))
.order_by(desc(TriggerExecution.created_at))
)
result = paginate(session, stmt)
total = result.total if hasattr(result, 'total') else 0
logger.debug("Executions listed", extra={
"user_id": user_id,
"total": total,
"filters": {
"trigger_id": trigger_id,
"status": status.value if status is not None else None,
"execution_type": execution_type.value if execution_type else None
}
})
return result
@router.get("/{execution_id}", name="get execution", response_model=TriggerExecutionOut)
def get_execution(
execution_id: str,
session: Session = Depends(session),
auth: Auth = Depends(auth_must)
):
"""Get a specific execution by execution ID."""
user_id = auth.user.id
# Get the execution and verify ownership through trigger
execution = session.exec(
select(TriggerExecution)
.join(Trigger)
.where(
and_(
TriggerExecution.execution_id == execution_id,
Trigger.user_id == str(user_id)
)
)
).first()
if not execution:
logger.warning("Execution not found", extra={
"user_id": user_id,
"execution_id": execution_id
})
raise HTTPException(status_code=404, detail="Execution not found")
logger.debug("Execution retrieved", extra={
"user_id": user_id,
"execution_id": execution_id
})
return execution
@router.put("/{execution_id}", name="update execution", response_model=TriggerExecutionOut)
async def update_execution(
execution_id: str,
data: TriggerExecutionUpdate,
session: Session = Depends(session),
auth: Auth = Depends(auth_must)
):
"""Update a trigger execution."""
user_id = auth.user.id
# Get the execution and verify ownership through trigger
execution = session.exec(
select(TriggerExecution)
.join(Trigger)
.where(
and_(
TriggerExecution.execution_id == execution_id,
Trigger.user_id == str(user_id)
)
)
).first()
if not execution:
logger.warning("Execution not found for update", extra={
"user_id": user_id,
"execution_id": execution_id
})
raise HTTPException(status_code=404, detail="Execution not found")
try:
update_data = data.model_dump(exclude_unset=True)
# Check if status is being updated - use TriggerService for proper failure tracking
if "status" in update_data:
trigger_service = TriggerService(session)
# Convert status string back to enum for TriggerService
status_value = ExecutionStatus(update_data["status"]) if isinstance(update_data["status"], str) else update_data["status"]
trigger_service.update_execution_status(
execution=execution,
status=status_value,
output_data=update_data.get("output_data"),
error_message=update_data.get("error_message"),
tokens_used=update_data.get("tokens_used"),
tools_executed=update_data.get("tools_executed")
)
# Remove status-related fields from update_data since TriggerService handled them
for key in ["status", "output_data", "error_message", "tokens_used", "tools_executed"]:
update_data.pop(key, None)
# Update remaining fields
if update_data:
# Auto-calculate duration if both started_at and completed_at are set
if ("started_at" in update_data or "completed_at" in update_data) and execution.started_at:
completed_at = update_data.get("completed_at") or execution.completed_at
if completed_at:
# Ensure both datetimes are timezone-aware for subtraction
started_at = execution.started_at
if started_at.tzinfo is None:
started_at = started_at.replace(tzinfo=timezone.utc)
if completed_at.tzinfo is None:
completed_at = completed_at.replace(tzinfo=timezone.utc)
duration = (completed_at - started_at).total_seconds()
update_data["duration_seconds"] = duration
for key, value in update_data.items():
setattr(execution, key, value)
session.add(execution)
session.commit()
session.refresh(execution)
# Get trigger for event publishing
trigger = session.get(Trigger, execution.trigger_id)
logger.info("Execution updated", extra={
"user_id": user_id,
"execution_id": execution_id,
"fields_updated": list(data.model_dump(exclude_unset=True).keys())
})
# Publish to Redis pub/sub (broadcasts to all workers)
redis_manager = get_redis_manager()
redis_manager.publish_execution_event({
"type": "execution_updated",
"execution_id": execution_id,
"trigger_id": execution.trigger_id,
"status": execution.status.value,
"updated_fields": list(update_data.keys()),
"user_id": str(user_id),
"timestamp": datetime.now(timezone.utc).isoformat(),
"project_id": str(trigger.project_id) if trigger else None
})
return execution
except Exception as e:
session.rollback()
logger.error("Execution update failed", extra={
"user_id": user_id,
"execution_id": execution_id,
"error": str(e)
}, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/{execution_id}", name="delete execution")
def delete_execution(
execution_id: str,
session: Session = Depends(session),
auth: Auth = Depends(auth_must)
):
"""Delete a trigger execution."""
user_id = auth.user.id
# Get the execution and verify ownership through trigger
execution = session.exec(
select(TriggerExecution)
.join(Trigger)
.where(
and_(
TriggerExecution.execution_id == execution_id,
Trigger.user_id == str(user_id)
)
)
).first()
if not execution:
logger.warning("Execution not found for deletion", extra={
"user_id": user_id,
"execution_id": execution_id
})
raise HTTPException(status_code=404, detail="Execution not found")
try:
session.delete(execution)
session.commit()
logger.info("Execution deleted", extra={
"user_id": user_id,
"execution_id": execution_id
})
return Response(status_code=204)
except Exception as e:
session.rollback()
logger.error("Execution deletion failed", extra={
"user_id": user_id,
"execution_id": execution_id,
"error": str(e)
}, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/{execution_id}/retry", name="retry execution", response_model=TriggerExecutionOut)
def retry_execution(
execution_id: str,
session: Session = Depends(session),
auth: Auth = Depends(auth_must)
):
"""Retry a failed execution."""
user_id = auth.user.id
# Get the execution and verify ownership through trigger
execution = session.exec(
select(TriggerExecution)
.join(Trigger)
.where(
and_(
TriggerExecution.execution_id == execution_id,
Trigger.user_id == str(user_id)
)
)
).first()
if not execution:
logger.warning("Execution not found for retry", extra={
"user_id": user_id,
"execution_id": execution_id
})
raise HTTPException(status_code=404, detail="Execution not found")
if execution.status != ExecutionStatus.failed:
raise HTTPException(status_code=400, detail="Only failed executions can be retried")
if execution.attempts >= execution.max_retries:
raise HTTPException(status_code=400, detail="Maximum retry attempts exceeded")
try:
# Create a new execution for the retry
new_execution_id = str(uuid4())
new_execution = TriggerExecution(
trigger_id=execution.trigger_id,
execution_id=new_execution_id,
execution_type=execution.execution_type,
input_data=execution.input_data,
attempts=execution.attempts + 1,
max_retries=execution.max_retries
)
session.add(new_execution)
session.commit()
session.refresh(new_execution)
# Get trigger for event publishing
trigger = session.get(Trigger, execution.trigger_id)
logger.info("Execution retry created", extra={
"user_id": user_id,
"original_execution_id": execution_id,
"new_execution_id": new_execution_id,
"attempts": new_execution.attempts
})
# Publish to Redis pub/sub (broadcasts to all workers)
redis_manager = get_redis_manager()
redis_manager.publish_execution_event({
"type": "execution_created",
"execution_id": new_execution.execution_id,
"trigger_id": trigger.id if trigger else execution.trigger_id,
"trigger_type": trigger.trigger_type.value if trigger and trigger.trigger_type else "unknown",
"task_prompt": trigger.task_prompt if trigger else None,
"status": new_execution.status.value,
"input_data": new_execution.input_data,
"execution_type": new_execution.execution_type.value,
"user_id": str(user_id),
"timestamp": datetime.now(timezone.utc).isoformat(),
"project_id": str(trigger.project_id) if trigger else None
})
return new_execution
except Exception as e:
session.rollback()
logger.error("Execution retry failed", extra={
"user_id": user_id,
"execution_id": execution_id,
"error": str(e)
}, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.websocket("/subscribe")
async def subscribe_executions(websocket: WebSocket):
"""Subscribe to trigger execution events via WebSocket.
Client sends: {"type": "subscribe", "session_id": "unique-session-id", "auth_token": "bearer-token"}
Client acknowledges execution: {"type": "ack", "execution_id": "exec-id"}
Server sends: {"type": "execution_created", "execution_id": "...", ...}
Server sends: {"type": "heartbeat", "timestamp": "..."}
"""
# Ensure pub/sub listener is started in THIS worker process
await start_pubsub_listener()
await websocket.accept()
session_id = None
user_id = None
db_session = None
try:
# Create database session manually for WebSocket
from app.component.database import session_make
db_session = session_make()
# Wait for subscription message
data = await websocket.receive_json()
if data.get("type") != "subscribe" or not data.get("session_id"):
await websocket.send_json({
"type": "error",
"message": "Invalid subscription. Send {type: 'subscribe', session_id: 'your-session-id', auth_token: 'bearer-token'}"
})
await websocket.close()
return
session_id = data["session_id"]
auth_token = data.get("auth_token")
# Authenticate user
if not auth_token:
await websocket.send_json({
"type": "error",
"message": "Authentication required. Provide 'auth_token' in subscription message"
})
await websocket.close()
return
try:
from app.component.auth import Auth
# Decode token and fetch user
auth = Auth.decode_token(auth_token)
user = db_session.get(User, auth.id)
if not user:
raise Exception("User not found")
auth._user = user
user_id = auth.user.id
logger.info(f"User authenticated for WebSocket {user_id} and {session_id}", extra={
"user_id": user_id,
"session_id": session_id
})
except Exception as e:
await websocket.send_json({
"type": "error",
"message": "Authentication failed"
})
await websocket.close()
logger.warning("WebSocket authentication failed", extra={
"session_id": session_id,
"error": str(e)
})
return
# Register session in Redis and store WebSocket reference
redis_manager = get_redis_manager()
redis_manager.store_session(session_id, str(user_id))
active_websockets[session_id] = websocket
logger.info(f"WebSocket session registered", extra={
"session_id": session_id,
"user_id": user_id,
"total_active": len(active_websockets)
})
await websocket.send_json({
"type": "connected",
"session_id": session_id,
"timestamp": datetime.now(timezone.utc).isoformat()
})
logger.info("Client subscribed to executions", extra={
"session_id": session_id,
"user_id": user_id,
"total_sessions": len(active_websockets),
"all_session_ids": list(active_websockets.keys())
})
# Handle incoming messages (acknowledgments)
async def handle_messages():
while True:
try:
msg = await websocket.receive_json()
if msg.get("type") == "ack" and msg.get("execution_id"):
execution_id = msg["execution_id"]
# Remove from pending in Redis
redis_manager.remove_pending_execution(session_id, execution_id)
# Update execution status to running
execution = db_session.exec(
select(TriggerExecution).where(
TriggerExecution.execution_id == execution_id
)
).first()
if execution and execution.status == ExecutionStatus.pending:
execution.status = ExecutionStatus.running
execution.started_at = datetime.now(timezone.utc)
db_session.add(execution)
db_session.commit()
logger.info("Execution acknowledged and started", extra={
"session_id": session_id,
"execution_id": execution_id
})
await websocket.send_json({
"type": "ack_confirmed",
"execution_id": execution_id,
"status": "running"
})
elif msg.get("type") == "ping":
# Publish pong through Redis pub/sub
redis_manager.publish_execution_event({
"type": "pong",
"session_id": session_id,
"user_id": str(user_id),
"timestamp": datetime.now(timezone.utc).isoformat()
})
except WebSocketDisconnect:
break
# Start heartbeat task
async def send_heartbeat():
while True:
await asyncio.sleep(30)
try:
await websocket.send_json({
"type": "heartbeat",
"timestamp": datetime.now(timezone.utc).isoformat()
})
except:
break
# Run both tasks concurrently
await asyncio.gather(
handle_messages(),
send_heartbeat(),
return_exceptions=True
)
except WebSocketDisconnect as e:
logger.info("Client disconnected", extra={
"session_id": session_id,
"disconnect_code": getattr(e, 'code', None),
"reason": "websocket_disconnect"
})
except Exception as e:
logger.error("WebSocket error", extra={"session_id": session_id, "error": str(e)}, exc_info=True)
finally:
# Mark pending executions as missed
if session_id:
redis_manager = get_redis_manager()
# Clean up session from Redis and local WebSocket dict
redis_manager.remove_session(session_id)
if session_id in active_websockets:
del active_websockets[session_id]
logger.info("Session cleaned up", extra={"session_id": session_id})
# Close database session
if db_session:
db_session.close()
async def handle_pubsub_message(event_data: Dict[str, Any]):
"""Handle execution events from Redis pub/sub.
This function is called by each worker when a message is published.
Each worker will send the message to its own local WebSocket connections.
"""
try:
event_type = event_data.get("type")
logger.info(f"[PUBSUB] Received event from Redis: {event_type}", extra={
"event_type": event_type,
"execution_id": event_data.get("execution_id"),
"user_id": event_data.get("user_id")
})
# Handle pong events - send only to the specific session
if event_type == "pong":
target_session_id = event_data.get("session_id")
if target_session_id and target_session_id in active_websockets:
try:
ws = active_websockets[target_session_id]
await ws.send_json({
"type": "pong",
"timestamp": event_data.get("timestamp")
})
logger.debug("Pong sent via Redis pub/sub", extra={
"session_id": target_session_id
})
except Exception as e:
logger.error("Failed to send pong", extra={
"session_id": target_session_id,
"error": str(e)
})
return
execution_id = event_data.get("execution_id")
event_user_id = event_data.get("user_id")
if not event_user_id:
logger.warning("Event missing user_id, cannot filter subscribers", extra={
"execution_id": execution_id
})
return
# Get user sessions from Redis
redis_manager = get_redis_manager()
user_session_ids = redis_manager.get_user_sessions(event_user_id)
# Get user sessions from Redis and match with local connections
logger.debug(f"User has {len(user_session_ids)} active session(s)", extra={
"user_id": event_user_id,
"session_count": len(user_session_ids)
})
# Only notify sessions that are connected to THIS worker
local_sessions = set(active_websockets.keys()) & user_session_ids
if not local_sessions:
logger.debug("No local WebSocket connections for this user", extra={
"user_id": event_user_id,
"execution_id": execution_id
})
return # No local connections for this user
logger.info(f"Broadcasting execution to {len(local_sessions)} WebSocket(s)", extra={
"execution_id": execution_id,
"user_id": event_user_id,
"session_count": len(local_sessions)
})
disconnected_sessions = []
notified_count = 0
for session_id in local_sessions:
try:
ws = active_websockets.get(session_id)
if not ws:
disconnected_sessions.append(session_id)
continue
# Send execution event
await ws.send_json(event_data)
notified_count += 1
# Track as pending if it's a new execution
if event_data.get("type") == "execution_created" and execution_id:
redis_manager.add_pending_execution(session_id, execution_id)
# Confirm delivery for webhook to proceed
redis_manager.confirm_delivery(execution_id, session_id)
logger.debug("Notified session of execution", extra={
"session_id": session_id,
"user_id": event_user_id,
"execution_id": execution_id
})
except Exception as e:
logger.error("Failed to notify session", extra={
"session_id": session_id,
"error": str(e)
})
disconnected_sessions.append(session_id)
# Clean up disconnected sessions
for session_id in disconnected_sessions:
redis_manager.remove_session(session_id)
if session_id in active_websockets:
del active_websockets[session_id]
if notified_count > 0:
logger.debug("Execution event broadcast complete", extra={
"execution_id": execution_id,
"user_id": event_user_id,
"sessions_notified": notified_count
})
except Exception as e:
logger.error(f"Error handling pub/sub message: {str(e)}", extra={
"error": str(e),
"error_type": type(e).__name__,
"event_data": event_data
}, exc_info=True)
async def start_pubsub_listener():
"""Start the Redis pub/sub listener for this worker."""
global _pubsub_task
if _pubsub_task is not None:
return # Already started
import os
logger.info(f"[PID {os.getpid()}] Starting Redis pub/sub listener for execution events")
redis_manager = get_redis_manager()
async def run_subscriber():
try:
await redis_manager.subscribe_to_execution_events(handle_pubsub_message)
except Exception as e:
logger.error("Pub/sub listener crashed", extra={"error": str(e)}, exc_info=True)
_pubsub_task = asyncio.create_task(run_subscriber())

View file

@ -0,0 +1,348 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
"""
Webhook Controller
Handles incoming webhook triggers with modular app-specific processing.
"""
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlmodel import Session, select, and_, or_
from uuid import uuid4
from datetime import datetime, timezone
import json
import logging
from fastapi_limiter.depends import RateLimiter
from app.model.trigger.trigger import Trigger
from app.model.trigger.trigger_execution import TriggerExecution
from app.type.trigger_types import TriggerType, TriggerStatus, ExecutionType, ExecutionStatus
from app.component.database import session
from app.component.trigger_utils import check_rate_limits
from app.service.trigger.app_handler_service import get_app_handler
logger = logging.getLogger("server_webhook_controller")
router = APIRouter(prefix="/webhook", tags=["Webhook"])
# Trigger types that use webhooks
WEBHOOK_TRIGGER_TYPES = [TriggerType.webhook, TriggerType.slack_trigger]
@router.api_route("/trigger/{webhook_uuid}", methods=["GET", "POST"], name="webhook trigger", dependencies=[Depends(RateLimiter(times=10, seconds=60))])
async def webhook_trigger(
webhook_uuid: str,
request: Request,
db_session: Session = Depends(session)
):
"""Handle incoming webhook triggers with app-specific processing."""
try:
# Get request body
body = await request.body()
try:
input_data = json.loads(body) if body else {}
except json.JSONDecodeError:
input_data = {"raw_body": body.decode()}
headers = dict(request.headers)
webhook_url = f"/webhook/trigger/{webhook_uuid}"
# Find the trigger (allow active and pending_verification for verification flows)
trigger = db_session.exec(
select(Trigger).where(
and_(
Trigger.webhook_url == webhook_url,
Trigger.trigger_type.in_(WEBHOOK_TRIGGER_TYPES),
Trigger.status.in_([TriggerStatus.active, TriggerStatus.pending_verification])
)
)
).first()
if not trigger:
logger.warning("Webhook trigger not found or inactive", extra={
"webhook_uuid": webhook_uuid
})
raise HTTPException(status_code=404, detail="Webhook not found or inactive")
# Get app handler based on trigger_type
handler = get_app_handler(trigger.trigger_type)
# App-specific authentication
if handler:
auth_result = await handler.authenticate(request, body, trigger, db_session)
if not auth_result.success:
raise HTTPException(status_code=401, detail=auth_result.reason or "Invalid signature")
# Return challenge response for URL verification (e.g., Slack)
# Don't update status yet - wait for actual events to confirm integration works
if auth_result.data:
logger.info("URL verification challenge received", extra={
"trigger_id": trigger.id,
"trigger_type": trigger.trigger_type.value,
"status": trigger.status.value
})
return auth_result.data
# Update trigger status from pending_verification to active after receiving
# a real event (not just URL verification) with valid signature
if trigger.status == TriggerStatus.pending_verification:
trigger.status = TriggerStatus.active
db_session.add(trigger)
db_session.commit()
db_session.refresh(trigger)
logger.info("Trigger status updated to active after receiving valid event", extra={
"trigger_id": trigger.id,
"trigger_type": trigger.trigger_type.value
})
# Notify Redis subscribers of successful activation
try:
from app.component.redis_utils import get_redis_manager
redis_manager = get_redis_manager()
redis_manager.publish_execution_event({
"type": "trigger_activated",
"trigger_id": trigger.id,
"trigger_type": trigger.trigger_type.value,
"task_prompt": trigger.task_prompt,
"user_id": str(trigger.user_id),
"project_id": str(trigger.project_id),
"webhook_uuid": webhook_uuid
})
except Exception as e:
logger.warning(f"Failed to publish activation event: {e}")
# Default webhook: validate request method
if trigger.trigger_type == TriggerType.webhook and trigger.webhook_method:
expected_method = trigger.webhook_method.value if hasattr(trigger.webhook_method, 'value') else str(trigger.webhook_method)
expected_method = expected_method.rstrip(',')
if request.method.upper() != expected_method.upper():
raise HTTPException(
status_code=405,
detail=f"Method not allowed. This webhook only accepts {expected_method} requests"
)
# Prepare request metadata for filtering and normalization
safe_headers = {k: v for k, v in headers.items() if k.lower() not in ['authorization', 'cookie']}
query_params = dict(request.query_params)
body_raw = body.decode() if body else ""
request_meta = {
"headers": safe_headers,
"query_params": query_params,
"method": request.method,
"url": str(request.url),
"client_ip": request.client.host if request.client else None
}
# App-specific event filtering (pass headers and body for webhook config filtering)
if handler:
# For default webhook handler, pass additional context
if trigger.trigger_type == TriggerType.webhook:
filter_result = await handler.filter_event(
input_data,
trigger,
headers=safe_headers,
body_raw=body_raw
)
else:
filter_result = await handler.filter_event(input_data, trigger)
if not filter_result.success:
logger.debug("Event filtered", extra={
"trigger_id": trigger.id,
"reason": filter_result.reason
})
return {"status": "ignored", "reason": filter_result.reason}
# Check rate limits
current_time = datetime.now(timezone.utc)
if trigger.max_executions_per_hour or trigger.max_executions_per_day:
if not check_rate_limits(db_session, trigger):
logger.warning("Webhook rate limit exceeded", extra={
"trigger_id": trigger.id
})
raise HTTPException(status_code=429, detail="Rate limit exceeded")
# Check single execution
if trigger.is_single_execution:
from sqlmodel import func
execution_count = db_session.exec(
select(func.count(TriggerExecution.id)).where(
TriggerExecution.trigger_id == trigger.id
)
).first()
if execution_count > 0:
raise HTTPException(status_code=409, detail="Single execution trigger already executed")
# Normalize input data (pass request_meta for full webhook input)
if handler:
execution_input = handler.normalize_payload(input_data, trigger, request_meta=request_meta)
else:
execution_input = {
"headers": safe_headers,
"query_params": query_params,
"body": input_data,
"method": request.method,
"url": str(request.url),
"client_ip": request.client.host if request.client else None
}
# Determine execution type
execution_type = handler.execution_type if handler else ExecutionType.webhook
# Create execution record
execution_id = str(uuid4())
execution = TriggerExecution(
trigger_id=trigger.id,
execution_id=execution_id,
execution_type=execution_type,
status=ExecutionStatus.pending,
input_data=execution_input,
started_at=current_time
)
db_session.add(execution)
# Update trigger
trigger.last_executed_at = current_time
trigger.last_execution_status = "pending"
db_session.add(trigger)
db_session.commit()
db_session.refresh(execution)
logger.info("Webhook trigger executed", extra={
"trigger_id": trigger.id,
"execution_id": execution_id,
"trigger_type": trigger.trigger_type.value,
"user_id": trigger.user_id
})
# Notify WebSocket subscribers and wait for delivery confirmation
try:
from app.component.redis_utils import get_redis_manager
redis_manager = get_redis_manager()
# Check if user has any active WebSocket sessions
has_active_sessions = redis_manager.has_active_sessions_for_user(str(trigger.user_id))
redis_manager.publish_execution_event({
"type": "execution_created",
"execution_id": execution_id,
"trigger_id": trigger.id,
"trigger_type": trigger.trigger_type.value,
"task_prompt": trigger.task_prompt,
"status": "pending",
"input_data": execution_input,
"user_id": str(trigger.user_id),
"project_id": str(trigger.project_id)
})
if has_active_sessions:
# Wait for delivery confirmation (10 second timeout)
delivery_confirmation = await redis_manager.wait_for_delivery(
execution_id,
timeout=10.0
)
if delivery_confirmation:
logger.info("Webhook delivery confirmed", extra={
"execution_id": execution_id,
"session_id": delivery_confirmation.get("session_id")
})
return {
"success": True,
"execution_id": execution_id,
"message": "Webhook trigger delivered to client",
"delivered": True,
"session_id": delivery_confirmation.get("session_id")
}
else:
logger.warning("Webhook delivery confirmation timed out", extra={
"execution_id": execution_id,
"trigger_id": trigger.id
})
return {
"success": True,
"execution_id": execution_id,
"message": "Webhook trigger processed but delivery not confirmed",
"delivered": False,
"reason": "timeout"
}
else:
# No active sessions, execution is queued
logger.info("No active WebSocket sessions for user", extra={
"execution_id": execution_id,
"user_id": trigger.user_id
})
return {
"success": True,
"execution_id": execution_id,
"message": "Webhook trigger processed, no active client connected",
"delivered": False,
"reason": "no_active_sessions"
}
except Exception as e:
logger.warning(f"Failed to publish/confirm WebSocket event: {e}")
return {
"success": True,
"execution_id": execution_id,
"message": "Webhook trigger processed but WebSocket notification failed",
"delivered": False,
"reason": str(e)
}
except HTTPException:
raise
except Exception as e:
logger.error("Webhook trigger processing failed", extra={
"webhook_uuid": webhook_uuid,
"error": str(e)
}, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/trigger/{webhook_uuid}/info", name="webhook info")
def get_webhook_info(
webhook_uuid: str,
db_session: Session = Depends(session)
):
"""Get information about a webhook trigger (public endpoint)."""
webhook_url = f"/webhook/trigger/{webhook_uuid}"
trigger = db_session.exec(
select(Trigger).where(
and_(
Trigger.webhook_url == webhook_url,
Trigger.trigger_type.in_(WEBHOOK_TRIGGER_TYPES)
)
)
).first()
if not trigger:
raise HTTPException(status_code=404, detail="Webhook not found")
return {
"name": trigger.name,
"description": trigger.description,
"status": trigger.status.value,
"trigger_type": trigger.trigger_type.value,
"is_active": trigger.status == TriggerStatus.active,
"webhook_method": trigger.webhook_method.value if trigger.webhook_method else None,
"last_executed_at": trigger.last_executed_at.isoformat() if trigger.last_executed_at else None,
}

View file

@ -0,0 +1,28 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
"""Trigger models package."""
from app.model.trigger.trigger import Trigger, TriggerIn, TriggerUpdate, TriggerOut, TriggerConfigSchemaOut
from app.model.trigger.trigger_execution import TriggerExecution, TriggerExecutionIn, TriggerExecutionUpdate
__all__ = [
"Trigger",
"TriggerIn",
"TriggerUpdate",
"TriggerOut",
"TriggerExecution",
"TriggerExecutionIn",
"TriggerExecutionUpdate",
"TriggerConfigSchemaOut"
]

View file

@ -0,0 +1,64 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
"""
App Trigger Configuration Models
This package contains configuration models for different trigger app types.
"""
from app.model.trigger.app_configs.base_config import (
BaseTriggerConfig,
ActivationError,
)
from app.model.trigger.app_configs.slack_config import (
SlackEventType,
SlackTriggerConfig,
)
from app.model.trigger.app_configs.webhook_config import (
WebhookTriggerConfig,
)
from app.model.trigger.app_configs.schedule_config import (
ScheduleTriggerConfig,
)
from app.model.trigger.app_configs.config_registry import (
get_config_class,
get_config_schema,
validate_config,
register_config_class,
get_supported_config_types,
has_config,
validate_activation,
)
__all__ = [
# Base config
"BaseTriggerConfig",
"ActivationError",
# Slack config
"SlackEventType",
"SlackTriggerConfig",
# Webhook config
"WebhookTriggerConfig",
# Schedule config
"ScheduleTriggerConfig",
# Registry functions
"get_config_class",
"get_config_schema",
"validate_config",
"register_config_class",
"get_supported_config_types",
"has_config",
"validate_activation",
]

View file

@ -0,0 +1,253 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
"""
Base Trigger Configuration Models
Base configuration models that all app-specific trigger configs should extend from.
Contains common fields and validation logic shared across all trigger types.
"""
import re
from typing import Optional, List, Dict, Any, TYPE_CHECKING
from pydantic import BaseModel, Field, field_validator
from app.type.config_group import ConfigGroup
if TYPE_CHECKING:
from sqlmodel import Session
class ActivationError(Exception):
"""Exception raised when trigger activation requirements are not met."""
def __init__(self, message: str, missing_requirements: List[str] = None):
self.message = message
self.missing_requirements = missing_requirements or []
super().__init__(self.message)
class BaseTriggerConfig(BaseModel):
"""
Base trigger configuration that all app-specific configs should extend.
Contains common fields like message filtering and authentication requirements
that are shared across different trigger types.
"""
# Authentication Configuration
authentication_required: bool = Field(
default=False,
description="Whether authentication is required for this trigger",
json_schema_extra={
"ui:widget": "switch",
"ui:label": "triggers.base.authentication_required.label",
"ui:notice": "triggers.base.authentication_required.notice"
},
)
# Auto-disable Configuration
max_failure_count: Optional[int] = Field(
default=None,
description="Maximum consecutive failures before auto-disabling the trigger. Set to None to disable this feature.",
ge=1,
le=100,
json_schema_extra={
"ui:widget": "number-input",
"ui:label": "triggers.base.max_failure_count.label",
"ui:placeholder": "triggers.base.max_failure_count.placeholder",
"ui:notice": "triggers.base.max_failure_count.notice"
},
)
# Common Filtering Options
message_filter: Optional[str] = Field(
default=None,
description="Regex pattern to filter incoming messages/events",
json_schema_extra={
"ui:label": "triggers.base.message_filter.label",
"ui:widget": "text-input",
"ui:placeholder": "triggers.base.message_filter.placeholder",
"ui:notice": "triggers.base.message_filter.notice",
"ui:validation": "regex",
"maxLength": 500
},
)
@field_validator("message_filter")
@classmethod
def validate_regex(cls, v):
"""Validate that the message_filter is a valid regex pattern."""
if v is None:
return v
try:
re.compile(v)
except re.error as e:
raise ValueError(f"Invalid regex: {e}")
return v
def matches_filter(self, text: Optional[str]) -> bool:
"""
Check if the given text matches the message filter.
Args:
text: The text to check against the filter
Returns:
True if no filter is set, or if the text matches the filter
"""
if self.message_filter is None or text is None:
return True
pattern = re.compile(self.message_filter)
return bool(pattern.search(text))
def should_auto_disable(self, consecutive_failures: int) -> bool:
"""
Check if the trigger should be auto-disabled based on failure count.
Args:
consecutive_failures: The current number of consecutive failures
Returns:
True if the trigger should be disabled, False otherwise
"""
if self.max_failure_count is None:
return False
return consecutive_failures >= self.max_failure_count
def get_required_config_group(self) -> Optional[ConfigGroup]:
"""
Get the config group required for this trigger type.
Override this in subclasses to specify the config group (e.g., ConfigGroup.SLACK).
This leverages the same ConfigGroup enum used by toolkits, ensuring triggers
and toolkits for the same service share credentials.
Returns:
The ConfigGroup enum value, or None if no config group is required
"""
return None
def get_required_credentials(self) -> List[str]:
"""
Get the list of required credential names for this trigger.
Override this in subclasses to specify required credentials.
Returns:
List of credential names that must be present (e.g., ["SLACK_BOT_TOKEN"])
"""
return []
# Built in, depends on ConfigInfo from models/config/config.py
def check_activation_requirements(
self,
user_id: int,
session: "Session"
) -> Dict[str, Any]:
"""
Check if all activation requirements are met for this trigger.
Args:
user_id: The ID of the user who owns the trigger
session: Database session for querying credentials
Returns:
Dict with:
- can_activate: bool - whether the trigger can be activated
- missing_requirements: list - list of missing requirements
- message: str - human-readable status message
Raises:
ActivationError: If activation requirements are not met
"""
if not self.authentication_required:
return {
"can_activate": True,
"missing_requirements": [],
"message": "Authentication not required"
}
config_group = self.get_required_config_group()
required_credentials = self.get_required_credentials()
if not config_group or not required_credentials:
return {
"can_activate": True,
"missing_requirements": [],
"message": "No specific credentials required"
}
# Import here to avoid circular imports
from sqlmodel import select, and_
from app.model.config.config import Config
# Query for user's credentials in the required config group
# Use config_group.value since config_group is now a ConfigGroup enum
configs = session.exec(
select(Config).where(
and_(
Config.user_id == int(user_id),
Config.config_group == config_group.value
)
)
).all()
available_credentials = {
config.config_name: config.config_value
for config in configs
if config.config_value # Only count non-empty values
}
missing = [
cred for cred in required_credentials
if cred not in available_credentials
]
if missing:
return {
"can_activate": False,
"missing_requirements": missing,
"message": f"Missing required credentials: {', '.join(missing)}"
}
return {
"can_activate": True,
"missing_requirements": [],
"message": "All requirements met"
}
def validate_activation(self, user_id: int, session: "Session") -> None:
"""
Validate that the trigger can be activated.
Args:
user_id: The ID of the user who owns the trigger
session: Database session for querying credentials
Raises:
ActivationError: If activation requirements are not met
"""
result = self.check_activation_requirements(user_id, session)
if not result["can_activate"]:
raise ActivationError(
message=result["message"],
missing_requirements=result["missing_requirements"]
)
@classmethod
def validate_config(cls, config_data: dict) -> "BaseTriggerConfig":
"""Validate and return a config instance."""
return cls(**config_data)

View file

@ -0,0 +1,159 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
"""
Trigger Config Registry
Registry for mapping trigger types to their configuration classes.
Used for validation and JSON schema generation.
"""
from typing import Type, Optional, Dict, Any, TYPE_CHECKING
from app.type.trigger_types import TriggerType
from app.model.trigger.app_configs.base_config import BaseTriggerConfig
from app.model.trigger.app_configs.slack_config import SlackTriggerConfig
from app.model.trigger.app_configs.webhook_config import WebhookTriggerConfig
from app.model.trigger.app_configs.schedule_config import ScheduleTriggerConfig
if TYPE_CHECKING:
from sqlmodel import Session
# Registry of trigger types to their config classes
_CONFIG_REGISTRY: Dict[TriggerType, Type[BaseTriggerConfig]] = {
TriggerType.slack_trigger: SlackTriggerConfig,
TriggerType.webhook: WebhookTriggerConfig,
TriggerType.schedule: ScheduleTriggerConfig,
}
def get_config_class(trigger_type: TriggerType) -> Optional[Type[BaseTriggerConfig]]:
"""
Get the config class for a trigger type.
Args:
trigger_type: The trigger type to get config class for
Returns:
The Pydantic model class for the trigger config, or None if not found
"""
return _CONFIG_REGISTRY.get(trigger_type)
def register_config_class(trigger_type: TriggerType, config_class: Type[BaseTriggerConfig]):
"""
Register a config class for a trigger type.
Args:
trigger_type: The trigger type to register
config_class: The Pydantic model class for the trigger config
"""
_CONFIG_REGISTRY[trigger_type] = config_class
def get_config_schema(trigger_type: TriggerType) -> Optional[Dict[str, Any]]:
"""
Get the JSON schema for a trigger type's config.
Args:
trigger_type: The trigger type to get schema for
Returns:
The JSON schema dict, or None if no config class is registered
"""
config_class = get_config_class(trigger_type)
if config_class:
return config_class.model_json_schema()
return None
def validate_config(trigger_type: TriggerType, config_data: Optional[dict]) -> Optional[BaseTriggerConfig]:
"""
Validate config data against the registered config class.
Args:
trigger_type: The trigger type to validate for
config_data: The config data to validate
Returns:
The validated Pydantic model instance, or None if no config class is registered
Raises:
ValidationError: If the config data is invalid
"""
if config_data is None:
return None
config_class = get_config_class(trigger_type)
if config_class:
return config_class(**config_data)
return None
def get_supported_config_types() -> list[TriggerType]:
"""Get list of trigger types that have config classes registered."""
return list(_CONFIG_REGISTRY.keys())
def has_config(trigger_type: TriggerType) -> bool:
"""Check if a trigger type has a config class registered."""
return trigger_type in _CONFIG_REGISTRY
def requires_authentication(trigger_type: TriggerType, config_data: Optional[dict] = None) -> bool:
"""
Check if a trigger type requires authentication.
Args:
trigger_type: The trigger type to check
config_data: Optional config data to check against
Returns:
True if authentication is required, False otherwise
"""
config_class = get_config_class(trigger_type)
if not config_class:
return False
config = config_class(**(config_data or {}))
return config.authentication_required
def validate_activation(
trigger_type: TriggerType,
config_data: Optional[dict],
user_id: int,
session: "Session"
) -> None:
"""
Validate that a trigger can be activated. Raises an exception if not.
Args:
trigger_type: The trigger type to validate
config_data: The config data for the trigger
user_id: The ID of the user who owns the trigger
session: Database session for querying credentials
Raises:
ActivationError: If activation requirements are not met
"""
config_class = get_config_class(trigger_type)
if not config_class:
return # No requirements to check
config = config_class(**(config_data or {}))
config.validate_activation(user_id, session)

View file

@ -0,0 +1,147 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
"""
Schedule Trigger Configuration Models
Minimal configuration for scheduled triggers. Schedule details (time, day, weekday)
are handled by custom_cron_expression. This config only handles:
- date: For one-time executions (cron has no year)
- expirationDate: For recurring schedules with an end date
"""
from datetime import datetime, timezone
from typing import Optional, Tuple
from pydantic import Field, field_validator
from app.model.trigger.app_configs.base_config import BaseTriggerConfig
class ScheduleTriggerConfig(BaseTriggerConfig):
"""
Minimal schedule trigger configuration.
The cron expression handles time, day, weekday, and month scheduling.
This config only handles what cron cannot:
- date: Full date for one-time execution (cron has no year)
- expirationDate: End date for recurring schedules
Examples:
Once (One-time execution):
{
"date": "2026-03-15"
}
Daily/Weekly/Monthly (no expiration):
{}
Daily/Weekly/Monthly (with expiration):
{
"expirationDate": "2026-06-30"
}
"""
# Date for one-time execution (YYYY-MM-DD format)
# Required when is_single_execution=True because cron has no year
date: Optional[str] = Field(
default=None,
description="Full date for one-time execution (YYYY-MM-DD). Required for is_single_execution=True since cron has no year."
)
# Expiration date for recurring schedules (YYYY-MM-DD format)
expirationDate: Optional[str] = Field(
default=None,
description="End date for recurring schedules (YYYY-MM-DD). Schedule will be marked as completed after this date."
)
@field_validator("date")
@classmethod
def validate_date_format(cls, v: Optional[str]) -> Optional[str]:
"""Validate that date is in YYYY-MM-DD format."""
if v is None:
return None
try:
datetime.strptime(v, "%Y-%m-%d")
except ValueError:
raise ValueError("Date must be in YYYY-MM-DD format")
return v
@field_validator("expirationDate")
@classmethod
def validate_expiration_date_format(cls, v: Optional[str]) -> Optional[str]:
"""Validate that expiration date is in YYYY-MM-DD format."""
if v is None:
return None
try:
datetime.strptime(v, "%Y-%m-%d")
except ValueError:
raise ValueError("Expiration date must be in YYYY-MM-DD format")
return v
def is_expired(self, check_date: Optional[datetime] = None) -> bool:
"""
Check if the schedule has expired.
For one-time (date is set): Check if date has passed
For recurring (expirationDate is set): Check if expiration date has passed
Args:
check_date: Date to check against (defaults to now)
Returns:
True if the schedule has expired, False otherwise
"""
if check_date is None:
check_date = datetime.now(timezone.utc)
# One-time execution: check if date has passed
if self.date:
execution_date = datetime.strptime(self.date, "%Y-%m-%d").replace(
hour=23, minute=59, second=59, tzinfo=timezone.utc
)
return check_date > execution_date
# Recurring with expiration: check if expiration date has passed
if self.expirationDate:
expiration = datetime.strptime(self.expirationDate, "%Y-%m-%d").replace(
hour=23, minute=59, second=59, tzinfo=timezone.utc
)
return check_date > expiration
# No expiration set
return False
def should_execute(self, check_date: Optional[datetime] = None) -> Tuple[bool, str]:
"""
Check if the schedule should execute.
Args:
check_date: Date to check against (defaults to now)
Returns:
Tuple of (should_execute, reason)
"""
if self.is_expired(check_date):
return False, "schedule_expired"
return True, "ok"
@classmethod
def validate_config(cls, config_data: dict) -> "ScheduleTriggerConfig":
"""Validate and return a ScheduleTriggerConfig instance."""
return cls(**config_data)

View file

@ -0,0 +1,181 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
"""
Slack Trigger Configuration Models
Configuration models for Slack webhook triggers. These are stored in the
trigger's config field and used by the webhook controller for
app-specific event handling.
"""
from enum import StrEnum
from typing import Optional, List, TYPE_CHECKING
from pydantic import Field
from app.model.trigger.app_configs.base_config import BaseTriggerConfig
from app.type.config_group import ConfigGroup
if TYPE_CHECKING:
from sqlmodel import Session
class SlackEventType(StrEnum):
"""Slack event types that can trigger the workflow"""
ANY = "any_event"
APP_MENTION = "app_mention"
MESSAGE = "message"
FILE_SHARED = "file_shared"
FILE_PUBLIC = "file_public"
CHANNEL_CREATED = "channel_created"
CHANNEL_ARCHIVE = "channel_archive"
CHANNEL_UNARCHIVE = "channel_unarchive"
CHANNEL_RENAME = "channel_rename"
MEMBER_JOINED_CHANNEL = "member_joined_channel"
MEMBER_LEFT_CHANNEL = "member_left_channel"
TEAM_JOIN = "team_join"
REACTION_ADDED = "reaction_added"
REACTION_REMOVED = "reaction_removed"
PIN_ADDED = "pin_added"
PIN_REMOVED = "pin_removed"
APP_HOME_OPENED = "app_home_opened"
class SlackTriggerConfig(BaseTriggerConfig):
"""
Slack-specific trigger configuration.
Extends BaseTriggerConfig with Slack-specific fields for event handling,
channel filtering, and bot message handling.
"""
# Override: Slack triggers require authentication
authentication_required: bool = Field(
default=True,
description="Whether authentication is required for this trigger",
json_schema_extra={
"ui:widget": "switch",
"ui:label": "triggers.slack.authentication_required.label",
"ui:notice": "triggers.slack.authentication_required.notice",
"hidden": True
},
)
# API Key
SLACK_BOT_TOKEN: Optional[str] = Field(
default=None,
description="Slack Bot Token for API access",
json_schema_extra={
"ui:label": "triggers.slack.bot_token.label",
"ui:widget": "text-input",
"ui:widget:type": "secret",
"ui:placeholder": "triggers.slack.bot_token.placeholder",
"ui:notice": "triggers.slack.bot_token.notice",
"minLength": 20,
"maxLength": 200,
"pattern": "^xoxb-",
"api:GET": f"/configs?config_group={ConfigGroup.SLACK.value}",
"api:POST": "/configs",
"api:PUT": "/configs/{config_id}",
"config_group": ConfigGroup.SLACK.value,
"exclude": True # Exclude from saving to trigger/config
},
)
SLACK_SIGNING_SECRET: Optional[str] = Field(
default=None,
description="Slack Signing Secret for API request verification",
json_schema_extra={
"ui:label": "triggers.slack.signing_secret.label",
"ui:widget": "text-input",
"ui:widget:type": "secret",
"ui:placeholder": "triggers.slack.signing_secret.placeholder",
"ui:notice": "triggers.slack.signing_secret.notice",
"minLength": 32,
"maxLength": 64,
"pattern": "^[a-f0-9]+$",
"api:GET": f"/configs?config_group={ConfigGroup.SLACK.value}",
"api:POST": "/configs",
"api:PUT": "/configs/{config_id}",
"config_group": ConfigGroup.SLACK.value,
"exclude": True # Exclude from saving to trigger/config
},
)
# Event Selection
events: List[SlackEventType] = Field(
default=[SlackEventType.MESSAGE],
description="Slack event types to trigger on",
json_schema_extra={
"ui:label": "triggers.slack.events.label",
"ui:widget": "multi-select",
"ui:options": [{"label": e.value, "value": e.value} for e in SlackEventType],
"ui:notice": "triggers.slack.events.notice"
}
)
# Channel Configuration
channel_id: Optional[str] = Field(
default=None,
description="Specific channel ID to watch",
json_schema_extra={
"ui:label": "triggers.slack.channel_id.label",
"ui:widget": "multi-select",
"ui:options": ["fetch channel IDs from Slack API"],
"ui:placeholder": "triggers.slack.channel_id.placeholder",
"pattern": "^C[A-Z0-9]{8,}$",
"api:GET": "trigger/slack/channels",
"ui:notice": "triggers.slack.channel_id.notice",
"hidden": True
},
)
# Slack-Specific Filtering Options
ignore_bot_messages: bool = Field(
default=True,
description="Ignore messages from bots",
json_schema_extra={
"ui:widget": "switch",
"ui:label": "triggers.slack.ignore_bot_messages.label",
},
)
ignore_users: List[str] = Field(
default=[],
description="User IDs to ignore",
json_schema_extra={
"ui:label": "triggers.slack.ignore_users.label",
"ui:widget": "multi-text-input",
"ui:placeholder": "triggers.slack.ignore_users.placeholder",
"ui:notice": "triggers.slack.ignore_users.notice",
"pattern": "^U[A-Z0-9]{8,}$"
},
)
def get_required_config_group(self) -> ConfigGroup:
"""Get the config group required for Slack triggers."""
return ConfigGroup.SLACK
def get_required_credentials(self) -> List[str]:
"""Get the list of required Slack credentials."""
return ["SLACK_BOT_TOKEN", "SLACK_SIGNING_SECRET"]
def should_trigger(self, event_type: str) -> bool:
"""Check if this event type should trigger the workflow."""
if "any_event" in self.events:
return True
return event_type in self.events
@classmethod
def validate_config(cls, config_data: dict) -> "SlackTriggerConfig":
"""Validate and return a SlackTriggerConfig instance."""
return cls(**config_data)

View file

@ -0,0 +1,219 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
"""
Webhook Trigger Configuration Models
Configuration models for generic webhook triggers. These are stored in the
trigger's config field and used by the webhook controller for
request filtering and payload normalization.
"""
import re
from typing import Optional, List
from pydantic import Field
from app.model.trigger.app_configs.base_config import BaseTriggerConfig
class WebhookTriggerConfig(BaseTriggerConfig):
"""
Generic webhook trigger configuration.
Extends BaseTriggerConfig with webhook-specific fields for filtering
incoming requests based on headers, body content, or custom patterns.
"""
# Override authentication_required to default to False for generic webhooks
authentication_required: bool = Field(
default=False,
description="Whether authentication is required for this trigger",
json_schema_extra={
"ui:widget": "switch",
"ui:label": "triggers.webhook.authentication_required.label",
"ui:notice": "triggers.webhook.authentication_required.notice"
},
)
# Content filtering
body_contains: Optional[str] = Field(
default=None,
description="Only trigger if the request body contains this string",
json_schema_extra={
"ui:label": "triggers.webhook.body_contains.label",
"ui:widget": "text-input",
"ui:placeholder": "triggers.webhook.body_contains.placeholder",
"ui:notice": "triggers.webhook.body_contains.notice",
"minLength": 1,
"maxLength": 500
},
)
# Header filtering
required_headers: List[str] = Field(
default=[],
description="List of headers that must be present in the request",
json_schema_extra={
"ui:label": "triggers.webhook.required_headers.label",
"ui:widget": "multi-text-input",
"ui:placeholder": "triggers.webhook.required_headers.placeholder",
"ui:notice": "triggers.webhook.required_headers.notice",
"pattern": "^[A-Za-z0-9-]+$",
"maxLength": 100
},
)
header_match: Optional[str] = Field(
default=None,
description="Regex pattern to match against request headers (format: Header-Name: pattern)",
json_schema_extra={
"ui:label": "triggers.webhook.header_match.label",
"ui:widget": "text-input",
"ui:placeholder": "triggers.webhook.header_match.placeholder",
"ui:notice": "triggers.webhook.header_match.notice",
"ui:validation": "regex",
"maxLength": 500
},
)
# Include full request metadata
include_headers: bool = Field(
default=False,
description="Include request headers in the execution input",
json_schema_extra={
"ui:widget": "switch",
"ui:label": "triggers.webhook.include_headers.label",
"ui:notice": "triggers.webhook.include_headers.notice"
},
)
include_query_params: bool = Field(
default=True,
description="Include query parameters in the execution input",
json_schema_extra={
"ui:widget": "switch",
"ui:label": "triggers.webhook.include_query_params.label",
},
)
include_request_metadata: bool = Field(
default=False,
description="Include request metadata (method, URL, client IP) in execution input",
json_schema_extra={
"ui:widget": "switch",
"ui:label": "triggers.webhook.include_request_metadata.label",
"ui:notice": "triggers.webhook.include_request_metadata.notice"
},
)
def matches_body_filter(self, body: str) -> bool:
"""
Check if the body matches the body_contains filter.
Args:
body: The request body as string
Returns:
True if no filter is set, or if the body contains the filter string
"""
if self.body_contains is None:
return True
return self.body_contains in body
def has_required_headers(self, headers: dict) -> bool:
"""
Check if all required headers are present.
Args:
headers: Dict of request headers (case-insensitive check)
Returns:
True if all required headers are present
"""
if not self.required_headers:
return True
# Normalize headers to lowercase for comparison
lower_headers = {k.lower(): v for k, v in headers.items()}
for required in self.required_headers:
if required.lower() not in lower_headers:
return False
return True
def matches_header_pattern(self, headers: dict) -> bool:
"""
Check if headers match the header_match pattern.
Args:
headers: Dict of request headers
Returns:
True if no pattern is set, or if headers match the pattern
"""
if self.header_match is None:
return True
# Parse pattern: "Header-Name: pattern"
if ":" not in self.header_match:
return True
header_name, pattern = self.header_match.split(":", 1)
header_name = header_name.strip()
pattern = pattern.strip()
# Find the header (case-insensitive)
for key, value in headers.items():
if key.lower() == header_name.lower():
try:
return bool(re.search(pattern, str(value), re.IGNORECASE))
except re.error:
return False
return False # Header not found
def should_trigger(self, body: str, headers: dict, text: Optional[str] = None) -> tuple[bool, str]:
"""
Check if all webhook filters pass.
Args:
body: Request body as string
headers: Request headers dict
text: Optional text content to check against message_filter
Returns:
Tuple of (should_trigger, reason)
"""
# Check message_filter from base class
if not self.matches_filter(text):
return False, "message_filter_not_matched"
# Check body_contains
if not self.matches_body_filter(body):
return False, "body_filter_not_matched"
# Check required headers
if not self.has_required_headers(headers):
return False, "required_headers_missing"
# Check header pattern
if not self.matches_header_pattern(headers):
return False, "header_pattern_not_matched"
return True, "ok"
@classmethod
def validate_config(cls, config_data: dict) -> "WebhookTriggerConfig":
"""Validate and return a WebhookTriggerConfig instance."""
return cls(**config_data)

View file

@ -0,0 +1,203 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
from datetime import datetime
from typing import Any, Dict, Optional
from sqlmodel import Field, Column, SmallInteger, JSON, String
from sqlalchemy_utils import ChoiceType
from pydantic import BaseModel
from app.model.abstract.model import AbstractModel, DefaultTimes
from app.type.trigger_types import TriggerType, TriggerStatus, ListenerType, RequestType
class Trigger(AbstractModel, DefaultTimes, table=True):
"""Trigger model for automated task execution"""
id: int = Field(default=None, primary_key=True)
user_id: str = Field(index=True, description="User ID who owns this trigger")
project_id: str = Field(index=True, description="Project ID this trigger belongs to")
name: str = Field(max_length=100, description="Human readable name for the trigger")
description: str = Field(default="", max_length=1000, description="Description of what this trigger does")
# Trigger configuration
trigger_type: TriggerType = Field(
sa_column=Column(ChoiceType(TriggerType, String(50))),
description="Type of trigger (schedule, webhook, slack_trigger)"
)
status: TriggerStatus = Field(
default=TriggerStatus.inactive,
sa_column=Column(ChoiceType(TriggerStatus, String(50))),
description="Current status of the trigger"
)
# Webhook specific fields
webhook_url: Optional[str] = Field(
default=None,
sa_column=Column(String(1024)),
description="Auto-generated webhook URL for webhook triggers"
)
webhook_method: Optional[RequestType] = Field(
default=None,
sa_column=Column(ChoiceType(RequestType, String(50))),
description="Http/s Request Type"
)
# Schedule specific fields
custom_cron_expression: Optional[str] = Field(
default=None,
sa_column=Column(String(100)),
description="Custom cron expression for scheduled triggers"
)
# Listener configuration
listener_type: Optional[ListenerType] = Field(
default=None,
sa_column=Column(ChoiceType(ListenerType, String(50))),
description="Type of listener (workforce, chat_agent)"
)
agent_model: Optional[str] = Field(
default=None,
sa_column=Column(String(100)),
description="Model to use for the agent"
)
# Task configuration
task_prompt: Optional[str] = Field(
default=None,
max_length=1500,
description="Prompt template for tasks created by this trigger"
)
# Trigger-type specific configuration (validated based on trigger_type)
config: Optional[dict] = Field(
default=None,
sa_column=Column(JSON),
description="Trigger-type specific configuration (e.g., SlackTriggerConfig)"
)
# Execution limits
max_executions_per_hour: Optional[int] = Field(
default=None,
description="Maximum executions allowed per hour"
)
max_executions_per_day: Optional[int] = Field(
default=None,
description="Maximum executions allowed per day"
)
is_single_execution: bool = Field(
default=False,
description="Whether this trigger should only execute once"
)
# Execution tracking
last_executed_at: Optional[datetime] = Field(
default=None,
description="Timestamp of last execution"
)
next_run_at: Optional[datetime] = Field(
default=None,
index=True,
description="Timestamp of next scheduled execution"
)
last_execution_status: Optional[str] = Field(
default=None,
sa_column=Column(String(50)),
description="Status of the last execution"
)
consecutive_failures: int = Field(
default=0,
description="Number of consecutive execution failures"
)
auto_disabled_at: Optional[datetime] = Field(
default=None,
description="Timestamp when trigger was auto-disabled due to max failures"
)
class TriggerIn(BaseModel):
"""Input model for creating triggers"""
name: str = Field(max_length=100)
description: str = Field(default="", max_length=1000)
project_id: str
trigger_type: TriggerType
custom_cron_expression: Optional[str] = None
listener_type: Optional[ListenerType] = None
agent_model: Optional[str] = None
task_prompt: Optional[str] = Field(default=None, max_length=1500)
config: Optional[dict] = None # Trigger-type specific config
max_executions_per_hour: Optional[int] = None
max_executions_per_day: Optional[int] = None
is_single_execution: bool = False
webhook_method: Optional[RequestType] = None
class TriggerUpdate(BaseModel):
"""Model for updating triggers"""
name: Optional[str] = Field(default=None, max_length=100)
description: Optional[str] = Field(default=None, max_length=1000)
status: Optional[TriggerStatus] = None
custom_cron_expression: Optional[str] = None
listener_type: Optional[ListenerType] = None
agent_model: Optional[str] = None
task_prompt: Optional[str] = Field(default=None, max_length=1500)
config: Optional[dict] = None # Trigger-type specific config
max_executions_per_hour: Optional[int] = None
max_executions_per_day: Optional[int] = None
is_single_execution: Optional[bool] = None
webhook_method: Optional[RequestType] = None
class TriggerOut(BaseModel):
"""Output model for trigger responses"""
id: int
user_id: str
project_id: str
name: str
description: str
trigger_type: TriggerType
status: TriggerStatus
execution_count: int = 0
webhook_url: Optional[str] = None
webhook_method: Optional[RequestType] = None
custom_cron_expression: Optional[str] = None
listener_type: Optional[ListenerType] = None
agent_model: Optional[str] = None
task_prompt: Optional[str] = None
config: Optional[dict] = None # Trigger-type specific config
max_executions_per_hour: Optional[int] = None
max_executions_per_day: Optional[int] = None
is_single_execution: bool
last_executed_at: Optional[datetime] = None
next_run_at: Optional[datetime] = None
last_execution_status: Optional[str] = None
consecutive_failures: int = 0
auto_disabled_at: Optional[datetime] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class TriggerConfigSchemaOut(BaseModel):
"""Output model for trigger config schema."""
trigger_type: str
has_config: bool
schema_: Optional[Dict[str, Any]] = None
class Config:
populate_by_name = True
json_schema_extra = {
"properties": {
"schema": {"$ref": "#/definitions/schema_"}
}
}

View file

@ -0,0 +1,134 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
from datetime import datetime
from typing import Optional
from sqlmodel import Field, Column, SmallInteger, JSON, String, Float
from sqlalchemy_utils import ChoiceType
from pydantic import BaseModel
from app.model.abstract.model import AbstractModel, DefaultTimes
from app.type.trigger_types import ExecutionType, ExecutionStatus
class TriggerExecution(AbstractModel, DefaultTimes, table=True):
"""Output model for execution records"""
id: int = Field(default=None, primary_key=True)
trigger_id: int = Field(foreign_key="trigger.id", index=True, description="ID of the trigger that created this execution")
execution_id: str = Field(unique=True, index=True, description="Unique execution identifier")
execution_type: ExecutionType = Field(
sa_column=Column(ChoiceType(ExecutionType, String(50))),
description="Type of execution (scheduled, webhook)"
)
status: ExecutionStatus = Field(
default=ExecutionStatus.pending,
sa_column=Column(ChoiceType(ExecutionStatus, String(50))),
description="Current status of the execution"
)
# Execution timing
started_at: Optional[datetime] = Field(
default=None,
description="Timestamp when execution started"
)
completed_at: Optional[datetime] = Field(
default=None,
description="Timestamp when execution completed"
)
duration_seconds: Optional[float] = Field(
default=None,
sa_column=Column(Float),
description="Duration of execution in seconds"
)
# Execution data
input_data: Optional[dict] = Field(
default=None,
sa_column=Column(JSON),
description="Input data that triggered the execution"
)
output_data: Optional[dict] = Field(
default=None,
sa_column=Column(JSON),
description="Output data from the execution"
)
error_message: Optional[str] = Field(
default=None,
description="Error message if execution failed"
)
# Retry configuration
attempts: int = Field(
default=1,
description="Current number of retry attempts"
)
max_retries: int = Field(
default=3,
description="Maximum number of retry attempts"
)
# Resource usage tracking
tokens_used: Optional[int] = Field(
default=None,
description="Number of tokens used during execution"
)
tools_executed: Optional[dict] = Field(
default=None,
sa_column=Column(JSON),
description="Tools that were executed and their results"
)
class TriggerExecutionIn(BaseModel):
"""Input model for creating trigger executions"""
trigger_id: int
execution_id: str
execution_type: ExecutionType
input_data: Optional[dict] = None
max_retries: int = 3
class TriggerExecutionUpdate(BaseModel):
"""Model for updating trigger executions"""
status: Optional[ExecutionStatus] = None
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
duration_seconds: Optional[float] = None
output_data: Optional[dict] = None
error_message: Optional[str] = None
attempts: Optional[int] = None
tokens_used: Optional[int] = None
tools_executed: Optional[dict] = None
class TriggerExecutionOut(BaseModel):
"""Output model for execution records"""
id: int
trigger_id: int
execution_id: str
execution_type: ExecutionType
status: ExecutionStatus
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
duration_seconds: Optional[float] = None
input_data: Optional[dict] = None
output_data: Optional[dict] = None
error_message: Optional[str] = None
attempts: int
max_retries: int
tokens_used: Optional[int] = None
tools_executed: Optional[dict] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None

View file

@ -0,0 +1,176 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
from celery import shared_task
import logging
from datetime import datetime, timezone
from sqlmodel import select, or_
from app.component.database import session_make
from app.component.environment import env
from app.service.trigger.trigger_schedule_service import TriggerScheduleService
from app.service.trigger.trigger_service import TriggerService
from app.component.trigger_utils import MAX_DISPATCH_PER_TICK
from app.component.redis_utils import get_redis_manager
from app.model.trigger.trigger_execution import TriggerExecution
from app.model.trigger.trigger import Trigger
from app.type.trigger_types import ExecutionStatus
# Timeout configuration from environment variables
EXECUTION_PENDING_TIMEOUT_SECONDS = int(env("EXECUTION_PENDING_TIMEOUT_SECONDS", "60"))
EXECUTION_RUNNING_TIMEOUT_SECONDS = int(env("EXECUTION_RUNNING_TIMEOUT_SECONDS", "600")) # 10 minutes
logger = logging.getLogger("server_trigger_schedule_task")
@shared_task(queue="poll_trigger_schedules")
def poll_trigger_schedules() -> None:
"""
Celery task to poll and execute scheduled triggers.
This runs periodically to check for triggers that are due for execution.
This is a lightweight wrapper around TriggerScheduleService that handles
session management and delegates all business logic to the service layer.
"""
logger.info("Starting poll_trigger_schedules task")
session = session_make()
try:
# Create service instance with session
schedule_service = TriggerScheduleService(session)
# Delegate all logic to the service
schedule_service.poll_and_execute_due_triggers(
max_dispatch_per_tick=MAX_DISPATCH_PER_TICK
)
finally:
session.close()
@shared_task(queue="check_execution_timeouts")
def check_execution_timeouts() -> None:
"""
Celery task to check for timed-out pending and running executions.
This runs periodically to find:
- Pending executions that haven't been acknowledged within EXECUTION_PENDING_TIMEOUT_SECONDS
- Running executions that have been stuck for more than EXECUTION_RUNNING_TIMEOUT_SECONDS
These are marked as missed/failed respectively.
"""
logger.info("Starting check_execution_timeouts task", extra={
"pending_timeout": EXECUTION_PENDING_TIMEOUT_SECONDS,
"running_timeout": EXECUTION_RUNNING_TIMEOUT_SECONDS
})
session = session_make()
redis_manager = get_redis_manager()
trigger_service = TriggerService(session)
try:
now = datetime.now(timezone.utc)
# Find all pending and running executions
executions = session.exec(
select(TriggerExecution).where(
or_(
TriggerExecution.status == ExecutionStatus.pending,
TriggerExecution.status == ExecutionStatus.running
)
)
).all()
timed_out_pending_count = 0
timed_out_running_count = 0
for execution in executions:
is_pending = execution.status == ExecutionStatus.pending
is_running = execution.status == ExecutionStatus.running
# Determine the reference time and timeout based on status
if is_pending:
reference_time = execution.created_at
timeout_seconds = EXECUTION_PENDING_TIMEOUT_SECONDS
else: # running
reference_time = execution.started_at or execution.created_at
timeout_seconds = EXECUTION_RUNNING_TIMEOUT_SECONDS
if reference_time.tzinfo is None:
reference_time = reference_time.replace(tzinfo=timezone.utc)
time_elapsed = (now - reference_time).total_seconds()
if time_elapsed > timeout_seconds:
# Determine the new status and error message
if is_pending:
new_status = ExecutionStatus.missed
error_message = f"Execution acknowledgment timeout ({timeout_seconds} seconds)"
timed_out_pending_count += 1
else:
new_status = ExecutionStatus.failed
error_message = f"Execution running timeout ({timeout_seconds} seconds) - no completion received"
timed_out_running_count += 1
# Use TriggerService.update_execution_status for proper failure tracking
trigger_service.update_execution_status(
execution=execution,
status=new_status,
error_message=error_message
)
# Remove from Redis pending list (best effort, may not exist)
try:
# Get all sessions for this execution's user
trigger = session.get(Trigger, execution.trigger_id)
if trigger and trigger.user_id:
user_session_ids = redis_manager.get_user_sessions(trigger.user_id)
for session_id in user_session_ids:
redis_manager.remove_pending_execution(session_id, execution.execution_id)
elif not trigger:
logger.warning("Trigger not found for execution", extra={
"execution_id": execution.execution_id,
"trigger_id": execution.trigger_id
})
except Exception as e:
logger.warning("Failed to remove execution from Redis", extra={
"execution_id": execution.execution_id,
"trigger_id": execution.trigger_id,
"error": str(e)
})
logger.info("Execution timed out", extra={
"execution_id": execution.execution_id,
"trigger_id": execution.trigger_id,
"original_status": "pending" if is_pending else "running",
"new_status": new_status.value,
"time_elapsed": time_elapsed
})
total_timed_out = timed_out_pending_count + timed_out_running_count
if total_timed_out > 0:
logger.info("Marked executions as timed out", extra={
"timed_out_pending_count": timed_out_pending_count,
"timed_out_running_count": timed_out_running_count,
"total_timed_out": total_timed_out
})
else:
logger.debug("No timed-out executions found")
except Exception as e:
logger.error("Error checking execution timeouts", extra={
"error": str(e),
"error_type": type(e).__name__
}, exc_info=True)
session.rollback()
finally:
session.close()

View file

@ -0,0 +1,54 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
"""
Trigger Service Package
Contains services for managing triggers including:
- TriggerService: Main service for trigger operations
- TriggerScheduleService: Service for scheduled trigger operations
- App Handlers: Handlers for different trigger types (Slack, Webhook, Schedule)
"""
from app.service.trigger.trigger_service import TriggerService, get_trigger_service
from app.service.trigger.trigger_schedule_service import TriggerScheduleService
from app.service.trigger.app_handler_service import (
BaseAppHandler,
SlackAppHandler,
DefaultWebhookHandler,
ScheduleAppHandler,
AppHandlerResult,
get_app_handler,
get_schedule_handler,
register_app_handler,
get_supported_trigger_types,
)
__all__ = [
# Services
"TriggerService",
"get_trigger_service",
"TriggerScheduleService",
# Handlers
"BaseAppHandler",
"SlackAppHandler",
"DefaultWebhookHandler",
"ScheduleAppHandler",
"AppHandlerResult",
# Handler functions
"get_app_handler",
"get_schedule_handler",
"register_app_handler",
"get_supported_trigger_types",
]

View file

@ -0,0 +1,448 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
"""
Trigger App Handler Service
Modular service for handling app-specific webhook authentication,
filtering, and payload normalization based on trigger_type.
"""
import re
from typing import Optional
from dataclasses import dataclass
from fastapi import Request
from sqlmodel import Session, select, and_
import logging
from app.model.trigger.trigger import Trigger
from app.model.config.config import Config
from app.model.trigger.app_configs import SlackTriggerConfig, WebhookTriggerConfig, ScheduleTriggerConfig
from app.type.trigger_types import TriggerType, ExecutionType, TriggerStatus
from app.type.config_group import ConfigGroup
logger = logging.getLogger("server_app_handler_service")
@dataclass
class AppHandlerResult:
"""Result from app handler operations."""
success: bool
data: Optional[dict] = None
reason: Optional[str] = None
class BaseAppHandler:
"""Base class for app-specific handlers."""
trigger_type: TriggerType
execution_type: ExecutionType = ExecutionType.webhook
config_group: Optional[str] = None
async def get_credentials(self, session: Session, user_id: str) -> dict:
"""Get user credentials from config table."""
if not self.config_group:
return {}
configs = session.exec(
select(Config).where(
and_(
Config.user_id == int(user_id),
Config.config_group == self.config_group
)
)
).all()
return {config.config_name: config.config_value for config in configs}
async def authenticate(
self,
request: Request,
body: bytes,
trigger: Trigger,
session: Session
) -> AppHandlerResult:
"""
Authenticate the incoming webhook request.
Returns (success, challenge_response or None)
"""
return AppHandlerResult(success=True)
async def filter_event(
self,
payload: dict,
trigger: Trigger
) -> AppHandlerResult:
"""
Filter events based on trigger configuration.
Returns (should_process, reason)
"""
return AppHandlerResult(success=True, reason="ok")
def normalize_payload(
self,
payload: dict,
trigger: Trigger,
request_meta: dict = None
) -> dict:
"""Normalize the payload for execution input."""
return payload
class SlackAppHandler(BaseAppHandler):
"""Handler for Slack triggers."""
trigger_type = TriggerType.slack_trigger
execution_type = ExecutionType.slack
config_group = ConfigGroup.SLACK.value
async def authenticate(
self,
request: Request,
body: bytes,
trigger: Trigger,
session: Session
) -> AppHandlerResult:
"""Handle Slack authentication and URL verification."""
from camel.auth.slack_auth import SlackAuth
credentials = await self.get_credentials(session, trigger.user_id)
slack_auth = SlackAuth(
signing_secret=credentials.get("SLACK_SIGNING_SECRET"),
bot_token=credentials.get("SLACK_BOT_TOKEN"),
api_token=credentials.get("SLACK_API_TOKEN"),
)
# Check for URL verification challenge
challenge_response = slack_auth.get_verification_response(request, body)
if challenge_response:
# Return the challenge response (already in correct format: {"challenge": "..."})
logger.info(f"Slack URL verification - challenge_response: {challenge_response}")
return AppHandlerResult(success=True, data=challenge_response)
# Verify webhook signature
if not slack_auth.verify_webhook_request(request, body):
logger.warning("Invalid Slack webhook signature", extra={
"trigger_id": trigger.id
})
return AppHandlerResult(success=False, reason="invalid_signature")
return AppHandlerResult(success=True)
async def filter_event(
self,
payload: dict,
trigger: Trigger
) -> AppHandlerResult:
"""Filter Slack events based on trigger config."""
# Prefer 'config' field
config_data = trigger.config or {}
config = SlackTriggerConfig(**config_data)
event = payload.get("event", {})
event_type = event.get("type", "")
# Check event type
if not config.should_trigger(event_type):
return AppHandlerResult(success=False, reason="event_type_not_configured")
# Check channel filter (if channel_id is set, only trigger for that channel)
if config.channel_id:
if event.get("channel") != config.channel_id:
return AppHandlerResult(success=False, reason="channel_not_matched")
# Check bot message filter
if config.ignore_bot_messages:
if event.get("bot_id") or event.get("subtype") == "bot_message":
return AppHandlerResult(success=False, reason="bot_message_ignored")
# Check user filter
if config.ignore_users and event.get("user") in config.ignore_users:
return AppHandlerResult(success=False, reason="user_filtered")
# Check message filter regex
if config.message_filter and event.get("text"):
if not re.search(config.message_filter, event.get("text", ""), re.IGNORECASE):
return AppHandlerResult(success=False, reason="message_filter_not_matched")
return AppHandlerResult(success=True, reason="ok")
def normalize_payload(
self,
payload: dict,
trigger: Trigger,
request_meta: dict = None
) -> dict:
"""Normalize Slack event payload."""
logger.info("Normalizing payload", extra={"payload": payload})
# Prefer 'config' field
config_data = trigger.config or {}
config = SlackTriggerConfig(**config_data)
event = payload.get("event", {})
normalized = {
"event_type": event.get("type"),
"event_ts": event.get("event_ts"),
"team_id": payload.get("team_id"),
"user_id": event.get("user"),
"channel_id": event.get("channel"),
"text": event.get("text"),
"message_ts": event.get("ts"),
"thread_ts": event.get("thread_ts"),
"reaction": event.get("reaction"),
"files": event.get("files"),
"event_id": payload.get("event_id") or payload.get("id")
}
# if config.include_raw_payload:
# normalized["raw_payload"] = payload
return normalized
class DefaultWebhookHandler(BaseAppHandler):
"""Default handler for generic webhooks with config-based filtering."""
trigger_type = TriggerType.webhook
execution_type = ExecutionType.webhook
async def filter_event(
self,
payload: dict,
trigger: Trigger,
headers: dict = None,
body_raw: str = None
) -> AppHandlerResult:
"""Filter webhook events based on trigger config."""
config_data = trigger.config or {}
config = WebhookTriggerConfig(**config_data)
# Get text content for message_filter (check body for text field or stringify)
text = None
if isinstance(payload, dict):
text = payload.get("text") or payload.get("message") or payload.get("content")
if text is None and body_raw:
text = body_raw
# Use the config's should_trigger method
should_trigger, reason = config.should_trigger(
body=body_raw or "",
headers=headers or {},
text=text
)
if not should_trigger:
return AppHandlerResult(success=False, reason=reason)
return AppHandlerResult(success=True, reason="ok")
def normalize_payload(
self,
payload: dict,
trigger: Trigger,
request_meta: dict = None
) -> dict:
"""Normalize generic webhook payload with full request metadata."""
config_data = trigger.config or {}
config = WebhookTriggerConfig(**config_data)
result = {"body": payload}
if request_meta:
# Include headers if configured
if config.include_headers and "headers" in request_meta:
result["headers"] = request_meta["headers"]
# Include query params if configured
if config.include_query_params and "query_params" in request_meta:
result["query_params"] = request_meta["query_params"]
# Include request metadata if configured
if config.include_request_metadata:
if "method" in request_meta:
result["method"] = request_meta["method"]
if "url" in request_meta:
result["url"] = request_meta["url"]
if "client_ip" in request_meta:
result["client_ip"] = request_meta["client_ip"]
return result
class ScheduleAppHandler(BaseAppHandler):
"""
Handler for scheduled triggers.
Manages schedule-specific logic including:
- Expiration checking (expirationDate for recurring schedules)
- Date validation for one-time executions (date field)
"""
trigger_type = TriggerType.schedule
execution_type = ExecutionType.scheduled
async def filter_event(
self,
payload: dict,
trigger: Trigger
) -> AppHandlerResult:
"""
Filter scheduled events based on trigger config.
Checks:
- If one-time (date set) and date has passed
- If recurring with expirationDate and it has passed
"""
config_data = trigger.config or {}
try:
config = ScheduleTriggerConfig(**config_data)
except Exception as e:
logger.warning(
"Invalid schedule config",
extra={"trigger_id": trigger.id, "error": str(e)}
)
# Allow execution if config is missing/invalid (backwards compatibility)
return AppHandlerResult(success=True, reason="ok")
# Check if schedule should execute
should_execute, reason = config.should_execute()
if not should_execute:
return AppHandlerResult(success=False, reason=reason)
return AppHandlerResult(success=True, reason="ok")
def normalize_payload(
self,
payload: dict,
trigger: Trigger,
request_meta: dict = None
) -> dict:
"""Normalize scheduled trigger payload."""
config_data = trigger.config or {}
normalized = {
"scheduled_at": payload.get("scheduled_at"),
"trigger_id": trigger.id,
"trigger_name": trigger.name,
"is_single_execution": trigger.is_single_execution,
}
# Include config details if present
if config_data:
if config_data.get("date"):
normalized["date"] = config_data.get("date")
if config_data.get("expirationDate"):
normalized["expirationDate"] = config_data.get("expirationDate")
return normalized
def check_and_handle_expiration(
self,
trigger: Trigger,
session: Session
) -> bool:
"""
Check if a schedule has expired and handle accordingly.
Args:
trigger: The trigger to check
session: Database session for updates
Returns:
True if trigger is expired and was deactivated, False otherwise
"""
config_data = trigger.config or {}
try:
config = ScheduleTriggerConfig(**config_data)
except Exception as e:
logger.warning(
"Invalid schedule config during expiration check",
extra={"trigger_id": trigger.id, "error": str(e)}
)
return False
if config.is_expired():
# Deactivate the trigger
trigger.status = TriggerStatus.completed
session.add(trigger)
session.commit()
logger.info(
"Schedule trigger expired and deactivated",
extra={
"trigger_id": trigger.id,
"trigger_name": trigger.name,
"expiration_date": config.expirationDate or config.date
}
)
return True
return False
def validate_schedule_for_execution(
self,
trigger: Trigger
) -> tuple[bool, str]:
"""
Validate that a scheduled trigger is valid for execution.
Args:
trigger: The trigger to validate
Returns:
Tuple of (is_valid, reason)
"""
config_data = trigger.config or {}
try:
config = ScheduleTriggerConfig(**config_data)
except Exception as e:
return False, f"invalid_config: {str(e)}"
# Check expiration
if config.is_expired():
return False, "schedule_expired"
return True, "ok"
# Registry of handlers by trigger_type
_HANDLERS: dict[TriggerType, BaseAppHandler] = {
TriggerType.slack_trigger: SlackAppHandler(),
TriggerType.webhook: DefaultWebhookHandler(),
TriggerType.schedule: ScheduleAppHandler(),
}
def get_app_handler(trigger_type: TriggerType) -> Optional[BaseAppHandler]:
"""Get the handler for a trigger type."""
return _HANDLERS.get(trigger_type)
def register_app_handler(trigger_type: TriggerType, handler: BaseAppHandler):
"""Register a new app handler."""
_HANDLERS[trigger_type] = handler
def get_supported_trigger_types() -> list[TriggerType]:
"""Get list of trigger types with webhook support."""
return list(_HANDLERS.keys())
def get_schedule_handler() -> ScheduleAppHandler:
"""Get the schedule handler instance."""
return _HANDLERS.get(TriggerType.schedule)

View file

@ -0,0 +1,430 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
from datetime import datetime, timedelta, timezone
from typing import List, Tuple, Optional
import logging
from croniter import croniter
from uuid import uuid4
import asyncio
from sqlmodel import select
from app.model.trigger.trigger import Trigger
from app.model.trigger.trigger_execution import TriggerExecution
from app.type.trigger_types import TriggerStatus, ExecutionType, ExecutionStatus, TriggerType
from app.component.trigger_utils import check_rate_limits, MAX_DISPATCH_PER_TICK
from app.model.trigger.app_configs import ScheduleTriggerConfig
logger = logging.getLogger("server_trigger_schedule_service")
class TriggerScheduleService:
"""Service for managing scheduled trigger operations.
This service mainly delegates schedule business logic
from the main trigger_service.py.
Handles tasks from the Celery beat scheduler.
Mainly handles:
- Polling for due schedules
- Dispatching scheduled triggers
- Calculating next run times based on cron expressions
"""
def __init__(self, session):
"""
Initialize the schedule service with a database session.
Args:
session: SQLModel session for database operations
"""
self.session = session
def fetch_due_schedules(self, limit: Optional[int] = 100) -> List[Trigger]:
"""
Fetch triggers that are due for execution.
Args:
limit: Maximum number of triggers to fetch
Returns:
List of triggers that need to be executed
"""
now = datetime.now(timezone.utc)
try:
statement = (
select(Trigger)
.where(Trigger.trigger_type == TriggerType.schedule)
.where(Trigger.status == TriggerStatus.active)
.where(Trigger.next_run_at <= now)
.order_by(Trigger.next_run_at)
.limit(limit)
)
results = self.session.exec(statement).all()
logger.debug(
"Fetched due schedules",
extra={
"count": len(results),
"current_time": now.isoformat()
}
)
return list(results)
except Exception as e:
logger.error(
"Failed to fetch due schedules",
extra={"error": str(e)},
exc_info=True
)
return []
def calculate_next_run_at(
self,
trigger: Trigger,
base_time: Optional[datetime] = None
) -> datetime:
"""
Calculate the next run time for a trigger based on its cron expression.
Args:
trigger: The trigger to calculate next run time for
base_time: Base time to calculate from (defaults to now)
Returns:
The next scheduled run time
Raises:
ValueError: If trigger has no cron expression or invalid expression
"""
if not trigger.custom_cron_expression:
raise ValueError(f"Trigger {trigger.id} has no cron expression")
if base_time is None:
base_time = datetime.now(timezone.utc)
try:
cron = croniter(trigger.custom_cron_expression, base_time)
next_run = cron.get_next(datetime)
return next_run
except Exception as e:
logger.error(
"Failed to calculate next run time",
extra={
"trigger_id": trigger.id,
"cron_expression": trigger.custom_cron_expression,
"error": str(e)
}
)
raise
def dispatch_trigger(self, trigger: Trigger) -> bool:
"""
Dispatch a trigger for execution.
Args:
trigger: The trigger to dispatch
Returns:
True if dispatched successfully, False otherwise
"""
try:
# Check schedule expiration before dispatching
if not self._check_schedule_valid(trigger):
logger.info(
"Schedule trigger expired, skipping dispatch",
extra={"trigger_id": trigger.id, "trigger_name": trigger.name}
)
return False
# Create execution record
execution_id = str(uuid4())
execution = TriggerExecution(
trigger_id=trigger.id,
execution_id=execution_id,
execution_type=ExecutionType.scheduled,
status=ExecutionStatus.pending,
input_data={"scheduled_at": datetime.now(timezone.utc).isoformat()},
started_at=datetime.now(timezone.utc)
)
self.session.add(execution)
# Update trigger statistics
trigger.last_executed_at = datetime.now(timezone.utc)
trigger.last_execution_status = "pending"
# Calculate and set next run time
try:
trigger.next_run_at = self.calculate_next_run_at(trigger, datetime.now(timezone.utc))
except Exception as e:
logger.error(
"Failed to calculate next run time, trigger will be skipped",
extra={"trigger_id": trigger.id, "error": str(e)}
)
# Set next_run_at far in the future to prevent immediate re-execution
trigger.next_run_at = datetime.now(timezone.utc) + timedelta(days=365)
# If single execution, deactivate the trigger
if trigger.is_single_execution:
trigger.status = TriggerStatus.inactive
logger.info(
"Trigger deactivated after single execution",
extra={"trigger_id": trigger.id}
)
self.session.add(trigger)
self.session.commit()
# TODO: Queue the actual task execution
# This would integrate with a task queue (e.g., Celery) to execute the trigger's action
# For now event is sent to client for execution
logger.info(
"Trigger dispatched successfully",
extra={
"trigger_id": trigger.id,
"trigger_name": trigger.name,
"execution_id": execution_id,
"next_run_at": trigger.next_run_at.isoformat() if trigger.next_run_at else None
}
)
# Notify WebSocket subscribers
# Using asyncio.run() to run async code from sync Celery worker context
try:
# Notify WebSocket subscribers via Redis pub/sub
from app.component.redis_utils import get_redis_manager
redis_manager = get_redis_manager()
redis_manager.publish_execution_event({
"type": "execution_created",
"execution_id": execution_id,
"trigger_id": trigger.id,
"trigger_type": "schedule",
"status": "pending",
"input_data": execution.input_data,
"task_prompt": trigger.task_prompt,
"execution_type": "schedule",
"user_id": str(trigger.user_id),
"project_id": str(trigger.project_id)
})
logger.debug("WebSocket notification sent", extra={
"execution_id": execution_id,
"trigger_id": trigger.id
})
except Exception as e:
# Don't fail the trigger dispatch if notification fails
logger.warning("Failed to send WebSocket notification", extra={
"trigger_id": trigger.id,
"execution_id": execution_id,
"error": str(e)
})
return True
except Exception as e:
logger.error(
"Failed to dispatch trigger",
extra={
"trigger_id": trigger.id,
"error": str(e)
},
exc_info=True
)
self.session.rollback()
return False
def process_schedules(self, due_schedules: List[Trigger]) -> Tuple[int, int]:
"""
Process due schedules, checking rate limits and dispatching.
Args:
due_schedules: List of triggers that are due for execution
Returns:
Tuple of (dispatched_count, rate_limited_count)
"""
dispatched_count = 0
rate_limited_count = 0
for trigger in due_schedules:
# Check rate limits
if not check_rate_limits(self.session, trigger):
rate_limited_count += 1
# Still update next_run_at even if rate limited, so we don't keep checking
try:
trigger.next_run_at = self.calculate_next_run_at(trigger, datetime.now(timezone.utc))
self.session.add(trigger)
self.session.commit()
except Exception as e:
logger.error(
"Failed to update next_run_at for rate limited trigger",
extra={"trigger_id": trigger.id, "error": str(e)}
)
continue
# Dispatch the trigger
if self.dispatch_trigger(trigger):
dispatched_count += 1
return dispatched_count, rate_limited_count
def poll_and_execute_due_triggers(
self,
max_dispatch_per_tick: Optional[int] = None
) -> Tuple[int, int]:
"""
Poll for due triggers and execute them in batches.
Args:
max_dispatch_per_tick: Maximum number of triggers to dispatch in this tick
(defaults to MAX_DISPATCH_PER_TICK)
Returns:
Tuple of (total_dispatched, total_rate_limited)
"""
max_dispatch = max_dispatch_per_tick or MAX_DISPATCH_PER_TICK
total_dispatched = 0
total_rate_limited = 0
# Process in batches until we've handled all due schedules or hit the limit
while True:
due_schedules = self.fetch_due_schedules()
if not due_schedules:
break
dispatched_count, rate_limited_count = self.process_schedules(due_schedules)
total_dispatched += dispatched_count
total_rate_limited += rate_limited_count
logger.debug(
"Batch processed",
extra={
"dispatched": dispatched_count,
"rate_limited": rate_limited_count
}
)
# Check if we've hit the per-tick limit (if enabled)
if max_dispatch > 0 and total_dispatched >= max_dispatch:
logger.warning(
"Circuit breaker activated: reached dispatch limit, will continue next tick",
extra={"limit": max_dispatch}
)
break
if total_dispatched > 0 or total_rate_limited > 0:
logger.info(
"Trigger schedule poll completed",
extra={
"total_dispatched": total_dispatched,
"total_rate_limited": total_rate_limited
}
)
return total_dispatched, total_rate_limited
def _check_schedule_valid(self, trigger: Trigger) -> bool:
"""
Check if a scheduled trigger is valid for execution.
Validates:
- For one-time (date set): Checks if the scheduled date has passed
- For recurring (expirationDate set): Checks if expirationDate has passed
If expired, the trigger will be marked as completed.
Args:
trigger: The trigger to check
Returns:
True if trigger is valid for execution, False if expired
"""
config_data = trigger.config or {}
# If no config or empty config, allow execution (no expiration)
if not config_data:
return True
try:
config = ScheduleTriggerConfig(**config_data)
except Exception as e:
logger.warning(
"Invalid schedule config",
extra={"trigger_id": trigger.id, "error": str(e)}
)
return False
# Check if schedule has expired
if config.is_expired():
# Mark trigger as completed
trigger.status = TriggerStatus.completed
self.session.add(trigger)
self.session.commit()
logger.info(
"Schedule trigger expired and marked as completed",
extra={
"trigger_id": trigger.id,
"trigger_name": trigger.name,
"expiration_info": config.expirationDate or config.date
}
)
return False
return True
def update_trigger_next_run(self, trigger: Trigger) -> None:
"""
Update a trigger's next_run_at based on its cron expression.
Args:
trigger: The trigger to update
"""
try:
# Check if schedule is expired before updating next run
if not self._check_schedule_valid(trigger):
logger.info(
"Trigger expired, not updating next_run_at",
extra={"trigger_id": trigger.id}
)
return
trigger.next_run_at = self.calculate_next_run_at(trigger)
self.session.add(trigger)
self.session.commit()
logger.info(
"Trigger next_run_at updated",
extra={
"trigger_id": trigger.id,
"next_run_at": trigger.next_run_at.isoformat()
}
)
except Exception as e:
logger.error(
"Failed to update trigger next_run_at",
extra={
"trigger_id": trigger.id,
"error": str(e)
}
)
self.session.rollback()

View file

@ -0,0 +1,392 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
from datetime import datetime, timedelta, timezone
from typing import Optional, List, Dict, Any
from sqlmodel import select, and_, or_
from uuid import uuid4
import logging
from app.model.trigger.trigger import Trigger
from app.model.trigger.trigger_execution import TriggerExecution
from app.type.trigger_types import TriggerType, TriggerStatus, ExecutionType, ExecutionStatus
from app.component.database import session_make
from app.service.trigger.trigger_schedule_service import TriggerScheduleService
from app.component.trigger_utils import SCHEDULED_FETCH_BATCH_SIZE, check_rate_limits
from app.model.trigger.app_configs import ScheduleTriggerConfig, WebhookTriggerConfig
from app.model.trigger.app_configs.base_config import BaseTriggerConfig
logger = logging.getLogger("server_trigger_service")
class TriggerService:
"""Service for managing trigger operations and scheduling."""
def __init__(self, session=None):
self.session = session or session_make()
self.schedule_service = TriggerScheduleService(self.session)
def create_execution(
self,
trigger: Trigger,
execution_type: ExecutionType,
input_data: Optional[Dict[str, Any]] = None
) -> TriggerExecution:
"""Create a new trigger execution."""
execution_id = str(uuid4())
execution = TriggerExecution(
trigger_id=trigger.id,
execution_id=execution_id,
execution_type=execution_type,
status=ExecutionStatus.pending,
input_data=input_data or {},
started_at=datetime.now(timezone.utc)
)
self.session.add(execution)
self.session.commit()
self.session.refresh(execution)
# Update trigger statistics
trigger.last_executed_at = datetime.now(timezone.utc)
trigger.last_execution_status = "pending"
self.session.add(trigger)
self.session.commit()
logger.info("Execution created", extra={
"trigger_id": trigger.id,
"execution_id": execution_id,
"execution_type": execution_type.value
})
return execution
def update_execution_status(
self,
execution: TriggerExecution,
status: ExecutionStatus,
output_data: Optional[Dict[str, Any]] = None,
error_message: Optional[str] = None,
tokens_used: Optional[int] = None,
tools_executed: Optional[Dict[str, Any]] = None
) -> TriggerExecution:
"""Update execution status and metadata."""
execution.status = status
# Set completed_at and duration for terminal statuses
if status in [ExecutionStatus.completed, ExecutionStatus.failed, ExecutionStatus.cancelled, ExecutionStatus.missed]:
execution.completed_at = datetime.now(timezone.utc)
if execution.started_at:
# Ensure started_at is timezone-aware for subtraction
started_at = execution.started_at
if started_at.tzinfo is None:
started_at = started_at.replace(tzinfo=timezone.utc)
execution.duration_seconds = (execution.completed_at - started_at).total_seconds()
if output_data:
execution.output_data = output_data
if error_message:
execution.error_message = error_message
if tokens_used:
execution.tokens_used = tokens_used
if tools_executed:
execution.tools_executed = tools_executed
self.session.add(execution)
self.session.commit()
# Update trigger status and handle auto-disable logic
trigger = self.session.get(Trigger, execution.trigger_id)
if trigger:
if status == ExecutionStatus.failed:
trigger.last_execution_status = "failed"
trigger.consecutive_failures += 1
# Check for auto-disable based on max_failure_count in config
self._check_auto_disable(trigger)
elif status == ExecutionStatus.completed:
trigger.last_execution_status = "completed"
# Reset consecutive failures on success
trigger.consecutive_failures = 0
elif status == ExecutionStatus.cancelled:
trigger.last_execution_status = "cancelled"
elif status == ExecutionStatus.missed:
trigger.last_execution_status = "missed"
self.session.add(trigger)
self.session.commit()
logger.info("Execution status updated", extra={
"execution_id": execution.execution_id,
"status": status.name,
"duration": execution.duration_seconds
})
return execution
def _check_auto_disable(self, trigger: Trigger) -> bool:
"""
Check if trigger should be auto-disabled based on consecutive failures.
Args:
trigger: The trigger to check
Returns:
True if trigger was auto-disabled, False otherwise
"""
if not trigger.config:
return False
try:
# Get the appropriate config class based on trigger type
config: BaseTriggerConfig
if trigger.trigger_type == TriggerType.schedule:
config = ScheduleTriggerConfig(**trigger.config)
elif trigger.trigger_type == TriggerType.webhook:
config = WebhookTriggerConfig(**trigger.config)
else:
# For other trigger types, use base config
config = BaseTriggerConfig(**trigger.config)
# Check if auto-disable should happen
if config.should_auto_disable(trigger.consecutive_failures):
trigger.status = TriggerStatus.inactive
trigger.auto_disabled_at = datetime.now(timezone.utc)
logger.warning(
"Trigger auto-disabled due to max failures",
extra={
"trigger_id": trigger.id,
"trigger_name": trigger.name,
"consecutive_failures": trigger.consecutive_failures,
"max_failure_count": config.max_failure_count
}
)
return True
except Exception as e:
logger.error(
"Failed to check auto-disable for trigger",
extra={
"trigger_id": trigger.id,
"error": str(e)
}
)
return False
def get_pending_executions(self) -> List[TriggerExecution]:
"""Get all pending executions that need to be processed."""
executions = self.session.exec(
select(TriggerExecution).where(
TriggerExecution.status == ExecutionStatus.pending
).order_by(TriggerExecution.created_at)
).all()
return list(executions)
def get_failed_executions_for_retry(self) -> List[TriggerExecution]:
"""Get failed executions that can be retried."""
executions = self.session.exec(
select(TriggerExecution).where(
and_(
TriggerExecution.status == ExecutionStatus.failed,
TriggerExecution.attempts < TriggerExecution.max_retries
)
).order_by(TriggerExecution.created_at)
).all()
return list(executions)
def get_due_scheduled_triggers(self, limit: Optional[int] = None) -> List[Trigger]:
"""
Fetch scheduled triggers that are due for execution.
Args:
limit: Maximum number of triggers to fetch (defaults to SCHEDULED_FETCH_BATCH_SIZE)
Returns:
List of triggers that are due for execution
"""
current_time = datetime.now(timezone.utc)
limit = limit or SCHEDULED_FETCH_BATCH_SIZE
# Query triggers that:
# 1. Are scheduled type
# 2. Are active
# 3. Have a cron expression
# 4. next_run_at is null (never run) or next_run_at <= now
triggers = self.session.exec(
select(Trigger)
.where(
and_(
Trigger.trigger_type == TriggerType.schedule,
Trigger.status == TriggerStatus.active,
Trigger.custom_cron_expression.is_not(None),
or_(
Trigger.next_run_at.is_(None),
Trigger.next_run_at <= current_time
)
)
)
.limit(limit)
).all()
return list(triggers)
def execute_scheduled_triggers(self) -> int:
"""
Execute all due scheduled triggers.
Uses TriggerScheduleService for the actual execution logic.
"""
due_triggers = self.get_due_scheduled_triggers()
if not due_triggers:
return 0
dispatched_count, rate_limited_count = self.schedule_service.process_schedules(due_triggers)
logger.info(
"Scheduled triggers execution completed",
extra={
"dispatched": dispatched_count,
"rate_limited": rate_limited_count
}
)
return dispatched_count
def process_slack_trigger(
self,
trigger: Trigger,
slack_data: Dict[str, Any]
) -> Optional[TriggerExecution]:
"""Process a Slack trigger event."""
if trigger.trigger_type != TriggerType.slack_trigger:
raise ValueError("Trigger is not a Slack trigger")
if trigger.status != TriggerStatus.active:
logger.warning("Slack trigger is not active", extra={
"trigger_id": trigger.id
})
return None
if not check_rate_limits(self.session, trigger):
logger.warning("Slack trigger execution skipped due to rate limits", extra={
"trigger_id": trigger.id
})
return None
try:
execution = self.create_execution(
trigger=trigger,
execution_type=ExecutionType.slack,
input_data=slack_data
)
# TODO: Queue the actual task execution
logger.info("Slack trigger executed", extra={
"trigger_id": trigger.id,
"execution_id": execution.execution_id
})
return execution
except Exception as e:
logger.error("Slack trigger execution failed", extra={
"trigger_id": trigger.id,
"error": str(e)
}, exc_info=True)
return None
def cleanup_old_executions(self, days_to_keep: int = 30) -> int:
"""Clean up old execution records."""
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days_to_keep)
old_executions = self.session.exec(
select(TriggerExecution).where(
and_(
TriggerExecution.created_at < cutoff_date,
TriggerExecution.status.in_([
ExecutionStatus.completed,
ExecutionStatus.failed,
ExecutionStatus.cancelled
])
)
)
).all()
count = len(old_executions)
for execution in old_executions:
self.session.delete(execution)
self.session.commit()
logger.info("Old executions cleaned up", extra={
"count": count,
"days_to_keep": days_to_keep
})
return count
def get_trigger_statistics(self, trigger_id: int) -> Dict[str, Any]:
"""Get statistics for a specific trigger."""
trigger = self.session.get(Trigger, trigger_id)
if not trigger:
raise ValueError("Trigger not found")
# Get execution counts by status
executions = self.session.exec(
select(TriggerExecution).where(
TriggerExecution.trigger_id == trigger_id
)
).all()
stats = {
"trigger_id": trigger_id,
"name": trigger.name,
"trigger_type": trigger.trigger_type.value,
"status": trigger.status.name,
"total_executions": len(executions),
"successful_executions": len([e for e in executions if e.status == ExecutionStatus.completed]),
"failed_executions": len([e for e in executions if e.status == ExecutionStatus.failed]),
"pending_executions": len([e for e in executions if e.status == ExecutionStatus.pending]),
"cancelled_executions": len([e for e in executions if e.status == ExecutionStatus.cancelled]),
"last_executed_at": trigger.last_executed_at.isoformat() if trigger.last_executed_at else None,
"created_at": trigger.created_at.isoformat() if trigger.created_at else None
}
# Calculate average execution time for completed executions
completed_executions = [e for e in executions if e.status == ExecutionStatus.completed and e.duration_seconds]
if completed_executions:
avg_duration = sum(e.duration_seconds for e in completed_executions) / len(completed_executions)
stats["average_execution_time_seconds"] = round(avg_duration, 2)
# Calculate total tokens used
total_tokens = sum(e.tokens_used for e in executions if e.tokens_used)
if total_tokens:
stats["total_tokens_used"] = total_tokens
return stats
def get_trigger_service(session=None) -> TriggerService:
"""Factory function to create a TriggerService instance with a fresh session."""
return TriggerService(session)

View file

@ -0,0 +1,51 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
from enum import StrEnum
class TriggerType(StrEnum):
schedule = "schedule"
webhook = "webhook"
slack_trigger = "slack_trigger"
class TriggerStatus(StrEnum):
pending_verification = "pending_verification"
inactive = "inactive"
active = "active"
stale = "stale"
completed = "completed"
class ListenerType(StrEnum):
workforce = "workforce"
# chat_agent = "chat_agent"
class ExecutionType(StrEnum):
scheduled = "scheduled"
webhook = "webhook"
manual = "manual"
slack = "slack"
class ExecutionStatus(StrEnum):
pending = "pending"
running = "running"
completed = "completed"
failed = "failed"
cancelled = "cancelled"
missed = "missed"
class RequestType(StrEnum):
GET = "GET"
POST = "POST"

View file

@ -1,5 +1,5 @@
services:
# PostgreSQL Database Only
# PostgreSQL Database
postgres:
image: postgres:15
container_name: eigent_postgres
@ -8,15 +8,32 @@ services:
POSTGRES_DB: eigent
POSTGRES_USER: postgres
POSTGRES_PASSWORD: 123456
POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --lc-collate=C --lc-ctype=C"
POSTGRES_INITDB_ARGS: '--encoding=UTF-8 --lc-collate=C --lc-ctype=C'
ports:
- "5432:5432"
- '5432:5432'
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- eigent_network
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U postgres -d eigent" ]
test: ['CMD-SHELL', 'pg_isready -U postgres -d eigent']
interval: 10s
timeout: 5s
retries: 5
# Redis
redis:
image: redis:7-alpine
container_name: eigent-redis-dev
restart: unless-stopped
ports:
- '6379:6379'
volumes:
- eigent_redis_data:/data
networks:
- eigent_network
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 10s
timeout: 5s
retries: 5
@ -24,6 +41,7 @@ services:
volumes:
postgres_data:
driver: local
eigent_redis_data:
networks:
eigent_network:

View file

@ -21,6 +21,22 @@ services:
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: eigent-redis
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- eigent_redis_data:/data
networks:
- eigent_network
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
interval: 10s
timeout: 5s
retries: 5
# FastAPI Application
api:
@ -33,10 +49,8 @@ services:
restart: unless-stopped
ports:
- "3001:5678"
environment:
- DATABASE_URL=postgresql://postgres:123456@postgres:5432/eigent
- ENVIRONMENT=production
- DEBUG=false
env_file:
- .env
# volumes:
# - ./app:/app/app
# - ./alembic:/app/alembic
@ -45,6 +59,8 @@ services:
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- eigent_network
healthcheck:
@ -54,9 +70,60 @@ services:
retries: 3
start_period: 40s
# Celery Worker
celery_worker:
build:
context: ..
dockerfile: server/Dockerfile
args:
database_url: postgresql://postgres:123456@postgres:5432/eigent
container_name: eigent_celery_worker
restart: unless-stopped
command: /app/app/celery/worker/start
env_file:
- .env
# volumes:
# - ./app:/app/app
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- eigent_network
healthcheck:
test: ["CMD-SHELL", "celery -A app.component.celery inspect ping -d celery@$$HOSTNAME"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# Celery Beat Scheduler
celery_beat:
build:
context: ..
dockerfile: server/Dockerfile
args:
database_url: postgresql://postgres:123456@postgres:5432/eigent
container_name: eigent_celery_beat
restart: unless-stopped
command: /app/app/celery/beat/start
env_file:
- .env
# volumes:
# - ./app:/app/app
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- eigent_network
volumes:
postgres_data:
driver: local
eigent_redis_data:
networks:
eigent_network:

View file

@ -34,6 +34,8 @@ dependencies = [
"cryptography>=45.0.4",
"sqids>=0.5.2",
"exa-py>=1.14.16",
"fastapi-limiter>=0.1.6",
"slack-sdk>=3.39.0",
]
[tool.ruff]
@ -69,3 +71,6 @@ combine-as-imports = true
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
[tool.uv.sources]
camel-ai = { git = "https://github.com/camel-ai/camel.git", rev = "feat-trigger" }

968
server/uv.lock generated

File diff suppressed because it is too large Load diff