agenthandler 0.92.0__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.
Potentially problematic release.
This version of agenthandler might be problematic. Click here for more details.
- agenthandler/__init__.py +163 -0
- agenthandler/approval.py +135 -0
- agenthandler/audit.py +144 -0
- agenthandler/auth.py +188 -0
- agenthandler/budget.py +94 -0
- agenthandler/builder.py +178 -0
- agenthandler/circuit_breaker.py +88 -0
- agenthandler/connectors.py +425 -0
- agenthandler/context.py +410 -0
- agenthandler/errors.py +130 -0
- agenthandler/guardrails/__init__.py +79 -0
- agenthandler/guardrails/deterministic.py +472 -0
- agenthandler/guardrails/llm_based.py +549 -0
- agenthandler/guardrails/types.py +83 -0
- agenthandler/observe.py +525 -0
- agenthandler/openclaw.py +370 -0
- agenthandler/pipeline.py +532 -0
- agenthandler/pipeline_poml.py +593 -0
- agenthandler/policy.py +251 -0
- agenthandler/py.typed +0 -0
- agenthandler/redact.py +260 -0
- agenthandler/reflection.py +371 -0
- agenthandler/replay.py +225 -0
- agenthandler/server.py +752 -0
- agenthandler/session.py +391 -0
- agenthandler/skill.py +265 -0
- agenthandler/store.py +341 -0
- agenthandler/streams.py +339 -0
- agenthandler/supervisor.py +882 -0
- agenthandler/triggers.py +471 -0
- agenthandler-0.92.0.dist-info/METADATA +1355 -0
- agenthandler-0.92.0.dist-info/RECORD +34 -0
- agenthandler-0.92.0.dist-info/WHEEL +4 -0
- agenthandler-0.92.0.dist-info/licenses/LICENSE +201 -0
agenthandler/triggers.py
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
"""Triggers and scheduling — run pipelines on schedule, webhook, or data event.
|
|
2
|
+
|
|
3
|
+
Three trigger types:
|
|
4
|
+
- Schedule: cron expression or fixed interval
|
|
5
|
+
- Webhook: fires when an HTTP endpoint is called
|
|
6
|
+
- Database watch: polls a SQL query and fires when the result changes or
|
|
7
|
+
crosses a threshold
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
from agenthandler.triggers import Scheduler, WebhookTrigger, DbWatchTrigger
|
|
11
|
+
|
|
12
|
+
scheduler = Scheduler()
|
|
13
|
+
|
|
14
|
+
# Run every 15 minutes
|
|
15
|
+
scheduler.add_interval("check-alerts", pipeline_fn, minutes=15)
|
|
16
|
+
|
|
17
|
+
# Run on cron schedule
|
|
18
|
+
scheduler.add_cron("morning-report", pipeline_fn, cron="0 9 * * *")
|
|
19
|
+
|
|
20
|
+
# Run when webhook is called
|
|
21
|
+
webhook = WebhookTrigger("deploy-notify", pipeline_fn)
|
|
22
|
+
|
|
23
|
+
# Run when query result crosses threshold
|
|
24
|
+
db_watch = DbWatchTrigger(
|
|
25
|
+
"alert-monitor", pipeline_fn, sql_connector,
|
|
26
|
+
query="SELECT count(*) as cnt FROM alerts WHERE seen=false",
|
|
27
|
+
condition=lambda result: result["rows"][0]["cnt"] > 0,
|
|
28
|
+
poll_seconds=60,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
scheduler.start() # background thread
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import asyncio
|
|
37
|
+
import threading
|
|
38
|
+
import time
|
|
39
|
+
from dataclasses import dataclass
|
|
40
|
+
from datetime import datetime, timezone
|
|
41
|
+
from typing import Any, Callable, Coroutine, Dict, List, Optional
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class TriggerRecord:
|
|
46
|
+
"""Record of a trigger firing."""
|
|
47
|
+
|
|
48
|
+
trigger_name: str
|
|
49
|
+
trigger_type: str # "interval", "cron", "webhook", "db_watch"
|
|
50
|
+
fired_at: str = ""
|
|
51
|
+
result: Optional[Dict[str, Any]] = None
|
|
52
|
+
error: Optional[str] = None
|
|
53
|
+
|
|
54
|
+
def __post_init__(self) -> None:
|
|
55
|
+
if not self.fired_at:
|
|
56
|
+
self.fired_at = datetime.now(timezone.utc).isoformat()
|
|
57
|
+
|
|
58
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
59
|
+
d: Dict[str, Any] = {
|
|
60
|
+
"trigger_name": self.trigger_name,
|
|
61
|
+
"trigger_type": self.trigger_type,
|
|
62
|
+
"fired_at": self.fired_at,
|
|
63
|
+
}
|
|
64
|
+
if self.error:
|
|
65
|
+
d["error"] = self.error
|
|
66
|
+
if self.result:
|
|
67
|
+
d["result"] = self.result
|
|
68
|
+
return d
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# Cron parser (minimal — handles standard 5-field cron expressions)
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _cron_matches(cron_expr: str, dt: datetime) -> bool:
|
|
77
|
+
"""Check if a datetime matches a cron expression (minute hour dom month dow)."""
|
|
78
|
+
parts = cron_expr.strip().split()
|
|
79
|
+
if len(parts) != 5:
|
|
80
|
+
raise ValueError(f"Invalid cron expression: {cron_expr} (need 5 fields)")
|
|
81
|
+
|
|
82
|
+
fields = [dt.minute, dt.hour, dt.day, dt.month, dt.weekday()]
|
|
83
|
+
ranges = [
|
|
84
|
+
(0, 59), # minute
|
|
85
|
+
(0, 23), # hour
|
|
86
|
+
(1, 31), # day of month
|
|
87
|
+
(1, 12), # month
|
|
88
|
+
(0, 6), # day of week (0=Monday)
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
for part, val, (lo, hi) in zip(parts, fields, ranges):
|
|
92
|
+
if not _cron_field_matches(part, val, lo, hi):
|
|
93
|
+
return False
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _cron_field_matches(field_expr: str, value: int, lo: int, hi: int) -> bool:
|
|
98
|
+
"""Check if a single cron field matches a value."""
|
|
99
|
+
if field_expr == "*":
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
for item in field_expr.split(","):
|
|
103
|
+
# Handle */N
|
|
104
|
+
if item.startswith("*/"):
|
|
105
|
+
step = int(item[2:])
|
|
106
|
+
if value % step == 0:
|
|
107
|
+
return True
|
|
108
|
+
# Handle N-M
|
|
109
|
+
elif "-" in item:
|
|
110
|
+
start, end = item.split("-", 1)
|
|
111
|
+
if int(start) <= value <= int(end):
|
|
112
|
+
return True
|
|
113
|
+
# Handle plain N
|
|
114
|
+
else:
|
|
115
|
+
if int(item) == value:
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
# Trigger entries
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class IntervalEntry:
|
|
128
|
+
name: str
|
|
129
|
+
fn: Callable[[], Coroutine[Any, Any, Any]]
|
|
130
|
+
seconds: float
|
|
131
|
+
last_run: float = 0.0
|
|
132
|
+
enabled: bool = True
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class CronEntry:
|
|
137
|
+
name: str
|
|
138
|
+
fn: Callable[[], Coroutine[Any, Any, Any]]
|
|
139
|
+
cron: str
|
|
140
|
+
last_run_minute: int = -1 # track minute to avoid double-firing
|
|
141
|
+
enabled: bool = True
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass
|
|
145
|
+
class DbWatchEntry:
|
|
146
|
+
name: str
|
|
147
|
+
fn: Callable[[], Coroutine[Any, Any, Any]]
|
|
148
|
+
connector: Any # SqlConnector
|
|
149
|
+
query: str
|
|
150
|
+
condition: Callable[[Dict[str, Any]], bool]
|
|
151
|
+
poll_seconds: float = 60.0
|
|
152
|
+
last_poll: float = 0.0
|
|
153
|
+
last_result: Optional[Dict[str, Any]] = None
|
|
154
|
+
enabled: bool = True
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
# Scheduler
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class Scheduler:
|
|
163
|
+
"""Background scheduler for interval and cron triggers.
|
|
164
|
+
|
|
165
|
+
Runs in a daemon thread. Non-blocking.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
def __init__(self) -> None:
|
|
169
|
+
self._intervals: Dict[str, IntervalEntry] = {}
|
|
170
|
+
self._crons: Dict[str, CronEntry] = {}
|
|
171
|
+
self._db_watches: Dict[str, DbWatchEntry] = {}
|
|
172
|
+
self._history: List[TriggerRecord] = []
|
|
173
|
+
self._lock = threading.Lock()
|
|
174
|
+
self._running = False
|
|
175
|
+
self._thread: Optional[threading.Thread] = None
|
|
176
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
177
|
+
|
|
178
|
+
def add_interval(
|
|
179
|
+
self,
|
|
180
|
+
name: str,
|
|
181
|
+
fn: Callable[[], Coroutine[Any, Any, Any]],
|
|
182
|
+
seconds: float = 0,
|
|
183
|
+
minutes: float = 0,
|
|
184
|
+
hours: float = 0,
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Add an interval trigger.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
name: Unique trigger name.
|
|
190
|
+
fn: Async callable to run (e.g., pipeline.run).
|
|
191
|
+
seconds/minutes/hours: Interval (combined).
|
|
192
|
+
"""
|
|
193
|
+
total = seconds + minutes * 60 + hours * 3600
|
|
194
|
+
if total <= 0:
|
|
195
|
+
raise ValueError("Interval must be > 0")
|
|
196
|
+
with self._lock:
|
|
197
|
+
self._intervals[name] = IntervalEntry(name=name, fn=fn, seconds=total)
|
|
198
|
+
|
|
199
|
+
def add_cron(
|
|
200
|
+
self,
|
|
201
|
+
name: str,
|
|
202
|
+
fn: Callable[[], Coroutine[Any, Any, Any]],
|
|
203
|
+
cron: str,
|
|
204
|
+
) -> None:
|
|
205
|
+
"""Add a cron trigger.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
name: Unique trigger name.
|
|
209
|
+
fn: Async callable to run.
|
|
210
|
+
cron: 5-field cron expression (minute hour dom month dow).
|
|
211
|
+
"""
|
|
212
|
+
# Validate
|
|
213
|
+
_cron_matches(cron, datetime.now(timezone.utc))
|
|
214
|
+
with self._lock:
|
|
215
|
+
self._crons[name] = CronEntry(name=name, fn=fn, cron=cron)
|
|
216
|
+
|
|
217
|
+
def add_db_watch(
|
|
218
|
+
self,
|
|
219
|
+
name: str,
|
|
220
|
+
fn: Callable[[], Coroutine[Any, Any, Any]],
|
|
221
|
+
connector: Any,
|
|
222
|
+
query: str,
|
|
223
|
+
condition: Callable[[Dict[str, Any]], bool],
|
|
224
|
+
poll_seconds: float = 60.0,
|
|
225
|
+
) -> None:
|
|
226
|
+
"""Add a database watch trigger.
|
|
227
|
+
|
|
228
|
+
Polls the query at poll_seconds interval. Fires fn when
|
|
229
|
+
condition(query_result) returns True.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
name: Unique trigger name.
|
|
233
|
+
fn: Async callable to run when condition fires.
|
|
234
|
+
connector: SqlConnector instance.
|
|
235
|
+
query: SQL query to poll.
|
|
236
|
+
condition: Function that takes query result dict and returns bool.
|
|
237
|
+
poll_seconds: How often to poll.
|
|
238
|
+
"""
|
|
239
|
+
with self._lock:
|
|
240
|
+
self._db_watches[name] = DbWatchEntry(
|
|
241
|
+
name=name,
|
|
242
|
+
fn=fn,
|
|
243
|
+
connector=connector,
|
|
244
|
+
query=query,
|
|
245
|
+
condition=condition,
|
|
246
|
+
poll_seconds=poll_seconds,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def remove(self, name: str) -> bool:
|
|
250
|
+
"""Remove a trigger by name."""
|
|
251
|
+
with self._lock:
|
|
252
|
+
found = False
|
|
253
|
+
if name in self._intervals:
|
|
254
|
+
del self._intervals[name]
|
|
255
|
+
found = True
|
|
256
|
+
if name in self._crons:
|
|
257
|
+
del self._crons[name]
|
|
258
|
+
found = True
|
|
259
|
+
if name in self._db_watches:
|
|
260
|
+
del self._db_watches[name]
|
|
261
|
+
found = True
|
|
262
|
+
return found
|
|
263
|
+
|
|
264
|
+
def enable(self, name: str) -> None:
|
|
265
|
+
"""Enable a trigger."""
|
|
266
|
+
with self._lock:
|
|
267
|
+
for registry in (self._intervals, self._crons, self._db_watches):
|
|
268
|
+
if name in registry:
|
|
269
|
+
registry[name].enabled = True
|
|
270
|
+
|
|
271
|
+
def disable(self, name: str) -> None:
|
|
272
|
+
"""Disable a trigger without removing it."""
|
|
273
|
+
with self._lock:
|
|
274
|
+
for registry in (self._intervals, self._crons, self._db_watches):
|
|
275
|
+
if name in registry:
|
|
276
|
+
registry[name].enabled = False
|
|
277
|
+
|
|
278
|
+
def list_triggers(self) -> List[Dict[str, Any]]:
|
|
279
|
+
"""List all registered triggers."""
|
|
280
|
+
with self._lock:
|
|
281
|
+
result: List[Dict[str, Any]] = []
|
|
282
|
+
for ie in self._intervals.values():
|
|
283
|
+
result.append(
|
|
284
|
+
{
|
|
285
|
+
"name": ie.name,
|
|
286
|
+
"type": "interval",
|
|
287
|
+
"seconds": ie.seconds,
|
|
288
|
+
"enabled": ie.enabled,
|
|
289
|
+
}
|
|
290
|
+
)
|
|
291
|
+
for ce in self._crons.values():
|
|
292
|
+
result.append(
|
|
293
|
+
{
|
|
294
|
+
"name": ce.name,
|
|
295
|
+
"type": "cron",
|
|
296
|
+
"cron": ce.cron,
|
|
297
|
+
"enabled": ce.enabled,
|
|
298
|
+
}
|
|
299
|
+
)
|
|
300
|
+
for de in self._db_watches.values():
|
|
301
|
+
result.append(
|
|
302
|
+
{
|
|
303
|
+
"name": de.name,
|
|
304
|
+
"type": "db_watch",
|
|
305
|
+
"query": de.query,
|
|
306
|
+
"poll_seconds": de.poll_seconds,
|
|
307
|
+
"enabled": de.enabled,
|
|
308
|
+
}
|
|
309
|
+
)
|
|
310
|
+
return result
|
|
311
|
+
|
|
312
|
+
def history(self, limit: int = 50) -> List[Dict[str, Any]]:
|
|
313
|
+
"""Get recent trigger firing history."""
|
|
314
|
+
with self._lock:
|
|
315
|
+
return [r.to_dict() for r in self._history[-limit:]]
|
|
316
|
+
|
|
317
|
+
def start(self) -> None:
|
|
318
|
+
"""Start the scheduler in a background thread."""
|
|
319
|
+
if self._running:
|
|
320
|
+
return
|
|
321
|
+
self._running = True
|
|
322
|
+
self._loop = asyncio.new_event_loop()
|
|
323
|
+
self._thread = threading.Thread(
|
|
324
|
+
target=self._run_loop, daemon=True, name="agenthandler-scheduler"
|
|
325
|
+
)
|
|
326
|
+
self._thread.start()
|
|
327
|
+
|
|
328
|
+
def stop(self) -> None:
|
|
329
|
+
"""Stop the scheduler."""
|
|
330
|
+
self._running = False
|
|
331
|
+
if self._loop:
|
|
332
|
+
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
333
|
+
if self._thread:
|
|
334
|
+
self._thread.join(timeout=5)
|
|
335
|
+
self._thread = None
|
|
336
|
+
self._loop = None
|
|
337
|
+
|
|
338
|
+
def _run_loop(self) -> None:
|
|
339
|
+
"""Main scheduler loop."""
|
|
340
|
+
assert self._loop is not None
|
|
341
|
+
asyncio.set_event_loop(self._loop)
|
|
342
|
+
while self._running:
|
|
343
|
+
self._loop.run_until_complete(self._tick())
|
|
344
|
+
time.sleep(1) # 1-second resolution
|
|
345
|
+
|
|
346
|
+
async def _tick(self) -> None:
|
|
347
|
+
"""Check all triggers and fire any that are due."""
|
|
348
|
+
now = time.monotonic()
|
|
349
|
+
now_dt = datetime.now(timezone.utc)
|
|
350
|
+
|
|
351
|
+
with self._lock:
|
|
352
|
+
intervals = list(self._intervals.values())
|
|
353
|
+
crons = list(self._crons.values())
|
|
354
|
+
db_watches = list(self._db_watches.values())
|
|
355
|
+
|
|
356
|
+
# Interval triggers
|
|
357
|
+
for ie in intervals:
|
|
358
|
+
if not ie.enabled:
|
|
359
|
+
continue
|
|
360
|
+
if now - ie.last_run >= ie.seconds:
|
|
361
|
+
ie.last_run = now
|
|
362
|
+
await self._fire(ie.name, "interval", ie.fn)
|
|
363
|
+
|
|
364
|
+
# Cron triggers
|
|
365
|
+
current_minute = now_dt.hour * 60 + now_dt.minute
|
|
366
|
+
for ce in crons:
|
|
367
|
+
if not ce.enabled:
|
|
368
|
+
continue
|
|
369
|
+
if _cron_matches(ce.cron, now_dt) and ce.last_run_minute != current_minute:
|
|
370
|
+
ce.last_run_minute = current_minute
|
|
371
|
+
await self._fire(ce.name, "cron", ce.fn)
|
|
372
|
+
|
|
373
|
+
# DB watch triggers
|
|
374
|
+
for de in db_watches:
|
|
375
|
+
if not de.enabled:
|
|
376
|
+
continue
|
|
377
|
+
if now - de.last_poll >= de.poll_seconds:
|
|
378
|
+
de.last_poll = now
|
|
379
|
+
try:
|
|
380
|
+
result = await de.connector.query(sql=de.query)
|
|
381
|
+
if de.condition(result):
|
|
382
|
+
de.last_result = result
|
|
383
|
+
await self._fire(de.name, "db_watch", de.fn)
|
|
384
|
+
except Exception as e:
|
|
385
|
+
self._record(de.name, "db_watch", error=str(e))
|
|
386
|
+
|
|
387
|
+
async def _fire(self, name: str, trigger_type: str, fn: Callable[..., Any]) -> None:
|
|
388
|
+
"""Fire a trigger and record the result."""
|
|
389
|
+
try:
|
|
390
|
+
result = await fn()
|
|
391
|
+
result_dict = None
|
|
392
|
+
if hasattr(result, "completed"):
|
|
393
|
+
# PipelineResult
|
|
394
|
+
result_dict = {
|
|
395
|
+
"completed": result.completed,
|
|
396
|
+
"steps": len(result.steps) if hasattr(result, "steps") else 0,
|
|
397
|
+
}
|
|
398
|
+
self._record(name, trigger_type, result=result_dict)
|
|
399
|
+
except Exception as e:
|
|
400
|
+
self._record(name, trigger_type, error=str(e))
|
|
401
|
+
|
|
402
|
+
def _record(
|
|
403
|
+
self,
|
|
404
|
+
name: str,
|
|
405
|
+
trigger_type: str,
|
|
406
|
+
result: Optional[Dict[str, Any]] = None,
|
|
407
|
+
error: Optional[str] = None,
|
|
408
|
+
) -> None:
|
|
409
|
+
"""Record a trigger firing in history."""
|
|
410
|
+
record = TriggerRecord(
|
|
411
|
+
trigger_name=name,
|
|
412
|
+
trigger_type=trigger_type,
|
|
413
|
+
result=result,
|
|
414
|
+
error=error,
|
|
415
|
+
)
|
|
416
|
+
with self._lock:
|
|
417
|
+
self._history.append(record)
|
|
418
|
+
if len(self._history) > 1000:
|
|
419
|
+
self._history = self._history[-500:]
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
# ---------------------------------------------------------------------------
|
|
423
|
+
# WebhookTrigger — fires when an HTTP endpoint is called
|
|
424
|
+
# ---------------------------------------------------------------------------
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
class WebhookTrigger:
|
|
428
|
+
"""A trigger that fires when its HTTP endpoint is called.
|
|
429
|
+
|
|
430
|
+
Register with the REST server via Scheduler or directly.
|
|
431
|
+
|
|
432
|
+
Usage:
|
|
433
|
+
trigger = WebhookTrigger("deploy", pipeline.run)
|
|
434
|
+
# POST /triggers/deploy → fires pipeline.run()
|
|
435
|
+
"""
|
|
436
|
+
|
|
437
|
+
def __init__(
|
|
438
|
+
self,
|
|
439
|
+
name: str,
|
|
440
|
+
fn: Callable[[], Coroutine[Any, Any, Any]],
|
|
441
|
+
):
|
|
442
|
+
self.name = name
|
|
443
|
+
self.fn = fn
|
|
444
|
+
self._history: List[TriggerRecord] = []
|
|
445
|
+
|
|
446
|
+
async def fire(self, payload: Optional[Dict[str, Any]] = None) -> TriggerRecord:
|
|
447
|
+
"""Fire the trigger. Called by the REST endpoint handler."""
|
|
448
|
+
try:
|
|
449
|
+
result = await self.fn()
|
|
450
|
+
result_dict = None
|
|
451
|
+
if hasattr(result, "completed"):
|
|
452
|
+
result_dict = {
|
|
453
|
+
"completed": result.completed,
|
|
454
|
+
"steps": len(result.steps) if hasattr(result, "steps") else 0,
|
|
455
|
+
}
|
|
456
|
+
record = TriggerRecord(
|
|
457
|
+
trigger_name=self.name,
|
|
458
|
+
trigger_type="webhook",
|
|
459
|
+
result=result_dict,
|
|
460
|
+
)
|
|
461
|
+
except Exception as e:
|
|
462
|
+
record = TriggerRecord(
|
|
463
|
+
trigger_name=self.name,
|
|
464
|
+
trigger_type="webhook",
|
|
465
|
+
error=str(e),
|
|
466
|
+
)
|
|
467
|
+
self._history.append(record)
|
|
468
|
+
return record
|
|
469
|
+
|
|
470
|
+
def history(self, limit: int = 50) -> List[Dict[str, Any]]:
|
|
471
|
+
return [r.to_dict() for r in self._history[-limit:]]
|