alma-memory 0.5.0__py3-none-any.whl → 0.7.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.
- alma/__init__.py +296 -194
- alma/compression/__init__.py +33 -0
- alma/compression/pipeline.py +980 -0
- alma/confidence/__init__.py +47 -47
- alma/confidence/engine.py +540 -540
- alma/confidence/types.py +351 -351
- alma/config/loader.py +157 -157
- alma/consolidation/__init__.py +23 -23
- alma/consolidation/engine.py +678 -678
- alma/consolidation/prompts.py +84 -84
- alma/core.py +1189 -322
- alma/domains/__init__.py +30 -30
- alma/domains/factory.py +359 -359
- alma/domains/schemas.py +448 -448
- alma/domains/types.py +272 -272
- alma/events/__init__.py +75 -75
- alma/events/emitter.py +285 -284
- alma/events/storage_mixin.py +246 -246
- alma/events/types.py +126 -126
- alma/events/webhook.py +425 -425
- alma/exceptions.py +49 -49
- alma/extraction/__init__.py +31 -31
- alma/extraction/auto_learner.py +265 -264
- alma/extraction/extractor.py +420 -420
- alma/graph/__init__.py +106 -81
- alma/graph/backends/__init__.py +32 -18
- alma/graph/backends/kuzu.py +624 -0
- alma/graph/backends/memgraph.py +432 -0
- alma/graph/backends/memory.py +236 -236
- alma/graph/backends/neo4j.py +417 -417
- alma/graph/base.py +159 -159
- alma/graph/extraction.py +198 -198
- alma/graph/store.py +860 -860
- alma/harness/__init__.py +35 -35
- alma/harness/base.py +386 -386
- alma/harness/domains.py +705 -705
- alma/initializer/__init__.py +37 -37
- alma/initializer/initializer.py +418 -418
- alma/initializer/types.py +250 -250
- alma/integration/__init__.py +62 -62
- alma/integration/claude_agents.py +444 -432
- alma/integration/helena.py +423 -423
- alma/integration/victor.py +471 -471
- alma/learning/__init__.py +101 -86
- alma/learning/decay.py +878 -0
- alma/learning/forgetting.py +1446 -1446
- alma/learning/heuristic_extractor.py +390 -390
- alma/learning/protocols.py +374 -374
- alma/learning/validation.py +346 -346
- alma/mcp/__init__.py +123 -45
- alma/mcp/__main__.py +156 -156
- alma/mcp/resources.py +122 -122
- alma/mcp/server.py +955 -591
- alma/mcp/tools.py +3254 -511
- alma/observability/__init__.py +91 -0
- alma/observability/config.py +302 -0
- alma/observability/guidelines.py +170 -0
- alma/observability/logging.py +424 -0
- alma/observability/metrics.py +583 -0
- alma/observability/tracing.py +440 -0
- alma/progress/__init__.py +21 -21
- alma/progress/tracker.py +607 -607
- alma/progress/types.py +250 -250
- alma/retrieval/__init__.py +134 -53
- alma/retrieval/budget.py +525 -0
- alma/retrieval/cache.py +1304 -1061
- alma/retrieval/embeddings.py +202 -202
- alma/retrieval/engine.py +850 -366
- alma/retrieval/modes.py +365 -0
- alma/retrieval/progressive.py +560 -0
- alma/retrieval/scoring.py +344 -344
- alma/retrieval/trust_scoring.py +637 -0
- alma/retrieval/verification.py +797 -0
- alma/session/__init__.py +19 -19
- alma/session/manager.py +442 -399
- alma/session/types.py +288 -288
- alma/storage/__init__.py +101 -61
- alma/storage/archive.py +233 -0
- alma/storage/azure_cosmos.py +1259 -1048
- alma/storage/base.py +1083 -525
- alma/storage/chroma.py +1443 -1443
- alma/storage/constants.py +103 -0
- alma/storage/file_based.py +614 -619
- alma/storage/migrations/__init__.py +21 -0
- alma/storage/migrations/base.py +321 -0
- alma/storage/migrations/runner.py +323 -0
- alma/storage/migrations/version_stores.py +337 -0
- alma/storage/migrations/versions/__init__.py +11 -0
- alma/storage/migrations/versions/v1_0_0.py +373 -0
- alma/storage/migrations/versions/v1_1_0_workflow_context.py +551 -0
- alma/storage/pinecone.py +1080 -1080
- alma/storage/postgresql.py +1948 -1452
- alma/storage/qdrant.py +1306 -1306
- alma/storage/sqlite_local.py +3041 -1358
- alma/testing/__init__.py +46 -0
- alma/testing/factories.py +301 -0
- alma/testing/mocks.py +389 -0
- alma/types.py +292 -264
- alma/utils/__init__.py +19 -0
- alma/utils/tokenizer.py +521 -0
- alma/workflow/__init__.py +83 -0
- alma/workflow/artifacts.py +170 -0
- alma/workflow/checkpoint.py +311 -0
- alma/workflow/context.py +228 -0
- alma/workflow/outcomes.py +189 -0
- alma/workflow/reducers.py +393 -0
- {alma_memory-0.5.0.dist-info → alma_memory-0.7.0.dist-info}/METADATA +244 -72
- alma_memory-0.7.0.dist-info/RECORD +112 -0
- alma_memory-0.5.0.dist-info/RECORD +0 -76
- {alma_memory-0.5.0.dist-info → alma_memory-0.7.0.dist-info}/WHEEL +0 -0
- {alma_memory-0.5.0.dist-info → alma_memory-0.7.0.dist-info}/top_level.txt +0 -0
alma/events/webhook.py
CHANGED
|
@@ -1,425 +1,425 @@
|
|
|
1
|
-
"""
|
|
2
|
-
ALMA Webhook Delivery.
|
|
3
|
-
|
|
4
|
-
Provides webhook delivery capabilities for external system integration.
|
|
5
|
-
Webhooks are delivered asynchronously with retry logic and signature
|
|
6
|
-
verification support.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
import asyncio
|
|
10
|
-
import hashlib
|
|
11
|
-
import hmac
|
|
12
|
-
import json
|
|
13
|
-
import logging
|
|
14
|
-
import time
|
|
15
|
-
from dataclasses import dataclass, field
|
|
16
|
-
from enum import Enum
|
|
17
|
-
|
|
18
|
-
# TYPE_CHECKING import for forward references
|
|
19
|
-
from typing import TYPE_CHECKING, Dict, List, Optional
|
|
20
|
-
|
|
21
|
-
from alma.events.types import MemoryEvent, MemoryEventType
|
|
22
|
-
|
|
23
|
-
if TYPE_CHECKING:
|
|
24
|
-
from alma.events.emitter import EventEmitter
|
|
25
|
-
|
|
26
|
-
logger = logging.getLogger(__name__)
|
|
27
|
-
|
|
28
|
-
# Try to import aiohttp, provide fallback warning
|
|
29
|
-
try:
|
|
30
|
-
import aiohttp
|
|
31
|
-
|
|
32
|
-
AIOHTTP_AVAILABLE = True
|
|
33
|
-
except ImportError:
|
|
34
|
-
AIOHTTP_AVAILABLE = False
|
|
35
|
-
logger.warning("aiohttp not installed - webhook delivery will be unavailable")
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
class WebhookDeliveryStatus(Enum):
|
|
39
|
-
"""Status of webhook delivery attempt."""
|
|
40
|
-
|
|
41
|
-
SUCCESS = "success"
|
|
42
|
-
FAILED = "failed"
|
|
43
|
-
RETRYING = "retrying"
|
|
44
|
-
PENDING = "pending"
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
@dataclass
|
|
48
|
-
class WebhookConfig:
|
|
49
|
-
"""
|
|
50
|
-
Configuration for a webhook endpoint.
|
|
51
|
-
|
|
52
|
-
Attributes:
|
|
53
|
-
url: The URL to send webhook payloads to
|
|
54
|
-
events: List of event types to deliver (empty = all)
|
|
55
|
-
secret: Optional secret for HMAC signature generation
|
|
56
|
-
max_retries: Maximum number of retry attempts
|
|
57
|
-
timeout_seconds: Request timeout in seconds
|
|
58
|
-
headers: Optional additional headers to include
|
|
59
|
-
"""
|
|
60
|
-
|
|
61
|
-
url: str
|
|
62
|
-
events: List[MemoryEventType] = field(default_factory=list)
|
|
63
|
-
secret: Optional[str] = None
|
|
64
|
-
max_retries: int = 3
|
|
65
|
-
timeout_seconds: int = 10
|
|
66
|
-
headers: Dict[str, str] = field(default_factory=dict)
|
|
67
|
-
|
|
68
|
-
def matches_event(self, event_type: MemoryEventType) -> bool:
|
|
69
|
-
"""Check if this webhook should receive the given event type."""
|
|
70
|
-
if not self.events:
|
|
71
|
-
return True # Empty list means all events
|
|
72
|
-
return event_type in self.events
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
@dataclass
|
|
76
|
-
class WebhookDeliveryResult:
|
|
77
|
-
"""Result of a webhook delivery attempt."""
|
|
78
|
-
|
|
79
|
-
config: WebhookConfig
|
|
80
|
-
event: MemoryEvent
|
|
81
|
-
status: WebhookDeliveryStatus
|
|
82
|
-
status_code: Optional[int] = None
|
|
83
|
-
attempts: int = 0
|
|
84
|
-
error: Optional[str] = None
|
|
85
|
-
response_body: Optional[str] = None
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
class WebhookDelivery:
|
|
89
|
-
"""
|
|
90
|
-
Handles webhook delivery with retry logic and signature generation.
|
|
91
|
-
|
|
92
|
-
Features:
|
|
93
|
-
- HMAC-SHA256 signature for payload verification
|
|
94
|
-
- Exponential backoff retry logic
|
|
95
|
-
- Async delivery to avoid blocking
|
|
96
|
-
- Configurable timeouts
|
|
97
|
-
|
|
98
|
-
Example:
|
|
99
|
-
```python
|
|
100
|
-
configs = [
|
|
101
|
-
WebhookConfig(
|
|
102
|
-
url="https://example.com/webhook",
|
|
103
|
-
events=[MemoryEventType.CREATED],
|
|
104
|
-
secret="my-secret"
|
|
105
|
-
)
|
|
106
|
-
]
|
|
107
|
-
delivery = WebhookDelivery(configs)
|
|
108
|
-
results = await delivery.deliver(event)
|
|
109
|
-
```
|
|
110
|
-
"""
|
|
111
|
-
|
|
112
|
-
def __init__(self, configs: List[WebhookConfig]):
|
|
113
|
-
"""
|
|
114
|
-
Initialize webhook delivery.
|
|
115
|
-
|
|
116
|
-
Args:
|
|
117
|
-
configs: List of webhook configurations
|
|
118
|
-
"""
|
|
119
|
-
self.configs = configs
|
|
120
|
-
|
|
121
|
-
async def deliver(self, event: MemoryEvent) -> List[WebhookDeliveryResult]:
|
|
122
|
-
"""
|
|
123
|
-
Deliver event to all matching webhooks.
|
|
124
|
-
|
|
125
|
-
Args:
|
|
126
|
-
event: The event to deliver
|
|
127
|
-
|
|
128
|
-
Returns:
|
|
129
|
-
List of delivery results for each webhook
|
|
130
|
-
"""
|
|
131
|
-
if not AIOHTTP_AVAILABLE:
|
|
132
|
-
logger.error("Cannot deliver webhooks: aiohttp not installed")
|
|
133
|
-
return []
|
|
134
|
-
|
|
135
|
-
# Filter to matching webhooks
|
|
136
|
-
matching_configs = [
|
|
137
|
-
config for config in self.configs if config.matches_event(event.event_type)
|
|
138
|
-
]
|
|
139
|
-
|
|
140
|
-
if not matching_configs:
|
|
141
|
-
return []
|
|
142
|
-
|
|
143
|
-
# Deliver to all matching webhooks concurrently
|
|
144
|
-
tasks = [self._send_webhook(config, event) for config in matching_configs]
|
|
145
|
-
|
|
146
|
-
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
147
|
-
|
|
148
|
-
# Handle any exceptions that occurred
|
|
149
|
-
delivery_results = []
|
|
150
|
-
for i, result in enumerate(results):
|
|
151
|
-
if isinstance(result, Exception):
|
|
152
|
-
delivery_results.append(
|
|
153
|
-
WebhookDeliveryResult(
|
|
154
|
-
config=matching_configs[i],
|
|
155
|
-
event=event,
|
|
156
|
-
status=WebhookDeliveryStatus.FAILED,
|
|
157
|
-
error=str(result),
|
|
158
|
-
)
|
|
159
|
-
)
|
|
160
|
-
else:
|
|
161
|
-
delivery_results.append(result)
|
|
162
|
-
|
|
163
|
-
return delivery_results
|
|
164
|
-
|
|
165
|
-
def _sign_payload(self, payload: str, secret: str) -> str:
|
|
166
|
-
"""
|
|
167
|
-
Sign payload with HMAC-SHA256.
|
|
168
|
-
|
|
169
|
-
Args:
|
|
170
|
-
payload: JSON payload string
|
|
171
|
-
secret: Secret key for signing
|
|
172
|
-
|
|
173
|
-
Returns:
|
|
174
|
-
Hexadecimal signature string
|
|
175
|
-
"""
|
|
176
|
-
return hmac.new(
|
|
177
|
-
secret.encode("utf-8"),
|
|
178
|
-
payload.encode("utf-8"),
|
|
179
|
-
hashlib.sha256,
|
|
180
|
-
).hexdigest()
|
|
181
|
-
|
|
182
|
-
def _build_headers(
|
|
183
|
-
self,
|
|
184
|
-
config: WebhookConfig,
|
|
185
|
-
payload: str,
|
|
186
|
-
timestamp: int,
|
|
187
|
-
) -> Dict[str, str]:
|
|
188
|
-
"""Build headers for webhook request."""
|
|
189
|
-
headers = {
|
|
190
|
-
"Content-Type": "application/json",
|
|
191
|
-
"X-ALMA-Event-Type": payload, # Will be replaced with actual event type
|
|
192
|
-
"X-ALMA-Timestamp": str(timestamp),
|
|
193
|
-
**config.headers,
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
if config.secret:
|
|
197
|
-
# Create signature from timestamp + payload
|
|
198
|
-
signature_payload = f"{timestamp}.{payload}"
|
|
199
|
-
signature = self._sign_payload(signature_payload, config.secret)
|
|
200
|
-
headers["X-ALMA-Signature"] = f"sha256={signature}"
|
|
201
|
-
|
|
202
|
-
return headers
|
|
203
|
-
|
|
204
|
-
async def _send_webhook(
|
|
205
|
-
self,
|
|
206
|
-
config: WebhookConfig,
|
|
207
|
-
event: MemoryEvent,
|
|
208
|
-
) -> WebhookDeliveryResult:
|
|
209
|
-
"""
|
|
210
|
-
Send a single webhook with retry logic.
|
|
211
|
-
|
|
212
|
-
Uses exponential backoff for retries:
|
|
213
|
-
- Attempt 1: immediate
|
|
214
|
-
- Attempt 2: 1 second delay
|
|
215
|
-
- Attempt 3: 2 second delay
|
|
216
|
-
- Attempt 4: 4 second delay
|
|
217
|
-
|
|
218
|
-
Args:
|
|
219
|
-
config: Webhook configuration
|
|
220
|
-
event: Event to deliver
|
|
221
|
-
|
|
222
|
-
Returns:
|
|
223
|
-
Delivery result
|
|
224
|
-
"""
|
|
225
|
-
payload = json.dumps(event.to_dict(), default=str)
|
|
226
|
-
timestamp = int(time.time())
|
|
227
|
-
|
|
228
|
-
headers = {
|
|
229
|
-
"Content-Type": "application/json",
|
|
230
|
-
"X-ALMA-Event-Type": event.event_type.value,
|
|
231
|
-
"X-ALMA-Timestamp": str(timestamp),
|
|
232
|
-
**config.headers,
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
if config.secret:
|
|
236
|
-
signature_payload = f"{timestamp}.{payload}"
|
|
237
|
-
signature = self._sign_payload(signature_payload, config.secret)
|
|
238
|
-
headers["X-ALMA-Signature"] = f"sha256={signature}"
|
|
239
|
-
|
|
240
|
-
attempts = 0
|
|
241
|
-
last_error = None
|
|
242
|
-
last_status_code = None
|
|
243
|
-
|
|
244
|
-
async with aiohttp.ClientSession() as session:
|
|
245
|
-
for attempt in range(config.max_retries + 1):
|
|
246
|
-
attempts = attempt + 1
|
|
247
|
-
|
|
248
|
-
try:
|
|
249
|
-
async with session.post(
|
|
250
|
-
config.url,
|
|
251
|
-
data=payload,
|
|
252
|
-
headers=headers,
|
|
253
|
-
timeout=aiohttp.ClientTimeout(total=config.timeout_seconds),
|
|
254
|
-
) as response:
|
|
255
|
-
last_status_code = response.status
|
|
256
|
-
|
|
257
|
-
if 200 <= response.status < 300:
|
|
258
|
-
logger.info(
|
|
259
|
-
f"Webhook delivered successfully to {config.url} "
|
|
260
|
-
f"(attempt {attempts})"
|
|
261
|
-
)
|
|
262
|
-
return WebhookDeliveryResult(
|
|
263
|
-
config=config,
|
|
264
|
-
event=event,
|
|
265
|
-
status=WebhookDeliveryStatus.SUCCESS,
|
|
266
|
-
status_code=response.status,
|
|
267
|
-
attempts=attempts,
|
|
268
|
-
)
|
|
269
|
-
|
|
270
|
-
# Non-2xx response
|
|
271
|
-
response_body = await response.text()
|
|
272
|
-
last_error = f"HTTP {response.status}: {response_body[:200]}"
|
|
273
|
-
logger.warning(
|
|
274
|
-
f"Webhook delivery failed to {config.url}: {last_error}"
|
|
275
|
-
)
|
|
276
|
-
|
|
277
|
-
except asyncio.TimeoutError:
|
|
278
|
-
last_error = f"Timeout after {config.timeout_seconds} seconds"
|
|
279
|
-
logger.warning(
|
|
280
|
-
f"Webhook delivery timeout to {config.url} (attempt {attempts})"
|
|
281
|
-
)
|
|
282
|
-
|
|
283
|
-
except aiohttp.ClientError as e:
|
|
284
|
-
last_error = str(e)
|
|
285
|
-
logger.warning(
|
|
286
|
-
f"Webhook delivery error to {config.url}: {e} (attempt {attempts})"
|
|
287
|
-
)
|
|
288
|
-
|
|
289
|
-
# Calculate backoff for next attempt
|
|
290
|
-
if attempt < config.max_retries:
|
|
291
|
-
backoff = 2**attempt # 1, 2, 4 seconds
|
|
292
|
-
await asyncio.sleep(backoff)
|
|
293
|
-
|
|
294
|
-
# All retries exhausted
|
|
295
|
-
logger.error(
|
|
296
|
-
f"Webhook delivery failed after {attempts} attempts to {config.url}"
|
|
297
|
-
)
|
|
298
|
-
return WebhookDeliveryResult(
|
|
299
|
-
config=config,
|
|
300
|
-
event=event,
|
|
301
|
-
status=WebhookDeliveryStatus.FAILED,
|
|
302
|
-
status_code=last_status_code,
|
|
303
|
-
attempts=attempts,
|
|
304
|
-
error=last_error,
|
|
305
|
-
)
|
|
306
|
-
|
|
307
|
-
def add_config(self, config: WebhookConfig) -> None:
|
|
308
|
-
"""Add a webhook configuration."""
|
|
309
|
-
self.configs.append(config)
|
|
310
|
-
|
|
311
|
-
def remove_config(self, url: str) -> bool:
|
|
312
|
-
"""
|
|
313
|
-
Remove a webhook configuration by URL.
|
|
314
|
-
|
|
315
|
-
Args:
|
|
316
|
-
url: URL of the webhook to remove
|
|
317
|
-
|
|
318
|
-
Returns:
|
|
319
|
-
True if removed, False if not found
|
|
320
|
-
"""
|
|
321
|
-
for i, config in enumerate(self.configs):
|
|
322
|
-
if config.url == url:
|
|
323
|
-
self.configs.pop(i)
|
|
324
|
-
return True
|
|
325
|
-
return False
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
class WebhookManager:
|
|
329
|
-
"""
|
|
330
|
-
High-level manager for webhook delivery integrated with the event emitter.
|
|
331
|
-
|
|
332
|
-
Automatically subscribes to the event emitter and delivers webhooks
|
|
333
|
-
for configured events.
|
|
334
|
-
|
|
335
|
-
Example:
|
|
336
|
-
```python
|
|
337
|
-
from alma.events import get_emitter
|
|
338
|
-
from alma.events.webhook import WebhookManager, WebhookConfig
|
|
339
|
-
|
|
340
|
-
manager = WebhookManager()
|
|
341
|
-
manager.add_webhook(WebhookConfig(
|
|
342
|
-
url="https://example.com/webhook",
|
|
343
|
-
secret="my-secret"
|
|
344
|
-
))
|
|
345
|
-
manager.start(get_emitter())
|
|
346
|
-
```
|
|
347
|
-
"""
|
|
348
|
-
|
|
349
|
-
def __init__(self):
|
|
350
|
-
"""Initialize the webhook manager."""
|
|
351
|
-
self._configs: List[WebhookConfig] = []
|
|
352
|
-
self._delivery: Optional[WebhookDelivery] = None
|
|
353
|
-
self._running = False
|
|
354
|
-
|
|
355
|
-
def add_webhook(self, config: WebhookConfig) -> None:
|
|
356
|
-
"""Add a webhook configuration."""
|
|
357
|
-
self._configs.append(config)
|
|
358
|
-
if self._delivery:
|
|
359
|
-
self._delivery.add_config(config)
|
|
360
|
-
|
|
361
|
-
def remove_webhook(self, url: str) -> bool:
|
|
362
|
-
"""Remove a webhook by URL."""
|
|
363
|
-
for i, config in enumerate(self._configs):
|
|
364
|
-
if config.url == url:
|
|
365
|
-
self._configs.pop(i)
|
|
366
|
-
if self._delivery:
|
|
367
|
-
self._delivery.remove_config(url)
|
|
368
|
-
return True
|
|
369
|
-
return False
|
|
370
|
-
|
|
371
|
-
def start(self, emitter: "EventEmitter") -> None:
|
|
372
|
-
"""
|
|
373
|
-
Start the webhook manager.
|
|
374
|
-
|
|
375
|
-
Subscribes to all events from the emitter.
|
|
376
|
-
|
|
377
|
-
Args:
|
|
378
|
-
emitter: The event emitter to subscribe to
|
|
379
|
-
"""
|
|
380
|
-
|
|
381
|
-
if self._running:
|
|
382
|
-
return
|
|
383
|
-
|
|
384
|
-
self._delivery = WebhookDelivery(self._configs)
|
|
385
|
-
emitter.subscribe_all(self._on_event)
|
|
386
|
-
self._running = True
|
|
387
|
-
logger.info(f"Webhook manager started with {len(self._configs)} webhooks")
|
|
388
|
-
|
|
389
|
-
def stop(self, emitter: "EventEmitter") -> None:
|
|
390
|
-
"""
|
|
391
|
-
Stop the webhook manager.
|
|
392
|
-
|
|
393
|
-
Args:
|
|
394
|
-
emitter: The event emitter to unsubscribe from
|
|
395
|
-
"""
|
|
396
|
-
|
|
397
|
-
if not self._running:
|
|
398
|
-
return
|
|
399
|
-
|
|
400
|
-
emitter.unsubscribe_all(self._on_event)
|
|
401
|
-
self._running = False
|
|
402
|
-
logger.info("Webhook manager stopped")
|
|
403
|
-
|
|
404
|
-
def _on_event(self, event: MemoryEvent) -> None:
|
|
405
|
-
"""Handle incoming events by delivering webhooks."""
|
|
406
|
-
if not self._delivery or not AIOHTTP_AVAILABLE:
|
|
407
|
-
return
|
|
408
|
-
|
|
409
|
-
# Run async delivery in a new event loop if needed
|
|
410
|
-
try:
|
|
411
|
-
asyncio.get_running_loop()
|
|
412
|
-
asyncio.create_task(self._delivery.deliver(event))
|
|
413
|
-
except RuntimeError:
|
|
414
|
-
# No running event loop, create one
|
|
415
|
-
asyncio.run(self._delivery.deliver(event))
|
|
416
|
-
|
|
417
|
-
@property
|
|
418
|
-
def webhook_count(self) -> int:
|
|
419
|
-
"""Get the number of configured webhooks."""
|
|
420
|
-
return len(self._configs)
|
|
421
|
-
|
|
422
|
-
@property
|
|
423
|
-
def is_running(self) -> bool:
|
|
424
|
-
"""Check if the manager is running."""
|
|
425
|
-
return self._running
|
|
1
|
+
"""
|
|
2
|
+
ALMA Webhook Delivery.
|
|
3
|
+
|
|
4
|
+
Provides webhook delivery capabilities for external system integration.
|
|
5
|
+
Webhooks are delivered asynchronously with retry logic and signature
|
|
6
|
+
verification support.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import hashlib
|
|
11
|
+
import hmac
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from enum import Enum
|
|
17
|
+
|
|
18
|
+
# TYPE_CHECKING import for forward references
|
|
19
|
+
from typing import TYPE_CHECKING, Dict, List, Optional
|
|
20
|
+
|
|
21
|
+
from alma.events.types import MemoryEvent, MemoryEventType
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from alma.events.emitter import EventEmitter
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
# Try to import aiohttp, provide fallback warning
|
|
29
|
+
try:
|
|
30
|
+
import aiohttp
|
|
31
|
+
|
|
32
|
+
AIOHTTP_AVAILABLE = True
|
|
33
|
+
except ImportError:
|
|
34
|
+
AIOHTTP_AVAILABLE = False
|
|
35
|
+
logger.warning("aiohttp not installed - webhook delivery will be unavailable")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class WebhookDeliveryStatus(Enum):
|
|
39
|
+
"""Status of webhook delivery attempt."""
|
|
40
|
+
|
|
41
|
+
SUCCESS = "success"
|
|
42
|
+
FAILED = "failed"
|
|
43
|
+
RETRYING = "retrying"
|
|
44
|
+
PENDING = "pending"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class WebhookConfig:
|
|
49
|
+
"""
|
|
50
|
+
Configuration for a webhook endpoint.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
url: The URL to send webhook payloads to
|
|
54
|
+
events: List of event types to deliver (empty = all)
|
|
55
|
+
secret: Optional secret for HMAC signature generation
|
|
56
|
+
max_retries: Maximum number of retry attempts
|
|
57
|
+
timeout_seconds: Request timeout in seconds
|
|
58
|
+
headers: Optional additional headers to include
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
url: str
|
|
62
|
+
events: List[MemoryEventType] = field(default_factory=list)
|
|
63
|
+
secret: Optional[str] = None
|
|
64
|
+
max_retries: int = 3
|
|
65
|
+
timeout_seconds: int = 10
|
|
66
|
+
headers: Dict[str, str] = field(default_factory=dict)
|
|
67
|
+
|
|
68
|
+
def matches_event(self, event_type: MemoryEventType) -> bool:
|
|
69
|
+
"""Check if this webhook should receive the given event type."""
|
|
70
|
+
if not self.events:
|
|
71
|
+
return True # Empty list means all events
|
|
72
|
+
return event_type in self.events
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class WebhookDeliveryResult:
|
|
77
|
+
"""Result of a webhook delivery attempt."""
|
|
78
|
+
|
|
79
|
+
config: WebhookConfig
|
|
80
|
+
event: MemoryEvent
|
|
81
|
+
status: WebhookDeliveryStatus
|
|
82
|
+
status_code: Optional[int] = None
|
|
83
|
+
attempts: int = 0
|
|
84
|
+
error: Optional[str] = None
|
|
85
|
+
response_body: Optional[str] = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class WebhookDelivery:
|
|
89
|
+
"""
|
|
90
|
+
Handles webhook delivery with retry logic and signature generation.
|
|
91
|
+
|
|
92
|
+
Features:
|
|
93
|
+
- HMAC-SHA256 signature for payload verification
|
|
94
|
+
- Exponential backoff retry logic
|
|
95
|
+
- Async delivery to avoid blocking
|
|
96
|
+
- Configurable timeouts
|
|
97
|
+
|
|
98
|
+
Example:
|
|
99
|
+
```python
|
|
100
|
+
configs = [
|
|
101
|
+
WebhookConfig(
|
|
102
|
+
url="https://example.com/webhook",
|
|
103
|
+
events=[MemoryEventType.CREATED],
|
|
104
|
+
secret="my-secret"
|
|
105
|
+
)
|
|
106
|
+
]
|
|
107
|
+
delivery = WebhookDelivery(configs)
|
|
108
|
+
results = await delivery.deliver(event)
|
|
109
|
+
```
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def __init__(self, configs: List[WebhookConfig]):
|
|
113
|
+
"""
|
|
114
|
+
Initialize webhook delivery.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
configs: List of webhook configurations
|
|
118
|
+
"""
|
|
119
|
+
self.configs = configs
|
|
120
|
+
|
|
121
|
+
async def deliver(self, event: MemoryEvent) -> List[WebhookDeliveryResult]:
|
|
122
|
+
"""
|
|
123
|
+
Deliver event to all matching webhooks.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
event: The event to deliver
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
List of delivery results for each webhook
|
|
130
|
+
"""
|
|
131
|
+
if not AIOHTTP_AVAILABLE:
|
|
132
|
+
logger.error("Cannot deliver webhooks: aiohttp not installed")
|
|
133
|
+
return []
|
|
134
|
+
|
|
135
|
+
# Filter to matching webhooks
|
|
136
|
+
matching_configs = [
|
|
137
|
+
config for config in self.configs if config.matches_event(event.event_type)
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
if not matching_configs:
|
|
141
|
+
return []
|
|
142
|
+
|
|
143
|
+
# Deliver to all matching webhooks concurrently
|
|
144
|
+
tasks = [self._send_webhook(config, event) for config in matching_configs]
|
|
145
|
+
|
|
146
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
147
|
+
|
|
148
|
+
# Handle any exceptions that occurred
|
|
149
|
+
delivery_results = []
|
|
150
|
+
for i, result in enumerate(results):
|
|
151
|
+
if isinstance(result, Exception):
|
|
152
|
+
delivery_results.append(
|
|
153
|
+
WebhookDeliveryResult(
|
|
154
|
+
config=matching_configs[i],
|
|
155
|
+
event=event,
|
|
156
|
+
status=WebhookDeliveryStatus.FAILED,
|
|
157
|
+
error=str(result),
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
else:
|
|
161
|
+
delivery_results.append(result)
|
|
162
|
+
|
|
163
|
+
return delivery_results
|
|
164
|
+
|
|
165
|
+
def _sign_payload(self, payload: str, secret: str) -> str:
|
|
166
|
+
"""
|
|
167
|
+
Sign payload with HMAC-SHA256.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
payload: JSON payload string
|
|
171
|
+
secret: Secret key for signing
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Hexadecimal signature string
|
|
175
|
+
"""
|
|
176
|
+
return hmac.new(
|
|
177
|
+
secret.encode("utf-8"),
|
|
178
|
+
payload.encode("utf-8"),
|
|
179
|
+
hashlib.sha256,
|
|
180
|
+
).hexdigest()
|
|
181
|
+
|
|
182
|
+
def _build_headers(
|
|
183
|
+
self,
|
|
184
|
+
config: WebhookConfig,
|
|
185
|
+
payload: str,
|
|
186
|
+
timestamp: int,
|
|
187
|
+
) -> Dict[str, str]:
|
|
188
|
+
"""Build headers for webhook request."""
|
|
189
|
+
headers = {
|
|
190
|
+
"Content-Type": "application/json",
|
|
191
|
+
"X-ALMA-Event-Type": payload, # Will be replaced with actual event type
|
|
192
|
+
"X-ALMA-Timestamp": str(timestamp),
|
|
193
|
+
**config.headers,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if config.secret:
|
|
197
|
+
# Create signature from timestamp + payload
|
|
198
|
+
signature_payload = f"{timestamp}.{payload}"
|
|
199
|
+
signature = self._sign_payload(signature_payload, config.secret)
|
|
200
|
+
headers["X-ALMA-Signature"] = f"sha256={signature}"
|
|
201
|
+
|
|
202
|
+
return headers
|
|
203
|
+
|
|
204
|
+
async def _send_webhook(
|
|
205
|
+
self,
|
|
206
|
+
config: WebhookConfig,
|
|
207
|
+
event: MemoryEvent,
|
|
208
|
+
) -> WebhookDeliveryResult:
|
|
209
|
+
"""
|
|
210
|
+
Send a single webhook with retry logic.
|
|
211
|
+
|
|
212
|
+
Uses exponential backoff for retries:
|
|
213
|
+
- Attempt 1: immediate
|
|
214
|
+
- Attempt 2: 1 second delay
|
|
215
|
+
- Attempt 3: 2 second delay
|
|
216
|
+
- Attempt 4: 4 second delay
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
config: Webhook configuration
|
|
220
|
+
event: Event to deliver
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
Delivery result
|
|
224
|
+
"""
|
|
225
|
+
payload = json.dumps(event.to_dict(), default=str)
|
|
226
|
+
timestamp = int(time.time())
|
|
227
|
+
|
|
228
|
+
headers = {
|
|
229
|
+
"Content-Type": "application/json",
|
|
230
|
+
"X-ALMA-Event-Type": event.event_type.value,
|
|
231
|
+
"X-ALMA-Timestamp": str(timestamp),
|
|
232
|
+
**config.headers,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if config.secret:
|
|
236
|
+
signature_payload = f"{timestamp}.{payload}"
|
|
237
|
+
signature = self._sign_payload(signature_payload, config.secret)
|
|
238
|
+
headers["X-ALMA-Signature"] = f"sha256={signature}"
|
|
239
|
+
|
|
240
|
+
attempts = 0
|
|
241
|
+
last_error = None
|
|
242
|
+
last_status_code = None
|
|
243
|
+
|
|
244
|
+
async with aiohttp.ClientSession() as session:
|
|
245
|
+
for attempt in range(config.max_retries + 1):
|
|
246
|
+
attempts = attempt + 1
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
async with session.post(
|
|
250
|
+
config.url,
|
|
251
|
+
data=payload,
|
|
252
|
+
headers=headers,
|
|
253
|
+
timeout=aiohttp.ClientTimeout(total=config.timeout_seconds),
|
|
254
|
+
) as response:
|
|
255
|
+
last_status_code = response.status
|
|
256
|
+
|
|
257
|
+
if 200 <= response.status < 300:
|
|
258
|
+
logger.info(
|
|
259
|
+
f"Webhook delivered successfully to {config.url} "
|
|
260
|
+
f"(attempt {attempts})"
|
|
261
|
+
)
|
|
262
|
+
return WebhookDeliveryResult(
|
|
263
|
+
config=config,
|
|
264
|
+
event=event,
|
|
265
|
+
status=WebhookDeliveryStatus.SUCCESS,
|
|
266
|
+
status_code=response.status,
|
|
267
|
+
attempts=attempts,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# Non-2xx response
|
|
271
|
+
response_body = await response.text()
|
|
272
|
+
last_error = f"HTTP {response.status}: {response_body[:200]}"
|
|
273
|
+
logger.warning(
|
|
274
|
+
f"Webhook delivery failed to {config.url}: {last_error}"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
except asyncio.TimeoutError:
|
|
278
|
+
last_error = f"Timeout after {config.timeout_seconds} seconds"
|
|
279
|
+
logger.warning(
|
|
280
|
+
f"Webhook delivery timeout to {config.url} (attempt {attempts})"
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
except aiohttp.ClientError as e:
|
|
284
|
+
last_error = str(e)
|
|
285
|
+
logger.warning(
|
|
286
|
+
f"Webhook delivery error to {config.url}: {e} (attempt {attempts})"
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
# Calculate backoff for next attempt
|
|
290
|
+
if attempt < config.max_retries:
|
|
291
|
+
backoff = 2**attempt # 1, 2, 4 seconds
|
|
292
|
+
await asyncio.sleep(backoff)
|
|
293
|
+
|
|
294
|
+
# All retries exhausted
|
|
295
|
+
logger.error(
|
|
296
|
+
f"Webhook delivery failed after {attempts} attempts to {config.url}"
|
|
297
|
+
)
|
|
298
|
+
return WebhookDeliveryResult(
|
|
299
|
+
config=config,
|
|
300
|
+
event=event,
|
|
301
|
+
status=WebhookDeliveryStatus.FAILED,
|
|
302
|
+
status_code=last_status_code,
|
|
303
|
+
attempts=attempts,
|
|
304
|
+
error=last_error,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
def add_config(self, config: WebhookConfig) -> None:
|
|
308
|
+
"""Add a webhook configuration."""
|
|
309
|
+
self.configs.append(config)
|
|
310
|
+
|
|
311
|
+
def remove_config(self, url: str) -> bool:
|
|
312
|
+
"""
|
|
313
|
+
Remove a webhook configuration by URL.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
url: URL of the webhook to remove
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
True if removed, False if not found
|
|
320
|
+
"""
|
|
321
|
+
for i, config in enumerate(self.configs):
|
|
322
|
+
if config.url == url:
|
|
323
|
+
self.configs.pop(i)
|
|
324
|
+
return True
|
|
325
|
+
return False
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class WebhookManager:
|
|
329
|
+
"""
|
|
330
|
+
High-level manager for webhook delivery integrated with the event emitter.
|
|
331
|
+
|
|
332
|
+
Automatically subscribes to the event emitter and delivers webhooks
|
|
333
|
+
for configured events.
|
|
334
|
+
|
|
335
|
+
Example:
|
|
336
|
+
```python
|
|
337
|
+
from alma.events import get_emitter
|
|
338
|
+
from alma.events.webhook import WebhookManager, WebhookConfig
|
|
339
|
+
|
|
340
|
+
manager = WebhookManager()
|
|
341
|
+
manager.add_webhook(WebhookConfig(
|
|
342
|
+
url="https://example.com/webhook",
|
|
343
|
+
secret="my-secret"
|
|
344
|
+
))
|
|
345
|
+
manager.start(get_emitter())
|
|
346
|
+
```
|
|
347
|
+
"""
|
|
348
|
+
|
|
349
|
+
def __init__(self):
|
|
350
|
+
"""Initialize the webhook manager."""
|
|
351
|
+
self._configs: List[WebhookConfig] = []
|
|
352
|
+
self._delivery: Optional[WebhookDelivery] = None
|
|
353
|
+
self._running = False
|
|
354
|
+
|
|
355
|
+
def add_webhook(self, config: WebhookConfig) -> None:
|
|
356
|
+
"""Add a webhook configuration."""
|
|
357
|
+
self._configs.append(config)
|
|
358
|
+
if self._delivery:
|
|
359
|
+
self._delivery.add_config(config)
|
|
360
|
+
|
|
361
|
+
def remove_webhook(self, url: str) -> bool:
|
|
362
|
+
"""Remove a webhook by URL."""
|
|
363
|
+
for i, config in enumerate(self._configs):
|
|
364
|
+
if config.url == url:
|
|
365
|
+
self._configs.pop(i)
|
|
366
|
+
if self._delivery:
|
|
367
|
+
self._delivery.remove_config(url)
|
|
368
|
+
return True
|
|
369
|
+
return False
|
|
370
|
+
|
|
371
|
+
def start(self, emitter: "EventEmitter") -> None:
|
|
372
|
+
"""
|
|
373
|
+
Start the webhook manager.
|
|
374
|
+
|
|
375
|
+
Subscribes to all events from the emitter.
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
emitter: The event emitter to subscribe to
|
|
379
|
+
"""
|
|
380
|
+
|
|
381
|
+
if self._running:
|
|
382
|
+
return
|
|
383
|
+
|
|
384
|
+
self._delivery = WebhookDelivery(self._configs)
|
|
385
|
+
emitter.subscribe_all(self._on_event)
|
|
386
|
+
self._running = True
|
|
387
|
+
logger.info(f"Webhook manager started with {len(self._configs)} webhooks")
|
|
388
|
+
|
|
389
|
+
def stop(self, emitter: "EventEmitter") -> None:
|
|
390
|
+
"""
|
|
391
|
+
Stop the webhook manager.
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
emitter: The event emitter to unsubscribe from
|
|
395
|
+
"""
|
|
396
|
+
|
|
397
|
+
if not self._running:
|
|
398
|
+
return
|
|
399
|
+
|
|
400
|
+
emitter.unsubscribe_all(self._on_event)
|
|
401
|
+
self._running = False
|
|
402
|
+
logger.info("Webhook manager stopped")
|
|
403
|
+
|
|
404
|
+
def _on_event(self, event: MemoryEvent) -> None:
|
|
405
|
+
"""Handle incoming events by delivering webhooks."""
|
|
406
|
+
if not self._delivery or not AIOHTTP_AVAILABLE:
|
|
407
|
+
return
|
|
408
|
+
|
|
409
|
+
# Run async delivery in a new event loop if needed
|
|
410
|
+
try:
|
|
411
|
+
asyncio.get_running_loop()
|
|
412
|
+
asyncio.create_task(self._delivery.deliver(event))
|
|
413
|
+
except RuntimeError:
|
|
414
|
+
# No running event loop, create one
|
|
415
|
+
asyncio.run(self._delivery.deliver(event))
|
|
416
|
+
|
|
417
|
+
@property
|
|
418
|
+
def webhook_count(self) -> int:
|
|
419
|
+
"""Get the number of configured webhooks."""
|
|
420
|
+
return len(self._configs)
|
|
421
|
+
|
|
422
|
+
@property
|
|
423
|
+
def is_running(self) -> bool:
|
|
424
|
+
"""Check if the manager is running."""
|
|
425
|
+
return self._running
|