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.
Files changed (46) hide show
  1. smartify/__init__.py +3 -0
  2. smartify/agents/__init__.py +0 -0
  3. smartify/agents/adapters/__init__.py +13 -0
  4. smartify/agents/adapters/anthropic.py +253 -0
  5. smartify/agents/adapters/openai.py +289 -0
  6. smartify/api/__init__.py +26 -0
  7. smartify/api/auth.py +352 -0
  8. smartify/api/errors.py +380 -0
  9. smartify/api/events.py +345 -0
  10. smartify/api/server.py +992 -0
  11. smartify/cli/__init__.py +1 -0
  12. smartify/cli/main.py +430 -0
  13. smartify/engine/__init__.py +64 -0
  14. smartify/engine/approval.py +479 -0
  15. smartify/engine/orchestrator.py +1365 -0
  16. smartify/engine/scheduler.py +380 -0
  17. smartify/engine/spark.py +294 -0
  18. smartify/guardrails/__init__.py +22 -0
  19. smartify/guardrails/breakers.py +409 -0
  20. smartify/models/__init__.py +61 -0
  21. smartify/models/grid.py +625 -0
  22. smartify/notifications/__init__.py +22 -0
  23. smartify/notifications/webhook.py +556 -0
  24. smartify/state/__init__.py +46 -0
  25. smartify/state/checkpoint.py +558 -0
  26. smartify/state/resume.py +301 -0
  27. smartify/state/store.py +370 -0
  28. smartify/tools/__init__.py +17 -0
  29. smartify/tools/base.py +196 -0
  30. smartify/tools/builtin/__init__.py +79 -0
  31. smartify/tools/builtin/file.py +464 -0
  32. smartify/tools/builtin/http.py +195 -0
  33. smartify/tools/builtin/shell.py +137 -0
  34. smartify/tools/mcp/__init__.py +33 -0
  35. smartify/tools/mcp/adapter.py +157 -0
  36. smartify/tools/mcp/client.py +334 -0
  37. smartify/tools/mcp/registry.py +130 -0
  38. smartify/validator/__init__.py +0 -0
  39. smartify/validator/validate.py +271 -0
  40. smartify/workspace/__init__.py +5 -0
  41. smartify/workspace/manager.py +248 -0
  42. smartify_ai-0.1.0.dist-info/METADATA +201 -0
  43. smartify_ai-0.1.0.dist-info/RECORD +46 -0
  44. smartify_ai-0.1.0.dist-info/WHEEL +4 -0
  45. smartify_ai-0.1.0.dist-info/entry_points.txt +2 -0
  46. 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
+ ]