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,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
|