langchain-trigger-server 0.1.8__tar.gz → 0.1.10__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.1.8 → langchain_trigger_server-0.1.10}/PKG-INFO +3 -1
- {langchain_trigger_server-0.1.8 → langchain_trigger_server-0.1.10}/langchain_triggers/__init__.py +3 -2
- {langchain_trigger_server-0.1.8 → langchain_trigger_server-0.1.10}/langchain_triggers/app.py +53 -198
- langchain_trigger_server-0.1.10/langchain_triggers/core.py +78 -0
- langchain_trigger_server-0.1.10/langchain_triggers/cron_manager.py +259 -0
- {langchain_trigger_server-0.1.8 → langchain_trigger_server-0.1.10}/langchain_triggers/database/interface.py +7 -7
- {langchain_trigger_server-0.1.8 → langchain_trigger_server-0.1.10}/langchain_triggers/database/supabase.py +17 -33
- {langchain_trigger_server-0.1.8 → langchain_trigger_server-0.1.10}/langchain_triggers/decorators.py +7 -13
- langchain_trigger_server-0.1.10/langchain_triggers/triggers/__init__.py +7 -0
- langchain_trigger_server-0.1.10/langchain_triggers/triggers/cron_trigger.py +97 -0
- {langchain_trigger_server-0.1.8 → langchain_trigger_server-0.1.10}/pyproject.toml +3 -1
- langchain_trigger_server-0.1.8/langchain_triggers/core.py +0 -83
- {langchain_trigger_server-0.1.8 → langchain_trigger_server-0.1.10}/.github/workflows/release.yml +0 -0
- {langchain_trigger_server-0.1.8 → langchain_trigger_server-0.1.10}/README.md +0 -0
- {langchain_trigger_server-0.1.8 → langchain_trigger_server-0.1.10}/langchain_triggers/database/__init__.py +0 -0
- {langchain_trigger_server-0.1.8 → langchain_trigger_server-0.1.10}/test_framework.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: langchain-trigger-server
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.10
|
|
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
|
|
@@ -16,6 +16,8 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.11
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.12
|
|
18
18
|
Requires-Python: >=3.9
|
|
19
|
+
Requires-Dist: apscheduler>=3.10.0
|
|
20
|
+
Requires-Dist: croniter>=1.4.0
|
|
19
21
|
Requires-Dist: cryptography>=3.0.0
|
|
20
22
|
Requires-Dist: fastapi>=0.100.0
|
|
21
23
|
Requires-Dist: httpx>=0.24.0
|
{langchain_trigger_server-0.1.8 → langchain_trigger_server-0.1.10}/langchain_triggers/__init__.py
RENAMED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"""LangChain Triggers Framework - Event-driven triggers for AI agents."""
|
|
2
2
|
|
|
3
|
-
from .core import UserAuthInfo, TriggerRegistrationModel, TriggerHandlerResult, TriggerRegistrationResult
|
|
3
|
+
from .core import UserAuthInfo, TriggerRegistrationModel, TriggerHandlerResult, TriggerRegistrationResult
|
|
4
4
|
from .decorators import TriggerTemplate
|
|
5
5
|
from .app import TriggerServer
|
|
6
|
+
from .triggers.cron_trigger import cron_trigger
|
|
6
7
|
|
|
7
8
|
__version__ = "0.1.0"
|
|
8
9
|
|
|
@@ -11,7 +12,7 @@ __all__ = [
|
|
|
11
12
|
"TriggerRegistrationModel",
|
|
12
13
|
"TriggerHandlerResult",
|
|
13
14
|
"TriggerRegistrationResult",
|
|
14
|
-
"MetadataManager",
|
|
15
15
|
"TriggerTemplate",
|
|
16
16
|
"TriggerServer",
|
|
17
|
+
"cron_trigger",
|
|
17
18
|
]
|
{langchain_trigger_server-0.1.8 → langchain_trigger_server-0.1.10}/langchain_triggers/app.py
RENAMED
|
@@ -6,16 +6,15 @@ import logging
|
|
|
6
6
|
import os
|
|
7
7
|
from typing import Any, Callable, Dict, List, Optional
|
|
8
8
|
|
|
9
|
-
import httpx
|
|
10
|
-
|
|
11
9
|
from fastapi import FastAPI, HTTPException, Request, Depends
|
|
12
10
|
from langgraph_sdk import get_client
|
|
11
|
+
from langchain_auth.client import Client
|
|
13
12
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
14
13
|
from starlette.responses import Response
|
|
15
14
|
|
|
16
|
-
from .core import UserAuthInfo, ProviderAuthInfo, MetadataManager
|
|
17
15
|
from .decorators import TriggerTemplate
|
|
18
16
|
from .database import create_database, TriggerDatabaseInterface
|
|
17
|
+
from .cron_manager import CronTriggerManager
|
|
19
18
|
|
|
20
19
|
logger = logging.getLogger(__name__)
|
|
21
20
|
|
|
@@ -72,7 +71,6 @@ class TriggerServer:
|
|
|
72
71
|
def __init__(
|
|
73
72
|
self,
|
|
74
73
|
auth_handler: Callable,
|
|
75
|
-
langgraph_headers_builder: Callable,
|
|
76
74
|
):
|
|
77
75
|
self.app = FastAPI(
|
|
78
76
|
title="Triggers Server",
|
|
@@ -82,11 +80,10 @@ class TriggerServer:
|
|
|
82
80
|
|
|
83
81
|
self.database = create_database()
|
|
84
82
|
self.auth_handler = auth_handler
|
|
85
|
-
self.langgraph_headers_builder = langgraph_headers_builder
|
|
86
83
|
|
|
87
84
|
# LangGraph configuration
|
|
88
85
|
self.langgraph_api_url = os.getenv("LANGGRAPH_API_URL")
|
|
89
|
-
self.
|
|
86
|
+
self.langsmith_api_key = os.getenv("LANGSMITH_API_KEY")
|
|
90
87
|
|
|
91
88
|
if not self.langgraph_api_url:
|
|
92
89
|
raise ValueError("LANGGRAPH_API_URL environment variable is required")
|
|
@@ -94,32 +91,37 @@ class TriggerServer:
|
|
|
94
91
|
self.langgraph_api_url = self.langgraph_api_url.rstrip("/")
|
|
95
92
|
|
|
96
93
|
# Initialize LangGraph SDK client
|
|
97
|
-
self.langgraph_client = get_client(url=self.langgraph_api_url)
|
|
94
|
+
self.langgraph_client = get_client(url=self.langgraph_api_url, api_key=self.langsmith_api_key)
|
|
98
95
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
logger.warning("LANGCHAIN_API_KEY not found - OAuth token injection disabled")
|
|
108
|
-
except ImportError:
|
|
109
|
-
logger.warning("langchain_auth not installed - OAuth token injection disabled")
|
|
96
|
+
# Initialize LangChain auth client
|
|
97
|
+
langchain_api_key = os.getenv("LANGCHAIN_API_KEY")
|
|
98
|
+
if langchain_api_key:
|
|
99
|
+
self.langchain_auth_client = Client(api_key=langchain_api_key)
|
|
100
|
+
logger.info("✓ LangChain auth client initialized")
|
|
101
|
+
else:
|
|
102
|
+
self.langchain_auth_client = None
|
|
103
|
+
logger.warning("LANGCHAIN_API_KEY not found - OAuth token injection disabled")
|
|
110
104
|
|
|
111
105
|
self.triggers: List[TriggerTemplate] = []
|
|
112
106
|
|
|
107
|
+
# Initialize CronTriggerManager
|
|
108
|
+
self.cron_manager = CronTriggerManager(self)
|
|
109
|
+
|
|
113
110
|
# Setup authentication middleware
|
|
114
111
|
self.app.add_middleware(AuthenticationMiddleware, auth_handler=auth_handler)
|
|
115
112
|
|
|
116
113
|
# Setup routes
|
|
117
114
|
self._setup_routes()
|
|
118
115
|
|
|
119
|
-
# Add startup
|
|
116
|
+
# Add startup and shutdown events
|
|
120
117
|
@self.app.on_event("startup")
|
|
121
118
|
async def startup_event():
|
|
122
119
|
await self.ensure_trigger_templates()
|
|
120
|
+
await self.cron_manager.start()
|
|
121
|
+
|
|
122
|
+
@self.app.on_event("shutdown")
|
|
123
|
+
async def shutdown_event():
|
|
124
|
+
await self.cron_manager.shutdown()
|
|
123
125
|
|
|
124
126
|
def add_trigger(self, trigger: TriggerTemplate) -> None:
|
|
125
127
|
"""Add a trigger template to the app."""
|
|
@@ -255,43 +257,31 @@ class TriggerServer:
|
|
|
255
257
|
resource_dict = registration_instance.model_dump()
|
|
256
258
|
existing_registration = await self.database.find_registration_by_resource(
|
|
257
259
|
template_id=trigger.id,
|
|
258
|
-
resource_data=resource_dict
|
|
259
|
-
user_id=user_id
|
|
260
|
+
resource_data=resource_dict
|
|
260
261
|
)
|
|
261
262
|
|
|
263
|
+
# TODO(sam) figure out how to allow duplicates across users.....very unnatural constraint to have
|
|
262
264
|
if existing_registration:
|
|
263
265
|
raise HTTPException(
|
|
264
266
|
status_code=400,
|
|
265
267
|
detail=f"A registration with this configuration already exists for trigger type '{trigger.id}'. Registration ID: {existing_registration.get('id')}"
|
|
266
268
|
)
|
|
267
269
|
|
|
268
|
-
# Inject OAuth tokens if needed for registration
|
|
269
|
-
auth_user = None
|
|
270
|
-
if trigger.oauth_providers:
|
|
271
|
-
try:
|
|
272
|
-
auth_user = await self._get_authenticated_user(trigger, user_id)
|
|
273
|
-
|
|
274
|
-
# Check if any provider requires authentication - return early if so
|
|
275
|
-
for provider in trigger.oauth_providers.keys():
|
|
276
|
-
provider_info = auth_user.providers.get(provider)
|
|
277
|
-
if provider_info and provider_info.auth_required:
|
|
278
|
-
logger.info(f"User {user_id} needs to authenticate for {provider} - returning auth URL")
|
|
279
|
-
return {
|
|
280
|
-
"success": True,
|
|
281
|
-
"registered": False,
|
|
282
|
-
"auth_required": True,
|
|
283
|
-
"auth_url": provider_info.auth_url,
|
|
284
|
-
"auth_id": provider_info.auth_id,
|
|
285
|
-
"provider": provider
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
except Exception as e:
|
|
289
|
-
logger.error(f"OAuth authentication failed during registration: {e}")
|
|
290
|
-
raise HTTPException(status_code=500, detail="OAuth authentication failed")
|
|
291
|
-
|
|
292
270
|
|
|
293
271
|
# Call the trigger's registration handler with parsed registration model
|
|
294
|
-
result = await trigger.registration_handler(
|
|
272
|
+
result = await trigger.registration_handler(user_id, self.langchain_auth_client, registration_instance)
|
|
273
|
+
|
|
274
|
+
# Check if handler requested to skip registration (e.g., for OAuth or URL verification)
|
|
275
|
+
if not result.create_registration:
|
|
276
|
+
logger.info(f"Registration handler requested to skip database creation")
|
|
277
|
+
from fastapi import Response
|
|
278
|
+
import json
|
|
279
|
+
return Response(
|
|
280
|
+
content=json.dumps(result.response_body),
|
|
281
|
+
status_code=result.status_code,
|
|
282
|
+
media_type="application/json"
|
|
283
|
+
)
|
|
284
|
+
|
|
295
285
|
resource_dict = registration_instance.model_dump()
|
|
296
286
|
|
|
297
287
|
registration = await self.database.create_trigger_registration(
|
|
@@ -304,6 +294,9 @@ class TriggerServer:
|
|
|
304
294
|
if not registration:
|
|
305
295
|
raise HTTPException(status_code=500, detail="Failed to create trigger registration")
|
|
306
296
|
|
|
297
|
+
# Reload cron manager to pick up any new cron registrations
|
|
298
|
+
await self.cron_manager.reload_from_database()
|
|
299
|
+
|
|
307
300
|
# Return registration result
|
|
308
301
|
return {
|
|
309
302
|
"success": True,
|
|
@@ -314,7 +307,7 @@ class TriggerServer:
|
|
|
314
307
|
except HTTPException:
|
|
315
308
|
raise
|
|
316
309
|
except Exception as e:
|
|
317
|
-
logger.
|
|
310
|
+
logger.exception(f"Error creating trigger registration: {e}")
|
|
318
311
|
raise HTTPException(status_code=500, detail=str(e))
|
|
319
312
|
|
|
320
313
|
@self.app.get("/api/triggers/registrations/{registration_id}/agents")
|
|
@@ -418,16 +411,6 @@ class TriggerServer:
|
|
|
418
411
|
except Exception as e:
|
|
419
412
|
logger.error(f"Error unlinking agent from trigger: {e}")
|
|
420
413
|
raise HTTPException(status_code=500, detail=str(e))
|
|
421
|
-
|
|
422
|
-
@self.app.get("/events/subscriptions")
|
|
423
|
-
async def list_event_subscriptions() -> Dict[str, Any]:
|
|
424
|
-
"""List event bus subscriptions."""
|
|
425
|
-
if hasattr(self.event_bus, "list_subscriptions"):
|
|
426
|
-
subscriptions = self.event_bus.list_subscriptions()
|
|
427
|
-
else:
|
|
428
|
-
subscriptions = {}
|
|
429
|
-
|
|
430
|
-
return {"subscriptions": subscriptions}
|
|
431
414
|
|
|
432
415
|
|
|
433
416
|
async def _handle_request(
|
|
@@ -437,24 +420,6 @@ class TriggerServer:
|
|
|
437
420
|
) -> Dict[str, Any]:
|
|
438
421
|
"""Handle an incoming request with a handler function."""
|
|
439
422
|
try:
|
|
440
|
-
# Step 1: API Key Authentication (required for webhooks)
|
|
441
|
-
# Check for API key in header first, then query params (for Pub/Sub compatibility)
|
|
442
|
-
api_key = request.headers.get("x-api-key") or request.query_params.get("api_key")
|
|
443
|
-
if not api_key:
|
|
444
|
-
logger.warning("Webhook request missing x-api-key header or api_key query parameter")
|
|
445
|
-
raise HTTPException(
|
|
446
|
-
status_code=401,
|
|
447
|
-
detail="Missing x-api-key header or api_key query parameter"
|
|
448
|
-
)
|
|
449
|
-
|
|
450
|
-
# Validate API key and get user_id
|
|
451
|
-
user_id = await self.database.validate_api_key(api_key)
|
|
452
|
-
if not user_id:
|
|
453
|
-
logger.warning("Invalid API key provided to webhook")
|
|
454
|
-
raise HTTPException(
|
|
455
|
-
status_code=401,
|
|
456
|
-
detail="Invalid API key"
|
|
457
|
-
)
|
|
458
423
|
|
|
459
424
|
# Parse request data
|
|
460
425
|
if request.method == "POST":
|
|
@@ -466,108 +431,35 @@ class TriggerServer:
|
|
|
466
431
|
payload = {"raw_body": body.decode("utf-8") if body else ""}
|
|
467
432
|
else:
|
|
468
433
|
payload = dict(request.query_params)
|
|
469
|
-
|
|
470
|
-
# Step 2: Registration resolution
|
|
471
|
-
if not trigger.registration_resolver:
|
|
472
|
-
raise HTTPException(
|
|
473
|
-
status_code=500,
|
|
474
|
-
detail=f"Trigger {trigger.id} missing required registration_resolver"
|
|
475
|
-
)
|
|
476
|
-
|
|
477
|
-
# Extract resource identifiers using resolver (gets both query params and payload)
|
|
478
|
-
resource_data = await trigger.registration_resolver(payload, dict(request.query_params))
|
|
479
|
-
|
|
480
|
-
# Find matching registration for the authenticated user
|
|
481
|
-
# Convert Pydantic model to dict for database lookup
|
|
482
|
-
resource_dict = resource_data.model_dump()
|
|
483
|
-
registration = await self.database.find_registration_by_resource(
|
|
484
|
-
trigger.id,
|
|
485
|
-
resource_dict,
|
|
486
|
-
user_id
|
|
487
|
-
)
|
|
488
434
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
raise HTTPException(
|
|
492
|
-
status_code=400,
|
|
493
|
-
detail=f"No registration found for {trigger.id} with resource {resource_dict}"
|
|
494
|
-
)
|
|
495
|
-
|
|
496
|
-
# Step 3: Inject OAuth tokens if needed
|
|
497
|
-
auth_user = None
|
|
498
|
-
if trigger.oauth_providers and self.langchain_auth_client:
|
|
499
|
-
try:
|
|
500
|
-
auth_user = await self._get_authenticated_user(trigger, user_id)
|
|
501
|
-
|
|
502
|
-
# Check if any provider requires re-authentication - this shouldn't happen in webhooks
|
|
503
|
-
for provider in trigger.oauth_providers.keys():
|
|
504
|
-
provider_info = auth_user.providers.get(provider)
|
|
505
|
-
if provider_info and provider_info.auth_required:
|
|
506
|
-
logger.error(f"User {user_id} needs to re-authenticate for {provider} - this should have been handled during registration")
|
|
507
|
-
return {
|
|
508
|
-
"success": False,
|
|
509
|
-
"error": f"Authentication required for {provider}",
|
|
510
|
-
"message": "User needs to re-authenticate this trigger"
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
except Exception as e:
|
|
514
|
-
logger.error(f"OAuth authentication failed: {e}")
|
|
515
|
-
# Continue without auth - triggers can handle missing tokens
|
|
516
|
-
|
|
517
|
-
# Step 4: Create metadata manager
|
|
518
|
-
metadata_manager = MetadataManager(
|
|
519
|
-
database=self.database,
|
|
520
|
-
registration_id=registration["id"],
|
|
521
|
-
initial_metadata=registration.get("metadata", {})
|
|
522
|
-
)
|
|
523
|
-
|
|
524
|
-
# Step 5: Call handler with parsed registration data
|
|
525
|
-
result = await trigger.trigger_handler(payload, auth_user, metadata_manager)
|
|
526
|
-
registration_id = registration["id"]
|
|
527
|
-
|
|
528
|
-
# Check if we should invoke agents
|
|
435
|
+
query_params = dict(request.query_params)
|
|
436
|
+
result = await trigger.trigger_handler(payload, query_params, self.database, self.langchain_auth_client)
|
|
529
437
|
if not result.invoke_agent:
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
"agents_invoked": 0
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
# Get agents linked to this trigger registration
|
|
438
|
+
return result.response_body
|
|
439
|
+
|
|
440
|
+
registration_id = result.registration["id"]
|
|
537
441
|
agent_links = await self.database.get_agents_for_trigger(registration_id)
|
|
538
|
-
|
|
539
|
-
if not agent_links:
|
|
540
|
-
logger.info(f"No agents linked to registration {registration_id}")
|
|
541
|
-
return {
|
|
542
|
-
"success": True,
|
|
543
|
-
"agents_invoked": 0
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
logger.info(f"Processing trigger result for registration {registration_id} with {len(agent_links)} linked agents")
|
|
547
|
-
|
|
548
|
-
# Invoke each linked agent
|
|
442
|
+
|
|
549
443
|
agents_invoked = 0
|
|
550
444
|
for agent_link in agent_links:
|
|
551
445
|
agent_id = agent_link if isinstance(agent_link, str) else agent_link.get("agent_id")
|
|
552
|
-
|
|
553
|
-
# Use the data string from TriggerHandlerResult directly
|
|
446
|
+
|
|
554
447
|
agent_input = {
|
|
555
448
|
"messages": [
|
|
556
|
-
{"role": "human", "content": result.
|
|
449
|
+
{"role": "human", "content": result.agent_message}
|
|
557
450
|
]
|
|
558
451
|
}
|
|
559
452
|
|
|
560
453
|
try:
|
|
561
454
|
success = await self._invoke_agent(
|
|
562
455
|
agent_id=agent_id,
|
|
563
|
-
user_id=registration["user_id"],
|
|
456
|
+
user_id=result.registration["user_id"],
|
|
564
457
|
input_data=agent_input,
|
|
565
458
|
)
|
|
566
459
|
if success:
|
|
567
460
|
agents_invoked += 1
|
|
568
461
|
except Exception as e:
|
|
569
462
|
logger.error(f"Error invoking agent {agent_id}: {e}", exc_info=True)
|
|
570
|
-
|
|
571
463
|
logger.info(f"Processed trigger handler, invoked {agents_invoked} agents")
|
|
572
464
|
|
|
573
465
|
return {
|
|
@@ -595,12 +487,10 @@ class TriggerServer:
|
|
|
595
487
|
logger.info(f"Invoking LangGraph agent {agent_id} for user {user_id}")
|
|
596
488
|
|
|
597
489
|
try:
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
user_id
|
|
601
|
-
|
|
602
|
-
agent_id=agent_id
|
|
603
|
-
)
|
|
490
|
+
headers = {
|
|
491
|
+
"x-auth-scheme": "oap-trigger",
|
|
492
|
+
"x-supabase-user-id": user_id,
|
|
493
|
+
}
|
|
604
494
|
|
|
605
495
|
thread = await self.langgraph_client.threads.create(
|
|
606
496
|
metadata={
|
|
@@ -634,41 +524,6 @@ class TriggerServer:
|
|
|
634
524
|
logger.error(f"Error invoking agent {agent_id}: {e}")
|
|
635
525
|
raise
|
|
636
526
|
|
|
637
|
-
async def _get_authenticated_user(self, trigger: TriggerTemplate, user_id: str) -> UserAuthInfo:
|
|
638
|
-
"""Get authenticated user with OAuth tokens for the trigger's required providers."""
|
|
639
|
-
providers = {}
|
|
640
|
-
|
|
641
|
-
# Get tokens for each required OAuth provider
|
|
642
|
-
for provider, scopes in trigger.oauth_providers.items():
|
|
643
|
-
try:
|
|
644
|
-
auth_result = await self.langchain_auth_client.authenticate(
|
|
645
|
-
provider=provider,
|
|
646
|
-
scopes=scopes,
|
|
647
|
-
user_id=user_id
|
|
648
|
-
)
|
|
649
|
-
|
|
650
|
-
# Debug logging
|
|
651
|
-
logger.info(f"Auth result for {provider}: {vars(auth_result) if hasattr(auth_result, '__dict__') else 'Not available'}")
|
|
652
|
-
|
|
653
|
-
if hasattr(auth_result, 'token') and auth_result.token:
|
|
654
|
-
providers[provider] = ProviderAuthInfo(token=auth_result.token)
|
|
655
|
-
logger.debug(f"Successfully got {provider} token for user {user_id}")
|
|
656
|
-
elif hasattr(auth_result, 'auth_required') and auth_result.auth_required:
|
|
657
|
-
logger.info(f"User {user_id} needs to authenticate for {provider}")
|
|
658
|
-
providers[provider] = ProviderAuthInfo(
|
|
659
|
-
auth_required=True,
|
|
660
|
-
auth_url=getattr(auth_result, 'auth_url', None),
|
|
661
|
-
auth_id=getattr(auth_result, 'auth_id', None)
|
|
662
|
-
)
|
|
663
|
-
else:
|
|
664
|
-
logger.warning(f"No token returned for {provider} provider")
|
|
665
|
-
|
|
666
|
-
except Exception as e:
|
|
667
|
-
logger.error(f"Failed to get {provider} token: {e}")
|
|
668
|
-
# Continue with other providers
|
|
669
|
-
|
|
670
|
-
return UserAuthInfo(user_id=user_id, providers=providers)
|
|
671
|
-
|
|
672
527
|
def get_app(self) -> FastAPI:
|
|
673
528
|
"""Get the FastAPI app instance."""
|
|
674
529
|
return self.app
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Core types and interfaces for the triggers framework."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ProviderAuthInfo(BaseModel):
|
|
14
|
+
"""Authentication info for a specific OAuth provider."""
|
|
15
|
+
|
|
16
|
+
token: Optional[str] = None
|
|
17
|
+
auth_required: bool = False
|
|
18
|
+
auth_url: Optional[str] = None
|
|
19
|
+
auth_id: Optional[str] = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class UserAuthInfo(BaseModel):
|
|
23
|
+
"""User authentication info containing OAuth tokens or auth requirements."""
|
|
24
|
+
|
|
25
|
+
user_id: str
|
|
26
|
+
providers: Dict[str, ProviderAuthInfo] = Field(default_factory=dict)
|
|
27
|
+
|
|
28
|
+
class Config:
|
|
29
|
+
arbitrary_types_allowed = True
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AgentInvocationRequest(BaseModel):
|
|
35
|
+
"""Request to invoke an AI agent."""
|
|
36
|
+
|
|
37
|
+
assistant_id: str
|
|
38
|
+
user_id: str
|
|
39
|
+
input_data: Any
|
|
40
|
+
thread_id: Optional[str] = None
|
|
41
|
+
metadata: Dict[str, Any] = Field(default_factory=dict)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TriggerHandlerResult(BaseModel):
|
|
45
|
+
"""Result returned by trigger handlers."""
|
|
46
|
+
invoke_agent: bool = Field(default=True, description="Whether to invoke agents for this event")
|
|
47
|
+
agent_message: Optional[str] = Field(default=None, description="String message to send to agents")
|
|
48
|
+
response_body: Optional[Dict[str, Any]] = Field(default=None, description="Custom HTTP response body (when invoke_agent=False)")
|
|
49
|
+
registration: Optional[Dict[str, Any]] = Field(default=None, description="Registration data (required when invoke_agent=True)")
|
|
50
|
+
|
|
51
|
+
def model_post_init(self, __context) -> None:
|
|
52
|
+
"""Validate that required fields are provided based on invoke_agent."""
|
|
53
|
+
if self.invoke_agent and not self.agent_message:
|
|
54
|
+
raise ValueError("agent_message is required when invoke_agent=True")
|
|
55
|
+
if self.invoke_agent and not self.registration:
|
|
56
|
+
raise ValueError("registration is required when invoke_agent=True")
|
|
57
|
+
if not self.invoke_agent and not self.response_body:
|
|
58
|
+
raise ValueError("response_body is required when invoke_agent=False")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TriggerRegistrationResult(BaseModel):
|
|
62
|
+
"""Result returned by registration handlers."""
|
|
63
|
+
create_registration: bool = Field(default=True, description="Whether to create database registration (False = return custom response)")
|
|
64
|
+
metadata: Dict[str, Any] = Field(default_factory=dict, description="Metadata to store with the registration")
|
|
65
|
+
response_body: Optional[Dict[str, Any]] = Field(default=None, description="Custom HTTP response body (when create_registration=False)")
|
|
66
|
+
status_code: Optional[int] = Field(default=None, description="HTTP status code (when create_registration=False)")
|
|
67
|
+
|
|
68
|
+
def model_post_init(self, __context) -> None:
|
|
69
|
+
"""Validate that required fields are provided based on create_registration."""
|
|
70
|
+
if self.create_registration and not self.metadata:
|
|
71
|
+
self.metadata = {} # Allow empty metadata for create_registration=True
|
|
72
|
+
if not self.create_registration and (not self.response_body or not self.status_code):
|
|
73
|
+
raise ValueError("Both response_body and status_code are required when create_registration=False")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TriggerRegistrationModel(BaseModel):
|
|
77
|
+
"""Base class for trigger resource models that define how webhooks are matched to registrations."""
|
|
78
|
+
pass
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Dynamic Cron Trigger Manager for scheduled agent execution."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Dict, Any, Optional
|
|
6
|
+
|
|
7
|
+
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
8
|
+
from apscheduler.triggers.cron import CronTrigger as APSCronTrigger
|
|
9
|
+
from croniter import croniter
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
from langchain_triggers.core import TriggerHandlerResult
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CronJobExecution(BaseModel):
|
|
18
|
+
"""Model for tracking cron job execution history."""
|
|
19
|
+
|
|
20
|
+
registration_id: str
|
|
21
|
+
cron_pattern: str
|
|
22
|
+
scheduled_time: datetime
|
|
23
|
+
actual_start_time: datetime
|
|
24
|
+
completion_time: Optional[datetime] = None
|
|
25
|
+
status: str # "running", "completed", "failed"
|
|
26
|
+
error_message: Optional[str] = None
|
|
27
|
+
agents_invoked: int = 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CronTriggerManager:
|
|
31
|
+
"""Manages dynamic cron job scheduling based on database registrations."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, trigger_server):
|
|
34
|
+
self.scheduler = AsyncIOScheduler(timezone='UTC')
|
|
35
|
+
self.trigger_server = trigger_server
|
|
36
|
+
self.active_jobs = {} # registration_id -> job_id mapping
|
|
37
|
+
self.execution_history = [] # Keep recent execution history
|
|
38
|
+
self.max_history = 1000
|
|
39
|
+
|
|
40
|
+
async def start(self):
|
|
41
|
+
"""Start scheduler and load existing cron registrations."""
|
|
42
|
+
try:
|
|
43
|
+
self.scheduler.start()
|
|
44
|
+
await self._load_existing_registrations()
|
|
45
|
+
logger.info("✓ CronTriggerManager started")
|
|
46
|
+
except Exception as e:
|
|
47
|
+
logger.error(f"Failed to start CronTriggerManager: {e}")
|
|
48
|
+
raise
|
|
49
|
+
|
|
50
|
+
async def shutdown(self):
|
|
51
|
+
"""Shutdown scheduler gracefully."""
|
|
52
|
+
try:
|
|
53
|
+
self.scheduler.shutdown(wait=True)
|
|
54
|
+
logger.info("✓ CronTriggerManager stopped")
|
|
55
|
+
except Exception as e:
|
|
56
|
+
logger.error(f"Error shutting down CronTriggerManager: {e}")
|
|
57
|
+
|
|
58
|
+
async def _load_existing_registrations(self):
|
|
59
|
+
"""Load all existing cron registrations from database and schedule them."""
|
|
60
|
+
try:
|
|
61
|
+
registrations = await self.trigger_server.database.get_all_registrations("cron-trigger")
|
|
62
|
+
|
|
63
|
+
scheduled_count = 0
|
|
64
|
+
for registration in registrations:
|
|
65
|
+
if registration.get("status") == "active":
|
|
66
|
+
try:
|
|
67
|
+
await self._schedule_cron_job(registration)
|
|
68
|
+
scheduled_count += 1
|
|
69
|
+
except Exception as e:
|
|
70
|
+
logger.error(f"Failed to schedule existing cron job {registration.get('id')}: {e}")
|
|
71
|
+
|
|
72
|
+
logger.info(f"Loaded {scheduled_count} existing cron registrations from {len(registrations)} total")
|
|
73
|
+
|
|
74
|
+
except Exception as e:
|
|
75
|
+
logger.error(f"Failed to load existing cron registrations: {e}")
|
|
76
|
+
|
|
77
|
+
async def reload_from_database(self):
|
|
78
|
+
"""Reload all cron registrations from database, replacing current schedules."""
|
|
79
|
+
try:
|
|
80
|
+
# Clear all current jobs
|
|
81
|
+
for registration_id in list(self.active_jobs.keys()):
|
|
82
|
+
await self._unschedule_cron_job(registration_id)
|
|
83
|
+
|
|
84
|
+
# Reload from database
|
|
85
|
+
await self._load_existing_registrations()
|
|
86
|
+
logger.info("✓ Reloaded cron jobs from database")
|
|
87
|
+
|
|
88
|
+
except Exception as e:
|
|
89
|
+
logger.error(f"Failed to reload cron jobs from database: {e}")
|
|
90
|
+
raise
|
|
91
|
+
|
|
92
|
+
async def on_registration_created(self, registration: Dict[str, Any]):
|
|
93
|
+
"""Called when a new cron registration is created."""
|
|
94
|
+
if registration.get("trigger_template_id") == "cron-trigger":
|
|
95
|
+
try:
|
|
96
|
+
await self._schedule_cron_job(registration)
|
|
97
|
+
logger.info(f"Scheduled new cron job for registration {registration['id']}")
|
|
98
|
+
except Exception as e:
|
|
99
|
+
logger.error(f"Failed to schedule new cron job {registration['id']}: {e}")
|
|
100
|
+
raise
|
|
101
|
+
|
|
102
|
+
async def on_registration_deleted(self, registration_id: str):
|
|
103
|
+
"""Called when a cron registration is deleted."""
|
|
104
|
+
try:
|
|
105
|
+
await self._unschedule_cron_job(registration_id)
|
|
106
|
+
logger.info(f"Unscheduled cron job for deleted registration {registration_id}")
|
|
107
|
+
except Exception as e:
|
|
108
|
+
logger.error(f"Failed to unschedule cron job {registration_id}: {e}")
|
|
109
|
+
|
|
110
|
+
async def _schedule_cron_job(self, registration: Dict[str, Any]):
|
|
111
|
+
"""Add a cron job to the scheduler."""
|
|
112
|
+
registration_id = registration["id"]
|
|
113
|
+
resource_data = registration.get("resource", {})
|
|
114
|
+
crontab = resource_data.get("crontab", "")
|
|
115
|
+
|
|
116
|
+
if not crontab:
|
|
117
|
+
raise ValueError(f"No crontab pattern found in registration {registration_id}")
|
|
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
|
+
logger.info(f"✓ Scheduled cron job: '{crontab}' for registration {registration_id}")
|
|
150
|
+
|
|
151
|
+
except Exception as e:
|
|
152
|
+
logger.error(f"Failed to schedule cron job for registration {registration_id}: {e}")
|
|
153
|
+
raise
|
|
154
|
+
|
|
155
|
+
async def _unschedule_cron_job(self, registration_id: str):
|
|
156
|
+
"""Remove a cron job from the scheduler."""
|
|
157
|
+
if registration_id in self.active_jobs:
|
|
158
|
+
job_id = self.active_jobs[registration_id]
|
|
159
|
+
try:
|
|
160
|
+
self.scheduler.remove_job(job_id)
|
|
161
|
+
del self.active_jobs[registration_id]
|
|
162
|
+
logger.info(f"✓ Unscheduled cron job for registration {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(f"Attempted to unschedule non-existent cron job {registration_id}")
|
|
168
|
+
|
|
169
|
+
async def _execute_cron_job_with_monitoring(self, registration: Dict[str, Any]):
|
|
170
|
+
"""Execute a scheduled cron job with full monitoring and error handling."""
|
|
171
|
+
registration_id = registration["id"]
|
|
172
|
+
cron_pattern = registration["resource"]["crontab"]
|
|
173
|
+
|
|
174
|
+
execution = CronJobExecution(
|
|
175
|
+
registration_id=registration_id,
|
|
176
|
+
cron_pattern=cron_pattern,
|
|
177
|
+
scheduled_time=datetime.utcnow(),
|
|
178
|
+
actual_start_time=datetime.utcnow(),
|
|
179
|
+
status="running"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
logger.info(f"🕐 Executing cron job {registration_id} with pattern '{cron_pattern}'")
|
|
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(f"✓ Cron job {registration_id} completed - invoked {agents_invoked} agents")
|
|
189
|
+
|
|
190
|
+
except Exception as e:
|
|
191
|
+
execution.status = "failed"
|
|
192
|
+
execution.error_message = str(e)
|
|
193
|
+
logger.error(f"✗ Cron job {registration_id} failed: {e}")
|
|
194
|
+
|
|
195
|
+
finally:
|
|
196
|
+
execution.completion_time = datetime.utcnow()
|
|
197
|
+
await self._record_execution(execution)
|
|
198
|
+
|
|
199
|
+
async def _execute_cron_job(self, registration: Dict[str, Any]) -> int:
|
|
200
|
+
"""Execute a scheduled cron job - invoke agents."""
|
|
201
|
+
registration_id = registration["id"]
|
|
202
|
+
user_id = registration["user_id"]
|
|
203
|
+
|
|
204
|
+
# Get agent links
|
|
205
|
+
agent_links = await self.trigger_server.database.get_agents_for_trigger(registration_id)
|
|
206
|
+
|
|
207
|
+
if not agent_links:
|
|
208
|
+
logger.warning(f"No agents linked to cron job {registration_id}")
|
|
209
|
+
return 0
|
|
210
|
+
|
|
211
|
+
agents_invoked = 0
|
|
212
|
+
for agent_link in agent_links:
|
|
213
|
+
agent_id = agent_link if isinstance(agent_link, str) else agent_link.get("agent_id")
|
|
214
|
+
|
|
215
|
+
agent_input = {
|
|
216
|
+
"messages": [
|
|
217
|
+
{"role": "human", "content": ""}
|
|
218
|
+
]
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
success = await self.trigger_server._invoke_agent(
|
|
223
|
+
agent_id=agent_id,
|
|
224
|
+
user_id=user_id,
|
|
225
|
+
input_data=agent_input,
|
|
226
|
+
)
|
|
227
|
+
if success:
|
|
228
|
+
agents_invoked += 1
|
|
229
|
+
logger.info(f"✓ Invoked agent {agent_id} for cron job {registration_id}")
|
|
230
|
+
|
|
231
|
+
except Exception as e:
|
|
232
|
+
logger.error(f"✗ Error invoking agent {agent_id} for cron job {registration_id}: {e}")
|
|
233
|
+
|
|
234
|
+
return agents_invoked
|
|
235
|
+
|
|
236
|
+
async def _record_execution(self, execution: CronJobExecution):
|
|
237
|
+
"""Record execution history (in memory for now)."""
|
|
238
|
+
self.execution_history.append(execution)
|
|
239
|
+
|
|
240
|
+
# Keep only recent executions
|
|
241
|
+
if len(self.execution_history) > self.max_history:
|
|
242
|
+
self.execution_history = self.execution_history[-self.max_history:]
|
|
243
|
+
|
|
244
|
+
def get_active_jobs(self) -> Dict[str, str]:
|
|
245
|
+
"""Get currently active cron jobs."""
|
|
246
|
+
return self.active_jobs.copy()
|
|
247
|
+
|
|
248
|
+
def get_execution_history(self, limit: int = 100) -> list[CronJobExecution]:
|
|
249
|
+
"""Get recent execution history."""
|
|
250
|
+
return self.execution_history[-limit:]
|
|
251
|
+
|
|
252
|
+
def get_job_status(self) -> Dict[str, Any]:
|
|
253
|
+
"""Get status information about the cron manager."""
|
|
254
|
+
return {
|
|
255
|
+
"active_jobs": len(self.active_jobs),
|
|
256
|
+
"scheduler_running": self.scheduler.running,
|
|
257
|
+
"total_executions": len(self.execution_history),
|
|
258
|
+
"active_job_ids": list(self.active_jobs.keys())
|
|
259
|
+
}
|
|
@@ -61,10 +61,14 @@ class TriggerDatabaseInterface(ABC):
|
|
|
61
61
|
async def find_registration_by_resource(
|
|
62
62
|
self,
|
|
63
63
|
template_id: str,
|
|
64
|
-
resource_data: Dict[str, Any]
|
|
65
|
-
user_id: str
|
|
64
|
+
resource_data: Dict[str, Any]
|
|
66
65
|
) -> Optional[Dict[str, Any]]:
|
|
67
|
-
"""Find trigger registration by matching resource data
|
|
66
|
+
"""Find trigger registration by matching resource data."""
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
@abstractmethod
|
|
70
|
+
async def get_all_registrations(self, template_id: str) -> List[Dict[str, Any]]:
|
|
71
|
+
"""Get all registrations for a specific trigger template."""
|
|
68
72
|
pass
|
|
69
73
|
|
|
70
74
|
@abstractmethod
|
|
@@ -130,7 +134,3 @@ class TriggerDatabaseInterface(ABC):
|
|
|
130
134
|
"""Get user ID by email from trigger registrations."""
|
|
131
135
|
pass
|
|
132
136
|
|
|
133
|
-
@abstractmethod
|
|
134
|
-
async def validate_api_key(self, api_key: str) -> Optional[str]:
|
|
135
|
-
"""Validate API key and return user_id if valid, None if invalid."""
|
|
136
|
-
pass
|
|
@@ -143,7 +143,7 @@ class SupabaseTriggerDatabase(TriggerDatabaseInterface):
|
|
|
143
143
|
return response.data[0] if response.data else None
|
|
144
144
|
|
|
145
145
|
except Exception as e:
|
|
146
|
-
logger.
|
|
146
|
+
logger.exception(f"Error creating trigger registration: {e}")
|
|
147
147
|
return None
|
|
148
148
|
|
|
149
149
|
async def get_user_trigger_registrations(self, user_id: str) -> List[Dict[str, Any]]:
|
|
@@ -225,15 +225,14 @@ class SupabaseTriggerDatabase(TriggerDatabaseInterface):
|
|
|
225
225
|
async def find_registration_by_resource(
|
|
226
226
|
self,
|
|
227
227
|
template_id: str,
|
|
228
|
-
resource_data: Dict[str, Any]
|
|
229
|
-
user_id: str
|
|
228
|
+
resource_data: Dict[str, Any]
|
|
230
229
|
) -> Optional[Dict[str, Any]]:
|
|
231
|
-
"""Find trigger registration by matching resource data
|
|
230
|
+
"""Find trigger registration by matching resource data."""
|
|
232
231
|
try:
|
|
233
|
-
# Build query to match against trigger_registrations with template_id
|
|
232
|
+
# Build query to match against trigger_registrations with template_id filter
|
|
234
233
|
query = self.client.table("trigger_registrations").select(
|
|
235
234
|
"*, trigger_templates(id, name, description)"
|
|
236
|
-
).eq("trigger_templates.id", template_id)
|
|
235
|
+
).eq("trigger_templates.id", template_id)
|
|
237
236
|
|
|
238
237
|
# Add resource field matches
|
|
239
238
|
for field, value in resource_data.items():
|
|
@@ -249,6 +248,18 @@ class SupabaseTriggerDatabase(TriggerDatabaseInterface):
|
|
|
249
248
|
logger.error(f"Error finding registration by resource: {e}")
|
|
250
249
|
return None
|
|
251
250
|
|
|
251
|
+
async def get_all_registrations(self, template_id: str) -> List[Dict[str, Any]]:
|
|
252
|
+
"""Get all registrations for a specific trigger template."""
|
|
253
|
+
try:
|
|
254
|
+
response = self.client.table("trigger_registrations").select(
|
|
255
|
+
"*, trigger_templates(id, name, description)"
|
|
256
|
+
).eq("template_id", template_id).execute()
|
|
257
|
+
|
|
258
|
+
return response.data or []
|
|
259
|
+
except Exception as e:
|
|
260
|
+
logger.error(f"Error getting all registrations for template {template_id}: {e}")
|
|
261
|
+
return []
|
|
262
|
+
|
|
252
263
|
# ========== Agent-Trigger Links ==========
|
|
253
264
|
|
|
254
265
|
async def link_agent_to_trigger(
|
|
@@ -346,30 +357,3 @@ class SupabaseTriggerDatabase(TriggerDatabaseInterface):
|
|
|
346
357
|
logger.error(f"Error getting user by email: {e}")
|
|
347
358
|
return None
|
|
348
359
|
|
|
349
|
-
async def validate_api_key(self, api_key: str) -> Optional[str]:
|
|
350
|
-
"""Validate API key and return user_id if valid, None if invalid."""
|
|
351
|
-
try:
|
|
352
|
-
# Query all user API keys to find a match
|
|
353
|
-
response = self.client.table("user_api_keys").select("user_id, key_hash").execute()
|
|
354
|
-
|
|
355
|
-
if not response.data:
|
|
356
|
-
return None
|
|
357
|
-
|
|
358
|
-
# Check each encrypted key to see if it matches the provided key
|
|
359
|
-
for row in response.data:
|
|
360
|
-
try:
|
|
361
|
-
decrypted_key = self._decrypt_secret(row["key_hash"])
|
|
362
|
-
if decrypted_key == api_key:
|
|
363
|
-
logger.info(f"Valid API key authenticated for user: {row['user_id']}")
|
|
364
|
-
return row["user_id"]
|
|
365
|
-
except Exception as e:
|
|
366
|
-
# Skip keys that fail to decrypt
|
|
367
|
-
logger.debug(f"Failed to decrypt API key: {e}")
|
|
368
|
-
continue
|
|
369
|
-
|
|
370
|
-
logger.warning(f"Invalid API key provided")
|
|
371
|
-
return None
|
|
372
|
-
|
|
373
|
-
except Exception as e:
|
|
374
|
-
logger.error(f"Error validating API key: {e}")
|
|
375
|
-
return None
|
{langchain_trigger_server-0.1.8 → langchain_trigger_server-0.1.10}/langchain_triggers/decorators.py
RENAMED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import inspect
|
|
6
|
-
from typing import Any,
|
|
7
|
-
from .
|
|
6
|
+
from typing import Any, Dict, List, Type, get_type_hints
|
|
7
|
+
from langchain_auth.client import Client
|
|
8
|
+
from .core import TriggerHandlerResult, TriggerRegistrationResult
|
|
8
9
|
from pydantic import BaseModel
|
|
9
10
|
|
|
10
11
|
class TriggerTemplate:
|
|
@@ -19,9 +20,6 @@ class TriggerTemplate:
|
|
|
19
20
|
|
|
20
21
|
registration_handler,
|
|
21
22
|
trigger_handler,
|
|
22
|
-
registration_resolver,
|
|
23
|
-
|
|
24
|
-
oauth_providers: Optional[Dict[str, List[str]]] = None,
|
|
25
23
|
):
|
|
26
24
|
self.id = id
|
|
27
25
|
self.name = name
|
|
@@ -29,21 +27,17 @@ class TriggerTemplate:
|
|
|
29
27
|
self.registration_model = registration_model
|
|
30
28
|
self.registration_handler = registration_handler
|
|
31
29
|
self.trigger_handler = trigger_handler
|
|
32
|
-
self.registration_resolver = registration_resolver
|
|
33
|
-
self.oauth_providers = oauth_providers or {}
|
|
34
30
|
|
|
35
31
|
self._validate_handler_signatures()
|
|
36
32
|
|
|
37
33
|
def _validate_handler_signatures(self):
|
|
38
34
|
"""Validate that all handler functions have the correct signatures."""
|
|
39
|
-
# Expected: async def handler(
|
|
40
|
-
self._validate_handler("registration_handler", self.registration_handler, [self.registration_model
|
|
35
|
+
# Expected: async def handler(user_id: str, auth_client: Client, registration: RegistrationModel) -> TriggerRegistrationResult
|
|
36
|
+
self._validate_handler("registration_handler", self.registration_handler, [str, Client, self.registration_model], TriggerRegistrationResult)
|
|
41
37
|
|
|
42
|
-
# Expected: async def handler(payload: Dict[str, Any],
|
|
43
|
-
self._validate_handler("trigger_handler", self.trigger_handler, [Dict[str, Any],
|
|
38
|
+
# Expected: async def handler(payload: Dict[str, Any], query_params: Dict[str, str], database, auth_client: Client) -> TriggerHandlerResult
|
|
39
|
+
self._validate_handler("trigger_handler", self.trigger_handler, [Dict[str, Any], Dict[str, str], Any, Client], TriggerHandlerResult)
|
|
44
40
|
|
|
45
|
-
# Expected: async def resolver(payload: Dict[str, Any], query_params: Dict[str, str]) -> RegistrationModel
|
|
46
|
-
self._validate_handler("registration_resolver", self.registration_resolver, [Dict[str, Any], Dict[str, str]], self.registration_model)
|
|
47
41
|
|
|
48
42
|
def _validate_handler(self, handler_name: str, handler_func, expected_types: List[Type], expected_return_type: Type = None):
|
|
49
43
|
"""Common validation logic for all handler functions."""
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Cron-based trigger for scheduled agent execution."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Dict, Any
|
|
6
|
+
|
|
7
|
+
from croniter import croniter
|
|
8
|
+
from langchain_auth.client import Client
|
|
9
|
+
from pydantic import Field
|
|
10
|
+
|
|
11
|
+
from langchain_triggers.core import (
|
|
12
|
+
TriggerRegistrationModel,
|
|
13
|
+
TriggerHandlerResult,
|
|
14
|
+
TriggerRegistrationResult,
|
|
15
|
+
)
|
|
16
|
+
from langchain_triggers.decorators import TriggerTemplate
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CronRegistration(TriggerRegistrationModel):
|
|
22
|
+
"""Registration model for cron triggers - just a crontab pattern."""
|
|
23
|
+
|
|
24
|
+
crontab: str = Field(
|
|
25
|
+
...,
|
|
26
|
+
description="Cron pattern (e.g., '0 9 * * MON-FRI', '*/15 * * * *')",
|
|
27
|
+
examples=["0 9 * * MON-FRI", "*/15 * * * *", "0 2 * * SUN"],
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def cron_registration_handler(
|
|
32
|
+
user_id: str, auth_client: Client, registration: CronRegistration
|
|
33
|
+
) -> TriggerRegistrationResult:
|
|
34
|
+
"""Handle cron trigger registration - validates cron pattern and prepares for scheduling."""
|
|
35
|
+
logger.info(f"Cron registration request: {registration}")
|
|
36
|
+
|
|
37
|
+
cron_pattern = registration.crontab.strip()
|
|
38
|
+
|
|
39
|
+
# Validate cron pattern
|
|
40
|
+
try:
|
|
41
|
+
if not croniter.is_valid(cron_pattern):
|
|
42
|
+
return TriggerRegistrationResult(
|
|
43
|
+
create_registration=False,
|
|
44
|
+
response_body={
|
|
45
|
+
"success": False,
|
|
46
|
+
"error": "invalid_cron_pattern",
|
|
47
|
+
"message": f"Invalid cron pattern: '{cron_pattern}'"
|
|
48
|
+
},
|
|
49
|
+
status_code=400
|
|
50
|
+
)
|
|
51
|
+
except Exception as e:
|
|
52
|
+
return TriggerRegistrationResult(
|
|
53
|
+
create_registration=False,
|
|
54
|
+
response_body={
|
|
55
|
+
"success": False,
|
|
56
|
+
"error": "cron_validation_failed",
|
|
57
|
+
"message": f"Failed to validate cron pattern: {str(e)}"
|
|
58
|
+
},
|
|
59
|
+
status_code=400
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
logger.info(f"Successfully validated cron pattern: {cron_pattern}")
|
|
63
|
+
return TriggerRegistrationResult(
|
|
64
|
+
metadata={
|
|
65
|
+
"cron_pattern": cron_pattern,
|
|
66
|
+
"timezone": "UTC",
|
|
67
|
+
"created_at": datetime.utcnow().isoformat(),
|
|
68
|
+
"validated": True
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def cron_trigger_handler(
|
|
74
|
+
payload: Dict[str, Any],
|
|
75
|
+
query_params: Dict[str, str],
|
|
76
|
+
database,
|
|
77
|
+
auth_client: Client,
|
|
78
|
+
) -> TriggerHandlerResult:
|
|
79
|
+
"""Cron trigger handler - this should never be called via HTTP."""
|
|
80
|
+
logger.warning("Cron trigger handler called via HTTP - this shouldn't happen")
|
|
81
|
+
return TriggerHandlerResult(
|
|
82
|
+
invoke_agent=False,
|
|
83
|
+
response_body={
|
|
84
|
+
"success": False,
|
|
85
|
+
"message": "Cron triggers are executed by scheduler, not HTTP requests"
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
cron_trigger = TriggerTemplate(
|
|
91
|
+
id="cron-trigger",
|
|
92
|
+
name="Cron Scheduler",
|
|
93
|
+
description="Triggers agents on a cron schedule",
|
|
94
|
+
registration_model=CronRegistration,
|
|
95
|
+
registration_handler=cron_registration_handler,
|
|
96
|
+
trigger_handler=cron_trigger_handler,
|
|
97
|
+
)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "langchain-trigger-server"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.10"
|
|
8
8
|
description = "Generic event-driven triggers framework"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -33,6 +33,8 @@ dependencies = [
|
|
|
33
33
|
"langgraph-sdk>=0.2.6",
|
|
34
34
|
"supabase>=2.0.0",
|
|
35
35
|
"cryptography>=3.0.0",
|
|
36
|
+
"APScheduler>=3.10.0",
|
|
37
|
+
"croniter>=1.4.0",
|
|
36
38
|
]
|
|
37
39
|
|
|
38
40
|
[project.optional-dependencies]
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
"""Core types and interfaces for the triggers framework."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from typing import Any, Dict, Optional
|
|
6
|
-
from pydantic import BaseModel, Field
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class ProviderAuthInfo(BaseModel):
|
|
11
|
-
"""Authentication info for a specific OAuth provider."""
|
|
12
|
-
|
|
13
|
-
token: Optional[str] = None
|
|
14
|
-
auth_required: bool = False
|
|
15
|
-
auth_url: Optional[str] = None
|
|
16
|
-
auth_id: Optional[str] = None
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
class UserAuthInfo(BaseModel):
|
|
20
|
-
"""User authentication info containing OAuth tokens or auth requirements."""
|
|
21
|
-
|
|
22
|
-
user_id: str
|
|
23
|
-
providers: Dict[str, ProviderAuthInfo] = Field(default_factory=dict)
|
|
24
|
-
|
|
25
|
-
class Config:
|
|
26
|
-
arbitrary_types_allowed = True
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
class MetadataManager:
|
|
30
|
-
"""Manages trigger registration metadata with database persistence."""
|
|
31
|
-
|
|
32
|
-
def __init__(self, database: Any, registration_id: str, initial_metadata: Dict[str, Any]):
|
|
33
|
-
self.database = database
|
|
34
|
-
self.registration_id = registration_id
|
|
35
|
-
self.metadata = initial_metadata.copy()
|
|
36
|
-
|
|
37
|
-
def get(self, key: str, default: Any = None) -> Any:
|
|
38
|
-
"""Get a metadata value by key."""
|
|
39
|
-
return self.metadata.get(key, default)
|
|
40
|
-
|
|
41
|
-
async def update(self, updates: Dict[str, Any]) -> None:
|
|
42
|
-
"""Update metadata and persist to database."""
|
|
43
|
-
# Update local state
|
|
44
|
-
self.metadata.update(updates)
|
|
45
|
-
|
|
46
|
-
# Persist to database
|
|
47
|
-
await self.database.update_trigger_metadata(self.registration_id, updates)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
class AgentInvocationRequest(BaseModel):
|
|
52
|
-
"""Request to invoke an AI agent."""
|
|
53
|
-
|
|
54
|
-
assistant_id: str
|
|
55
|
-
user_id: str
|
|
56
|
-
input_data: Any
|
|
57
|
-
thread_id: Optional[str] = None
|
|
58
|
-
metadata: Dict[str, Any] = Field(default_factory=dict)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
class TriggerHandlerResult(BaseModel):
|
|
62
|
-
"""Result returned by trigger handlers."""
|
|
63
|
-
|
|
64
|
-
invoke_agent: bool = Field(default=True, description="Whether to invoke agents for this event")
|
|
65
|
-
data: Optional[str] = Field(default=None, description="String data to send to agents")
|
|
66
|
-
|
|
67
|
-
def model_post_init(self, __context) -> None:
|
|
68
|
-
"""Validate that data is provided when invoke_agent is True."""
|
|
69
|
-
if self.invoke_agent and not self.data:
|
|
70
|
-
raise ValueError("data field is required when invoke_agent is True")
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
class TriggerRegistrationResult(BaseModel):
|
|
74
|
-
"""Result returned by registration handlers."""
|
|
75
|
-
|
|
76
|
-
metadata: Dict[str, Any] = Field(default_factory=dict, description="Metadata to store with the registration")
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
class TriggerRegistrationModel(BaseModel):
|
|
80
|
-
"""Base class for trigger resource models that define how webhooks are matched to registrations."""
|
|
81
|
-
|
|
82
|
-
class Config:
|
|
83
|
-
arbitrary_types_allowed = True
|
{langchain_trigger_server-0.1.8 → langchain_trigger_server-0.1.10}/.github/workflows/release.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|