Spaces:
Runtime error
Runtime error
Apurva Tiwari commited on
Commit ·
602eeec
1
Parent(s): 19c073e
Implement simplified session manager with persistence
Browse files- New SimpleSessionManager: thread-safe in-memory with filesystem persistence
- JSON index for session metadata and alias mapping
- Automatic cleanup of idle sessions (>6 hours)
- Human-readable session aliases
- Much simpler and more maintainable than previous complex system
- Updated app.py to use new manager
- app.py +40 -82
- simple_session_manager.py +270 -0
app.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
"""
|
| 2 |
-
Quantum Applications - Unified Single-Server App with
|
| 3 |
|
| 4 |
This app provides EM scattering and QLBM pages in a single Trame server
|
| 5 |
-
with
|
| 6 |
"""
|
| 7 |
import os
|
| 8 |
import errno
|
|
@@ -17,30 +17,12 @@ import threading
|
|
| 17 |
import time
|
| 18 |
import base64
|
| 19 |
|
| 20 |
-
from
|
| 21 |
-
from landing_page import build_landing_page_ui
|
| 22 |
|
| 23 |
# Create a single server for the entire app
|
| 24 |
server = get_server()
|
| 25 |
state, ctrl = server.state, server.controller
|
| 26 |
|
| 27 |
-
# --- User & Session Management ---
|
| 28 |
-
# Generate or retrieve user ID (in production, use HF auth)
|
| 29 |
-
def _get_or_create_user_id():
|
| 30 |
-
"""Get user ID from HF auth or create a session-specific one."""
|
| 31 |
-
import secrets
|
| 32 |
-
|
| 33 |
-
# Try to get HF username from environment (when using HF launcher)
|
| 34 |
-
hf_user = os.environ.get("HF_USER")
|
| 35 |
-
if hf_user:
|
| 36 |
-
return hf_user
|
| 37 |
-
|
| 38 |
-
# Fallback: create a unique ID per session
|
| 39 |
-
return secrets.token_hex(8)
|
| 40 |
-
|
| 41 |
-
user_id = _get_or_create_user_id()
|
| 42 |
-
session_integration = SessionIntegration(user_id)
|
| 43 |
-
|
| 44 |
# --- App state
|
| 45 |
state.current_page = None # None = landing, "EM" or "QLBM"
|
| 46 |
state.session_active = False
|
|
@@ -89,43 +71,29 @@ em.init_state()
|
|
| 89 |
em.register_handlers()
|
| 90 |
|
| 91 |
# --- Session Callbacks ---
|
| 92 |
-
def on_session_selected(session_id: str
|
| 93 |
"""Callback when user selects a session from landing page."""
|
| 94 |
try:
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
state.current_session_id = session_id
|
| 97 |
-
state.session_alias =
|
| 98 |
state.session_active = True
|
| 99 |
-
|
| 100 |
-
# Restore app state from session
|
| 101 |
-
session_integration.restore_to_trame_state(state)
|
| 102 |
-
|
| 103 |
-
print(f"Session loaded: {metadata.alias} ({app_type})")
|
| 104 |
except Exception as e:
|
| 105 |
-
print(f"Error
|
|
|
|
| 106 |
state.session_active = False
|
| 107 |
|
| 108 |
-
def on_app_selected(app_type: str):
|
| 109 |
-
"""Callback when user selects an app after session."""
|
| 110 |
-
state.current_page = app_type
|
| 111 |
-
|
| 112 |
-
|
| 113 |
# --- Controller to reset UI on page load ---
|
| 114 |
@ctrl.add("reset_ui")
|
| 115 |
def _reset_ui():
|
| 116 |
-
"""Reset UI to landing page (called when browser
|
| 117 |
-
print(f"[RESET_UI]
|
| 118 |
-
print(f" current_page={state.current_page}, session_active={state.session_active}")
|
| 119 |
|
| 120 |
-
# Save current session before resetting if one is active
|
| 121 |
-
if state.session_active and session_integration.current_session_id:
|
| 122 |
-
try:
|
| 123 |
-
session_integration.save_current_session(state)
|
| 124 |
-
print(f"[RESET_UI] Session saved: {state.session_alias}")
|
| 125 |
-
except Exception as e:
|
| 126 |
-
print(f"[RESET_UI WARN] Could not save session: {e}")
|
| 127 |
-
|
| 128 |
-
# Reset all UI and session state to landing page
|
| 129 |
state.current_page = None
|
| 130 |
state.session_card_visible = True
|
| 131 |
state.session_active = False
|
|
@@ -134,13 +102,6 @@ def _reset_ui():
|
|
| 134 |
state.session_alias_input = ""
|
| 135 |
state.session_error = ""
|
| 136 |
state.session_action_trigger = None
|
| 137 |
-
|
| 138 |
-
# Clear session integration
|
| 139 |
-
session_integration.current_session_id = None
|
| 140 |
-
session_integration.current_metadata = None
|
| 141 |
-
session_integration.current_state = None
|
| 142 |
-
|
| 143 |
-
print(f"[RESET_UI] State reset. New state: current_page={state.current_page}")
|
| 144 |
|
| 145 |
|
| 146 |
@ctrl.add("on_page_load")
|
|
@@ -150,21 +111,17 @@ def _on_page_load(is_new_session: bool):
|
|
| 150 |
if is_new_session:
|
| 151 |
print(f"[PAGE_LOAD] New session detected. Resetting to landing page.")
|
| 152 |
_reset_ui()
|
| 153 |
-
else:
|
| 154 |
-
print(f"[PAGE_LOAD] Page refresh detected. Keeping current state.")
|
| 155 |
-
print(f"[PAGE_LOAD] Current state: current_page={state.current_page}, session_active={state.session_active}")
|
| 156 |
|
| 157 |
|
| 158 |
# Watcher to reset navigation when no session is active
|
| 159 |
-
# (handles case where user closes browser and comes back)
|
| 160 |
@state.change("session_active")
|
| 161 |
def _on_session_status_change(session_active, **kwargs):
|
| 162 |
"""Reset landing page if session becomes inactive."""
|
| 163 |
if not session_active and state.current_page is not None:
|
| 164 |
-
# Session became inactive; reset to landing page
|
| 165 |
state.current_page = None
|
| 166 |
state.session_card_visible = True
|
| 167 |
-
print("[
|
|
|
|
| 168 |
|
| 169 |
# --- Session action watchers (respond to state changes from UI) ---
|
| 170 |
@state.change("session_action_trigger")
|
|
@@ -174,32 +131,34 @@ def _on_session_action(session_action_trigger, **kwargs):
|
|
| 174 |
try:
|
| 175 |
state.session_action_busy = True
|
| 176 |
alias = state.session_alias_input.strip() if state.session_alias_input else f"session-{uuid.uuid4().hex[:6]}"
|
| 177 |
-
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
state.session_active = True
|
| 180 |
state.current_session_id = session_id
|
| 181 |
-
state.current_page = None
|
| 182 |
-
state.session_alias_input = ""
|
| 183 |
state.session_action_success = True
|
| 184 |
state.session_error = ""
|
| 185 |
-
print(f"[
|
| 186 |
|
| 187 |
-
#
|
| 188 |
def _hide():
|
| 189 |
-
time.sleep(1.5)
|
| 190 |
state.session_card_visible = False
|
| 191 |
state.session_action_success = False
|
| 192 |
state.session_action_busy = False
|
| 193 |
state.session_action_trigger = None
|
| 194 |
-
print("[OK] Card hidden")
|
| 195 |
|
| 196 |
threading.Thread(target=_hide, daemon=True).start()
|
| 197 |
-
return True
|
| 198 |
except Exception as e:
|
| 199 |
state.session_error = str(e)
|
| 200 |
state.session_action_busy = False
|
| 201 |
state.session_action_trigger = None
|
| 202 |
-
print(f"[
|
| 203 |
|
| 204 |
elif session_action_trigger == "load":
|
| 205 |
try:
|
|
@@ -211,38 +170,37 @@ def _on_session_action(session_action_trigger, **kwargs):
|
|
| 211 |
state.session_action_trigger = None
|
| 212 |
return
|
| 213 |
|
| 214 |
-
|
| 215 |
-
|
|
|
|
| 216 |
state.session_error = f"No session found with alias '{alias}'."
|
| 217 |
state.session_action_busy = False
|
| 218 |
state.session_action_trigger = None
|
| 219 |
return
|
| 220 |
|
| 221 |
-
|
| 222 |
-
state.session_alias =
|
| 223 |
state.session_active = True
|
| 224 |
-
state.current_session_id =
|
| 225 |
-
state.current_page = None
|
| 226 |
-
state.session_alias_input = ""
|
| 227 |
-
session_integration.restore_to_trame_state(state)
|
| 228 |
state.session_action_success = True
|
| 229 |
state.session_error = ""
|
| 230 |
-
print(f"[
|
| 231 |
|
| 232 |
def _hide_load():
|
| 233 |
-
time.sleep(1.5)
|
| 234 |
state.session_card_visible = False
|
| 235 |
state.session_action_success = False
|
| 236 |
state.session_action_busy = False
|
| 237 |
state.session_action_trigger = None
|
| 238 |
|
| 239 |
threading.Thread(target=_hide_load, daemon=True).start()
|
| 240 |
-
return True
|
| 241 |
except Exception as e:
|
| 242 |
state.session_error = str(e)
|
| 243 |
state.session_action_busy = False
|
| 244 |
state.session_action_trigger = None
|
| 245 |
-
print(f"[
|
| 246 |
|
| 247 |
|
| 248 |
# --- Build the Layout ---
|
|
|
|
| 1 |
"""
|
| 2 |
+
Quantum Applications - Unified Single-Server App with Simplified Session Management
|
| 3 |
|
| 4 |
This app provides EM scattering and QLBM pages in a single Trame server
|
| 5 |
+
with simplified session management based on filesystem directories and persistent indices.
|
| 6 |
"""
|
| 7 |
import os
|
| 8 |
import errno
|
|
|
|
| 17 |
import time
|
| 18 |
import base64
|
| 19 |
|
| 20 |
+
from simple_session_manager import SESSION_MANAGER, get_session_by_alias
|
|
|
|
| 21 |
|
| 22 |
# Create a single server for the entire app
|
| 23 |
server = get_server()
|
| 24 |
state, ctrl = server.state, server.controller
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
# --- App state
|
| 27 |
state.current_page = None # None = landing, "EM" or "QLBM"
|
| 28 |
state.session_active = False
|
|
|
|
| 71 |
em.register_handlers()
|
| 72 |
|
| 73 |
# --- Session Callbacks ---
|
| 74 |
+
def on_session_selected(session_id: str):
|
| 75 |
"""Callback when user selects a session from landing page."""
|
| 76 |
try:
|
| 77 |
+
session_info = SESSION_MANAGER.get_session(session_id=session_id)
|
| 78 |
+
if not session_info:
|
| 79 |
+
state.session_error = f"Session not found: {session_id}"
|
| 80 |
+
return
|
| 81 |
+
|
| 82 |
state.current_session_id = session_id
|
| 83 |
+
state.session_alias = session_info.get("alias", "Untitled")
|
| 84 |
state.session_active = True
|
| 85 |
+
print(f"[APP] Session selected: {state.session_alias} ({session_id})")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
except Exception as e:
|
| 87 |
+
print(f"[APP] Error selecting session: {e}")
|
| 88 |
+
state.session_error = str(e)
|
| 89 |
state.session_active = False
|
| 90 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
# --- Controller to reset UI on page load ---
|
| 92 |
@ctrl.add("reset_ui")
|
| 93 |
def _reset_ui():
|
| 94 |
+
"""Reset UI to landing page (called when browser closes/reopens)."""
|
| 95 |
+
print(f"[RESET_UI] Resetting to landing page")
|
|
|
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
state.current_page = None
|
| 98 |
state.session_card_visible = True
|
| 99 |
state.session_active = False
|
|
|
|
| 102 |
state.session_alias_input = ""
|
| 103 |
state.session_error = ""
|
| 104 |
state.session_action_trigger = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
|
| 107 |
@ctrl.add("on_page_load")
|
|
|
|
| 111 |
if is_new_session:
|
| 112 |
print(f"[PAGE_LOAD] New session detected. Resetting to landing page.")
|
| 113 |
_reset_ui()
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
|
| 116 |
# Watcher to reset navigation when no session is active
|
|
|
|
| 117 |
@state.change("session_active")
|
| 118 |
def _on_session_status_change(session_active, **kwargs):
|
| 119 |
"""Reset landing page if session becomes inactive."""
|
| 120 |
if not session_active and state.current_page is not None:
|
|
|
|
| 121 |
state.current_page = None
|
| 122 |
state.session_card_visible = True
|
| 123 |
+
print("[APP] Session inactive; reset to landing page")
|
| 124 |
+
|
| 125 |
|
| 126 |
# --- Session action watchers (respond to state changes from UI) ---
|
| 127 |
@state.change("session_action_trigger")
|
|
|
|
| 131 |
try:
|
| 132 |
state.session_action_busy = True
|
| 133 |
alias = state.session_alias_input.strip() if state.session_alias_input else f"session-{uuid.uuid4().hex[:6]}"
|
| 134 |
+
|
| 135 |
+
# Create new session
|
| 136 |
+
session_info = SESSION_MANAGER.create_session(alias=alias)
|
| 137 |
+
session_id = session_info["session_id"]
|
| 138 |
+
|
| 139 |
+
state.session_alias = session_info.get("alias", "Untitled")
|
| 140 |
state.session_active = True
|
| 141 |
state.current_session_id = session_id
|
| 142 |
+
state.current_page = None
|
| 143 |
+
state.session_alias_input = ""
|
| 144 |
state.session_action_success = True
|
| 145 |
state.session_error = ""
|
| 146 |
+
print(f"[APP] Session created: {alias} ({session_id})")
|
| 147 |
|
| 148 |
+
# Hide card after success animation
|
| 149 |
def _hide():
|
| 150 |
+
time.sleep(1.5)
|
| 151 |
state.session_card_visible = False
|
| 152 |
state.session_action_success = False
|
| 153 |
state.session_action_busy = False
|
| 154 |
state.session_action_trigger = None
|
|
|
|
| 155 |
|
| 156 |
threading.Thread(target=_hide, daemon=True).start()
|
|
|
|
| 157 |
except Exception as e:
|
| 158 |
state.session_error = str(e)
|
| 159 |
state.session_action_busy = False
|
| 160 |
state.session_action_trigger = None
|
| 161 |
+
print(f"[APP] Error creating session: {e}")
|
| 162 |
|
| 163 |
elif session_action_trigger == "load":
|
| 164 |
try:
|
|
|
|
| 170 |
state.session_action_trigger = None
|
| 171 |
return
|
| 172 |
|
| 173 |
+
# Load session by alias
|
| 174 |
+
session_info = get_session_by_alias(alias)
|
| 175 |
+
if not session_info:
|
| 176 |
state.session_error = f"No session found with alias '{alias}'."
|
| 177 |
state.session_action_busy = False
|
| 178 |
state.session_action_trigger = None
|
| 179 |
return
|
| 180 |
|
| 181 |
+
session_id = session_info["session_id"]
|
| 182 |
+
state.session_alias = session_info.get("alias", "Untitled")
|
| 183 |
state.session_active = True
|
| 184 |
+
state.current_session_id = session_id
|
| 185 |
+
state.current_page = None
|
| 186 |
+
state.session_alias_input = ""
|
|
|
|
| 187 |
state.session_action_success = True
|
| 188 |
state.session_error = ""
|
| 189 |
+
print(f"[APP] Session loaded: {alias} ({session_id})")
|
| 190 |
|
| 191 |
def _hide_load():
|
| 192 |
+
time.sleep(1.5)
|
| 193 |
state.session_card_visible = False
|
| 194 |
state.session_action_success = False
|
| 195 |
state.session_action_busy = False
|
| 196 |
state.session_action_trigger = None
|
| 197 |
|
| 198 |
threading.Thread(target=_hide_load, daemon=True).start()
|
|
|
|
| 199 |
except Exception as e:
|
| 200 |
state.session_error = str(e)
|
| 201 |
state.session_action_busy = False
|
| 202 |
state.session_action_trigger = None
|
| 203 |
+
print(f"[APP] Error loading session: {e}")
|
| 204 |
|
| 205 |
|
| 206 |
# --- Build the Layout ---
|
simple_session_manager.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Simplified Session Manager (inspired by Udbhav's approach)
|
| 3 |
+
|
| 4 |
+
Features:
|
| 5 |
+
- In-memory session tracking with thread-safe RLock
|
| 6 |
+
- Simple filesystem-based storage (one directory per session)
|
| 7 |
+
- Persistent session index (JSON) and alias map
|
| 8 |
+
- Automatic cleanup of idle sessions (>6 hours)
|
| 9 |
+
- Human-readable aliases for sessions
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
import json
|
| 14 |
+
import shutil
|
| 15 |
+
import threading
|
| 16 |
+
import uuid
|
| 17 |
+
import time
|
| 18 |
+
from datetime import datetime, timedelta
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
from typing import Dict, Optional
|
| 21 |
+
|
| 22 |
+
# Base directory for all sessions (HF persistent storage)
|
| 23 |
+
BASE_DATA_DIR = Path("/tmp/outputs/sessions")
|
| 24 |
+
|
| 25 |
+
# Session index file (persisted to disk)
|
| 26 |
+
SESSIONS_INDEX_FILE = BASE_DATA_DIR / "sessions_index.json"
|
| 27 |
+
|
| 28 |
+
# Alias map file (maps friendly names to session IDs)
|
| 29 |
+
ALIAS_MAP_FILE = BASE_DATA_DIR / "alias_map.json"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class SimpleSessionManager:
|
| 33 |
+
"""
|
| 34 |
+
Thread-safe session manager with persistence and auto-cleanup.
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
def __init__(self):
|
| 38 |
+
self.sessions: Dict[str, dict] = {}
|
| 39 |
+
self.alias_map: Dict[str, str] = {} # alias -> session_id
|
| 40 |
+
self._lock = threading.RLock()
|
| 41 |
+
self._cleanup_thread = None
|
| 42 |
+
|
| 43 |
+
# Ensure directories exist
|
| 44 |
+
BASE_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 45 |
+
print(f"[SESSION] Base directory: {BASE_DATA_DIR}")
|
| 46 |
+
|
| 47 |
+
# Load persisted data
|
| 48 |
+
self._load_index()
|
| 49 |
+
self._load_alias_map()
|
| 50 |
+
|
| 51 |
+
# Start cleanup thread
|
| 52 |
+
self._start_cleanup_thread()
|
| 53 |
+
|
| 54 |
+
def _load_index(self):
|
| 55 |
+
"""Load sessions index from disk."""
|
| 56 |
+
if SESSIONS_INDEX_FILE.exists():
|
| 57 |
+
try:
|
| 58 |
+
with open(SESSIONS_INDEX_FILE, 'r') as f:
|
| 59 |
+
data = json.load(f)
|
| 60 |
+
self.sessions = data
|
| 61 |
+
print(f"[SESSION] Loaded {len(self.sessions)} sessions from index")
|
| 62 |
+
except Exception as e:
|
| 63 |
+
print(f"[SESSION] Error loading index: {e}")
|
| 64 |
+
|
| 65 |
+
def _load_alias_map(self):
|
| 66 |
+
"""Load alias map from disk."""
|
| 67 |
+
if ALIAS_MAP_FILE.exists():
|
| 68 |
+
try:
|
| 69 |
+
with open(ALIAS_MAP_FILE, 'r') as f:
|
| 70 |
+
self.alias_map = json.load(f)
|
| 71 |
+
print(f"[SESSION] Loaded {len(self.alias_map)} aliases")
|
| 72 |
+
except Exception as e:
|
| 73 |
+
print(f"[SESSION] Error loading alias map: {e}")
|
| 74 |
+
|
| 75 |
+
def _save_index(self):
|
| 76 |
+
"""Save sessions index to disk."""
|
| 77 |
+
try:
|
| 78 |
+
SESSIONS_INDEX_FILE.parent.mkdir(parents=True, exist_ok=True)
|
| 79 |
+
with open(SESSIONS_INDEX_FILE, 'w') as f:
|
| 80 |
+
json.dump(self.sessions, f, indent=2, default=str)
|
| 81 |
+
except Exception as e:
|
| 82 |
+
print(f"[SESSION] Error saving index: {e}")
|
| 83 |
+
|
| 84 |
+
def _save_alias_map(self):
|
| 85 |
+
"""Save alias map to disk."""
|
| 86 |
+
try:
|
| 87 |
+
ALIAS_MAP_FILE.parent.mkdir(parents=True, exist_ok=True)
|
| 88 |
+
with open(ALIAS_MAP_FILE, 'w') as f:
|
| 89 |
+
json.dump(self.alias_map, f, indent=2)
|
| 90 |
+
except Exception as e:
|
| 91 |
+
print(f"[SESSION] Error saving alias map: {e}")
|
| 92 |
+
|
| 93 |
+
def _new_session(self, session_id: Optional[str] = None, alias: Optional[str] = None) -> Dict:
|
| 94 |
+
"""Create a new session directory structure."""
|
| 95 |
+
if session_id is None:
|
| 96 |
+
session_id = str(uuid.uuid4())
|
| 97 |
+
|
| 98 |
+
session_root = BASE_DATA_DIR / f"user_{session_id}"
|
| 99 |
+
|
| 100 |
+
# Create subdirectories
|
| 101 |
+
subdirs = {
|
| 102 |
+
"data": session_root / "data",
|
| 103 |
+
"cache": session_root / "cache",
|
| 104 |
+
"results": session_root / "results",
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
for dir_path in subdirs.values():
|
| 108 |
+
dir_path.mkdir(parents=True, exist_ok=True)
|
| 109 |
+
|
| 110 |
+
session_info = {
|
| 111 |
+
"session_id": session_id,
|
| 112 |
+
"root": str(session_root),
|
| 113 |
+
"subdirs": {k: str(v) for k, v in subdirs.items()},
|
| 114 |
+
"created_at": datetime.now().isoformat(),
|
| 115 |
+
"last_accessed": datetime.now().isoformat(),
|
| 116 |
+
"alias": alias,
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
print(f"[SESSION] Created new session: {session_id} (alias: {alias})")
|
| 120 |
+
return session_info
|
| 121 |
+
|
| 122 |
+
def create_session(self, alias: Optional[str] = None) -> Dict:
|
| 123 |
+
"""
|
| 124 |
+
Create a new session with optional alias.
|
| 125 |
+
|
| 126 |
+
Args:
|
| 127 |
+
alias: Human-readable name for the session
|
| 128 |
+
|
| 129 |
+
Returns:
|
| 130 |
+
Session info dict
|
| 131 |
+
"""
|
| 132 |
+
with self._lock:
|
| 133 |
+
# If alias exists, return existing session
|
| 134 |
+
if alias and alias in self.alias_map:
|
| 135 |
+
session_id = self.alias_map[alias]
|
| 136 |
+
if session_id in self.sessions:
|
| 137 |
+
print(f"[SESSION] Session '{alias}' already exists: {session_id}")
|
| 138 |
+
self.sessions[session_id]["last_accessed"] = datetime.now().isoformat()
|
| 139 |
+
self._save_index()
|
| 140 |
+
return self.sessions[session_id]
|
| 141 |
+
|
| 142 |
+
# Create new session
|
| 143 |
+
session_id = str(uuid.uuid4())
|
| 144 |
+
session_info = self._new_session(session_id, alias)
|
| 145 |
+
|
| 146 |
+
self.sessions[session_id] = session_info
|
| 147 |
+
|
| 148 |
+
if alias:
|
| 149 |
+
self.alias_map[alias] = session_id
|
| 150 |
+
self._save_alias_map()
|
| 151 |
+
|
| 152 |
+
self._save_index()
|
| 153 |
+
return session_info
|
| 154 |
+
|
| 155 |
+
def get_session(self, session_id: Optional[str] = None, alias: Optional[str] = None) -> Optional[Dict]:
|
| 156 |
+
"""
|
| 157 |
+
Get session by ID or alias.
|
| 158 |
+
|
| 159 |
+
Args:
|
| 160 |
+
session_id: Session UUID
|
| 161 |
+
alias: Human-readable session name
|
| 162 |
+
|
| 163 |
+
Returns:
|
| 164 |
+
Session info dict or None
|
| 165 |
+
"""
|
| 166 |
+
with self._lock:
|
| 167 |
+
# Lookup by alias first
|
| 168 |
+
if alias:
|
| 169 |
+
session_id = self.alias_map.get(alias)
|
| 170 |
+
|
| 171 |
+
if not session_id:
|
| 172 |
+
return None
|
| 173 |
+
|
| 174 |
+
session_info = self.sessions.get(session_id)
|
| 175 |
+
if session_info:
|
| 176 |
+
session_info["last_accessed"] = datetime.now().isoformat()
|
| 177 |
+
self._save_index()
|
| 178 |
+
|
| 179 |
+
return session_info
|
| 180 |
+
|
| 181 |
+
def list_sessions(self) -> Dict[str, Dict]:
|
| 182 |
+
"""List all active sessions."""
|
| 183 |
+
with self._lock:
|
| 184 |
+
return {alias: self.sessions[sid] for alias, sid in self.alias_map.items() if sid in self.sessions}
|
| 185 |
+
|
| 186 |
+
def delete_session(self, session_id: str) -> bool:
|
| 187 |
+
"""Delete a session and its files."""
|
| 188 |
+
with self._lock:
|
| 189 |
+
session_info = self.sessions.pop(session_id, None)
|
| 190 |
+
if not session_info:
|
| 191 |
+
return False
|
| 192 |
+
|
| 193 |
+
# Remove alias mapping
|
| 194 |
+
for alias, sid in list(self.alias_map.items()):
|
| 195 |
+
if sid == session_id:
|
| 196 |
+
self.alias_map.pop(alias)
|
| 197 |
+
|
| 198 |
+
# Remove directory
|
| 199 |
+
session_root = Path(session_info["root"])
|
| 200 |
+
try:
|
| 201 |
+
if session_root.exists():
|
| 202 |
+
shutil.rmtree(session_root)
|
| 203 |
+
print(f"[SESSION] Deleted session directory: {session_root}")
|
| 204 |
+
except Exception as e:
|
| 205 |
+
print(f"[SESSION] Error deleting {session_root}: {e}")
|
| 206 |
+
|
| 207 |
+
self._save_index()
|
| 208 |
+
self._save_alias_map()
|
| 209 |
+
return True
|
| 210 |
+
|
| 211 |
+
def _cleanup_old(self, max_age_hours: int = 6):
|
| 212 |
+
"""Remove sessions idle longer than max_age_hours."""
|
| 213 |
+
now = datetime.now()
|
| 214 |
+
to_remove = []
|
| 215 |
+
|
| 216 |
+
with self._lock:
|
| 217 |
+
for session_id, session_info in self.sessions.items():
|
| 218 |
+
last_accessed = datetime.fromisoformat(session_info["last_accessed"])
|
| 219 |
+
if now - last_accessed > timedelta(hours=max_age_hours):
|
| 220 |
+
to_remove.append(session_id)
|
| 221 |
+
|
| 222 |
+
for session_id in to_remove:
|
| 223 |
+
print(f"[SESSION] Cleaning up idle session: {session_id}")
|
| 224 |
+
self.delete_session(session_id)
|
| 225 |
+
|
| 226 |
+
def _start_cleanup_thread(self):
|
| 227 |
+
"""Start background cleanup thread."""
|
| 228 |
+
if self._cleanup_thread and self._cleanup_thread.is_alive():
|
| 229 |
+
return
|
| 230 |
+
|
| 231 |
+
def loop():
|
| 232 |
+
while True:
|
| 233 |
+
try:
|
| 234 |
+
# Run cleanup every hour, remove sessions idle > 6 hours
|
| 235 |
+
time.sleep(3600)
|
| 236 |
+
self._cleanup_old(max_age_hours=6)
|
| 237 |
+
except Exception as e:
|
| 238 |
+
print(f"[SESSION] Cleanup error: {e}")
|
| 239 |
+
|
| 240 |
+
self._cleanup_thread = threading.Thread(target=loop, daemon=True)
|
| 241 |
+
self._cleanup_thread.start()
|
| 242 |
+
print("[SESSION] Cleanup thread started")
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
# Global session manager instance
|
| 246 |
+
SESSION_MANAGER = SimpleSessionManager()
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def get_or_create_session(alias: Optional[str] = None) -> Dict:
|
| 250 |
+
"""Convenience function to get or create a session."""
|
| 251 |
+
if alias:
|
| 252 |
+
session = SESSION_MANAGER.get_session(alias=alias)
|
| 253 |
+
if session:
|
| 254 |
+
return session
|
| 255 |
+
return SESSION_MANAGER.create_session(alias=alias)
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def get_session(session_id: str) -> Optional[Dict]:
|
| 259 |
+
"""Get session by ID."""
|
| 260 |
+
return SESSION_MANAGER.get_session(session_id=session_id)
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def get_session_by_alias(alias: str) -> Optional[Dict]:
|
| 264 |
+
"""Get session by alias."""
|
| 265 |
+
return SESSION_MANAGER.get_session(alias=alias)
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def list_all_sessions() -> Dict[str, Dict]:
|
| 269 |
+
"""List all sessions."""
|
| 270 |
+
return SESSION_MANAGER.list_sessions()
|