smartify-ai 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. smartify/__init__.py +3 -0
  2. smartify/agents/__init__.py +0 -0
  3. smartify/agents/adapters/__init__.py +13 -0
  4. smartify/agents/adapters/anthropic.py +253 -0
  5. smartify/agents/adapters/openai.py +289 -0
  6. smartify/api/__init__.py +26 -0
  7. smartify/api/auth.py +352 -0
  8. smartify/api/errors.py +380 -0
  9. smartify/api/events.py +345 -0
  10. smartify/api/server.py +992 -0
  11. smartify/cli/__init__.py +1 -0
  12. smartify/cli/main.py +430 -0
  13. smartify/engine/__init__.py +64 -0
  14. smartify/engine/approval.py +479 -0
  15. smartify/engine/orchestrator.py +1365 -0
  16. smartify/engine/scheduler.py +380 -0
  17. smartify/engine/spark.py +294 -0
  18. smartify/guardrails/__init__.py +22 -0
  19. smartify/guardrails/breakers.py +409 -0
  20. smartify/models/__init__.py +61 -0
  21. smartify/models/grid.py +625 -0
  22. smartify/notifications/__init__.py +22 -0
  23. smartify/notifications/webhook.py +556 -0
  24. smartify/state/__init__.py +46 -0
  25. smartify/state/checkpoint.py +558 -0
  26. smartify/state/resume.py +301 -0
  27. smartify/state/store.py +370 -0
  28. smartify/tools/__init__.py +17 -0
  29. smartify/tools/base.py +196 -0
  30. smartify/tools/builtin/__init__.py +79 -0
  31. smartify/tools/builtin/file.py +464 -0
  32. smartify/tools/builtin/http.py +195 -0
  33. smartify/tools/builtin/shell.py +137 -0
  34. smartify/tools/mcp/__init__.py +33 -0
  35. smartify/tools/mcp/adapter.py +157 -0
  36. smartify/tools/mcp/client.py +334 -0
  37. smartify/tools/mcp/registry.py +130 -0
  38. smartify/validator/__init__.py +0 -0
  39. smartify/validator/validate.py +271 -0
  40. smartify/workspace/__init__.py +5 -0
  41. smartify/workspace/manager.py +248 -0
  42. smartify_ai-0.1.0.dist-info/METADATA +201 -0
  43. smartify_ai-0.1.0.dist-info/RECORD +46 -0
  44. smartify_ai-0.1.0.dist-info/WHEEL +4 -0
  45. smartify_ai-0.1.0.dist-info/entry_points.txt +2 -0
  46. smartify_ai-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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
@@ -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
+ ]