supervaizer 0.10.5__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.
- supervaizer/__init__.py +97 -0
- supervaizer/__version__.py +10 -0
- supervaizer/account.py +308 -0
- supervaizer/account_service.py +93 -0
- supervaizer/admin/routes.py +1293 -0
- supervaizer/admin/static/js/job-start-form.js +373 -0
- supervaizer/admin/templates/agent_detail.html +145 -0
- supervaizer/admin/templates/agents.html +249 -0
- supervaizer/admin/templates/agents_grid.html +82 -0
- supervaizer/admin/templates/base.html +233 -0
- supervaizer/admin/templates/case_detail.html +230 -0
- supervaizer/admin/templates/cases_list.html +182 -0
- supervaizer/admin/templates/cases_table.html +134 -0
- supervaizer/admin/templates/console.html +389 -0
- supervaizer/admin/templates/dashboard.html +153 -0
- supervaizer/admin/templates/job_detail.html +192 -0
- supervaizer/admin/templates/job_start_test.html +109 -0
- supervaizer/admin/templates/jobs_list.html +180 -0
- supervaizer/admin/templates/jobs_table.html +122 -0
- supervaizer/admin/templates/navigation.html +163 -0
- supervaizer/admin/templates/recent_activity.html +81 -0
- supervaizer/admin/templates/server.html +105 -0
- supervaizer/admin/templates/server_status_cards.html +121 -0
- supervaizer/admin/templates/supervaize_instructions.html +212 -0
- supervaizer/agent.py +956 -0
- supervaizer/case.py +432 -0
- supervaizer/cli.py +395 -0
- supervaizer/common.py +324 -0
- supervaizer/deploy/__init__.py +16 -0
- supervaizer/deploy/cli.py +305 -0
- supervaizer/deploy/commands/__init__.py +9 -0
- supervaizer/deploy/commands/clean.py +294 -0
- supervaizer/deploy/commands/down.py +119 -0
- supervaizer/deploy/commands/local.py +460 -0
- supervaizer/deploy/commands/plan.py +167 -0
- supervaizer/deploy/commands/status.py +169 -0
- supervaizer/deploy/commands/up.py +281 -0
- supervaizer/deploy/docker.py +377 -0
- supervaizer/deploy/driver_factory.py +42 -0
- supervaizer/deploy/drivers/__init__.py +39 -0
- supervaizer/deploy/drivers/aws_app_runner.py +607 -0
- supervaizer/deploy/drivers/base.py +196 -0
- supervaizer/deploy/drivers/cloud_run.py +570 -0
- supervaizer/deploy/drivers/do_app_platform.py +504 -0
- supervaizer/deploy/health.py +404 -0
- supervaizer/deploy/state.py +210 -0
- supervaizer/deploy/templates/Dockerfile.template +44 -0
- supervaizer/deploy/templates/debug_env.py +69 -0
- supervaizer/deploy/templates/docker-compose.yml.template +37 -0
- supervaizer/deploy/templates/dockerignore.template +66 -0
- supervaizer/deploy/templates/entrypoint.sh +20 -0
- supervaizer/deploy/utils.py +52 -0
- supervaizer/event.py +181 -0
- supervaizer/examples/controller_template.py +196 -0
- supervaizer/instructions.py +145 -0
- supervaizer/job.py +392 -0
- supervaizer/job_service.py +156 -0
- supervaizer/lifecycle.py +417 -0
- supervaizer/parameter.py +233 -0
- supervaizer/protocol/__init__.py +11 -0
- supervaizer/protocol/a2a/__init__.py +21 -0
- supervaizer/protocol/a2a/model.py +227 -0
- supervaizer/protocol/a2a/routes.py +99 -0
- supervaizer/py.typed +1 -0
- supervaizer/routes.py +917 -0
- supervaizer/server.py +553 -0
- supervaizer/server_utils.py +54 -0
- supervaizer/storage.py +462 -0
- supervaizer/telemetry.py +81 -0
- supervaizer/utils/__init__.py +16 -0
- supervaizer/utils/version_check.py +56 -0
- supervaizer-0.10.5.dist-info/METADATA +317 -0
- supervaizer-0.10.5.dist-info/RECORD +76 -0
- supervaizer-0.10.5.dist-info/WHEEL +4 -0
- supervaizer-0.10.5.dist-info/entry_points.txt +2 -0
- supervaizer-0.10.5.dist-info/licenses/LICENSE.md +346 -0
supervaizer/storage.py
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
# Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved.
|
|
2
|
+
#
|
|
3
|
+
# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
|
|
4
|
+
# If a copy of the MPL was not distributed with this file, you can obtain one at
|
|
5
|
+
# https://mozilla.org/MPL/2.0/.
|
|
6
|
+
|
|
7
|
+
# Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved.
|
|
8
|
+
#
|
|
9
|
+
# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
|
|
10
|
+
# If a copy of the MPL was not distributed with this file, You can obtain one at
|
|
11
|
+
# https://mozilla.org/MPL/2.0/.
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import threading
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TYPE_CHECKING, Any, Dict, Generic, List, Optional, TypeVar
|
|
17
|
+
|
|
18
|
+
from tinydb import Query, TinyDB
|
|
19
|
+
from tinydb.storages import MemoryStorage
|
|
20
|
+
|
|
21
|
+
from supervaizer.common import log, singleton
|
|
22
|
+
from supervaizer.lifecycle import WorkflowEntity
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _MemoryStorage(MemoryStorage):
|
|
26
|
+
"""MemoryStorage that accepts TinyDB's sort_keys/indent kwargs (ignored)."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
29
|
+
super().__init__(*args)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from supervaizer.case import Case
|
|
34
|
+
from supervaizer.job import Job
|
|
35
|
+
from supervaizer.lifecycle import EntityEvents, EntityStatus
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
T = TypeVar("T", bound=WorkflowEntity)
|
|
39
|
+
|
|
40
|
+
DATA_STORAGE_PATH = os.getenv("DATA_STORAGE_PATH", "./data")
|
|
41
|
+
|
|
42
|
+
# When False (default), use in-memory storage only (e.g. Vercel, serverless).
|
|
43
|
+
# Set SUPERVAIZER_PERSISTENCE=true to persist to file.
|
|
44
|
+
PERSISTENCE_ENABLED = os.getenv("SUPERVAIZER_PERSISTENCE", "false").lower() in (
|
|
45
|
+
"true",
|
|
46
|
+
"1",
|
|
47
|
+
"yes",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@singleton
|
|
52
|
+
class StorageManager:
|
|
53
|
+
"""
|
|
54
|
+
Thread-safe TinyDB-based persistence manager for WorkflowEntity instances.
|
|
55
|
+
|
|
56
|
+
Stores entities in separate tables by type, with foreign key relationships
|
|
57
|
+
represented as ID references (Job.case_ids, Case.job_id).
|
|
58
|
+
|
|
59
|
+
When SUPERVAIZER_PERSISTENCE is false (default), uses in-memory storage only.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, db_path: Optional[str] = None):
|
|
63
|
+
"""
|
|
64
|
+
Initialize the storage manager.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
db_path: Path to the TinyDB JSON file, or None to use env-based
|
|
68
|
+
persistence (file if SUPERVAIZER_PERSISTENCE=true, else memory).
|
|
69
|
+
"""
|
|
70
|
+
self._lock = threading.Lock()
|
|
71
|
+
# Explicit file path (e.g. tests) uses file; else file only if persistence enabled
|
|
72
|
+
use_file = (db_path is not None and db_path != ":memory:") or (
|
|
73
|
+
db_path is None and PERSISTENCE_ENABLED
|
|
74
|
+
)
|
|
75
|
+
if use_file:
|
|
76
|
+
path = (
|
|
77
|
+
db_path
|
|
78
|
+
if (db_path and db_path != ":memory:")
|
|
79
|
+
else f"{DATA_STORAGE_PATH}/entities.json"
|
|
80
|
+
)
|
|
81
|
+
self.db_path = Path(path)
|
|
82
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
self._db = TinyDB(path, sort_keys=True, indent=2)
|
|
84
|
+
else:
|
|
85
|
+
# In-memory only (default for Vercel/serverless)
|
|
86
|
+
self.db_path = Path(":memory:")
|
|
87
|
+
self._db = TinyDB(storage=_MemoryStorage, sort_keys=True, indent=2)
|
|
88
|
+
|
|
89
|
+
# log.debug(
|
|
90
|
+
# f"[StorageManager] 🗃️ Local DB initialized at {self.db_path.absolute()}"
|
|
91
|
+
# )
|
|
92
|
+
|
|
93
|
+
def save_object(self, type: str, obj: Dict[str, Any]) -> None:
|
|
94
|
+
"""
|
|
95
|
+
Save an object to the appropriate table.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
type: The object type (class name)
|
|
99
|
+
obj: Dictionary representation of the object
|
|
100
|
+
"""
|
|
101
|
+
with self._lock:
|
|
102
|
+
table = self._db.table(type)
|
|
103
|
+
obj_id = obj.get("id")
|
|
104
|
+
|
|
105
|
+
if not obj_id:
|
|
106
|
+
raise ValueError(
|
|
107
|
+
f"[StorageManager] §SSSS01 Object must have an 'id' field: {obj}"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Use upsert to handle both new and existing objects
|
|
111
|
+
query = Query()
|
|
112
|
+
table.upsert(obj, query.id == obj_id)
|
|
113
|
+
|
|
114
|
+
# log.debug(f"Saved object with ID: {type} {obj_id} - {obj}")
|
|
115
|
+
|
|
116
|
+
def get_objects(self, type: str) -> List[Dict[str, Any]]:
|
|
117
|
+
"""
|
|
118
|
+
Get all objects of a specific type.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
type: The object type (class name)
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
List of object dictionaries
|
|
125
|
+
"""
|
|
126
|
+
with self._lock:
|
|
127
|
+
table = self._db.table(type)
|
|
128
|
+
documents = table.all()
|
|
129
|
+
return [dict(doc) for doc in documents]
|
|
130
|
+
|
|
131
|
+
def get_object_by_id(self, type: str, obj_id: str) -> Optional[Dict[str, Any]]:
|
|
132
|
+
"""
|
|
133
|
+
Get a specific object by its ID.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
type: The object type (class name)
|
|
137
|
+
obj_id: The object ID
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Object dictionary if found, None otherwise
|
|
141
|
+
"""
|
|
142
|
+
with self._lock:
|
|
143
|
+
table = self._db.table(type)
|
|
144
|
+
query = Query()
|
|
145
|
+
result = table.search(query.id == obj_id)
|
|
146
|
+
return result[0] if result else None
|
|
147
|
+
|
|
148
|
+
def delete_object(self, type: str, obj_id: str) -> bool:
|
|
149
|
+
"""
|
|
150
|
+
Delete an object by its ID.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
type: The object type (class name)
|
|
154
|
+
obj_id: The object ID
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
True if object was deleted, False if not found
|
|
158
|
+
"""
|
|
159
|
+
with self._lock:
|
|
160
|
+
table = self._db.table(type)
|
|
161
|
+
query = Query()
|
|
162
|
+
deleted_count = len(table.remove(query.id == obj_id))
|
|
163
|
+
|
|
164
|
+
if deleted_count > 0:
|
|
165
|
+
log.debug(f"Deleted {type} object with ID: {obj_id}")
|
|
166
|
+
return True
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
def reset_storage(self) -> None:
|
|
170
|
+
"""
|
|
171
|
+
Reset storage by clearing all tables but preserving the database file.
|
|
172
|
+
"""
|
|
173
|
+
with self._lock:
|
|
174
|
+
# Clear all tables
|
|
175
|
+
for table_name in self._db.tables():
|
|
176
|
+
self._db.drop_table(table_name)
|
|
177
|
+
|
|
178
|
+
log.info("Storage reset - all tables cleared")
|
|
179
|
+
|
|
180
|
+
def get_cases_for_job(self, job_id: str) -> List[Dict[str, Any]]:
|
|
181
|
+
"""
|
|
182
|
+
Helper method to get all cases for a specific job.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
job_id: The job ID
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
List of case dictionaries
|
|
189
|
+
"""
|
|
190
|
+
with self._lock:
|
|
191
|
+
table = self._db.table("Case")
|
|
192
|
+
query = Query()
|
|
193
|
+
documents = table.search(query.job_id == job_id)
|
|
194
|
+
return [dict(doc) for doc in documents]
|
|
195
|
+
|
|
196
|
+
def close(self) -> None:
|
|
197
|
+
"""Close the database connection."""
|
|
198
|
+
with self._lock:
|
|
199
|
+
if hasattr(self, "_db") and self._db is not None:
|
|
200
|
+
try:
|
|
201
|
+
if hasattr(self._db, "close"):
|
|
202
|
+
self._db.close()
|
|
203
|
+
log.info("StorageManager database closed")
|
|
204
|
+
except ValueError as e:
|
|
205
|
+
# Handle the case where the file is already closed
|
|
206
|
+
if "I/O operation on closed file" in str(e):
|
|
207
|
+
log.debug("Database file already closed")
|
|
208
|
+
else:
|
|
209
|
+
raise
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class EntityRepository(Generic[T]):
|
|
213
|
+
"""
|
|
214
|
+
Generic repository for WorkflowEntity types with type-safe operations.
|
|
215
|
+
|
|
216
|
+
Provides higher-level abstraction over StorageManager for specific entity types.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
def __init__(
|
|
220
|
+
self, entity_class: type[T], storage_manager: Optional[StorageManager] = None
|
|
221
|
+
):
|
|
222
|
+
"""
|
|
223
|
+
Initialize repository for a specific entity type.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
entity_class: The entity class this repository manages
|
|
227
|
+
storage_manager: Optional storage manager instance
|
|
228
|
+
"""
|
|
229
|
+
self.entity_class = entity_class
|
|
230
|
+
self.type_name = entity_class.__name__
|
|
231
|
+
self.storage = storage_manager or StorageManager()
|
|
232
|
+
|
|
233
|
+
def get_by_id(self, entity_id: str) -> Optional[T]:
|
|
234
|
+
"""
|
|
235
|
+
Get an entity by its ID.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
entity_id: The entity ID
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
Entity instance if found, None otherwise
|
|
242
|
+
"""
|
|
243
|
+
data = self.storage.get_object_by_id(self.type_name, entity_id)
|
|
244
|
+
if data:
|
|
245
|
+
return self._from_dict(data)
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
def save(self, entity: T) -> None:
|
|
249
|
+
"""
|
|
250
|
+
Save an entity to storage.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
entity: The entity to save
|
|
254
|
+
"""
|
|
255
|
+
data = self._to_dict(entity)
|
|
256
|
+
self.storage.save_object(self.type_name, data)
|
|
257
|
+
|
|
258
|
+
def get_all(self) -> List[T]:
|
|
259
|
+
"""
|
|
260
|
+
Get all entities of this type.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
List of entity instances
|
|
264
|
+
"""
|
|
265
|
+
data_list = self.storage.get_objects(self.type_name)
|
|
266
|
+
return [self._from_dict(data) for data in data_list]
|
|
267
|
+
|
|
268
|
+
def delete(self, entity_id: str) -> bool:
|
|
269
|
+
"""
|
|
270
|
+
Delete an entity by its ID.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
entity_id: The entity ID
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
True if deleted, False if not found
|
|
277
|
+
"""
|
|
278
|
+
return self.storage.delete_object(self.type_name, entity_id)
|
|
279
|
+
|
|
280
|
+
def _to_dict(self, entity: T) -> Dict[str, Any]:
|
|
281
|
+
"""Convert entity to dictionary using its to_dict property."""
|
|
282
|
+
if hasattr(entity, "to_dict"):
|
|
283
|
+
return dict(entity.to_dict)
|
|
284
|
+
else:
|
|
285
|
+
# Fallback for entities without to_dict
|
|
286
|
+
return {
|
|
287
|
+
field: getattr(entity, field)
|
|
288
|
+
for field in getattr(entity, "__dataclass_fields__", {})
|
|
289
|
+
if hasattr(entity, field)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
def _from_dict(self, data: Dict[str, Any]) -> T:
|
|
293
|
+
"""
|
|
294
|
+
Convert dictionary back to entity instance.
|
|
295
|
+
|
|
296
|
+
Note: This is a simplified implementation. In practice, you might need
|
|
297
|
+
more sophisticated deserialization depending on your entity structure.
|
|
298
|
+
"""
|
|
299
|
+
# For entities inheriting from SvBaseModel (Pydantic), use model construction
|
|
300
|
+
if hasattr(self.entity_class, "model_validate"):
|
|
301
|
+
return self.entity_class.model_validate(data) # type: ignore
|
|
302
|
+
else:
|
|
303
|
+
# Fallback for other types
|
|
304
|
+
return self.entity_class(**data)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class PersistentEntityLifecycle:
|
|
308
|
+
"""
|
|
309
|
+
Enhanced EntityLifecycle that automatically persists entity state changes.
|
|
310
|
+
|
|
311
|
+
This class wraps the original EntityLifecycle methods to add persistence.
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
@staticmethod
|
|
315
|
+
def transition(
|
|
316
|
+
entity: T, to_status: "EntityStatus", storage: Optional[StorageManager] = None
|
|
317
|
+
) -> tuple[bool, str]:
|
|
318
|
+
"""
|
|
319
|
+
Transition an entity and automatically persist the change.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
entity: The entity to transition
|
|
323
|
+
to_status: Target status
|
|
324
|
+
storage: Optional storage manager instance
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
Tuple of (success, error_message)
|
|
328
|
+
"""
|
|
329
|
+
# Import here to avoid circular imports
|
|
330
|
+
from supervaizer.lifecycle import EntityLifecycle
|
|
331
|
+
|
|
332
|
+
# Perform the transition
|
|
333
|
+
success, error = EntityLifecycle.transition(entity, to_status)
|
|
334
|
+
|
|
335
|
+
# If successful, persist the entity
|
|
336
|
+
if success:
|
|
337
|
+
storage_mgr = storage or StorageManager()
|
|
338
|
+
entity_dict = entity.to_dict if hasattr(entity, "to_dict") else vars(entity)
|
|
339
|
+
storage_mgr.save_object(type(entity).__name__, entity_dict)
|
|
340
|
+
log.debug(
|
|
341
|
+
f"[Storage transition] Auto-persisted {type(entity).__name__} {entity.id} after transition to {to_status}"
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
return success, error
|
|
345
|
+
|
|
346
|
+
@staticmethod
|
|
347
|
+
def handle_event(
|
|
348
|
+
entity: T, event: "EntityEvents", storage: Optional[StorageManager] = None
|
|
349
|
+
) -> tuple[bool, str]:
|
|
350
|
+
"""
|
|
351
|
+
Handle an event and automatically persist the change.
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
entity: The entity to handle event for
|
|
355
|
+
event: The event to handle
|
|
356
|
+
storage: Optional storage manager instance
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
Tuple of (success, error_message)
|
|
360
|
+
"""
|
|
361
|
+
# Import here to avoid circular imports
|
|
362
|
+
from supervaizer.lifecycle import EntityLifecycle
|
|
363
|
+
|
|
364
|
+
# Handle the event
|
|
365
|
+
success, error = EntityLifecycle.handle_event(entity, event)
|
|
366
|
+
|
|
367
|
+
# If successful, persist the entity
|
|
368
|
+
if success:
|
|
369
|
+
storage_mgr = storage or StorageManager()
|
|
370
|
+
entity_dict = entity.to_dict if hasattr(entity, "to_dict") else vars(entity)
|
|
371
|
+
storage_mgr.save_object(type(entity).__name__, entity_dict)
|
|
372
|
+
log.debug(
|
|
373
|
+
f"[Storage handle_event] Auto-persisted {type(entity).__name__} {entity.id} after handling event {event}"
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
return success, error
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def create_job_repository() -> "EntityRepository[Job]":
|
|
380
|
+
"""Factory function to create a Job repository."""
|
|
381
|
+
from supervaizer.job import Job
|
|
382
|
+
|
|
383
|
+
return EntityRepository(Job)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def create_case_repository() -> "EntityRepository[Case]":
|
|
387
|
+
"""Factory function to create a Case repository."""
|
|
388
|
+
from supervaizer.case import Case
|
|
389
|
+
|
|
390
|
+
return EntityRepository(Case)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def load_running_entities_on_startup() -> None:
|
|
394
|
+
"""
|
|
395
|
+
Load all running entities from storage and populate registries at startup.
|
|
396
|
+
|
|
397
|
+
This function loads jobs and cases that are in running states:
|
|
398
|
+
- IN_PROGRESS
|
|
399
|
+
- CANCELLING
|
|
400
|
+
- AWAITING
|
|
401
|
+
|
|
402
|
+
This ensures that after a server restart, all running workflows
|
|
403
|
+
continue to be accessible through the in-memory registries.
|
|
404
|
+
"""
|
|
405
|
+
from supervaizer.case import Case, Cases
|
|
406
|
+
from supervaizer.job import Job, Jobs
|
|
407
|
+
from supervaizer.lifecycle import EntityStatus
|
|
408
|
+
|
|
409
|
+
storage = StorageManager()
|
|
410
|
+
|
|
411
|
+
# Clear existing registries to start fresh
|
|
412
|
+
Jobs().reset()
|
|
413
|
+
Cases().reset()
|
|
414
|
+
|
|
415
|
+
# Load running jobs
|
|
416
|
+
job_data_list = storage.get_objects("Job")
|
|
417
|
+
loaded_jobs = 0
|
|
418
|
+
|
|
419
|
+
for job_data in job_data_list:
|
|
420
|
+
job_status = job_data.get("status")
|
|
421
|
+
if job_status in [status.value for status in EntityStatus.status_running()]:
|
|
422
|
+
try:
|
|
423
|
+
# Use model_construct to avoid triggering __init__ side effects
|
|
424
|
+
job = Job.model_construct(**job_data)
|
|
425
|
+
# Manually add to registry since we bypassed __init__
|
|
426
|
+
Jobs().add_job(job)
|
|
427
|
+
loaded_jobs += 1
|
|
428
|
+
# log.debug(
|
|
429
|
+
# f"[Startup] Loaded running job: {job.id} (status: {job.status})"
|
|
430
|
+
# )
|
|
431
|
+
except Exception as e:
|
|
432
|
+
log.error(
|
|
433
|
+
f"[Storage] §SSL01 Failed to load job {job_data.get('id', 'unknown')}: {e}"
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
# Load running cases
|
|
437
|
+
case_data_list = storage.get_objects("Case")
|
|
438
|
+
loaded_cases = 0
|
|
439
|
+
|
|
440
|
+
for case_data in case_data_list:
|
|
441
|
+
case_status = case_data.get("status")
|
|
442
|
+
if case_status in [status.value for status in EntityStatus.status_running()]:
|
|
443
|
+
try:
|
|
444
|
+
# Use model_construct to avoid triggering __init__ side effects
|
|
445
|
+
case = Case.model_construct(**case_data)
|
|
446
|
+
# Manually add to registry since we bypassed __init__
|
|
447
|
+
Cases().add_case(case)
|
|
448
|
+
loaded_cases += 1
|
|
449
|
+
# log.debug(
|
|
450
|
+
# f"[Storage] Loaded running case: {case.id} (status: {case.status}, job: {case.job_id})"
|
|
451
|
+
# )
|
|
452
|
+
except Exception as e:
|
|
453
|
+
log.error(
|
|
454
|
+
f"[Storage] §SSL03 Failed to load case {case_data.get('id', 'unknown')}: {e}"
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
log.info(
|
|
458
|
+
f"[Storage] Entity re-loading complete: {loaded_jobs} running jobs, {loaded_cases} running cases"
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
storage_manager = StorageManager()
|
supervaizer/telemetry.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved.
|
|
2
|
+
#
|
|
3
|
+
# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
|
|
4
|
+
# If a copy of the MPL was not distributed with this file, you can obtain one at
|
|
5
|
+
# https://mozilla.org/MPL/2.0/.
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Any, ClassVar, Dict
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from supervaizer.__version__ import VERSION
|
|
14
|
+
|
|
15
|
+
# TODO: Uuse OpenTelemetry / OpenInference standard - Consider connecting to Arize Phoenix observability backend for storage and visualization.
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TelemetryType(str, Enum):
|
|
19
|
+
LOGS = "logs"
|
|
20
|
+
METRICS = "metrics"
|
|
21
|
+
EVENTS = "events"
|
|
22
|
+
TRACES = "traces"
|
|
23
|
+
EXCEPTIONS = "exceptions"
|
|
24
|
+
DIAGNOSTICS = "diagnostics"
|
|
25
|
+
CUSTOM = "custom"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TelemetryCategory(str, Enum):
|
|
29
|
+
SYSTEM = "system"
|
|
30
|
+
APPLICATION = "application"
|
|
31
|
+
USER_INTERACTION = "user_interaction"
|
|
32
|
+
SECURITY = "security"
|
|
33
|
+
BUSINESS = "business"
|
|
34
|
+
ENVIRONMENT = "environment"
|
|
35
|
+
NETWORKING = "networking"
|
|
36
|
+
CUSTOM = "custom"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TelemetrySeverity(str, Enum):
|
|
40
|
+
DEBUG = "debug"
|
|
41
|
+
INFO = "info"
|
|
42
|
+
WARNING = "warning"
|
|
43
|
+
ERROR = "error"
|
|
44
|
+
CRITICAL = "critical"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TelemetryModel(BaseModel):
|
|
48
|
+
supervaizer_VERSION: ClassVar[str] = VERSION
|
|
49
|
+
agentId: str
|
|
50
|
+
type: TelemetryType
|
|
51
|
+
category: TelemetryCategory
|
|
52
|
+
severity: TelemetrySeverity
|
|
53
|
+
details: Dict[str, Any]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Telemetry(TelemetryModel):
|
|
57
|
+
"""Base class for all telemetry data in the Supervaize Control system.
|
|
58
|
+
|
|
59
|
+
Telemetry represents monitoring and observability data sent from agents to the control system.
|
|
60
|
+
This includes logs, metrics, events, traces, exceptions, diagnostics and custom telemetry.
|
|
61
|
+
|
|
62
|
+
Inherits from TelemetryModel which defines the core telemetry attributes:
|
|
63
|
+
- agentId: The ID of the agent sending the telemetry
|
|
64
|
+
- type: The TelemetryType enum indicating the telemetry category (logs, metrics, etc)
|
|
65
|
+
- category: The TelemetryCategory enum for the functional area (system, application, etc)
|
|
66
|
+
- severity: The TelemetrySeverity enum indicating importance (debug, info, warning, etc)
|
|
67
|
+
- details: A dictionary containing telemetry-specific details
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
71
|
+
super().__init__(**kwargs)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def payload(self) -> Dict[str, Any]:
|
|
75
|
+
return {
|
|
76
|
+
"agentId": self.agentId,
|
|
77
|
+
"eventType": self.type.value,
|
|
78
|
+
"severity": self.severity.value,
|
|
79
|
+
"eventCategory": self.category.value,
|
|
80
|
+
"details": self.details,
|
|
81
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved.
|
|
2
|
+
#
|
|
3
|
+
# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
|
|
4
|
+
# If a copy of the MPL was not distributed with this file, you can obtain one at
|
|
5
|
+
# https://mozilla.org/MPL/2.0/.
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
from supervaizer.utils.version_check import (
|
|
9
|
+
check_is_latest_version,
|
|
10
|
+
get_latest_version,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"check_is_latest_version",
|
|
15
|
+
"get_latest_version",
|
|
16
|
+
]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved.
|
|
2
|
+
#
|
|
3
|
+
# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
|
|
4
|
+
# If a copy of the MPL was not distributed with this file, you can obtain one at
|
|
5
|
+
# https://mozilla.org/MPL/2.0/.
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from packaging import version
|
|
12
|
+
except ImportError:
|
|
13
|
+
version = None
|
|
14
|
+
|
|
15
|
+
from supervaizer import __version__
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def get_latest_version() -> str | None:
|
|
19
|
+
"""
|
|
20
|
+
Retrieve the latest version number of supervaizer from PyPI.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
The latest version string (e.g., "0.9.8") if successful, None otherwise.
|
|
24
|
+
"""
|
|
25
|
+
try:
|
|
26
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
27
|
+
response = await client.get("https://pypi.org/pypi/supervaizer/json")
|
|
28
|
+
response.raise_for_status()
|
|
29
|
+
data = response.json()
|
|
30
|
+
return data.get("info", {}).get("version")
|
|
31
|
+
except Exception:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def check_is_latest_version() -> tuple[bool, str | None]:
|
|
36
|
+
"""
|
|
37
|
+
Check if the currently running supervaizer version is the latest available on PyPI.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
A tuple of (is_latest: bool, latest_version: str | None).
|
|
41
|
+
- is_latest: True if current version is latest or if check failed
|
|
42
|
+
- latest_version: The latest version string if available, None otherwise
|
|
43
|
+
"""
|
|
44
|
+
current_version = __version__.VERSION
|
|
45
|
+
latest_version = await get_latest_version()
|
|
46
|
+
|
|
47
|
+
if latest_version is None:
|
|
48
|
+
return True, None
|
|
49
|
+
|
|
50
|
+
# Compare versions using packaging.version for proper semantic version comparison
|
|
51
|
+
if version is not None:
|
|
52
|
+
is_latest = version.parse(current_version) >= version.parse(latest_version)
|
|
53
|
+
else:
|
|
54
|
+
# Fallback to simple string comparison if packaging is not available
|
|
55
|
+
is_latest = current_version >= latest_version
|
|
56
|
+
return is_latest, latest_version
|