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/state/resume.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Resume manager for recovering incomplete grid runs.
|
|
2
|
+
|
|
3
|
+
Handles:
|
|
4
|
+
1. Scanning for incomplete runs on startup
|
|
5
|
+
2. Reconstructing GridRun from checkpoint
|
|
6
|
+
3. Resuming execution from last checkpoint
|
|
7
|
+
4. Background worker for webhook retries
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from smartify.state.checkpoint import (
|
|
19
|
+
CheckpointStore,
|
|
20
|
+
Checkpoint,
|
|
21
|
+
CheckpointStatus,
|
|
22
|
+
WebhookRetryJob,
|
|
23
|
+
WebhookDeliveryStatus,
|
|
24
|
+
get_checkpoint_store,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from smartify.engine.orchestrator import Orchestrator, GridRun
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ResumeManager:
|
|
34
|
+
"""Manages run recovery and webhook retry.
|
|
35
|
+
|
|
36
|
+
Usage:
|
|
37
|
+
manager = ResumeManager(orchestrator)
|
|
38
|
+
|
|
39
|
+
# On startup, resume any incomplete runs
|
|
40
|
+
await manager.recover_incomplete_runs()
|
|
41
|
+
|
|
42
|
+
# Start background webhook retry worker
|
|
43
|
+
await manager.start_webhook_worker()
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
orchestrator: "Orchestrator",
|
|
49
|
+
checkpoint_store: Optional[CheckpointStore] = None,
|
|
50
|
+
webhook_retry_interval: float = 30.0,
|
|
51
|
+
max_resume_attempts: int = 3,
|
|
52
|
+
):
|
|
53
|
+
self.orchestrator = orchestrator
|
|
54
|
+
self.store = checkpoint_store or get_checkpoint_store()
|
|
55
|
+
self.webhook_retry_interval = webhook_retry_interval
|
|
56
|
+
self.max_resume_attempts = max_resume_attempts
|
|
57
|
+
|
|
58
|
+
self._webhook_worker_task: Optional[asyncio.Task] = None
|
|
59
|
+
self._shutdown = False
|
|
60
|
+
|
|
61
|
+
async def recover_incomplete_runs(self) -> List[str]:
|
|
62
|
+
"""Scan for and resume incomplete runs.
|
|
63
|
+
|
|
64
|
+
Returns list of run IDs that were resumed.
|
|
65
|
+
"""
|
|
66
|
+
resumable = self.store.get_resumable_runs()
|
|
67
|
+
|
|
68
|
+
if not resumable:
|
|
69
|
+
logger.info("No incomplete runs to resume")
|
|
70
|
+
return []
|
|
71
|
+
|
|
72
|
+
logger.info(f"Found {len(resumable)} incomplete run(s) to resume")
|
|
73
|
+
|
|
74
|
+
resumed = []
|
|
75
|
+
for checkpoint in resumable:
|
|
76
|
+
try:
|
|
77
|
+
if checkpoint.resume_count >= self.max_resume_attempts:
|
|
78
|
+
logger.warning(
|
|
79
|
+
f"Run {checkpoint.run_id} exceeded max resume attempts "
|
|
80
|
+
f"({checkpoint.resume_count}), marking as failed"
|
|
81
|
+
)
|
|
82
|
+
self.store.mark_failed(
|
|
83
|
+
checkpoint.run_id,
|
|
84
|
+
f"Exceeded max resume attempts ({self.max_resume_attempts})"
|
|
85
|
+
)
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
await self._resume_run(checkpoint)
|
|
89
|
+
resumed.append(checkpoint.run_id)
|
|
90
|
+
|
|
91
|
+
except Exception as e:
|
|
92
|
+
logger.error(f"Failed to resume run {checkpoint.run_id}: {e}")
|
|
93
|
+
self.store.mark_failed(checkpoint.run_id, str(e))
|
|
94
|
+
|
|
95
|
+
return resumed
|
|
96
|
+
|
|
97
|
+
async def _resume_run(self, checkpoint: Checkpoint) -> None:
|
|
98
|
+
"""Resume a single run from checkpoint."""
|
|
99
|
+
logger.info(
|
|
100
|
+
f"Resuming run {checkpoint.run_id} "
|
|
101
|
+
f"(attempt {checkpoint.resume_count + 1}, "
|
|
102
|
+
f"{len(checkpoint.completed_nodes)} nodes completed)"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Increment resume count
|
|
106
|
+
self.store.increment_resume_count(checkpoint.run_id)
|
|
107
|
+
|
|
108
|
+
# Parse grid YAML
|
|
109
|
+
grid_dict = yaml.safe_load(checkpoint.grid_yaml)
|
|
110
|
+
|
|
111
|
+
# Load grid through orchestrator
|
|
112
|
+
run = await self.orchestrator.load_grid(
|
|
113
|
+
source=grid_dict,
|
|
114
|
+
inputs=checkpoint.inputs,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Restore context state
|
|
118
|
+
run.context.outputs = checkpoint.outputs
|
|
119
|
+
run.context.total_tokens = checkpoint.total_tokens
|
|
120
|
+
run.context.total_cost = checkpoint.total_cost
|
|
121
|
+
|
|
122
|
+
# Mark completed nodes in scheduler
|
|
123
|
+
for node_id in checkpoint.completed_nodes:
|
|
124
|
+
if node_id in run.scheduler.nodes:
|
|
125
|
+
output = checkpoint.outputs.get(node_id, {})
|
|
126
|
+
run.scheduler.mark_completed(node_id, output)
|
|
127
|
+
|
|
128
|
+
# Mark failed nodes
|
|
129
|
+
for node_id in checkpoint.failed_nodes:
|
|
130
|
+
if node_id in run.scheduler.nodes:
|
|
131
|
+
run.scheduler.mark_failed(node_id, "Failed in previous run")
|
|
132
|
+
|
|
133
|
+
# Energize and continue execution
|
|
134
|
+
await self.orchestrator.energize(run)
|
|
135
|
+
|
|
136
|
+
logger.info(f"Resuming execution for run {checkpoint.run_id}")
|
|
137
|
+
|
|
138
|
+
# Execute (this will continue from where we left off)
|
|
139
|
+
try:
|
|
140
|
+
await self.orchestrator.execute(run)
|
|
141
|
+
self.store.mark_completed(checkpoint.run_id)
|
|
142
|
+
except Exception as e:
|
|
143
|
+
self.store.mark_failed(checkpoint.run_id, str(e))
|
|
144
|
+
raise
|
|
145
|
+
|
|
146
|
+
async def start_webhook_worker(self) -> None:
|
|
147
|
+
"""Start the background webhook retry worker."""
|
|
148
|
+
if self._webhook_worker_task is not None:
|
|
149
|
+
logger.warning("Webhook worker already running")
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
self._shutdown = False
|
|
153
|
+
self._webhook_worker_task = asyncio.create_task(self._webhook_worker_loop())
|
|
154
|
+
logger.info("Webhook retry worker started")
|
|
155
|
+
|
|
156
|
+
async def stop_webhook_worker(self) -> None:
|
|
157
|
+
"""Stop the webhook retry worker."""
|
|
158
|
+
self._shutdown = True
|
|
159
|
+
|
|
160
|
+
if self._webhook_worker_task:
|
|
161
|
+
self._webhook_worker_task.cancel()
|
|
162
|
+
try:
|
|
163
|
+
await self._webhook_worker_task
|
|
164
|
+
except asyncio.CancelledError:
|
|
165
|
+
pass
|
|
166
|
+
self._webhook_worker_task = None
|
|
167
|
+
|
|
168
|
+
logger.info("Webhook retry worker stopped")
|
|
169
|
+
|
|
170
|
+
async def _webhook_worker_loop(self) -> None:
|
|
171
|
+
"""Background loop for retrying failed webhooks."""
|
|
172
|
+
while not self._shutdown:
|
|
173
|
+
try:
|
|
174
|
+
await self._process_webhook_retries()
|
|
175
|
+
except Exception as e:
|
|
176
|
+
logger.error(f"Webhook worker error: {e}")
|
|
177
|
+
|
|
178
|
+
await asyncio.sleep(self.webhook_retry_interval)
|
|
179
|
+
|
|
180
|
+
async def _process_webhook_retries(self) -> int:
|
|
181
|
+
"""Process pending webhook retries. Returns count processed."""
|
|
182
|
+
jobs = self.store.get_pending_webhook_jobs(limit=50)
|
|
183
|
+
|
|
184
|
+
if not jobs:
|
|
185
|
+
return 0
|
|
186
|
+
|
|
187
|
+
logger.debug(f"Processing {len(jobs)} webhook retry job(s)")
|
|
188
|
+
|
|
189
|
+
processed = 0
|
|
190
|
+
for job in jobs:
|
|
191
|
+
try:
|
|
192
|
+
success = await self._deliver_webhook(job)
|
|
193
|
+
|
|
194
|
+
if success:
|
|
195
|
+
self.store.mark_webhook_delivered(job.job_id)
|
|
196
|
+
logger.info(f"Webhook job {job.job_id} delivered successfully")
|
|
197
|
+
else:
|
|
198
|
+
self.store.mark_webhook_failed(
|
|
199
|
+
job.job_id,
|
|
200
|
+
"Delivery failed",
|
|
201
|
+
retry_delay_seconds=60.0,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
processed += 1
|
|
205
|
+
|
|
206
|
+
except Exception as e:
|
|
207
|
+
logger.error(f"Error processing webhook job {job.job_id}: {e}")
|
|
208
|
+
self.store.mark_webhook_failed(job.job_id, str(e))
|
|
209
|
+
|
|
210
|
+
return processed
|
|
211
|
+
|
|
212
|
+
async def _deliver_webhook(self, job: WebhookRetryJob) -> bool:
|
|
213
|
+
"""Attempt to deliver a webhook."""
|
|
214
|
+
import hashlib
|
|
215
|
+
import hmac
|
|
216
|
+
import json
|
|
217
|
+
|
|
218
|
+
body = json.dumps(job.payload)
|
|
219
|
+
|
|
220
|
+
headers = {
|
|
221
|
+
"Content-Type": "application/json",
|
|
222
|
+
"User-Agent": "Smartify-Webhook/1.0",
|
|
223
|
+
"X-Smartify-Event": job.event_type,
|
|
224
|
+
"X-Smartify-Retry-Attempt": str(job.attempts + 1),
|
|
225
|
+
**job.headers,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
# Add signature if secret configured
|
|
229
|
+
if job.secret:
|
|
230
|
+
signature = hmac.new(
|
|
231
|
+
job.secret.encode('utf-8'),
|
|
232
|
+
body.encode('utf-8'),
|
|
233
|
+
hashlib.sha256,
|
|
234
|
+
).hexdigest()
|
|
235
|
+
headers["X-Smartify-Signature"] = f"sha256={signature}"
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
async with httpx.AsyncClient() as client:
|
|
239
|
+
response = await client.post(
|
|
240
|
+
job.webhook_url,
|
|
241
|
+
content=body,
|
|
242
|
+
headers=headers,
|
|
243
|
+
timeout=30.0,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
return 200 <= response.status_code < 300
|
|
247
|
+
|
|
248
|
+
except Exception as e:
|
|
249
|
+
logger.warning(f"Webhook delivery failed: {e}")
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
def queue_failed_webhook(
|
|
253
|
+
self,
|
|
254
|
+
event_type: str,
|
|
255
|
+
grid_id: str,
|
|
256
|
+
webhook_url: str,
|
|
257
|
+
payload: Dict[str, Any],
|
|
258
|
+
headers: Dict[str, str],
|
|
259
|
+
secret: Optional[str] = None,
|
|
260
|
+
max_attempts: int = 3,
|
|
261
|
+
) -> None:
|
|
262
|
+
"""Queue a failed webhook for retry."""
|
|
263
|
+
self.store.queue_webhook_retry(
|
|
264
|
+
event_type=event_type,
|
|
265
|
+
grid_id=grid_id,
|
|
266
|
+
webhook_url=webhook_url,
|
|
267
|
+
payload=payload,
|
|
268
|
+
headers=headers,
|
|
269
|
+
secret=secret,
|
|
270
|
+
max_attempts=max_attempts,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def get_queue_stats(self) -> Dict[str, int]:
|
|
274
|
+
"""Get webhook queue statistics."""
|
|
275
|
+
return self.store.get_queue_stats()
|
|
276
|
+
|
|
277
|
+
async def cleanup(self, days: int = 7) -> Dict[str, int]:
|
|
278
|
+
"""Clean up old data."""
|
|
279
|
+
webhook_deleted = self.store.cleanup_old_jobs(days=days)
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
"webhook_jobs_deleted": webhook_deleted,
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# Integration helpers
|
|
287
|
+
|
|
288
|
+
def create_checkpointed_orchestrator(
|
|
289
|
+
db_path: str = "smartify_state.db",
|
|
290
|
+
) -> tuple["Orchestrator", "ResumeManager", CheckpointStore]:
|
|
291
|
+
"""Create an orchestrator with checkpoint support.
|
|
292
|
+
|
|
293
|
+
Returns (orchestrator, resume_manager, checkpoint_store) tuple.
|
|
294
|
+
"""
|
|
295
|
+
from smartify.engine.orchestrator import Orchestrator
|
|
296
|
+
|
|
297
|
+
store = CheckpointStore(db_path)
|
|
298
|
+
orchestrator = Orchestrator()
|
|
299
|
+
manager = ResumeManager(orchestrator, store)
|
|
300
|
+
|
|
301
|
+
return orchestrator, manager, store
|
smartify/state/store.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""State persistence for Smartify runs.
|
|
2
|
+
|
|
3
|
+
Provides storage backends for:
|
|
4
|
+
- Run state (grid + execution state)
|
|
5
|
+
- Node outputs
|
|
6
|
+
- Event logs
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import sqlite3
|
|
12
|
+
from abc import ABC, abstractmethod
|
|
13
|
+
from dataclasses import dataclass, asdict
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Dict, List, Optional
|
|
17
|
+
from enum import Enum
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RunStatus(str, Enum):
|
|
23
|
+
"""Status of a grid run."""
|
|
24
|
+
PENDING = "pending"
|
|
25
|
+
RUNNING = "running"
|
|
26
|
+
PAUSED = "paused"
|
|
27
|
+
COMPLETED = "completed"
|
|
28
|
+
FAILED = "failed"
|
|
29
|
+
STOPPED = "stopped"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class RunRecord:
|
|
34
|
+
"""Record of a grid run."""
|
|
35
|
+
run_id: str
|
|
36
|
+
grid_id: str
|
|
37
|
+
grid_name: str
|
|
38
|
+
status: RunStatus
|
|
39
|
+
created_at: datetime
|
|
40
|
+
started_at: Optional[datetime] = None
|
|
41
|
+
completed_at: Optional[datetime] = None
|
|
42
|
+
total_tokens: int = 0
|
|
43
|
+
total_cost: float = 0.0
|
|
44
|
+
error: Optional[str] = None
|
|
45
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
46
|
+
|
|
47
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
48
|
+
d = asdict(self)
|
|
49
|
+
d['status'] = self.status.value
|
|
50
|
+
d['created_at'] = self.created_at.isoformat() if self.created_at else None
|
|
51
|
+
d['started_at'] = self.started_at.isoformat() if self.started_at else None
|
|
52
|
+
d['completed_at'] = self.completed_at.isoformat() if self.completed_at else None
|
|
53
|
+
return d
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_dict(cls, d: Dict[str, Any]) -> "RunRecord":
|
|
57
|
+
return cls(
|
|
58
|
+
run_id=d['run_id'],
|
|
59
|
+
grid_id=d['grid_id'],
|
|
60
|
+
grid_name=d['grid_name'],
|
|
61
|
+
status=RunStatus(d['status']),
|
|
62
|
+
created_at=datetime.fromisoformat(d['created_at']) if d['created_at'] else datetime.now(),
|
|
63
|
+
started_at=datetime.fromisoformat(d['started_at']) if d.get('started_at') else None,
|
|
64
|
+
completed_at=datetime.fromisoformat(d['completed_at']) if d.get('completed_at') else None,
|
|
65
|
+
total_tokens=d.get('total_tokens', 0),
|
|
66
|
+
total_cost=d.get('total_cost', 0.0),
|
|
67
|
+
error=d.get('error'),
|
|
68
|
+
metadata=d.get('metadata'),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class NodeOutput:
|
|
74
|
+
"""Output from a node execution."""
|
|
75
|
+
run_id: str
|
|
76
|
+
node_id: str
|
|
77
|
+
success: bool
|
|
78
|
+
output: Optional[Dict[str, Any]]
|
|
79
|
+
error: Optional[str]
|
|
80
|
+
started_at: datetime
|
|
81
|
+
completed_at: datetime
|
|
82
|
+
tokens_used: int = 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class StateStore(ABC):
|
|
86
|
+
"""Abstract base class for state storage."""
|
|
87
|
+
|
|
88
|
+
@abstractmethod
|
|
89
|
+
def save_run(self, run: RunRecord) -> None:
|
|
90
|
+
"""Save or update a run record."""
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
@abstractmethod
|
|
94
|
+
def get_run(self, run_id: str) -> Optional[RunRecord]:
|
|
95
|
+
"""Get a run by ID."""
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
@abstractmethod
|
|
99
|
+
def list_runs(
|
|
100
|
+
self,
|
|
101
|
+
status: Optional[RunStatus] = None,
|
|
102
|
+
grid_id: Optional[str] = None,
|
|
103
|
+
limit: int = 100,
|
|
104
|
+
) -> List[RunRecord]:
|
|
105
|
+
"""List runs with optional filters."""
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
@abstractmethod
|
|
109
|
+
def delete_run(self, run_id: str) -> bool:
|
|
110
|
+
"""Delete a run and its data."""
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
@abstractmethod
|
|
114
|
+
def save_node_output(self, output: NodeOutput) -> None:
|
|
115
|
+
"""Save node output."""
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
@abstractmethod
|
|
119
|
+
def get_node_outputs(self, run_id: str) -> List[NodeOutput]:
|
|
120
|
+
"""Get all node outputs for a run."""
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class InMemoryStore(StateStore):
|
|
125
|
+
"""In-memory state store for testing and development."""
|
|
126
|
+
|
|
127
|
+
def __init__(self):
|
|
128
|
+
self._runs: Dict[str, RunRecord] = {}
|
|
129
|
+
self._outputs: Dict[str, List[NodeOutput]] = {}
|
|
130
|
+
|
|
131
|
+
def save_run(self, run: RunRecord) -> None:
|
|
132
|
+
self._runs[run.run_id] = run
|
|
133
|
+
|
|
134
|
+
def get_run(self, run_id: str) -> Optional[RunRecord]:
|
|
135
|
+
return self._runs.get(run_id)
|
|
136
|
+
|
|
137
|
+
def list_runs(
|
|
138
|
+
self,
|
|
139
|
+
status: Optional[RunStatus] = None,
|
|
140
|
+
grid_id: Optional[str] = None,
|
|
141
|
+
limit: int = 100,
|
|
142
|
+
) -> List[RunRecord]:
|
|
143
|
+
runs = list(self._runs.values())
|
|
144
|
+
|
|
145
|
+
if status:
|
|
146
|
+
runs = [r for r in runs if r.status == status]
|
|
147
|
+
if grid_id:
|
|
148
|
+
runs = [r for r in runs if r.grid_id == grid_id]
|
|
149
|
+
|
|
150
|
+
# Sort by created_at descending
|
|
151
|
+
runs.sort(key=lambda r: r.created_at, reverse=True)
|
|
152
|
+
|
|
153
|
+
return runs[:limit]
|
|
154
|
+
|
|
155
|
+
def delete_run(self, run_id: str) -> bool:
|
|
156
|
+
if run_id in self._runs:
|
|
157
|
+
del self._runs[run_id]
|
|
158
|
+
self._outputs.pop(run_id, None)
|
|
159
|
+
return True
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
def save_node_output(self, output: NodeOutput) -> None:
|
|
163
|
+
if output.run_id not in self._outputs:
|
|
164
|
+
self._outputs[output.run_id] = []
|
|
165
|
+
self._outputs[output.run_id].append(output)
|
|
166
|
+
|
|
167
|
+
def get_node_outputs(self, run_id: str) -> List[NodeOutput]:
|
|
168
|
+
return self._outputs.get(run_id, [])
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class SQLiteStore(StateStore):
|
|
172
|
+
"""SQLite-based persistent state store."""
|
|
173
|
+
|
|
174
|
+
def __init__(self, db_path: str = "smartify_runs.db"):
|
|
175
|
+
self.db_path = db_path
|
|
176
|
+
self._init_db()
|
|
177
|
+
|
|
178
|
+
def _init_db(self) -> None:
|
|
179
|
+
"""Initialize database schema."""
|
|
180
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
181
|
+
conn.execute("""
|
|
182
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
183
|
+
run_id TEXT PRIMARY KEY,
|
|
184
|
+
grid_id TEXT NOT NULL,
|
|
185
|
+
grid_name TEXT NOT NULL,
|
|
186
|
+
status TEXT NOT NULL,
|
|
187
|
+
created_at TEXT NOT NULL,
|
|
188
|
+
started_at TEXT,
|
|
189
|
+
completed_at TEXT,
|
|
190
|
+
total_tokens INTEGER DEFAULT 0,
|
|
191
|
+
total_cost REAL DEFAULT 0.0,
|
|
192
|
+
error TEXT,
|
|
193
|
+
metadata TEXT
|
|
194
|
+
)
|
|
195
|
+
""")
|
|
196
|
+
|
|
197
|
+
conn.execute("""
|
|
198
|
+
CREATE TABLE IF NOT EXISTS node_outputs (
|
|
199
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
200
|
+
run_id TEXT NOT NULL,
|
|
201
|
+
node_id TEXT NOT NULL,
|
|
202
|
+
success INTEGER NOT NULL,
|
|
203
|
+
output TEXT,
|
|
204
|
+
error TEXT,
|
|
205
|
+
started_at TEXT NOT NULL,
|
|
206
|
+
completed_at TEXT NOT NULL,
|
|
207
|
+
tokens_used INTEGER DEFAULT 0,
|
|
208
|
+
FOREIGN KEY (run_id) REFERENCES runs(run_id)
|
|
209
|
+
)
|
|
210
|
+
""")
|
|
211
|
+
|
|
212
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status)")
|
|
213
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_runs_grid ON runs(grid_id)")
|
|
214
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_outputs_run ON node_outputs(run_id)")
|
|
215
|
+
|
|
216
|
+
conn.commit()
|
|
217
|
+
|
|
218
|
+
def save_run(self, run: RunRecord) -> None:
|
|
219
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
220
|
+
conn.execute("""
|
|
221
|
+
INSERT OR REPLACE INTO runs
|
|
222
|
+
(run_id, grid_id, grid_name, status, created_at, started_at,
|
|
223
|
+
completed_at, total_tokens, total_cost, error, metadata)
|
|
224
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
225
|
+
""", (
|
|
226
|
+
run.run_id,
|
|
227
|
+
run.grid_id,
|
|
228
|
+
run.grid_name,
|
|
229
|
+
run.status.value,
|
|
230
|
+
run.created_at.isoformat(),
|
|
231
|
+
run.started_at.isoformat() if run.started_at else None,
|
|
232
|
+
run.completed_at.isoformat() if run.completed_at else None,
|
|
233
|
+
run.total_tokens,
|
|
234
|
+
run.total_cost,
|
|
235
|
+
run.error,
|
|
236
|
+
json.dumps(run.metadata) if run.metadata else None,
|
|
237
|
+
))
|
|
238
|
+
conn.commit()
|
|
239
|
+
|
|
240
|
+
def get_run(self, run_id: str) -> Optional[RunRecord]:
|
|
241
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
242
|
+
conn.row_factory = sqlite3.Row
|
|
243
|
+
cursor = conn.execute(
|
|
244
|
+
"SELECT * FROM runs WHERE run_id = ?", (run_id,)
|
|
245
|
+
)
|
|
246
|
+
row = cursor.fetchone()
|
|
247
|
+
|
|
248
|
+
if not row:
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
return RunRecord(
|
|
252
|
+
run_id=row['run_id'],
|
|
253
|
+
grid_id=row['grid_id'],
|
|
254
|
+
grid_name=row['grid_name'],
|
|
255
|
+
status=RunStatus(row['status']),
|
|
256
|
+
created_at=datetime.fromisoformat(row['created_at']),
|
|
257
|
+
started_at=datetime.fromisoformat(row['started_at']) if row['started_at'] else None,
|
|
258
|
+
completed_at=datetime.fromisoformat(row['completed_at']) if row['completed_at'] else None,
|
|
259
|
+
total_tokens=row['total_tokens'],
|
|
260
|
+
total_cost=row['total_cost'],
|
|
261
|
+
error=row['error'],
|
|
262
|
+
metadata=json.loads(row['metadata']) if row['metadata'] else None,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def list_runs(
|
|
266
|
+
self,
|
|
267
|
+
status: Optional[RunStatus] = None,
|
|
268
|
+
grid_id: Optional[str] = None,
|
|
269
|
+
limit: int = 100,
|
|
270
|
+
) -> List[RunRecord]:
|
|
271
|
+
query = "SELECT * FROM runs WHERE 1=1"
|
|
272
|
+
params = []
|
|
273
|
+
|
|
274
|
+
if status:
|
|
275
|
+
query += " AND status = ?"
|
|
276
|
+
params.append(status.value)
|
|
277
|
+
if grid_id:
|
|
278
|
+
query += " AND grid_id = ?"
|
|
279
|
+
params.append(grid_id)
|
|
280
|
+
|
|
281
|
+
query += " ORDER BY created_at DESC LIMIT ?"
|
|
282
|
+
params.append(limit)
|
|
283
|
+
|
|
284
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
285
|
+
conn.row_factory = sqlite3.Row
|
|
286
|
+
cursor = conn.execute(query, params)
|
|
287
|
+
|
|
288
|
+
runs = []
|
|
289
|
+
for row in cursor:
|
|
290
|
+
runs.append(RunRecord(
|
|
291
|
+
run_id=row['run_id'],
|
|
292
|
+
grid_id=row['grid_id'],
|
|
293
|
+
grid_name=row['grid_name'],
|
|
294
|
+
status=RunStatus(row['status']),
|
|
295
|
+
created_at=datetime.fromisoformat(row['created_at']),
|
|
296
|
+
started_at=datetime.fromisoformat(row['started_at']) if row['started_at'] else None,
|
|
297
|
+
completed_at=datetime.fromisoformat(row['completed_at']) if row['completed_at'] else None,
|
|
298
|
+
total_tokens=row['total_tokens'],
|
|
299
|
+
total_cost=row['total_cost'],
|
|
300
|
+
error=row['error'],
|
|
301
|
+
metadata=json.loads(row['metadata']) if row['metadata'] else None,
|
|
302
|
+
))
|
|
303
|
+
|
|
304
|
+
return runs
|
|
305
|
+
|
|
306
|
+
def delete_run(self, run_id: str) -> bool:
|
|
307
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
308
|
+
cursor = conn.execute("DELETE FROM runs WHERE run_id = ?", (run_id,))
|
|
309
|
+
conn.execute("DELETE FROM node_outputs WHERE run_id = ?", (run_id,))
|
|
310
|
+
conn.commit()
|
|
311
|
+
return cursor.rowcount > 0
|
|
312
|
+
|
|
313
|
+
def save_node_output(self, output: NodeOutput) -> None:
|
|
314
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
315
|
+
conn.execute("""
|
|
316
|
+
INSERT INTO node_outputs
|
|
317
|
+
(run_id, node_id, success, output, error, started_at, completed_at, tokens_used)
|
|
318
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
319
|
+
""", (
|
|
320
|
+
output.run_id,
|
|
321
|
+
output.node_id,
|
|
322
|
+
1 if output.success else 0,
|
|
323
|
+
json.dumps(output.output) if output.output else None,
|
|
324
|
+
output.error,
|
|
325
|
+
output.started_at.isoformat(),
|
|
326
|
+
output.completed_at.isoformat(),
|
|
327
|
+
output.tokens_used,
|
|
328
|
+
))
|
|
329
|
+
conn.commit()
|
|
330
|
+
|
|
331
|
+
def get_node_outputs(self, run_id: str) -> List[NodeOutput]:
|
|
332
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
333
|
+
conn.row_factory = sqlite3.Row
|
|
334
|
+
cursor = conn.execute(
|
|
335
|
+
"SELECT * FROM node_outputs WHERE run_id = ? ORDER BY started_at",
|
|
336
|
+
(run_id,)
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
outputs = []
|
|
340
|
+
for row in cursor:
|
|
341
|
+
outputs.append(NodeOutput(
|
|
342
|
+
run_id=row['run_id'],
|
|
343
|
+
node_id=row['node_id'],
|
|
344
|
+
success=bool(row['success']),
|
|
345
|
+
output=json.loads(row['output']) if row['output'] else None,
|
|
346
|
+
error=row['error'],
|
|
347
|
+
started_at=datetime.fromisoformat(row['started_at']),
|
|
348
|
+
completed_at=datetime.fromisoformat(row['completed_at']),
|
|
349
|
+
tokens_used=row['tokens_used'],
|
|
350
|
+
))
|
|
351
|
+
|
|
352
|
+
return outputs
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# Default store factory
|
|
356
|
+
_default_store: Optional[StateStore] = None
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def get_default_store() -> StateStore:
|
|
360
|
+
"""Get or create the default state store."""
|
|
361
|
+
global _default_store
|
|
362
|
+
if _default_store is None:
|
|
363
|
+
_default_store = InMemoryStore()
|
|
364
|
+
return _default_store
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def set_default_store(store: StateStore) -> None:
|
|
368
|
+
"""Set the default state store."""
|
|
369
|
+
global _default_store
|
|
370
|
+
_default_store = store
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Smartify tools - mechanism for LLM nodes to interact with the world."""
|
|
2
|
+
|
|
3
|
+
from smartify.tools.base import (
|
|
4
|
+
Tool,
|
|
5
|
+
ToolResult,
|
|
6
|
+
ToolRegistry,
|
|
7
|
+
get_default_registry,
|
|
8
|
+
register_tool,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Tool",
|
|
13
|
+
"ToolResult",
|
|
14
|
+
"ToolRegistry",
|
|
15
|
+
"get_default_registry",
|
|
16
|
+
"register_tool",
|
|
17
|
+
]
|