show upcoming run in agent page

This commit is contained in:
Saumya 2025-07-15 11:18:01 +05:30
parent df29b41a2f
commit 414eb23949
19 changed files with 1324 additions and 934 deletions

View File

@ -12,7 +12,7 @@ class ExternalUserIdGeneratorService(ABC):
class MCPQualifiedNameService(ABC):
@abstractmethod
def generate(self, app_slug: AppSlug) -> str:
pass

View File

@ -3,7 +3,9 @@ from fastapi.responses import JSONResponse
from typing import List, Optional, Dict, Any
from pydantic import BaseModel
import os
from datetime import datetime
from datetime import datetime, timezone
import croniter
import pytz
from .support.factory import TriggerModuleFactory
from .support.exceptions import TriggerError, ConfigurationError, ProviderError
@ -53,6 +55,7 @@ class TriggerResponse(BaseModel):
webhook_url: Optional[str]
created_at: str
updated_at: str
config: Dict[str, Any]
class ProviderResponse(BaseModel):
@ -65,10 +68,29 @@ class ProviderResponse(BaseModel):
config_schema: Dict[str, Any]
class UpcomingRun(BaseModel):
trigger_id: str
trigger_name: str
trigger_type: str
next_run_time: str
next_run_time_local: str
timezone: str
cron_expression: str
execution_type: str
agent_prompt: Optional[str] = None
workflow_id: Optional[str] = None
is_active: bool
human_readable: str
class UpcomingRunsResponse(BaseModel):
upcoming_runs: List[UpcomingRun]
total_count: int
def initialize(database: DBConnection):
global db, trigger_service, execution_service, provider_service
db = database
# Initialize workflows API with DB connection
set_workflows_db_connection(database)
@ -170,7 +192,8 @@ async def get_agent_triggers(
is_active=trigger.is_active,
webhook_url=webhook_url,
created_at=trigger.metadata.created_at.isoformat(),
updated_at=trigger.metadata.updated_at.isoformat()
updated_at=trigger.metadata.updated_at.isoformat(),
config=trigger.config.config
))
return responses
@ -179,6 +202,159 @@ async def get_agent_triggers(
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/agents/{agent_id}/upcoming-runs", response_model=UpcomingRunsResponse)
async def get_agent_upcoming_runs(
agent_id: str,
limit: int = Query(10, ge=1, le=50),
user_id: str = Depends(get_current_user_id_from_jwt)
):
if not await is_enabled("agent_triggers"):
raise HTTPException(status_code=403, detail="Agent triggers are not enabled")
await verify_agent_access(agent_id, user_id)
try:
trigger_svc, _, _ = await get_services()
triggers = await trigger_svc.get_agent_triggers(agent_id)
schedule_triggers = [
trigger for trigger in triggers
if trigger.is_active and trigger.trigger_type.value == "schedule"
]
upcoming_runs = []
now = datetime.now(timezone.utc)
for trigger in schedule_triggers:
config = trigger.config.config
cron_expression = config.get('cron_expression')
user_timezone = config.get('timezone', 'UTC')
if not cron_expression:
continue
try:
next_run = _get_next_run_time(cron_expression, user_timezone)
if not next_run:
continue
local_tz = pytz.timezone(user_timezone)
next_run_local = next_run.astimezone(local_tz)
human_readable = _get_human_readable_schedule(cron_expression, user_timezone)
upcoming_runs.append(UpcomingRun(
trigger_id=trigger.trigger_id,
trigger_name=trigger.config.name,
trigger_type=trigger.trigger_type.value,
next_run_time=next_run.isoformat(),
next_run_time_local=next_run_local.isoformat(),
timezone=user_timezone,
cron_expression=cron_expression,
execution_type=config.get('execution_type', 'agent'),
agent_prompt=config.get('agent_prompt'),
workflow_id=config.get('workflow_id'),
is_active=trigger.is_active,
human_readable=human_readable
))
except Exception as e:
logger.warning(f"Error calculating next run for trigger {trigger.trigger_id}: {e}")
continue
upcoming_runs.sort(key=lambda x: x.next_run_time)
upcoming_runs = upcoming_runs[:limit]
return UpcomingRunsResponse(
upcoming_runs=upcoming_runs,
total_count=len(upcoming_runs)
)
except Exception as e:
logger.error(f"Error getting upcoming runs: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
def _get_next_run_time(cron_expression: str, user_timezone: str) -> Optional[datetime]:
try:
tz = pytz.timezone(user_timezone)
now_local = datetime.now(tz)
cron = croniter.croniter(cron_expression, now_local)
next_run_local = cron.get_next(datetime)
next_run_utc = next_run_local.astimezone(timezone.utc)
return next_run_utc
except Exception as e:
logger.error(f"Error calculating next run time: {e}")
return None
def _get_human_readable_schedule(cron_expression: str, user_timezone: str) -> str:
try:
patterns = {
'*/5 * * * *': 'Every 5 minutes',
'*/10 * * * *': 'Every 10 minutes',
'*/15 * * * *': 'Every 15 minutes',
'*/30 * * * *': 'Every 30 minutes',
'0 * * * *': 'Every hour',
'0 */2 * * *': 'Every 2 hours',
'0 */4 * * *': 'Every 4 hours',
'0 */6 * * *': 'Every 6 hours',
'0 */12 * * *': 'Every 12 hours',
'0 0 * * *': 'Daily at midnight',
'0 9 * * *': 'Daily at 9:00 AM',
'0 12 * * *': 'Daily at 12:00 PM',
'0 18 * * *': 'Daily at 6:00 PM',
'0 9 * * 1-5': 'Weekdays at 9:00 AM',
'0 9 * * 1': 'Every Monday at 9:00 AM',
'0 9 * * 2': 'Every Tuesday at 9:00 AM',
'0 9 * * 3': 'Every Wednesday at 9:00 AM',
'0 9 * * 4': 'Every Thursday at 9:00 AM',
'0 9 * * 5': 'Every Friday at 9:00 AM',
'0 9 * * 6': 'Every Saturday at 9:00 AM',
'0 9 * * 0': 'Every Sunday at 9:00 AM',
'0 9 1 * *': 'Monthly on the 1st at 9:00 AM',
'0 9 15 * *': 'Monthly on the 15th at 9:00 AM',
'0 9,17 * * *': 'Daily at 9:00 AM and 5:00 PM',
'0 10 * * 0,6': 'Weekends at 10:00 AM',
}
if cron_expression in patterns:
description = patterns[cron_expression]
if user_timezone != 'UTC':
description += f" ({user_timezone})"
return description
parts = cron_expression.split()
if len(parts) != 5:
return f"Custom schedule: {cron_expression}"
minute, hour, day, month, weekday = parts
if minute.isdigit() and hour == '*' and day == '*' and month == '*' and weekday == '*':
return f"Every hour at :{minute.zfill(2)}"
if minute.isdigit() and hour.isdigit() and day == '*' and month == '*' and weekday == '*':
time_str = f"{hour.zfill(2)}:{minute.zfill(2)}"
description = f"Daily at {time_str}"
if user_timezone != 'UTC':
description += f" ({user_timezone})"
return description
if minute.isdigit() and hour.isdigit() and day == '*' and month == '*' and weekday == '1-5':
time_str = f"{hour.zfill(2)}:{minute.zfill(2)}"
description = f"Weekdays at {time_str}"
if user_timezone != 'UTC':
description += f" ({user_timezone})"
return description
return f"Custom schedule: {cron_expression}"
except Exception:
return f"Custom schedule: {cron_expression}"
@router.post("/agents/{agent_id}/triggers", response_model=TriggerResponse)
async def create_agent_trigger(
agent_id: str,
@ -217,7 +393,8 @@ async def create_agent_trigger(
is_active=trigger.is_active,
webhook_url=webhook_url,
created_at=trigger.metadata.created_at.isoformat(),
updated_at=trigger.metadata.updated_at.isoformat()
updated_at=trigger.metadata.updated_at.isoformat(),
config=trigger.config.config
)
except (ValueError, ConfigurationError, ProviderError) as e:
@ -261,7 +438,8 @@ async def get_trigger(
is_active=trigger.is_active,
webhook_url=webhook_url,
created_at=trigger.metadata.created_at.isoformat(),
updated_at=trigger.metadata.updated_at.isoformat()
updated_at=trigger.metadata.updated_at.isoformat(),
config=trigger.config.config
)
except Exception as e:
logger.error(f"Error getting trigger: {e}")
@ -311,7 +489,8 @@ async def update_trigger(
is_active=updated_trigger.is_active,
webhook_url=webhook_url,
created_at=updated_trigger.metadata.created_at.isoformat(),
updated_at=updated_trigger.metadata.updated_at.isoformat()
updated_at=updated_trigger.metadata.updated_at.isoformat(),
config=updated_trigger.config.config
)
except (ValueError, ConfigurationError, ProviderError) as e:
@ -331,7 +510,6 @@ async def delete_trigger(
try:
trigger_svc, _, _ = await get_services()
trigger = await trigger_svc.get_trigger(trigger_id)
if not trigger:
raise HTTPException(status_code=404, detail="Trigger not found")
@ -339,7 +517,6 @@ async def delete_trigger(
await verify_agent_access(trigger.agent_id, user_id)
success = await trigger_svc.delete_trigger(trigger_id)
if not success:
raise HTTPException(status_code=404, detail="Trigger not found")
@ -360,7 +537,6 @@ async def trigger_webhook(
try:
trigger_svc, execution_svc, _ = await get_services()
try:
raw_data = await request.json()
except:
@ -368,9 +544,6 @@ async def trigger_webhook(
result = await trigger_svc.process_trigger_event(trigger_id, raw_data)
logger.info(f"Webhook trigger result: success={result.success}, should_execute_agent={result.should_execute_agent}, should_execute_workflow={result.should_execute_workflow}")
logger.info(f"Trigger result details: workflow_id={result.workflow_id}, agent_prompt={result.agent_prompt}")
if not result.success:
return JSONResponse(
status_code=400,
@ -427,96 +600,3 @@ async def trigger_webhook(
status_code=500,
content={"success": False, "error": "Internal server error"}
)
@router.post("/qstash/webhook")
async def qstash_webhook(request: Request):
if not await is_enabled("agent_triggers"):
raise HTTPException(status_code=403, detail="Agent triggers are not enabled")
try:
headers = dict(request.headers)
logger.info(f"QStash webhook received with headers: {headers}")
try:
raw_data = await request.json()
except:
raw_data = {}
logger.info(f"QStash webhook payload: {raw_data}")
trigger_id = raw_data.get('trigger_id')
if not trigger_id:
logger.error("No trigger_id found in QStash webhook payload")
return JSONResponse(
status_code=400,
content={"error": "trigger_id is required in webhook payload"}
)
raw_data.update({
"webhook_source": "qstash",
"webhook_headers": headers,
"webhook_timestamp": datetime.now(timezone.utc).isoformat()
})
trigger_svc, execution_svc, _ = await get_services()
result = await trigger_svc.process_trigger_event(trigger_id, raw_data)
logger.info(f"QStash trigger result: success={result.success}, should_execute_agent={result.should_execute_agent}, should_execute_workflow={result.should_execute_workflow}")
if not result.success:
return JSONResponse(
status_code=400,
content={"success": False, "error": result.error_message}
)
if result.should_execute_agent or result.should_execute_workflow:
trigger = await trigger_svc.get_trigger(trigger_id)
if trigger:
logger.info(f"Executing QStash trigger for agent {trigger.agent_id}")
from .domain.entities import TriggerEvent
event = TriggerEvent(
trigger_id=trigger_id,
agent_id=trigger.agent_id,
trigger_type=trigger.trigger_type,
raw_data=raw_data
)
execution_result = await execution_svc.execute_trigger_result(
agent_id=trigger.agent_id,
trigger_result=result,
trigger_event=event
)
logger.info(f"QStash execution result: {execution_result}")
return JSONResponse(content={
"success": True,
"message": "QStash trigger processed and execution started",
"execution": execution_result,
"trigger_id": trigger_id,
"agent_id": trigger.agent_id
})
else:
logger.warning(f"QStash trigger {trigger_id} not found for execution")
return JSONResponse(
status_code=404,
content={"error": f"Trigger {trigger_id} not found"}
)
logger.info(f"QStash webhook processed but no execution needed")
return JSONResponse(content={
"success": True,
"message": "QStash trigger processed successfully (no execution needed)",
"trigger_id": trigger_id
})
except Exception as e:
logger.error(f"Error processing QStash webhook: {e}")
import traceback
logger.error(f"QStash webhook error traceback: {traceback.format_exc()}")
return JSONResponse(
status_code=500,
content={"error": "Internal server error"}
)

View File

@ -208,7 +208,7 @@ class TriggerManager:
name="Schedule",
description="Schedule agent or workflow execution using Cloudflare Workers and cron expressions",
trigger_type="schedule",
provider_class="triggers.providers.schedule_provider.ScheduleTriggerProvider",
provider_class="triggers.infrastructure.providers.schedule_provider.ScheduleTriggerProvider",
webhook_enabled=True,
config_schema={
"type": "object",

View File

@ -3,6 +3,7 @@ import json
import os
from datetime import datetime, timezone
from typing import Dict, Any, Optional
import pytz
from qstash.client import QStash
from utils.logger import logger
from ...domain.entities import TriggerProvider, TriggerEvent, TriggerResult, Trigger
@ -41,6 +42,14 @@ class ScheduleTriggerProvider(TriggerProvider):
if 'workflow_id' not in config:
raise ValueError("workflow_id is required for workflow execution")
# Validate timezone if provided
user_timezone = config.get('timezone', 'UTC')
if user_timezone != 'UTC':
try:
pytz.timezone(user_timezone)
except pytz.UnknownTimeZoneError:
raise ValueError(f"Invalid timezone: {user_timezone}")
try:
import croniter
croniter.croniter(config['cron_expression'])
@ -51,6 +60,64 @@ class ScheduleTriggerProvider(TriggerProvider):
return config
def _convert_cron_to_utc(self, cron_expression: str, user_timezone: str) -> str:
try:
import croniter
parts = cron_expression.split()
if len(parts) != 5:
logger.warning(f"Invalid cron expression format: {cron_expression}")
return cron_expression
minute, hour, day, month, weekday = parts
if minute.startswith('*/') and hour == '*':
return cron_expression
if hour == '*' or minute == '*':
return cron_expression
try:
user_tz = pytz.timezone(user_timezone)
utc_tz = pytz.UTC
from datetime import datetime as dt
now = dt.now(user_tz)
if hour.isdigit() and minute.isdigit():
user_time = user_tz.localize(dt(now.year, now.month, now.day, int(hour), int(minute)))
utc_time = user_time.astimezone(utc_tz)
utc_minute = str(utc_time.minute)
utc_hour = str(utc_time.hour)
return f"{utc_minute} {utc_hour} {day} {month} {weekday}"
elif ',' in hour and minute.isdigit():
hours = hour.split(',')
utc_hours = []
for h in hours:
if h.isdigit():
user_time = user_tz.localize(dt(now.year, now.month, now.day, int(h), int(minute)))
utc_time = user_time.astimezone(utc_tz)
utc_hours.append(str(utc_time.hour))
if utc_hours:
utc_minute = str(utc_time.minute)
return f"{utc_minute} {','.join(utc_hours)} {day} {month} {weekday}"
elif '-' in hour and minute.isdigit():
pass
except Exception as e:
logger.warning(f"Failed to convert timezone for cron expression {cron_expression}: {e}")
return cron_expression
except ImportError:
logger.warning("croniter not available for cron expression validation")
return cron_expression
except Exception as e:
logger.error(f"Error converting cron expression to UTC: {e}")
return cron_expression
async def setup_trigger(self, trigger: Trigger) -> bool:
if not self._qstash:
logger.error("QStash client not available")
@ -58,31 +125,45 @@ class ScheduleTriggerProvider(TriggerProvider):
try:
webhook_url = f"{self._webhook_base_url}/api/triggers/{trigger.trigger_id}/webhook"
cron_expression = trigger.config.config['cron_expression']
execution_type = trigger.config.config.get('execution_type', 'agent')
user_timezone = trigger.config.config.get('timezone', 'UTC')
if user_timezone != 'UTC':
cron_expression = self._convert_cron_to_utc(cron_expression, user_timezone)
logger.info(f"Converted cron expression from {user_timezone} to UTC: {trigger.config.config['cron_expression']} -> {cron_expression}")
payload = {
"trigger_id": trigger.trigger_id,
"agent_id": trigger.agent_id,
"execution_type": trigger.config.config.get('execution_type', 'agent'),
"execution_type": execution_type,
"agent_prompt": trigger.config.config.get('agent_prompt'),
"workflow_id": trigger.config.config.get('workflow_id'),
"workflow_input": trigger.config.config.get('workflow_input', {}),
"timestamp": datetime.now(timezone.utc).isoformat()
}
headers = {
"Content-Type": "application/json",
"X-Trigger-Source": "schedule"
}
if config.ENV_MODE == EnvMode.STAGING:
vercel_bypass_key = os.getenv("VERCEL_PROTECTION_BYPASS_KEY", "")
if vercel_bypass_key:
headers["X-Vercel-Protection-Bypass"] = vercel_bypass_key
schedule_id = await asyncio.to_thread(
self._qstash.schedule.create,
destination=webhook_url,
cron=cron_expression,
body=json.dumps(payload),
headers={
"Content-Type": "application/json",
"X-Trigger-Source": "schedule"
}
headers=headers,
retries=3,
delay="5s"
)
logger.info(f"Created QStash schedule {schedule_id} for trigger {trigger.trigger_id}")
trigger.config.config['qstash_schedule_id'] = schedule_id
logger.info(f"Created QStash schedule {schedule_id} for trigger {trigger.trigger_id} with cron: {cron_expression}")
return True
except Exception as e:
@ -91,9 +172,20 @@ class ScheduleTriggerProvider(TriggerProvider):
async def teardown_trigger(self, trigger: Trigger) -> bool:
if not self._qstash:
logger.warning("QStash client not available, skipping teardown")
return True
try:
schedule_id = trigger.config.config.get('qstash_schedule_id')
if schedule_id:
try:
await asyncio.to_thread(self._qstash.schedule.delete, schedule_id)
logger.info(f"Deleted QStash schedule {schedule_id} for trigger {trigger.trigger_id}")
return True
except Exception as e:
logger.warning(f"Failed to delete QStash schedule {schedule_id} by ID: {e}")
logger.info(f"Attempting to find and delete QStash schedule for trigger {trigger.trigger_id} by webhook URL")
schedules = await asyncio.to_thread(self._qstash.schedule.list)
webhook_url = f"{self._webhook_base_url}/api/triggers/{trigger.trigger_id}/webhook"
@ -102,8 +194,9 @@ class ScheduleTriggerProvider(TriggerProvider):
if schedule.get('destination') == webhook_url:
await asyncio.to_thread(self._qstash.schedule.delete, schedule['scheduleId'])
logger.info(f"Deleted QStash schedule {schedule['scheduleId']} for trigger {trigger.trigger_id}")
break
return True
logger.warning(f"No QStash schedule found for trigger {trigger.trigger_id}")
return True
except Exception as e:
@ -156,16 +249,30 @@ class ScheduleTriggerProvider(TriggerProvider):
async def health_check(self, trigger: Trigger) -> bool:
if not self._qstash:
logger.warning("QStash client not available for health check")
return False
try:
schedule_id = trigger.config.config.get('qstash_schedule_id')
if schedule_id:
try:
schedule = await asyncio.to_thread(self._qstash.schedule.get, schedule_id)
is_healthy = schedule is not None
logger.info(f"Health check for trigger {trigger.trigger_id} using schedule ID {schedule_id}: {'healthy' if is_healthy else 'unhealthy'}")
return is_healthy
except Exception as e:
logger.warning(f"Failed to check health for QStash schedule {schedule_id} by ID: {e}")
logger.info(f"Attempting health check for trigger {trigger.trigger_id} by webhook URL")
webhook_url = f"{self._webhook_base_url}/api/triggers/{trigger.trigger_id}/webhook"
schedules = await asyncio.to_thread(self._qstash.schedule.list)
for schedule in schedules:
if schedule.get('destination') == webhook_url:
logger.info(f"Health check for trigger {trigger.trigger_id}: healthy (found schedule)")
return True
logger.warning(f"Health check for trigger {trigger.trigger_id}: no schedule found")
return False
except Exception as e:
@ -179,20 +286,43 @@ class ScheduleTriggerProvider(TriggerProvider):
return await self.setup_trigger(trigger)
async def update_trigger(self, trigger: Trigger) -> bool:
await self.teardown_trigger(trigger)
if trigger.is_active:
return await self.setup_trigger(trigger)
return True
if not self._qstash:
logger.warning("QStash client not available for trigger update")
return True
try:
logger.info(f"Updating QStash schedule for trigger {trigger.trigger_id}")
teardown_success = await self.teardown_trigger(trigger)
if not teardown_success:
logger.warning(f"Failed to teardown existing schedule for trigger {trigger.trigger_id}, proceeding with setup")
if trigger.is_active:
setup_success = await self.setup_trigger(trigger)
if setup_success:
logger.info(f"Successfully updated QStash schedule for trigger {trigger.trigger_id}")
else:
logger.error(f"Failed to setup updated schedule for trigger {trigger.trigger_id}")
return setup_success
else:
logger.info(f"Trigger {trigger.trigger_id} is inactive, skipping schedule setup")
return True
except Exception as e:
logger.error(f"Error updating QStash schedule for trigger {trigger.trigger_id}: {e}")
return False
def get_webhook_url(self, trigger_id: str, base_url: str) -> Optional[str]:
return f"{base_url}/api/triggers/{trigger_id}/webhook"
async def list_schedules(self) -> list:
if not self._qstash:
logger.warning("QStash client not available for listing schedules")
return []
try:
return await asyncio.to_thread(self._qstash.schedule.list)
schedules = await asyncio.to_thread(self._qstash.schedule.list)
logger.info(f"Successfully retrieved {len(schedules)} schedules from QStash")
return schedules
except Exception as e:
logger.error(f"Failed to list QStash schedules: {e}")
return []

View File

@ -1,5 +0,0 @@
from .schedule_provider import ScheduleTriggerProvider
__all__ = [
'ScheduleTriggerProvider'
]

View File

@ -1,294 +0,0 @@
import asyncio
import json
import os
from datetime import datetime, timezone
from typing import Dict, Any, Optional
from qstash.client import QStash
from utils.logger import logger
from ..core import TriggerProvider, TriggerType, TriggerEvent, TriggerResult, TriggerConfig, ProviderDefinition
from utils.config import config, EnvMode
class ScheduleTriggerProvider(TriggerProvider):
def __init__(self, provider_definition: Optional[ProviderDefinition] = None):
super().__init__(TriggerType.SCHEDULE, provider_definition)
self.qstash_token = os.getenv("QSTASH_TOKEN")
self.webhook_base_url = os.getenv("WEBHOOK_BASE_URL", "http://localhost:3000")
if not self.qstash_token:
logger.warning("QSTASH_TOKEN not found. QStash provider will not work without it.")
self.qstash = None
else:
self.qstash = QStash(token=self.qstash_token)
async def validate_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
if not self.qstash:
raise ValueError("QSTASH_TOKEN environment variable is required for QStash scheduling")
if 'cron_expression' not in config:
raise ValueError("cron_expression is required for QStash schedule triggers")
execution_type = config.get('execution_type', 'agent')
if execution_type not in ['agent', 'workflow']:
raise ValueError("execution_type must be either 'agent' or 'workflow'")
if execution_type == 'agent':
if 'agent_prompt' not in config:
raise ValueError("agent_prompt is required for agent execution")
elif execution_type == 'workflow':
if 'workflow_id' not in config:
raise ValueError("workflow_id is required for workflow execution")
try:
import croniter
croniter.croniter(config['cron_expression'])
except ImportError:
raise ValueError("croniter package is required for cron expressions. Please install it with: pip install croniter")
except Exception as e:
raise ValueError(f"Invalid cron expression: {str(e)}")
return config
async def setup_trigger(self, trigger_config: TriggerConfig) -> bool:
if config.ENV_MODE == EnvMode.STAGING:
vercel_bypass_key = os.getenv("VERCEL_PROTECTION_BYPASS_KEY", "")
else:
vercel_bypass_key = ""
try:
webhook_url = f"{self.webhook_base_url}/api/triggers/qstash/webhook"
execution_type = trigger_config.config.get('execution_type', 'agent')
webhook_payload = {
"trigger_id": trigger_config.trigger_id,
"agent_id": trigger_config.agent_id,
"execution_type": execution_type,
"schedule_name": trigger_config.name,
"cron_expression": trigger_config.config['cron_expression'],
"event_type": "scheduled",
"provider": "qstash"
}
if execution_type == 'agent':
webhook_payload["agent_prompt"] = trigger_config.config['agent_prompt']
elif execution_type == 'workflow':
webhook_payload["workflow_id"] = trigger_config.config['workflow_id']
webhook_payload["workflow_input"] = trigger_config.config.get('workflow_input', {})
schedule_id = await asyncio.to_thread(
self.qstash.schedule.create,
destination=webhook_url,
cron=trigger_config.config['cron_expression'],
body=json.dumps(webhook_payload),
headers={
"Content-Type": "application/json",
"X-Schedule-Provider": "qstash",
"X-Trigger-ID": trigger_config.trigger_id,
"X-Agent-ID": trigger_config.agent_id,
"X-Vercel-Protection-Bypass": vercel_bypass_key
},
retries=3,
delay="5s"
)
trigger_config.config['qstash_schedule_id'] = schedule_id
logger.info(f"Successfully created QStash schedule {schedule_id} for trigger {trigger_config.trigger_id}")
return True
except Exception as e:
logger.error(f"Error setting up QStash scheduled trigger {trigger_config.trigger_id}: {e}")
return False
async def teardown_trigger(self, trigger_config: TriggerConfig) -> bool:
try:
schedule_id = trigger_config.config.get('qstash_schedule_id')
if not schedule_id:
logger.warning(f"No QStash schedule ID found for trigger {trigger_config.trigger_id}")
return True
await asyncio.to_thread(
self.qstash.schedule.delete,
schedule_id=schedule_id
)
logger.info(f"Successfully deleted QStash schedule {schedule_id}")
return True
except Exception as e:
logger.error(f"Error removing QStash scheduled trigger {trigger_config.trigger_id}: {e}")
return False
async def process_event(self, event: TriggerEvent) -> TriggerResult:
try:
raw_data = event.raw_data
execution_type = raw_data.get('execution_type', 'agent')
execution_variables = {
'scheduled_at': event.timestamp.isoformat(),
'trigger_id': event.trigger_id,
'agent_id': event.agent_id,
'schedule_name': raw_data.get('schedule_name', 'Scheduled Task'),
'execution_source': 'qstash',
'execution_type': execution_type,
'cron_expression': raw_data.get('cron_expression'),
'qstash_message_id': raw_data.get('messageId')
}
if execution_type == 'workflow':
workflow_id = raw_data.get('workflow_id')
workflow_input = raw_data.get('workflow_input', {})
return TriggerResult(
success=True,
should_execute_workflow=True,
workflow_id=workflow_id,
workflow_input=workflow_input,
execution_variables=execution_variables
)
else:
agent_prompt = raw_data.get('agent_prompt', 'Execute scheduled task')
return TriggerResult(
success=True,
should_execute_agent=True,
agent_prompt=agent_prompt,
execution_variables=execution_variables
)
except Exception as e:
return TriggerResult(
success=False,
error_message=f"Error processing QStash scheduled trigger event: {str(e)}"
)
async def health_check(self, trigger_config: TriggerConfig) -> bool:
try:
schedule_id = trigger_config.config.get('qstash_schedule_id')
if not schedule_id:
return False
schedule = await asyncio.to_thread(
self.qstash.schedule.get,
schedule_id=schedule_id
)
return getattr(schedule, 'is_active', False)
except Exception as e:
logger.error(f"Health check failed for QStash scheduled trigger {trigger_config.trigger_id}: {e}")
return False
async def pause_trigger(self, trigger_config: TriggerConfig) -> bool:
try:
schedule_id = trigger_config.config.get('qstash_schedule_id')
if not schedule_id:
return False
await asyncio.to_thread(
self.qstash.schedules.pause,
schedule_id=schedule_id
)
logger.info(f"Successfully paused QStash schedule {schedule_id}")
return True
except Exception as e:
logger.error(f"Error pausing QStash schedule: {e}")
return False
async def resume_trigger(self, trigger_config: TriggerConfig) -> bool:
try:
schedule_id = trigger_config.config.get('qstash_schedule_id')
if not schedule_id:
return False
await asyncio.to_thread(
self.qstash.schedules.resume,
schedule_id=schedule_id
)
logger.info(f"Successfully resumed QStash schedule {schedule_id}")
return True
except Exception as e:
logger.error(f"Error resuming QStash schedule: {e}")
return False
async def update_trigger(self, trigger_config: TriggerConfig) -> bool:
try:
schedule_id = trigger_config.config.get('qstash_schedule_id')
webhook_url = f"{self.webhook_base_url}/api/triggers/qstash/webhook"
execution_type = trigger_config.config.get('execution_type', 'agent')
webhook_payload = {
"trigger_id": trigger_config.trigger_id,
"agent_id": trigger_config.agent_id,
"execution_type": execution_type,
"schedule_name": trigger_config.name,
"cron_expression": trigger_config.config['cron_expression'],
"event_type": "scheduled",
"provider": "qstash"
}
if execution_type == 'agent':
webhook_payload["agent_prompt"] = trigger_config.config['agent_prompt']
elif execution_type == 'workflow':
webhook_payload["workflow_id"] = trigger_config.config['workflow_id']
webhook_payload["workflow_input"] = trigger_config.config.get('workflow_input', {})
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.qstash_base_url}/schedules",
headers={
"Authorization": f"Bearer {self.qstash_token}",
"Content-Type": "application/json"
},
json={
"scheduleId": schedule_id,
"destination": webhook_url,
"cron": trigger_config.config['cron_expression'],
"body": webhook_payload,
"headers": {
"Content-Type": "application/json",
"X-Schedule-Provider": "qstash",
"X-Trigger-ID": trigger_config.trigger_id,
"X-Agent-ID": trigger_config.agent_id
},
"retries": 3,
"delay": "5s"
},
timeout=30.0
)
if response.status_code == 200:
logger.info(f"Successfully updated QStash schedule {schedule_id}")
return True
else:
logger.error(f"Failed to update QStash schedule: {response.status_code} - {response.text}")
return False
except Exception as e:
logger.error(f"Error updating QStash schedule: {e}")
return False
def get_webhook_url(self, trigger_id: str, base_url: str) -> Optional[str]:
base_url = os.getenv("WEBHOOK_BASE_URL", "http://localhost:3000")
return f"{base_url}/api/triggers/qstash/webhook"
async def list_schedules(self) -> list:
try:
schedules_data = await asyncio.to_thread(
self.qstash.schedules.list
)
schedules = []
for schedule in schedules_data:
schedules.append({
'id': getattr(schedule, 'schedule_id', None),
'destination': getattr(schedule, 'destination', None),
'cron': getattr(schedule, 'cron', None),
'is_active': getattr(schedule, 'is_active', False),
'created_at': getattr(schedule, 'created_at', None),
'next_delivery': getattr(schedule, 'next_delivery', None)
})
return schedules
except Exception as e:
logger.error(f"Error listing QStash schedules: {e}")
return []

View File

@ -1,51 +0,0 @@
import warnings
warnings.warn(
"triggers.registry is deprecated. Use triggers.domain.services.ProviderRegistryService instead.",
DeprecationWarning,
stacklevel=2
)
from typing import Dict, List, Optional
from .core import TriggerProvider, TriggerType
from .providers import ScheduleTriggerProvider
class TriggerRegistry:
"""Registry for trigger providers."""
def __init__(self):
self._providers: Dict[TriggerType, TriggerProvider] = {}
self._initialize_default_providers()
def _initialize_default_providers(self):
"""Initialize default trigger providers."""
self.register_provider(ScheduleTriggerProvider())
def register_provider(self, provider: TriggerProvider):
"""Register a trigger provider."""
self._providers[provider.trigger_type] = provider
def get_provider(self, trigger_type: TriggerType) -> Optional[TriggerProvider]:
"""Get a trigger provider by type."""
return self._providers.get(trigger_type)
def get_all_providers(self) -> Dict[TriggerType, TriggerProvider]:
"""Get all registered providers."""
return self._providers.copy()
def get_supported_types(self) -> List[TriggerType]:
"""Get list of supported trigger types."""
return list(self._providers.keys())
def get_provider_schemas(self) -> Dict[str, Dict]:
"""Get configuration schemas for all providers."""
schemas = {}
for trigger_type, provider in self._providers.items():
trigger_type_str = trigger_type.value if hasattr(trigger_type, 'value') else str(trigger_type)
schemas[trigger_type_str] = provider.get_config_schema()
return schemas
def is_supported(self, trigger_type: TriggerType) -> bool:
"""Check if a trigger type is supported."""
return trigger_type in self._providers
trigger_registry = TriggerRegistry()

View File

@ -19,6 +19,7 @@ import { useAgentVersionStore } from '../../../../../lib/stores/agent-version-st
import { cn } from '@/lib/utils';
import { AgentHeader, VersionAlert, AgentBuilderTab, ConfigurationTab } from '@/components/agents/config';
import { UpcomingRunsDropdown } from '@/components/agents/upcoming-runs-dropdown';
interface FormData {
name: string;
@ -268,6 +269,7 @@ export default function AgentConfigurationPageRefactored() {
setOriginalData(formData);
}}
/>
<UpcomingRunsDropdown agentId={agentId} />
</div>
<div className="flex items-center gap-2">
{hasUnsavedChanges && !isViewingOldVersion && (
@ -341,7 +343,101 @@ export default function AgentConfigurationPageRefactored() {
</div>
<div className="md:hidden flex flex-col h-full w-full">
<div className="flex-1 overflow-y-auto p-4">
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex-shrink-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<AgentVersionSwitcher
agentId={agentId}
currentVersionId={agent?.current_version_id}
currentFormData={{
system_prompt: formData.system_prompt,
configured_mcps: formData.configured_mcps,
custom_mcps: formData.custom_mcps,
agentpress_tools: formData.agentpress_tools
}}
/>
<CreateVersionButton
agentId={agentId}
currentFormData={{
system_prompt: formData.system_prompt,
configured_mcps: formData.configured_mcps,
custom_mcps: formData.custom_mcps,
agentpress_tools: formData.agentpress_tools
}}
hasChanges={hasUnsavedChanges && !isViewingOldVersion}
onVersionCreated={() => {
setOriginalData(formData);
}}
/>
<UpcomingRunsDropdown agentId={agentId} />
</div>
<div className="flex items-center gap-2">
{hasUnsavedChanges && !isViewingOldVersion && (
<Button
size="sm"
onClick={handleSave}
disabled={isSaving}
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
{isSaving ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Save className="h-3 w-3" />
)}
Save
</Button>
)}
</div>
</div>
{isViewingOldVersion && (
<VersionAlert
versionData={versionData}
isActivating={activateVersionMutation.isPending}
onActivateVersion={handleActivateVersion}
/>
)}
<AgentHeader
agentId={agentId}
displayData={displayData}
currentStyle={currentStyle}
activeTab={activeTab}
isViewingOldVersion={isViewingOldVersion}
onFieldChange={handleFieldChange}
onStyleChange={handleStyleChange}
onTabChange={setActiveTab}
/>
</div>
</div>
<div className="flex-1 overflow-hidden">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full">
<TabsContent value="agent-builder" className="flex-1 h-0 m-0">
<AgentBuilderTab
agentId={agentId}
displayData={displayData}
currentStyle={currentStyle}
isViewingOldVersion={isViewingOldVersion}
onFieldChange={handleFieldChange}
onStyleChange={handleStyleChange}
/>
</TabsContent>
<TabsContent value="configuration" className="flex-1 h-0 m-0 overflow-y-auto">
<ConfigurationTab
agentId={agentId}
displayData={displayData}
versionData={versionData}
isViewingOldVersion={isViewingOldVersion}
onFieldChange={handleFieldChange}
onMCPChange={handleMCPChange}
/>
</TabsContent>
</Tabs>
</div>
</div>
<Drawer open={isPreviewOpen} onOpenChange={setIsPreviewOpen}>

View File

@ -49,17 +49,7 @@ export const AgentToolsConfiguration = ({ tools, onToolsChange }: AgentToolsConf
{getSelectedToolsCount()} selected
</span>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search tools..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div className="gap-4 grid grid-cols-1 md:grid-cols-2">
{getFilteredTools().map(([toolName, toolInfo]) => (

View File

@ -58,6 +58,7 @@ export function AgentHeader({
/>
</div>
</div>
<Tabs value={activeTab} onValueChange={onTabChange}>
<TabsList className="grid grid-cols-2 bg-muted/50 h-9">
<TabsTrigger

View File

@ -1,8 +1,7 @@
"use client";
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Zap, MessageSquare, Webhook, Plus, Settings } from 'lucide-react';
import { Zap } from 'lucide-react';
import { Dialog } from '@/components/ui/dialog';
import { ConfiguredTriggersList } from './configured-triggers-list';
import { TriggerConfigDialog } from './trigger-config-dialog';
@ -30,6 +29,7 @@ export const AgentTriggersConfiguration: React.FC<AgentTriggersConfigurationProp
const [editingTrigger, setEditingTrigger] = useState<TriggerConfiguration | null>(null);
const { data: triggers = [], isLoading, error } = useAgentTriggers(agentId);
const { data: providers = [] } = useTriggerProviders();
const createTriggerMutation = useCreateTrigger();
const updateTriggerMutation = useUpdateTrigger();
const deleteTriggerMutation = useDeleteTrigger();
@ -37,19 +37,28 @@ export const AgentTriggersConfiguration: React.FC<AgentTriggersConfigurationProp
const handleEditTrigger = (trigger: TriggerConfiguration) => {
setEditingTrigger(trigger);
setConfiguringProvider({
provider_id: trigger.provider_id,
name: trigger.trigger_type,
description: '',
trigger_type: trigger.trigger_type,
webhook_enabled: !!trigger.webhook_url,
config_schema: {}
});
const provider = providers.find(p => p.provider_id === trigger.provider_id);
if (provider) {
setConfiguringProvider(provider);
} else {
setConfiguringProvider({
provider_id: trigger.provider_id,
name: trigger.trigger_type,
description: '',
trigger_type: trigger.trigger_type,
webhook_enabled: !!trigger.webhook_url,
config_schema: {}
});
}
};
const handleRemoveTrigger = async (trigger: TriggerConfiguration) => {
try {
await deleteTriggerMutation.mutateAsync(trigger.trigger_id);
await deleteTriggerMutation.mutateAsync({
triggerId: trigger.trigger_id,
agentId: trigger.agent_id
});
toast.success('Trigger deleted successfully');
} catch (error) {
toast.error('Failed to delete trigger');
@ -123,23 +132,13 @@ export const AgentTriggersConfiguration: React.FC<AgentTriggersConfigurationProp
<OneClickIntegrations agentId={agentId} />
{triggers.length > 0 && (
<div className="bg-card rounded-xl border border-border overflow-hidden">
<div className="px-6 py-4 border-b border-border bg-muted/30">
<h4 className="text-sm font-medium text-foreground flex items-center gap-2">
<Settings className="h-4 w-4" />
Configured Triggers
</h4>
</div>
<div className="p-2 divide-y divide-border">
<ConfiguredTriggersList
triggers={triggers}
onEdit={handleEditTrigger}
onRemove={handleRemoveTrigger}
onToggle={handleToggleTrigger}
isLoading={deleteTriggerMutation.isPending || toggleTriggerMutation.isPending}
/>
</div>
</div>
<ConfiguredTriggersList
triggers={triggers}
onEdit={handleEditTrigger}
onRemove={handleRemoveTrigger}
onToggle={handleToggleTrigger}
isLoading={deleteTriggerMutation.isPending || toggleTriggerMutation.isPending}
/>
)}
{!isLoading && triggers.length === 0 && (

View File

@ -38,6 +38,26 @@ const copyToClipboard = async (text: string) => {
}
};
const getCronDescription = (cron: string): string => {
const cronDescriptions: Record<string, string> = {
'0 9 * * *': 'Daily at 9:00 AM',
'0 18 * * *': 'Daily at 6:00 PM',
'0 9 * * 1-5': 'Weekdays at 9:00 AM',
'0 10 * * 1-5': 'Weekdays at 10:00 AM',
'0 9 * * 1': 'Every Monday at 9:00 AM',
'0 9 1 * *': 'Monthly on the 1st at 9:00 AM',
'0 9 1 1 *': 'Yearly on Jan 1st at 9:00 AM',
'0 */2 * * *': 'Every 2 hours',
'*/30 * * * *': 'Every 30 minutes',
'0 0 * * *': 'Daily at midnight',
'0 12 * * *': 'Daily at noon',
'0 9 * * 0': 'Every Sunday at 9:00 AM',
'0 9 * * 6': 'Every Saturday at 9:00 AM',
};
return cronDescriptions[cron] || cron;
};
export const ConfiguredTriggersList: React.FC<ConfiguredTriggersListProps> = ({
triggers,
onEdit,
@ -76,7 +96,16 @@ export const ConfiguredTriggersList: React.FC<ConfiguredTriggersListProps> = ({
{truncateString(trigger.description, 50)}
</p>
)}
{trigger.trigger_type === 'schedule' && trigger.config && (
<div className="text-xs text-muted-foreground mt-1">
{trigger.config.execution_type === 'agent' && trigger.config.agent_prompt && (
<p>Prompt: {truncateString(trigger.config.agent_prompt, 40)}</p>
)}
{trigger.config.execution_type === 'workflow' && trigger.config.workflow_id && (
<p>Workflow: {trigger.config.workflow_id}</p>
)}
</div>
)}
{trigger.webhook_url && (
<div className="flex items-center space-x-2 mt-2">
<code className="text-xs bg-muted px-2 py-1 rounded font-mono max-w-xs truncate">
@ -118,7 +147,6 @@ export const ConfiguredTriggersList: React.FC<ConfiguredTriggersListProps> = ({
)}
</div>
</div>
<div className="flex items-center space-x-2">
<Tooltip>
<TooltipTrigger asChild>

View File

@ -66,7 +66,10 @@ export const OneClickIntegrations: React.FC<OneClickIntegrationsProps> = ({
const handleUninstall = async (provider: ProviderKey, triggerId?: string) => {
if (provider === 'schedule' && triggerId) {
try {
await deleteTriggerMutation.mutateAsync(triggerId);
await deleteTriggerMutation.mutateAsync({
triggerId,
agentId
});
toast.success('Schedule trigger removed successfully');
} catch (error) {
toast.error('Failed to remove schedule trigger');
@ -120,7 +123,6 @@ export const OneClickIntegrations: React.FC<OneClickIntegrationsProps> = ({
const scheduleProvider: TriggerProvider = {
provider_id: 'schedule',
name: 'Schedule',
description: 'Schedule agent execution using cron expressions',
trigger_type: 'schedule',
webhook_enabled: true,
config_schema: {}

View File

@ -11,6 +11,7 @@ import { Card, CardContent, CardDescription, CardHeader } from '@/components/ui/
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Calendar } from '@/components/ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Switch } from '@/components/ui/switch';
import { Clock, Calendar as CalendarIcon, Info, Zap, Repeat, Timer, Target } from 'lucide-react';
import { format, startOfDay } from 'date-fns';
import { cn } from '@/lib/utils';
@ -23,6 +24,12 @@ interface ScheduleTriggerConfigFormProps {
onChange: (config: ScheduleTriggerConfig) => void;
errors: Record<string, string>;
agentId: string;
name: string;
description: string;
onNameChange: (name: string) => void;
onDescriptionChange: (description: string) => void;
isActive: boolean;
onActiveChange: (active: boolean) => void;
}
type ScheduleType = 'quick' | 'recurring' | 'advanced' | 'one-time';
@ -102,6 +109,12 @@ export const ScheduleTriggerConfigForm: React.FC<ScheduleTriggerConfigFormProps>
onChange,
errors,
agentId,
name,
description,
onNameChange,
onDescriptionChange,
isActive,
onActiveChange,
}) => {
const { data: workflows = [], isLoading: isLoadingWorkflows } = useAgentWorkflows(agentId);
const [scheduleType, setScheduleType] = useState<ScheduleType>('quick');
@ -116,7 +129,34 @@ export const ScheduleTriggerConfigForm: React.FC<ScheduleTriggerConfigFormProps>
const [selectedDate, setSelectedDate] = useState<Date>();
const [oneTimeTime, setOneTimeTime] = useState<{ hour: string; minute: string }>({ hour: '09', minute: '00' });
useEffect(() => {
if (!config.timezone) {
try {
const detectedTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
onChange({
...config,
timezone: detectedTimezone,
});
} catch (error) {
onChange({
...config,
timezone: 'UTC',
});
}
}
}, []);
useEffect(() => {
if (config.cron_expression) {
const preset = QUICK_PRESETS.find(p => p.cron === config.cron_expression);
if (preset) {
setScheduleType('quick');
setSelectedPreset(config.cron_expression);
} else {
setScheduleType('advanced');
}
}
}, [config.cron_expression]);
const generateCronExpression = () => {
if (scheduleType === 'quick' && selectedPreset) {
@ -179,6 +219,29 @@ export const ScheduleTriggerConfigForm: React.FC<ScheduleTriggerConfigFormProps>
});
};
const getSchedulePreview = () => {
if (!config.cron_expression) return null;
try {
const descriptions: Record<string, string> = {
'0 9 * * *': 'Every day at 9:00 AM',
'0 18 * * *': 'Every day at 6:00 PM',
'0 9 * * 1-5': 'Weekdays at 9:00 AM',
'0 10 * * 1-5': 'Weekdays at 10:00 AM',
'0 9 * * 1': 'Every Monday at 9:00 AM',
'0 9 1 * *': 'Monthly on the 1st at 9:00 AM',
'0 */2 * * *': 'Every 2 hours',
'*/30 * * * *': 'Every 30 minutes',
'0 0 * * *': 'Every day at midnight',
'0 12 * * *': 'Every day at noon',
};
return descriptions[config.cron_expression] || config.cron_expression;
} catch {
return config.cron_expression;
}
};
const handleExecutionTypeChange = (value: 'agent' | 'workflow') => {
const newConfig = {
...config,
@ -206,8 +269,6 @@ export const ScheduleTriggerConfigForm: React.FC<ScheduleTriggerConfigFormProps>
});
};
const handleWeekdayToggle = (weekday: string) => {
setSelectedWeekdays(prev =>
prev.includes(weekday)
@ -238,392 +299,508 @@ export const ScheduleTriggerConfigForm: React.FC<ScheduleTriggerConfigFormProps>
return (
<div className="space-y-6">
<Card className="border-none bg-transparent shadow-none p-0">
<CardHeader className='p-0 -mt-2'>
<CardHeader className='p-0'>
<CardDescription>
Configure when your agent should be triggered automatically. Choose from quick presets, recurring schedules, or set up advanced cron expressions.
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<Tabs value={scheduleType} onValueChange={(value) => setScheduleType(value as ScheduleType)} className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="quick" className="flex items-center gap-2">
<Zap className="h-4 w-4" />
Quick
</TabsTrigger>
<TabsTrigger value="recurring" className="flex items-center gap-2">
<Repeat className="h-4 w-4" />
Recurring
</TabsTrigger>
<TabsTrigger value="one-time" className="flex items-center gap-2">
<CalendarIcon className="h-4 w-4" />
One-time
</TabsTrigger>
<TabsTrigger value="advanced" className="flex items-center gap-2">
<Target className="h-4 w-4" />
Advanced
</TabsTrigger>
</TabsList>
<CardContent className="p-0 pt-4">
<div className="grid grid-cols-1 xl:grid-cols-2 gap-8">
<div className="space-y-6">
<div>
<h3 className="text-sm font-medium mb-4 flex items-center gap-2">
<Target className="h-4 w-4" />
Trigger Details
</h3>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="trigger-name">Name *</Label>
<Input
id="trigger-name"
value={name}
onChange={(e) => onNameChange(e.target.value)}
placeholder="Enter a name for this trigger"
className={errors.name ? 'border-destructive' : ''}
/>
{errors.name && (
<p className="text-sm text-destructive">{errors.name}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="trigger-description">Description</Label>
<Textarea
id="trigger-description"
value={description}
onChange={(e) => onDescriptionChange(e.target.value)}
placeholder="Optional description for this trigger"
rows={2}
/>
</div>
<div className="flex items-center space-x-2">
<Switch
id="trigger-active"
checked={isActive}
onCheckedChange={onActiveChange}
/>
<Label htmlFor="trigger-active">
Enable trigger immediately
</Label>
</div>
</div>
</div>
<TabsContent value="quick" className="space-y-4 mt-6">
<div className="space-y-4">
{Object.entries(groupedPresets).map(([category, presets]) => (
<div key={category}>
<h4 className="text-sm font-medium mb-3 capitalize">{category} Schedules</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{presets.map((preset) => (
<Card
key={preset.cron}
className={cn(
"p-0 cursor-pointer transition-colors hover:bg-accent",
selectedPreset === preset.cron && "ring-2 ring-primary bg-accent"
)}
onClick={() => handlePresetSelect(preset)}
>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="text-primary">{preset.icon}</div>
<div className="flex-1">
<div className="font-medium text-sm">{preset.name}</div>
<div className="text-xs text-muted-foreground">{preset.description}</div>
<div>
<h3 className="text-sm font-medium mb-4 flex items-center gap-2">
<Zap className="h-4 w-4" />
Execution Configuration
</h3>
<div className="space-y-4">
<div>
<Label className="text-sm font-medium mb-3 block">
Execution Type *
</Label>
<RadioGroup value={config.execution_type || 'agent'} onValueChange={handleExecutionTypeChange}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="agent" id="execution-agent" />
<Label htmlFor="execution-agent">Execute Agent</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="workflow" id="execution-workflow" />
<Label htmlFor="execution-workflow">Execute Workflow</Label>
</div>
</RadioGroup>
<p className="text-xs text-muted-foreground mt-1">
Choose whether to execute the agent directly or run a specific workflow.
</p>
</div>
{config.execution_type === 'workflow' ? (
<div className="space-y-4">
<div>
<Label htmlFor="workflow_id" className="text-sm font-medium">
Workflow *
</Label>
<Select value={config.workflow_id || ''} onValueChange={handleWorkflowChange}>
<SelectTrigger className={errors.workflow_id ? 'border-destructive' : ''}>
<SelectValue placeholder="Select a workflow" />
</SelectTrigger>
<SelectContent>
{isLoadingWorkflows ? (
<SelectItem value="__loading__" disabled>Loading workflows...</SelectItem>
) : workflows.length === 0 ? (
<SelectItem value="__no_workflows__" disabled>No workflows available</SelectItem>
) : (
workflows.filter(w => w.status === 'active').map((workflow) => (
<SelectItem key={workflow.id} value={workflow.id}>
{workflow.name}
</SelectItem>
))
)}
</SelectContent>
</Select>
{errors.workflow_id && (
<p className="text-xs text-destructive mt-1">{errors.workflow_id}</p>
)}
<p className="text-xs text-muted-foreground mt-1">
Select the workflow to execute when triggered.
</p>
</div>
<div>
<Label htmlFor="workflow_input" className="text-sm font-medium">
Instructions for Workflow
</Label>
<Textarea
id="workflow_input"
value={config.workflow_input?.prompt || config.workflow_input?.message || ''}
onChange={(e) => {
onChange({
...config,
workflow_input: { prompt: e.target.value },
});
}}
placeholder="Write what you want the workflow to do..."
rows={4}
className={errors.workflow_input ? 'border-destructive' : ''}
/>
{errors.workflow_input && (
<p className="text-xs text-destructive mt-1">{errors.workflow_input}</p>
)}
<p className="text-xs text-muted-foreground mt-1">
Simply describe what you want the workflow to accomplish. The workflow will interpret your instructions naturally.
</p>
</div>
</div>
) : (
<div>
<Label htmlFor="agent_prompt" className="text-sm font-medium">
Agent Prompt *
</Label>
<Textarea
id="agent_prompt"
value={config.agent_prompt || ''}
onChange={(e) => handleAgentPromptChange(e.target.value)}
placeholder="Enter the prompt that will be sent to your agent when triggered..."
rows={4}
className={errors.agent_prompt ? 'border-destructive' : ''}
/>
{errors.agent_prompt && (
<p className="text-xs text-destructive mt-1">{errors.agent_prompt}</p>
)}
<p className="text-xs text-muted-foreground mt-1">
This prompt will be sent to your agent each time the schedule triggers.
</p>
</div>
)}
</div>
</div>
</div>
<div className="space-y-6">
<div>
<h3 className="text-sm font-medium mb-4 flex items-center gap-2">
<Clock className="h-4 w-4" />
Schedule Configuration
</h3>
<div className="space-y-4">
<div>
<Label htmlFor="timezone" className="text-sm font-medium">
Timezone
<span className="text-xs text-muted-foreground ml-2">
(Auto-detected: {Intl.DateTimeFormat().resolvedOptions().timeZone})
</span>
</Label>
<Select value={config.timezone || 'UTC'} onValueChange={handleTimezoneChange}>
<SelectTrigger>
<SelectValue placeholder="Select timezone" />
</SelectTrigger>
<SelectContent>
{TIMEZONES.map((tz) => (
<SelectItem key={tz.value} value={tz.value}>
<div className="flex items-center justify-between w-full">
<span>{tz.label}</span>
<span className="text-xs text-muted-foreground ml-2">
{new Date().toLocaleTimeString('en-US', {
timeZone: tz.value,
hour12: false,
hour: '2-digit',
minute: '2-digit'
})}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{config.timezone && (
<p className="text-xs text-muted-foreground mt-1">
Current time: {new Date().toLocaleString('en-US', {
timeZone: config.timezone,
hour12: true,
weekday: 'short',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</p>
)}
</div>
<Tabs value={scheduleType} onValueChange={(value) => setScheduleType(value as ScheduleType)} className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="quick" className="flex items-center gap-1 px-2">
<Zap className="h-4 w-4" />
<span className="hidden sm:inline">Quick</span>
</TabsTrigger>
<TabsTrigger value="recurring" className="flex items-center gap-1 px-2">
<Repeat className="h-4 w-4" />
<span className="hidden sm:inline">Recurring</span>
</TabsTrigger>
<TabsTrigger value="one-time" className="flex items-center gap-1 px-2">
<CalendarIcon className="h-4 w-4" />
<span className="hidden sm:inline">One-time</span>
</TabsTrigger>
<TabsTrigger value="advanced" className="flex items-center gap-1 px-2">
<Target className="h-4 w-4" />
<span className="hidden sm:inline">Advanced</span>
</TabsTrigger>
</TabsList>
<TabsContent value="quick" className="space-y-4 mt-6">
<div className="space-y-4">
{Object.entries(groupedPresets).map(([category, presets]) => (
<div key={category}>
<h4 className="text-sm font-medium mb-3 capitalize">{category} Schedules</h4>
<div className="grid grid-cols-1 gap-2">
{presets.map((preset) => (
<Card
key={preset.cron}
className={cn(
"p-0 cursor-pointer transition-colors hover:bg-accent",
selectedPreset === preset.cron && "ring-2 ring-primary bg-accent"
)}
onClick={() => handlePresetSelect(preset)}
>
<CardContent className="p-3">
<div className="flex items-center gap-3">
<div className="text-primary">{preset.icon}</div>
<div className="flex-1">
<div className="font-medium text-sm">{preset.name}</div>
<div className="text-xs text-muted-foreground">{preset.description}</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
))}
</div>
</TabsContent>
<TabsContent value="recurring" className="space-y-6 mt-6">
<div className="space-y-4">
<div>
<Label className="text-sm font-medium mb-3 block">Schedule Type</Label>
<RadioGroup value={recurringType} onValueChange={(value) => setRecurringType(value as any)}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="daily" id="daily" />
<Label htmlFor="daily">Daily</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="weekly" id="weekly" />
<Label htmlFor="weekly">Weekly</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="monthly" id="monthly" />
<Label htmlFor="monthly">Monthly</Label>
</div>
</RadioGroup>
</div>
{recurringType === 'weekly' && (
<div>
<Label className="text-sm font-medium mb-3 block">Days of Week</Label>
<div className="flex flex-wrap gap-2">
{WEEKDAYS.map((day) => (
<Button
key={day.value}
variant={selectedWeekdays.includes(day.value) ? "default" : "outline"}
size="sm"
onClick={() => handleWeekdayToggle(day.value)}
>
{day.short}
</Button>
))}
</div>
</div>
)}
{recurringType === 'monthly' && (
<div className="space-y-4">
<div>
<Label className="text-sm font-medium mb-3 block">Day of Month</Label>
<Select value={dayOfMonth} onValueChange={setDayOfMonth}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 31 }, (_, i) => (
<SelectItem key={i + 1} value={(i + 1).toString()}>
{i + 1}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-sm font-medium mb-3 block">Months</Label>
<div className="space-y-2">
<Button
variant={selectedMonths.includes('*') ? "default" : "outline"}
size="sm"
onClick={() => handleMonthToggle('*')}
>
All Months
</Button>
<div className="grid grid-cols-3 gap-2">
{MONTHS.map((month) => (
<Button
key={month.value}
variant={selectedMonths.includes(month.value) ? "default" : "outline"}
size="sm"
onClick={() => handleMonthToggle(month.value)}
disabled={selectedMonths.includes('*')}
>
{month.label.slice(0, 3)}
</Button>
))}
</div>
</div>
</div>
</div>
)}
<div>
<Label className="text-sm font-medium mb-3 block">Time</Label>
<div className="flex gap-2 items-center">
<Select value={scheduleTime.hour} onValueChange={(value) => setScheduleTime(prev => ({ ...prev, hour: value }))}>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 24 }, (_, i) => (
<SelectItem key={i} value={i.toString().padStart(2, '0')}>
{i.toString().padStart(2, '0')}
</SelectItem>
))}
</SelectContent>
</Select>
<span>:</span>
<Select value={scheduleTime.minute} onValueChange={(value) => setScheduleTime(prev => ({ ...prev, minute: value }))}>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 60 }, (_, i) => (
<SelectItem key={i} value={i.toString().padStart(2, '0')}>
{i.toString().padStart(2, '0')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="one-time" className="space-y-6 mt-6">
<div className="space-y-4">
<div>
<Label className="text-sm font-medium mb-3 block">Date</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!selectedDate && "text-muted-foreground"
)}
>
<CalendarIcon className="h-4 w-4" />
{selectedDate ? format(selectedDate, "PPP") : "Pick a date"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={selectedDate}
onSelect={setSelectedDate}
disabled={(date) => date < startOfDay(new Date())}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
<div>
<Label className="text-sm font-medium mb-3 block">Time</Label>
<div className="flex gap-2 items-center">
<Select value={oneTimeTime.hour} onValueChange={(value) => setOneTimeTime(prev => ({ ...prev, hour: value }))}>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 24 }, (_, i) => (
<SelectItem key={i} value={i.toString().padStart(2, '0')}>
{i.toString().padStart(2, '0')}
</SelectItem>
))}
</SelectContent>
</Select>
<span>:</span>
<Select value={oneTimeTime.minute} onValueChange={(value) => setOneTimeTime(prev => ({ ...prev, minute: value }))}>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 60 }, (_, i) => (
<SelectItem key={i} value={i.toString().padStart(2, '0')}>
{i.toString().padStart(2, '0')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="advanced" className="space-y-4 mt-6">
<div>
<Label htmlFor="cron_expression" className="text-sm font-medium">
Cron Expression *
</Label>
<Input
id="cron_expression"
type="text"
value={config.cron_expression || ''}
onChange={(e) => onChange({ ...config, cron_expression: e.target.value })}
placeholder="0 9 * * 1-5"
className={errors.cron_expression ? 'border-destructive' : ''}
/>
{errors.cron_expression && (
<p className="text-xs text-destructive mt-1">{errors.cron_expression}</p>
)}
{config.cron_expression && !errors.cron_expression && (
<p className="text-xs text-green-600 mt-1">
{getSchedulePreview()}
</p>
)}
<Card className="mt-3 p-0 py-4">
<CardContent>
<div className="flex items-center gap-2 mb-2">
<Info className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Cron Format</span>
</div>
<div className="text-sm text-muted-foreground space-y-1">
<div>Format: <code className="bg-muted px-1 rounded text-xs">minute hour day month weekday</code></div>
<div>Example: <code className="bg-muted px-1 rounded text-xs">0 9 * * 1-5</code> = Weekdays at 9 AM</div>
<div>Use <code className="bg-muted px-1 rounded text-xs">*</code> for any value, <code className="bg-muted px-1 rounded text-xs">*/5</code> for every 5 units</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
))}
</div>
</TabsContent>
<TabsContent value="recurring" className="space-y-6 mt-6">
<div className="space-y-4">
<div>
<Label className="text-sm font-medium mb-3 block">Schedule Type</Label>
<RadioGroup value={recurringType} onValueChange={(value) => setRecurringType(value as any)}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="daily" id="daily" />
<Label htmlFor="daily">Daily</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="weekly" id="weekly" />
<Label htmlFor="weekly">Weekly</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="monthly" id="monthly" />
<Label htmlFor="monthly">Monthly</Label>
</div>
</RadioGroup>
</div>
{recurringType === 'weekly' && (
<div>
<Label className="text-sm font-medium mb-3 block">Days of Week</Label>
<div className="flex flex-wrap gap-2">
{WEEKDAYS.map((day) => (
<Button
key={day.value}
variant={selectedWeekdays.includes(day.value) ? "default" : "outline"}
size="sm"
onClick={() => handleWeekdayToggle(day.value)}
>
{day.short}
</Button>
))}
</div>
</div>
)}
{recurringType === 'monthly' && (
<div className="space-y-4">
<div>
<Label className="text-sm font-medium mb-3 block">Day of Month</Label>
<Select value={dayOfMonth} onValueChange={setDayOfMonth}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 31 }, (_, i) => (
<SelectItem key={i + 1} value={(i + 1).toString()}>
{i + 1}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-sm font-medium mb-3 block">Months</Label>
</div>
</TabsContent>
</Tabs>
{config.cron_expression && (
<div className="border rounded-lg p-4 bg-muted/30">
<h4 className="text-sm font-medium mb-2 flex items-center gap-2">
<Info className="h-4 w-4" />
Schedule Preview
</h4>
<div className="space-y-2">
<Button
variant={selectedMonths.includes('*') ? "default" : "outline"}
size="sm"
onClick={() => handleMonthToggle('*')}
>
All Months
</Button>
<div className="grid grid-cols-3 gap-2">
{MONTHS.map((month) => (
<Button
key={month.value}
variant={selectedMonths.includes(month.value) ? "default" : "outline"}
size="sm"
onClick={() => handleMonthToggle(month.value)}
disabled={selectedMonths.includes('*')}
>
{month.label.slice(0, 3)}
</Button>
))}
<div className="flex items-center gap-2">
<Clock className="h-3 w-3 text-muted-foreground" />
<span className="text-sm">{getSchedulePreview()}</span>
</div>
<div className="flex items-center gap-2">
<CalendarIcon className="h-3 w-3 text-muted-foreground" />
<span className="text-sm">{config.timezone || 'UTC'}</span>
</div>
<div className="flex items-center gap-2">
<Target className="h-3 w-3 text-muted-foreground" />
<span className="text-sm capitalize">{config.execution_type || 'agent'} execution</span>
</div>
</div>
</div>
</div>
)}
<div>
<Label className="text-sm font-medium mb-3 block">Time</Label>
<div className="flex gap-2 items-center">
<Select value={scheduleTime.hour} onValueChange={(value) => setScheduleTime(prev => ({ ...prev, hour: value }))}>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 24 }, (_, i) => (
<SelectItem key={i} value={i.toString().padStart(2, '0')}>
{i.toString().padStart(2, '0')}
</SelectItem>
))}
</SelectContent>
</Select>
<span>:</span>
<Select value={scheduleTime.minute} onValueChange={(value) => setScheduleTime(prev => ({ ...prev, minute: value }))}>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 60 }, (_, i) => (
<SelectItem key={i} value={i.toString().padStart(2, '0')}>
{i.toString().padStart(2, '0')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
</div>
</TabsContent>
<TabsContent value="one-time" className="space-y-6 mt-6">
<div className="space-y-4">
<div>
<Label className="text-sm font-medium mb-3 block">Date</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!selectedDate && "text-muted-foreground"
)}
>
<CalendarIcon className="h-4 w-4" />
{selectedDate ? format(selectedDate, "PPP") : "Pick a date"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={selectedDate}
onSelect={setSelectedDate}
disabled={(date) => date < startOfDay(new Date())}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
<div>
<Label className="text-sm font-medium mb-3 block">Time</Label>
<div className="flex gap-2 items-center">
<Select value={oneTimeTime.hour} onValueChange={(value) => setOneTimeTime(prev => ({ ...prev, hour: value }))}>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 24 }, (_, i) => (
<SelectItem key={i} value={i.toString().padStart(2, '0')}>
{i.toString().padStart(2, '0')}
</SelectItem>
))}
</SelectContent>
</Select>
<span>:</span>
<Select value={oneTimeTime.minute} onValueChange={(value) => setOneTimeTime(prev => ({ ...prev, minute: value }))}>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 60 }, (_, i) => (
<SelectItem key={i} value={i.toString().padStart(2, '0')}>
{i.toString().padStart(2, '0')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="advanced" className="space-y-4 mt-6">
<div>
<Label htmlFor="cron_expression" className="text-sm font-medium">
Cron Expression *
</Label>
<Input
id="cron_expression"
type="text"
value={config.cron_expression || ''}
onChange={(e) => onChange({ ...config, cron_expression: e.target.value })}
placeholder="0 9 * * 1-5"
className={errors.cron_expression ? 'border-destructive' : ''}
/>
{errors.cron_expression && (
<p className="text-xs text-destructive mt-1">{errors.cron_expression}</p>
)}
<Card className="mt-3 p-0 py-4">
<CardContent>
<div className="flex items-center gap-2 mb-2">
<Info className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Cron Format</span>
</div>
<div className="text-sm text-muted-foreground space-y-1">
<div>Format: <code className="bg-muted px-1 rounded text-xs">minute hour day month weekday</code></div>
<div>Example: <code className="bg-muted px-1 rounded text-xs">0 9 * * 1-5</code> = Weekdays at 9 AM</div>
<div>Use <code className="bg-muted px-1 rounded text-xs">*</code> for any value, <code className="bg-muted px-1 rounded text-xs">*/5</code> for every 5 units</div>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
<Card className="border-none bg-transparent shadow-none p-0">
<CardContent className="space-y-4 p-0">
<div>
<Label htmlFor="timezone" className="text-sm font-medium">
Timezone
</Label>
<Select value={config.timezone || 'UTC'} onValueChange={handleTimezoneChange}>
<SelectTrigger>
<SelectValue placeholder="Select timezone" />
</SelectTrigger>
<SelectContent>
{TIMEZONES.map((tz) => (
<SelectItem key={tz.value} value={tz.value}>
{tz.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-sm font-medium mb-3 block">
Execution Type *
</Label>
<RadioGroup value={config.execution_type || 'agent'} onValueChange={handleExecutionTypeChange}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="agent" id="execution-agent" />
<Label htmlFor="execution-agent">Execute Agent</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="workflow" id="execution-workflow" />
<Label htmlFor="execution-workflow">Execute Workflow</Label>
</div>
</RadioGroup>
<p className="text-xs text-muted-foreground mt-1">
Choose whether to execute the agent directly or run a specific workflow.
</p>
</div>
{config.execution_type === 'workflow' ? (
<div className="space-y-4">
<div>
<Label htmlFor="workflow_id" className="text-sm font-medium">
Workflow *
</Label>
<Select value={config.workflow_id || ''} onValueChange={handleWorkflowChange}>
<SelectTrigger className={errors.workflow_id ? 'border-destructive' : ''}>
<SelectValue placeholder="Select a workflow" />
</SelectTrigger>
<SelectContent>
{isLoadingWorkflows ? (
<SelectItem value="__loading__" disabled>Loading workflows...</SelectItem>
) : workflows.length === 0 ? (
<SelectItem value="__no_workflows__" disabled>No workflows available</SelectItem>
) : (
workflows.filter(w => w.status === 'active').map((workflow) => (
<SelectItem key={workflow.id} value={workflow.id}>
{workflow.name}
</SelectItem>
))
)}
</SelectContent>
</Select>
{errors.workflow_id && (
<p className="text-xs text-destructive mt-1">{errors.workflow_id}</p>
)}
<p className="text-xs text-muted-foreground mt-1">
Select the workflow to execute when triggered.
</p>
</div>
<div>
<Label htmlFor="workflow_input" className="text-sm font-medium">
Instructions for Workflow
</Label>
<Textarea
id="workflow_input"
value={config.workflow_input?.prompt || config.workflow_input?.message || ''}
onChange={(e) => {
onChange({
...config,
workflow_input: { prompt: e.target.value },
});
}}
placeholder="Write what you want the workflow to do..."
rows={3}
className={errors.workflow_input ? 'border-destructive' : ''}
/>
{errors.workflow_input && (
<p className="text-xs text-destructive mt-1">{errors.workflow_input}</p>
)}
<p className="text-xs text-muted-foreground mt-1">
Simply describe what you want the workflow to accomplish. The workflow will interpret your instructions naturally.
</p>
</div>
</div>
) : (
<div>
<Label htmlFor="agent_prompt" className="text-sm font-medium">
Agent Prompt *
</Label>
<Textarea
id="agent_prompt"
value={config.agent_prompt || ''}
onChange={(e) => handleAgentPromptChange(e.target.value)}
placeholder="Enter the prompt that will be sent to your agent when triggered..."
rows={4}
className={errors.agent_prompt ? 'border-destructive' : ''}
/>
{errors.agent_prompt && (
<p className="text-xs text-destructive mt-1">{errors.agent_prompt}</p>
)}
<p className="text-xs text-muted-foreground mt-1">
This prompt will be sent to your agent each time the schedule triggers.
</p>
</div>
)}
</div>
</CardContent>
</Card>
</div>

View File

@ -105,6 +105,12 @@ export const TriggerConfigDialog: React.FC<TriggerConfigDialogProps> = ({
onChange={setConfig}
errors={errors}
agentId={agentId}
name={name}
description={description}
onNameChange={setName}
onDescriptionChange={setDescription}
isActive={isActive}
onActiveChange={setIsActive}
/>
);
default:
@ -118,7 +124,7 @@ export const TriggerConfigDialog: React.FC<TriggerConfigDialogProps> = ({
};
return (
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center space-x-3">
<div className="p-2 rounded-lg bg-muted border">
@ -133,49 +139,55 @@ export const TriggerConfigDialog: React.FC<TriggerConfigDialogProps> = ({
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-6 scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="trigger-name">Name *</Label>
<Input
id="trigger-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter a name for this trigger"
className={errors.name ? 'border-destructive' : ''}
/>
{errors.name && (
<p className="text-sm text-destructive">{errors.name}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="trigger-description">Description</Label>
<Textarea
id="trigger-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional description for this trigger"
rows={2}
/>
</div>
<div className="flex items-center space-x-2">
<Switch
id="trigger-active"
checked={isActive}
onCheckedChange={setIsActive}
/>
<Label htmlFor="trigger-active">
Enable trigger immediately
</Label>
</div>
</div>
<div className="border-t pt-6">
<h3 className="text-sm font-medium mb-4">
{provider.name} Configuration
</h3>
{renderProviderSpecificConfig()}
</div>
{provider.provider_id === 'schedule' ? (
renderProviderSpecificConfig()
) : (
<>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="trigger-name">Name *</Label>
<Input
id="trigger-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter a name for this trigger"
className={errors.name ? 'border-destructive' : ''}
/>
{errors.name && (
<p className="text-sm text-destructive">{errors.name}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="trigger-description">Description</Label>
<Textarea
id="trigger-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional description for this trigger"
rows={2}
/>
</div>
<div className="flex items-center space-x-2">
<Switch
id="trigger-active"
checked={isActive}
onCheckedChange={setIsActive}
/>
<Label htmlFor="trigger-active">
Enable trigger immediately
</Label>
</div>
</div>
<div className="border-t pt-6">
<h3 className="text-sm font-medium mb-4">
{provider.name} Configuration
</h3>
{renderProviderSpecificConfig()}
</div>
</>
)}
{provider.webhook_enabled && existingConfig?.webhook_url && (
<div className="border-t pt-6">
<h3 className="text-sm font-medium mb-4">Webhook Information</h3>

View File

@ -1,7 +1,7 @@
export interface TriggerProvider {
provider_id: string;
name: string;
description: string;
description?: string;
trigger_type: string;
webhook_enabled: boolean;
config_schema: Record<string, any>;

View File

@ -0,0 +1,184 @@
"use client";
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator
} from '@/components/ui/dropdown-menu';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Clock, Calendar, ChevronDown, Activity, Zap, AlertCircle } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { useAgentUpcomingRuns, type UpcomingRun } from '@/hooks/react-query/agents/use-agent-upcoming-runs';
import { formatDistanceToNow, parseISO } from 'date-fns';
interface UpcomingRunsDropdownProps {
agentId: string;
}
interface RunItemProps {
run: UpcomingRun;
}
const RunItem: React.FC<RunItemProps> = ({ run }) => {
const nextRunTime = parseISO(run.next_run_time_local);
const timeUntilRun = formatDistanceToNow(nextRunTime, { addSuffix: true });
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuItem className="flex flex-col items-start p-3 cursor-pointer hover:bg-accent/80">
<div className="flex items-center justify-between w-full">
<div className="flex items-center space-x-2">
<div className="p-1 bg-primary/10 rounded-lg">
<Clock className="h-3 w-3 text-primary" />
</div>
<span className="font-medium text-sm truncate max-w-32">
{run.trigger_name}
</span>
</div>
<Badge variant="outline" className="text-xs">
{run.execution_type}
</Badge>
</div>
<div className="text-xs text-muted-foreground">
{timeUntilRun}
</div>
</DropdownMenuItem>
</TooltipTrigger>
<TooltipContent side="left" className="max-w-80 p-4">
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Activity className="h-4 w-4 text-primary" />
<span className="font-semibold">{run.trigger_name}</span>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center space-x-2">
<Clock className="h-3 w-3" />
<span>{run.human_readable}</span>
</div>
<div className="flex items-center space-x-2">
<Calendar className="h-3 w-3" />
<span>Next run: {nextRunTime.toLocaleString()}</span>
</div>
<div className="flex items-center space-x-2">
<Zap className="h-3 w-3" />
<span>Type: {run.execution_type}</span>
</div>
{run.agent_prompt && (
<div className="mt-2 p-2 bg-muted/50 rounded text-xs">
<strong>Prompt:</strong> {run.agent_prompt.substring(0, 100)}
{run.agent_prompt.length > 100 && '...'}
</div>
)}
{run.workflow_id && (
<div className="mt-2 p-2 bg-muted/50 rounded text-xs">
<strong>Workflow:</strong> {run.workflow_id}
</div>
)}
<div className="text-xs text-muted-foreground mt-2">
Timezone: {run.timezone}
</div>
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
export const UpcomingRunsDropdown: React.FC<UpcomingRunsDropdownProps> = ({ agentId }) => {
const { data: upcomingRuns, isLoading, error } = useAgentUpcomingRuns(agentId, 5);
const [isOpen, setIsOpen] = useState(false);
const hasRuns = upcomingRuns?.upcoming_runs?.length > 0;
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 px-3 text-muted-foreground hover:text-foreground"
>
<div className="flex items-center space-x-2">
<div className="relative">
<Clock className="h-4 w-4" />
{hasRuns && (
<div className="absolute -top-1 -right-1 h-2 w-2 bg-green-500 rounded-full animate-pulse" />
)}
</div>
<span className="text-sm">Upcoming</span>
<ChevronDown className="h-3 w-3" />
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-80">
<DropdownMenuLabel className="flex items-center space-x-2">
<Clock className="h-4 w-4" />
<span>Upcoming Runs</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{isLoading && (
<DropdownMenuItem disabled className="flex items-center justify-center py-4">
<div className="flex items-center space-x-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary" />
<span>Loading...</span>
</div>
</DropdownMenuItem>
)}
{error && (
<DropdownMenuItem disabled className="flex items-center justify-center py-4">
<div className="flex items-center space-x-2 text-destructive">
<AlertCircle className="h-4 w-4" />
<span>Failed to load runs</span>
</div>
</DropdownMenuItem>
)}
{!isLoading && !error && !hasRuns && (
<DropdownMenuItem disabled className="flex items-center justify-center py-4">
<div className="flex flex-col items-center text-muted-foreground">
<Clock className="h-6 w-6" />
<span className="text-sm">No upcoming runs</span>
<span className="text-xs text-center">
Create a schedule trigger to see upcoming runs
</span>
</div>
</DropdownMenuItem>
)}
{hasRuns && upcomingRuns.upcoming_runs.map((run) => (
<RunItem key={run.trigger_id} run={run} />
))}
{hasRuns && upcomingRuns.total_count > 5 && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-center text-sm text-muted-foreground">
Showing 5 of {upcomingRuns.total_count} upcoming runs
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@ -0,0 +1,37 @@
import { useQuery } from '@tanstack/react-query';
import { apiClient, backendApi } from '@/lib/api-client';
interface UpcomingRun {
trigger_id: string;
trigger_name: string;
trigger_type: string;
next_run_time: string;
next_run_time_local: string;
timezone: string;
cron_expression: string;
execution_type: string;
agent_prompt?: string;
workflow_id?: string;
is_active: boolean;
human_readable: string;
}
interface UpcomingRunsResponse {
upcoming_runs: UpcomingRun[];
total_count: number;
}
export const useAgentUpcomingRuns = (agentId: string, limit: number = 10) => {
return useQuery({
queryKey: ['agent-upcoming-runs', agentId, limit],
queryFn: async (): Promise<UpcomingRunsResponse> => {
const response = await backendApi.get(`/triggers/agents/${agentId}/upcoming-runs?limit=${limit}`);
return response.data;
},
enabled: !!agentId,
refetchInterval: 60000,
staleTime: 30000,
});
};
export type { UpcomingRun, UpcomingRunsResponse };

View File

@ -81,13 +81,13 @@ const updateTrigger = async (data: {
return response.json();
};
const deleteTrigger = async (triggerId: string): Promise<void> => {
const deleteTrigger = async (data: { triggerId: string; agentId: string }): Promise<void> => {
const supabase = createClient();
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
throw new Error('You must be logged in to create a trigger');
}
const response = await fetch(`${API_URL}/triggers/${triggerId}`, {
const response = await fetch(`${API_URL}/triggers/${data.triggerId}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${session.access_token}` },
});
@ -113,6 +113,7 @@ export const useCreateTrigger = () => {
return useMutation({
mutationFn: createTrigger,
onSuccess: (newTrigger) => {
queryClient.invalidateQueries({ queryKey: ['agent-upcoming-runs', newTrigger.agent_id] });
queryClient.setQueryData(
['agent-triggers', newTrigger.agent_id],
(old: TriggerConfiguration[] | undefined) => {
@ -129,6 +130,7 @@ export const useUpdateTrigger = () => {
return useMutation({
mutationFn: updateTrigger,
onSuccess: (updatedTrigger) => {
queryClient.invalidateQueries({ queryKey: ['agent-upcoming-runs', updatedTrigger.agent_id] });
queryClient.setQueryData(
['agent-triggers', updatedTrigger.agent_id],
(old: TriggerConfiguration[] | undefined) => {
@ -147,7 +149,8 @@ export const useDeleteTrigger = () => {
return useMutation({
mutationFn: deleteTrigger,
onSuccess: (_, triggerId) => {
onSuccess: (_, { triggerId, agentId }) => {
queryClient.invalidateQueries({ queryKey: ['agent-upcoming-runs', agentId] });
queryClient.invalidateQueries({ queryKey: ['agent-triggers'] });
},
});
@ -164,6 +167,7 @@ export const useToggleTrigger = () => {
});
},
onSuccess: (updatedTrigger) => {
queryClient.invalidateQueries({ queryKey: ['agent-upcoming-runs', updatedTrigger.agent_id] });
queryClient.setQueryData(
['agent-triggers', updatedTrigger.agent_id],
(old: TriggerConfiguration[] | undefined) => {