langchain-trigger-server 0.3__tar.gz → 0.31__tar.gz

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.

Potentially problematic release.


This version of langchain-trigger-server might be problematic. Click here for more details.

Files changed (31) hide show
  1. {langchain_trigger_server-0.3 → langchain_trigger_server-0.31}/PKG-INFO +1 -1
  2. {langchain_trigger_server-0.3 → langchain_trigger_server-0.31}/langchain_triggers/app.py +26 -4
  3. {langchain_trigger_server-0.3 → langchain_trigger_server-0.31}/langchain_triggers/core.py +8 -0
  4. langchain_trigger_server-0.31/langchain_triggers/cron_manager.py +418 -0
  5. {langchain_trigger_server-0.3 → langchain_trigger_server-0.31}/langchain_triggers/decorators.py +37 -11
  6. {langchain_trigger_server-0.3 → langchain_trigger_server-0.31}/langchain_triggers/triggers/cron_trigger.py +18 -11
  7. {langchain_trigger_server-0.3 → langchain_trigger_server-0.31}/pyproject.toml +1 -1
  8. langchain_trigger_server-0.31/tests/unit/test_cron_manager_polling_filter.py +136 -0
  9. langchain_trigger_server-0.31/tests/unit/test_cron_manager_schedule_validation.py +48 -0
  10. langchain_trigger_server-0.31/version_comparison.txt +1 -0
  11. langchain_trigger_server-0.3/langchain_triggers/cron_manager.py +0 -282
  12. langchain_trigger_server-0.3/version_comparison.txt +0 -1
  13. {langchain_trigger_server-0.3 → langchain_trigger_server-0.31}/.github/actions/uv_setup/action.yml +0 -0
  14. {langchain_trigger_server-0.3 → langchain_trigger_server-0.31}/.github/workflows/_lint.yml +0 -0
  15. {langchain_trigger_server-0.3 → langchain_trigger_server-0.31}/.github/workflows/_test.yml +0 -0
  16. {langchain_trigger_server-0.3 → langchain_trigger_server-0.31}/.github/workflows/ci.yml +0 -0
  17. {langchain_trigger_server-0.3 → langchain_trigger_server-0.31}/.github/workflows/release.yml +0 -0
  18. {langchain_trigger_server-0.3 → langchain_trigger_server-0.31}/.gitignore +0 -0
  19. {langchain_trigger_server-0.3 → langchain_trigger_server-0.31}/Makefile +0 -0
  20. {langchain_trigger_server-0.3 → langchain_trigger_server-0.31}/README.md +0 -0
  21. {langchain_trigger_server-0.3 → langchain_trigger_server-0.31}/langchain_triggers/__init__.py +0 -0
  22. {langchain_trigger_server-0.3 → langchain_trigger_server-0.31}/langchain_triggers/auth/__init__.py +0 -0
  23. {langchain_trigger_server-0.3 → langchain_trigger_server-0.31}/langchain_triggers/auth/slack_hmac.py +0 -0
  24. {langchain_trigger_server-0.3 → langchain_trigger_server-0.31}/langchain_triggers/database/__init__.py +0 -0
  25. {langchain_trigger_server-0.3 → langchain_trigger_server-0.31}/langchain_triggers/database/interface.py +0 -0
  26. {langchain_trigger_server-0.3 → langchain_trigger_server-0.31}/langchain_triggers/database/supabase.py +0 -0
  27. {langchain_trigger_server-0.3 → langchain_trigger_server-0.31}/langchain_triggers/triggers/__init__.py +0 -0
  28. {langchain_trigger_server-0.3 → langchain_trigger_server-0.31}/tests/__init__.py +0 -0
  29. {langchain_trigger_server-0.3 → langchain_trigger_server-0.31}/tests/unit/__init__.py +0 -0
  30. {langchain_trigger_server-0.3 → langchain_trigger_server-0.31}/tests/unit/test_trigger_server_api.py +0 -0
  31. {langchain_trigger_server-0.3 → langchain_trigger_server-0.31}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langchain-trigger-server
3
- Version: 0.3
3
+ Version: 0.31
4
4
  Summary: Generic event-driven triggers framework
5
5
  Project-URL: Homepage, https://github.com/langchain-ai/open-agent-platform
6
6
  Project-URL: Repository, https://github.com/langchain-ai/open-agent-platform
@@ -21,10 +21,10 @@ from .auth.slack_hmac import (
21
21
  get_slack_signing_secret,
22
22
  verify_slack_signature,
23
23
  )
24
+ from .core import TriggerType
24
25
  from .cron_manager import CronTriggerManager
25
26
  from .database import TriggerDatabaseInterface, create_database
26
27
  from .decorators import TriggerTemplate
27
- from .triggers.cron_trigger import CRON_TRIGGER_ID
28
28
 
29
29
  logger = logging.getLogger(__name__)
30
30
 
@@ -566,12 +566,34 @@ class TriggerServer:
566
566
  detail="Trigger registration not found or access denied",
567
567
  )
568
568
 
569
- # Get the template to check if it's a cron trigger
569
+ # Get the template to check if it's a polling trigger
570
570
  template_id = registration.get("template_id")
571
- if template_id != CRON_TRIGGER_ID:
571
+ tmpl = (
572
+ next((t for t in self.triggers if t.id == template_id), None)
573
+ if template_id
574
+ else None
575
+ )
576
+ if not template_id or not tmpl:
577
+ error_reason = (
578
+ "missing_template_id"
579
+ if not template_id
580
+ else "template_not_found"
581
+ )
582
+ logger.error(
583
+ "manual_execute_error registration_id=%s template_id=%s error=%s",
584
+ registration_id,
585
+ template_id,
586
+ error_reason,
587
+ stack_info=True,
588
+ )
589
+ raise HTTPException(status_code=500, detail="Internal server error")
590
+ if (
591
+ getattr(tmpl.trigger_type, "value", tmpl.trigger_type)
592
+ != TriggerType.POLLING.value
593
+ ):
572
594
  raise HTTPException(
573
595
  status_code=400,
574
- detail="Manual execution is only supported for cron triggers",
596
+ detail="Manual execution is only supported for polling triggers",
575
597
  )
576
598
 
577
599
  # Execute the cron trigger using the cron manager
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
+ from enum import Enum
6
7
  from typing import Any
7
8
 
8
9
  from pydantic import BaseModel, Field
@@ -10,6 +11,13 @@ from pydantic import BaseModel, Field
10
11
  logger = logging.getLogger(__name__)
11
12
 
12
13
 
14
+ class TriggerType(str, Enum):
15
+ """Type of trigger supported by the framework."""
16
+
17
+ WEBHOOK = "webhook"
18
+ POLLING = "polling"
19
+
20
+
13
21
  class ProviderAuthInfo(BaseModel):
14
22
  """Authentication info for a specific OAuth provider."""
15
23
 
@@ -0,0 +1,418 @@
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 TriggerHandlerResult, 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} provider={(template.provider or '').lower()} name={getattr(template, '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
+ tmpl = next(
183
+ (t for t in self.trigger_server.triggers if t.id == template_id), None
184
+ )
185
+ if tmpl and not crontab:
186
+ crontab = (getattr(tmpl, "default_crontab", None) or "").strip()
187
+
188
+ try:
189
+ if not crontab:
190
+ if template_id is None:
191
+ raise ValueError(
192
+ f"No schedule provided for registration {registration_id} (missing template_id)"
193
+ )
194
+ if tmpl is None:
195
+ raise ValueError(
196
+ f"No schedule provided for registration {registration_id} (template '{template_id}' not found)"
197
+ )
198
+ raise ValueError(
199
+ f"No schedule provided for registration {registration_id} (no crontab and no default_crontab for template '{template_id}')"
200
+ )
201
+
202
+ cron_parts = crontab.split()
203
+ if len(cron_parts) != 5:
204
+ raise ValueError(f"Invalid cron format: {crontab} (expected 5 parts)")
205
+ minute, hour, day, month, day_of_week = cron_parts
206
+ trigger = APSCronTrigger(
207
+ minute=minute,
208
+ hour=hour,
209
+ day=day,
210
+ month=month,
211
+ day_of_week=day_of_week,
212
+ timezone="UTC",
213
+ )
214
+ job_id = f"cron_{registration_id}"
215
+ logger.info(
216
+ f"schedule_cron registration_id={registration_id} crontab='{crontab}' job_id={job_id}"
217
+ )
218
+
219
+ job = self.scheduler.add_job(
220
+ self._execute_cron_job_with_monitoring,
221
+ trigger=trigger,
222
+ args=[registration],
223
+ id=job_id,
224
+ name=f"Polling job for registration {registration_id}",
225
+ max_instances=1,
226
+ replace_existing=True,
227
+ )
228
+
229
+ self.active_jobs[registration_id] = job.id
230
+
231
+ except Exception as e:
232
+ logger.error(
233
+ f"Failed to schedule polling job for registration {registration_id}: {e}"
234
+ )
235
+ raise
236
+
237
+ async def _unschedule_cron_job(self, registration_id: str):
238
+ """Remove a cron job from the scheduler."""
239
+ if registration_id in self.active_jobs:
240
+ job_id = self.active_jobs[registration_id]
241
+ try:
242
+ self.scheduler.remove_job(job_id)
243
+ del self.active_jobs[registration_id]
244
+ except Exception as e:
245
+ logger.error(f"Failed to unschedule cron job {job_id}: {e}")
246
+ raise
247
+ else:
248
+ logger.warning(
249
+ f"Attempted to unschedule non-existent cron job {registration_id}"
250
+ )
251
+
252
+ async def _execute_cron_job_with_monitoring(self, registration: dict[str, Any]):
253
+ """Execute a scheduled cron job with full monitoring and error handling."""
254
+ registration_id = registration["id"]
255
+ cron_pattern = registration["resource"]["crontab"]
256
+
257
+ execution = CronJobExecution(
258
+ registration_id=str(registration_id),
259
+ cron_pattern=cron_pattern,
260
+ scheduled_time=datetime.utcnow(),
261
+ actual_start_time=datetime.utcnow(),
262
+ status="running",
263
+ )
264
+
265
+ try:
266
+ agents_invoked = await self.execute_cron_job(registration)
267
+ execution.status = "completed"
268
+ execution.agents_invoked = agents_invoked
269
+ logger.info(
270
+ f"✓ Cron job {registration_id} completed successfully - invoked {agents_invoked} agent(s)"
271
+ )
272
+
273
+ except Exception as e:
274
+ execution.status = "failed"
275
+ execution.error_message = str(e)
276
+ logger.error(f"✗ Cron job {registration_id} failed: {e}")
277
+
278
+ finally:
279
+ execution.completion_time = datetime.utcnow()
280
+ await self._record_execution(execution)
281
+
282
+ async def execute_cron_job(self, registration: dict[str, Any]) -> int:
283
+ """Execute a cron job - invoke agents. Can be called manually or by scheduler."""
284
+ registration_id = registration["id"]
285
+ user_id = registration["user_id"]
286
+ template_id = registration.get("template_id")
287
+ template_id = str(template_id) if template_id is not None else None
288
+ tenant_id = (
289
+ registration.get("metadata", {}).get("client_metadata", {}).get("tenant_id")
290
+ )
291
+
292
+ # Get agent links
293
+ agent_links = await self.trigger_server.database.get_agents_for_trigger(
294
+ registration_id
295
+ )
296
+
297
+ if not agent_links:
298
+ logger.warning(f"No agents linked to cron job {registration_id}")
299
+ return 0
300
+
301
+ tmpl = next(
302
+ (t for t in self.trigger_server.triggers if t.id == template_id), None
303
+ )
304
+
305
+ if not tmpl or not self._is_polling(tmpl):
306
+ available_ids = ",".join([t.id for t in self.trigger_server.triggers])
307
+ logger.error(
308
+ "template_not_polling "
309
+ f"template_id={template_id} available_templates={available_ids}"
310
+ )
311
+ return 0
312
+
313
+ result: TriggerHandlerResult = await tmpl.poll_handler(
314
+ registration,
315
+ self.trigger_server.database,
316
+ self.trigger_server.langchain_auth_client,
317
+ )
318
+
319
+ if not result.invoke_agent:
320
+ logger.info(
321
+ "poll_result "
322
+ f"registration_id={registration_id} "
323
+ f"trigger_id={template_id} "
324
+ f"provider={(tmpl.provider or '').lower()} "
325
+ f"invoke_agent=false messages_count=0"
326
+ )
327
+ return 0
328
+
329
+ agents_invoked = 0
330
+ messages = result.agent_messages or []
331
+
332
+ logger.info(
333
+ "poll_result "
334
+ f"registration_id={registration_id} "
335
+ f"trigger_id={template_id} "
336
+ f"provider={(tmpl.provider or '').lower()} "
337
+ f"invoke_agent=true messages_count={len(messages)} "
338
+ f"agents_linked={len(agent_links)}"
339
+ )
340
+
341
+ for message in messages:
342
+ for agent_link in agent_links:
343
+ agent_id = (
344
+ agent_link
345
+ if isinstance(agent_link, str)
346
+ else agent_link.get("agent_id")
347
+ )
348
+ # Ensure agent_id and user_id are strings for JSON serialization
349
+ agent_id_str = str(agent_id)
350
+ user_id_str = str(user_id)
351
+ tenant_id_str = str(tenant_id)
352
+
353
+ current_time = datetime.utcnow()
354
+ current_time_str = current_time.strftime("%A, %B %d, %Y at %H:%M UTC")
355
+
356
+ agent_input = {
357
+ "messages": [
358
+ {
359
+ "role": "human",
360
+ "content": f"ACTION: triggering cron from langchain-trigger-server\nCURRENT TIME: {current_time_str}\n\n{message}",
361
+ }
362
+ ]
363
+ }
364
+
365
+ try:
366
+ success = await self.trigger_server._invoke_agent(
367
+ agent_id=agent_id_str,
368
+ user_id=user_id_str,
369
+ tenant_id=tenant_id_str,
370
+ input_data=agent_input,
371
+ )
372
+ if success:
373
+ logger.info(
374
+ "invoke_agent_ok "
375
+ f"registration_id={registration_id} "
376
+ f"agent_id={agent_id_str}"
377
+ )
378
+ agents_invoked += 1
379
+ except Exception as e:
380
+ logger.error(
381
+ "invoke_agent_err "
382
+ f"registration_id={registration_id} "
383
+ f"agent_id={agent_id_str} "
384
+ f"error={str(e)}"
385
+ )
386
+
387
+ logger.info(
388
+ "poll_invoke_summary "
389
+ f"registration_id={registration_id} "
390
+ f"agents_invoked={agents_invoked} "
391
+ f"messages_count={len(messages)}"
392
+ )
393
+ return agents_invoked
394
+
395
+ async def _record_execution(self, execution: CronJobExecution):
396
+ """Record execution history (in memory for now)."""
397
+ self.execution_history.append(execution)
398
+
399
+ # Keep only recent executions
400
+ if len(self.execution_history) > self.max_history:
401
+ self.execution_history = self.execution_history[-self.max_history :]
402
+
403
+ def get_active_jobs(self) -> dict[str, str]:
404
+ """Get currently active cron jobs."""
405
+ return self.active_jobs.copy()
406
+
407
+ def get_execution_history(self, limit: int = 100) -> list[CronJobExecution]:
408
+ """Get recent execution history."""
409
+ return self.execution_history[-limit:]
410
+
411
+ def get_job_status(self) -> dict[str, Any]:
412
+ """Get status information about the cron manager."""
413
+ return {
414
+ "active_jobs": len(self.active_jobs),
415
+ "scheduler_running": self.scheduler.running,
416
+ "total_executions": len(self.execution_history),
417
+ "active_job_ids": list(self.active_jobs.keys()),
418
+ }
@@ -1,4 +1,8 @@
1
- """Trigger system - templates with registration and webhook handlers."""
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
+ """
2
6
 
3
7
  from __future__ import annotations
4
8
 
@@ -8,7 +12,7 @@ from typing import Any, get_type_hints
8
12
  from langchain_auth.client import Client
9
13
  from pydantic import BaseModel
10
14
 
11
- from .core import TriggerHandlerResult, TriggerRegistrationResult
15
+ from .core import TriggerHandlerResult, TriggerRegistrationResult, TriggerType
12
16
 
13
17
 
14
18
  class TriggerTemplate:
@@ -22,7 +26,11 @@ class TriggerTemplate:
22
26
  description: str,
23
27
  registration_model: type[BaseModel],
24
28
  registration_handler,
25
- trigger_handler,
29
+ trigger_handler=None,
30
+ *,
31
+ trigger_type: TriggerType = TriggerType.WEBHOOK,
32
+ poll_handler: Any | None = None,
33
+ default_crontab: str | None = None,
26
34
  ):
27
35
  self.id = id
28
36
  self.provider = provider
@@ -31,12 +39,15 @@ class TriggerTemplate:
31
39
  self.registration_model = registration_model
32
40
  self.registration_handler = registration_handler
33
41
  self.trigger_handler = trigger_handler
42
+ self.trigger_type = trigger_type
43
+ self.poll_handler = poll_handler
44
+ self.default_crontab = default_crontab
34
45
 
35
46
  self._validate_handler_signatures()
36
47
 
37
48
  def _validate_handler_signatures(self):
38
49
  """Validate that all handler functions have the correct signatures."""
39
- # Expected: async def handler(user_id: str, auth_client: Client, registration: RegistrationModel) -> TriggerRegistrationResult
50
+ # Expected reg: async def handler(user_id: str, auth_client: Client, registration: RegistrationModel) -> TriggerRegistrationResult
40
51
  self._validate_handler(
41
52
  "registration_handler",
42
53
  self.registration_handler,
@@ -44,13 +55,28 @@ class TriggerTemplate:
44
55
  TriggerRegistrationResult,
45
56
  )
46
57
 
47
- # Expected: async def handler(payload: Dict[str, Any], query_params: Dict[str, str], database, auth_client: Client) -> TriggerHandlerResult
48
- self._validate_handler(
49
- "trigger_handler",
50
- self.trigger_handler,
51
- [dict[str, Any], dict[str, str], Any, Client],
52
- TriggerHandlerResult,
53
- )
58
+ if self.trigger_type == TriggerType.WEBHOOK:
59
+ if not self.trigger_handler:
60
+ raise TypeError(
61
+ f"trigger_handler required for webhook trigger '{self.id}'"
62
+ )
63
+ self._validate_handler(
64
+ "trigger_handler",
65
+ self.trigger_handler,
66
+ [dict[str, Any], dict[str, str], Any, Client],
67
+ TriggerHandlerResult,
68
+ )
69
+ else:
70
+ if not self.poll_handler:
71
+ raise TypeError(
72
+ f"poll_handler required for polling trigger '{self.id}'"
73
+ )
74
+ self._validate_handler(
75
+ "poll_handler",
76
+ self.poll_handler,
77
+ [dict[str, Any], Any, Client],
78
+ TriggerHandlerResult,
79
+ )
54
80
 
55
81
  def _validate_handler(
56
82
  self,
@@ -12,6 +12,7 @@ from langchain_triggers.core import (
12
12
  TriggerHandlerResult,
13
13
  TriggerRegistrationModel,
14
14
  TriggerRegistrationResult,
15
+ TriggerType,
15
16
  )
16
17
  from langchain_triggers.decorators import TriggerTemplate
17
18
 
@@ -73,20 +74,25 @@ async def cron_registration_handler(
73
74
  )
74
75
 
75
76
 
76
- async def cron_trigger_handler(
77
- payload: dict[str, Any],
78
- query_params: dict[str, str],
77
+ async def cron_poll_handler(
78
+ registration: dict[str, Any],
79
79
  database,
80
80
  auth_client: Client,
81
81
  ) -> TriggerHandlerResult:
82
- """Cron trigger handler - this should never be called via HTTP."""
83
- logger.warning("Cron trigger handler called via HTTP - this shouldn't happen")
82
+ """Polling handler for generic cron.
83
+
84
+ Produces a simple time-based message for linked agents.
85
+ """
86
+ current_time = datetime.utcnow()
87
+ current_time_str = current_time.strftime("%A, %B %d, %Y at %H:%M UTC")
88
+ message = (
89
+ "ACTION: triggering cron from langchain-trigger-server\n"
90
+ f"CURRENT TIME: {current_time_str}"
91
+ )
84
92
  return TriggerHandlerResult(
85
- invoke_agent=False,
86
- response_body={
87
- "success": False,
88
- "message": "Cron triggers are executed by scheduler, not HTTP requests",
89
- },
93
+ invoke_agent=True,
94
+ agent_messages=[message],
95
+ registration=registration,
90
96
  )
91
97
 
92
98
 
@@ -97,5 +103,6 @@ cron_trigger = TriggerTemplate(
97
103
  description="Triggers agents on a cron schedule",
98
104
  registration_model=CronRegistration,
99
105
  registration_handler=cron_registration_handler,
100
- trigger_handler=cron_trigger_handler,
106
+ trigger_type=TriggerType.POLLING,
107
+ poll_handler=cron_poll_handler,
101
108
  )
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "langchain-trigger-server"
7
- version = "0.3"
7
+ version = "0.31"
8
8
  description = "Generic event-driven triggers framework"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1,136 @@
1
+ """Unit tests for CronTriggerManager filtering logic (polling vs webhook)."""
2
+
3
+ from unittest.mock import AsyncMock
4
+
5
+ import pytest
6
+ from pydantic import BaseModel
7
+
8
+ from langchain_triggers.core import TriggerType
9
+ from langchain_triggers.cron_manager import CronTriggerManager
10
+ from langchain_triggers.decorators import TriggerTemplate
11
+ from langchain_triggers.triggers import cron_trigger
12
+
13
+
14
+ class _DummyRegModel(BaseModel):
15
+ field: str | None = None
16
+
17
+
18
+ async def _dummy_reg_handler(user_id, auth_client, registration):
19
+ from langchain_triggers.core import TriggerRegistrationResult
20
+
21
+ return TriggerRegistrationResult()
22
+
23
+
24
+ async def _dummy_trigger_handler(payload, query_params, database, auth_client):
25
+ from langchain_triggers.core import TriggerHandlerResult
26
+
27
+ return TriggerHandlerResult(invoke_agent=False, response_body={"ok": True})
28
+
29
+
30
+ class _FakeDB:
31
+ def __init__(self, registrations_by_template):
32
+ self._registrations_by_template = registrations_by_template
33
+
34
+ async def get_all_registrations(self, template_id):
35
+ return self._registrations_by_template.get(template_id, [])
36
+
37
+
38
+ class _FakeTriggerServer:
39
+ def __init__(self, triggers, database):
40
+ self.triggers = triggers
41
+ self.database = database
42
+ self.langchain_auth_client = None
43
+
44
+
45
+ @pytest.mark.asyncio
46
+ async def test_load_existing_registrations_only_schedules_polling():
47
+ webhook_trigger = TriggerTemplate(
48
+ id="webhook_tmpl",
49
+ provider="test",
50
+ name="Webhook",
51
+ description="webhook",
52
+ registration_model=_DummyRegModel,
53
+ registration_handler=_dummy_reg_handler,
54
+ trigger_handler=_dummy_trigger_handler,
55
+ trigger_type=TriggerType.WEBHOOK,
56
+ )
57
+
58
+ polling_trigger = cron_trigger
59
+
60
+ # Prepare registrations for both templates
61
+ regs = {
62
+ webhook_trigger.id: [
63
+ {
64
+ "id": "reg_w1",
65
+ "status": "active",
66
+ "template_id": webhook_trigger.id,
67
+ "resource": {},
68
+ }
69
+ ],
70
+ polling_trigger.id: [
71
+ {
72
+ "id": "reg_p1",
73
+ "status": "active",
74
+ "template_id": polling_trigger.id,
75
+ "resource": {"crontab": "* * * * *"},
76
+ },
77
+ {
78
+ "id": "reg_p2",
79
+ "status": "inactive",
80
+ "template_id": polling_trigger.id,
81
+ "resource": {"crontab": "* * * * *"},
82
+ },
83
+ ],
84
+ }
85
+
86
+ fake_db = _FakeDB(registrations_by_template=regs)
87
+ fake_server = _FakeTriggerServer(
88
+ triggers=[webhook_trigger, polling_trigger], database=fake_db
89
+ )
90
+ mgr = CronTriggerManager(fake_server)
91
+
92
+ mgr._schedule_cron_job = AsyncMock()
93
+
94
+ await mgr._load_existing_registrations()
95
+
96
+ assert mgr._schedule_cron_job.await_count == 1
97
+ called_with = mgr._schedule_cron_job.await_args.args[0]
98
+ assert called_with["id"] == "reg_p1"
99
+
100
+
101
+ @pytest.mark.asyncio
102
+ async def test_on_registration_created_only_for_polling():
103
+ """on_registration_created should call schedule only for polling templates."""
104
+ webhook_trigger = TriggerTemplate(
105
+ id="webhook_tmpl2",
106
+ provider="test",
107
+ name="Webhook2",
108
+ description="webhook",
109
+ registration_model=_DummyRegModel,
110
+ registration_handler=_dummy_reg_handler,
111
+ trigger_handler=_dummy_trigger_handler,
112
+ trigger_type=TriggerType.WEBHOOK,
113
+ )
114
+
115
+ polling_trigger = cron_trigger
116
+
117
+ fake_db = _FakeDB(registrations_by_template={})
118
+ fake_server = _FakeTriggerServer(
119
+ triggers=[webhook_trigger, polling_trigger], database=fake_db
120
+ )
121
+ mgr = CronTriggerManager(fake_server)
122
+ mgr._schedule_cron_job = AsyncMock()
123
+
124
+ await mgr.on_registration_created(
125
+ {"id": "reg_w2", "template_id": webhook_trigger.id, "resource": {}}
126
+ )
127
+ assert mgr._schedule_cron_job.await_count == 0
128
+
129
+ await mgr.on_registration_created(
130
+ {
131
+ "id": "reg_p3",
132
+ "template_id": polling_trigger.id,
133
+ "resource": {"crontab": "* * * * *"},
134
+ }
135
+ )
136
+ assert mgr._schedule_cron_job.await_count == 1
@@ -0,0 +1,48 @@
1
+ """Validation tests for CronTriggerManager._schedule_cron_job."""
2
+
3
+ import pytest
4
+
5
+ from langchain_triggers.cron_manager import CronTriggerManager
6
+ from langchain_triggers.triggers import cron_trigger
7
+
8
+
9
+ class _FakeTriggerServer:
10
+ def __init__(self, triggers):
11
+ self.triggers = triggers
12
+ self.database = None
13
+ self.langchain_auth_client = None
14
+
15
+
16
+ @pytest.mark.asyncio
17
+ async def test_schedule_cron_job_requires_crontab():
18
+ """Missing/empty crontab should raise a clear error."""
19
+ mgr = CronTriggerManager(_FakeTriggerServer([cron_trigger]))
20
+
21
+ registration = {
22
+ "id": "reg_missing",
23
+ "template_id": cron_trigger.id,
24
+ "resource": {},
25
+ }
26
+
27
+ with pytest.raises(ValueError) as exc:
28
+ await mgr._schedule_cron_job(registration)
29
+
30
+ # Message should indicate no schedule was provided
31
+ assert "No schedule provided" in str(exc.value)
32
+
33
+
34
+ @pytest.mark.asyncio
35
+ async def test_schedule_cron_job_rejects_invalid_format():
36
+ """Invalid crontab (wrong number of fields) should raise."""
37
+ mgr = CronTriggerManager(_FakeTriggerServer([cron_trigger]))
38
+
39
+ registration = {
40
+ "id": "reg_bad_format",
41
+ "template_id": cron_trigger.id,
42
+ "resource": {"crontab": "*"},
43
+ }
44
+
45
+ with pytest.raises(ValueError) as exc:
46
+ await mgr._schedule_cron_job(registration)
47
+
48
+ assert "expected 5 parts" in str(exc.value)
@@ -0,0 +1 @@
1
+ Current: 0.31, PyPI: 0.3, Should publish: True
@@ -1,282 +0,0 @@
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.triggers.cron_trigger import CRON_TRIGGER_ID
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
- async def start(self):
40
- """Start scheduler and load existing cron registrations."""
41
- try:
42
- self.scheduler.start()
43
- await self._load_existing_registrations()
44
- except Exception as e:
45
- logger.error(f"Failed to start CronTriggerManager: {e}")
46
- raise
47
-
48
- async def shutdown(self):
49
- """Shutdown scheduler gracefully."""
50
- try:
51
- self.scheduler.shutdown(wait=True)
52
- except Exception as e:
53
- logger.error(f"Error shutting down CronTriggerManager: {e}")
54
-
55
- async def _load_existing_registrations(self):
56
- """Load all existing cron registrations from database and schedule them."""
57
- try:
58
- registrations = await self.trigger_server.database.get_all_registrations(
59
- CRON_TRIGGER_ID
60
- )
61
-
62
- scheduled_count = 0
63
- for registration in registrations:
64
- if registration.get("status") == "active":
65
- try:
66
- await self._schedule_cron_job(registration)
67
- scheduled_count += 1
68
- except Exception as e:
69
- logger.error(
70
- f"Failed to schedule existing cron job {registration.get('id')}: {e}"
71
- )
72
-
73
- except Exception as e:
74
- logger.error(f"Failed to load existing cron registrations: {e}")
75
-
76
- async def reload_from_database(self):
77
- """Reload all cron registrations from database, replacing current schedules."""
78
- try:
79
- # Clear all current jobs
80
- for registration_id in list(self.active_jobs.keys()):
81
- await self._unschedule_cron_job(registration_id)
82
-
83
- # Reload from database
84
- await self._load_existing_registrations()
85
-
86
- except Exception as e:
87
- logger.error(f"Failed to reload cron jobs from database: {e}")
88
- raise
89
-
90
- async def on_registration_created(self, registration: dict[str, Any]):
91
- """Called when a new cron registration is created."""
92
- if registration.get("trigger_template_id") == CRON_TRIGGER_ID:
93
- try:
94
- await self._schedule_cron_job(registration)
95
- except Exception as e:
96
- logger.error(
97
- f"Failed to schedule new cron job {registration['id']}: {e}"
98
- )
99
- raise
100
-
101
- async def on_registration_deleted(self, registration_id: str):
102
- """Called when a cron registration is deleted."""
103
- try:
104
- await self._unschedule_cron_job(registration_id)
105
- except Exception as e:
106
- logger.error(f"Failed to unschedule cron job {registration_id}: {e}")
107
-
108
- async def _schedule_cron_job(self, registration: dict[str, Any]):
109
- """Add a cron job to the scheduler."""
110
- registration_id = registration["id"]
111
- resource_data = registration.get("resource", {})
112
- crontab = resource_data.get("crontab", "")
113
-
114
- if not crontab:
115
- raise ValueError(
116
- f"No crontab pattern found in registration {registration_id}"
117
- )
118
-
119
- try:
120
- # Parse cron expression
121
- cron_parts = crontab.strip().split()
122
- if len(cron_parts) != 5:
123
- raise ValueError(f"Invalid cron format: {crontab} (expected 5 parts)")
124
-
125
- minute, hour, day, month, day_of_week = cron_parts
126
-
127
- # Create APScheduler cron trigger
128
- trigger = APSCronTrigger(
129
- minute=minute,
130
- hour=hour,
131
- day=day,
132
- month=month,
133
- day_of_week=day_of_week,
134
- timezone="UTC",
135
- )
136
-
137
- # Schedule the job
138
- job = self.scheduler.add_job(
139
- self._execute_cron_job_with_monitoring,
140
- trigger=trigger,
141
- args=[registration],
142
- id=f"cron_{registration_id}",
143
- name=f"Cron job for registration {registration_id}",
144
- max_instances=1, # Prevent overlapping executions
145
- replace_existing=True,
146
- )
147
-
148
- self.active_jobs[registration_id] = job.id
149
-
150
- except Exception as e:
151
- logger.error(
152
- f"Failed to schedule cron job for registration {registration_id}: {e}"
153
- )
154
- raise
155
-
156
- async def _unschedule_cron_job(self, registration_id: str):
157
- """Remove a cron job from the scheduler."""
158
- if registration_id in self.active_jobs:
159
- job_id = self.active_jobs[registration_id]
160
- try:
161
- self.scheduler.remove_job(job_id)
162
- del self.active_jobs[registration_id]
163
- except Exception as e:
164
- logger.error(f"Failed to unschedule cron job {job_id}: {e}")
165
- raise
166
- else:
167
- logger.warning(
168
- f"Attempted to unschedule non-existent cron job {registration_id}"
169
- )
170
-
171
- async def _execute_cron_job_with_monitoring(self, registration: dict[str, Any]):
172
- """Execute a scheduled cron job with full monitoring and error handling."""
173
- registration_id = registration["id"]
174
- cron_pattern = registration["resource"]["crontab"]
175
-
176
- execution = CronJobExecution(
177
- registration_id=str(registration_id),
178
- cron_pattern=cron_pattern,
179
- scheduled_time=datetime.utcnow(),
180
- actual_start_time=datetime.utcnow(),
181
- status="running",
182
- )
183
-
184
- try:
185
- agents_invoked = await self.execute_cron_job(registration)
186
- execution.status = "completed"
187
- execution.agents_invoked = agents_invoked
188
- logger.info(
189
- f"✓ Cron job {registration_id} completed successfully - invoked {agents_invoked} agent(s)"
190
- )
191
-
192
- except Exception as e:
193
- execution.status = "failed"
194
- execution.error_message = str(e)
195
- logger.error(f"✗ Cron job {registration_id} failed: {e}")
196
-
197
- finally:
198
- execution.completion_time = datetime.utcnow()
199
- await self._record_execution(execution)
200
-
201
- async def execute_cron_job(self, registration: dict[str, Any]) -> int:
202
- """Execute a cron job - invoke agents. Can be called manually or by scheduler."""
203
- registration_id = registration["id"]
204
- user_id = registration["user_id"]
205
- tenant_id = (
206
- registration.get("metadata", {}).get("client_metadata", {}).get("tenant_id")
207
- )
208
-
209
- # Get agent links
210
- agent_links = await self.trigger_server.database.get_agents_for_trigger(
211
- registration_id
212
- )
213
-
214
- if not agent_links:
215
- logger.warning(f"No agents linked to cron job {registration_id}")
216
- return 0
217
-
218
- agents_invoked = 0
219
- for agent_link in agent_links:
220
- agent_id = (
221
- agent_link
222
- if isinstance(agent_link, str)
223
- else agent_link.get("agent_id")
224
- )
225
- # Ensure agent_id and user_id are strings for JSON serialization
226
- agent_id_str = str(agent_id)
227
- user_id_str = str(user_id)
228
- tenant_id_str = str(tenant_id)
229
-
230
- current_time = datetime.utcnow()
231
- current_time_str = current_time.strftime("%A, %B %d, %Y at %H:%M UTC")
232
-
233
- agent_input = {
234
- "messages": [
235
- {
236
- "role": "human",
237
- "content": f"ACTION: triggering cron from langchain-trigger-server\nCURRENT TIME: {current_time_str}",
238
- }
239
- ]
240
- }
241
-
242
- try:
243
- success = await self.trigger_server._invoke_agent(
244
- agent_id=agent_id_str,
245
- user_id=user_id_str,
246
- tenant_id=tenant_id_str,
247
- input_data=agent_input,
248
- )
249
- if success:
250
- agents_invoked += 1
251
-
252
- except Exception as e:
253
- logger.error(
254
- f"✗ Error invoking agent {agent_id_str} for cron job {registration_id}: {e}"
255
- )
256
-
257
- return agents_invoked
258
-
259
- async def _record_execution(self, execution: CronJobExecution):
260
- """Record execution history (in memory for now)."""
261
- self.execution_history.append(execution)
262
-
263
- # Keep only recent executions
264
- if len(self.execution_history) > self.max_history:
265
- self.execution_history = self.execution_history[-self.max_history :]
266
-
267
- def get_active_jobs(self) -> dict[str, str]:
268
- """Get currently active cron jobs."""
269
- return self.active_jobs.copy()
270
-
271
- def get_execution_history(self, limit: int = 100) -> list[CronJobExecution]:
272
- """Get recent execution history."""
273
- return self.execution_history[-limit:]
274
-
275
- def get_job_status(self) -> dict[str, Any]:
276
- """Get status information about the cron manager."""
277
- return {
278
- "active_jobs": len(self.active_jobs),
279
- "scheduler_running": self.scheduler.running,
280
- "total_executions": len(self.execution_history),
281
- "active_job_ids": list(self.active_jobs.keys()),
282
- }
@@ -1 +0,0 @@
1
- Current: 0.3, PyPI: 0.2.10, Should publish: True