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,479 @@
1
+ """Approval flow for human-in-the-loop nodes.
2
+
3
+ Approval nodes pause grid execution and wait for human approval
4
+ before continuing. They can send notifications via multiple channels
5
+ (Slack, webhook, email) and track approval state.
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ from abc import ABC, abstractmethod
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime, timedelta
13
+ from enum import Enum
14
+ from typing import Any, Callable, Dict, List, Optional, Protocol
15
+ from uuid import uuid4
16
+ import json
17
+
18
+ import httpx
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class ApprovalStatus(str, Enum):
24
+ """Status of an approval request."""
25
+ PENDING = "pending"
26
+ APPROVED = "approved"
27
+ REJECTED = "rejected"
28
+ EXPIRED = "expired"
29
+ CANCELLED = "cancelled"
30
+
31
+
32
+ @dataclass
33
+ class ApprovalRequest:
34
+ """An approval request awaiting human decision."""
35
+ id: str
36
+ grid_id: str
37
+ node_id: str
38
+ prompt: str
39
+ context: Dict[str, Any]
40
+
41
+ # Configuration
42
+ timeout_seconds: int = 86400 # 24 hours default
43
+ required_approvers: int = 1
44
+ allowed_approvers: List[str] = field(default_factory=list)
45
+
46
+ # State
47
+ status: ApprovalStatus = ApprovalStatus.PENDING
48
+ created_at: datetime = field(default_factory=datetime.now)
49
+ resolved_at: Optional[datetime] = None
50
+ approvers: List[str] = field(default_factory=list)
51
+ rejection_reason: Optional[str] = None
52
+
53
+ # Outputs from preceding nodes to show
54
+ show_outputs: Dict[str, Any] = field(default_factory=dict)
55
+
56
+ @property
57
+ def expires_at(self) -> datetime:
58
+ return self.created_at + timedelta(seconds=self.timeout_seconds)
59
+
60
+ @property
61
+ def is_expired(self) -> bool:
62
+ return datetime.now() > self.expires_at and self.status == ApprovalStatus.PENDING
63
+
64
+ def approve(self, approver: str) -> bool:
65
+ """Record an approval. Returns True if fully approved."""
66
+ if self.status != ApprovalStatus.PENDING:
67
+ return False
68
+
69
+ if self.allowed_approvers and approver not in self.allowed_approvers:
70
+ logger.warning(f"Approver {approver} not in allowed list")
71
+ return False
72
+
73
+ if approver not in self.approvers:
74
+ self.approvers.append(approver)
75
+
76
+ if len(self.approvers) >= self.required_approvers:
77
+ self.status = ApprovalStatus.APPROVED
78
+ self.resolved_at = datetime.now()
79
+ return True
80
+
81
+ return False
82
+
83
+ def reject(self, approver: str, reason: Optional[str] = None) -> None:
84
+ """Reject the approval request."""
85
+ if self.status != ApprovalStatus.PENDING:
86
+ return
87
+
88
+ self.status = ApprovalStatus.REJECTED
89
+ self.resolved_at = datetime.now()
90
+ self.rejection_reason = reason
91
+ self.approvers.append(approver)
92
+
93
+
94
+ class NotificationChannel(ABC):
95
+ """Abstract base for notification channels."""
96
+
97
+ @abstractmethod
98
+ async def send(self, request: ApprovalRequest, callback_url: str) -> bool:
99
+ """Send approval notification. Returns True if sent successfully."""
100
+ pass
101
+
102
+
103
+ class SlackNotificationChannel(NotificationChannel):
104
+ """Send approval requests to Slack."""
105
+
106
+ def __init__(
107
+ self,
108
+ webhook_url: Optional[str] = None,
109
+ bot_token: Optional[str] = None,
110
+ channel: Optional[str] = None,
111
+ ):
112
+ self.webhook_url = webhook_url
113
+ self.bot_token = bot_token
114
+ self.channel = channel
115
+
116
+ async def send(self, request: ApprovalRequest, callback_url: str) -> bool:
117
+ """Send Slack notification with approval buttons."""
118
+ if not (self.webhook_url or self.bot_token):
119
+ logger.warning("Slack not configured - skipping notification")
120
+ return False
121
+
122
+ # Build message blocks
123
+ blocks = [
124
+ {
125
+ "type": "header",
126
+ "text": {
127
+ "type": "plain_text",
128
+ "text": "🔔 Approval Required",
129
+ }
130
+ },
131
+ {
132
+ "type": "section",
133
+ "text": {
134
+ "type": "mrkdwn",
135
+ "text": f"*Grid:* {request.grid_id}\n*Node:* {request.node_id}",
136
+ }
137
+ },
138
+ {
139
+ "type": "section",
140
+ "text": {
141
+ "type": "mrkdwn",
142
+ "text": request.prompt,
143
+ }
144
+ },
145
+ ]
146
+
147
+ # Add context from previous nodes if available
148
+ if request.show_outputs:
149
+ context_text = "```\n" + json.dumps(request.show_outputs, indent=2)[:2000] + "\n```"
150
+ blocks.append({
151
+ "type": "section",
152
+ "text": {
153
+ "type": "mrkdwn",
154
+ "text": f"*Context:*\n{context_text}",
155
+ }
156
+ })
157
+
158
+ # Add approval buttons
159
+ blocks.append({
160
+ "type": "actions",
161
+ "elements": [
162
+ {
163
+ "type": "button",
164
+ "text": {"type": "plain_text", "text": "✅ Approve"},
165
+ "style": "primary",
166
+ "action_id": f"approve_{request.id}",
167
+ "value": request.id,
168
+ },
169
+ {
170
+ "type": "button",
171
+ "text": {"type": "plain_text", "text": "❌ Reject"},
172
+ "style": "danger",
173
+ "action_id": f"reject_{request.id}",
174
+ "value": request.id,
175
+ },
176
+ ]
177
+ })
178
+
179
+ # Add expiration notice
180
+ blocks.append({
181
+ "type": "context",
182
+ "elements": [{
183
+ "type": "mrkdwn",
184
+ "text": f"⏰ Expires: {request.expires_at.strftime('%Y-%m-%d %H:%M:%S')} | Request ID: `{request.id}`"
185
+ }]
186
+ })
187
+
188
+ payload = {"blocks": blocks}
189
+
190
+ try:
191
+ async with httpx.AsyncClient() as client:
192
+ if self.webhook_url:
193
+ response = await client.post(
194
+ self.webhook_url,
195
+ json=payload,
196
+ timeout=10.0,
197
+ )
198
+ elif self.bot_token and self.channel:
199
+ payload["channel"] = self.channel
200
+ response = await client.post(
201
+ "https://slack.com/api/chat.postMessage",
202
+ json=payload,
203
+ headers={"Authorization": f"Bearer {self.bot_token}"},
204
+ timeout=10.0,
205
+ )
206
+ else:
207
+ return False
208
+
209
+ if response.status_code == 200:
210
+ logger.info(f"Slack notification sent for approval {request.id}")
211
+ return True
212
+ else:
213
+ logger.error(f"Slack notification failed: {response.text}")
214
+ return False
215
+
216
+ except Exception as e:
217
+ logger.error(f"Failed to send Slack notification: {e}")
218
+ return False
219
+
220
+
221
+ class WebhookNotificationChannel(NotificationChannel):
222
+ """Send approval requests to a webhook URL."""
223
+
224
+ def __init__(self, url: str, headers: Optional[Dict[str, str]] = None):
225
+ self.url = url
226
+ self.headers = headers or {}
227
+
228
+ async def send(self, request: ApprovalRequest, callback_url: str) -> bool:
229
+ """POST approval request to webhook."""
230
+ payload = {
231
+ "type": "approval_request",
232
+ "request_id": request.id,
233
+ "grid_id": request.grid_id,
234
+ "node_id": request.node_id,
235
+ "prompt": request.prompt,
236
+ "context": request.show_outputs,
237
+ "callback_url": callback_url,
238
+ "expires_at": request.expires_at.isoformat(),
239
+ }
240
+
241
+ try:
242
+ async with httpx.AsyncClient() as client:
243
+ response = await client.post(
244
+ self.url,
245
+ json=payload,
246
+ headers=self.headers,
247
+ timeout=10.0,
248
+ )
249
+
250
+ if response.status_code in (200, 201, 202):
251
+ logger.info(f"Webhook notification sent for approval {request.id}")
252
+ return True
253
+ else:
254
+ logger.error(f"Webhook notification failed: {response.status_code}")
255
+ return False
256
+
257
+ except Exception as e:
258
+ logger.error(f"Failed to send webhook notification: {e}")
259
+ return False
260
+
261
+
262
+ class ApprovalManager:
263
+ """Manages approval requests and notifications.
264
+
265
+ Usage:
266
+ manager = ApprovalManager()
267
+ manager.add_channel(SlackNotificationChannel(webhook_url="..."))
268
+
269
+ # Create approval request
270
+ request = await manager.create_request(
271
+ grid_id="grid-1",
272
+ node_id="approval-node-1",
273
+ prompt="Review this deployment",
274
+ context={"changes": [...]},
275
+ )
276
+
277
+ # Wait for approval (with timeout)
278
+ result = await manager.wait_for_approval(request.id, timeout=3600)
279
+ """
280
+
281
+ def __init__(self, callback_base_url: Optional[str] = None):
282
+ self.requests: Dict[str, ApprovalRequest] = {}
283
+ self.channels: List[NotificationChannel] = []
284
+ self.callback_base_url = callback_base_url or "http://localhost:8000"
285
+
286
+ # Event tracking for async waiting
287
+ self._events: Dict[str, asyncio.Event] = {}
288
+
289
+ def add_channel(self, channel: NotificationChannel) -> None:
290
+ """Add a notification channel."""
291
+ self.channels.append(channel)
292
+
293
+ async def create_request(
294
+ self,
295
+ grid_id: str,
296
+ node_id: str,
297
+ prompt: str,
298
+ context: Optional[Dict[str, Any]] = None,
299
+ timeout_seconds: int = 86400,
300
+ required_approvers: int = 1,
301
+ allowed_approvers: Optional[List[str]] = None,
302
+ show_outputs: Optional[Dict[str, Any]] = None,
303
+ ) -> ApprovalRequest:
304
+ """Create a new approval request and send notifications."""
305
+ request_id = f"apr-{uuid4().hex[:12]}"
306
+
307
+ request = ApprovalRequest(
308
+ id=request_id,
309
+ grid_id=grid_id,
310
+ node_id=node_id,
311
+ prompt=prompt,
312
+ context=context or {},
313
+ timeout_seconds=timeout_seconds,
314
+ required_approvers=required_approvers,
315
+ allowed_approvers=allowed_approvers or [],
316
+ show_outputs=show_outputs or {},
317
+ )
318
+
319
+ self.requests[request_id] = request
320
+ self._events[request_id] = asyncio.Event()
321
+
322
+ # Send notifications
323
+ callback_url = f"{self.callback_base_url}/approvals/{request_id}"
324
+
325
+ for channel in self.channels:
326
+ try:
327
+ await channel.send(request, callback_url)
328
+ except Exception as e:
329
+ logger.error(f"Notification channel failed: {e}")
330
+
331
+ logger.info(f"Created approval request {request_id} for {grid_id}/{node_id}")
332
+
333
+ return request
334
+
335
+ def get_request(self, request_id: str) -> Optional[ApprovalRequest]:
336
+ """Get an approval request by ID."""
337
+ return self.requests.get(request_id)
338
+
339
+ def process_approval(self, request_id: str, approver: str) -> bool:
340
+ """Process an approval action. Returns True if fully approved."""
341
+ request = self.requests.get(request_id)
342
+ if not request:
343
+ logger.warning(f"Approval request {request_id} not found")
344
+ return False
345
+
346
+ if request.is_expired:
347
+ request.status = ApprovalStatus.EXPIRED
348
+ self._signal_complete(request_id)
349
+ return False
350
+
351
+ result = request.approve(approver)
352
+
353
+ if request.status == ApprovalStatus.APPROVED:
354
+ self._signal_complete(request_id)
355
+
356
+ return result
357
+
358
+ def process_rejection(
359
+ self,
360
+ request_id: str,
361
+ approver: str,
362
+ reason: Optional[str] = None,
363
+ ) -> None:
364
+ """Process a rejection action."""
365
+ request = self.requests.get(request_id)
366
+ if not request:
367
+ logger.warning(f"Approval request {request_id} not found")
368
+ return
369
+
370
+ request.reject(approver, reason)
371
+ self._signal_complete(request_id)
372
+
373
+ def _signal_complete(self, request_id: str) -> None:
374
+ """Signal that a request has been resolved."""
375
+ if request_id in self._events:
376
+ self._events[request_id].set()
377
+
378
+ async def wait_for_approval(
379
+ self,
380
+ request_id: str,
381
+ timeout: Optional[int] = None,
382
+ ) -> ApprovalRequest:
383
+ """Wait for an approval request to be resolved.
384
+
385
+ Args:
386
+ request_id: The approval request ID
387
+ timeout: Override timeout in seconds (defaults to request's timeout)
388
+
389
+ Returns:
390
+ The resolved ApprovalRequest
391
+
392
+ Raises:
393
+ TimeoutError: If timeout is reached
394
+ """
395
+ request = self.requests.get(request_id)
396
+ if not request:
397
+ raise ValueError(f"Approval request {request_id} not found")
398
+
399
+ if request.status != ApprovalStatus.PENDING:
400
+ return request
401
+
402
+ event = self._events.get(request_id)
403
+ if not event:
404
+ event = asyncio.Event()
405
+ self._events[request_id] = event
406
+
407
+ wait_timeout = timeout or request.timeout_seconds
408
+
409
+ try:
410
+ await asyncio.wait_for(event.wait(), timeout=wait_timeout)
411
+ except asyncio.TimeoutError:
412
+ request.status = ApprovalStatus.EXPIRED
413
+ raise TimeoutError(f"Approval request {request_id} expired")
414
+
415
+ return request
416
+
417
+ def get_pending_requests(self, grid_id: Optional[str] = None) -> List[ApprovalRequest]:
418
+ """Get all pending approval requests, optionally filtered by grid."""
419
+ pending = []
420
+ for request in self.requests.values():
421
+ if request.status == ApprovalStatus.PENDING:
422
+ # Check for expiration
423
+ if request.is_expired:
424
+ request.status = ApprovalStatus.EXPIRED
425
+ continue
426
+
427
+ if grid_id is None or request.grid_id == grid_id:
428
+ pending.append(request)
429
+ return pending
430
+
431
+ def cleanup_expired(self) -> int:
432
+ """Mark expired requests and return count."""
433
+ count = 0
434
+ for request in self.requests.values():
435
+ if request.status == ApprovalStatus.PENDING and request.is_expired:
436
+ request.status = ApprovalStatus.EXPIRED
437
+ self._signal_complete(request.id)
438
+ count += 1
439
+ return count
440
+
441
+
442
+ # Singleton for global access
443
+ _approval_manager: Optional[ApprovalManager] = None
444
+
445
+
446
+ def get_approval_manager() -> ApprovalManager:
447
+ """Get the global approval manager instance."""
448
+ global _approval_manager
449
+ if _approval_manager is None:
450
+ _approval_manager = ApprovalManager()
451
+ return _approval_manager
452
+
453
+
454
+ def configure_approval_manager(
455
+ callback_base_url: Optional[str] = None,
456
+ slack_webhook: Optional[str] = None,
457
+ slack_bot_token: Optional[str] = None,
458
+ slack_channel: Optional[str] = None,
459
+ webhooks: Optional[List[str]] = None,
460
+ ) -> ApprovalManager:
461
+ """Configure the global approval manager with notification channels."""
462
+ global _approval_manager
463
+
464
+ _approval_manager = ApprovalManager(callback_base_url=callback_base_url)
465
+
466
+ # Add Slack if configured
467
+ if slack_webhook or slack_bot_token:
468
+ _approval_manager.add_channel(SlackNotificationChannel(
469
+ webhook_url=slack_webhook,
470
+ bot_token=slack_bot_token,
471
+ channel=slack_channel,
472
+ ))
473
+
474
+ # Add webhooks
475
+ if webhooks:
476
+ for url in webhooks:
477
+ _approval_manager.add_channel(WebhookNotificationChannel(url))
478
+
479
+ return _approval_manager