mirror of https://github.com/kortix-ai/suna.git
show upcoming run in agent page
This commit is contained in:
parent
df29b41a2f
commit
414eb23949
|
@ -12,7 +12,7 @@ class ExternalUserIdGeneratorService(ABC):
|
||||||
|
|
||||||
|
|
||||||
class MCPQualifiedNameService(ABC):
|
class MCPQualifiedNameService(ABC):
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def generate(self, app_slug: AppSlug) -> str:
|
def generate(self, app_slug: AppSlug) -> str:
|
||||||
pass
|
pass
|
||||||
|
|
|
@ -3,7 +3,9 @@ from fastapi.responses import JSONResponse
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
|
import croniter
|
||||||
|
import pytz
|
||||||
|
|
||||||
from .support.factory import TriggerModuleFactory
|
from .support.factory import TriggerModuleFactory
|
||||||
from .support.exceptions import TriggerError, ConfigurationError, ProviderError
|
from .support.exceptions import TriggerError, ConfigurationError, ProviderError
|
||||||
|
@ -53,6 +55,7 @@ class TriggerResponse(BaseModel):
|
||||||
webhook_url: Optional[str]
|
webhook_url: Optional[str]
|
||||||
created_at: str
|
created_at: str
|
||||||
updated_at: str
|
updated_at: str
|
||||||
|
config: Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
class ProviderResponse(BaseModel):
|
class ProviderResponse(BaseModel):
|
||||||
|
@ -65,10 +68,29 @@ class ProviderResponse(BaseModel):
|
||||||
config_schema: Dict[str, Any]
|
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):
|
def initialize(database: DBConnection):
|
||||||
global db, trigger_service, execution_service, provider_service
|
global db, trigger_service, execution_service, provider_service
|
||||||
db = database
|
db = database
|
||||||
# Initialize workflows API with DB connection
|
|
||||||
set_workflows_db_connection(database)
|
set_workflows_db_connection(database)
|
||||||
|
|
||||||
|
|
||||||
|
@ -170,7 +192,8 @@ async def get_agent_triggers(
|
||||||
is_active=trigger.is_active,
|
is_active=trigger.is_active,
|
||||||
webhook_url=webhook_url,
|
webhook_url=webhook_url,
|
||||||
created_at=trigger.metadata.created_at.isoformat(),
|
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
|
return responses
|
||||||
|
@ -179,6 +202,159 @@ async def get_agent_triggers(
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
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)
|
@router.post("/agents/{agent_id}/triggers", response_model=TriggerResponse)
|
||||||
async def create_agent_trigger(
|
async def create_agent_trigger(
|
||||||
agent_id: str,
|
agent_id: str,
|
||||||
|
@ -217,7 +393,8 @@ async def create_agent_trigger(
|
||||||
is_active=trigger.is_active,
|
is_active=trigger.is_active,
|
||||||
webhook_url=webhook_url,
|
webhook_url=webhook_url,
|
||||||
created_at=trigger.metadata.created_at.isoformat(),
|
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:
|
except (ValueError, ConfigurationError, ProviderError) as e:
|
||||||
|
@ -261,7 +438,8 @@ async def get_trigger(
|
||||||
is_active=trigger.is_active,
|
is_active=trigger.is_active,
|
||||||
webhook_url=webhook_url,
|
webhook_url=webhook_url,
|
||||||
created_at=trigger.metadata.created_at.isoformat(),
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error getting trigger: {e}")
|
logger.error(f"Error getting trigger: {e}")
|
||||||
|
@ -311,7 +489,8 @@ async def update_trigger(
|
||||||
is_active=updated_trigger.is_active,
|
is_active=updated_trigger.is_active,
|
||||||
webhook_url=webhook_url,
|
webhook_url=webhook_url,
|
||||||
created_at=updated_trigger.metadata.created_at.isoformat(),
|
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:
|
except (ValueError, ConfigurationError, ProviderError) as e:
|
||||||
|
@ -331,7 +510,6 @@ async def delete_trigger(
|
||||||
|
|
||||||
try:
|
try:
|
||||||
trigger_svc, _, _ = await get_services()
|
trigger_svc, _, _ = await get_services()
|
||||||
|
|
||||||
trigger = await trigger_svc.get_trigger(trigger_id)
|
trigger = await trigger_svc.get_trigger(trigger_id)
|
||||||
if not trigger:
|
if not trigger:
|
||||||
raise HTTPException(status_code=404, detail="Trigger not found")
|
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)
|
await verify_agent_access(trigger.agent_id, user_id)
|
||||||
|
|
||||||
success = await trigger_svc.delete_trigger(trigger_id)
|
success = await trigger_svc.delete_trigger(trigger_id)
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
raise HTTPException(status_code=404, detail="Trigger not found")
|
raise HTTPException(status_code=404, detail="Trigger not found")
|
||||||
|
|
||||||
|
@ -360,7 +537,6 @@ async def trigger_webhook(
|
||||||
|
|
||||||
try:
|
try:
|
||||||
trigger_svc, execution_svc, _ = await get_services()
|
trigger_svc, execution_svc, _ = await get_services()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw_data = await request.json()
|
raw_data = await request.json()
|
||||||
except:
|
except:
|
||||||
|
@ -368,9 +544,6 @@ async def trigger_webhook(
|
||||||
|
|
||||||
result = await trigger_svc.process_trigger_event(trigger_id, raw_data)
|
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:
|
if not result.success:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
|
@ -427,96 +600,3 @@ async def trigger_webhook(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
content={"success": False, "error": "Internal server error"}
|
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"}
|
|
||||||
)
|
|
||||||
|
|
|
@ -208,7 +208,7 @@ class TriggerManager:
|
||||||
name="Schedule",
|
name="Schedule",
|
||||||
description="Schedule agent or workflow execution using Cloudflare Workers and cron expressions",
|
description="Schedule agent or workflow execution using Cloudflare Workers and cron expressions",
|
||||||
trigger_type="schedule",
|
trigger_type="schedule",
|
||||||
provider_class="triggers.providers.schedule_provider.ScheduleTriggerProvider",
|
provider_class="triggers.infrastructure.providers.schedule_provider.ScheduleTriggerProvider",
|
||||||
webhook_enabled=True,
|
webhook_enabled=True,
|
||||||
config_schema={
|
config_schema={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|
|
@ -3,6 +3,7 @@ import json
|
||||||
import os
|
import os
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
|
import pytz
|
||||||
from qstash.client import QStash
|
from qstash.client import QStash
|
||||||
from utils.logger import logger
|
from utils.logger import logger
|
||||||
from ...domain.entities import TriggerProvider, TriggerEvent, TriggerResult, Trigger
|
from ...domain.entities import TriggerProvider, TriggerEvent, TriggerResult, Trigger
|
||||||
|
@ -41,6 +42,14 @@ class ScheduleTriggerProvider(TriggerProvider):
|
||||||
if 'workflow_id' not in config:
|
if 'workflow_id' not in config:
|
||||||
raise ValueError("workflow_id is required for workflow execution")
|
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:
|
try:
|
||||||
import croniter
|
import croniter
|
||||||
croniter.croniter(config['cron_expression'])
|
croniter.croniter(config['cron_expression'])
|
||||||
|
@ -51,6 +60,64 @@ class ScheduleTriggerProvider(TriggerProvider):
|
||||||
|
|
||||||
return config
|
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:
|
async def setup_trigger(self, trigger: Trigger) -> bool:
|
||||||
if not self._qstash:
|
if not self._qstash:
|
||||||
logger.error("QStash client not available")
|
logger.error("QStash client not available")
|
||||||
|
@ -58,31 +125,45 @@ class ScheduleTriggerProvider(TriggerProvider):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
webhook_url = f"{self._webhook_base_url}/api/triggers/{trigger.trigger_id}/webhook"
|
webhook_url = f"{self._webhook_base_url}/api/triggers/{trigger.trigger_id}/webhook"
|
||||||
|
|
||||||
cron_expression = trigger.config.config['cron_expression']
|
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 = {
|
payload = {
|
||||||
"trigger_id": trigger.trigger_id,
|
"trigger_id": trigger.trigger_id,
|
||||||
"agent_id": trigger.agent_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'),
|
"agent_prompt": trigger.config.config.get('agent_prompt'),
|
||||||
"workflow_id": trigger.config.config.get('workflow_id'),
|
"workflow_id": trigger.config.config.get('workflow_id'),
|
||||||
"workflow_input": trigger.config.config.get('workflow_input', {}),
|
"workflow_input": trigger.config.config.get('workflow_input', {}),
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat()
|
"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(
|
schedule_id = await asyncio.to_thread(
|
||||||
self._qstash.schedule.create,
|
self._qstash.schedule.create,
|
||||||
destination=webhook_url,
|
destination=webhook_url,
|
||||||
cron=cron_expression,
|
cron=cron_expression,
|
||||||
body=json.dumps(payload),
|
body=json.dumps(payload),
|
||||||
headers={
|
headers=headers,
|
||||||
"Content-Type": "application/json",
|
retries=3,
|
||||||
"X-Trigger-Source": "schedule"
|
delay="5s"
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
trigger.config.config['qstash_schedule_id'] = schedule_id
|
||||||
logger.info(f"Created QStash schedule {schedule_id} for trigger {trigger.trigger_id}")
|
logger.info(f"Created QStash schedule {schedule_id} for trigger {trigger.trigger_id} with cron: {cron_expression}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
@ -91,9 +172,20 @@ class ScheduleTriggerProvider(TriggerProvider):
|
||||||
|
|
||||||
async def teardown_trigger(self, trigger: Trigger) -> bool:
|
async def teardown_trigger(self, trigger: Trigger) -> bool:
|
||||||
if not self._qstash:
|
if not self._qstash:
|
||||||
|
logger.warning("QStash client not available, skipping teardown")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
try:
|
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)
|
schedules = await asyncio.to_thread(self._qstash.schedule.list)
|
||||||
|
|
||||||
webhook_url = f"{self._webhook_base_url}/api/triggers/{trigger.trigger_id}/webhook"
|
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:
|
if schedule.get('destination') == webhook_url:
|
||||||
await asyncio.to_thread(self._qstash.schedule.delete, schedule['scheduleId'])
|
await asyncio.to_thread(self._qstash.schedule.delete, schedule['scheduleId'])
|
||||||
logger.info(f"Deleted QStash schedule {schedule['scheduleId']} for trigger {trigger.trigger_id}")
|
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
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
@ -156,16 +249,30 @@ class ScheduleTriggerProvider(TriggerProvider):
|
||||||
|
|
||||||
async def health_check(self, trigger: Trigger) -> bool:
|
async def health_check(self, trigger: Trigger) -> bool:
|
||||||
if not self._qstash:
|
if not self._qstash:
|
||||||
|
logger.warning("QStash client not available for health check")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
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"
|
webhook_url = f"{self._webhook_base_url}/api/triggers/{trigger.trigger_id}/webhook"
|
||||||
schedules = await asyncio.to_thread(self._qstash.schedule.list)
|
schedules = await asyncio.to_thread(self._qstash.schedule.list)
|
||||||
|
|
||||||
for schedule in schedules:
|
for schedule in schedules:
|
||||||
if schedule.get('destination') == webhook_url:
|
if schedule.get('destination') == webhook_url:
|
||||||
|
logger.info(f"Health check for trigger {trigger.trigger_id}: healthy (found schedule)")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
logger.warning(f"Health check for trigger {trigger.trigger_id}: no schedule found")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
@ -179,20 +286,43 @@ class ScheduleTriggerProvider(TriggerProvider):
|
||||||
return await self.setup_trigger(trigger)
|
return await self.setup_trigger(trigger)
|
||||||
|
|
||||||
async def update_trigger(self, trigger: Trigger) -> bool:
|
async def update_trigger(self, trigger: Trigger) -> bool:
|
||||||
await self.teardown_trigger(trigger)
|
if not self._qstash:
|
||||||
if trigger.is_active:
|
logger.warning("QStash client not available for trigger update")
|
||||||
return await self.setup_trigger(trigger)
|
return True
|
||||||
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]:
|
def get_webhook_url(self, trigger_id: str, base_url: str) -> Optional[str]:
|
||||||
return f"{base_url}/api/triggers/{trigger_id}/webhook"
|
return f"{base_url}/api/triggers/{trigger_id}/webhook"
|
||||||
|
|
||||||
async def list_schedules(self) -> list:
|
async def list_schedules(self) -> list:
|
||||||
if not self._qstash:
|
if not self._qstash:
|
||||||
|
logger.warning("QStash client not available for listing schedules")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Failed to list QStash schedules: {e}")
|
logger.error(f"Failed to list QStash schedules: {e}")
|
||||||
return []
|
return []
|
|
@ -1,5 +0,0 @@
|
||||||
from .schedule_provider import ScheduleTriggerProvider
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
'ScheduleTriggerProvider'
|
|
||||||
]
|
|
|
@ -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 []
|
|
|
@ -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()
|
|
|
@ -19,6 +19,7 @@ import { useAgentVersionStore } from '../../../../../lib/stores/agent-version-st
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
import { AgentHeader, VersionAlert, AgentBuilderTab, ConfigurationTab } from '@/components/agents/config';
|
import { AgentHeader, VersionAlert, AgentBuilderTab, ConfigurationTab } from '@/components/agents/config';
|
||||||
|
import { UpcomingRunsDropdown } from '@/components/agents/upcoming-runs-dropdown';
|
||||||
|
|
||||||
interface FormData {
|
interface FormData {
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -268,6 +269,7 @@ export default function AgentConfigurationPageRefactored() {
|
||||||
setOriginalData(formData);
|
setOriginalData(formData);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<UpcomingRunsDropdown agentId={agentId} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{hasUnsavedChanges && !isViewingOldVersion && (
|
{hasUnsavedChanges && !isViewingOldVersion && (
|
||||||
|
@ -341,7 +343,101 @@ export default function AgentConfigurationPageRefactored() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:hidden flex flex-col h-full w-full">
|
<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>
|
</div>
|
||||||
|
|
||||||
<Drawer open={isPreviewOpen} onOpenChange={setIsPreviewOpen}>
|
<Drawer open={isPreviewOpen} onOpenChange={setIsPreviewOpen}>
|
||||||
|
|
|
@ -49,17 +49,7 @@ export const AgentToolsConfiguration = ({ tools, onToolsChange }: AgentToolsConf
|
||||||
{getSelectedToolsCount()} selected
|
{getSelectedToolsCount()} selected
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="gap-4 grid grid-cols-1 md:grid-cols-2">
|
<div className="gap-4 grid grid-cols-1 md:grid-cols-2">
|
||||||
{getFilteredTools().map(([toolName, toolInfo]) => (
|
{getFilteredTools().map(([toolName, toolInfo]) => (
|
||||||
|
|
|
@ -58,6 +58,7 @@ export function AgentHeader({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs value={activeTab} onValueChange={onTabChange}>
|
<Tabs value={activeTab} onValueChange={onTabChange}>
|
||||||
<TabsList className="grid grid-cols-2 bg-muted/50 h-9">
|
<TabsList className="grid grid-cols-2 bg-muted/50 h-9">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Zap } from 'lucide-react';
|
||||||
import { Zap, MessageSquare, Webhook, Plus, Settings } from 'lucide-react';
|
|
||||||
import { Dialog } from '@/components/ui/dialog';
|
import { Dialog } from '@/components/ui/dialog';
|
||||||
import { ConfiguredTriggersList } from './configured-triggers-list';
|
import { ConfiguredTriggersList } from './configured-triggers-list';
|
||||||
import { TriggerConfigDialog } from './trigger-config-dialog';
|
import { TriggerConfigDialog } from './trigger-config-dialog';
|
||||||
|
@ -30,6 +29,7 @@ export const AgentTriggersConfiguration: React.FC<AgentTriggersConfigurationProp
|
||||||
const [editingTrigger, setEditingTrigger] = useState<TriggerConfiguration | null>(null);
|
const [editingTrigger, setEditingTrigger] = useState<TriggerConfiguration | null>(null);
|
||||||
|
|
||||||
const { data: triggers = [], isLoading, error } = useAgentTriggers(agentId);
|
const { data: triggers = [], isLoading, error } = useAgentTriggers(agentId);
|
||||||
|
const { data: providers = [] } = useTriggerProviders();
|
||||||
const createTriggerMutation = useCreateTrigger();
|
const createTriggerMutation = useCreateTrigger();
|
||||||
const updateTriggerMutation = useUpdateTrigger();
|
const updateTriggerMutation = useUpdateTrigger();
|
||||||
const deleteTriggerMutation = useDeleteTrigger();
|
const deleteTriggerMutation = useDeleteTrigger();
|
||||||
|
@ -37,19 +37,28 @@ export const AgentTriggersConfiguration: React.FC<AgentTriggersConfigurationProp
|
||||||
|
|
||||||
const handleEditTrigger = (trigger: TriggerConfiguration) => {
|
const handleEditTrigger = (trigger: TriggerConfiguration) => {
|
||||||
setEditingTrigger(trigger);
|
setEditingTrigger(trigger);
|
||||||
setConfiguringProvider({
|
|
||||||
provider_id: trigger.provider_id,
|
const provider = providers.find(p => p.provider_id === trigger.provider_id);
|
||||||
name: trigger.trigger_type,
|
if (provider) {
|
||||||
description: '',
|
setConfiguringProvider(provider);
|
||||||
trigger_type: trigger.trigger_type,
|
} else {
|
||||||
webhook_enabled: !!trigger.webhook_url,
|
setConfiguringProvider({
|
||||||
config_schema: {}
|
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) => {
|
const handleRemoveTrigger = async (trigger: TriggerConfiguration) => {
|
||||||
try {
|
try {
|
||||||
await deleteTriggerMutation.mutateAsync(trigger.trigger_id);
|
await deleteTriggerMutation.mutateAsync({
|
||||||
|
triggerId: trigger.trigger_id,
|
||||||
|
agentId: trigger.agent_id
|
||||||
|
});
|
||||||
toast.success('Trigger deleted successfully');
|
toast.success('Trigger deleted successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to delete trigger');
|
toast.error('Failed to delete trigger');
|
||||||
|
@ -123,23 +132,13 @@ export const AgentTriggersConfiguration: React.FC<AgentTriggersConfigurationProp
|
||||||
<OneClickIntegrations agentId={agentId} />
|
<OneClickIntegrations agentId={agentId} />
|
||||||
|
|
||||||
{triggers.length > 0 && (
|
{triggers.length > 0 && (
|
||||||
<div className="bg-card rounded-xl border border-border overflow-hidden">
|
<ConfiguredTriggersList
|
||||||
<div className="px-6 py-4 border-b border-border bg-muted/30">
|
triggers={triggers}
|
||||||
<h4 className="text-sm font-medium text-foreground flex items-center gap-2">
|
onEdit={handleEditTrigger}
|
||||||
<Settings className="h-4 w-4" />
|
onRemove={handleRemoveTrigger}
|
||||||
Configured Triggers
|
onToggle={handleToggleTrigger}
|
||||||
</h4>
|
isLoading={deleteTriggerMutation.isPending || toggleTriggerMutation.isPending}
|
||||||
</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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && triggers.length === 0 && (
|
{!isLoading && triggers.length === 0 && (
|
||||||
|
|
|
@ -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> = ({
|
export const ConfiguredTriggersList: React.FC<ConfiguredTriggersListProps> = ({
|
||||||
triggers,
|
triggers,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
@ -76,7 +96,16 @@ export const ConfiguredTriggersList: React.FC<ConfiguredTriggersListProps> = ({
|
||||||
{truncateString(trigger.description, 50)}
|
{truncateString(trigger.description, 50)}
|
||||||
</p>
|
</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 && (
|
{trigger.webhook_url && (
|
||||||
<div className="flex items-center space-x-2 mt-2">
|
<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">
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|
|
@ -66,7 +66,10 @@ export const OneClickIntegrations: React.FC<OneClickIntegrationsProps> = ({
|
||||||
const handleUninstall = async (provider: ProviderKey, triggerId?: string) => {
|
const handleUninstall = async (provider: ProviderKey, triggerId?: string) => {
|
||||||
if (provider === 'schedule' && triggerId) {
|
if (provider === 'schedule' && triggerId) {
|
||||||
try {
|
try {
|
||||||
await deleteTriggerMutation.mutateAsync(triggerId);
|
await deleteTriggerMutation.mutateAsync({
|
||||||
|
triggerId,
|
||||||
|
agentId
|
||||||
|
});
|
||||||
toast.success('Schedule trigger removed successfully');
|
toast.success('Schedule trigger removed successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to remove schedule trigger');
|
toast.error('Failed to remove schedule trigger');
|
||||||
|
@ -120,7 +123,6 @@ export const OneClickIntegrations: React.FC<OneClickIntegrationsProps> = ({
|
||||||
const scheduleProvider: TriggerProvider = {
|
const scheduleProvider: TriggerProvider = {
|
||||||
provider_id: 'schedule',
|
provider_id: 'schedule',
|
||||||
name: 'Schedule',
|
name: 'Schedule',
|
||||||
description: 'Schedule agent execution using cron expressions',
|
|
||||||
trigger_type: 'schedule',
|
trigger_type: 'schedule',
|
||||||
webhook_enabled: true,
|
webhook_enabled: true,
|
||||||
config_schema: {}
|
config_schema: {}
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { Card, CardContent, CardDescription, CardHeader } from '@/components/ui/
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
import { Calendar } from '@/components/ui/calendar';
|
import { Calendar } from '@/components/ui/calendar';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
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 { Clock, Calendar as CalendarIcon, Info, Zap, Repeat, Timer, Target } from 'lucide-react';
|
||||||
import { format, startOfDay } from 'date-fns';
|
import { format, startOfDay } from 'date-fns';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
@ -23,6 +24,12 @@ interface ScheduleTriggerConfigFormProps {
|
||||||
onChange: (config: ScheduleTriggerConfig) => void;
|
onChange: (config: ScheduleTriggerConfig) => void;
|
||||||
errors: Record<string, string>;
|
errors: Record<string, string>;
|
||||||
agentId: 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';
|
type ScheduleType = 'quick' | 'recurring' | 'advanced' | 'one-time';
|
||||||
|
@ -102,6 +109,12 @@ export const ScheduleTriggerConfigForm: React.FC<ScheduleTriggerConfigFormProps>
|
||||||
onChange,
|
onChange,
|
||||||
errors,
|
errors,
|
||||||
agentId,
|
agentId,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
onNameChange,
|
||||||
|
onDescriptionChange,
|
||||||
|
isActive,
|
||||||
|
onActiveChange,
|
||||||
}) => {
|
}) => {
|
||||||
const { data: workflows = [], isLoading: isLoadingWorkflows } = useAgentWorkflows(agentId);
|
const { data: workflows = [], isLoading: isLoadingWorkflows } = useAgentWorkflows(agentId);
|
||||||
const [scheduleType, setScheduleType] = useState<ScheduleType>('quick');
|
const [scheduleType, setScheduleType] = useState<ScheduleType>('quick');
|
||||||
|
@ -116,7 +129,34 @@ export const ScheduleTriggerConfigForm: React.FC<ScheduleTriggerConfigFormProps>
|
||||||
const [selectedDate, setSelectedDate] = useState<Date>();
|
const [selectedDate, setSelectedDate] = useState<Date>();
|
||||||
const [oneTimeTime, setOneTimeTime] = useState<{ hour: string; minute: string }>({ hour: '09', minute: '00' });
|
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 = () => {
|
const generateCronExpression = () => {
|
||||||
if (scheduleType === 'quick' && selectedPreset) {
|
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 handleExecutionTypeChange = (value: 'agent' | 'workflow') => {
|
||||||
const newConfig = {
|
const newConfig = {
|
||||||
...config,
|
...config,
|
||||||
|
@ -206,8 +269,6 @@ export const ScheduleTriggerConfigForm: React.FC<ScheduleTriggerConfigFormProps>
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleWeekdayToggle = (weekday: string) => {
|
const handleWeekdayToggle = (weekday: string) => {
|
||||||
setSelectedWeekdays(prev =>
|
setSelectedWeekdays(prev =>
|
||||||
prev.includes(weekday)
|
prev.includes(weekday)
|
||||||
|
@ -238,392 +299,508 @@ export const ScheduleTriggerConfigForm: React.FC<ScheduleTriggerConfigFormProps>
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card className="border-none bg-transparent shadow-none p-0">
|
<Card className="border-none bg-transparent shadow-none p-0">
|
||||||
<CardHeader className='p-0 -mt-2'>
|
<CardHeader className='p-0'>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Configure when your agent should be triggered automatically. Choose from quick presets, recurring schedules, or set up advanced cron expressions.
|
Configure when your agent should be triggered automatically. Choose from quick presets, recurring schedules, or set up advanced cron expressions.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0 pt-4">
|
||||||
<Tabs value={scheduleType} onValueChange={(value) => setScheduleType(value as ScheduleType)} className="w-full">
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-8">
|
||||||
<TabsList className="grid w-full grid-cols-4">
|
<div className="space-y-6">
|
||||||
<TabsTrigger value="quick" className="flex items-center gap-2">
|
<div>
|
||||||
<Zap className="h-4 w-4" />
|
<h3 className="text-sm font-medium mb-4 flex items-center gap-2">
|
||||||
Quick
|
<Target className="h-4 w-4" />
|
||||||
</TabsTrigger>
|
Trigger Details
|
||||||
<TabsTrigger value="recurring" className="flex items-center gap-2">
|
</h3>
|
||||||
<Repeat className="h-4 w-4" />
|
<div className="space-y-4">
|
||||||
Recurring
|
<div className="space-y-2">
|
||||||
</TabsTrigger>
|
<Label htmlFor="trigger-name">Name *</Label>
|
||||||
<TabsTrigger value="one-time" className="flex items-center gap-2">
|
<Input
|
||||||
<CalendarIcon className="h-4 w-4" />
|
id="trigger-name"
|
||||||
One-time
|
value={name}
|
||||||
</TabsTrigger>
|
onChange={(e) => onNameChange(e.target.value)}
|
||||||
<TabsTrigger value="advanced" className="flex items-center gap-2">
|
placeholder="Enter a name for this trigger"
|
||||||
<Target className="h-4 w-4" />
|
className={errors.name ? 'border-destructive' : ''}
|
||||||
Advanced
|
/>
|
||||||
</TabsTrigger>
|
{errors.name && (
|
||||||
</TabsList>
|
<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>
|
||||||
<div className="space-y-4">
|
<h3 className="text-sm font-medium mb-4 flex items-center gap-2">
|
||||||
{Object.entries(groupedPresets).map(([category, presets]) => (
|
<Zap className="h-4 w-4" />
|
||||||
<div key={category}>
|
Execution Configuration
|
||||||
<h4 className="text-sm font-medium mb-3 capitalize">{category} Schedules</h4>
|
</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="space-y-4">
|
||||||
{presets.map((preset) => (
|
<div>
|
||||||
<Card
|
<Label className="text-sm font-medium mb-3 block">
|
||||||
key={preset.cron}
|
Execution Type *
|
||||||
className={cn(
|
</Label>
|
||||||
"p-0 cursor-pointer transition-colors hover:bg-accent",
|
<RadioGroup value={config.execution_type || 'agent'} onValueChange={handleExecutionTypeChange}>
|
||||||
selectedPreset === preset.cron && "ring-2 ring-primary bg-accent"
|
<div className="flex items-center space-x-2">
|
||||||
)}
|
<RadioGroupItem value="agent" id="execution-agent" />
|
||||||
onClick={() => handlePresetSelect(preset)}
|
<Label htmlFor="execution-agent">Execute Agent</Label>
|
||||||
>
|
</div>
|
||||||
<CardContent className="p-4">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="flex items-center gap-3">
|
<RadioGroupItem value="workflow" id="execution-workflow" />
|
||||||
<div className="text-primary">{preset.icon}</div>
|
<Label htmlFor="execution-workflow">Execute Workflow</Label>
|
||||||
<div className="flex-1">
|
</div>
|
||||||
<div className="font-medium text-sm">{preset.name}</div>
|
</RadioGroup>
|
||||||
<div className="text-xs text-muted-foreground">{preset.description}</div>
|
<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>
|
||||||
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
</div>
|
||||||
</div>
|
</TabsContent>
|
||||||
</div>
|
</Tabs>
|
||||||
))}
|
{config.cron_expression && (
|
||||||
</div>
|
<div className="border rounded-lg p-4 bg-muted/30">
|
||||||
</TabsContent>
|
<h4 className="text-sm font-medium mb-2 flex items-center gap-2">
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
<TabsContent value="recurring" className="space-y-6 mt-6">
|
Schedule Preview
|
||||||
<div className="space-y-4">
|
</h4>
|
||||||
<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">
|
<div className="space-y-2">
|
||||||
<Button
|
<div className="flex items-center gap-2">
|
||||||
variant={selectedMonths.includes('*') ? "default" : "outline"}
|
<Clock className="h-3 w-3 text-muted-foreground" />
|
||||||
size="sm"
|
<span className="text-sm">{getSchedulePreview()}</span>
|
||||||
onClick={() => handleMonthToggle('*')}
|
</div>
|
||||||
>
|
<div className="flex items-center gap-2">
|
||||||
All Months
|
<CalendarIcon className="h-3 w-3 text-muted-foreground" />
|
||||||
</Button>
|
<span className="text-sm">{config.timezone || 'UTC'}</span>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
</div>
|
||||||
{MONTHS.map((month) => (
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Target className="h-3 w-3 text-muted-foreground" />
|
||||||
key={month.value}
|
<span className="text-sm capitalize">{config.execution_type || 'agent'} execution</span>
|
||||||
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>
|
</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>
|
||||||
</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>
|
||||||
) : (
|
</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>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -105,6 +105,12 @@ export const TriggerConfigDialog: React.FC<TriggerConfigDialogProps> = ({
|
||||||
onChange={setConfig}
|
onChange={setConfig}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
|
name={name}
|
||||||
|
description={description}
|
||||||
|
onNameChange={setName}
|
||||||
|
onDescriptionChange={setDescription}
|
||||||
|
isActive={isActive}
|
||||||
|
onActiveChange={setIsActive}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
|
@ -118,7 +124,7 @@ export const TriggerConfigDialog: React.FC<TriggerConfigDialogProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center space-x-3">
|
<DialogTitle className="flex items-center space-x-3">
|
||||||
<div className="p-2 rounded-lg bg-muted border">
|
<div className="p-2 rounded-lg bg-muted border">
|
||||||
|
@ -133,49 +139,55 @@ export const TriggerConfigDialog: React.FC<TriggerConfigDialogProps> = ({
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex-1 overflow-y-auto space-y-6 scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100">
|
<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">
|
{provider.provider_id === 'schedule' ? (
|
||||||
<div className="space-y-2">
|
renderProviderSpecificConfig()
|
||||||
<Label htmlFor="trigger-name">Name *</Label>
|
) : (
|
||||||
<Input
|
<>
|
||||||
id="trigger-name"
|
<div className="space-y-4">
|
||||||
value={name}
|
<div className="space-y-2">
|
||||||
onChange={(e) => setName(e.target.value)}
|
<Label htmlFor="trigger-name">Name *</Label>
|
||||||
placeholder="Enter a name for this trigger"
|
<Input
|
||||||
className={errors.name ? 'border-destructive' : ''}
|
id="trigger-name"
|
||||||
/>
|
value={name}
|
||||||
{errors.name && (
|
onChange={(e) => setName(e.target.value)}
|
||||||
<p className="text-sm text-destructive">{errors.name}</p>
|
placeholder="Enter a name for this trigger"
|
||||||
)}
|
className={errors.name ? 'border-destructive' : ''}
|
||||||
</div>
|
/>
|
||||||
|
{errors.name && (
|
||||||
<div className="space-y-2">
|
<p className="text-sm text-destructive">{errors.name}</p>
|
||||||
<Label htmlFor="trigger-description">Description</Label>
|
)}
|
||||||
<Textarea
|
</div>
|
||||||
id="trigger-description"
|
|
||||||
value={description}
|
<div className="space-y-2">
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
<Label htmlFor="trigger-description">Description</Label>
|
||||||
placeholder="Optional description for this trigger"
|
<Textarea
|
||||||
rows={2}
|
id="trigger-description"
|
||||||
/>
|
value={description}
|
||||||
</div>
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Optional description for this trigger"
|
||||||
<div className="flex items-center space-x-2">
|
rows={2}
|
||||||
<Switch
|
/>
|
||||||
id="trigger-active"
|
</div>
|
||||||
checked={isActive}
|
|
||||||
onCheckedChange={setIsActive}
|
<div className="flex items-center space-x-2">
|
||||||
/>
|
<Switch
|
||||||
<Label htmlFor="trigger-active">
|
id="trigger-active"
|
||||||
Enable trigger immediately
|
checked={isActive}
|
||||||
</Label>
|
onCheckedChange={setIsActive}
|
||||||
</div>
|
/>
|
||||||
</div>
|
<Label htmlFor="trigger-active">
|
||||||
<div className="border-t pt-6">
|
Enable trigger immediately
|
||||||
<h3 className="text-sm font-medium mb-4">
|
</Label>
|
||||||
{provider.name} Configuration
|
</div>
|
||||||
</h3>
|
</div>
|
||||||
{renderProviderSpecificConfig()}
|
<div className="border-t pt-6">
|
||||||
</div>
|
<h3 className="text-sm font-medium mb-4">
|
||||||
|
{provider.name} Configuration
|
||||||
|
</h3>
|
||||||
|
{renderProviderSpecificConfig()}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{provider.webhook_enabled && existingConfig?.webhook_url && (
|
{provider.webhook_enabled && existingConfig?.webhook_url && (
|
||||||
<div className="border-t pt-6">
|
<div className="border-t pt-6">
|
||||||
<h3 className="text-sm font-medium mb-4">Webhook Information</h3>
|
<h3 className="text-sm font-medium mb-4">Webhook Information</h3>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
export interface TriggerProvider {
|
export interface TriggerProvider {
|
||||||
provider_id: string;
|
provider_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description?: string;
|
||||||
trigger_type: string;
|
trigger_type: string;
|
||||||
webhook_enabled: boolean;
|
webhook_enabled: boolean;
|
||||||
config_schema: Record<string, any>;
|
config_schema: Record<string, any>;
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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 };
|
|
@ -81,13 +81,13 @@ const updateTrigger = async (data: {
|
||||||
return response.json();
|
return response.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteTrigger = async (triggerId: string): Promise<void> => {
|
const deleteTrigger = async (data: { triggerId: string; agentId: string }): Promise<void> => {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error('You must be logged in to create a trigger');
|
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',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${session.access_token}` },
|
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${session.access_token}` },
|
||||||
});
|
});
|
||||||
|
@ -113,6 +113,7 @@ export const useCreateTrigger = () => {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: createTrigger,
|
mutationFn: createTrigger,
|
||||||
onSuccess: (newTrigger) => {
|
onSuccess: (newTrigger) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['agent-upcoming-runs', newTrigger.agent_id] });
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
['agent-triggers', newTrigger.agent_id],
|
['agent-triggers', newTrigger.agent_id],
|
||||||
(old: TriggerConfiguration[] | undefined) => {
|
(old: TriggerConfiguration[] | undefined) => {
|
||||||
|
@ -129,6 +130,7 @@ export const useUpdateTrigger = () => {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: updateTrigger,
|
mutationFn: updateTrigger,
|
||||||
onSuccess: (updatedTrigger) => {
|
onSuccess: (updatedTrigger) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['agent-upcoming-runs', updatedTrigger.agent_id] });
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
['agent-triggers', updatedTrigger.agent_id],
|
['agent-triggers', updatedTrigger.agent_id],
|
||||||
(old: TriggerConfiguration[] | undefined) => {
|
(old: TriggerConfiguration[] | undefined) => {
|
||||||
|
@ -147,7 +149,8 @@ export const useDeleteTrigger = () => {
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: deleteTrigger,
|
mutationFn: deleteTrigger,
|
||||||
onSuccess: (_, triggerId) => {
|
onSuccess: (_, { triggerId, agentId }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['agent-upcoming-runs', agentId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['agent-triggers'] });
|
queryClient.invalidateQueries({ queryKey: ['agent-triggers'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -164,6 +167,7 @@ export const useToggleTrigger = () => {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: (updatedTrigger) => {
|
onSuccess: (updatedTrigger) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['agent-upcoming-runs', updatedTrigger.agent_id] });
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
['agent-triggers', updatedTrigger.agent_id],
|
['agent-triggers', updatedTrigger.agent_id],
|
||||||
(old: TriggerConfiguration[] | undefined) => {
|
(old: TriggerConfiguration[] | undefined) => {
|
||||||
|
|
Loading…
Reference in New Issue