1#!/usr/bin/env python3

2"""Stateless/Serverless Mode for Session Management MCP Server.

3

4 Enables request-scoped sessions with Oneiric storage backends and keeps session

5 state external to the request lifecycle.

6"""

7

8 import hashlib

9 import json

10 import logging

11 from datetime import datetime

12 from pathlib import Path

13 from typing import Any

14

15 from session_buddy.adapters.serverless_storage_adapter import ServerlessStorageAdapter

16 from session_buddy.backends import SessionState, SessionStorage

17

18 CONFIG_LOGGER = logging.getLogger("serverless.config")

19

20

21 class ServerlessSessionManager:

22 """Main session manager for serverless/stateless operation."""

23

24 def __init__(self, storage_backend: SessionStorage) -> None:

25 self.storage = storage_backend

26 self.logger = logging.getLogger("serverless.session_manager")

27 self.session_cache: dict[

28 str,

29 SessionState,

30 ] = {} # In-memory cache for current request

31

32 async def create_session(

33 self,

34 user_id: str,

35 project_id: str,

36 session_data: dict[str, Any] | None = None,

37 ttl_hours: int = 24,

38 ) -> str:

39 """Create new session."""

40 session_id = self._generate_session_id(user_id, project_id)

41

42 session_state = SessionState(

43 session_id=session_id,

44 user_id=user_id,

45 project_id=project_id,

46 created_at=datetime.now().isoformat(),

47 last_activity=datetime.now().isoformat(),

48 permissions=[],

49 conversation_history=[],

50 reflection_data={},

51 app_monitoring_state={},

52 llm_provider_configs={},

53 metadata=session_data or {},

54 )

55

56 # Store with TTL

57 ttl_seconds = ttl_hours * 3600

58 success = await self.storage.store_session(session_state, ttl_seconds)

59

60 if success: 60 ↛ 63 line 60 didn't jump to line 63 because the condition on line 60 was always true

61 self.session_cache[session_id] = session_state

62 return session_id

63 msg = "Failed to create session"

64 raise RuntimeError(msg)

65

66 async def get_session(self, session_id: str) -> SessionState | None:

67 """Get session state."""

68 # Check cache first

69 if session_id in self.session_cache: 69 ↛ 73 line 69 didn't jump to line 73 because the condition on line 69 was always true

70 return self.session_cache[session_id]

71

72 # Load from storage

73 session_state = await self.storage.retrieve_session(session_id)

74 if session_state:

75 self.session_cache[session_id] = session_state

76

77 return session_state

78

79 async def update_session(

80 self,

81 session_id: str,

82 updates: dict[str, Any],

83 ttl_hours: int | None = None,

84 ) -> bool:

85 """Update session state."""

86 session_state = await self.get_session(session_id)

87 if not session_state: 87 ↛ 88 line 87 didn't jump to line 88 because the condition on line 87 was never true

88 return False

89

90 # Apply updates

91 for key, value in updates.items():

92 if hasattr(session_state, key): 92 ↛ 91 line 92 didn't jump to line 91 because the condition on line 92 was always true

93 setattr(session_state, key, value)

94

95 # Update last activity

96 session_state.last_activity = datetime.now().isoformat()

97

98 # Store updated state

99 ttl_seconds = ttl_hours * 3600 if ttl_hours else None

100 success = await self.storage.store_session(session_state, ttl_seconds)

101

102 if success: 102 ↛ 105 line 102 didn't jump to line 105 because the condition on line 102 was always true

103 self.session_cache[session_id] = session_state

104

105 return success

106

107 async def delete_session(self, session_id: str) -> bool:

108 """Delete session."""

109 # Remove from cache

110 self.session_cache.pop(session_id, None)

111

112 # Delete from storage

113 return await self.storage.delete_session(session_id)

114

115 async def list_user_sessions(self, user_id: str) -> list[str]:

116 """List sessions for user."""

117 return await self.storage.list_sessions(user_id=user_id)

118

119 async def list_project_sessions(self, project_id: str) -> list[str]:

120 """List sessions for project."""

121 return await self.storage.list_sessions(project_id=project_id)

122

123 async def cleanup_sessions(self) -> int:

124 """Clean up expired sessions."""

125 return await self.storage.cleanup_expired_sessions()

126

127 def _generate_session_id(self, user_id: str, project_id: str) -> str:

128 """Generate unique session ID."""

129 timestamp = datetime.now().isoformat()

130 data = f"{user_id}:{project_id}:{timestamp}"

131 return hashlib.sha256(data.encode()).hexdigest()[:16]

132

133 def get_session_stats(self) -> dict[str, Any]:

134 """Get session statistics."""

135 return {

136 "cached_sessions": len(self.session_cache),

137 "storage_backend": self.storage.__class__.__name__,

138 "storage_config": {

139 k: v for k, v in self.storage.config.items() if "key" not in k.lower()

140 },

141 }

142

143

144 class ServerlessConfigManager:

145 """Manages configuration for serverless mode."""

146

147 @staticmethod

148 def load_config(config_path: str | None = None) -> dict[str, Any]:

149 """Load serverless configuration."""

150 default_config = {

151 "storage_backend": "file",

152 "session_ttl_hours": 24,

153 "cleanup_interval_hours": 6,

154 "backends": {

155 "file": {

156 "storage_dir": str(Path.home() / ".claude" / "data" / "sessions"),

157 },

158 "memory": {},

159 },

160 }

161

162 if config_path and Path(config_path).exists():

163 try:

164 with open(config_path, encoding="utf-8") as f:

165 file_config = json.load(f)

166 default_config.update(file_config)

167 except (OSError, json.JSONDecodeError):

168 pass

169

170 return default_config

171

172 @staticmethod

173 def create_storage_backend(config: dict[str, Any]) -> SessionStorage:

174 """Create storage backend from config.

175

176 Supports Oneiric storage adapters registered in the storage registry.

177

178 Recommended backends:

179 - "file": Local file storage (default, best for development)

180 - "memory": In-memory storage (testing only)

181 """

182 backend_type = config.get("storage_backend", "file") # Default to file

183 backend_config = config.get("backends", {}).get(backend_type, {})

184

185 if backend_type in ("file", "memory"):

186 CONFIG_LOGGER.info(

187 f"Using Oneiric storage adapter: {backend_type}",

188 )

189 return ServerlessStorageAdapter(config=backend_config, backend=backend_type)

190

191 msg = (

192 f"Unsupported storage backend: {backend_type}. "

193 "Supported backends: file, memory."

194 )

195 raise ValueError(msg)

196

197 @staticmethod

198 async def test_storage_backends(config: dict[str, Any]) -> dict[str, bool]:

199 """Test all configured storage backends."""

200 results: dict[str, bool] = {}

201

202 for backend_name, backend_config in config.get("backends", {}).items():

203 try:

204 storage: SessionStorage

205 match backend_name:

206 case "file" | "memory": 206 ↛ 210 line 206 didn't jump to line 210 because the pattern on line 206 always matched

207 storage = ServerlessStorageAdapter(

208 config=backend_config, backend=backend_name

209 )

210 case _:

211 results[backend_name] = False

212 continue

213

214 results[backend_name] = await storage.is_available()

215

216 except Exception:

217 results[backend_name] = False

218

219 return results