langchain-trigger-server 0.3__tar.gz → 0.3.2__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.
- {langchain_trigger_server-0.3 → langchain_trigger_server-0.3.2}/PKG-INFO +1 -1
- {langchain_trigger_server-0.3 → langchain_trigger_server-0.3.2}/langchain_triggers/app.py +49 -4
- {langchain_trigger_server-0.3 → langchain_trigger_server-0.3.2}/langchain_triggers/core.py +8 -0
- langchain_trigger_server-0.3.2/langchain_triggers/cron_manager.py +418 -0
- {langchain_trigger_server-0.3 → langchain_trigger_server-0.3.2}/langchain_triggers/decorators.py +37 -11
- {langchain_trigger_server-0.3 → langchain_trigger_server-0.3.2}/langchain_triggers/triggers/cron_trigger.py +18 -11
- {langchain_trigger_server-0.3 → langchain_trigger_server-0.3.2}/pyproject.toml +1 -1
- langchain_trigger_server-0.3.2/tests/unit/test_cron_manager_polling_filter.py +136 -0
- langchain_trigger_server-0.3.2/tests/unit/test_cron_manager_schedule_validation.py +48 -0
- langchain_trigger_server-0.3.2/version_comparison.txt +1 -0
- langchain_trigger_server-0.3/langchain_triggers/cron_manager.py +0 -282
- langchain_trigger_server-0.3/version_comparison.txt +0 -1
- {langchain_trigger_server-0.3 → langchain_trigger_server-0.3.2}/.github/actions/uv_setup/action.yml +0 -0
- {langchain_trigger_server-0.3 → langchain_trigger_server-0.3.2}/.github/workflows/_lint.yml +0 -0
- {langchain_trigger_server-0.3 → langchain_trigger_server-0.3.2}/.github/workflows/_test.yml +0 -0
- {langchain_trigger_server-0.3 → langchain_trigger_server-0.3.2}/.github/workflows/ci.yml +0 -0
- {langchain_trigger_server-0.3 → langchain_trigger_server-0.3.2}/.github/workflows/release.yml +0 -0
- {langchain_trigger_server-0.3 → langchain_trigger_server-0.3.2}/.gitignore +0 -0
- {langchain_trigger_server-0.3 → langchain_trigger_server-0.3.2}/Makefile +0 -0
- {langchain_trigger_server-0.3 → langchain_trigger_server-0.3.2}/README.md +0 -0
- {langchain_trigger_server-0.3 → langchain_trigger_server-0.3.2}/langchain_triggers/__init__.py +0 -0
- {langchain_trigger_server-0.3 → langchain_trigger_server-0.3.2}/langchain_triggers/auth/__init__.py +0 -0
- {langchain_trigger_server-0.3 → langchain_trigger_server-0.3.2}/langchain_triggers/auth/slack_hmac.py +0 -0
- {langchain_trigger_server-0.3 → langchain_trigger_server-0.3.2}/langchain_triggers/database/__init__.py +0 -0
- {langchain_trigger_server-0.3 → langchain_trigger_server-0.3.2}/langchain_triggers/database/interface.py +0 -0
- {langchain_trigger_server-0.3 → langchain_trigger_server-0.3.2}/langchain_triggers/database/supabase.py +0 -0
- {langchain_trigger_server-0.3 → langchain_trigger_server-0.3.2}/langchain_triggers/triggers/__init__.py +0 -0
- {langchain_trigger_server-0.3 → langchain_trigger_server-0.3.2}/tests/__init__.py +0 -0
- {langchain_trigger_server-0.3 → langchain_trigger_server-0.3.2}/tests/unit/__init__.py +0 -0
- {langchain_trigger_server-0.3 → langchain_trigger_server-0.3.2}/tests/unit/test_trigger_server_api.py +0 -0
- {langchain_trigger_server-0.3 → langchain_trigger_server-0.3.2}/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.3.2
|
|
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
|
|
|
@@ -419,6 +419,29 @@ class TriggerServer:
|
|
|
419
419
|
logger.exception(f"Error creating trigger registration: {e}")
|
|
420
420
|
raise HTTPException(status_code=500, detail=str(e))
|
|
421
421
|
|
|
422
|
+
@self.app.delete("/v1/triggers/registrations/{registration_id}")
|
|
423
|
+
async def api_delete_registration(
|
|
424
|
+
registration_id: str,
|
|
425
|
+
current_user: dict[str, Any] = Depends(get_current_user),
|
|
426
|
+
) -> dict[str, Any]:
|
|
427
|
+
"""Delete a trigger registration."""
|
|
428
|
+
try:
|
|
429
|
+
user_id = current_user["identity"]
|
|
430
|
+
success = await self.database.delete_trigger_registration(
|
|
431
|
+
registration_id, user_id
|
|
432
|
+
)
|
|
433
|
+
if not success:
|
|
434
|
+
raise HTTPException(
|
|
435
|
+
status_code=500, detail="Failed to delete trigger registration"
|
|
436
|
+
)
|
|
437
|
+
return {"success": True}
|
|
438
|
+
|
|
439
|
+
except HTTPException:
|
|
440
|
+
raise
|
|
441
|
+
except Exception as e:
|
|
442
|
+
logger.error(f"Error deleting trigger registration: {e}")
|
|
443
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
444
|
+
|
|
422
445
|
@self.app.get("/v1/triggers/registrations/{registration_id}/agents")
|
|
423
446
|
async def api_list_registration_agents(
|
|
424
447
|
registration_id: str,
|
|
@@ -566,12 +589,34 @@ class TriggerServer:
|
|
|
566
589
|
detail="Trigger registration not found or access denied",
|
|
567
590
|
)
|
|
568
591
|
|
|
569
|
-
# Get the template to check if it's a
|
|
592
|
+
# Get the template to check if it's a polling trigger
|
|
570
593
|
template_id = registration.get("template_id")
|
|
571
|
-
|
|
594
|
+
tmpl = (
|
|
595
|
+
next((t for t in self.triggers if t.id == template_id), None)
|
|
596
|
+
if template_id
|
|
597
|
+
else None
|
|
598
|
+
)
|
|
599
|
+
if not template_id or not tmpl:
|
|
600
|
+
error_reason = (
|
|
601
|
+
"missing_template_id"
|
|
602
|
+
if not template_id
|
|
603
|
+
else "template_not_found"
|
|
604
|
+
)
|
|
605
|
+
logger.error(
|
|
606
|
+
"manual_execute_error registration_id=%s template_id=%s error=%s",
|
|
607
|
+
registration_id,
|
|
608
|
+
template_id,
|
|
609
|
+
error_reason,
|
|
610
|
+
stack_info=True,
|
|
611
|
+
)
|
|
612
|
+
raise HTTPException(status_code=500, detail="Internal server error")
|
|
613
|
+
if (
|
|
614
|
+
getattr(tmpl.trigger_type, "value", tmpl.trigger_type)
|
|
615
|
+
!= TriggerType.POLLING.value
|
|
616
|
+
):
|
|
572
617
|
raise HTTPException(
|
|
573
618
|
status_code=400,
|
|
574
|
-
detail="Manual execution is only supported for
|
|
619
|
+
detail="Manual execution is only supported for polling triggers",
|
|
575
620
|
)
|
|
576
621
|
|
|
577
622
|
# 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
|
+
}
|
{langchain_trigger_server-0.3 → langchain_trigger_server-0.3.2}/langchain_triggers/decorators.py
RENAMED
|
@@ -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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
77
|
-
|
|
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
|
-
"""
|
|
83
|
-
|
|
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=
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
106
|
+
trigger_type=TriggerType.POLLING,
|
|
107
|
+
poll_handler=cron_poll_handler,
|
|
101
108
|
)
|
|
@@ -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.3.2, 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
|
{langchain_trigger_server-0.3 → langchain_trigger_server-0.3.2}/.github/actions/uv_setup/action.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{langchain_trigger_server-0.3 → langchain_trigger_server-0.3.2}/.github/workflows/release.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{langchain_trigger_server-0.3 → langchain_trigger_server-0.3.2}/langchain_triggers/__init__.py
RENAMED
|
File without changes
|
{langchain_trigger_server-0.3 → langchain_trigger_server-0.3.2}/langchain_triggers/auth/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|