langchain-trigger-server 0.3.22__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- langchain_trigger_server-0.3.22.dist-info/METADATA +38 -0
- langchain_trigger_server-0.3.22.dist-info/RECORD +14 -0
- langchain_trigger_server-0.3.22.dist-info/WHEEL +4 -0
- langchain_triggers/__init__.py +22 -0
- langchain_triggers/app.py +676 -0
- langchain_triggers/auth/__init__.py +3 -0
- langchain_triggers/core.py +59 -0
- langchain_triggers/cron_manager.py +329 -0
- langchain_triggers/database/__init__.py +5 -0
- langchain_triggers/database/interface.py +107 -0
- langchain_triggers/decorators.py +141 -0
- langchain_triggers/triggers/__init__.py +7 -0
- langchain_triggers/triggers/cron_trigger.py +177 -0
- langchain_triggers/util.py +122 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Core types and interfaces for the triggers framework."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TriggerType(str, Enum):
|
|
15
|
+
"""Type of trigger supported by the framework."""
|
|
16
|
+
|
|
17
|
+
WEBHOOK = "webhook"
|
|
18
|
+
POLLING = "polling"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TriggerRegistrationResult(BaseModel):
|
|
22
|
+
"""Result returned by registration handlers."""
|
|
23
|
+
|
|
24
|
+
create_registration: bool = Field(
|
|
25
|
+
default=True,
|
|
26
|
+
description="Whether to create database registration (False = return custom response)",
|
|
27
|
+
)
|
|
28
|
+
metadata: dict[str, Any] = Field(
|
|
29
|
+
default_factory=dict, description="Metadata to store with the registration"
|
|
30
|
+
)
|
|
31
|
+
resource: dict[str, Any] = Field(
|
|
32
|
+
default_factory=dict,
|
|
33
|
+
description="Resource data to merge with user-provided registration data (backend-controlled fields)",
|
|
34
|
+
)
|
|
35
|
+
response_body: dict[str, Any] | None = Field(
|
|
36
|
+
default=None,
|
|
37
|
+
description="Custom HTTP response body (when create_registration=False)",
|
|
38
|
+
)
|
|
39
|
+
status_code: int | None = Field(
|
|
40
|
+
default=None, description="HTTP status code (when create_registration=False)"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def model_post_init(self, __context) -> None:
|
|
44
|
+
"""Validate that required fields are provided based on create_registration."""
|
|
45
|
+
if self.create_registration and not self.metadata:
|
|
46
|
+
self.metadata = {} # Allow empty metadata for create_registration=True
|
|
47
|
+
|
|
48
|
+
if not self.create_registration and (
|
|
49
|
+
not self.response_body or not self.status_code
|
|
50
|
+
):
|
|
51
|
+
raise ValueError(
|
|
52
|
+
"Both response_body and status_code are required when create_registration=False"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TriggerRegistrationModel(BaseModel):
|
|
57
|
+
"""Base class for trigger resource models that define how webhooks are matched to registrations."""
|
|
58
|
+
|
|
59
|
+
pass
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Dynamic Cron Trigger Manager for scheduled agent execution."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
8
|
+
from apscheduler.triggers.cron import CronTrigger as APSCronTrigger
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from langchain_triggers.core import TriggerType
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CronJobExecution(BaseModel):
|
|
17
|
+
"""Model for tracking cron job execution history."""
|
|
18
|
+
|
|
19
|
+
registration_id: str
|
|
20
|
+
cron_pattern: str
|
|
21
|
+
scheduled_time: datetime
|
|
22
|
+
actual_start_time: datetime
|
|
23
|
+
completion_time: datetime | None = None
|
|
24
|
+
status: str # "running", "completed", "failed"
|
|
25
|
+
error_message: str | None = None
|
|
26
|
+
agents_invoked: int = 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CronTriggerManager:
|
|
30
|
+
"""Manages dynamic cron job scheduling based on database registrations."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, trigger_server):
|
|
33
|
+
self.scheduler = AsyncIOScheduler(timezone="UTC")
|
|
34
|
+
self.trigger_server = trigger_server
|
|
35
|
+
self.active_jobs = {} # registration_id -> job_id mapping
|
|
36
|
+
self.execution_history = [] # Keep recent execution history
|
|
37
|
+
self.max_history = 1000
|
|
38
|
+
|
|
39
|
+
def _is_polling(self, trigger) -> bool:
|
|
40
|
+
ttype = getattr(trigger, "trigger_type", None)
|
|
41
|
+
val = getattr(ttype, "value", ttype)
|
|
42
|
+
try:
|
|
43
|
+
return str(val).lower() == TriggerType.POLLING.value
|
|
44
|
+
except Exception:
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
async def start(self):
|
|
48
|
+
"""Start scheduler and load existing cron registrations."""
|
|
49
|
+
try:
|
|
50
|
+
self.scheduler.start()
|
|
51
|
+
logger.info("polling_manager_started timezone=UTC")
|
|
52
|
+
await self._load_existing_registrations()
|
|
53
|
+
except Exception as e:
|
|
54
|
+
logger.error(f"Failed to start CronTriggerManager: {e}")
|
|
55
|
+
raise
|
|
56
|
+
|
|
57
|
+
async def shutdown(self):
|
|
58
|
+
"""Shutdown scheduler gracefully."""
|
|
59
|
+
try:
|
|
60
|
+
self.scheduler.shutdown(wait=True)
|
|
61
|
+
except Exception as e:
|
|
62
|
+
logger.error(f"Error shutting down CronTriggerManager: {e}")
|
|
63
|
+
|
|
64
|
+
async def _load_existing_registrations(self):
|
|
65
|
+
"""Load all existing polling registrations from database and schedule them.
|
|
66
|
+
|
|
67
|
+
Discovers polling-capable triggers dynamically from registered templates.
|
|
68
|
+
"""
|
|
69
|
+
try:
|
|
70
|
+
scheduled_total = 0
|
|
71
|
+
polling_templates = [
|
|
72
|
+
t for t in self.trigger_server.triggers if self._is_polling(t)
|
|
73
|
+
]
|
|
74
|
+
ids_csv = ",".join([t.id for t in polling_templates]) or ""
|
|
75
|
+
logger.info(
|
|
76
|
+
f"polling_templates_loaded count={len(polling_templates)} ids={ids_csv}"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
for template in polling_templates:
|
|
80
|
+
template_id = template.id
|
|
81
|
+
logger.info(
|
|
82
|
+
"polling_template "
|
|
83
|
+
f"template_id={template_id} display_name={getattr(template, 'display_name', '')}"
|
|
84
|
+
)
|
|
85
|
+
try:
|
|
86
|
+
registrations = (
|
|
87
|
+
await self.trigger_server.database.get_all_registrations(
|
|
88
|
+
template_id
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
logger.info(
|
|
92
|
+
f"registrations_fetched template_id={template_id} count={len(registrations)}"
|
|
93
|
+
)
|
|
94
|
+
except Exception as e:
|
|
95
|
+
logger.error(
|
|
96
|
+
f"registrations_fetch_err template_id={template_id} error={str(e)}"
|
|
97
|
+
)
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
scheduled_for_template = 0
|
|
101
|
+
for registration in registrations:
|
|
102
|
+
if registration.get("status") == "active":
|
|
103
|
+
try:
|
|
104
|
+
await self._schedule_cron_job(registration)
|
|
105
|
+
scheduled_total += 1
|
|
106
|
+
scheduled_for_template += 1
|
|
107
|
+
except Exception as e:
|
|
108
|
+
logger.error(
|
|
109
|
+
"registration_schedule_err "
|
|
110
|
+
f"registration_id={registration.get('id')} template_id={template_id} error={str(e)}"
|
|
111
|
+
)
|
|
112
|
+
logger.debug(
|
|
113
|
+
f"registrations_scheduled template_id={template_id} scheduled={scheduled_for_template}"
|
|
114
|
+
)
|
|
115
|
+
logger.debug(f"polling_schedule_complete total_scheduled={scheduled_total}")
|
|
116
|
+
except Exception as e:
|
|
117
|
+
logger.error(f"polling_load_err error={str(e)}")
|
|
118
|
+
|
|
119
|
+
async def reload_from_database(self):
|
|
120
|
+
"""Reload all cron registrations from database, replacing current schedules."""
|
|
121
|
+
try:
|
|
122
|
+
# Clear all current jobs
|
|
123
|
+
for registration_id in list(self.active_jobs.keys()):
|
|
124
|
+
await self._unschedule_cron_job(registration_id)
|
|
125
|
+
|
|
126
|
+
# Reload from database
|
|
127
|
+
await self._load_existing_registrations()
|
|
128
|
+
|
|
129
|
+
except Exception as e:
|
|
130
|
+
logger.error(f"Failed to reload cron jobs from database: {e}")
|
|
131
|
+
raise
|
|
132
|
+
|
|
133
|
+
async def on_registration_created(self, registration: dict[str, Any]):
|
|
134
|
+
"""Called when a new polling registration is created."""
|
|
135
|
+
template_id_raw = registration.get("template_id")
|
|
136
|
+
template_id = str(template_id_raw) if template_id_raw is not None else None
|
|
137
|
+
if template_id is None:
|
|
138
|
+
logger.error(
|
|
139
|
+
"registration_missing_template_id id=%s", registration.get("id")
|
|
140
|
+
)
|
|
141
|
+
return
|
|
142
|
+
tmpl = next(
|
|
143
|
+
(t for t in self.trigger_server.triggers if t.id == template_id), None
|
|
144
|
+
)
|
|
145
|
+
if not tmpl:
|
|
146
|
+
logger.error(
|
|
147
|
+
"registration_template_not_found id=%s template_id=%s",
|
|
148
|
+
registration.get("id"),
|
|
149
|
+
template_id,
|
|
150
|
+
)
|
|
151
|
+
return
|
|
152
|
+
if self._is_polling(tmpl):
|
|
153
|
+
try:
|
|
154
|
+
await self._schedule_cron_job(registration)
|
|
155
|
+
except Exception as e:
|
|
156
|
+
logger.error(
|
|
157
|
+
f"Failed to schedule new cron job {registration.get('id')}: {e}"
|
|
158
|
+
)
|
|
159
|
+
raise
|
|
160
|
+
else:
|
|
161
|
+
logger.debug(
|
|
162
|
+
"registration_not_polling id=%s template_id=%s trigger_type=%s",
|
|
163
|
+
registration.get("id"),
|
|
164
|
+
template_id,
|
|
165
|
+
tmpl.trigger_type,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
async def on_registration_deleted(self, registration_id: str):
|
|
169
|
+
"""Called when a cron registration is deleted."""
|
|
170
|
+
try:
|
|
171
|
+
await self._unschedule_cron_job(registration_id)
|
|
172
|
+
except Exception as e:
|
|
173
|
+
logger.error(f"Failed to unschedule cron job {registration_id}: {e}")
|
|
174
|
+
|
|
175
|
+
async def _schedule_cron_job(self, registration: dict[str, Any]):
|
|
176
|
+
"""Add a polling job to the scheduler using a 5-field crontab."""
|
|
177
|
+
registration_id = registration["id"]
|
|
178
|
+
resource_data = registration.get("resource", {})
|
|
179
|
+
crontab = (resource_data.get("crontab") or "").strip()
|
|
180
|
+
template_id = registration.get("template_id")
|
|
181
|
+
template_id = str(template_id) if template_id is not None else None
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
if template_id is None:
|
|
185
|
+
raise ValueError(
|
|
186
|
+
f"No schedule provided for registration {registration_id} (missing template_id)"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
if not crontab:
|
|
190
|
+
raise ValueError(
|
|
191
|
+
f"No schedule provided for registration {registration_id} (no crontab in resource)"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
cron_parts = crontab.split()
|
|
195
|
+
if len(cron_parts) != 5:
|
|
196
|
+
raise ValueError(f"Invalid cron format: {crontab} (expected 5 parts)")
|
|
197
|
+
minute, hour, day, month, day_of_week = cron_parts
|
|
198
|
+
trigger = APSCronTrigger(
|
|
199
|
+
minute=minute,
|
|
200
|
+
hour=hour,
|
|
201
|
+
day=day,
|
|
202
|
+
month=month,
|
|
203
|
+
day_of_week=day_of_week,
|
|
204
|
+
timezone="UTC",
|
|
205
|
+
)
|
|
206
|
+
job_id = f"cron_{registration_id}"
|
|
207
|
+
logger.info(
|
|
208
|
+
f"schedule_cron registration_id={registration_id} crontab='{crontab}' job_id={job_id}"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
job = self.scheduler.add_job(
|
|
212
|
+
self._execute_cron_job_with_monitoring,
|
|
213
|
+
trigger=trigger,
|
|
214
|
+
args=[registration],
|
|
215
|
+
id=job_id,
|
|
216
|
+
name=f"Polling job for registration {registration_id}",
|
|
217
|
+
max_instances=1,
|
|
218
|
+
replace_existing=True,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
self.active_jobs[registration_id] = job.id
|
|
222
|
+
|
|
223
|
+
except Exception as e:
|
|
224
|
+
logger.error(
|
|
225
|
+
f"Failed to schedule polling job for registration {registration_id}: {e}"
|
|
226
|
+
)
|
|
227
|
+
raise
|
|
228
|
+
|
|
229
|
+
async def _unschedule_cron_job(self, registration_id: str):
|
|
230
|
+
"""Remove a cron job from the scheduler."""
|
|
231
|
+
if registration_id in self.active_jobs:
|
|
232
|
+
job_id = self.active_jobs[registration_id]
|
|
233
|
+
try:
|
|
234
|
+
self.scheduler.remove_job(job_id)
|
|
235
|
+
del self.active_jobs[registration_id]
|
|
236
|
+
except Exception as e:
|
|
237
|
+
logger.error(f"Failed to unschedule cron job {job_id}: {e}")
|
|
238
|
+
raise
|
|
239
|
+
else:
|
|
240
|
+
logger.warning(
|
|
241
|
+
f"Attempted to unschedule non-existent cron job {registration_id}"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
async def _execute_cron_job_with_monitoring(self, registration: dict[str, Any]):
|
|
245
|
+
"""Execute a scheduled cron job with full monitoring and error handling."""
|
|
246
|
+
registration_id = registration["id"]
|
|
247
|
+
cron_pattern = registration["resource"]["crontab"]
|
|
248
|
+
|
|
249
|
+
execution = CronJobExecution(
|
|
250
|
+
registration_id=str(registration_id),
|
|
251
|
+
cron_pattern=cron_pattern,
|
|
252
|
+
scheduled_time=datetime.utcnow(),
|
|
253
|
+
actual_start_time=datetime.utcnow(),
|
|
254
|
+
status="running",
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
agents_invoked = await self.execute_cron_job(registration)
|
|
259
|
+
execution.status = "completed"
|
|
260
|
+
execution.agents_invoked = agents_invoked
|
|
261
|
+
logger.info(
|
|
262
|
+
f"✓ Cron job {registration_id} completed successfully - invoked {agents_invoked} agent(s)"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
except Exception as e:
|
|
266
|
+
execution.status = "failed"
|
|
267
|
+
execution.error_message = str(e)
|
|
268
|
+
logger.error(f"✗ Cron job {registration_id} failed: {e}")
|
|
269
|
+
|
|
270
|
+
finally:
|
|
271
|
+
execution.completion_time = datetime.utcnow()
|
|
272
|
+
await self._record_execution(execution)
|
|
273
|
+
|
|
274
|
+
async def execute_cron_job(self, registration: dict[str, Any]) -> int:
|
|
275
|
+
"""Execute a cron job - calls poll handler which invokes agents."""
|
|
276
|
+
registration_id = registration["id"]
|
|
277
|
+
template_id = registration.get("template_id")
|
|
278
|
+
template_id = str(template_id) if template_id is not None else None
|
|
279
|
+
|
|
280
|
+
tmpl = next(
|
|
281
|
+
(t for t in self.trigger_server.triggers if t.id == template_id), None
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
if not tmpl or not self._is_polling(tmpl):
|
|
285
|
+
available_ids = ",".join([t.id for t in self.trigger_server.triggers])
|
|
286
|
+
logger.error(
|
|
287
|
+
"template_not_polling "
|
|
288
|
+
f"template_id={template_id} available_templates={available_ids}"
|
|
289
|
+
)
|
|
290
|
+
return 0
|
|
291
|
+
|
|
292
|
+
response = await tmpl.poll_handler(
|
|
293
|
+
registration,
|
|
294
|
+
self.trigger_server.database,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
agents_invoked = response.get("agents_invoked", 0)
|
|
298
|
+
logger.info(
|
|
299
|
+
"poll_result "
|
|
300
|
+
f"registration_id={registration_id} "
|
|
301
|
+
f"trigger_id={template_id} "
|
|
302
|
+
f"agents_invoked={agents_invoked}"
|
|
303
|
+
)
|
|
304
|
+
return agents_invoked
|
|
305
|
+
|
|
306
|
+
async def _record_execution(self, execution: CronJobExecution):
|
|
307
|
+
"""Record execution history (in memory for now)."""
|
|
308
|
+
self.execution_history.append(execution)
|
|
309
|
+
|
|
310
|
+
# Keep only recent executions
|
|
311
|
+
if len(self.execution_history) > self.max_history:
|
|
312
|
+
self.execution_history = self.execution_history[-self.max_history :]
|
|
313
|
+
|
|
314
|
+
def get_active_jobs(self) -> dict[str, str]:
|
|
315
|
+
"""Get currently active cron jobs."""
|
|
316
|
+
return self.active_jobs.copy()
|
|
317
|
+
|
|
318
|
+
def get_execution_history(self, limit: int = 100) -> list[CronJobExecution]:
|
|
319
|
+
"""Get recent execution history."""
|
|
320
|
+
return self.execution_history[-limit:]
|
|
321
|
+
|
|
322
|
+
def get_job_status(self) -> dict[str, Any]:
|
|
323
|
+
"""Get status information about the cron manager."""
|
|
324
|
+
return {
|
|
325
|
+
"active_jobs": len(self.active_jobs),
|
|
326
|
+
"scheduler_running": self.scheduler.running,
|
|
327
|
+
"total_executions": len(self.execution_history),
|
|
328
|
+
"active_job_ids": list(self.active_jobs.keys()),
|
|
329
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Database interface for trigger operations."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TriggerDatabaseInterface(ABC):
|
|
8
|
+
"""Abstract interface for trigger database operations."""
|
|
9
|
+
|
|
10
|
+
# ========== Trigger Registrations ==========
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
async def create_trigger_registration(
|
|
14
|
+
self,
|
|
15
|
+
user_id: str,
|
|
16
|
+
tenant_id: str,
|
|
17
|
+
template_id: str,
|
|
18
|
+
resource: dict,
|
|
19
|
+
metadata: dict = None,
|
|
20
|
+
) -> dict[str, Any] | None:
|
|
21
|
+
"""Create a new trigger registration for a user."""
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
async def get_user_trigger_registrations(
|
|
26
|
+
self, user_id: str, tenant_id: str
|
|
27
|
+
) -> list[dict[str, Any]]:
|
|
28
|
+
"""Get all trigger registrations for a user within a tenant."""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
async def get_user_trigger_registrations_with_agents(
|
|
33
|
+
self, user_id: str, tenant_id: str
|
|
34
|
+
) -> list[dict[str, Any]]:
|
|
35
|
+
"""Get all trigger registrations for a user with linked agents in a single query within a tenant."""
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
async def get_trigger_registration(
|
|
40
|
+
self, registration_id: str, user_id: str, tenant_id: str
|
|
41
|
+
) -> dict[str, Any] | None:
|
|
42
|
+
"""Get a specific trigger registration."""
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
async def find_registration_by_resource(
|
|
47
|
+
self, template_id: str, resource_data: dict[str, Any]
|
|
48
|
+
) -> dict[str, Any] | None:
|
|
49
|
+
"""Find trigger registration by matching resource data."""
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
async def find_user_registration_by_resource(
|
|
54
|
+
self,
|
|
55
|
+
user_id: str,
|
|
56
|
+
tenant_id: str,
|
|
57
|
+
template_id: str,
|
|
58
|
+
resource_data: dict[str, Any],
|
|
59
|
+
) -> dict[str, Any] | None:
|
|
60
|
+
"""Find trigger registration by matching resource data for a specific user within a tenant."""
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
@abstractmethod
|
|
64
|
+
async def get_all_registrations(self, template_id: str) -> list[dict[str, Any]]:
|
|
65
|
+
"""Get all registrations for a specific trigger template."""
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
async def delete_trigger_registration(
|
|
70
|
+
self, registration_id: str, user_id: str, tenant_id: str
|
|
71
|
+
) -> bool:
|
|
72
|
+
"""Delete a trigger registration."""
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
# ========== Agent-Trigger Links ==========
|
|
76
|
+
|
|
77
|
+
@abstractmethod
|
|
78
|
+
async def link_agent_to_trigger(
|
|
79
|
+
self,
|
|
80
|
+
agent_id: str,
|
|
81
|
+
registration_id: str,
|
|
82
|
+
created_by: str,
|
|
83
|
+
field_selection: dict[str, bool] | None = None,
|
|
84
|
+
) -> bool:
|
|
85
|
+
"""Link an agent to a trigger registration with optional field selection."""
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
@abstractmethod
|
|
89
|
+
async def unlink_agent_from_trigger(
|
|
90
|
+
self, agent_id: str, registration_id: str
|
|
91
|
+
) -> bool:
|
|
92
|
+
"""Unlink an agent from a trigger registration."""
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
@abstractmethod
|
|
96
|
+
async def get_agents_for_trigger(
|
|
97
|
+
self, registration_id: str
|
|
98
|
+
) -> list[dict[str, Any]]:
|
|
99
|
+
"""Get all agent links for a trigger registration with field_selection."""
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
@abstractmethod
|
|
103
|
+
async def get_registrations_for_agent(
|
|
104
|
+
self, agent_id: str, tenant_id: str
|
|
105
|
+
) -> list[dict[str, Any]]:
|
|
106
|
+
"""Get all trigger registrations linked to a specific agent within a tenant."""
|
|
107
|
+
pass
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Trigger system - templates with registration and webhook handlers.
|
|
2
|
+
|
|
3
|
+
Also supports polling triggers (no HTTP route) via a `poll_handler` that the
|
|
4
|
+
framework scheduler can call on a cadence.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import inspect
|
|
10
|
+
from typing import Any, get_type_hints
|
|
11
|
+
|
|
12
|
+
from fastapi import Request
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from .core import TriggerRegistrationResult, TriggerType
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TriggerTemplate:
|
|
19
|
+
"""A trigger template with registration handler and main handler."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
id: str,
|
|
24
|
+
description: str,
|
|
25
|
+
registration_model: type[BaseModel],
|
|
26
|
+
registration_handler,
|
|
27
|
+
trigger_handler=None,
|
|
28
|
+
*,
|
|
29
|
+
trigger_type: TriggerType = TriggerType.WEBHOOK,
|
|
30
|
+
poll_handler: Any | None = None,
|
|
31
|
+
display_name: str | None = None,
|
|
32
|
+
integration: str | None = None,
|
|
33
|
+
auth_provider: str | None = None,
|
|
34
|
+
scopes: list[str] | None = None,
|
|
35
|
+
):
|
|
36
|
+
"""Initialize a trigger template.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
id: Unique identifier for the trigger
|
|
40
|
+
description: Description of what the trigger does
|
|
41
|
+
registration_model: Pydantic model for registration data
|
|
42
|
+
registration_handler: Async function to handle registration
|
|
43
|
+
trigger_handler: Async function to handle webhook events (for webhook triggers)
|
|
44
|
+
trigger_type: Type of trigger (webhook or polling)
|
|
45
|
+
poll_handler: Async function to handle polling (for polling triggers)
|
|
46
|
+
display_name: Display name for grouping triggers (e.g., "Slack - Channel Message Received", "Gmail - Email Received")
|
|
47
|
+
integration: Integration ID for logo mapping (e.g., "slack", "gmail")
|
|
48
|
+
auth_provider: OAuth provider ID (e.g., "google", "slack") for authentication
|
|
49
|
+
scopes: List of OAuth scopes required for this trigger (e.g., ["https://www.googleapis.com/auth/gmail.readonly"])
|
|
50
|
+
"""
|
|
51
|
+
self.id = id
|
|
52
|
+
self.description = description
|
|
53
|
+
self.registration_model = registration_model
|
|
54
|
+
self.registration_handler = registration_handler
|
|
55
|
+
self.trigger_handler = trigger_handler
|
|
56
|
+
self.trigger_type = trigger_type
|
|
57
|
+
self.poll_handler = poll_handler
|
|
58
|
+
self.display_name = display_name
|
|
59
|
+
self.integration = integration
|
|
60
|
+
self.auth_provider = auth_provider
|
|
61
|
+
self.scopes = scopes or []
|
|
62
|
+
|
|
63
|
+
self._validate_handler_signatures()
|
|
64
|
+
|
|
65
|
+
def _validate_handler_signatures(self):
|
|
66
|
+
"""Validate that all handler functions have the correct signatures."""
|
|
67
|
+
# Expected reg: async def handler(request: Request, user_id: str, registration: RegistrationModel) -> TriggerRegistrationResult
|
|
68
|
+
self._validate_handler(
|
|
69
|
+
"registration_handler",
|
|
70
|
+
self.registration_handler,
|
|
71
|
+
[Request, str, self.registration_model],
|
|
72
|
+
TriggerRegistrationResult,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if self.trigger_type == TriggerType.WEBHOOK:
|
|
76
|
+
if not self.trigger_handler:
|
|
77
|
+
raise TypeError(
|
|
78
|
+
f"trigger_handler required for webhook trigger '{self.id}'"
|
|
79
|
+
)
|
|
80
|
+
self._validate_handler(
|
|
81
|
+
"trigger_handler",
|
|
82
|
+
self.trigger_handler,
|
|
83
|
+
[Request, Any],
|
|
84
|
+
dict[str, Any],
|
|
85
|
+
)
|
|
86
|
+
else:
|
|
87
|
+
if not self.poll_handler:
|
|
88
|
+
raise TypeError(
|
|
89
|
+
f"poll_handler required for polling trigger '{self.id}'"
|
|
90
|
+
)
|
|
91
|
+
self._validate_handler(
|
|
92
|
+
"poll_handler",
|
|
93
|
+
self.poll_handler,
|
|
94
|
+
[dict[str, Any], Any],
|
|
95
|
+
dict[str, Any],
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def _validate_handler(
|
|
99
|
+
self,
|
|
100
|
+
handler_name: str,
|
|
101
|
+
handler_func,
|
|
102
|
+
expected_types: list[type],
|
|
103
|
+
expected_return_type: type = None,
|
|
104
|
+
):
|
|
105
|
+
"""Common validation logic for all handler functions."""
|
|
106
|
+
if not inspect.iscoroutinefunction(handler_func):
|
|
107
|
+
raise TypeError(f"{handler_name} for trigger '{self.id}' must be async")
|
|
108
|
+
|
|
109
|
+
sig = inspect.signature(handler_func)
|
|
110
|
+
params = list(sig.parameters.values())
|
|
111
|
+
expected_param_count = len(expected_types)
|
|
112
|
+
|
|
113
|
+
if len(params) != expected_param_count:
|
|
114
|
+
raise TypeError(
|
|
115
|
+
f"{handler_name} for trigger '{self.id}' must have {expected_param_count} parameters, got {len(params)}"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
hints = get_type_hints(handler_func)
|
|
119
|
+
param_names = list(sig.parameters.keys())
|
|
120
|
+
|
|
121
|
+
# Check each parameter type if type hints are available
|
|
122
|
+
for i, expected_type in enumerate(expected_types):
|
|
123
|
+
if param_names[i] in hints and hints[param_names[i]] != expected_type:
|
|
124
|
+
expected_name = getattr(expected_type, "__name__", str(expected_type))
|
|
125
|
+
raise TypeError(
|
|
126
|
+
f"{handler_name} for trigger '{self.id}': param {i + 1} should be {expected_name}"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Check return type if expected and available
|
|
130
|
+
if expected_return_type and "return" in hints:
|
|
131
|
+
actual_return_type = hints["return"]
|
|
132
|
+
if actual_return_type != expected_return_type:
|
|
133
|
+
expected_name = getattr(
|
|
134
|
+
expected_return_type, "__name__", str(expected_return_type)
|
|
135
|
+
)
|
|
136
|
+
actual_name = getattr(
|
|
137
|
+
actual_return_type, "__name__", str(actual_return_type)
|
|
138
|
+
)
|
|
139
|
+
raise TypeError(
|
|
140
|
+
f"{handler_name} for trigger '{self.id}': return type should be {expected_name}, got {actual_name}"
|
|
141
|
+
)
|