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
smartify/api/events.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Grid execution events for async operation tracking.
|
|
2
|
+
|
|
3
|
+
Provides:
|
|
4
|
+
- Event storage and retrieval
|
|
5
|
+
- Polling endpoint for execution progress
|
|
6
|
+
- WebSocket endpoint for real-time streaming (optional)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
from collections import deque
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
import asyncio
|
|
15
|
+
import logging
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, Field
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ============================================================================
|
|
24
|
+
# Event Types
|
|
25
|
+
# ============================================================================
|
|
26
|
+
|
|
27
|
+
class EventType(str, Enum):
|
|
28
|
+
"""Types of events that can occur during grid execution."""
|
|
29
|
+
|
|
30
|
+
# Grid lifecycle events
|
|
31
|
+
GRID_LOADED = "grid.loaded"
|
|
32
|
+
GRID_ENERGIZED = "grid.energized"
|
|
33
|
+
GRID_STARTED = "grid.started"
|
|
34
|
+
GRID_PAUSED = "grid.paused"
|
|
35
|
+
GRID_RESUMED = "grid.resumed"
|
|
36
|
+
GRID_STOPPED = "grid.stopped"
|
|
37
|
+
GRID_COMPLETED = "grid.completed"
|
|
38
|
+
GRID_FAILED = "grid.failed"
|
|
39
|
+
|
|
40
|
+
# Node events
|
|
41
|
+
NODE_STARTED = "node.started"
|
|
42
|
+
NODE_COMPLETED = "node.completed"
|
|
43
|
+
NODE_FAILED = "node.failed"
|
|
44
|
+
NODE_SKIPPED = "node.skipped"
|
|
45
|
+
NODE_RETRYING = "node.retrying"
|
|
46
|
+
|
|
47
|
+
# Spark events
|
|
48
|
+
SPARK_SPAWNED = "spark.spawned"
|
|
49
|
+
SPARK_COMPLETED = "spark.completed"
|
|
50
|
+
SPARK_FAILED = "spark.failed"
|
|
51
|
+
|
|
52
|
+
# Breaker events
|
|
53
|
+
BREAKER_WARNING = "breaker.warning"
|
|
54
|
+
BREAKER_TRIPPED = "breaker.tripped"
|
|
55
|
+
|
|
56
|
+
# Approval events
|
|
57
|
+
APPROVAL_REQUESTED = "approval.requested"
|
|
58
|
+
APPROVAL_GRANTED = "approval.granted"
|
|
59
|
+
APPROVAL_DENIED = "approval.denied"
|
|
60
|
+
APPROVAL_EXPIRED = "approval.expired"
|
|
61
|
+
|
|
62
|
+
# Tool events
|
|
63
|
+
TOOL_CALLED = "tool.called"
|
|
64
|
+
TOOL_RESULT = "tool.result"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ============================================================================
|
|
68
|
+
# Event Models
|
|
69
|
+
# ============================================================================
|
|
70
|
+
|
|
71
|
+
class GridEvent(BaseModel):
|
|
72
|
+
"""A single event from grid execution."""
|
|
73
|
+
|
|
74
|
+
id: str = Field(..., description="Unique event ID")
|
|
75
|
+
type: EventType = Field(..., description="Event type")
|
|
76
|
+
grid_id: str = Field(..., description="Grid this event belongs to")
|
|
77
|
+
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
78
|
+
|
|
79
|
+
# Optional context
|
|
80
|
+
node_id: Optional[str] = Field(None, description="Node ID if node-specific event")
|
|
81
|
+
|
|
82
|
+
# Event payload
|
|
83
|
+
data: Dict[str, Any] = Field(default_factory=dict, description="Event-specific data")
|
|
84
|
+
|
|
85
|
+
# For progress tracking
|
|
86
|
+
progress: Optional[float] = Field(None, ge=0.0, le=1.0, description="Execution progress (0-1)")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class EventsResponse(BaseModel):
|
|
90
|
+
"""Response for events polling endpoint."""
|
|
91
|
+
|
|
92
|
+
grid_id: str
|
|
93
|
+
events: List[GridEvent]
|
|
94
|
+
total: int = Field(..., description="Total events for this grid")
|
|
95
|
+
has_more: bool = Field(..., description="Whether more events exist after these")
|
|
96
|
+
|
|
97
|
+
# Cursor for pagination
|
|
98
|
+
next_cursor: Optional[str] = Field(None, description="Cursor for next page")
|
|
99
|
+
|
|
100
|
+
# Current grid state summary
|
|
101
|
+
state: str
|
|
102
|
+
progress: float = Field(ge=0.0, le=1.0)
|
|
103
|
+
completed_nodes: int
|
|
104
|
+
total_nodes: int
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class EventFilter(BaseModel):
|
|
108
|
+
"""Filter for events query."""
|
|
109
|
+
|
|
110
|
+
types: Optional[List[EventType]] = Field(None, description="Filter by event types")
|
|
111
|
+
node_id: Optional[str] = Field(None, description="Filter by node ID")
|
|
112
|
+
after: Optional[str] = Field(None, description="Cursor: return events after this ID")
|
|
113
|
+
limit: int = Field(default=50, ge=1, le=200, description="Maximum events to return")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ============================================================================
|
|
117
|
+
# Event Store
|
|
118
|
+
# ============================================================================
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class EventStore:
|
|
122
|
+
"""In-memory event storage per grid.
|
|
123
|
+
|
|
124
|
+
Uses a bounded deque to limit memory usage.
|
|
125
|
+
For production, consider Redis Streams or similar.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
grid_id: str
|
|
129
|
+
max_events: int = 1000
|
|
130
|
+
_events: deque = field(default_factory=lambda: deque(maxlen=1000))
|
|
131
|
+
_event_counter: int = 0
|
|
132
|
+
_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
133
|
+
|
|
134
|
+
def __post_init__(self):
|
|
135
|
+
self._events = deque(maxlen=self.max_events)
|
|
136
|
+
|
|
137
|
+
async def append(self, event: GridEvent) -> None:
|
|
138
|
+
"""Add an event to the store."""
|
|
139
|
+
async with self._lock:
|
|
140
|
+
self._event_counter += 1
|
|
141
|
+
self._events.append(event)
|
|
142
|
+
|
|
143
|
+
async def get_events(
|
|
144
|
+
self,
|
|
145
|
+
after: Optional[str] = None,
|
|
146
|
+
types: Optional[List[EventType]] = None,
|
|
147
|
+
node_id: Optional[str] = None,
|
|
148
|
+
limit: int = 50,
|
|
149
|
+
) -> tuple[List[GridEvent], bool, Optional[str]]:
|
|
150
|
+
"""Get events with filtering and pagination.
|
|
151
|
+
|
|
152
|
+
Returns (events, has_more, next_cursor)
|
|
153
|
+
"""
|
|
154
|
+
async with self._lock:
|
|
155
|
+
events = list(self._events)
|
|
156
|
+
|
|
157
|
+
# Apply cursor filter (after)
|
|
158
|
+
if after:
|
|
159
|
+
found_idx = None
|
|
160
|
+
for i, e in enumerate(events):
|
|
161
|
+
if e.id == after:
|
|
162
|
+
found_idx = i
|
|
163
|
+
break
|
|
164
|
+
if found_idx is not None:
|
|
165
|
+
events = events[found_idx + 1:]
|
|
166
|
+
|
|
167
|
+
# Apply type filter
|
|
168
|
+
if types:
|
|
169
|
+
events = [e for e in events if e.type in types]
|
|
170
|
+
|
|
171
|
+
# Apply node filter
|
|
172
|
+
if node_id:
|
|
173
|
+
events = [e for e in events if e.node_id == node_id]
|
|
174
|
+
|
|
175
|
+
# Pagination
|
|
176
|
+
has_more = len(events) > limit
|
|
177
|
+
events = events[:limit]
|
|
178
|
+
|
|
179
|
+
next_cursor = events[-1].id if events and has_more else None
|
|
180
|
+
|
|
181
|
+
return events, has_more, next_cursor
|
|
182
|
+
|
|
183
|
+
async def count(self) -> int:
|
|
184
|
+
"""Get total event count."""
|
|
185
|
+
async with self._lock:
|
|
186
|
+
return len(self._events)
|
|
187
|
+
|
|
188
|
+
async def clear(self) -> None:
|
|
189
|
+
"""Clear all events."""
|
|
190
|
+
async with self._lock:
|
|
191
|
+
self._events.clear()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ============================================================================
|
|
195
|
+
# Event Manager
|
|
196
|
+
# ============================================================================
|
|
197
|
+
|
|
198
|
+
class EventManager:
|
|
199
|
+
"""Manages event stores for all grids.
|
|
200
|
+
|
|
201
|
+
Thread-safe and async-compatible.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
def __init__(self, max_events_per_grid: int = 1000):
|
|
205
|
+
self.max_events = max_events_per_grid
|
|
206
|
+
self._stores: Dict[str, EventStore] = {}
|
|
207
|
+
self._lock = asyncio.Lock()
|
|
208
|
+
|
|
209
|
+
# WebSocket subscribers (grid_id -> set of queues)
|
|
210
|
+
self._subscribers: Dict[str, set] = {}
|
|
211
|
+
|
|
212
|
+
async def get_store(self, grid_id: str) -> EventStore:
|
|
213
|
+
"""Get or create event store for a grid."""
|
|
214
|
+
async with self._lock:
|
|
215
|
+
if grid_id not in self._stores:
|
|
216
|
+
self._stores[grid_id] = EventStore(
|
|
217
|
+
grid_id=grid_id,
|
|
218
|
+
max_events=self.max_events,
|
|
219
|
+
)
|
|
220
|
+
return self._stores[grid_id]
|
|
221
|
+
|
|
222
|
+
async def emit(
|
|
223
|
+
self,
|
|
224
|
+
grid_id: str,
|
|
225
|
+
event_type: EventType,
|
|
226
|
+
node_id: Optional[str] = None,
|
|
227
|
+
data: Optional[Dict[str, Any]] = None,
|
|
228
|
+
progress: Optional[float] = None,
|
|
229
|
+
) -> GridEvent:
|
|
230
|
+
"""Emit an event for a grid.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
grid_id: Grid identifier
|
|
234
|
+
event_type: Type of event
|
|
235
|
+
node_id: Optional node ID for node-specific events
|
|
236
|
+
data: Optional event payload
|
|
237
|
+
progress: Optional execution progress (0-1)
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
The created event
|
|
241
|
+
"""
|
|
242
|
+
store = await self.get_store(grid_id)
|
|
243
|
+
|
|
244
|
+
event = GridEvent(
|
|
245
|
+
id=f"evt_{grid_id}_{store._event_counter + 1}",
|
|
246
|
+
type=event_type,
|
|
247
|
+
grid_id=grid_id,
|
|
248
|
+
node_id=node_id,
|
|
249
|
+
data=data or {},
|
|
250
|
+
progress=progress,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
await store.append(event)
|
|
254
|
+
|
|
255
|
+
# Notify WebSocket subscribers
|
|
256
|
+
await self._notify_subscribers(grid_id, event)
|
|
257
|
+
|
|
258
|
+
logger.debug(f"Event emitted: {event_type.value} for grid {grid_id}")
|
|
259
|
+
return event
|
|
260
|
+
|
|
261
|
+
async def get_events(
|
|
262
|
+
self,
|
|
263
|
+
grid_id: str,
|
|
264
|
+
filter: Optional[EventFilter] = None,
|
|
265
|
+
) -> tuple[List[GridEvent], bool, Optional[str]]:
|
|
266
|
+
"""Get events for a grid with optional filtering."""
|
|
267
|
+
store = await self.get_store(grid_id)
|
|
268
|
+
|
|
269
|
+
if filter:
|
|
270
|
+
return await store.get_events(
|
|
271
|
+
after=filter.after,
|
|
272
|
+
types=filter.types,
|
|
273
|
+
node_id=filter.node_id,
|
|
274
|
+
limit=filter.limit,
|
|
275
|
+
)
|
|
276
|
+
else:
|
|
277
|
+
return await store.get_events()
|
|
278
|
+
|
|
279
|
+
async def subscribe(self, grid_id: str) -> asyncio.Queue:
|
|
280
|
+
"""Subscribe to real-time events for a grid.
|
|
281
|
+
|
|
282
|
+
Returns a queue that will receive events.
|
|
283
|
+
"""
|
|
284
|
+
async with self._lock:
|
|
285
|
+
if grid_id not in self._subscribers:
|
|
286
|
+
self._subscribers[grid_id] = set()
|
|
287
|
+
|
|
288
|
+
queue: asyncio.Queue = asyncio.Queue()
|
|
289
|
+
self._subscribers[grid_id].add(queue)
|
|
290
|
+
return queue
|
|
291
|
+
|
|
292
|
+
async def unsubscribe(self, grid_id: str, queue: asyncio.Queue) -> None:
|
|
293
|
+
"""Unsubscribe from real-time events."""
|
|
294
|
+
async with self._lock:
|
|
295
|
+
if grid_id in self._subscribers:
|
|
296
|
+
self._subscribers[grid_id].discard(queue)
|
|
297
|
+
if not self._subscribers[grid_id]:
|
|
298
|
+
del self._subscribers[grid_id]
|
|
299
|
+
|
|
300
|
+
async def _notify_subscribers(self, grid_id: str, event: GridEvent) -> None:
|
|
301
|
+
"""Notify all subscribers of a new event."""
|
|
302
|
+
async with self._lock:
|
|
303
|
+
subscribers = self._subscribers.get(grid_id, set()).copy()
|
|
304
|
+
|
|
305
|
+
for queue in subscribers:
|
|
306
|
+
try:
|
|
307
|
+
queue.put_nowait(event)
|
|
308
|
+
except asyncio.QueueFull:
|
|
309
|
+
logger.warning(f"Event queue full for grid {grid_id}, dropping event")
|
|
310
|
+
|
|
311
|
+
async def cleanup_grid(self, grid_id: str) -> None:
|
|
312
|
+
"""Clean up event store for a deleted grid."""
|
|
313
|
+
async with self._lock:
|
|
314
|
+
if grid_id in self._stores:
|
|
315
|
+
del self._stores[grid_id]
|
|
316
|
+
if grid_id in self._subscribers:
|
|
317
|
+
# Close all subscriber queues
|
|
318
|
+
for queue in self._subscribers[grid_id]:
|
|
319
|
+
queue.put_nowait(None) # Signal end
|
|
320
|
+
del self._subscribers[grid_id]
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# Global event manager instance
|
|
324
|
+
event_manager = EventManager()
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# ============================================================================
|
|
328
|
+
# Helper Functions
|
|
329
|
+
# ============================================================================
|
|
330
|
+
|
|
331
|
+
def create_grid_event(
|
|
332
|
+
grid_id: str,
|
|
333
|
+
event_type: EventType,
|
|
334
|
+
node_id: Optional[str] = None,
|
|
335
|
+
**data,
|
|
336
|
+
) -> GridEvent:
|
|
337
|
+
"""Create an event (for use when not async)."""
|
|
338
|
+
import uuid
|
|
339
|
+
return GridEvent(
|
|
340
|
+
id=f"evt_{uuid.uuid4().hex[:8]}",
|
|
341
|
+
type=event_type,
|
|
342
|
+
grid_id=grid_id,
|
|
343
|
+
node_id=node_id,
|
|
344
|
+
data=data,
|
|
345
|
+
)
|