# ========= 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") ACTIVE_STATUSES = (TriggerStatus.active, TriggerStatus.pending_verification) MAX_ACTIVE_PER_USER = 25 MAX_ACTIVE_PER_PROJECT = 5 def get_active_trigger_counts(session: Session, user_id: str, project_id: str | None = None) -> tuple[int, int]: """Return (user_active_count, project_active_count) for active/pending triggers.""" user_count = session.exec( select(func.count(Trigger.id)).where( and_( Trigger.user_id == user_id, Trigger.status.in_(ACTIVE_STATUSES), # type: ignore[attr-defined] ) ) ).one() project_count = 0 if project_id: project_count = session.exec( select(func.count(Trigger.id)).where( and_( Trigger.user_id == user_id, Trigger.project_id == project_id, Trigger.status.in_(ACTIVE_STATUSES), # type: ignore[attr-defined] ) ) ).one() return user_count, project_count 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 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 # Determine desired initial status based on auth requirements if has_config(data.trigger_type) and data.config and requires_authentication(data.trigger_type, data.config): desired_status = TriggerStatus.pending_verification else: desired_status = TriggerStatus.active # Check concurrent active-trigger limits before auto-activating user_active, project_active = get_active_trigger_counts( session, str(user_id), data.project_id ) if user_active >= MAX_ACTIVE_PER_USER or ( data.project_id and project_active >= MAX_ACTIVE_PER_PROJECT ): logger.info( "Active trigger limit reached — new trigger created as inactive", extra={ "user_id": user_id, "project_id": data.project_id, "user_active": user_active, "project_active": project_active, }, ) trigger_data["status"] = TriggerStatus.inactive else: trigger_data["status"] = desired_status 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: # --- Concurrent active-trigger limits --- user_active, project_active = get_active_trigger_counts( session, str(user_id), trigger.project_id ) if user_active >= MAX_ACTIVE_PER_USER: logger.warning("User active trigger limit reached", extra={ "user_id": user_id, "trigger_id": trigger_id, "current_active": user_active, "limit": MAX_ACTIVE_PER_USER, }) raise HTTPException( status_code=400, detail=f"Maximum number of concurrent active triggers ({MAX_ACTIVE_PER_USER}) reached for this user" ) if trigger.project_id and project_active >= MAX_ACTIVE_PER_PROJECT: logger.warning("Project active trigger limit reached", extra={ "user_id": user_id, "trigger_id": trigger_id, "project_id": trigger.project_id, "current_active": project_active, "limit": MAX_ACTIVE_PER_PROJECT, }) raise HTTPException( status_code=400, detail=f"Maximum number of concurrent active triggers ({MAX_ACTIVE_PER_PROJECT}) reached for this project" ) # Check if authentication is required first — auth-required triggers # go straight to pending_verification (credentials are provided via # the auth flow, so missing-credential errors are expected). 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 } ) # For non-auth triggers, validate activation requirements 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 } ) 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 )