smartify-ai 0.1.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.
- smartify/__init__.py +3 -0
- smartify/agents/__init__.py +0 -0
- smartify/agents/adapters/__init__.py +13 -0
- smartify/agents/adapters/anthropic.py +253 -0
- smartify/agents/adapters/openai.py +289 -0
- smartify/api/__init__.py +26 -0
- smartify/api/auth.py +352 -0
- smartify/api/errors.py +380 -0
- smartify/api/events.py +345 -0
- smartify/api/server.py +992 -0
- smartify/cli/__init__.py +1 -0
- smartify/cli/main.py +430 -0
- smartify/engine/__init__.py +64 -0
- smartify/engine/approval.py +479 -0
- smartify/engine/orchestrator.py +1365 -0
- smartify/engine/scheduler.py +380 -0
- smartify/engine/spark.py +294 -0
- smartify/guardrails/__init__.py +22 -0
- smartify/guardrails/breakers.py +409 -0
- smartify/models/__init__.py +61 -0
- smartify/models/grid.py +625 -0
- smartify/notifications/__init__.py +22 -0
- smartify/notifications/webhook.py +556 -0
- smartify/state/__init__.py +46 -0
- smartify/state/checkpoint.py +558 -0
- smartify/state/resume.py +301 -0
- smartify/state/store.py +370 -0
- smartify/tools/__init__.py +17 -0
- smartify/tools/base.py +196 -0
- smartify/tools/builtin/__init__.py +79 -0
- smartify/tools/builtin/file.py +464 -0
- smartify/tools/builtin/http.py +195 -0
- smartify/tools/builtin/shell.py +137 -0
- smartify/tools/mcp/__init__.py +33 -0
- smartify/tools/mcp/adapter.py +157 -0
- smartify/tools/mcp/client.py +334 -0
- smartify/tools/mcp/registry.py +130 -0
- smartify/validator/__init__.py +0 -0
- smartify/validator/validate.py +271 -0
- smartify/workspace/__init__.py +5 -0
- smartify/workspace/manager.py +248 -0
- smartify_ai-0.1.0.dist-info/METADATA +201 -0
- smartify_ai-0.1.0.dist-info/RECORD +46 -0
- smartify_ai-0.1.0.dist-info/WHEEL +4 -0
- smartify_ai-0.1.0.dist-info/entry_points.txt +2 -0
- smartify_ai-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
"""Webhook notification system for Smartify grid events.
|
|
2
|
+
|
|
3
|
+
Sends HTTP POST notifications for grid lifecycle events:
|
|
4
|
+
- run_started: Grid execution has begun
|
|
5
|
+
- run_completed: Grid finished successfully
|
|
6
|
+
- run_failed: Grid execution failed
|
|
7
|
+
- run_paused: Grid was paused
|
|
8
|
+
- run_stopped: Grid was stopped
|
|
9
|
+
- approval_needed: Human approval required
|
|
10
|
+
- breaker_tripped: A guardrail breaker was triggered
|
|
11
|
+
- node_completed: Individual node finished (optional, high volume)
|
|
12
|
+
|
|
13
|
+
Webhooks are configured per-grid in the GridSpec, with global defaults
|
|
14
|
+
available at the server level.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import hashlib
|
|
19
|
+
import hmac
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
from enum import Enum
|
|
26
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
27
|
+
from uuid import uuid4
|
|
28
|
+
|
|
29
|
+
import httpx
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class EventType(str, Enum):
|
|
36
|
+
"""Types of events that can trigger webhooks."""
|
|
37
|
+
RUN_STARTED = "run_started"
|
|
38
|
+
RUN_COMPLETED = "run_completed"
|
|
39
|
+
RUN_FAILED = "run_failed"
|
|
40
|
+
RUN_PAUSED = "run_paused"
|
|
41
|
+
RUN_STOPPED = "run_stopped"
|
|
42
|
+
APPROVAL_NEEDED = "approval_needed"
|
|
43
|
+
BREAKER_TRIPPED = "breaker_tripped"
|
|
44
|
+
NODE_COMPLETED = "node_completed"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class WebhookEvent:
|
|
49
|
+
"""A webhook event to be delivered."""
|
|
50
|
+
id: str
|
|
51
|
+
type: EventType
|
|
52
|
+
grid_id: str
|
|
53
|
+
timestamp: datetime
|
|
54
|
+
data: Dict[str, Any]
|
|
55
|
+
|
|
56
|
+
# Delivery tracking
|
|
57
|
+
attempts: int = 0
|
|
58
|
+
last_attempt: Optional[datetime] = None
|
|
59
|
+
last_error: Optional[str] = None
|
|
60
|
+
delivered: bool = False
|
|
61
|
+
|
|
62
|
+
def to_payload(self) -> Dict[str, Any]:
|
|
63
|
+
"""Convert to webhook payload."""
|
|
64
|
+
return {
|
|
65
|
+
"event_id": self.id,
|
|
66
|
+
"event_type": self.type.value,
|
|
67
|
+
"grid_id": self.grid_id,
|
|
68
|
+
"timestamp": self.timestamp.isoformat(),
|
|
69
|
+
"data": self.data,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class WebhookConfig:
|
|
75
|
+
"""Configuration for a webhook endpoint."""
|
|
76
|
+
url: str
|
|
77
|
+
events: List[EventType] = field(default_factory=lambda: list(EventType))
|
|
78
|
+
|
|
79
|
+
# Authentication
|
|
80
|
+
secret: Optional[str] = None # For HMAC signature
|
|
81
|
+
headers: Dict[str, str] = field(default_factory=dict)
|
|
82
|
+
|
|
83
|
+
# Retry configuration
|
|
84
|
+
max_retries: int = 3
|
|
85
|
+
retry_delay_seconds: float = 5.0
|
|
86
|
+
retry_backoff_multiplier: float = 2.0
|
|
87
|
+
timeout_seconds: float = 30.0
|
|
88
|
+
|
|
89
|
+
# Filtering
|
|
90
|
+
enabled: bool = True
|
|
91
|
+
|
|
92
|
+
def accepts_event(self, event_type: EventType) -> bool:
|
|
93
|
+
"""Check if this webhook accepts the given event type."""
|
|
94
|
+
return self.enabled and event_type in self.events
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class DeliveryResult:
|
|
99
|
+
"""Result of a webhook delivery attempt."""
|
|
100
|
+
success: bool
|
|
101
|
+
status_code: Optional[int] = None
|
|
102
|
+
response_body: Optional[str] = None
|
|
103
|
+
error: Optional[str] = None
|
|
104
|
+
duration_ms: float = 0.0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class WebhookNotifier:
|
|
108
|
+
"""Manages webhook notifications for grid events.
|
|
109
|
+
|
|
110
|
+
Usage:
|
|
111
|
+
notifier = WebhookNotifier()
|
|
112
|
+
|
|
113
|
+
# Add webhook endpoints
|
|
114
|
+
notifier.add_webhook(WebhookConfig(
|
|
115
|
+
url="https://example.com/webhooks/smartify",
|
|
116
|
+
events=[EventType.RUN_COMPLETED, EventType.RUN_FAILED],
|
|
117
|
+
secret="my-secret-key",
|
|
118
|
+
))
|
|
119
|
+
|
|
120
|
+
# Send events
|
|
121
|
+
await notifier.notify(
|
|
122
|
+
event_type=EventType.RUN_COMPLETED,
|
|
123
|
+
grid_id="my-grid",
|
|
124
|
+
data={"outputs": {...}, "duration_seconds": 45.2}
|
|
125
|
+
)
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def __init__(
|
|
129
|
+
self,
|
|
130
|
+
default_timeout: float = 30.0,
|
|
131
|
+
max_concurrent_deliveries: int = 10,
|
|
132
|
+
):
|
|
133
|
+
self.webhooks: List[WebhookConfig] = []
|
|
134
|
+
self.default_timeout = default_timeout
|
|
135
|
+
self.max_concurrent = max_concurrent_deliveries
|
|
136
|
+
|
|
137
|
+
# Event history for debugging/auditing
|
|
138
|
+
self.event_history: List[WebhookEvent] = []
|
|
139
|
+
self.max_history_size: int = 1000
|
|
140
|
+
|
|
141
|
+
# Delivery semaphore to limit concurrency
|
|
142
|
+
self._semaphore = asyncio.Semaphore(max_concurrent_deliveries)
|
|
143
|
+
|
|
144
|
+
# Background retry queue
|
|
145
|
+
self._retry_queue: List[tuple[WebhookEvent, WebhookConfig]] = []
|
|
146
|
+
self._retry_task: Optional[asyncio.Task] = None
|
|
147
|
+
|
|
148
|
+
def add_webhook(self, config: WebhookConfig) -> None:
|
|
149
|
+
"""Add a webhook endpoint."""
|
|
150
|
+
self.webhooks.append(config)
|
|
151
|
+
logger.info(f"Added webhook: {config.url} for events: {[e.value for e in config.events]}")
|
|
152
|
+
|
|
153
|
+
def remove_webhook(self, url: str) -> bool:
|
|
154
|
+
"""Remove a webhook by URL. Returns True if found and removed."""
|
|
155
|
+
for i, webhook in enumerate(self.webhooks):
|
|
156
|
+
if webhook.url == url:
|
|
157
|
+
self.webhooks.pop(i)
|
|
158
|
+
logger.info(f"Removed webhook: {url}")
|
|
159
|
+
return True
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
def add_webhooks_from_grid(self, grid_spec: Any) -> None:
|
|
163
|
+
"""Add webhooks configured in a grid specification.
|
|
164
|
+
|
|
165
|
+
Looks for webhooks in grid.notifications.webhooks
|
|
166
|
+
"""
|
|
167
|
+
if not hasattr(grid_spec, 'notifications') or not grid_spec.notifications:
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
notifications = grid_spec.notifications
|
|
171
|
+
if not hasattr(notifications, 'webhooks') or not notifications.webhooks:
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
for webhook_config in notifications.webhooks:
|
|
175
|
+
# Convert from grid spec format
|
|
176
|
+
events = []
|
|
177
|
+
if hasattr(webhook_config, 'events') and webhook_config.events:
|
|
178
|
+
for event_name in webhook_config.events:
|
|
179
|
+
try:
|
|
180
|
+
events.append(EventType(event_name))
|
|
181
|
+
except ValueError:
|
|
182
|
+
logger.warning(f"Unknown event type: {event_name}")
|
|
183
|
+
else:
|
|
184
|
+
events = list(EventType) # All events by default
|
|
185
|
+
|
|
186
|
+
config = WebhookConfig(
|
|
187
|
+
url=webhook_config.url,
|
|
188
|
+
events=events,
|
|
189
|
+
secret=getattr(webhook_config, 'secret', None),
|
|
190
|
+
headers=getattr(webhook_config, 'headers', {}) or {},
|
|
191
|
+
max_retries=getattr(webhook_config, 'maxRetries', 3),
|
|
192
|
+
timeout_seconds=getattr(webhook_config, 'timeout', 30.0),
|
|
193
|
+
enabled=getattr(webhook_config, 'enabled', True),
|
|
194
|
+
)
|
|
195
|
+
self.add_webhook(config)
|
|
196
|
+
|
|
197
|
+
async def notify(
|
|
198
|
+
self,
|
|
199
|
+
event_type: EventType,
|
|
200
|
+
grid_id: str,
|
|
201
|
+
data: Dict[str, Any],
|
|
202
|
+
wait_for_delivery: bool = False,
|
|
203
|
+
) -> WebhookEvent:
|
|
204
|
+
"""Send a notification to all configured webhooks.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
event_type: Type of event
|
|
208
|
+
grid_id: ID of the grid this event relates to
|
|
209
|
+
data: Event-specific data payload
|
|
210
|
+
wait_for_delivery: If True, wait for all deliveries to complete
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
The created WebhookEvent
|
|
214
|
+
"""
|
|
215
|
+
event = WebhookEvent(
|
|
216
|
+
id=f"evt-{uuid4().hex[:12]}",
|
|
217
|
+
type=event_type,
|
|
218
|
+
grid_id=grid_id,
|
|
219
|
+
timestamp=datetime.now(),
|
|
220
|
+
data=data,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Track in history
|
|
224
|
+
self._add_to_history(event)
|
|
225
|
+
|
|
226
|
+
# Find webhooks that accept this event
|
|
227
|
+
matching_webhooks = [w for w in self.webhooks if w.accepts_event(event_type)]
|
|
228
|
+
|
|
229
|
+
if not matching_webhooks:
|
|
230
|
+
logger.debug(f"No webhooks configured for event {event_type.value}")
|
|
231
|
+
return event
|
|
232
|
+
|
|
233
|
+
logger.info(f"Sending {event_type.value} event to {len(matching_webhooks)} webhook(s)")
|
|
234
|
+
|
|
235
|
+
# Create delivery tasks
|
|
236
|
+
tasks = [
|
|
237
|
+
self._deliver_with_retry(event, webhook)
|
|
238
|
+
for webhook in matching_webhooks
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
if wait_for_delivery:
|
|
242
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
243
|
+
else:
|
|
244
|
+
# Fire and forget, but track tasks
|
|
245
|
+
for task in tasks:
|
|
246
|
+
asyncio.create_task(task)
|
|
247
|
+
|
|
248
|
+
return event
|
|
249
|
+
|
|
250
|
+
async def notify_run_started(
|
|
251
|
+
self,
|
|
252
|
+
grid_id: str,
|
|
253
|
+
grid_name: str,
|
|
254
|
+
inputs: Dict[str, Any],
|
|
255
|
+
**kwargs,
|
|
256
|
+
) -> WebhookEvent:
|
|
257
|
+
"""Convenience method for run_started events."""
|
|
258
|
+
return await self.notify(
|
|
259
|
+
EventType.RUN_STARTED,
|
|
260
|
+
grid_id,
|
|
261
|
+
{
|
|
262
|
+
"grid_name": grid_name,
|
|
263
|
+
"inputs": inputs,
|
|
264
|
+
"started_at": datetime.now().isoformat(),
|
|
265
|
+
**kwargs,
|
|
266
|
+
},
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
async def notify_run_completed(
|
|
270
|
+
self,
|
|
271
|
+
grid_id: str,
|
|
272
|
+
outputs: Dict[str, Any],
|
|
273
|
+
duration_seconds: float,
|
|
274
|
+
total_tokens: int,
|
|
275
|
+
total_cost: float,
|
|
276
|
+
**kwargs,
|
|
277
|
+
) -> WebhookEvent:
|
|
278
|
+
"""Convenience method for run_completed events."""
|
|
279
|
+
return await self.notify(
|
|
280
|
+
EventType.RUN_COMPLETED,
|
|
281
|
+
grid_id,
|
|
282
|
+
{
|
|
283
|
+
"outputs": outputs,
|
|
284
|
+
"duration_seconds": duration_seconds,
|
|
285
|
+
"total_tokens": total_tokens,
|
|
286
|
+
"total_cost": total_cost,
|
|
287
|
+
"completed_at": datetime.now().isoformat(),
|
|
288
|
+
**kwargs,
|
|
289
|
+
},
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
async def notify_run_failed(
|
|
293
|
+
self,
|
|
294
|
+
grid_id: str,
|
|
295
|
+
error: str,
|
|
296
|
+
node_id: Optional[str] = None,
|
|
297
|
+
duration_seconds: Optional[float] = None,
|
|
298
|
+
**kwargs,
|
|
299
|
+
) -> WebhookEvent:
|
|
300
|
+
"""Convenience method for run_failed events."""
|
|
301
|
+
return await self.notify(
|
|
302
|
+
EventType.RUN_FAILED,
|
|
303
|
+
grid_id,
|
|
304
|
+
{
|
|
305
|
+
"error": error,
|
|
306
|
+
"failed_node": node_id,
|
|
307
|
+
"duration_seconds": duration_seconds,
|
|
308
|
+
"failed_at": datetime.now().isoformat(),
|
|
309
|
+
**kwargs,
|
|
310
|
+
},
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
async def notify_breaker_tripped(
|
|
314
|
+
self,
|
|
315
|
+
grid_id: str,
|
|
316
|
+
breaker_type: str,
|
|
317
|
+
current_value: float,
|
|
318
|
+
limit: float,
|
|
319
|
+
action: str,
|
|
320
|
+
node_id: Optional[str] = None,
|
|
321
|
+
**kwargs,
|
|
322
|
+
) -> WebhookEvent:
|
|
323
|
+
"""Convenience method for breaker_tripped events."""
|
|
324
|
+
return await self.notify(
|
|
325
|
+
EventType.BREAKER_TRIPPED,
|
|
326
|
+
grid_id,
|
|
327
|
+
{
|
|
328
|
+
"breaker_type": breaker_type,
|
|
329
|
+
"current_value": current_value,
|
|
330
|
+
"limit": limit,
|
|
331
|
+
"action": action,
|
|
332
|
+
"node_id": node_id,
|
|
333
|
+
"tripped_at": datetime.now().isoformat(),
|
|
334
|
+
**kwargs,
|
|
335
|
+
},
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
async def notify_approval_needed(
|
|
339
|
+
self,
|
|
340
|
+
grid_id: str,
|
|
341
|
+
node_id: str,
|
|
342
|
+
approval_id: str,
|
|
343
|
+
prompt: str,
|
|
344
|
+
context: Dict[str, Any],
|
|
345
|
+
callback_url: str,
|
|
346
|
+
expires_at: datetime,
|
|
347
|
+
**kwargs,
|
|
348
|
+
) -> WebhookEvent:
|
|
349
|
+
"""Convenience method for approval_needed events."""
|
|
350
|
+
return await self.notify(
|
|
351
|
+
EventType.APPROVAL_NEEDED,
|
|
352
|
+
grid_id,
|
|
353
|
+
{
|
|
354
|
+
"node_id": node_id,
|
|
355
|
+
"approval_id": approval_id,
|
|
356
|
+
"prompt": prompt,
|
|
357
|
+
"context": context,
|
|
358
|
+
"callback_url": callback_url,
|
|
359
|
+
"expires_at": expires_at.isoformat(),
|
|
360
|
+
**kwargs,
|
|
361
|
+
},
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
async def _deliver_with_retry(
|
|
365
|
+
self,
|
|
366
|
+
event: WebhookEvent,
|
|
367
|
+
config: WebhookConfig,
|
|
368
|
+
) -> DeliveryResult:
|
|
369
|
+
"""Deliver event to webhook with retry logic."""
|
|
370
|
+
async with self._semaphore:
|
|
371
|
+
delay = config.retry_delay_seconds
|
|
372
|
+
|
|
373
|
+
for attempt in range(config.max_retries + 1):
|
|
374
|
+
event.attempts += 1
|
|
375
|
+
event.last_attempt = datetime.now()
|
|
376
|
+
|
|
377
|
+
result = await self._deliver(event, config)
|
|
378
|
+
|
|
379
|
+
if result.success:
|
|
380
|
+
event.delivered = True
|
|
381
|
+
logger.info(
|
|
382
|
+
f"Delivered event {event.id} to {config.url} "
|
|
383
|
+
f"(attempt {attempt + 1}, {result.duration_ms:.0f}ms)"
|
|
384
|
+
)
|
|
385
|
+
return result
|
|
386
|
+
|
|
387
|
+
event.last_error = result.error
|
|
388
|
+
|
|
389
|
+
if attempt < config.max_retries:
|
|
390
|
+
logger.warning(
|
|
391
|
+
f"Webhook delivery failed for {event.id} to {config.url}: "
|
|
392
|
+
f"{result.error}. Retrying in {delay}s..."
|
|
393
|
+
)
|
|
394
|
+
await asyncio.sleep(delay)
|
|
395
|
+
delay *= config.retry_backoff_multiplier
|
|
396
|
+
else:
|
|
397
|
+
logger.error(
|
|
398
|
+
f"Webhook delivery failed permanently for {event.id} to {config.url} "
|
|
399
|
+
f"after {config.max_retries + 1} attempts: {result.error}"
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
return result
|
|
403
|
+
|
|
404
|
+
async def _deliver(
|
|
405
|
+
self,
|
|
406
|
+
event: WebhookEvent,
|
|
407
|
+
config: WebhookConfig,
|
|
408
|
+
) -> DeliveryResult:
|
|
409
|
+
"""Deliver a single webhook request."""
|
|
410
|
+
start_time = time.monotonic()
|
|
411
|
+
payload = event.to_payload()
|
|
412
|
+
body = json.dumps(payload)
|
|
413
|
+
|
|
414
|
+
# Build headers
|
|
415
|
+
headers = {
|
|
416
|
+
"Content-Type": "application/json",
|
|
417
|
+
"User-Agent": "Smartify-Webhook/1.0",
|
|
418
|
+
"X-Smartify-Event": event.type.value,
|
|
419
|
+
"X-Smartify-Event-ID": event.id,
|
|
420
|
+
"X-Smartify-Grid-ID": event.grid_id,
|
|
421
|
+
"X-Smartify-Timestamp": str(int(event.timestamp.timestamp())),
|
|
422
|
+
**config.headers,
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
# Add HMAC signature if secret configured
|
|
426
|
+
if config.secret:
|
|
427
|
+
signature = self._compute_signature(body, config.secret)
|
|
428
|
+
headers["X-Smartify-Signature"] = f"sha256={signature}"
|
|
429
|
+
|
|
430
|
+
try:
|
|
431
|
+
async with httpx.AsyncClient() as client:
|
|
432
|
+
response = await client.post(
|
|
433
|
+
config.url,
|
|
434
|
+
content=body,
|
|
435
|
+
headers=headers,
|
|
436
|
+
timeout=config.timeout_seconds,
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
duration_ms = (time.monotonic() - start_time) * 1000
|
|
440
|
+
|
|
441
|
+
# Accept 2xx as success
|
|
442
|
+
if 200 <= response.status_code < 300:
|
|
443
|
+
return DeliveryResult(
|
|
444
|
+
success=True,
|
|
445
|
+
status_code=response.status_code,
|
|
446
|
+
response_body=response.text[:1000], # Truncate
|
|
447
|
+
duration_ms=duration_ms,
|
|
448
|
+
)
|
|
449
|
+
else:
|
|
450
|
+
return DeliveryResult(
|
|
451
|
+
success=False,
|
|
452
|
+
status_code=response.status_code,
|
|
453
|
+
response_body=response.text[:1000],
|
|
454
|
+
error=f"HTTP {response.status_code}: {response.text[:200]}",
|
|
455
|
+
duration_ms=duration_ms,
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
except httpx.TimeoutException:
|
|
459
|
+
duration_ms = (time.monotonic() - start_time) * 1000
|
|
460
|
+
return DeliveryResult(
|
|
461
|
+
success=False,
|
|
462
|
+
error=f"Timeout after {config.timeout_seconds}s",
|
|
463
|
+
duration_ms=duration_ms,
|
|
464
|
+
)
|
|
465
|
+
except httpx.RequestError as e:
|
|
466
|
+
duration_ms = (time.monotonic() - start_time) * 1000
|
|
467
|
+
return DeliveryResult(
|
|
468
|
+
success=False,
|
|
469
|
+
error=f"Request error: {str(e)}",
|
|
470
|
+
duration_ms=duration_ms,
|
|
471
|
+
)
|
|
472
|
+
except Exception as e:
|
|
473
|
+
duration_ms = (time.monotonic() - start_time) * 1000
|
|
474
|
+
return DeliveryResult(
|
|
475
|
+
success=False,
|
|
476
|
+
error=f"Unexpected error: {str(e)}",
|
|
477
|
+
duration_ms=duration_ms,
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
def _compute_signature(self, body: str, secret: str) -> str:
|
|
481
|
+
"""Compute HMAC-SHA256 signature for webhook payload."""
|
|
482
|
+
return hmac.new(
|
|
483
|
+
secret.encode('utf-8'),
|
|
484
|
+
body.encode('utf-8'),
|
|
485
|
+
hashlib.sha256,
|
|
486
|
+
).hexdigest()
|
|
487
|
+
|
|
488
|
+
def _add_to_history(self, event: WebhookEvent) -> None:
|
|
489
|
+
"""Add event to history, trimming if needed."""
|
|
490
|
+
self.event_history.append(event)
|
|
491
|
+
if len(self.event_history) > self.max_history_size:
|
|
492
|
+
self.event_history = self.event_history[-self.max_history_size:]
|
|
493
|
+
|
|
494
|
+
def get_recent_events(
|
|
495
|
+
self,
|
|
496
|
+
limit: int = 50,
|
|
497
|
+
event_type: Optional[EventType] = None,
|
|
498
|
+
grid_id: Optional[str] = None,
|
|
499
|
+
) -> List[WebhookEvent]:
|
|
500
|
+
"""Get recent events from history with optional filtering."""
|
|
501
|
+
events = self.event_history
|
|
502
|
+
|
|
503
|
+
if event_type:
|
|
504
|
+
events = [e for e in events if e.type == event_type]
|
|
505
|
+
|
|
506
|
+
if grid_id:
|
|
507
|
+
events = [e for e in events if e.grid_id == grid_id]
|
|
508
|
+
|
|
509
|
+
return events[-limit:]
|
|
510
|
+
|
|
511
|
+
def get_failed_deliveries(self) -> List[WebhookEvent]:
|
|
512
|
+
"""Get events that failed to deliver."""
|
|
513
|
+
return [e for e in self.event_history if not e.delivered and e.attempts > 0]
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
# Singleton for global access
|
|
517
|
+
_webhook_notifier: Optional[WebhookNotifier] = None
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def get_webhook_notifier() -> WebhookNotifier:
|
|
521
|
+
"""Get the global webhook notifier instance."""
|
|
522
|
+
global _webhook_notifier
|
|
523
|
+
if _webhook_notifier is None:
|
|
524
|
+
_webhook_notifier = WebhookNotifier()
|
|
525
|
+
return _webhook_notifier
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def configure_webhook_notifier(
|
|
529
|
+
webhooks: Optional[List[Dict[str, Any]]] = None,
|
|
530
|
+
default_timeout: float = 30.0,
|
|
531
|
+
) -> WebhookNotifier:
|
|
532
|
+
"""Configure the global webhook notifier."""
|
|
533
|
+
global _webhook_notifier
|
|
534
|
+
|
|
535
|
+
_webhook_notifier = WebhookNotifier(default_timeout=default_timeout)
|
|
536
|
+
|
|
537
|
+
if webhooks:
|
|
538
|
+
for wh in webhooks:
|
|
539
|
+
events = []
|
|
540
|
+
for event_name in wh.get('events', []):
|
|
541
|
+
try:
|
|
542
|
+
events.append(EventType(event_name))
|
|
543
|
+
except ValueError:
|
|
544
|
+
pass
|
|
545
|
+
|
|
546
|
+
config = WebhookConfig(
|
|
547
|
+
url=wh['url'],
|
|
548
|
+
events=events or list(EventType),
|
|
549
|
+
secret=wh.get('secret'),
|
|
550
|
+
headers=wh.get('headers', {}),
|
|
551
|
+
max_retries=wh.get('maxRetries', 3),
|
|
552
|
+
timeout_seconds=wh.get('timeout', 30.0),
|
|
553
|
+
)
|
|
554
|
+
_webhook_notifier.add_webhook(config)
|
|
555
|
+
|
|
556
|
+
return _webhook_notifier
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""State persistence for Smartify."""
|
|
2
|
+
|
|
3
|
+
from smartify.state.store import (
|
|
4
|
+
RunStatus,
|
|
5
|
+
RunRecord,
|
|
6
|
+
NodeOutput,
|
|
7
|
+
StateStore,
|
|
8
|
+
InMemoryStore,
|
|
9
|
+
SQLiteStore,
|
|
10
|
+
get_default_store,
|
|
11
|
+
set_default_store,
|
|
12
|
+
)
|
|
13
|
+
from smartify.state.checkpoint import (
|
|
14
|
+
CheckpointStore,
|
|
15
|
+
CheckpointStatus,
|
|
16
|
+
Checkpoint,
|
|
17
|
+
WebhookRetryJob,
|
|
18
|
+
WebhookDeliveryStatus,
|
|
19
|
+
get_checkpoint_store,
|
|
20
|
+
)
|
|
21
|
+
from smartify.state.resume import (
|
|
22
|
+
ResumeManager,
|
|
23
|
+
create_checkpointed_orchestrator,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
# Store
|
|
28
|
+
"RunStatus",
|
|
29
|
+
"RunRecord",
|
|
30
|
+
"NodeOutput",
|
|
31
|
+
"StateStore",
|
|
32
|
+
"InMemoryStore",
|
|
33
|
+
"SQLiteStore",
|
|
34
|
+
"get_default_store",
|
|
35
|
+
"set_default_store",
|
|
36
|
+
# Checkpoint
|
|
37
|
+
"CheckpointStore",
|
|
38
|
+
"CheckpointStatus",
|
|
39
|
+
"Checkpoint",
|
|
40
|
+
"WebhookRetryJob",
|
|
41
|
+
"WebhookDeliveryStatus",
|
|
42
|
+
"get_checkpoint_store",
|
|
43
|
+
# Resume
|
|
44
|
+
"ResumeManager",
|
|
45
|
+
"create_checkpointed_orchestrator",
|
|
46
|
+
]
|