alchemist-nrel 0.3.0__py3-none-any.whl → 0.3.2__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.
- alchemist_core/__init__.py +2 -2
- alchemist_core/acquisition/botorch_acquisition.py +84 -126
- alchemist_core/data/experiment_manager.py +196 -20
- alchemist_core/models/botorch_model.py +292 -63
- alchemist_core/models/sklearn_model.py +175 -15
- alchemist_core/session.py +3532 -76
- alchemist_core/utils/__init__.py +3 -1
- alchemist_core/utils/acquisition_utils.py +60 -0
- alchemist_core/visualization/__init__.py +45 -0
- alchemist_core/visualization/helpers.py +130 -0
- alchemist_core/visualization/plots.py +1449 -0
- alchemist_nrel-0.3.2.dist-info/METADATA +185 -0
- {alchemist_nrel-0.3.0.dist-info → alchemist_nrel-0.3.2.dist-info}/RECORD +34 -29
- {alchemist_nrel-0.3.0.dist-info → alchemist_nrel-0.3.2.dist-info}/WHEEL +1 -1
- {alchemist_nrel-0.3.0.dist-info → alchemist_nrel-0.3.2.dist-info}/entry_points.txt +1 -1
- {alchemist_nrel-0.3.0.dist-info → alchemist_nrel-0.3.2.dist-info}/top_level.txt +0 -1
- api/example_client.py +7 -2
- api/main.py +3 -2
- api/models/requests.py +76 -1
- api/models/responses.py +102 -2
- api/routers/acquisition.py +25 -0
- api/routers/experiments.py +352 -11
- api/routers/sessions.py +195 -11
- api/routers/visualizations.py +6 -4
- api/routers/websocket.py +132 -0
- run_api.py → api/run_api.py +8 -7
- api/services/session_store.py +370 -71
- api/static/assets/index-B6Cf6s_b.css +1 -0
- api/static/assets/{index-C0_glioA.js → index-B7njvc9r.js} +223 -208
- api/static/index.html +2 -2
- ui/gpr_panel.py +11 -5
- ui/target_column_dialog.py +299 -0
- ui/ui.py +52 -5
- alchemist_core/models/ax_model.py +0 -159
- alchemist_nrel-0.3.0.dist-info/METADATA +0 -223
- api/static/assets/index-CB4V1LI5.css +0 -1
- {alchemist_nrel-0.3.0.dist-info → alchemist_nrel-0.3.2.dist-info}/licenses/LICENSE +0 -0
api/services/session_store.py
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Session Store - Session management with disk persistence.
|
|
3
3
|
|
|
4
|
-
Stores OptimizationSession instances with
|
|
5
|
-
Sessions
|
|
4
|
+
Stores OptimizationSession instances with recovery backup system.
|
|
5
|
+
Sessions persist in RAM until explicitly saved by user.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
from typing import Dict, Optional
|
|
8
|
+
from typing import Dict, Optional, List
|
|
9
9
|
from datetime import datetime, timedelta
|
|
10
10
|
import uuid
|
|
11
11
|
from alchemist_core.session import OptimizationSession
|
|
@@ -35,20 +35,21 @@ class SessionStore:
|
|
|
35
35
|
Initialize session store.
|
|
36
36
|
|
|
37
37
|
Args:
|
|
38
|
-
default_ttl_hours:
|
|
38
|
+
default_ttl_hours: Legacy parameter (kept for compatibility, not used for TTL)
|
|
39
39
|
persist_dir: Directory to persist sessions (None = memory only)
|
|
40
40
|
"""
|
|
41
41
|
self._sessions: Dict[str, Dict] = {}
|
|
42
|
-
self.default_ttl = timedelta(hours=default_ttl_hours)
|
|
43
42
|
self.persist_dir = Path(persist_dir) if persist_dir else Path("cache/sessions")
|
|
43
|
+
self.recovery_dir = Path("cache/recovery")
|
|
44
44
|
|
|
45
|
-
# Create
|
|
45
|
+
# Create directories
|
|
46
46
|
if self.persist_dir:
|
|
47
47
|
self.persist_dir.mkdir(parents=True, exist_ok=True)
|
|
48
|
-
|
|
49
|
-
self._load_from_disk()
|
|
48
|
+
self.recovery_dir.mkdir(parents=True, exist_ok=True)
|
|
50
49
|
|
|
51
|
-
|
|
50
|
+
# Note: No longer auto-loading sessions on startup
|
|
51
|
+
# Sessions are created on-demand or loaded explicitly by user
|
|
52
|
+
logger.info(f"SessionStore initialized with persist_dir={self.persist_dir}, recovery_dir={self.recovery_dir}")
|
|
52
53
|
|
|
53
54
|
def _get_session_file(self, session_id: str) -> Path:
|
|
54
55
|
"""Get path to session file."""
|
|
@@ -71,8 +72,7 @@ class SessionStore:
|
|
|
71
72
|
# Store metadata alongside session
|
|
72
73
|
metadata = {
|
|
73
74
|
"created_at": session_data["created_at"].isoformat(),
|
|
74
|
-
"last_accessed": session_data["last_accessed"].isoformat()
|
|
75
|
-
"expires_at": session_data["expires_at"].isoformat()
|
|
75
|
+
"last_accessed": session_data["last_accessed"].isoformat()
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
# Load session JSON and add metadata
|
|
@@ -105,12 +105,7 @@ class SessionStore:
|
|
|
105
105
|
# Extract metadata
|
|
106
106
|
metadata = session_json.pop("_session_store_metadata", {})
|
|
107
107
|
|
|
108
|
-
#
|
|
109
|
-
if metadata:
|
|
110
|
-
expires_at = datetime.fromisoformat(metadata["expires_at"])
|
|
111
|
-
if datetime.now() > expires_at:
|
|
112
|
-
session_file.unlink() # Delete expired session file
|
|
113
|
-
continue
|
|
108
|
+
# No longer check for expiration - TTL system removed
|
|
114
109
|
|
|
115
110
|
# Write session data to temp file and load
|
|
116
111
|
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as tmp:
|
|
@@ -125,7 +120,6 @@ class SessionStore:
|
|
|
125
120
|
"session": session,
|
|
126
121
|
"created_at": datetime.fromisoformat(metadata.get("created_at", datetime.now().isoformat())),
|
|
127
122
|
"last_accessed": datetime.fromisoformat(metadata.get("last_accessed", datetime.now().isoformat())),
|
|
128
|
-
"expires_at": datetime.fromisoformat(metadata.get("expires_at", (datetime.now() + self.default_ttl).isoformat())),
|
|
129
123
|
"lock": threading.Lock()
|
|
130
124
|
}
|
|
131
125
|
loaded_count += 1
|
|
@@ -177,12 +171,11 @@ class SessionStore:
|
|
|
177
171
|
"session": session,
|
|
178
172
|
"created_at": datetime.now(),
|
|
179
173
|
"last_accessed": datetime.now(),
|
|
180
|
-
"expires_at": datetime.now() + self.default_ttl,
|
|
181
174
|
"lock": threading.Lock()
|
|
182
175
|
}
|
|
183
176
|
|
|
184
|
-
#
|
|
185
|
-
|
|
177
|
+
# Note: No automatic disk save on creation
|
|
178
|
+
# User will explicitly save when ready
|
|
186
179
|
|
|
187
180
|
logger.info(f"Created session {session_id}")
|
|
188
181
|
return session_id
|
|
@@ -195,11 +188,8 @@ class SessionStore:
|
|
|
195
188
|
session_id: Session identifier
|
|
196
189
|
|
|
197
190
|
Returns:
|
|
198
|
-
OptimizationSession or None if not found
|
|
191
|
+
OptimizationSession or None if not found
|
|
199
192
|
"""
|
|
200
|
-
# Clean up expired sessions first
|
|
201
|
-
self._cleanup_expired()
|
|
202
|
-
|
|
203
193
|
if session_id not in self._sessions:
|
|
204
194
|
logger.warning(f"Session {session_id} not found")
|
|
205
195
|
return None
|
|
@@ -208,27 +198,12 @@ class SessionStore:
|
|
|
208
198
|
lock = session_data.get("lock")
|
|
209
199
|
if lock:
|
|
210
200
|
with lock:
|
|
211
|
-
#
|
|
212
|
-
if datetime.now() > session_data["expires_at"]:
|
|
213
|
-
logger.info(f"Session {session_id} expired, removing")
|
|
214
|
-
del self._sessions[session_id]
|
|
215
|
-
return None
|
|
216
|
-
|
|
217
|
-
# Update last accessed time
|
|
201
|
+
# Update last accessed time (no save to disk)
|
|
218
202
|
session_data["last_accessed"] = datetime.now()
|
|
219
|
-
|
|
220
|
-
# Save updated access time to disk
|
|
221
|
-
self._save_to_disk(session_id)
|
|
222
|
-
|
|
223
203
|
return session_data["session"]
|
|
224
204
|
else:
|
|
225
205
|
# Fallback (no lock present)
|
|
226
|
-
if datetime.now() > session_data["expires_at"]:
|
|
227
|
-
logger.info(f"Session {session_id} expired, removing")
|
|
228
|
-
del self._sessions[session_id]
|
|
229
|
-
return None
|
|
230
206
|
session_data["last_accessed"] = datetime.now()
|
|
231
|
-
self._save_to_disk(session_id)
|
|
232
207
|
return session_data["session"]
|
|
233
208
|
|
|
234
209
|
def delete(self, session_id: str) -> bool:
|
|
@@ -274,7 +249,6 @@ class SessionStore:
|
|
|
274
249
|
"session_id": session_id,
|
|
275
250
|
"created_at": session_data["created_at"].isoformat(),
|
|
276
251
|
"last_accessed": session_data["last_accessed"].isoformat(),
|
|
277
|
-
"expires_at": session_data["expires_at"].isoformat(),
|
|
278
252
|
"search_space": session.get_search_space_summary(),
|
|
279
253
|
"data": session.get_data_summary(),
|
|
280
254
|
"model": session.get_model_summary()
|
|
@@ -282,52 +256,32 @@ class SessionStore:
|
|
|
282
256
|
|
|
283
257
|
def extend_ttl(self, session_id: str, hours: int = None) -> bool:
|
|
284
258
|
"""
|
|
285
|
-
|
|
259
|
+
Legacy method - no longer used (sessions don't expire).
|
|
260
|
+
Kept for API compatibility.
|
|
286
261
|
|
|
287
262
|
Args:
|
|
288
263
|
session_id: Session identifier
|
|
289
|
-
hours:
|
|
264
|
+
hours: Ignored
|
|
290
265
|
|
|
291
266
|
Returns:
|
|
292
|
-
True if
|
|
267
|
+
True if session exists, False otherwise
|
|
293
268
|
"""
|
|
294
269
|
if session_id not in self._sessions:
|
|
295
270
|
return False
|
|
296
|
-
|
|
297
|
-
if lock:
|
|
298
|
-
with lock:
|
|
299
|
-
extension = timedelta(hours=hours) if hours else self.default_ttl
|
|
300
|
-
self._sessions[session_id]["expires_at"] = datetime.now() + extension
|
|
301
|
-
self._save_to_disk(session_id)
|
|
302
|
-
else:
|
|
303
|
-
extension = timedelta(hours=hours) if hours else self.default_ttl
|
|
304
|
-
self._sessions[session_id]["expires_at"] = datetime.now() + extension
|
|
305
|
-
self._save_to_disk(session_id)
|
|
306
|
-
|
|
307
|
-
logger.info(f"Extended TTL for session {session_id}")
|
|
271
|
+
logger.info(f"extend_ttl called for session {session_id} (no-op - TTL removed)")
|
|
308
272
|
return True
|
|
309
273
|
|
|
310
274
|
def _cleanup_expired(self):
|
|
311
|
-
"""
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
sid for sid, data in self._sessions.items()
|
|
315
|
-
if now > data["expires_at"]
|
|
316
|
-
]
|
|
317
|
-
|
|
318
|
-
for sid in expired:
|
|
319
|
-
del self._sessions[sid]
|
|
320
|
-
self._delete_from_disk(sid)
|
|
321
|
-
logger.info(f"Cleaned up expired session {sid}")
|
|
275
|
+
"""Legacy method - no longer used (sessions don't expire)."""
|
|
276
|
+
# No-op: sessions no longer have TTL expiration
|
|
277
|
+
pass
|
|
322
278
|
|
|
323
279
|
def count(self) -> int:
|
|
324
280
|
"""Get count of active sessions."""
|
|
325
|
-
self._cleanup_expired()
|
|
326
281
|
return len(self._sessions)
|
|
327
282
|
|
|
328
283
|
def list_all(self) -> list:
|
|
329
284
|
"""Get list of all active session IDs."""
|
|
330
|
-
self._cleanup_expired()
|
|
331
285
|
return list(self._sessions.keys())
|
|
332
286
|
|
|
333
287
|
def export_session(self, session_id: str) -> Optional[str]:
|
|
@@ -429,7 +383,6 @@ class SessionStore:
|
|
|
429
383
|
"session": session,
|
|
430
384
|
"created_at": datetime.now(),
|
|
431
385
|
"last_accessed": datetime.now(),
|
|
432
|
-
"expires_at": datetime.now() + self.default_ttl,
|
|
433
386
|
"lock": threading.Lock()
|
|
434
387
|
}
|
|
435
388
|
|
|
@@ -442,6 +395,352 @@ class SessionStore:
|
|
|
442
395
|
logger.error(f"Failed to import session: {e}")
|
|
443
396
|
return None
|
|
444
397
|
|
|
398
|
+
# ============================================================
|
|
399
|
+
# Session Locking for Programmatic Control
|
|
400
|
+
# ============================================================
|
|
401
|
+
|
|
402
|
+
def lock_session(self, session_id: str, locked_by: str, client_id: Optional[str] = None) -> Dict:
|
|
403
|
+
"""Lock a session for external programmatic control."""
|
|
404
|
+
if session_id not in self._sessions:
|
|
405
|
+
raise KeyError(f"Session {session_id} not found")
|
|
406
|
+
|
|
407
|
+
lock_token = str(uuid.uuid4())
|
|
408
|
+
lock_time = datetime.now()
|
|
409
|
+
|
|
410
|
+
self._sessions[session_id]["lock_info"] = {
|
|
411
|
+
"locked": True,
|
|
412
|
+
"locked_by": locked_by,
|
|
413
|
+
"client_id": client_id,
|
|
414
|
+
"locked_at": lock_time.isoformat(),
|
|
415
|
+
"lock_token": lock_token
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
self._save_to_disk(session_id)
|
|
419
|
+
logger.info(f"Session {session_id} locked by {locked_by}")
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
"locked": True,
|
|
423
|
+
"locked_by": locked_by,
|
|
424
|
+
"locked_at": lock_time.isoformat(),
|
|
425
|
+
"lock_token": lock_token
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
def unlock_session(self, session_id: str, lock_token: Optional[str] = None) -> Dict:
|
|
429
|
+
"""Unlock a session."""
|
|
430
|
+
if session_id not in self._sessions:
|
|
431
|
+
raise KeyError(f"Session {session_id} not found")
|
|
432
|
+
|
|
433
|
+
lock_info = self._sessions[session_id].get("lock_info", {})
|
|
434
|
+
|
|
435
|
+
# If token provided, verify it
|
|
436
|
+
if lock_token and lock_info.get("lock_token") != lock_token:
|
|
437
|
+
raise ValueError("Invalid lock token")
|
|
438
|
+
|
|
439
|
+
# Clear lock info
|
|
440
|
+
self._sessions[session_id]["lock_info"] = {
|
|
441
|
+
"locked": False,
|
|
442
|
+
"locked_by": None,
|
|
443
|
+
"client_id": None,
|
|
444
|
+
"locked_at": None,
|
|
445
|
+
"lock_token": None
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
self._save_to_disk(session_id)
|
|
449
|
+
logger.info(f"Session {session_id} unlocked")
|
|
450
|
+
|
|
451
|
+
return {
|
|
452
|
+
"locked": False,
|
|
453
|
+
"locked_by": None,
|
|
454
|
+
"locked_at": None,
|
|
455
|
+
"lock_token": None
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
def get_lock_status(self, session_id: str) -> Dict:
|
|
459
|
+
"""Get current lock status without exposing the token."""
|
|
460
|
+
if session_id not in self._sessions:
|
|
461
|
+
raise KeyError(f"Session {session_id} not found")
|
|
462
|
+
|
|
463
|
+
lock_info = self._sessions[session_id].get("lock_info", {})
|
|
464
|
+
|
|
465
|
+
# Don't log status checks - they happen frequently via polling
|
|
466
|
+
return {
|
|
467
|
+
"locked": lock_info.get("locked", False),
|
|
468
|
+
"locked_by": lock_info.get("locked_by"),
|
|
469
|
+
"locked_at": lock_info.get("locked_at"),
|
|
470
|
+
"lock_token": None # Never expose token in status check
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
# ============================================================
|
|
474
|
+
# Recovery / Backup System
|
|
475
|
+
# ============================================================
|
|
476
|
+
|
|
477
|
+
def _get_recovery_file(self, session_id: str) -> Path:
|
|
478
|
+
"""Get path to recovery backup file."""
|
|
479
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
480
|
+
return self.recovery_dir / f"{session_id}_recovery_{timestamp}.json"
|
|
481
|
+
|
|
482
|
+
def save_recovery_backup(self, session_id: str) -> bool:
|
|
483
|
+
"""
|
|
484
|
+
Save a recovery backup for crash protection.
|
|
485
|
+
|
|
486
|
+
This is called periodically (every 30s) from frontend to create
|
|
487
|
+
silent backups. User never sees these unless needed for recovery.
|
|
488
|
+
|
|
489
|
+
Args:
|
|
490
|
+
session_id: Session identifier
|
|
491
|
+
|
|
492
|
+
Returns:
|
|
493
|
+
True if successful, False otherwise
|
|
494
|
+
"""
|
|
495
|
+
if session_id not in self._sessions:
|
|
496
|
+
return False
|
|
497
|
+
|
|
498
|
+
try:
|
|
499
|
+
lock = self._sessions[session_id].get("lock")
|
|
500
|
+
|
|
501
|
+
# Clean up old recovery files for this session first
|
|
502
|
+
self._cleanup_old_recovery_files(session_id)
|
|
503
|
+
|
|
504
|
+
recovery_file = self._get_recovery_file(session_id)
|
|
505
|
+
|
|
506
|
+
if lock:
|
|
507
|
+
with lock:
|
|
508
|
+
# Create temporary file
|
|
509
|
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as tmp:
|
|
510
|
+
self._sessions[session_id]["session"].save_session(tmp.name)
|
|
511
|
+
temp_path = tmp.name
|
|
512
|
+
|
|
513
|
+
# Read and add recovery metadata
|
|
514
|
+
with open(temp_path, 'r') as f:
|
|
515
|
+
session_json = json.load(f)
|
|
516
|
+
|
|
517
|
+
session_json["_recovery_metadata"] = {
|
|
518
|
+
"session_id": session_id,
|
|
519
|
+
"backup_time": datetime.now().isoformat(),
|
|
520
|
+
"session_name": self._sessions[session_id]["session"].metadata.name
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
# Write to recovery file
|
|
524
|
+
with open(recovery_file, 'w') as f:
|
|
525
|
+
json.dump(session_json, f, indent=2)
|
|
526
|
+
|
|
527
|
+
# Clean up temp file
|
|
528
|
+
Path(temp_path).unlink()
|
|
529
|
+
else:
|
|
530
|
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as tmp:
|
|
531
|
+
self._sessions[session_id]["session"].save_session(tmp.name)
|
|
532
|
+
temp_path = tmp.name
|
|
533
|
+
|
|
534
|
+
with open(temp_path, 'r') as f:
|
|
535
|
+
session_json = json.load(f)
|
|
536
|
+
|
|
537
|
+
session_json["_recovery_metadata"] = {
|
|
538
|
+
"session_id": session_id,
|
|
539
|
+
"backup_time": datetime.now().isoformat(),
|
|
540
|
+
"session_name": self._sessions[session_id]["session"].metadata.name
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
with open(recovery_file, 'w') as f:
|
|
544
|
+
json.dump(session_json, f, indent=2)
|
|
545
|
+
|
|
546
|
+
Path(temp_path).unlink()
|
|
547
|
+
|
|
548
|
+
logger.debug(f"Recovery backup saved for session {session_id}")
|
|
549
|
+
return True
|
|
550
|
+
|
|
551
|
+
except Exception as e:
|
|
552
|
+
logger.error(f"Failed to save recovery backup for {session_id}: {e}")
|
|
553
|
+
return False
|
|
554
|
+
|
|
555
|
+
def _cleanup_old_recovery_files(self, session_id: str, keep_newest: int = 1):
|
|
556
|
+
"""
|
|
557
|
+
Clean up old recovery files for a session, keeping only the newest.
|
|
558
|
+
|
|
559
|
+
Args:
|
|
560
|
+
session_id: Session identifier
|
|
561
|
+
keep_newest: Number of newest files to keep (default 1)
|
|
562
|
+
"""
|
|
563
|
+
if not self.recovery_dir.exists():
|
|
564
|
+
return
|
|
565
|
+
|
|
566
|
+
# Find all recovery files for this session
|
|
567
|
+
pattern = f"{session_id}_recovery_*.json"
|
|
568
|
+
recovery_files = sorted(self.recovery_dir.glob(pattern), key=lambda p: p.stat().st_mtime)
|
|
569
|
+
|
|
570
|
+
# Delete all but the newest
|
|
571
|
+
for old_file in recovery_files[:-keep_newest]:
|
|
572
|
+
try:
|
|
573
|
+
old_file.unlink()
|
|
574
|
+
logger.debug(f"Deleted old recovery file: {old_file.name}")
|
|
575
|
+
except Exception as e:
|
|
576
|
+
logger.warning(f"Failed to delete old recovery file {old_file}: {e}")
|
|
577
|
+
|
|
578
|
+
def clear_recovery_backup(self, session_id: str) -> bool:
|
|
579
|
+
"""
|
|
580
|
+
Delete all recovery backups for a session.
|
|
581
|
+
|
|
582
|
+
Called after user successfully saves their session to their computer.
|
|
583
|
+
|
|
584
|
+
Args:
|
|
585
|
+
session_id: Session identifier
|
|
586
|
+
|
|
587
|
+
Returns:
|
|
588
|
+
True if any files were deleted
|
|
589
|
+
"""
|
|
590
|
+
if not self.recovery_dir.exists():
|
|
591
|
+
return False
|
|
592
|
+
|
|
593
|
+
deleted = False
|
|
594
|
+
pattern = f"{session_id}_recovery_*.json"
|
|
595
|
+
|
|
596
|
+
for recovery_file in self.recovery_dir.glob(pattern):
|
|
597
|
+
try:
|
|
598
|
+
recovery_file.unlink()
|
|
599
|
+
logger.info(f"Deleted recovery backup: {recovery_file.name}")
|
|
600
|
+
deleted = True
|
|
601
|
+
except Exception as e:
|
|
602
|
+
logger.error(f"Failed to delete recovery file {recovery_file}: {e}")
|
|
603
|
+
|
|
604
|
+
return deleted
|
|
605
|
+
|
|
606
|
+
def list_recovery_sessions(self) -> List[Dict]:
|
|
607
|
+
"""
|
|
608
|
+
List all available recovery sessions.
|
|
609
|
+
|
|
610
|
+
Returns list of recovery metadata for frontend to display.
|
|
611
|
+
"""
|
|
612
|
+
if not self.recovery_dir.exists():
|
|
613
|
+
return []
|
|
614
|
+
|
|
615
|
+
recoveries = []
|
|
616
|
+
|
|
617
|
+
# Group by session_id (only show newest for each)
|
|
618
|
+
session_files = {}
|
|
619
|
+
for recovery_file in self.recovery_dir.glob("*_recovery_*.json"):
|
|
620
|
+
try:
|
|
621
|
+
# Extract session_id from filename
|
|
622
|
+
parts = recovery_file.stem.split("_recovery_")
|
|
623
|
+
if len(parts) != 2:
|
|
624
|
+
continue
|
|
625
|
+
|
|
626
|
+
session_id = parts[0]
|
|
627
|
+
|
|
628
|
+
# Keep only newest file per session
|
|
629
|
+
if session_id not in session_files:
|
|
630
|
+
session_files[session_id] = recovery_file
|
|
631
|
+
else:
|
|
632
|
+
# Compare modification times
|
|
633
|
+
if recovery_file.stat().st_mtime > session_files[session_id].stat().st_mtime:
|
|
634
|
+
session_files[session_id] = recovery_file
|
|
635
|
+
except Exception as e:
|
|
636
|
+
logger.warning(f"Error processing recovery file {recovery_file}: {e}")
|
|
637
|
+
|
|
638
|
+
# Load metadata from newest files
|
|
639
|
+
for session_id, recovery_file in session_files.items():
|
|
640
|
+
try:
|
|
641
|
+
with open(recovery_file, 'r') as f:
|
|
642
|
+
session_json = json.load(f)
|
|
643
|
+
|
|
644
|
+
metadata = session_json.get("_recovery_metadata", {})
|
|
645
|
+
session_meta = session_json.get("metadata", {})
|
|
646
|
+
|
|
647
|
+
# Get session statistics
|
|
648
|
+
experiments = session_json.get("experiments", {})
|
|
649
|
+
n_experiments = experiments.get("n_total", 0)
|
|
650
|
+
|
|
651
|
+
search_space = session_json.get("search_space", {})
|
|
652
|
+
n_variables = len(search_space.get("variables", []))
|
|
653
|
+
|
|
654
|
+
model_config = session_json.get("model_config", {})
|
|
655
|
+
model_trained = model_config is not None and len(model_config) > 0
|
|
656
|
+
|
|
657
|
+
recoveries.append({
|
|
658
|
+
"session_id": session_id,
|
|
659
|
+
"session_name": metadata.get("session_name", session_meta.get("name", "Untitled Session")),
|
|
660
|
+
"backup_time": metadata.get("backup_time", datetime.fromtimestamp(recovery_file.stat().st_mtime).isoformat()),
|
|
661
|
+
"n_variables": n_variables,
|
|
662
|
+
"n_experiments": n_experiments,
|
|
663
|
+
"model_trained": model_trained,
|
|
664
|
+
"file_path": str(recovery_file)
|
|
665
|
+
})
|
|
666
|
+
except Exception as e:
|
|
667
|
+
logger.error(f"Error reading recovery file {recovery_file}: {e}")
|
|
668
|
+
|
|
669
|
+
# Sort by backup time (newest first)
|
|
670
|
+
recoveries.sort(key=lambda x: x["backup_time"], reverse=True)
|
|
671
|
+
|
|
672
|
+
return recoveries
|
|
673
|
+
|
|
674
|
+
def restore_from_recovery(self, session_id: str) -> Optional[str]:
|
|
675
|
+
"""
|
|
676
|
+
Restore a session from recovery backup.
|
|
677
|
+
|
|
678
|
+
Creates a new active session from the recovery file.
|
|
679
|
+
|
|
680
|
+
Args:
|
|
681
|
+
session_id: Original session ID to restore
|
|
682
|
+
|
|
683
|
+
Returns:
|
|
684
|
+
New session ID if successful, None otherwise
|
|
685
|
+
"""
|
|
686
|
+
if not self.recovery_dir.exists():
|
|
687
|
+
return None
|
|
688
|
+
|
|
689
|
+
# Find newest recovery file for this session
|
|
690
|
+
pattern = f"{session_id}_recovery_*.json"
|
|
691
|
+
recovery_files = sorted(self.recovery_dir.glob(pattern), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
692
|
+
|
|
693
|
+
if not recovery_files:
|
|
694
|
+
logger.warning(f"No recovery files found for session {session_id}")
|
|
695
|
+
return None
|
|
696
|
+
|
|
697
|
+
recovery_file = recovery_files[0]
|
|
698
|
+
|
|
699
|
+
try:
|
|
700
|
+
# Read recovery file
|
|
701
|
+
with open(recovery_file, 'r') as f:
|
|
702
|
+
session_data = f.read()
|
|
703
|
+
|
|
704
|
+
# Import as new session (generates new ID)
|
|
705
|
+
new_session_id = self.import_session(session_data)
|
|
706
|
+
|
|
707
|
+
if new_session_id:
|
|
708
|
+
logger.info(f"Restored session {session_id} as new session {new_session_id}")
|
|
709
|
+
# Keep recovery file until user explicitly saves
|
|
710
|
+
return new_session_id
|
|
711
|
+
|
|
712
|
+
except Exception as e:
|
|
713
|
+
logger.error(f"Failed to restore from recovery {recovery_file}: {e}")
|
|
714
|
+
|
|
715
|
+
return None
|
|
716
|
+
|
|
717
|
+
def cleanup_old_recoveries(self, max_age_hours: int = 24):
|
|
718
|
+
"""
|
|
719
|
+
Clean up recovery files older than specified hours.
|
|
720
|
+
|
|
721
|
+
Called periodically to prevent accumulation.
|
|
722
|
+
|
|
723
|
+
Args:
|
|
724
|
+
max_age_hours: Maximum age in hours (default 24)
|
|
725
|
+
"""
|
|
726
|
+
if not self.recovery_dir.exists():
|
|
727
|
+
return
|
|
728
|
+
|
|
729
|
+
cutoff_time = datetime.now().timestamp() - (max_age_hours * 3600)
|
|
730
|
+
deleted_count = 0
|
|
731
|
+
|
|
732
|
+
for recovery_file in self.recovery_dir.glob("*_recovery_*.json"):
|
|
733
|
+
try:
|
|
734
|
+
if recovery_file.stat().st_mtime < cutoff_time:
|
|
735
|
+
recovery_file.unlink()
|
|
736
|
+
deleted_count += 1
|
|
737
|
+
logger.debug(f"Deleted old recovery file: {recovery_file.name}")
|
|
738
|
+
except Exception as e:
|
|
739
|
+
logger.warning(f"Failed to delete recovery file {recovery_file}: {e}")
|
|
740
|
+
|
|
741
|
+
if deleted_count > 0:
|
|
742
|
+
logger.info(f"Cleaned up {deleted_count} old recovery files")
|
|
743
|
+
|
|
445
744
|
|
|
446
745
|
# Global session store instance
|
|
447
746
|
session_store = SessionStore(default_ttl_hours=24)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root{--background: 0 0% 98%;--foreground: 222.2 84% 4.9%;--card: 0 0% 100%;--card-foreground: 222.2 84% 4.9%;--popover: 0 0% 100%;--popover-foreground: 222.2 84% 4.9%;--primary: 217 91% 50%;--primary-foreground: 0 0% 100%;--secondary: 214 32% 88%;--secondary-foreground: 222.2 47.4% 11.2%;--muted: 210 40% 93%;--muted-foreground: 215.4 16.3% 40%;--accent: 210 40% 93%;--accent-foreground: 222.2 47.4% 11.2%;--destructive: 0 84.2% 50%;--destructive-foreground: 0 0% 100%;--border: 214.3 31.8% 85%;--input: 214.3 31.8% 85%;--ring: 217 91% 50%;--radius: .5rem}.dark{--background: 220 13% 13%;--foreground: 210 40% 98%;--card: 220 13% 16%;--card-foreground: 210 40% 98%;--popover: 220 13% 16%;--popover-foreground: 210 40% 98%;--primary: 211 100% 65%;--primary-foreground: 220 13% 13%;--secondary: 217.2 32.6% 20%;--secondary-foreground: 210 40% 98%;--muted: 220 13% 20%;--muted-foreground: 215 15% 65%;--accent: 220 13% 20%;--accent-foreground: 210 40% 98%;--destructive: 0 70% 55%;--destructive-foreground: 210 40% 98%;--border: 220 13% 24%;--input: 220 13% 24%;--ring: 211 100% 65%}*{border-color:hsl(var(--border))}body{background-color:hsl(var(--background));color:hsl(var(--foreground));margin:0;min-height:100vh;font-feature-settings:"kern" 1,"liga" 1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}code,.tabular-nums{font-variant-numeric:tabular-nums;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.container{width:100%}@media(min-width:640px){.container{max-width:640px}}@media(min-width:768px){.container{max-width:768px}}@media(min-width:1024px){.container{max-width:1024px}}@media(min-width:1280px){.container{max-width:1280px}}@media(min-width:1536px){.container{max-width:1536px}}.visible{visibility:visible}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.bottom-0{bottom:0}.left-0{left:0}.right-0{right:0}.top-0{top:0}.z-50{z-index:50}.col-span-1{grid-column:span 1 / span 1}.col-span-2{grid-column:span 2 / span 2}.col-span-3{grid-column:span 3 / span 3}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-8{margin-top:2rem;margin-bottom:2rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-0\.5{height:.125rem}.h-12{height:3rem}.h-2{height:.5rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-8{height:2rem}.h-96{height:24rem}.h-auto{height:auto}.h-full{height:100%}.h-screen{height:100vh}.max-h-48{max-height:12rem}.max-h-64{max-height:16rem}.max-h-\[120px\]{max-height:120px}.max-h-\[450px\]{max-height:450px}.max-h-\[85vh\]{max-height:85vh}.max-h-\[90vh\]{max-height:90vh}.min-h-0{min-height:0px}.min-h-\[550px\]{min-height:550px}.min-h-screen{min-height:100vh}.w-12{width:3rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-64{width:16rem}.w-8{width:2rem}.w-80{width:20rem}.w-\[320px\]{width:320px}.w-\[580px\]{width:580px}.w-full{width:100%}.max-w-2xl{max-width:42rem}.max-w-7xl{max-width:80rem}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.cursor-pointer{cursor:pointer}.resize-none{resize:none}.resize-y{resize:vertical}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-x-3{-moz-column-gap:.75rem;column-gap:.75rem}.gap-y-1\.5{row-gap:.375rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse: 0;margin-right:calc(.5rem * var(--tw-space-x-reverse));margin-left:calc(.5rem * calc(1 - var(--tw-space-x-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-2\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.625rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.625rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l{border-left-width:1px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-amber-500\/20{border-color:#f59e0b33}.border-blue-500\/20{border-color:#3b82f633}.border-blue-500\/30{border-color:#3b82f64d}.border-border{border-color:hsl(var(--border))}.border-border\/50{border-color:hsl(var(--border) / .5)}.border-destructive\/30{border-color:hsl(var(--destructive) / .3)}.border-gray-200{--tw-border-opacity: 1;border-color:rgb(229 231 235 / var(--tw-border-opacity, 1))}.border-gray-300{--tw-border-opacity: 1;border-color:rgb(209 213 219 / var(--tw-border-opacity, 1))}.border-green-500\/20{border-color:#22c55e33}.border-input{border-color:hsl(var(--input))}.border-muted{border-color:hsl(var(--muted))}.border-muted-foreground\/20{border-color:hsl(var(--muted-foreground) / .2)}.border-primary{border-color:hsl(var(--primary))}.border-transparent{border-color:transparent}.border-yellow-200{--tw-border-opacity: 1;border-color:rgb(254 240 138 / var(--tw-border-opacity, 1))}.bg-accent{background-color:hsl(var(--accent))}.bg-amber-500\/10{background-color:#f59e0b1a}.bg-background{background-color:hsl(var(--background))}.bg-black\/50{background-color:#00000080}.bg-blue-500\/10{background-color:#3b82f61a}.bg-blue-500\/5{background-color:#3b82f60d}.bg-blue-600{--tw-bg-opacity: 1;background-color:rgb(37 99 235 / var(--tw-bg-opacity, 1))}.bg-card{background-color:hsl(var(--card))}.bg-gray-200{--tw-bg-opacity: 1;background-color:rgb(229 231 235 / var(--tw-bg-opacity, 1))}.bg-green-500\/10{background-color:#22c55e1a}.bg-green-600{--tw-bg-opacity: 1;background-color:rgb(22 163 74 / var(--tw-bg-opacity, 1))}.bg-muted{background-color:hsl(var(--muted))}.bg-muted\/20{background-color:hsl(var(--muted) / .2)}.bg-muted\/30{background-color:hsl(var(--muted) / .3)}.bg-muted\/50{background-color:hsl(var(--muted) / .5)}.bg-primary{background-color:hsl(var(--primary))}.bg-primary\/10{background-color:hsl(var(--primary) / .1)}.bg-primary\/5{background-color:hsl(var(--primary) / .05)}.bg-secondary{background-color:hsl(var(--secondary))}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.bg-yellow-50{--tw-bg-opacity: 1;background-color:rgb(254 252 232 / var(--tw-bg-opacity, 1))}.bg-yellow-600{--tw-bg-opacity: 1;background-color:rgb(202 138 4 / var(--tw-bg-opacity, 1))}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-2\.5{padding:.625rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.pb-1\.5{padding-bottom:.375rem}.pb-2{padding-bottom:.5rem}.pl-4{padding-left:1rem}.pr-2{padding-right:.5rem}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-6xl{font-size:3.75rem;line-height:1}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing: tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-tight{line-height:1.25}.tracking-wide{letter-spacing:.025em}.text-accent-foreground{color:hsl(var(--accent-foreground))}.text-amber-600{--tw-text-opacity: 1;color:rgb(217 119 6 / var(--tw-text-opacity, 1))}.text-amber-700{--tw-text-opacity: 1;color:rgb(180 83 9 / var(--tw-text-opacity, 1))}.text-blue-500{--tw-text-opacity: 1;color:rgb(59 130 246 / var(--tw-text-opacity, 1))}.text-blue-600{--tw-text-opacity: 1;color:rgb(37 99 235 / var(--tw-text-opacity, 1))}.text-destructive{color:hsl(var(--destructive))}.text-foreground{color:hsl(var(--foreground))}.text-gray-400{--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity, 1))}.text-gray-500{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity, 1))}.text-gray-700{--tw-text-opacity: 1;color:rgb(55 65 81 / var(--tw-text-opacity, 1))}.text-gray-800{--tw-text-opacity: 1;color:rgb(31 41 55 / var(--tw-text-opacity, 1))}.text-gray-900{--tw-text-opacity: 1;color:rgb(17 24 39 / var(--tw-text-opacity, 1))}.text-green-500{--tw-text-opacity: 1;color:rgb(34 197 94 / var(--tw-text-opacity, 1))}.text-green-600{--tw-text-opacity: 1;color:rgb(22 163 74 / var(--tw-text-opacity, 1))}.text-green-700{--tw-text-opacity: 1;color:rgb(21 128 61 / var(--tw-text-opacity, 1))}.text-muted-foreground{color:hsl(var(--muted-foreground))}.text-muted-foreground\/60{color:hsl(var(--muted-foreground) / .6)}.text-orange-600{--tw-text-opacity: 1;color:rgb(234 88 12 / var(--tw-text-opacity, 1))}.text-primary{color:hsl(var(--primary))}.text-primary-foreground{color:hsl(var(--primary-foreground))}.text-purple-500{--tw-text-opacity: 1;color:rgb(168 85 247 / var(--tw-text-opacity, 1))}.text-secondary-foreground{color:hsl(var(--secondary-foreground))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.text-yellow-500{--tw-text-opacity: 1;color:rgb(234 179 8 / var(--tw-text-opacity, 1))}.text-yellow-600{--tw-text-opacity: 1;color:rgb(202 138 4 / var(--tw-text-opacity, 1))}.text-yellow-700{--tw-text-opacity: 1;color:rgb(161 98 7 / var(--tw-text-opacity, 1))}.text-yellow-800{--tw-text-opacity: 1;color:rgb(133 77 14 / var(--tw-text-opacity, 1))}.text-yellow-900{--tw-text-opacity: 1;color:rgb(113 63 18 / var(--tw-text-opacity, 1))}.shadow-lg{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow: 0 20px 25px -5px rgb(0 0 0 / .1), 0 8px 10px -6px rgb(0 0 0 / .1);--tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:hsl(var(--muted-foreground) / .3);border-radius:4px}::-webkit-scrollbar-thumb:hover{background:hsl(var(--muted-foreground) / .5)}*{scrollbar-width:thin;scrollbar-color:hsl(var(--muted-foreground) / .3) transparent}.placeholder\:text-muted-foreground::-moz-placeholder{color:hsl(var(--muted-foreground))}.placeholder\:text-muted-foreground::placeholder{color:hsl(var(--muted-foreground))}.hover\:border-border:hover{border-color:hsl(var(--border))}.hover\:bg-accent:hover{background-color:hsl(var(--accent))}.hover\:bg-accent\/50:hover{background-color:hsl(var(--accent) / .5)}.hover\:bg-accent\/80:hover{background-color:hsl(var(--accent) / .8)}.hover\:bg-blue-700:hover{--tw-bg-opacity: 1;background-color:rgb(29 78 216 / var(--tw-bg-opacity, 1))}.hover\:bg-destructive\/10:hover{background-color:hsl(var(--destructive) / .1)}.hover\:bg-gray-300:hover{--tw-bg-opacity: 1;background-color:rgb(209 213 219 / var(--tw-bg-opacity, 1))}.hover\:bg-gray-50:hover{--tw-bg-opacity: 1;background-color:rgb(249 250 251 / var(--tw-bg-opacity, 1))}.hover\:bg-green-700:hover{--tw-bg-opacity: 1;background-color:rgb(21 128 61 / var(--tw-bg-opacity, 1))}.hover\:bg-muted\/5:hover{background-color:hsl(var(--muted) / .05)}.hover\:bg-muted\/50:hover{background-color:hsl(var(--muted) / .5)}.hover\:bg-muted\/80:hover{background-color:hsl(var(--muted) / .8)}.hover\:bg-primary\/90:hover{background-color:hsl(var(--primary) / .9)}.hover\:bg-secondary\/80:hover{background-color:hsl(var(--secondary) / .8)}.hover\:bg-secondary\/90:hover{background-color:hsl(var(--secondary) / .9)}.hover\:bg-yellow-700:hover{--tw-bg-opacity: 1;background-color:rgb(161 98 7 / var(--tw-bg-opacity, 1))}.hover\:text-destructive\/80:hover{color:hsl(var(--destructive) / .8)}.hover\:text-foreground:hover{color:hsl(var(--foreground))}.hover\:text-gray-600:hover{--tw-text-opacity: 1;color:rgb(75 85 99 / var(--tw-text-opacity, 1))}.hover\:text-primary\/80:hover{color:hsl(var(--primary) / .8)}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-blue-500:focus{--tw-ring-opacity: 1;--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity, 1))}.focus\:ring-primary\/50:focus{--tw-ring-color: hsl(var(--primary) / .5)}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.dark\:border-gray-600:is(.dark *){--tw-border-opacity: 1;border-color:rgb(75 85 99 / var(--tw-border-opacity, 1))}.dark\:border-gray-700:is(.dark *){--tw-border-opacity: 1;border-color:rgb(55 65 81 / var(--tw-border-opacity, 1))}.dark\:border-yellow-800:is(.dark *){--tw-border-opacity: 1;border-color:rgb(133 77 14 / var(--tw-border-opacity, 1))}.dark\:bg-gray-700:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(55 65 81 / var(--tw-bg-opacity, 1))}.dark\:bg-gray-800:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(31 41 55 / var(--tw-bg-opacity, 1))}.dark\:bg-yellow-900\/20:is(.dark *){background-color:#713f1233}.dark\:text-amber-400:is(.dark *){--tw-text-opacity: 1;color:rgb(251 191 36 / var(--tw-text-opacity, 1))}.dark\:text-amber-500:is(.dark *){--tw-text-opacity: 1;color:rgb(245 158 11 / var(--tw-text-opacity, 1))}.dark\:text-gray-200:is(.dark *){--tw-text-opacity: 1;color:rgb(229 231 235 / var(--tw-text-opacity, 1))}.dark\:text-gray-300:is(.dark *){--tw-text-opacity: 1;color:rgb(209 213 219 / var(--tw-text-opacity, 1))}.dark\:text-gray-400:is(.dark *){--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity, 1))}.dark\:text-green-500:is(.dark *){--tw-text-opacity: 1;color:rgb(34 197 94 / var(--tw-text-opacity, 1))}.dark\:text-white:is(.dark *){--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.dark\:text-yellow-100:is(.dark *){--tw-text-opacity: 1;color:rgb(254 249 195 / var(--tw-text-opacity, 1))}.dark\:text-yellow-200:is(.dark *){--tw-text-opacity: 1;color:rgb(254 240 138 / var(--tw-text-opacity, 1))}.dark\:text-yellow-300:is(.dark *){--tw-text-opacity: 1;color:rgb(253 224 71 / var(--tw-text-opacity, 1))}.dark\:text-yellow-500:is(.dark *){--tw-text-opacity: 1;color:rgb(234 179 8 / var(--tw-text-opacity, 1))}.dark\:hover\:bg-gray-600:hover:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(75 85 99 / var(--tw-bg-opacity, 1))}.dark\:hover\:text-gray-300:hover:is(.dark *){--tw-text-opacity: 1;color:rgb(209 213 219 / var(--tw-text-opacity, 1))}@media(min-width:768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media(min-width:1024px){.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}
|