mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-06-01 06:00:19 +00:00
feat: eigent server trigger implementation
This commit is contained in:
parent
d08d77f324
commit
ddc1d04cbb
39 changed files with 7944 additions and 397 deletions
|
|
@ -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
|
||||
|
|
@ -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 []
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ###
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
7
server/app/celery/beat/start
Normal file
7
server/app/celery/beat/start
Normal 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
|
||||
6
server/app/celery/worker/start
Normal file
6
server/app/celery/worker/start
Normal 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
|
||||
52
server/app/component/celery.py
Normal file
52
server/app/component/celery.py
Normal 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"
|
||||
498
server/app/component/redis_utils.py
Normal file
498
server/app/component/redis_utils.py
Normal 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
|
||||
175
server/app/component/schedule/trigger_schedule_task.py
Normal file
175
server/app/component/schedule/trigger_schedule_task.py
Normal 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()
|
||||
54
server/app/component/service/trigger/__init__.py
Normal file
54
server/app/component/service/trigger/__init__.py
Normal 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",
|
||||
]
|
||||
446
server/app/component/service/trigger/app_handler_service.py
Normal file
446
server/app/component/service/trigger/app_handler_service.py
Normal 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)
|
||||
428
server/app/component/service/trigger/trigger_schedule_service.py
Normal file
428
server/app/component/service/trigger/trigger_schedule_service.py
Normal 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()
|
||||
391
server/app/component/service/trigger/trigger_service.py
Normal file
391
server/app/component/service/trigger/trigger_service.py
Normal 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)
|
||||
95
server/app/component/trigger_utils.py
Normal file
95
server/app/component/trigger_utils.py
Normal 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
|
||||
14
server/app/controller/trigger/__init__.py
Normal file
14
server/app/controller/trigger/__init__.py
Normal 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. =========
|
||||
|
||||
135
server/app/controller/trigger/slack_controller.py
Normal file
135
server/app/controller/trigger/slack_controller.py
Normal 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")
|
||||
688
server/app/controller/trigger/trigger_controller.py
Normal file
688
server/app/controller/trigger/trigger_controller.py
Normal 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
|
||||
)
|
||||
783
server/app/controller/trigger/trigger_execution_controller.py
Normal file
783
server/app/controller/trigger/trigger_execution_controller.py
Normal 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())
|
||||
348
server/app/controller/trigger/webhook_controller.py
Normal file
348
server/app/controller/trigger/webhook_controller.py
Normal 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,
|
||||
}
|
||||
|
||||
|
||||
28
server/app/model/trigger/__init__.py
Normal file
28
server/app/model/trigger/__init__.py
Normal 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"
|
||||
]
|
||||
64
server/app/model/trigger/app_configs/__init__.py
Normal file
64
server/app/model/trigger/app_configs/__init__.py
Normal 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",
|
||||
]
|
||||
253
server/app/model/trigger/app_configs/base_config.py
Normal file
253
server/app/model/trigger/app_configs/base_config.py
Normal 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)
|
||||
159
server/app/model/trigger/app_configs/config_registry.py
Normal file
159
server/app/model/trigger/app_configs/config_registry.py
Normal 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)
|
||||
147
server/app/model/trigger/app_configs/schedule_config.py
Normal file
147
server/app/model/trigger/app_configs/schedule_config.py
Normal 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)
|
||||
181
server/app/model/trigger/app_configs/slack_config.py
Normal file
181
server/app/model/trigger/app_configs/slack_config.py
Normal 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)
|
||||
219
server/app/model/trigger/app_configs/webhook_config.py
Normal file
219
server/app/model/trigger/app_configs/webhook_config.py
Normal 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)
|
||||
203
server/app/model/trigger/trigger.py
Normal file
203
server/app/model/trigger/trigger.py
Normal 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_"}
|
||||
}
|
||||
}
|
||||
134
server/app/model/trigger/trigger_execution.py
Normal file
134
server/app/model/trigger/trigger_execution.py
Normal 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
|
||||
176
server/app/schedule/trigger_schedule_task.py
Normal file
176
server/app/schedule/trigger_schedule_task.py
Normal 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()
|
||||
54
server/app/service/trigger/__init__.py
Normal file
54
server/app/service/trigger/__init__.py
Normal 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",
|
||||
]
|
||||
448
server/app/service/trigger/app_handler_service.py
Normal file
448
server/app/service/trigger/app_handler_service.py
Normal 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)
|
||||
430
server/app/service/trigger/trigger_schedule_service.py
Normal file
430
server/app/service/trigger/trigger_schedule_service.py
Normal 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()
|
||||
392
server/app/service/trigger/trigger_service.py
Normal file
392
server/app/service/trigger/trigger_service.py
Normal 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)
|
||||
51
server/app/type/trigger_types.py
Normal file
51
server/app/type/trigger_types.py
Normal 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"
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
968
server/uv.lock
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue