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.
@@ -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,5 @@
1
+ """Database module for trigger operations."""
2
+
3
+ from .interface import TriggerDatabaseInterface
4
+
5
+ __all__ = ["TriggerDatabaseInterface"]
@@ -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
+ )
@@ -0,0 +1,7 @@
1
+ """Built-in trigger templates for the LangChain Triggers Framework."""
2
+
3
+ from .cron_trigger import cron_trigger
4
+
5
+ __all__ = [
6
+ "cron_trigger",
7
+ ]