langchain-trigger-server 0.3.22__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- langchain_trigger_server-0.3.22.dist-info/METADATA +38 -0
- langchain_trigger_server-0.3.22.dist-info/RECORD +14 -0
- langchain_trigger_server-0.3.22.dist-info/WHEEL +4 -0
- langchain_triggers/__init__.py +22 -0
- langchain_triggers/app.py +676 -0
- langchain_triggers/auth/__init__.py +3 -0
- langchain_triggers/core.py +59 -0
- langchain_triggers/cron_manager.py +329 -0
- langchain_triggers/database/__init__.py +5 -0
- langchain_triggers/database/interface.py +107 -0
- langchain_triggers/decorators.py +141 -0
- langchain_triggers/triggers/__init__.py +7 -0
- langchain_triggers/triggers/cron_trigger.py +177 -0
- langchain_triggers/util.py +122 -0
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
"""FastAPI application for trigger server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
from datetime import UTC, datetime, timedelta
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import jwt
|
|
13
|
+
from fastapi import Depends, FastAPI, HTTPException, Request
|
|
14
|
+
from langchain_auth.client import Client
|
|
15
|
+
from langgraph_sdk import get_client
|
|
16
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
17
|
+
from starlette.responses import Response
|
|
18
|
+
from starlette.types import Lifespan
|
|
19
|
+
|
|
20
|
+
from .core import TriggerType
|
|
21
|
+
from .cron_manager import CronTriggerManager
|
|
22
|
+
from .database import TriggerDatabaseInterface
|
|
23
|
+
from .decorators import TriggerTemplate
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_x_service_jwt_token(
|
|
29
|
+
payload: dict[str, Any] | None = None, expiration_seconds: int = 60 * 60
|
|
30
|
+
) -> str:
|
|
31
|
+
exp_datetime = datetime.now(tz=UTC) + timedelta(seconds=expiration_seconds)
|
|
32
|
+
exp = int(exp_datetime.timestamp())
|
|
33
|
+
|
|
34
|
+
payload = payload or {}
|
|
35
|
+
payload = {
|
|
36
|
+
"sub": "unspecified",
|
|
37
|
+
"exp": exp,
|
|
38
|
+
**payload,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
secret = os.environ["X_SERVICE_AUTH_JWT_SECRET"]
|
|
42
|
+
|
|
43
|
+
return jwt.encode(
|
|
44
|
+
payload,
|
|
45
|
+
secret,
|
|
46
|
+
algorithm="HS256",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AuthenticationMiddleware(BaseHTTPMiddleware):
|
|
51
|
+
"""Middleware to handle authentication for API endpoints."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, app, auth_handler: Callable):
|
|
54
|
+
super().__init__(app)
|
|
55
|
+
self.auth_handler = auth_handler
|
|
56
|
+
|
|
57
|
+
async def dispatch(self, request: Request, call_next):
|
|
58
|
+
# Skip auth for webhooks, health/root endpoints, and OPTIONS requests
|
|
59
|
+
if (
|
|
60
|
+
request.url.path.startswith("/v1/triggers/webhooks/")
|
|
61
|
+
or request.url.path in ["/", "/health"]
|
|
62
|
+
or request.method == "OPTIONS"
|
|
63
|
+
):
|
|
64
|
+
return await call_next(request)
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
identity = await self.auth_handler({}, dict(request.headers))
|
|
68
|
+
if (
|
|
69
|
+
not identity
|
|
70
|
+
or not identity.get("identity")
|
|
71
|
+
or not identity.get("tenant_id")
|
|
72
|
+
):
|
|
73
|
+
logger.error(
|
|
74
|
+
f"Authentication failed: missing required fields (identity={bool(identity.get('identity') if identity else None)}, tenant_id={bool(identity.get('tenant_id') if identity else None)})"
|
|
75
|
+
)
|
|
76
|
+
return Response(
|
|
77
|
+
content='{"detail": "Authentication required - identity and tenant_id must be provided"}',
|
|
78
|
+
status_code=401,
|
|
79
|
+
media_type="application/json",
|
|
80
|
+
)
|
|
81
|
+
request.state.current_user = identity
|
|
82
|
+
|
|
83
|
+
except Exception as e:
|
|
84
|
+
logger.error(f"Authentication middleware error: {e}")
|
|
85
|
+
return Response(
|
|
86
|
+
content='{"detail": "Authentication failed"}',
|
|
87
|
+
status_code=401,
|
|
88
|
+
media_type="application/json",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
return await call_next(request)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def get_current_user(request: Request) -> dict[str, Any]:
|
|
95
|
+
"""FastAPI dependency to get the current authenticated user."""
|
|
96
|
+
if not hasattr(request.state, "current_user"):
|
|
97
|
+
raise HTTPException(status_code=401, detail="Authentication required")
|
|
98
|
+
return request.state.current_user
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TriggerServer:
|
|
102
|
+
"""FastAPI application for trigger webhooks."""
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
auth_handler: Callable,
|
|
107
|
+
database: TriggerDatabaseInterface,
|
|
108
|
+
lifespan: Lifespan | None = None,
|
|
109
|
+
):
|
|
110
|
+
# Configure uvicorn logging to use consistent formatting
|
|
111
|
+
self._configure_uvicorn_logging()
|
|
112
|
+
|
|
113
|
+
self.database = database
|
|
114
|
+
self.auth_handler = auth_handler
|
|
115
|
+
|
|
116
|
+
# LangGraph configuration
|
|
117
|
+
self.langgraph_api_url = os.getenv("LANGGRAPH_API_URL")
|
|
118
|
+
self.trigger_server_auth_api_url = os.getenv("TRIGGER_SERVER_HOST_API_URL")
|
|
119
|
+
|
|
120
|
+
if not self.langgraph_api_url:
|
|
121
|
+
raise ValueError("LANGGRAPH_API_URL environment variable is required")
|
|
122
|
+
|
|
123
|
+
self.langgraph_api_url = self.langgraph_api_url.rstrip("/")
|
|
124
|
+
|
|
125
|
+
# Initialize LangGraph SDK client
|
|
126
|
+
self.langgraph_client = get_client(url=self.langgraph_api_url, api_key=None)
|
|
127
|
+
logger.info(
|
|
128
|
+
f"✓ LangGraph client initialized with URL: {self.langgraph_api_url}"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Initialize LangChain auth client
|
|
132
|
+
langchain_api_key = os.getenv("LANGCHAIN_API_KEY")
|
|
133
|
+
if langchain_api_key:
|
|
134
|
+
self.langchain_auth_client = Client(
|
|
135
|
+
api_key=langchain_api_key, api_url=self.trigger_server_auth_api_url
|
|
136
|
+
)
|
|
137
|
+
logger.info("✓ LangChain auth client initialized")
|
|
138
|
+
else:
|
|
139
|
+
self.langchain_auth_client = None
|
|
140
|
+
logger.warning(
|
|
141
|
+
"LANGCHAIN_API_KEY not found - OAuth token injection disabled"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
self.triggers: list[TriggerTemplate] = []
|
|
145
|
+
|
|
146
|
+
# Initialize CronTriggerManager
|
|
147
|
+
self.cron_manager = CronTriggerManager(self)
|
|
148
|
+
|
|
149
|
+
# Create merged lifespan that includes cron manager lifecycle
|
|
150
|
+
merged_lifespan = self._create_lifespan(lifespan)
|
|
151
|
+
|
|
152
|
+
self.app = FastAPI(
|
|
153
|
+
title="Triggers Server",
|
|
154
|
+
description="Event-driven triggers framework",
|
|
155
|
+
version="0.1.0",
|
|
156
|
+
lifespan=merged_lifespan,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Setup authentication middleware
|
|
160
|
+
self.app.add_middleware(AuthenticationMiddleware, auth_handler=auth_handler)
|
|
161
|
+
|
|
162
|
+
# Setup routes
|
|
163
|
+
self._setup_routes()
|
|
164
|
+
|
|
165
|
+
def _configure_uvicorn_logging(self) -> None:
|
|
166
|
+
"""Configure uvicorn loggers to use consistent formatting for production deployments."""
|
|
167
|
+
formatter = logging.Formatter("%(levelname)s: %(name)s - %(message)s")
|
|
168
|
+
|
|
169
|
+
# Configure uvicorn access logger
|
|
170
|
+
uvicorn_access_logger = logging.getLogger("uvicorn.access")
|
|
171
|
+
uvicorn_access_logger.handlers.clear()
|
|
172
|
+
access_handler = logging.StreamHandler()
|
|
173
|
+
access_handler.setFormatter(formatter)
|
|
174
|
+
uvicorn_access_logger.addHandler(access_handler)
|
|
175
|
+
|
|
176
|
+
# Configure uvicorn error logger
|
|
177
|
+
uvicorn_error_logger = logging.getLogger("uvicorn.error")
|
|
178
|
+
uvicorn_error_logger.handlers.clear()
|
|
179
|
+
error_handler = logging.StreamHandler()
|
|
180
|
+
error_handler.setFormatter(formatter)
|
|
181
|
+
uvicorn_error_logger.addHandler(error_handler)
|
|
182
|
+
|
|
183
|
+
# Configure uvicorn main logger
|
|
184
|
+
uvicorn_logger = logging.getLogger("uvicorn")
|
|
185
|
+
uvicorn_logger.handlers.clear()
|
|
186
|
+
main_handler = logging.StreamHandler()
|
|
187
|
+
main_handler.setFormatter(formatter)
|
|
188
|
+
uvicorn_logger.addHandler(main_handler)
|
|
189
|
+
|
|
190
|
+
def _create_lifespan(self, existing_lifespan: Lifespan | None) -> Lifespan:
|
|
191
|
+
"""Create a lifespan context manager that handles cron manager lifecycle."""
|
|
192
|
+
cron_manager = self.cron_manager
|
|
193
|
+
|
|
194
|
+
@asynccontextmanager
|
|
195
|
+
async def lifespan(app: FastAPI):
|
|
196
|
+
await cron_manager.start()
|
|
197
|
+
if existing_lifespan:
|
|
198
|
+
logger.info("Starting existing lifespan")
|
|
199
|
+
async with existing_lifespan(app):
|
|
200
|
+
yield
|
|
201
|
+
else:
|
|
202
|
+
yield
|
|
203
|
+
await cron_manager.shutdown()
|
|
204
|
+
|
|
205
|
+
return lifespan
|
|
206
|
+
|
|
207
|
+
def add_trigger(self, trigger: TriggerTemplate) -> None:
|
|
208
|
+
"""Add a trigger template to the app."""
|
|
209
|
+
# Check for duplicate IDs
|
|
210
|
+
if any(t.id == trigger.id for t in self.triggers):
|
|
211
|
+
raise ValueError(f"Trigger with id '{trigger.id}' already exists")
|
|
212
|
+
|
|
213
|
+
self.triggers.append(trigger)
|
|
214
|
+
|
|
215
|
+
if trigger.trigger_handler:
|
|
216
|
+
|
|
217
|
+
async def handler_endpoint(request: Request) -> dict[str, Any]:
|
|
218
|
+
return await self._handle_request(trigger, request)
|
|
219
|
+
|
|
220
|
+
handler_path = f"/v1/triggers/webhooks/{trigger.id}"
|
|
221
|
+
self.app.post(handler_path)(handler_endpoint)
|
|
222
|
+
logger.info(f"Added handler route: POST {handler_path}")
|
|
223
|
+
|
|
224
|
+
logger.info(
|
|
225
|
+
f"Registered trigger template in memory: {trigger.display_name} ({trigger.id})"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def add_triggers(self, triggers: list[TriggerTemplate]) -> None:
|
|
229
|
+
"""Add multiple triggers."""
|
|
230
|
+
for trigger in triggers:
|
|
231
|
+
self.add_trigger(trigger)
|
|
232
|
+
|
|
233
|
+
def _setup_routes(self) -> None:
|
|
234
|
+
"""Setup built-in API routes."""
|
|
235
|
+
|
|
236
|
+
@self.app.get("/")
|
|
237
|
+
async def root() -> dict[str, str]:
|
|
238
|
+
return {"message": "Triggers Server", "version": "0.1.0"}
|
|
239
|
+
|
|
240
|
+
@self.app.get("/health")
|
|
241
|
+
async def health() -> dict[str, str]:
|
|
242
|
+
return {"status": "healthy"}
|
|
243
|
+
|
|
244
|
+
@self.app.get("/v1/triggers")
|
|
245
|
+
async def api_list_triggers() -> dict[str, Any]:
|
|
246
|
+
"""List available trigger templates from in-memory registry."""
|
|
247
|
+
trigger_list = []
|
|
248
|
+
for trigger in self.triggers:
|
|
249
|
+
trigger_list.append(
|
|
250
|
+
{
|
|
251
|
+
"id": trigger.id,
|
|
252
|
+
"displayName": trigger.display_name,
|
|
253
|
+
"description": trigger.description,
|
|
254
|
+
"path": "/v1/triggers/registrations",
|
|
255
|
+
"method": "POST",
|
|
256
|
+
"payloadSchema": trigger.registration_model.model_json_schema(),
|
|
257
|
+
"integration": trigger.integration,
|
|
258
|
+
"authProvider": trigger.auth_provider,
|
|
259
|
+
"scopes": trigger.scopes,
|
|
260
|
+
}
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
return {"success": True, "data": trigger_list}
|
|
264
|
+
|
|
265
|
+
@self.app.get("/v1/triggers/registrations")
|
|
266
|
+
async def api_list_registrations(
|
|
267
|
+
current_user: dict[str, Any] = Depends(get_current_user),
|
|
268
|
+
) -> dict[str, Any]:
|
|
269
|
+
"""List user's trigger registrations (user and tenant-scoped)."""
|
|
270
|
+
try:
|
|
271
|
+
user_id = current_user["identity"]
|
|
272
|
+
tenant_id = current_user["tenant_id"]
|
|
273
|
+
|
|
274
|
+
# Get user's trigger registrations with linked agents in a single query
|
|
275
|
+
user_registrations = (
|
|
276
|
+
await self.database.get_user_trigger_registrations_with_agents(
|
|
277
|
+
user_id, tenant_id
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# Format response to match expected structure
|
|
282
|
+
registrations = []
|
|
283
|
+
for reg in user_registrations:
|
|
284
|
+
registrations.append(
|
|
285
|
+
{
|
|
286
|
+
"id": reg["id"],
|
|
287
|
+
"user_id": reg["user_id"],
|
|
288
|
+
"template_id": reg.get("template_id"),
|
|
289
|
+
"resource": reg["resource"],
|
|
290
|
+
"linked_agent_ids": reg.get("linked_agent_ids", []),
|
|
291
|
+
"created_at": reg["created_at"],
|
|
292
|
+
}
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
return {"success": True, "data": registrations}
|
|
296
|
+
|
|
297
|
+
except HTTPException:
|
|
298
|
+
raise
|
|
299
|
+
except Exception as e:
|
|
300
|
+
logger.error(f"Error listing registrations: {e}")
|
|
301
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
302
|
+
|
|
303
|
+
@self.app.post("/v1/triggers/registrations")
|
|
304
|
+
async def api_create_registration(
|
|
305
|
+
request: Request, current_user: dict[str, Any] = Depends(get_current_user)
|
|
306
|
+
) -> dict[str, Any]:
|
|
307
|
+
"""Create a new trigger registration."""
|
|
308
|
+
try:
|
|
309
|
+
payload = await request.json()
|
|
310
|
+
logger.info(f"Registration payload received: {payload}")
|
|
311
|
+
|
|
312
|
+
user_id = current_user["identity"]
|
|
313
|
+
tenant_id = current_user["tenant_id"]
|
|
314
|
+
trigger_id = payload.get("type")
|
|
315
|
+
if not trigger_id:
|
|
316
|
+
raise HTTPException(
|
|
317
|
+
status_code=400, detail="Missing required field: type"
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
trigger = next((t for t in self.triggers if t.id == trigger_id), None)
|
|
321
|
+
if not trigger:
|
|
322
|
+
raise HTTPException(
|
|
323
|
+
status_code=400, detail=f"Unknown trigger type: {trigger_id}"
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# Parse payload into registration model first
|
|
327
|
+
try:
|
|
328
|
+
registration_instance = trigger.registration_model(**payload)
|
|
329
|
+
except Exception as e:
|
|
330
|
+
raise HTTPException(
|
|
331
|
+
status_code=400, detail=f"Invalid payload for trigger: {str(e)}"
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
# Check for duplicate registration based on resource data within this user's tenant scope
|
|
335
|
+
resource_dict = registration_instance.model_dump()
|
|
336
|
+
existing_registration = (
|
|
337
|
+
await self.database.find_user_registration_by_resource(
|
|
338
|
+
user_id=user_id,
|
|
339
|
+
tenant_id=tenant_id,
|
|
340
|
+
template_id=trigger.id,
|
|
341
|
+
resource_data=resource_dict,
|
|
342
|
+
)
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
if existing_registration:
|
|
346
|
+
raise HTTPException(
|
|
347
|
+
status_code=400,
|
|
348
|
+
detail=f"You already have a registration with this configuration for trigger type '{trigger.id}'. Registration ID: {existing_registration.get('id')}",
|
|
349
|
+
)
|
|
350
|
+
result = await trigger.registration_handler(
|
|
351
|
+
request, user_id, registration_instance
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
# Check if handler requested to skip registration (e.g., for OAuth or URL verification)
|
|
355
|
+
if not result.create_registration:
|
|
356
|
+
logger.info(
|
|
357
|
+
"Registration handler requested to skip database creation"
|
|
358
|
+
)
|
|
359
|
+
import json
|
|
360
|
+
|
|
361
|
+
from fastapi import Response
|
|
362
|
+
|
|
363
|
+
return Response(
|
|
364
|
+
content=json.dumps(result.response_body),
|
|
365
|
+
status_code=result.status_code,
|
|
366
|
+
media_type="application/json",
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
resource_dict = registration_instance.model_dump()
|
|
370
|
+
resource_dict.update(result.resource)
|
|
371
|
+
|
|
372
|
+
registration = await self.database.create_trigger_registration(
|
|
373
|
+
user_id=user_id,
|
|
374
|
+
tenant_id=tenant_id,
|
|
375
|
+
template_id=trigger.id,
|
|
376
|
+
resource=resource_dict,
|
|
377
|
+
metadata=result.metadata,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
if not registration:
|
|
381
|
+
raise HTTPException(
|
|
382
|
+
status_code=500, detail="Failed to create trigger registration"
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
# Reload cron manager to pick up any new cron registrations
|
|
386
|
+
await self.cron_manager.reload_from_database()
|
|
387
|
+
|
|
388
|
+
# Return registration result
|
|
389
|
+
return {
|
|
390
|
+
"success": True,
|
|
391
|
+
"data": registration,
|
|
392
|
+
"metadata": result.metadata,
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
except HTTPException:
|
|
396
|
+
raise
|
|
397
|
+
except Exception as e:
|
|
398
|
+
logger.exception(f"Error creating trigger registration: {e}")
|
|
399
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
400
|
+
|
|
401
|
+
@self.app.delete("/v1/triggers/registrations/{registration_id}")
|
|
402
|
+
async def api_delete_registration(
|
|
403
|
+
registration_id: str,
|
|
404
|
+
current_user: dict[str, Any] = Depends(get_current_user),
|
|
405
|
+
) -> dict[str, Any]:
|
|
406
|
+
"""Delete a trigger registration."""
|
|
407
|
+
try:
|
|
408
|
+
user_id = current_user["identity"]
|
|
409
|
+
tenant_id = current_user["tenant_id"]
|
|
410
|
+
success = await self.database.delete_trigger_registration(
|
|
411
|
+
registration_id, user_id, tenant_id
|
|
412
|
+
)
|
|
413
|
+
if not success:
|
|
414
|
+
raise HTTPException(
|
|
415
|
+
status_code=500, detail="Failed to delete trigger registration"
|
|
416
|
+
)
|
|
417
|
+
return {"success": True}
|
|
418
|
+
|
|
419
|
+
except HTTPException:
|
|
420
|
+
raise
|
|
421
|
+
except Exception as e:
|
|
422
|
+
logger.error(f"Error deleting trigger registration: {e}")
|
|
423
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
424
|
+
|
|
425
|
+
@self.app.get("/v1/triggers/registrations/{registration_id}/agents")
|
|
426
|
+
async def api_list_registration_agents(
|
|
427
|
+
registration_id: str,
|
|
428
|
+
current_user: dict[str, Any] = Depends(get_current_user),
|
|
429
|
+
) -> dict[str, Any]:
|
|
430
|
+
"""List agents linked to this registration."""
|
|
431
|
+
try:
|
|
432
|
+
user_id = current_user["identity"]
|
|
433
|
+
tenant_id = current_user["tenant_id"]
|
|
434
|
+
|
|
435
|
+
# Get the specific trigger registration
|
|
436
|
+
trigger = await self.database.get_trigger_registration(
|
|
437
|
+
registration_id, user_id, tenant_id
|
|
438
|
+
)
|
|
439
|
+
if not trigger:
|
|
440
|
+
raise HTTPException(
|
|
441
|
+
status_code=404,
|
|
442
|
+
detail="Trigger registration not found or access denied",
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
# Return the linked agent IDs
|
|
446
|
+
return {
|
|
447
|
+
"success": True,
|
|
448
|
+
"data": trigger.get("linked_assistant_ids", []),
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
except HTTPException:
|
|
452
|
+
raise
|
|
453
|
+
except Exception as e:
|
|
454
|
+
logger.error(f"Error getting registration agents: {e}")
|
|
455
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
456
|
+
|
|
457
|
+
@self.app.post("/v1/triggers/registrations/{registration_id}/agents/{agent_id}")
|
|
458
|
+
async def api_add_agent_to_trigger(
|
|
459
|
+
registration_id: str,
|
|
460
|
+
agent_id: str,
|
|
461
|
+
request: Request,
|
|
462
|
+
current_user: dict[str, Any] = Depends(get_current_user),
|
|
463
|
+
) -> dict[str, Any]:
|
|
464
|
+
"""Add an agent to a trigger registration."""
|
|
465
|
+
try:
|
|
466
|
+
# Parse request body for field selection
|
|
467
|
+
try:
|
|
468
|
+
body = await request.json()
|
|
469
|
+
field_selection = body.get("field_selection")
|
|
470
|
+
except:
|
|
471
|
+
field_selection = None
|
|
472
|
+
|
|
473
|
+
user_id = current_user["identity"]
|
|
474
|
+
tenant_id = current_user["tenant_id"]
|
|
475
|
+
|
|
476
|
+
# Verify the trigger registration exists and belongs to the user
|
|
477
|
+
registration = await self.database.get_trigger_registration(
|
|
478
|
+
registration_id, user_id, tenant_id
|
|
479
|
+
)
|
|
480
|
+
if not registration:
|
|
481
|
+
raise HTTPException(
|
|
482
|
+
status_code=404,
|
|
483
|
+
detail="Trigger registration not found or access denied",
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
# Link the agent to the trigger
|
|
487
|
+
success = await self.database.link_agent_to_trigger(
|
|
488
|
+
agent_id=agent_id,
|
|
489
|
+
registration_id=registration_id,
|
|
490
|
+
created_by=user_id,
|
|
491
|
+
field_selection=field_selection,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
if not success:
|
|
495
|
+
raise HTTPException(
|
|
496
|
+
status_code=500, detail="Failed to link agent to trigger"
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
"success": True,
|
|
501
|
+
"message": f"Successfully linked agent {agent_id} to trigger {registration_id}",
|
|
502
|
+
"data": {"registration_id": registration_id, "agent_id": agent_id},
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
except HTTPException:
|
|
506
|
+
raise
|
|
507
|
+
except Exception as e:
|
|
508
|
+
logger.error(f"Error linking agent to trigger: {e}")
|
|
509
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
510
|
+
|
|
511
|
+
@self.app.delete(
|
|
512
|
+
"/v1/triggers/registrations/{registration_id}/agents/{agent_id}"
|
|
513
|
+
)
|
|
514
|
+
async def api_remove_agent_from_trigger(
|
|
515
|
+
registration_id: str,
|
|
516
|
+
agent_id: str,
|
|
517
|
+
current_user: dict[str, Any] = Depends(get_current_user),
|
|
518
|
+
) -> dict[str, Any]:
|
|
519
|
+
"""Remove an agent from a trigger registration."""
|
|
520
|
+
try:
|
|
521
|
+
user_id = current_user["identity"]
|
|
522
|
+
tenant_id = current_user["tenant_id"]
|
|
523
|
+
|
|
524
|
+
# Verify the trigger registration exists and belongs to the user
|
|
525
|
+
registration = await self.database.get_trigger_registration(
|
|
526
|
+
registration_id, user_id, tenant_id
|
|
527
|
+
)
|
|
528
|
+
if not registration:
|
|
529
|
+
raise HTTPException(
|
|
530
|
+
status_code=404,
|
|
531
|
+
detail="Trigger registration not found or access denied",
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
# Unlink the agent from the trigger
|
|
535
|
+
success = await self.database.unlink_agent_from_trigger(
|
|
536
|
+
agent_id=agent_id, registration_id=registration_id
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
if not success:
|
|
540
|
+
raise HTTPException(
|
|
541
|
+
status_code=500, detail="Failed to unlink agent from trigger"
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
return {
|
|
545
|
+
"success": True,
|
|
546
|
+
"message": f"Successfully unlinked agent {agent_id} from trigger {registration_id}",
|
|
547
|
+
"data": {"registration_id": registration_id, "agent_id": agent_id},
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
except HTTPException:
|
|
551
|
+
raise
|
|
552
|
+
except Exception as e:
|
|
553
|
+
logger.error(f"Error unlinking agent from trigger: {e}")
|
|
554
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
555
|
+
|
|
556
|
+
@self.app.get("/v1/triggers/agents/{agent_id}/registrations")
|
|
557
|
+
async def api_get_agent_registrations(
|
|
558
|
+
agent_id: str,
|
|
559
|
+
current_user: dict[str, Any] = Depends(get_current_user),
|
|
560
|
+
) -> dict[str, Any]:
|
|
561
|
+
"""Get all trigger registrations linked to a specific agent."""
|
|
562
|
+
try:
|
|
563
|
+
tenant_id = current_user["tenant_id"]
|
|
564
|
+
|
|
565
|
+
registrations = await self.database.get_registrations_for_agent(
|
|
566
|
+
agent_id, tenant_id
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
return {"data": registrations}
|
|
570
|
+
|
|
571
|
+
except HTTPException:
|
|
572
|
+
raise
|
|
573
|
+
except Exception as e:
|
|
574
|
+
logger.error(f"Error getting registrations for agent: {e}")
|
|
575
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
576
|
+
|
|
577
|
+
@self.app.post("/v1/triggers/registrations/{registration_id}/execute")
|
|
578
|
+
async def api_execute_trigger_now(
|
|
579
|
+
registration_id: str,
|
|
580
|
+
current_user: dict[str, Any] = Depends(get_current_user),
|
|
581
|
+
) -> dict[str, Any]:
|
|
582
|
+
"""Manually execute a cron trigger registration immediately."""
|
|
583
|
+
try:
|
|
584
|
+
user_id = current_user["identity"]
|
|
585
|
+
tenant_id = current_user["tenant_id"]
|
|
586
|
+
|
|
587
|
+
# Verify the trigger registration exists and belongs to the user
|
|
588
|
+
registration = await self.database.get_trigger_registration(
|
|
589
|
+
registration_id, user_id, tenant_id
|
|
590
|
+
)
|
|
591
|
+
if not registration:
|
|
592
|
+
raise HTTPException(
|
|
593
|
+
status_code=404,
|
|
594
|
+
detail="Trigger registration not found or access denied",
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
# Get the template to check if it's a polling trigger
|
|
598
|
+
template_id = registration.get("template_id")
|
|
599
|
+
tmpl = (
|
|
600
|
+
next((t for t in self.triggers if t.id == template_id), None)
|
|
601
|
+
if template_id
|
|
602
|
+
else None
|
|
603
|
+
)
|
|
604
|
+
if not template_id or not tmpl:
|
|
605
|
+
error_reason = (
|
|
606
|
+
"missing_template_id"
|
|
607
|
+
if not template_id
|
|
608
|
+
else "template_not_found"
|
|
609
|
+
)
|
|
610
|
+
logger.error(
|
|
611
|
+
"manual_execute_error registration_id=%s template_id=%s error=%s",
|
|
612
|
+
registration_id,
|
|
613
|
+
template_id,
|
|
614
|
+
error_reason,
|
|
615
|
+
stack_info=True,
|
|
616
|
+
)
|
|
617
|
+
raise HTTPException(status_code=500, detail="Internal server error")
|
|
618
|
+
if (
|
|
619
|
+
getattr(tmpl.trigger_type, "value", tmpl.trigger_type)
|
|
620
|
+
!= TriggerType.POLLING.value
|
|
621
|
+
):
|
|
622
|
+
raise HTTPException(
|
|
623
|
+
status_code=400,
|
|
624
|
+
detail="Manual execution is only supported for polling triggers",
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
# Execute the cron trigger using the cron manager
|
|
628
|
+
agents_invoked = await self.cron_manager.execute_cron_job(registration)
|
|
629
|
+
|
|
630
|
+
return {
|
|
631
|
+
"success": True,
|
|
632
|
+
"message": f"Manually executed cron trigger {registration_id}",
|
|
633
|
+
"agents_invoked": agents_invoked,
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
except HTTPException:
|
|
637
|
+
raise
|
|
638
|
+
except Exception as e:
|
|
639
|
+
logger.error(f"Error executing trigger: {e}")
|
|
640
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
641
|
+
|
|
642
|
+
async def _handle_request(
|
|
643
|
+
self, trigger: TriggerTemplate, request: Request
|
|
644
|
+
) -> dict[str, Any]:
|
|
645
|
+
"""Handle an incoming request with a handler function."""
|
|
646
|
+
try:
|
|
647
|
+
logger.info(
|
|
648
|
+
"incoming_trigger_request method=%s path=%s trigger_id=%s content_type=%s",
|
|
649
|
+
request.method,
|
|
650
|
+
request.url.path,
|
|
651
|
+
getattr(trigger, "id", "<unknown>"),
|
|
652
|
+
request.headers.get("content-type", ""),
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
logger.info(
|
|
656
|
+
"invoking_trigger_handler trigger_id=%s",
|
|
657
|
+
getattr(trigger, "id", "<unknown>"),
|
|
658
|
+
)
|
|
659
|
+
response = await trigger.trigger_handler(request, self.database)
|
|
660
|
+
logger.info(
|
|
661
|
+
"trigger_handler_completed trigger_id=%s",
|
|
662
|
+
getattr(trigger, "id", "<unknown>"),
|
|
663
|
+
)
|
|
664
|
+
return response
|
|
665
|
+
|
|
666
|
+
except HTTPException:
|
|
667
|
+
raise
|
|
668
|
+
except Exception as e:
|
|
669
|
+
logger.error(f"Error in trigger handler: {e}", exc_info=True)
|
|
670
|
+
raise HTTPException(
|
|
671
|
+
status_code=500, detail=f"Trigger processing failed: {str(e)}"
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
def get_app(self) -> FastAPI:
|
|
675
|
+
"""Get the FastAPI app instance."""
|
|
676
|
+
return self.app
|