Aksel Joonas Reedi commited on
Commit
d9d9785
·
unverified ·
1 Parent(s): 0f5b5cc

Persist Space sessions in MongoDB (#170)

Browse files

* Persist Space sessions across backend restarts

The hosted Space lost active conversations because sessions, approval state, event streams, and Claude quotas only lived in process memory. This adds a shared optional Mongo store for frontend sessions while keeping local and CLI runs on the no-op/local path when Mongo is absent.

Constraint: Space deployment needs durable state without making PyPI/local CLI users configure Mongo.

Constraint: Frontend deletes must hide sessions with a visibility tag, not erase traces needed for recovery or SFT export.

Rejected: Restore every active session at startup | large user counts would create a thundering herd after deploys.

Rejected: Store one giant session document | Mongo document limits make per-message snapshot docs safer.

Confidence: medium

Scope-risk: broad

Directive: Keep session_messages as compactable runtime snapshots and session_trace_messages/session_events as append-only raw traces; do not collapse them into one collection without revisiting SFT/replay needs.

Tested: UV_CACHE_DIR=/tmp/uv-cache uv run --extra dev pytest tests/unit/test_session_persistence.py tests/unit/test_user_quotas.py -q

Tested: UV_CACHE_DIR=/tmp/uv-cache uv run --extra dev python -m py_compile backend/session_manager.py backend/routes/agent.py backend/user_quotas.py backend/models.py agent/core/session_persistence.py agent/core/session.py agent/context_manager/manager.py

Tested: local Mongo smoke for snapshot load, event replay, quota cap, soft delete

Tested: npm run build

Not-tested: Full background worker recovery after Space restart; intentionally Phase 3/backlog.

Not-tested: Full unit suite currently has pre-existing doom-loop wording expectation failures unrelated to this change.

* Protect persisted session restore boundaries

Lazy restore now authorizes an existing session before mutating its HF token and starts at most one runtime task when concurrent reconnects race to restore the same Mongo-backed session. Regression tests cover cross-user token overwrite, duplicate task prevention, pending approval restore, dev listing, and Mongo quota cap delegation.

Constraint: Session restore is lazy because the Space may have many durable sessions and cannot eagerly hydrate all of them on startup.

Rejected: Per-session restore locks | idempotent task startup plus a post-load in-memory check prevents leaked loops with less bookkeeping.

Confidence: high

Scope-risk: narrow

Directive: Do not update runtime credentials before access checks in ensure_session_loaded.

Tested: UV_CACHE_DIR=/tmp/uv-cache uv run --extra dev pytest tests/unit/test_session_persistence.py tests/unit/test_session_manager_persistence.py tests/unit/test_user_quotas.py -q

Tested: UV_CACHE_DIR=/tmp/uv-cache uv run python -m py_compile backend/session_manager.py backend/user_quotas.py agent/core/session_persistence.py

Tested: git diff --check

agent/context_manager/manager.py CHANGED
@@ -160,6 +160,7 @@ class ContextManager:
160
  self.running_context_usage = 0
161
  self.untouched_messages = untouched_messages
162
  self.items: list[Message] = [Message(role="system", content=self.system_prompt)]
 
163
 
164
  def _load_system_prompt(
165
  self,
@@ -219,6 +220,8 @@ class ContextManager:
219
  if token_count:
220
  self.running_context_usage = token_count
221
  self.items.append(message)
 
 
222
 
223
  def get_messages(self) -> list[Message]:
224
  """Get all messages for sending to LLM.
 
160
  self.running_context_usage = 0
161
  self.untouched_messages = untouched_messages
162
  self.items: list[Message] = [Message(role="system", content=self.system_prompt)]
163
+ self.on_message_added = None
164
 
165
  def _load_system_prompt(
166
  self,
 
220
  if token_count:
221
  self.running_context_usage = token_count
222
  self.items.append(message)
223
+ if self.on_message_added:
224
+ self.on_message_added(message)
225
 
226
  def get_messages(self) -> list[Message]:
227
  """Get all messages for sending to LLM.
agent/core/session.py CHANGED
@@ -65,6 +65,7 @@ class OpType(Enum):
65
  class Event:
66
  event_type: str
67
  data: Optional[dict[str, Any]] = None
 
68
 
69
 
70
  class Session:
@@ -87,9 +88,11 @@ class Session:
87
  defer_turn_complete_notification: bool = False,
88
  session_id: str | None = None,
89
  user_id: str | None = None,
 
90
  ):
91
  self.hf_token: Optional[str] = hf_token
92
  self.user_id: Optional[str] = user_id
 
93
  self.tool_router = tool_router
94
  self.stream = stream
95
  if config is None:
@@ -135,11 +138,10 @@ class Session:
135
  # thinking params at all
136
  # Key absent → not probed yet; fall back to the raw preference.
137
  self.model_effective_effort: dict[str, str | None] = {}
 
138
 
139
  async def send_event(self, event: Event) -> None:
140
  """Send event back to client and log to trajectory"""
141
- await self.event_queue.put(event)
142
-
143
  # Log event to trajectory
144
  self.logged_events.append(
145
  {
@@ -148,6 +150,15 @@ class Session:
148
  "data": event.data,
149
  }
150
  )
 
 
 
 
 
 
 
 
 
151
  await self._enqueue_auto_notification_requests(event)
152
 
153
  # Mid-turn heartbeat flush (owned by telemetry module).
@@ -155,6 +166,25 @@ class Session:
155
 
156
  HeartbeatSaver.maybe_fire(self)
157
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  def set_notification_destinations(self, destinations: list[str]) -> None:
159
  """Replace the session's opted-in auto-notification destinations."""
160
  deduped: list[str] = []
 
65
  class Event:
66
  event_type: str
67
  data: Optional[dict[str, Any]] = None
68
+ seq: Optional[int] = None
69
 
70
 
71
  class Session:
 
88
  defer_turn_complete_notification: bool = False,
89
  session_id: str | None = None,
90
  user_id: str | None = None,
91
+ persistence_store: Any | None = None,
92
  ):
93
  self.hf_token: Optional[str] = hf_token
94
  self.user_id: Optional[str] = user_id
95
+ self.persistence_store = persistence_store
96
  self.tool_router = tool_router
97
  self.stream = stream
98
  if config is None:
 
138
  # thinking params at all
139
  # Key absent → not probed yet; fall back to the raw preference.
140
  self.model_effective_effort: dict[str, str | None] = {}
141
+ self.context_manager.on_message_added = self._schedule_trace_message
142
 
143
  async def send_event(self, event: Event) -> None:
144
  """Send event back to client and log to trajectory"""
 
 
145
  # Log event to trajectory
146
  self.logged_events.append(
147
  {
 
150
  "data": event.data,
151
  }
152
  )
153
+ if self.persistence_store is not None:
154
+ try:
155
+ event.seq = await self.persistence_store.append_event(
156
+ self.session_id, event.event_type, event.data
157
+ )
158
+ except Exception as e:
159
+ logger.debug("Event persistence failed for %s: %s", self.session_id, e)
160
+
161
+ await self.event_queue.put(event)
162
  await self._enqueue_auto_notification_requests(event)
163
 
164
  # Mid-turn heartbeat flush (owned by telemetry module).
 
166
 
167
  HeartbeatSaver.maybe_fire(self)
168
 
169
+ def _schedule_trace_message(self, message: Any) -> None:
170
+ """Best-effort append-only trace save for SFT/KPI export."""
171
+ if self.persistence_store is None:
172
+ return
173
+ try:
174
+ payload = message.model_dump(mode="json")
175
+ except Exception:
176
+ return
177
+ try:
178
+ loop = asyncio.get_running_loop()
179
+ except RuntimeError:
180
+ return
181
+ source = str(payload.get("role") or "message")
182
+ loop.create_task(
183
+ self.persistence_store.append_trace_message(
184
+ self.session_id, payload, source=source
185
+ )
186
+ )
187
+
188
  def set_notification_destinations(self, destinations: list[str]) -> None:
189
  """Replace the session's opted-in auto-notification destinations."""
190
  deduped: list[str] = []
agent/core/session_persistence.py ADDED
@@ -0,0 +1,428 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Optional durable session persistence for the hosted backend.
2
+
3
+ The public CLI must keep working without MongoDB. This module therefore
4
+ exposes one small async store interface and returns a no-op implementation
5
+ unless ``MONGODB_URI`` is configured and reachable.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import os
12
+ from datetime import UTC, datetime
13
+ from typing import Any
14
+
15
+ from bson import BSON
16
+ from pymongo import AsyncMongoClient, DeleteMany, ReturnDocument, UpdateOne
17
+ from pymongo.errors import DuplicateKeyError, InvalidDocument, PyMongoError
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ SCHEMA_VERSION = 1
22
+ MAX_BSON_BYTES = 15 * 1024 * 1024
23
+
24
+
25
+ def _now() -> datetime:
26
+ return datetime.now(UTC)
27
+
28
+
29
+ def _doc_id(session_id: str, idx: int) -> str:
30
+ return f"{session_id}:{idx}"
31
+
32
+
33
+ def _safe_message_doc(message: dict[str, Any]) -> dict[str, Any]:
34
+ """Return a Mongo-safe message document payload.
35
+
36
+ Mongo's hard document limit is 16 MB. We stay below that and store an
37
+ explicit marker rather than failing the whole snapshot for one huge tool log.
38
+ """
39
+ try:
40
+ if len(BSON.encode({"message": message})) <= MAX_BSON_BYTES:
41
+ return message
42
+ except (InvalidDocument, OverflowError):
43
+ pass
44
+ return {
45
+ "role": "tool",
46
+ "content": (
47
+ "[SYSTEM: A single persisted message exceeded MongoDB's document "
48
+ "size/encoding limit and was replaced by this marker.]"
49
+ ),
50
+ "ml_intern_persistence_error": "message_too_large_or_invalid",
51
+ }
52
+
53
+
54
+ class NoopSessionStore:
55
+ """Async no-op store used when Mongo is not configured."""
56
+
57
+ enabled = False
58
+
59
+ async def init(self) -> None:
60
+ return None
61
+
62
+ async def close(self) -> None:
63
+ return None
64
+
65
+ async def upsert_session(self, **_: Any) -> None:
66
+ return None
67
+
68
+ async def save_snapshot(self, **_: Any) -> None:
69
+ return None
70
+
71
+ async def load_session(self, *_: Any, **__: Any) -> dict[str, Any] | None:
72
+ return None
73
+
74
+ async def list_sessions(self, *_: Any, **__: Any) -> list[dict[str, Any]]:
75
+ return []
76
+
77
+ async def soft_delete_session(self, *_: Any, **__: Any) -> None:
78
+ return None
79
+
80
+ async def update_session_fields(self, *_: Any, **__: Any) -> None:
81
+ return None
82
+
83
+ async def append_event(self, *_: Any, **__: Any) -> int | None:
84
+ return None
85
+
86
+ async def load_events_after(self, *_: Any, **__: Any) -> list[dict[str, Any]]:
87
+ return []
88
+
89
+ async def append_trace_message(self, *_: Any, **__: Any) -> int | None:
90
+ return None
91
+
92
+ async def get_quota(self, *_: Any, **__: Any) -> int | None:
93
+ return None
94
+
95
+ async def try_increment_quota(self, *_: Any, **__: Any) -> int | None:
96
+ return None
97
+
98
+ async def refund_quota(self, *_: Any, **__: Any) -> None:
99
+ return None
100
+
101
+
102
+ class MongoSessionStore(NoopSessionStore):
103
+ """MongoDB-backed session store."""
104
+
105
+ enabled = True
106
+
107
+ def __init__(self, uri: str, db_name: str) -> None:
108
+ self.uri = uri
109
+ self.db_name = db_name
110
+ self.enabled = False
111
+ self.client: AsyncMongoClient | None = None
112
+ self.db = None
113
+
114
+ async def init(self) -> None:
115
+ try:
116
+ self.client = AsyncMongoClient(self.uri, serverSelectionTimeoutMS=3000)
117
+ self.db = self.client[self.db_name]
118
+ await self.client.admin.command("ping")
119
+ await self._create_indexes()
120
+ self.enabled = True
121
+ logger.info("Mongo session persistence enabled (db=%s)", self.db_name)
122
+ except Exception as e:
123
+ logger.warning("Mongo session persistence disabled: %s", e)
124
+ self.enabled = False
125
+ if self.client is not None:
126
+ await self.client.close()
127
+ self.client = None
128
+ self.db = None
129
+
130
+ async def close(self) -> None:
131
+ if self.client is not None:
132
+ await self.client.close()
133
+ self.client = None
134
+ self.db = None
135
+
136
+ async def _create_indexes(self) -> None:
137
+ if self.db is None:
138
+ return
139
+ await self.db.sessions.create_index(
140
+ [("user_id", 1), ("visibility", 1), ("updated_at", -1)]
141
+ )
142
+ await self.db.sessions.create_index(
143
+ [("visibility", 1), ("status", 1), ("last_active_at", -1)]
144
+ )
145
+ await self.db.session_messages.create_index(
146
+ [("session_id", 1), ("idx", 1)], unique=True
147
+ )
148
+ await self.db.session_events.create_index(
149
+ [("session_id", 1), ("seq", 1)], unique=True
150
+ )
151
+ await self.db.session_trace_messages.create_index(
152
+ [("session_id", 1), ("seq", 1)], unique=True
153
+ )
154
+ await self.db.session_trace_messages.create_index([("created_at", -1)])
155
+
156
+ def _ready(self) -> bool:
157
+ return bool(self.enabled and self.db is not None)
158
+
159
+ async def upsert_session(
160
+ self,
161
+ *,
162
+ session_id: str,
163
+ user_id: str,
164
+ model: str,
165
+ title: str | None = None,
166
+ surface: str = "frontend",
167
+ created_at: datetime | None = None,
168
+ runtime_state: str = "idle",
169
+ status: str = "active",
170
+ message_count: int = 0,
171
+ turn_count: int = 0,
172
+ pending_approval: list[dict[str, Any]] | None = None,
173
+ claude_counted: bool = False,
174
+ notification_destinations: list[str] | None = None,
175
+ ) -> None:
176
+ if not self._ready():
177
+ return
178
+ now = _now()
179
+ await self.db.sessions.update_one(
180
+ {"_id": session_id},
181
+ {
182
+ "$setOnInsert": {
183
+ "_id": session_id,
184
+ "session_id": session_id,
185
+ "user_id": user_id,
186
+ "surface": surface,
187
+ "created_at": created_at or now,
188
+ "schema_version": SCHEMA_VERSION,
189
+ "visibility": "live",
190
+ },
191
+ "$set": {
192
+ "title": title,
193
+ "model": model,
194
+ "status": status,
195
+ "runtime_state": runtime_state,
196
+ "updated_at": now,
197
+ "last_active_at": now,
198
+ "message_count": message_count,
199
+ "turn_count": turn_count,
200
+ "pending_approval": pending_approval or [],
201
+ "claude_counted": claude_counted,
202
+ "notification_destinations": notification_destinations or [],
203
+ },
204
+ },
205
+ upsert=True,
206
+ )
207
+
208
+ async def save_snapshot(
209
+ self,
210
+ *,
211
+ session_id: str,
212
+ user_id: str,
213
+ model: str,
214
+ messages: list[dict[str, Any]],
215
+ title: str | None = None,
216
+ runtime_state: str = "idle",
217
+ status: str = "active",
218
+ turn_count: int = 0,
219
+ pending_approval: list[dict[str, Any]] | None = None,
220
+ claude_counted: bool = False,
221
+ created_at: datetime | None = None,
222
+ notification_destinations: list[str] | None = None,
223
+ ) -> None:
224
+ if not self._ready():
225
+ return
226
+ now = _now()
227
+ await self.upsert_session(
228
+ session_id=session_id,
229
+ user_id=user_id,
230
+ model=model,
231
+ title=title,
232
+ created_at=created_at,
233
+ runtime_state=runtime_state,
234
+ status=status,
235
+ message_count=len(messages),
236
+ turn_count=turn_count,
237
+ pending_approval=pending_approval,
238
+ claude_counted=claude_counted,
239
+ notification_destinations=notification_destinations,
240
+ )
241
+ ops: list[Any] = []
242
+ for idx, raw in enumerate(messages):
243
+ ops.append(
244
+ UpdateOne(
245
+ {"_id": _doc_id(session_id, idx)},
246
+ {
247
+ "$set": {
248
+ "session_id": session_id,
249
+ "idx": idx,
250
+ "message": _safe_message_doc(raw),
251
+ "updated_at": now,
252
+ },
253
+ "$setOnInsert": {"created_at": now},
254
+ },
255
+ upsert=True,
256
+ )
257
+ )
258
+ ops.append(DeleteMany({"session_id": session_id, "idx": {"$gte": len(messages)}}))
259
+ try:
260
+ if ops:
261
+ await self.db.session_messages.bulk_write(ops, ordered=False)
262
+ except PyMongoError as e:
263
+ logger.warning("Failed to persist session %s snapshot: %s", session_id, e)
264
+
265
+ async def load_session(
266
+ self, session_id: str, *, include_deleted: bool = False
267
+ ) -> dict[str, Any] | None:
268
+ if not self._ready():
269
+ return None
270
+ meta = await self.db.sessions.find_one({"_id": session_id})
271
+ if not meta:
272
+ return None
273
+ if meta.get("visibility") == "deleted" and not include_deleted:
274
+ return None
275
+ cursor = self.db.session_messages.find({"session_id": session_id}).sort("idx", 1)
276
+ messages = [row.get("message") async for row in cursor]
277
+ return {"metadata": meta, "messages": messages}
278
+
279
+ async def list_sessions(
280
+ self, user_id: str, *, include_deleted: bool = False
281
+ ) -> list[dict[str, Any]]:
282
+ if not self._ready():
283
+ return []
284
+ query: dict[str, Any] = {"user_id": user_id}
285
+ if user_id == "dev":
286
+ query = {}
287
+ if not include_deleted:
288
+ query["visibility"] = {"$ne": "deleted"}
289
+ cursor = self.db.sessions.find(query).sort("updated_at", -1)
290
+ return [row async for row in cursor]
291
+
292
+ async def soft_delete_session(self, session_id: str) -> None:
293
+ if not self._ready():
294
+ return
295
+ await self.db.sessions.update_one(
296
+ {"_id": session_id},
297
+ {
298
+ "$set": {
299
+ "visibility": "deleted",
300
+ "runtime_state": "idle",
301
+ "updated_at": _now(),
302
+ }
303
+ },
304
+ )
305
+
306
+ async def update_session_fields(self, session_id: str, **fields: Any) -> None:
307
+ if not self._ready() or not fields:
308
+ return
309
+ fields["updated_at"] = _now()
310
+ await self.db.sessions.update_one({"_id": session_id}, {"$set": fields})
311
+
312
+ async def _next_seq(self, counter_id: str) -> int:
313
+ doc = await self.db.counters.find_one_and_update(
314
+ {"_id": counter_id},
315
+ {"$inc": {"seq": 1}},
316
+ upsert=True,
317
+ return_document=ReturnDocument.AFTER,
318
+ )
319
+ return int(doc["seq"])
320
+
321
+ async def append_event(
322
+ self, session_id: str, event_type: str, data: dict[str, Any] | None
323
+ ) -> int | None:
324
+ if not self._ready():
325
+ return None
326
+ try:
327
+ seq = await self._next_seq(f"event:{session_id}")
328
+ await self.db.session_events.insert_one(
329
+ {
330
+ "_id": _doc_id(session_id, seq),
331
+ "session_id": session_id,
332
+ "seq": seq,
333
+ "event_type": event_type,
334
+ "data": data or {},
335
+ "created_at": _now(),
336
+ }
337
+ )
338
+ return seq
339
+ except PyMongoError as e:
340
+ logger.debug("Failed to append event for %s: %s", session_id, e)
341
+ return None
342
+
343
+ async def load_events_after(self, session_id: str, after_seq: int = 0) -> list[dict[str, Any]]:
344
+ if not self._ready():
345
+ return []
346
+ cursor = self.db.session_events.find(
347
+ {"session_id": session_id, "seq": {"$gt": int(after_seq or 0)}}
348
+ ).sort("seq", 1)
349
+ return [row async for row in cursor]
350
+
351
+ async def append_trace_message(
352
+ self, session_id: str, message: dict[str, Any], source: str = "message"
353
+ ) -> int | None:
354
+ if not self._ready():
355
+ return None
356
+ try:
357
+ seq = await self._next_seq(f"trace:{session_id}")
358
+ await self.db.session_trace_messages.insert_one(
359
+ {
360
+ "_id": _doc_id(session_id, seq),
361
+ "session_id": session_id,
362
+ "seq": seq,
363
+ "role": message.get("role"),
364
+ "message": _safe_message_doc(message),
365
+ "source": source,
366
+ "created_at": _now(),
367
+ }
368
+ )
369
+ return seq
370
+ except PyMongoError as e:
371
+ logger.debug("Failed to append trace message for %s: %s", session_id, e)
372
+ return None
373
+
374
+ async def get_quota(self, user_id: str, day: str) -> int | None:
375
+ if not self._ready():
376
+ return None
377
+ doc = await self.db.claude_quotas.find_one({"_id": f"{user_id}:{day}"})
378
+ return int(doc.get("count", 0)) if doc else 0
379
+
380
+ async def try_increment_quota(self, user_id: str, day: str, cap: int) -> int | None:
381
+ if not self._ready():
382
+ return None
383
+ key = f"{user_id}:{day}"
384
+ now = _now()
385
+ try:
386
+ await self.db.claude_quotas.insert_one(
387
+ {
388
+ "_id": key,
389
+ "user_id": user_id,
390
+ "day": day,
391
+ "count": 1,
392
+ "updated_at": now,
393
+ }
394
+ )
395
+ return 1
396
+ except DuplicateKeyError:
397
+ pass
398
+ doc = await self.db.claude_quotas.find_one_and_update(
399
+ {"_id": key, "count": {"$lt": cap}},
400
+ {"$inc": {"count": 1}, "$set": {"updated_at": now}},
401
+ return_document=ReturnDocument.AFTER,
402
+ )
403
+ return int(doc["count"]) if doc else None
404
+
405
+ async def refund_quota(self, user_id: str, day: str) -> None:
406
+ if not self._ready():
407
+ return
408
+ await self.db.claude_quotas.update_one(
409
+ {"_id": f"{user_id}:{day}", "count": {"$gt": 0}},
410
+ {"$inc": {"count": -1}, "$set": {"updated_at": _now()}},
411
+ )
412
+
413
+
414
+ _store: NoopSessionStore | MongoSessionStore | None = None
415
+
416
+
417
+ def get_session_store() -> NoopSessionStore | MongoSessionStore:
418
+ global _store
419
+ if _store is None:
420
+ uri = os.environ.get("MONGODB_URI")
421
+ db_name = os.environ.get("MONGODB_DB", "ml-intern")
422
+ _store = MongoSessionStore(uri, db_name) if uri else NoopSessionStore()
423
+ return _store
424
+
425
+
426
+ def _reset_store_for_tests(store: NoopSessionStore | MongoSessionStore | None = None) -> None:
427
+ global _store
428
+ _store = store
backend/main.py CHANGED
@@ -6,6 +6,11 @@ from contextlib import asynccontextmanager
6
  from pathlib import Path
7
 
8
  from dotenv import load_dotenv
 
 
 
 
 
9
  from fastapi import FastAPI
10
  from fastapi.middleware.cors import CORSMiddleware
11
  from fastapi.staticfiles import StaticFiles
@@ -13,9 +18,6 @@ from routes.agent import router as agent_router
13
  from routes.auth import router as auth_router
14
  from session_manager import session_manager
15
 
16
- # Load .env from project root (parent directory)
17
- load_dotenv(Path(__file__).parent.parent / ".env")
18
-
19
  # Configure logging
20
  logging.basicConfig(
21
  level=logging.INFO,
 
6
  from pathlib import Path
7
 
8
  from dotenv import load_dotenv
9
+
10
+ # Load .env before importing routes/session_manager so persistence and quota
11
+ # modules see local Mongo settings during startup.
12
+ load_dotenv(Path(__file__).parent.parent / ".env")
13
+
14
  from fastapi import FastAPI
15
  from fastapi.middleware.cors import CORSMiddleware
16
  from fastapi.staticfiles import StaticFiles
 
18
  from routes.auth import router as auth_router
19
  from session_manager import session_manager
20
 
 
 
 
21
  # Configure logging
22
  logging.basicConfig(
23
  level=logging.INFO,
backend/models.py CHANGED
@@ -87,6 +87,7 @@ class SessionInfo(BaseModel):
87
  user_id: str = "dev"
88
  pending_approval: list[PendingApprovalTool] | None = None
89
  model: str | None = None
 
90
  notification_destinations: list[str] = Field(default_factory=list)
91
 
92
 
 
87
  user_id: str = "dev"
88
  pending_approval: list[PendingApprovalTool] | None = None
89
  model: str | None = None
90
+ title: str | None = None
91
  notification_destinations: list[str] = Field(default_factory=list)
92
 
93
 
backend/routes/agent.py CHANGED
@@ -120,9 +120,9 @@ async def _enforce_claude_quota(
120
  if not _is_anthropic_model(model_name):
121
  return
122
  user_id = user["user_id"]
123
- used = await user_quotas.get_claude_used_today(user_id)
124
  cap = user_quotas.daily_cap_for(user.get("plan"))
125
- if used >= cap:
 
126
  raise HTTPException(
127
  status_code=429,
128
  detail={
@@ -135,8 +135,8 @@ async def _enforce_claude_quota(
135
  ),
136
  },
137
  )
138
- await user_quotas.increment_claude(user_id)
139
  agent_session.claude_counted = True
 
140
 
141
 
142
  async def _enforce_jobs_access_for_approvals(
@@ -241,13 +241,23 @@ async def _enforce_jobs_access_for_approvals(
241
  )
242
 
243
 
244
- def _check_session_access(session_id: str, user: dict[str, Any]) -> None:
245
- """Verify the user has access to the given session. Raises 403 or 404."""
246
- info = session_manager.get_session_info(session_id)
247
- if not info:
 
 
 
 
 
 
 
 
 
248
  raise HTTPException(status_code=404, detail="Session not found")
249
- if not session_manager.verify_session_access(session_id, user["user_id"]):
250
  raise HTTPException(status_code=403, detail="Access denied to this session")
 
251
 
252
 
253
  @router.get("/health", response_model=HealthResponse)
@@ -369,11 +379,21 @@ async def generate_title(
369
  title = title.translate(_TITLE_STRIP_CHARS).strip()
370
  if len(title) > 50:
371
  title = title[:50].rstrip() + "…"
 
 
 
 
 
372
  return {"title": title}
373
  except Exception as e:
374
  logger.warning(f"Title generation failed: {e}")
375
  fallback = request.text.strip()
376
  title = fallback[:40].rstrip() + "…" if len(fallback) > 40 else fallback
 
 
 
 
 
377
  return {"title": title}
378
 
379
 
@@ -477,7 +497,7 @@ async def get_session(
477
  session_id: str, user: dict = Depends(get_current_user)
478
  ) -> SessionInfo:
479
  """Get session information. Only accessible by the session owner."""
480
- _check_session_access(session_id, user)
481
  info = session_manager.get_session_info(session_id)
482
  return SessionInfo(**info)
483
 
@@ -498,7 +518,7 @@ async def set_session_model(
498
  Switching TO an Anthropic model requires HF org membership (PR #63);
499
  free-model switches are unrestricted.
500
  """
501
- _check_session_access(session_id, user)
502
  model_id = body.get("model")
503
  if not model_id:
504
  raise HTTPException(status_code=400, detail="Missing 'model' field")
@@ -506,10 +526,9 @@ async def set_session_model(
506
  if model_id not in valid_ids:
507
  raise HTTPException(status_code=400, detail=f"Unknown model: {model_id}")
508
  await _require_hf_for_anthropic(request, model_id)
509
- agent_session = session_manager.sessions.get(session_id)
510
  if not agent_session:
511
  raise HTTPException(status_code=404, detail="Session not found")
512
- agent_session.session.update_model(model_id)
513
  logger.info(
514
  f"Session {session_id} model → {model_id} "
515
  f"(by {user.get('username', 'unknown')})"
@@ -524,13 +543,14 @@ async def set_session_notifications(
524
  user: dict = Depends(get_current_user),
525
  ) -> dict:
526
  """Replace the session's auto-notification destinations."""
527
- _check_session_access(session_id, user)
528
  try:
529
  destinations = session_manager.set_notification_destinations(
530
  session_id, body.destinations
531
  )
532
  except ValueError as e:
533
  raise HTTPException(status_code=400, detail=str(e))
 
534
  return {
535
  "session_id": session_id,
536
  "notification_destinations": destinations,
@@ -568,7 +588,7 @@ async def get_jobs_access_info(request: Request, user: dict = Depends(get_curren
568
  @router.get("/sessions", response_model=list[SessionInfo])
569
  async def list_sessions(user: dict = Depends(get_current_user)) -> list[SessionInfo]:
570
  """List sessions belonging to the authenticated user."""
571
- sessions = session_manager.list_sessions(user_id=user["user_id"])
572
  return [SessionInfo(**s) for s in sessions]
573
 
574
 
@@ -577,7 +597,7 @@ async def delete_session(
577
  session_id: str, user: dict = Depends(get_current_user)
578
  ) -> dict:
579
  """Delete a session. Only accessible by the session owner."""
580
- _check_session_access(session_id, user)
581
  success = await session_manager.delete_session(session_id)
582
  if not success:
583
  raise HTTPException(status_code=404, detail="Session not found")
@@ -589,10 +609,8 @@ async def submit_input(
589
  request: SubmitRequest, user: dict = Depends(get_current_user)
590
  ) -> dict:
591
  """Submit user input to a session. Only accessible by the session owner."""
592
- _check_session_access(request.session_id, user)
593
- agent_session = session_manager.sessions.get(request.session_id)
594
- if agent_session is not None:
595
- await _enforce_claude_quota(user, agent_session)
596
  success = await session_manager.submit_user_input(request.session_id, request.text)
597
  if not success:
598
  raise HTTPException(status_code=404, detail="Session not found or inactive")
@@ -604,10 +622,7 @@ async def submit_approval(
604
  request: ApprovalRequest, user: dict = Depends(get_current_user)
605
  ) -> dict:
606
  """Submit tool approvals to a session. Only accessible by the session owner."""
607
- _check_session_access(request.session_id, user)
608
- agent_session = session_manager.sessions.get(request.session_id)
609
- if agent_session is None:
610
- raise HTTPException(status_code=404, detail="Session not found or inactive")
611
  approvals = [
612
  {
613
  "tool_call_id": a.tool_call_id,
@@ -632,9 +647,7 @@ async def chat_sse(
632
  user: dict = Depends(get_current_user),
633
  ) -> StreamingResponse:
634
  """SSE endpoint: submit input or approval, then stream events until turn ends."""
635
- _check_session_access(session_id, user)
636
-
637
- agent_session = session_manager.sessions.get(session_id)
638
  if not agent_session or not agent_session.is_active:
639
  raise HTTPException(status_code=404, detail="Session not found or inactive")
640
 
@@ -700,10 +713,7 @@ async def record_pro_click(
700
  user: dict = Depends(get_current_user),
701
  ) -> dict:
702
  """Record a click on a Pro upgrade CTA shown from inside a session."""
703
- _check_session_access(session_id, user)
704
- agent_session = session_manager.sessions.get(session_id)
705
- if not agent_session:
706
- raise HTTPException(status_code=404, detail="Session not found")
707
 
708
  from agent.core import telemetry
709
  await telemetry.record_pro_cta_click(
@@ -725,12 +735,53 @@ _TERMINAL_EVENTS = {"turn_complete", "approval_required", "error", "interrupted"
725
  _SSE_KEEPALIVE_SECONDS = 15
726
 
727
 
728
- def _sse_response(broadcaster, event_queue, sub_id) -> StreamingResponse:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
729
  """Build a StreamingResponse that drains *event_queue* as SSE,
730
  sending keepalive comments every 15 s to prevent proxy timeouts."""
731
 
732
  async def event_generator():
733
  try:
 
 
 
 
 
 
 
 
 
734
  while True:
735
  try:
736
  msg = await asyncio.wait_for(
@@ -741,7 +792,7 @@ def _sse_response(broadcaster, event_queue, sub_id) -> StreamingResponse:
741
  yield ": keepalive\n\n"
742
  continue
743
  event_type = msg.get("event_type", "")
744
- yield f"data: {json.dumps(msg)}\n\n"
745
  if event_type in _TERMINAL_EVENTS:
746
  break
747
  finally:
@@ -761,6 +812,7 @@ def _sse_response(broadcaster, event_queue, sub_id) -> StreamingResponse:
761
  @router.get("/events/{session_id}")
762
  async def subscribe_events(
763
  session_id: str,
 
764
  user: dict = Depends(get_current_user),
765
  ) -> StreamingResponse:
766
  """Subscribe to events for a running session without submitting new input.
@@ -768,15 +820,21 @@ async def subscribe_events(
768
  Used by the frontend to re-attach after a connection drop (e.g. screen
769
  sleep). Returns 404 if the session isn't active or isn't processing.
770
  """
771
- _check_session_access(session_id, user)
772
-
773
- agent_session = session_manager.sessions.get(session_id)
774
  if not agent_session or not agent_session.is_active:
775
  raise HTTPException(status_code=404, detail="Session not found or inactive")
776
 
 
 
777
  broadcaster = agent_session.broadcaster
778
  sub_id, event_queue = broadcaster.subscribe()
779
- return _sse_response(broadcaster, event_queue, sub_id)
 
 
 
 
 
 
780
 
781
 
782
  @router.post("/interrupt/{session_id}")
@@ -784,7 +842,7 @@ async def interrupt_session(
784
  session_id: str, user: dict = Depends(get_current_user)
785
  ) -> dict:
786
  """Interrupt the current operation in a session."""
787
- _check_session_access(session_id, user)
788
  success = await session_manager.interrupt(session_id)
789
  if not success:
790
  raise HTTPException(status_code=404, detail="Session not found or inactive")
@@ -796,17 +854,16 @@ async def get_session_messages(
796
  session_id: str, user: dict = Depends(get_current_user)
797
  ) -> list[dict]:
798
  """Return the session's message history from memory."""
799
- _check_session_access(session_id, user)
800
- agent_session = session_manager.sessions.get(session_id)
801
  if not agent_session or not agent_session.is_active:
802
  raise HTTPException(status_code=404, detail="Session not found or inactive")
803
- return [msg.model_dump() for msg in agent_session.session.context_manager.items]
804
 
805
 
806
  @router.post("/undo/{session_id}")
807
  async def undo_session(session_id: str, user: dict = Depends(get_current_user)) -> dict:
808
  """Undo the last turn in a session."""
809
- _check_session_access(session_id, user)
810
  success = await session_manager.undo(session_id)
811
  if not success:
812
  raise HTTPException(status_code=404, detail="Session not found or inactive")
@@ -818,7 +875,7 @@ async def truncate_session(
818
  session_id: str, body: TruncateRequest, user: dict = Depends(get_current_user)
819
  ) -> dict:
820
  """Truncate conversation to before a specific user message."""
821
- _check_session_access(session_id, user)
822
  success = await session_manager.truncate(session_id, body.user_message_index)
823
  if not success:
824
  raise HTTPException(status_code=404, detail="Session not found, inactive, or message index out of range")
@@ -830,7 +887,7 @@ async def compact_session(
830
  session_id: str, user: dict = Depends(get_current_user)
831
  ) -> dict:
832
  """Compact the context in a session."""
833
- _check_session_access(session_id, user)
834
  success = await session_manager.compact(session_id)
835
  if not success:
836
  raise HTTPException(status_code=404, detail="Session not found or inactive")
@@ -842,7 +899,7 @@ async def shutdown_session(
842
  session_id: str, user: dict = Depends(get_current_user)
843
  ) -> dict:
844
  """Shutdown a session."""
845
- _check_session_access(session_id, user)
846
  success = await session_manager.shutdown_session(session_id)
847
  if not success:
848
  raise HTTPException(status_code=404, detail="Session not found or inactive")
@@ -860,10 +917,7 @@ async def submit_feedback(
860
  turn_index?: int, comment?: str, message_id?: str}
861
  Appended as a `feedback` event and saved with the session trajectory.
862
  """
863
- _check_session_access(session_id, user)
864
- agent_session = session_manager.sessions.get(session_id)
865
- if not agent_session:
866
- raise HTTPException(status_code=404, detail="Session not found")
867
 
868
  rating = body.get("rating")
869
  if rating not in {"up", "down", "outcome_success", "outcome_fail"}:
 
120
  if not _is_anthropic_model(model_name):
121
  return
122
  user_id = user["user_id"]
 
123
  cap = user_quotas.daily_cap_for(user.get("plan"))
124
+ new_count = await user_quotas.try_increment_claude(user_id, cap)
125
+ if new_count is None:
126
  raise HTTPException(
127
  status_code=429,
128
  detail={
 
135
  ),
136
  },
137
  )
 
138
  agent_session.claude_counted = True
139
+ await session_manager.persist_session_snapshot(agent_session)
140
 
141
 
142
  async def _enforce_jobs_access_for_approvals(
 
241
  )
242
 
243
 
244
+ async def _check_session_access(
245
+ session_id: str,
246
+ user: dict[str, Any],
247
+ request: Request | None = None,
248
+ ) -> AgentSession:
249
+ """Verify and lazily load the user's session. Raises 403 or 404."""
250
+ hf_token = resolve_hf_request_token(request) if request is not None else user.get("hf_token")
251
+ agent_session = await session_manager.ensure_session_loaded(
252
+ session_id,
253
+ user["user_id"],
254
+ hf_token=hf_token,
255
+ )
256
+ if not agent_session:
257
  raise HTTPException(status_code=404, detail="Session not found")
258
+ if user["user_id"] != "dev" and agent_session.user_id not in {user["user_id"], "dev"}:
259
  raise HTTPException(status_code=403, detail="Access denied to this session")
260
+ return agent_session
261
 
262
 
263
  @router.get("/health", response_model=HealthResponse)
 
379
  title = title.translate(_TITLE_STRIP_CHARS).strip()
380
  if len(title) > 50:
381
  title = title[:50].rstrip() + "…"
382
+ try:
383
+ await _check_session_access(request.session_id, user)
384
+ await session_manager.update_session_title(request.session_id, title)
385
+ except Exception:
386
+ logger.debug("Skipping title persistence for missing session %s", request.session_id)
387
  return {"title": title}
388
  except Exception as e:
389
  logger.warning(f"Title generation failed: {e}")
390
  fallback = request.text.strip()
391
  title = fallback[:40].rstrip() + "…" if len(fallback) > 40 else fallback
392
+ try:
393
+ await _check_session_access(request.session_id, user)
394
+ await session_manager.update_session_title(request.session_id, title)
395
+ except Exception:
396
+ logger.debug("Skipping fallback title persistence for missing session %s", request.session_id)
397
  return {"title": title}
398
 
399
 
 
497
  session_id: str, user: dict = Depends(get_current_user)
498
  ) -> SessionInfo:
499
  """Get session information. Only accessible by the session owner."""
500
+ await _check_session_access(session_id, user)
501
  info = session_manager.get_session_info(session_id)
502
  return SessionInfo(**info)
503
 
 
518
  Switching TO an Anthropic model requires HF org membership (PR #63);
519
  free-model switches are unrestricted.
520
  """
521
+ agent_session = await _check_session_access(session_id, user, request)
522
  model_id = body.get("model")
523
  if not model_id:
524
  raise HTTPException(status_code=400, detail="Missing 'model' field")
 
526
  if model_id not in valid_ids:
527
  raise HTTPException(status_code=400, detail=f"Unknown model: {model_id}")
528
  await _require_hf_for_anthropic(request, model_id)
 
529
  if not agent_session:
530
  raise HTTPException(status_code=404, detail="Session not found")
531
+ await session_manager.update_session_model(session_id, model_id)
532
  logger.info(
533
  f"Session {session_id} model → {model_id} "
534
  f"(by {user.get('username', 'unknown')})"
 
543
  user: dict = Depends(get_current_user),
544
  ) -> dict:
545
  """Replace the session's auto-notification destinations."""
546
+ agent_session = await _check_session_access(session_id, user)
547
  try:
548
  destinations = session_manager.set_notification_destinations(
549
  session_id, body.destinations
550
  )
551
  except ValueError as e:
552
  raise HTTPException(status_code=400, detail=str(e))
553
+ await session_manager.persist_session_snapshot(agent_session)
554
  return {
555
  "session_id": session_id,
556
  "notification_destinations": destinations,
 
588
  @router.get("/sessions", response_model=list[SessionInfo])
589
  async def list_sessions(user: dict = Depends(get_current_user)) -> list[SessionInfo]:
590
  """List sessions belonging to the authenticated user."""
591
+ sessions = await session_manager.list_sessions(user_id=user["user_id"])
592
  return [SessionInfo(**s) for s in sessions]
593
 
594
 
 
597
  session_id: str, user: dict = Depends(get_current_user)
598
  ) -> dict:
599
  """Delete a session. Only accessible by the session owner."""
600
+ await _check_session_access(session_id, user)
601
  success = await session_manager.delete_session(session_id)
602
  if not success:
603
  raise HTTPException(status_code=404, detail="Session not found")
 
609
  request: SubmitRequest, user: dict = Depends(get_current_user)
610
  ) -> dict:
611
  """Submit user input to a session. Only accessible by the session owner."""
612
+ agent_session = await _check_session_access(request.session_id, user)
613
+ await _enforce_claude_quota(user, agent_session)
 
 
614
  success = await session_manager.submit_user_input(request.session_id, request.text)
615
  if not success:
616
  raise HTTPException(status_code=404, detail="Session not found or inactive")
 
622
  request: ApprovalRequest, user: dict = Depends(get_current_user)
623
  ) -> dict:
624
  """Submit tool approvals to a session. Only accessible by the session owner."""
625
+ agent_session = await _check_session_access(request.session_id, user)
 
 
 
626
  approvals = [
627
  {
628
  "tool_call_id": a.tool_call_id,
 
647
  user: dict = Depends(get_current_user),
648
  ) -> StreamingResponse:
649
  """SSE endpoint: submit input or approval, then stream events until turn ends."""
650
+ agent_session = await _check_session_access(session_id, user, request)
 
 
651
  if not agent_session or not agent_session.is_active:
652
  raise HTTPException(status_code=404, detail="Session not found or inactive")
653
 
 
713
  user: dict = Depends(get_current_user),
714
  ) -> dict:
715
  """Record a click on a Pro upgrade CTA shown from inside a session."""
716
+ agent_session = await _check_session_access(session_id, user)
 
 
 
717
 
718
  from agent.core import telemetry
719
  await telemetry.record_pro_cta_click(
 
735
  _SSE_KEEPALIVE_SECONDS = 15
736
 
737
 
738
+ def _last_event_seq(request: Request) -> int:
739
+ raw = request.headers.get("last-event-id") or request.query_params.get("after") or "0"
740
+ try:
741
+ return max(0, int(raw))
742
+ except (TypeError, ValueError):
743
+ return 0
744
+
745
+
746
+ def _format_sse(msg: dict[str, Any]) -> str:
747
+ seq = msg.get("seq")
748
+ body = {"event_type": msg.get("event_type"), "data": msg.get("data") or {}}
749
+ if seq is not None:
750
+ body["seq"] = seq
751
+ return f"id: {seq}\ndata: {json.dumps(body)}\n\n"
752
+ return f"data: {json.dumps(body)}\n\n"
753
+
754
+
755
+ def _event_doc_to_msg(doc: dict[str, Any]) -> dict[str, Any]:
756
+ return {
757
+ "event_type": doc.get("event_type"),
758
+ "data": doc.get("data") or {},
759
+ "seq": doc.get("seq"),
760
+ }
761
+
762
+
763
+ def _sse_response(
764
+ broadcaster,
765
+ event_queue,
766
+ sub_id,
767
+ *,
768
+ replay_events: list[dict[str, Any]] | None = None,
769
+ after_seq: int = 0,
770
+ ) -> StreamingResponse:
771
  """Build a StreamingResponse that drains *event_queue* as SSE,
772
  sending keepalive comments every 15 s to prevent proxy timeouts."""
773
 
774
  async def event_generator():
775
  try:
776
+ for doc in replay_events or []:
777
+ msg = _event_doc_to_msg(doc)
778
+ seq = msg.get("seq")
779
+ if isinstance(seq, int) and seq <= after_seq:
780
+ continue
781
+ yield _format_sse(msg)
782
+ if msg.get("event_type", "") in _TERMINAL_EVENTS:
783
+ return
784
+
785
  while True:
786
  try:
787
  msg = await asyncio.wait_for(
 
792
  yield ": keepalive\n\n"
793
  continue
794
  event_type = msg.get("event_type", "")
795
+ yield _format_sse(msg)
796
  if event_type in _TERMINAL_EVENTS:
797
  break
798
  finally:
 
812
  @router.get("/events/{session_id}")
813
  async def subscribe_events(
814
  session_id: str,
815
+ request: Request,
816
  user: dict = Depends(get_current_user),
817
  ) -> StreamingResponse:
818
  """Subscribe to events for a running session without submitting new input.
 
820
  Used by the frontend to re-attach after a connection drop (e.g. screen
821
  sleep). Returns 404 if the session isn't active or isn't processing.
822
  """
823
+ agent_session = await _check_session_access(session_id, user, request)
 
 
824
  if not agent_session or not agent_session.is_active:
825
  raise HTTPException(status_code=404, detail="Session not found or inactive")
826
 
827
+ after_seq = _last_event_seq(request)
828
+ replay_events = await session_manager._store().load_events_after(session_id, after_seq)
829
  broadcaster = agent_session.broadcaster
830
  sub_id, event_queue = broadcaster.subscribe()
831
+ return _sse_response(
832
+ broadcaster,
833
+ event_queue,
834
+ sub_id,
835
+ replay_events=replay_events,
836
+ after_seq=after_seq,
837
+ )
838
 
839
 
840
  @router.post("/interrupt/{session_id}")
 
842
  session_id: str, user: dict = Depends(get_current_user)
843
  ) -> dict:
844
  """Interrupt the current operation in a session."""
845
+ await _check_session_access(session_id, user)
846
  success = await session_manager.interrupt(session_id)
847
  if not success:
848
  raise HTTPException(status_code=404, detail="Session not found or inactive")
 
854
  session_id: str, user: dict = Depends(get_current_user)
855
  ) -> list[dict]:
856
  """Return the session's message history from memory."""
857
+ agent_session = await _check_session_access(session_id, user)
 
858
  if not agent_session or not agent_session.is_active:
859
  raise HTTPException(status_code=404, detail="Session not found or inactive")
860
+ return [msg.model_dump(mode="json") for msg in agent_session.session.context_manager.items]
861
 
862
 
863
  @router.post("/undo/{session_id}")
864
  async def undo_session(session_id: str, user: dict = Depends(get_current_user)) -> dict:
865
  """Undo the last turn in a session."""
866
+ await _check_session_access(session_id, user)
867
  success = await session_manager.undo(session_id)
868
  if not success:
869
  raise HTTPException(status_code=404, detail="Session not found or inactive")
 
875
  session_id: str, body: TruncateRequest, user: dict = Depends(get_current_user)
876
  ) -> dict:
877
  """Truncate conversation to before a specific user message."""
878
+ await _check_session_access(session_id, user)
879
  success = await session_manager.truncate(session_id, body.user_message_index)
880
  if not success:
881
  raise HTTPException(status_code=404, detail="Session not found, inactive, or message index out of range")
 
887
  session_id: str, user: dict = Depends(get_current_user)
888
  ) -> dict:
889
  """Compact the context in a session."""
890
+ await _check_session_access(session_id, user)
891
  success = await session_manager.compact(session_id)
892
  if not success:
893
  raise HTTPException(status_code=404, detail="Session not found or inactive")
 
899
  session_id: str, user: dict = Depends(get_current_user)
900
  ) -> dict:
901
  """Shutdown a session."""
902
+ await _check_session_access(session_id, user)
903
  success = await session_manager.shutdown_session(session_id)
904
  if not success:
905
  raise HTTPException(status_code=404, detail="Session not found or inactive")
 
917
  turn_index?: int, comment?: str, message_id?: str}
918
  Appended as a `feedback` event and saved with the session trajectory.
919
  """
920
+ agent_session = await _check_session_access(session_id, user)
 
 
 
921
 
922
  rating = body.get("rating")
923
  if rating not in {"up", "down", "outcome_success", "outcome_fail"}:
backend/session_manager.py CHANGED
@@ -1,6 +1,7 @@
1
  """Session manager for handling multiple concurrent agent sessions."""
2
 
3
  import asyncio
 
4
  import logging
5
  import uuid
6
  from dataclasses import dataclass, field
@@ -12,6 +13,7 @@ from agent.config import load_config
12
  from agent.core.agent_loop import process_submission
13
  from agent.messaging.gateway import NotificationGateway
14
  from agent.core.session import Event, OpType, Session
 
15
  from agent.core.tools import ToolRouter
16
 
17
  # Get project root (parent of backend directory)
@@ -42,9 +44,8 @@ logger = logging.getLogger(__name__)
42
  class EventBroadcaster:
43
  """Reads from the agent's event queue and fans out to SSE subscribers.
44
 
45
- Events that arrive when no subscribers are listening are discarded.
46
- With SSE each turn is a separate request, so there is no reconnect
47
- scenario that would need buffered replay.
48
  """
49
 
50
  def __init__(self, event_queue: asyncio.Queue):
@@ -68,7 +69,7 @@ class EventBroadcaster:
68
  while True:
69
  try:
70
  event: Event = await self._source.get()
71
- msg = {"event_type": event.event_type, "data": event.data}
72
  for q in self._subscribers.values():
73
  await q.put(msg)
74
  except asyncio.CancelledError:
@@ -92,6 +93,7 @@ class AgentSession:
92
  is_active: bool = True
93
  is_processing: bool = False # True while a submission is being executed
94
  broadcaster: Any = None
 
95
  # True once this session has been counted against the user's daily
96
  # Claude quota. Guards double-counting when the user re-selects an
97
  # Anthropic model mid-session.
@@ -123,14 +125,24 @@ class SessionManager:
123
  self.messaging_gateway = NotificationGateway(self.config.messaging)
124
  self.sessions: dict[str, AgentSession] = {}
125
  self._lock = asyncio.Lock()
 
126
 
127
  async def start(self) -> None:
128
  """Start shared background resources."""
 
 
129
  await self.messaging_gateway.start()
130
 
131
  async def close(self) -> None:
132
  """Flush and close shared background resources."""
133
  await self.messaging_gateway.close()
 
 
 
 
 
 
 
134
 
135
  def _count_user_sessions(self, user_id: str) -> int:
136
  """Count active sessions owned by a specific user."""
@@ -140,6 +152,314 @@ class SessionManager:
140
  if s.user_id == user_id and s.is_active
141
  )
142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  async def create_session(
144
  self,
145
  user_id: str = "dev",
@@ -188,31 +508,14 @@ class SessionManager:
188
  event_queue: asyncio.Queue = asyncio.Queue()
189
 
190
  # Run blocking constructors in a thread to keep the event loop responsive.
191
- # Without this, Session.__init__ ContextManager → litellm.get_max_tokens()
192
- # blocks all HTTP/SSE handling.
193
- import time as _time
194
-
195
- def _create_session_sync():
196
- t0 = _time.monotonic()
197
- tool_router = ToolRouter(self.config.mcpServers, hf_token=hf_token)
198
- # Deep-copy config so each session's model switches independently —
199
- # tab A picking GLM doesn't flip tab B off Claude.
200
- session_config = self.config.model_copy(deep=True)
201
- if model:
202
- session_config.model_name = model
203
- session = Session(
204
- event_queue, config=session_config, tool_router=tool_router,
205
- hf_token=hf_token,
206
- user_id=user_id,
207
- notification_gateway=self.messaging_gateway,
208
- notification_destinations=[],
209
- session_id=session_id,
210
- )
211
- t1 = _time.monotonic()
212
- logger.info(f"Session initialized in {t1 - t0:.2f}s")
213
- return tool_router, session
214
-
215
- tool_router, session = await asyncio.to_thread(_create_session_sync)
216
 
217
  # Create wrapper
218
  agent_session = AgentSession(
@@ -224,14 +527,12 @@ class SessionManager:
224
  hf_token=hf_token,
225
  )
226
 
227
- async with self._lock:
228
- self.sessions[session_id] = agent_session
229
-
230
- # Start the agent loop task
231
- task = asyncio.create_task(
232
- self._run_session(session_id, submission_queue, event_queue, tool_router)
233
  )
234
- agent_session.task = task
235
 
236
  logger.info(f"Created session {session_id} for user {user_id}")
237
  return session_id
@@ -297,6 +598,7 @@ class SessionManager:
297
  ),
298
  )
299
  session.context_manager.items.append(seed)
 
300
  return len(parsed)
301
 
302
  @staticmethod
@@ -367,6 +669,7 @@ class SessionManager:
367
  should_continue = await process_submission(session, submission)
368
  finally:
369
  agent_session.is_processing = False
 
370
  if not should_continue:
371
  break
372
  except asyncio.TimeoutError:
@@ -401,6 +704,11 @@ class SessionManager:
401
  async with self._lock:
402
  if session_id in self.sessions:
403
  self.sessions[session_id].is_active = False
 
 
 
 
 
404
 
405
  logger.info(f"Session {session_id} ended")
406
 
@@ -450,7 +758,10 @@ class SessionManager:
450
  agent_session = self.sessions.get(session_id)
451
  if not agent_session or not agent_session.is_active:
452
  return False
453
- return agent_session.session.context_manager.truncate_to_user_message(user_message_index)
 
 
 
454
 
455
  async def compact(self, session_id: str) -> bool:
456
  """Compact context in a session."""
@@ -475,12 +786,15 @@ class SessionManager:
475
  return success
476
 
477
  async def delete_session(self, session_id: str) -> bool:
478
- """Delete a session entirely."""
479
  async with self._lock:
480
  agent_session = self.sessions.pop(session_id, None)
481
 
482
  if not agent_session:
483
- return False
 
 
 
484
 
485
  # Clean up sandbox Space before cancelling the task
486
  await self._cleanup_sandbox(agent_session.session)
@@ -495,6 +809,21 @@ class SessionManager:
495
 
496
  return True
497
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
498
  def get_session_owner(self, session_id: str) -> str | None:
499
  """Get the user_id that owns a session, or None if session doesn't exist."""
500
  agent_session = self.sessions.get(session_id)
@@ -522,22 +851,7 @@ class SessionManager:
522
  if not agent_session:
523
  return None
524
 
525
- # Extract pending approval tools if any
526
- pending_approval = None
527
- pa = agent_session.session.pending_approval
528
- if pa and pa.get("tool_calls"):
529
- pending_approval = []
530
- for tc in pa["tool_calls"]:
531
- import json
532
- try:
533
- args = json.loads(tc.function.arguments)
534
- except (json.JSONDecodeError, AttributeError):
535
- args = {}
536
- pending_approval.append({
537
- "tool": tc.function.name,
538
- "tool_call_id": tc.id,
539
- "arguments": args,
540
- })
541
 
542
  return {
543
  "session_id": session_id,
@@ -548,6 +862,7 @@ class SessionManager:
548
  "user_id": agent_session.user_id,
549
  "pending_approval": pending_approval,
550
  "model": agent_session.session.config.model_name,
 
551
  "notification_destinations": list(
552
  agent_session.session.notification_destinations
553
  ),
@@ -581,14 +896,46 @@ class SessionManager:
581
  agent_session.session.set_notification_destinations(normalized)
582
  return normalized
583
 
584
- def list_sessions(self, user_id: str | None = None) -> list[dict[str, Any]]:
585
  """List sessions, optionally filtered by user.
586
 
587
  Args:
588
  user_id: If provided, only return sessions owned by this user.
589
  If "dev", return all sessions (dev mode).
590
  """
591
- results = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
592
  for sid in self.sessions:
593
  info = self.get_session_info(sid)
594
  if not info:
 
1
  """Session manager for handling multiple concurrent agent sessions."""
2
 
3
  import asyncio
4
+ import json
5
  import logging
6
  import uuid
7
  from dataclasses import dataclass, field
 
13
  from agent.core.agent_loop import process_submission
14
  from agent.messaging.gateway import NotificationGateway
15
  from agent.core.session import Event, OpType, Session
16
+ from agent.core.session_persistence import get_session_store
17
  from agent.core.tools import ToolRouter
18
 
19
  # Get project root (parent of backend directory)
 
44
  class EventBroadcaster:
45
  """Reads from the agent's event queue and fans out to SSE subscribers.
46
 
47
+ Events that arrive when no subscribers are listening are discarded by
48
+ this in-memory fanout. Durable replay is handled by session_persistence.
 
49
  """
50
 
51
  def __init__(self, event_queue: asyncio.Queue):
 
69
  while True:
70
  try:
71
  event: Event = await self._source.get()
72
+ msg = {"event_type": event.event_type, "data": event.data, "seq": event.seq}
73
  for q in self._subscribers.values():
74
  await q.put(msg)
75
  except asyncio.CancelledError:
 
93
  is_active: bool = True
94
  is_processing: bool = False # True while a submission is being executed
95
  broadcaster: Any = None
96
+ title: str | None = None
97
  # True once this session has been counted against the user's daily
98
  # Claude quota. Guards double-counting when the user re-selects an
99
  # Anthropic model mid-session.
 
125
  self.messaging_gateway = NotificationGateway(self.config.messaging)
126
  self.sessions: dict[str, AgentSession] = {}
127
  self._lock = asyncio.Lock()
128
+ self.persistence_store = None
129
 
130
  async def start(self) -> None:
131
  """Start shared background resources."""
132
+ self.persistence_store = get_session_store()
133
+ await self.persistence_store.init()
134
  await self.messaging_gateway.start()
135
 
136
  async def close(self) -> None:
137
  """Flush and close shared background resources."""
138
  await self.messaging_gateway.close()
139
+ if self.persistence_store is not None:
140
+ await self.persistence_store.close()
141
+
142
+ def _store(self):
143
+ if self.persistence_store is None:
144
+ self.persistence_store = get_session_store()
145
+ return self.persistence_store
146
 
147
  def _count_user_sessions(self, user_id: str) -> int:
148
  """Count active sessions owned by a specific user."""
 
152
  if s.user_id == user_id and s.is_active
153
  )
154
 
155
+ def _create_session_sync(
156
+ self,
157
+ *,
158
+ session_id: str,
159
+ user_id: str,
160
+ hf_token: str | None,
161
+ model: str | None,
162
+ event_queue: asyncio.Queue,
163
+ notification_destinations: list[str] | None = None,
164
+ ) -> tuple[ToolRouter, Session]:
165
+ """Build blocking per-session resources in a worker thread."""
166
+ import time as _time
167
+
168
+ t0 = _time.monotonic()
169
+ tool_router = ToolRouter(self.config.mcpServers, hf_token=hf_token)
170
+ # Deep-copy config so each session's model switches independently —
171
+ # tab A picking GLM doesn't flip tab B off Claude.
172
+ session_config = self.config.model_copy(deep=True)
173
+ if model:
174
+ session_config.model_name = model
175
+ session = Session(
176
+ event_queue=event_queue,
177
+ config=session_config,
178
+ tool_router=tool_router,
179
+ hf_token=hf_token,
180
+ user_id=user_id,
181
+ notification_gateway=self.messaging_gateway,
182
+ notification_destinations=notification_destinations or [],
183
+ session_id=session_id,
184
+ persistence_store=self._store(),
185
+ )
186
+ t1 = _time.monotonic()
187
+ logger.info("Session initialized in %.2fs", t1 - t0)
188
+ return tool_router, session
189
+
190
+ def _serialize_messages(self, session: Session) -> list[dict[str, Any]]:
191
+ return [
192
+ msg.model_dump(mode="json")
193
+ for msg in session.context_manager.items
194
+ ]
195
+
196
+ def _serialize_pending_approval(self, session: Session) -> list[dict[str, Any]]:
197
+ pending = session.pending_approval or {}
198
+ tool_calls = pending.get("tool_calls") or []
199
+ serialized: list[dict[str, Any]] = []
200
+ for tc in tool_calls:
201
+ if hasattr(tc, "model_dump"):
202
+ serialized.append(tc.model_dump(mode="json"))
203
+ elif isinstance(tc, dict):
204
+ serialized.append(tc)
205
+ return serialized
206
+
207
+ @staticmethod
208
+ def _pending_tools_for_api(session: Session) -> list[dict[str, Any]] | None:
209
+ pending = session.pending_approval or {}
210
+ tool_calls = pending.get("tool_calls") or []
211
+ if not tool_calls:
212
+ return None
213
+ result: list[dict[str, Any]] = []
214
+ for tc in tool_calls:
215
+ try:
216
+ args = json.loads(tc.function.arguments)
217
+ except (json.JSONDecodeError, AttributeError, TypeError):
218
+ args = {}
219
+ result.append(
220
+ {
221
+ "tool": getattr(tc.function, "name", None),
222
+ "tool_call_id": getattr(tc, "id", None),
223
+ "arguments": args,
224
+ }
225
+ )
226
+ return result
227
+
228
+ def _restore_pending_approval(
229
+ self, session: Session, pending_approval: list[dict[str, Any]] | None
230
+ ) -> None:
231
+ if not pending_approval:
232
+ session.pending_approval = None
233
+ return
234
+ from litellm import ChatCompletionMessageToolCall as ToolCall
235
+
236
+ restored = []
237
+ for raw in pending_approval:
238
+ try:
239
+ if "function" in raw:
240
+ restored.append(ToolCall(**raw))
241
+ else:
242
+ restored.append(
243
+ ToolCall(
244
+ id=raw["tool_call_id"],
245
+ type="function",
246
+ function={
247
+ "name": raw["tool"],
248
+ "arguments": json.dumps(raw.get("arguments") or {}),
249
+ },
250
+ )
251
+ )
252
+ except Exception as e:
253
+ logger.warning("Dropping malformed pending approval: %s", e)
254
+ session.pending_approval = {"tool_calls": restored} if restored else None
255
+
256
+ @staticmethod
257
+ def _pending_docs_for_api(
258
+ pending_approval: list[dict[str, Any]] | None,
259
+ ) -> list[dict[str, Any]] | None:
260
+ if not pending_approval:
261
+ return None
262
+ result: list[dict[str, Any]] = []
263
+ for raw in pending_approval:
264
+ if "function" in raw:
265
+ function = raw.get("function") or {}
266
+ try:
267
+ args = json.loads(function.get("arguments") or "{}")
268
+ except (json.JSONDecodeError, TypeError):
269
+ args = {}
270
+ result.append(
271
+ {
272
+ "tool": function.get("name"),
273
+ "tool_call_id": raw.get("id"),
274
+ "arguments": args,
275
+ }
276
+ )
277
+ elif {"tool", "tool_call_id"}.issubset(raw):
278
+ result.append(
279
+ {
280
+ "tool": raw.get("tool"),
281
+ "tool_call_id": raw.get("tool_call_id"),
282
+ "arguments": raw.get("arguments") or {},
283
+ }
284
+ )
285
+ return result or None
286
+
287
+ @staticmethod
288
+ def _runtime_state(agent_session: AgentSession) -> str:
289
+ if agent_session.session.pending_approval:
290
+ return "waiting_approval"
291
+ if agent_session.is_processing:
292
+ return "processing"
293
+ if not agent_session.is_active:
294
+ return "ended"
295
+ return "idle"
296
+
297
+ async def _start_agent_session(
298
+ self,
299
+ *,
300
+ agent_session: AgentSession,
301
+ event_queue: asyncio.Queue,
302
+ tool_router: ToolRouter,
303
+ ) -> AgentSession:
304
+ async with self._lock:
305
+ existing = self.sessions.get(agent_session.session_id)
306
+ if existing:
307
+ return existing
308
+ self.sessions[agent_session.session_id] = agent_session
309
+
310
+ task = asyncio.create_task(
311
+ self._run_session(
312
+ agent_session.session_id,
313
+ agent_session.submission_queue,
314
+ event_queue,
315
+ tool_router,
316
+ )
317
+ )
318
+ agent_session.task = task
319
+ return agent_session
320
+
321
+ @staticmethod
322
+ def _can_access_session(agent_session: AgentSession, user_id: str) -> bool:
323
+ return (
324
+ user_id == "dev"
325
+ or agent_session.user_id == "dev"
326
+ or agent_session.user_id == user_id
327
+ )
328
+
329
+ @staticmethod
330
+ def _update_hf_token(agent_session: AgentSession, hf_token: str | None) -> None:
331
+ if not hf_token:
332
+ return
333
+ agent_session.hf_token = hf_token
334
+ agent_session.session.hf_token = hf_token
335
+
336
+ async def persist_session_snapshot(
337
+ self,
338
+ agent_session: AgentSession,
339
+ *,
340
+ runtime_state: str | None = None,
341
+ status: str = "active",
342
+ ) -> None:
343
+ """Persist the current runtime context snapshot."""
344
+ store = self._store()
345
+ if not getattr(store, "enabled", False):
346
+ return
347
+ try:
348
+ await store.save_snapshot(
349
+ session_id=agent_session.session_id,
350
+ user_id=agent_session.user_id,
351
+ model=agent_session.session.config.model_name,
352
+ title=agent_session.title,
353
+ messages=self._serialize_messages(agent_session.session),
354
+ runtime_state=runtime_state or self._runtime_state(agent_session),
355
+ status=status,
356
+ turn_count=agent_session.session.turn_count,
357
+ pending_approval=self._serialize_pending_approval(agent_session.session),
358
+ claude_counted=agent_session.claude_counted,
359
+ created_at=agent_session.created_at,
360
+ notification_destinations=list(
361
+ agent_session.session.notification_destinations
362
+ ),
363
+ )
364
+ except Exception as e:
365
+ logger.warning(
366
+ "Failed to persist snapshot for %s: %s",
367
+ agent_session.session_id,
368
+ e,
369
+ )
370
+
371
+ async def ensure_session_loaded(
372
+ self,
373
+ session_id: str,
374
+ user_id: str,
375
+ hf_token: str | None = None,
376
+ ) -> AgentSession | None:
377
+ """Return a live runtime session, lazily restoring it from Mongo."""
378
+ async with self._lock:
379
+ existing = self.sessions.get(session_id)
380
+ if existing:
381
+ if self._can_access_session(existing, user_id):
382
+ self._update_hf_token(existing, hf_token)
383
+ return existing
384
+ return None
385
+
386
+ store = self._store()
387
+ loaded = await store.load_session(session_id)
388
+ if not loaded:
389
+ return None
390
+
391
+ async with self._lock:
392
+ existing = self.sessions.get(session_id)
393
+ if existing:
394
+ if self._can_access_session(existing, user_id):
395
+ self._update_hf_token(existing, hf_token)
396
+ return existing
397
+ return None
398
+
399
+ meta = loaded.get("metadata") or {}
400
+ owner = str(meta.get("user_id") or "")
401
+ if user_id != "dev" and owner != "dev" and owner != user_id:
402
+ return None
403
+
404
+ from litellm import Message
405
+
406
+ model = meta.get("model") or self.config.model_name
407
+ event_queue: asyncio.Queue = asyncio.Queue()
408
+ submission_queue: asyncio.Queue = asyncio.Queue()
409
+ tool_router, session = await asyncio.to_thread(
410
+ self._create_session_sync,
411
+ session_id=session_id,
412
+ user_id=owner or user_id,
413
+ hf_token=hf_token,
414
+ model=model,
415
+ event_queue=event_queue,
416
+ notification_destinations=meta.get("notification_destinations") or [],
417
+ )
418
+
419
+ restored_messages: list[Message] = []
420
+ for raw in loaded.get("messages") or []:
421
+ if not isinstance(raw, dict) or raw.get("role") == "system":
422
+ continue
423
+ try:
424
+ restored_messages.append(Message.model_validate(raw))
425
+ except Exception as e:
426
+ logger.warning("Dropping malformed restored message: %s", e)
427
+ if restored_messages:
428
+ # Keep the freshly-rendered system prompt, then attach the durable
429
+ # non-system context so tools/date/user context stay current.
430
+ session.context_manager.items = [session.context_manager.items[0], *restored_messages]
431
+
432
+ self._restore_pending_approval(session, meta.get("pending_approval") or [])
433
+ session.turn_count = int(meta.get("turn_count") or 0)
434
+
435
+ created_at = meta.get("created_at")
436
+ if not isinstance(created_at, datetime):
437
+ created_at = datetime.utcnow()
438
+
439
+ agent_session = AgentSession(
440
+ session_id=session_id,
441
+ session=session,
442
+ tool_router=tool_router,
443
+ submission_queue=submission_queue,
444
+ user_id=owner or user_id,
445
+ hf_token=hf_token,
446
+ created_at=created_at,
447
+ is_active=True,
448
+ is_processing=False,
449
+ claude_counted=bool(meta.get("claude_counted")),
450
+ title=meta.get("title"),
451
+ )
452
+ started = await self._start_agent_session(
453
+ agent_session=agent_session,
454
+ event_queue=event_queue,
455
+ tool_router=tool_router,
456
+ )
457
+ if started is not agent_session:
458
+ self._update_hf_token(started, hf_token)
459
+ return started
460
+ logger.info("Restored session %s for user %s", session_id, owner or user_id)
461
+ return agent_session
462
+
463
  async def create_session(
464
  self,
465
  user_id: str = "dev",
 
508
  event_queue: asyncio.Queue = asyncio.Queue()
509
 
510
  # Run blocking constructors in a thread to keep the event loop responsive.
511
+ tool_router, session = await asyncio.to_thread(
512
+ self._create_session_sync,
513
+ session_id=session_id,
514
+ user_id=user_id,
515
+ hf_token=hf_token,
516
+ model=model,
517
+ event_queue=event_queue,
518
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
519
 
520
  # Create wrapper
521
  agent_session = AgentSession(
 
527
  hf_token=hf_token,
528
  )
529
 
530
+ await self._start_agent_session(
531
+ agent_session=agent_session,
532
+ event_queue=event_queue,
533
+ tool_router=tool_router,
 
 
534
  )
535
+ await self.persist_session_snapshot(agent_session, runtime_state="idle")
536
 
537
  logger.info(f"Created session {session_id} for user {user_id}")
538
  return session_id
 
598
  ),
599
  )
600
  session.context_manager.items.append(seed)
601
+ await self.persist_session_snapshot(agent_session, runtime_state="idle")
602
  return len(parsed)
603
 
604
  @staticmethod
 
669
  should_continue = await process_submission(session, submission)
670
  finally:
671
  agent_session.is_processing = False
672
+ await self.persist_session_snapshot(agent_session)
673
  if not should_continue:
674
  break
675
  except asyncio.TimeoutError:
 
704
  async with self._lock:
705
  if session_id in self.sessions:
706
  self.sessions[session_id].is_active = False
707
+ await self.persist_session_snapshot(
708
+ self.sessions[session_id],
709
+ runtime_state="ended",
710
+ status="ended",
711
+ )
712
 
713
  logger.info(f"Session {session_id} ended")
714
 
 
758
  agent_session = self.sessions.get(session_id)
759
  if not agent_session or not agent_session.is_active:
760
  return False
761
+ success = agent_session.session.context_manager.truncate_to_user_message(user_message_index)
762
+ if success:
763
+ await self.persist_session_snapshot(agent_session, runtime_state="idle")
764
+ return success
765
 
766
  async def compact(self, session_id: str) -> bool:
767
  """Compact context in a session."""
 
786
  return success
787
 
788
  async def delete_session(self, session_id: str) -> bool:
789
+ """Soft-delete a session and stop its runtime resources."""
790
  async with self._lock:
791
  agent_session = self.sessions.pop(session_id, None)
792
 
793
  if not agent_session:
794
+ await self._store().soft_delete_session(session_id)
795
+ return True
796
+
797
+ await self._store().soft_delete_session(session_id)
798
 
799
  # Clean up sandbox Space before cancelling the task
800
  await self._cleanup_sandbox(agent_session.session)
 
809
 
810
  return True
811
 
812
+ async def update_session_title(self, session_id: str, title: str | None) -> None:
813
+ """Persist a user-visible title for sidebar rehydration."""
814
+ agent_session = self.sessions.get(session_id)
815
+ if agent_session:
816
+ agent_session.title = title
817
+ await self._store().update_session_fields(session_id, title=title)
818
+
819
+ async def update_session_model(self, session_id: str, model_id: str) -> bool:
820
+ agent_session = self.sessions.get(session_id)
821
+ if not agent_session or not agent_session.is_active:
822
+ return False
823
+ agent_session.session.update_model(model_id)
824
+ await self.persist_session_snapshot(agent_session, runtime_state="idle")
825
+ return True
826
+
827
  def get_session_owner(self, session_id: str) -> str | None:
828
  """Get the user_id that owns a session, or None if session doesn't exist."""
829
  agent_session = self.sessions.get(session_id)
 
851
  if not agent_session:
852
  return None
853
 
854
+ pending_approval = self._pending_tools_for_api(agent_session.session)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
855
 
856
  return {
857
  "session_id": session_id,
 
862
  "user_id": agent_session.user_id,
863
  "pending_approval": pending_approval,
864
  "model": agent_session.session.config.model_name,
865
+ "title": agent_session.title,
866
  "notification_destinations": list(
867
  agent_session.session.notification_destinations
868
  ),
 
896
  agent_session.session.set_notification_destinations(normalized)
897
  return normalized
898
 
899
+ async def list_sessions(self, user_id: str | None = None) -> list[dict[str, Any]]:
900
  """List sessions, optionally filtered by user.
901
 
902
  Args:
903
  user_id: If provided, only return sessions owned by this user.
904
  If "dev", return all sessions (dev mode).
905
  """
906
+ results: list[dict[str, Any]] = []
907
+ store = self._store()
908
+ if getattr(store, "enabled", False):
909
+ for row in await store.list_sessions(user_id or "dev"):
910
+ sid = row.get("session_id") or row.get("_id")
911
+ if not sid:
912
+ continue
913
+ runtime_info = self.get_session_info(str(sid))
914
+ if runtime_info:
915
+ results.append(runtime_info)
916
+ continue
917
+ created_at = row.get("created_at")
918
+ if isinstance(created_at, datetime):
919
+ created_at_str = created_at.isoformat()
920
+ else:
921
+ created_at_str = str(created_at or datetime.utcnow().isoformat())
922
+ pending = self._pending_docs_for_api(row.get("pending_approval") or [])
923
+ results.append(
924
+ {
925
+ "session_id": str(sid),
926
+ "created_at": created_at_str,
927
+ "is_active": row.get("status") != "ended",
928
+ "is_processing": row.get("runtime_state") == "processing",
929
+ "message_count": int(row.get("message_count") or 0),
930
+ "user_id": row.get("user_id") or "dev",
931
+ "pending_approval": pending or None,
932
+ "model": row.get("model"),
933
+ "title": row.get("title"),
934
+ "notification_destinations": row.get("notification_destinations") or [],
935
+ }
936
+ )
937
+ return results
938
+
939
  for sid in self.sessions:
940
  info = self.get_session_info(sid)
941
  if not info:
backend/user_quotas.py CHANGED
@@ -1,9 +1,8 @@
1
- """In-memory daily quota for Claude session creations.
2
 
3
  Tracks per-user Claude session starts against a daily cap derived from the
4
- user's HF plan. Caps reset at UTC midnight; the store itself is in-process
5
- and wipes on restart (deliberate — the cost of occasional over-subsidy at
6
- restart is much lower than running a DB).
7
 
8
  Unit: session *creations*, not messages. A user who selects Claude in a new
9
  session consumes one quota point; switching an existing Claude session to
@@ -18,6 +17,8 @@ import asyncio
18
  import os
19
  from datetime import UTC, datetime
20
 
 
 
21
  CLAUDE_FREE_DAILY: int = int(os.environ.get("CLAUDE_FREE_DAILY", "1"))
22
  CLAUDE_PRO_DAILY: int = int(os.environ.get("CLAUDE_PRO_DAILY", "20"))
23
 
@@ -37,6 +38,11 @@ def daily_cap_for(plan: str | None) -> int:
37
 
38
  async def get_claude_used_today(user_id: str) -> int:
39
  """Return today's Claude session count for the user (0 if none / stale day)."""
 
 
 
 
 
40
  async with _lock:
41
  entry = _claude_counts.get(user_id)
42
  if entry is None:
@@ -51,11 +57,37 @@ async def get_claude_used_today(user_id: str) -> int:
51
 
52
  async def increment_claude(user_id: str) -> int:
53
  """Bump today's Claude session count for the user. Returns the new value."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  async with _lock:
55
  today = _today()
56
  day, count = _claude_counts.get(user_id, (today, 0))
57
  if day != today:
58
  count = 0
 
 
59
  count += 1
60
  _claude_counts[user_id] = (today, count)
61
  return count
@@ -63,6 +95,11 @@ async def increment_claude(user_id: str) -> int:
63
 
64
  async def refund_claude(user_id: str) -> None:
65
  """Decrement today's count — used when session creation fails after a successful gate."""
 
 
 
 
 
66
  async with _lock:
67
  entry = _claude_counts.get(user_id)
68
  if entry is None:
@@ -81,3 +118,4 @@ async def refund_claude(user_id: str) -> None:
81
  def _reset_for_tests() -> None:
82
  """Test-only: clear the in-memory store."""
83
  _claude_counts.clear()
 
 
1
+ """Daily quota for Claude session creations.
2
 
3
  Tracks per-user Claude session starts against a daily cap derived from the
4
+ user's HF plan. MongoDB is the source of truth when configured; the
5
+ in-process dict remains the fallback for local/dev/test runs.
 
6
 
7
  Unit: session *creations*, not messages. A user who selects Claude in a new
8
  session consumes one quota point; switching an existing Claude session to
 
17
  import os
18
  from datetime import UTC, datetime
19
 
20
+ from agent.core.session_persistence import NoopSessionStore, get_session_store, _reset_store_for_tests
21
+
22
  CLAUDE_FREE_DAILY: int = int(os.environ.get("CLAUDE_FREE_DAILY", "1"))
23
  CLAUDE_PRO_DAILY: int = int(os.environ.get("CLAUDE_PRO_DAILY", "20"))
24
 
 
38
 
39
  async def get_claude_used_today(user_id: str) -> int:
40
  """Return today's Claude session count for the user (0 if none / stale day)."""
41
+ store = get_session_store()
42
+ if getattr(store, "enabled", False):
43
+ db_count = await store.get_quota(user_id, _today())
44
+ return db_count or 0
45
+
46
  async with _lock:
47
  entry = _claude_counts.get(user_id)
48
  if entry is None:
 
57
 
58
  async def increment_claude(user_id: str) -> int:
59
  """Bump today's Claude session count for the user. Returns the new value."""
60
+ store = get_session_store()
61
+ if getattr(store, "enabled", False):
62
+ db_count = await store.try_increment_quota(user_id, _today(), cap=10**9)
63
+ return db_count or 0
64
+
65
+ async with _lock:
66
+ today = _today()
67
+ day, count = _claude_counts.get(user_id, (today, 0))
68
+ if day != today:
69
+ count = 0
70
+ count += 1
71
+ _claude_counts[user_id] = (today, count)
72
+ return count
73
+
74
+
75
+ async def try_increment_claude(user_id: str, cap: int) -> int | None:
76
+ """Atomically bump today's count if below *cap*.
77
+
78
+ Returns the new count, or None when the user is already at the cap.
79
+ """
80
+ store = get_session_store()
81
+ if getattr(store, "enabled", False):
82
+ return await store.try_increment_quota(user_id, _today(), cap)
83
+
84
  async with _lock:
85
  today = _today()
86
  day, count = _claude_counts.get(user_id, (today, 0))
87
  if day != today:
88
  count = 0
89
+ if count >= cap:
90
+ return None
91
  count += 1
92
  _claude_counts[user_id] = (today, count)
93
  return count
 
95
 
96
  async def refund_claude(user_id: str) -> None:
97
  """Decrement today's count — used when session creation fails after a successful gate."""
98
+ store = get_session_store()
99
+ if getattr(store, "enabled", False):
100
+ await store.refund_quota(user_id, _today())
101
+ return
102
+
103
  async with _lock:
104
  entry = _claude_counts.get(user_id)
105
  if entry is None:
 
118
  def _reset_for_tests() -> None:
119
  """Test-only: clear the in-memory store."""
120
  _claude_counts.clear()
121
+ _reset_store_for_tests(NoopSessionStore())
frontend/src/components/SessionSidebar/SessionSidebar.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useCallback, useState } from 'react';
2
  import {
3
  Alert,
4
  Box,
@@ -25,13 +25,30 @@ interface SessionSidebarProps {
25
  }
26
 
27
  export default function SessionSidebar({ onClose }: SessionSidebarProps) {
28
- const { sessions, activeSessionId, createSession, deleteSession, switchSession } =
29
  useSessionStore();
30
  const { setPlan, clearPanel } =
31
  useAgentStore();
32
  const [isCreatingSession, setIsCreatingSession] = useState(false);
33
  const [capacityError, setCapacityError] = useState<string | null>(null);
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  // -- Handlers -----------------------------------------------------------
36
 
37
  const handleNewSession = useCallback(async () => {
 
1
+ import { useCallback, useEffect, useState } from 'react';
2
  import {
3
  Alert,
4
  Box,
 
25
  }
26
 
27
  export default function SessionSidebar({ onClose }: SessionSidebarProps) {
28
+ const { sessions, activeSessionId, createSession, deleteSession, switchSession, mergeServerSessions } =
29
  useSessionStore();
30
  const { setPlan, clearPanel } =
31
  useAgentStore();
32
  const [isCreatingSession, setIsCreatingSession] = useState(false);
33
  const [capacityError, setCapacityError] = useState<string | null>(null);
34
 
35
+ useEffect(() => {
36
+ let cancelled = false;
37
+ (async () => {
38
+ try {
39
+ const response = await apiFetch('/api/sessions');
40
+ if (!response.ok) return;
41
+ const data = await response.json();
42
+ if (!cancelled && Array.isArray(data)) {
43
+ mergeServerSessions(data);
44
+ }
45
+ } catch {
46
+ /* local sidebar metadata is still usable */
47
+ }
48
+ })();
49
+ return () => { cancelled = true; };
50
+ }, [mergeServerSessions]);
51
+
52
  // -- Handlers -----------------------------------------------------------
53
 
54
  const handleNewSession = useCallback(async () => {
frontend/src/hooks/useAgentChat.ts CHANGED
@@ -371,7 +371,7 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
371
  } catch {
372
  return null;
373
  }
374
- }, [sessionId, setNeedsAttention]);
375
 
376
  // -- useChat from Vercel AI SDK -----------------------------------------
377
  const chat = useChat({
@@ -621,7 +621,10 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
621
  /** Read the event stream from GET /api/events and forward to side-channel. */
622
  const consumeEventStream = async (signal: AbortSignal) => {
623
  try {
624
- const res = await apiFetch(`/api/events/${sessionId}`, {
 
 
 
625
  headers: { 'Accept': 'text/event-stream' },
626
  signal,
627
  });
@@ -629,6 +632,71 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
629
 
630
  const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
631
  let buf = '';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
632
  while (true) {
633
  const { value, done } = await reader.read();
634
  if (done || signal.aborted) break;
@@ -636,59 +704,21 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
636
  const lines = buf.split('\n');
637
  buf = lines.pop() || '';
638
  for (const line of lines) {
639
- const trimmed = line.trim();
640
- if (!trimmed.startsWith('data: ')) continue;
641
- try {
642
- const event = JSON.parse(trimmed.slice(6));
643
- // Forward to side-channel for real-time UI updates
644
- const et = event.event_type as string;
645
- if (et === 'processing') sideChannel.onProcessing();
646
- else if (et === 'assistant_chunk') sideChannel.onStreaming();
647
- else if (et === 'tool_call') {
648
- const t = event.data?.tool as string;
649
- const d = event.data?.arguments?.description as string | undefined;
650
- sideChannel.onToolRunning(t, d);
651
- sideChannel.onToolCallPanel(t, (event.data?.arguments || {}) as Record<string, unknown>);
652
- } else if (et === 'tool_output') {
653
- sideChannel.onToolOutputPanel(
654
- event.data?.tool as string,
655
- event.data?.tool_call_id as string,
656
- event.data?.output as string,
657
- event.data?.success as boolean,
658
- );
659
- } else if (et === 'tool_state_change') {
660
- const state = event.data?.state as string;
661
- const toolName = event.data?.tool as string;
662
- if (state === 'running' && toolName) sideChannel.onToolRunning(toolName);
663
- } else if (et === 'turn_complete' || et === 'error' || et === 'interrupted') {
664
- sideChannel.onProcessingDone();
665
- stopReconnect();
666
- // Final hydration to get the complete message state
667
- const result = await hydrateMessages();
668
- if (result) {
669
- const uiMsgs = llmMessagesToUIMessages(result.data, result.pendingIds, chatActionsRef.current.messages);
670
- if (uiMsgs.length > 0) {
671
- chat.setMessages(uiMsgs);
672
- saveMessages(sessionId, uiMsgs);
673
- }
674
- }
675
- return;
676
- } else if (et === 'approval_required') {
677
- sideChannel.onApprovalRequired(
678
- (event.data?.tools || []) as Array<{ tool: string; arguments: Record<string, unknown>; tool_call_id: string }>,
679
- );
680
- stopReconnect();
681
- const result = await hydrateMessages();
682
- if (result) {
683
- const uiMsgs = llmMessagesToUIMessages(result.data, result.pendingIds, chatActionsRef.current.messages);
684
- if (uiMsgs.length > 0) {
685
- chat.setMessages(uiMsgs);
686
- saveMessages(sessionId, uiMsgs);
687
- }
688
- }
689
- return;
690
- }
691
- } catch { /* ignore parse errors */ }
692
  }
693
  }
694
  } catch {
 
371
  } catch {
372
  return null;
373
  }
374
+ }, [sessionId, setNeedsAttention, updateSession]);
375
 
376
  // -- useChat from Vercel AI SDK -----------------------------------------
377
  const chat = useChat({
 
621
  /** Read the event stream from GET /api/events and forward to side-channel. */
622
  const consumeEventStream = async (signal: AbortSignal) => {
623
  try {
624
+ const lastEventKey = `hf-agent-last-event:${sessionId}`;
625
+ const lastSeq = localStorage.getItem(lastEventKey);
626
+ const qs = lastSeq ? `?after=${encodeURIComponent(lastSeq)}` : '';
627
+ const res = await apiFetch(`/api/events/${sessionId}${qs}`, {
628
  headers: { 'Accept': 'text/event-stream' },
629
  signal,
630
  });
 
632
 
633
  const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
634
  let buf = '';
635
+ let eventId: string | null = null;
636
+ let eventData = '';
637
+ const dispatch = async () => {
638
+ if (!eventData.trim()) {
639
+ eventId = null;
640
+ eventData = '';
641
+ return false;
642
+ }
643
+ const event = JSON.parse(eventData.trim());
644
+ const seq = event.seq ?? (eventId ? Number(eventId) : undefined);
645
+ if (Number.isFinite(seq)) {
646
+ localStorage.setItem(lastEventKey, String(seq));
647
+ }
648
+ eventId = null;
649
+ eventData = '';
650
+ // Forward to side-channel for real-time UI updates
651
+ const et = event.event_type as string;
652
+ if (et === 'processing') sideChannel.onProcessing();
653
+ else if (et === 'assistant_chunk') sideChannel.onStreaming();
654
+ else if (et === 'tool_call') {
655
+ const t = event.data?.tool as string;
656
+ const d = event.data?.arguments?.description as string | undefined;
657
+ sideChannel.onToolRunning(t, d);
658
+ sideChannel.onToolCallPanel(t, (event.data?.arguments || {}) as Record<string, unknown>);
659
+ } else if (et === 'tool_output') {
660
+ sideChannel.onToolOutputPanel(
661
+ event.data?.tool as string,
662
+ event.data?.tool_call_id as string,
663
+ event.data?.output as string,
664
+ event.data?.success as boolean,
665
+ );
666
+ } else if (et === 'tool_state_change') {
667
+ const state = event.data?.state as string;
668
+ const toolName = event.data?.tool as string;
669
+ if (state === 'running' && toolName) sideChannel.onToolRunning(toolName);
670
+ } else if (et === 'turn_complete' || et === 'error' || et === 'interrupted') {
671
+ sideChannel.onProcessingDone();
672
+ stopReconnect();
673
+ // Final hydration to get the complete message state
674
+ const result = await hydrateMessages();
675
+ if (result) {
676
+ const uiMsgs = llmMessagesToUIMessages(result.data, result.pendingIds, chatActionsRef.current.messages);
677
+ if (uiMsgs.length > 0) {
678
+ chat.setMessages(uiMsgs);
679
+ saveMessages(sessionId, uiMsgs);
680
+ }
681
+ }
682
+ return true;
683
+ } else if (et === 'approval_required') {
684
+ sideChannel.onApprovalRequired(
685
+ (event.data?.tools || []) as Array<{ tool: string; arguments: Record<string, unknown>; tool_call_id: string }>,
686
+ );
687
+ stopReconnect();
688
+ const result = await hydrateMessages();
689
+ if (result) {
690
+ const uiMsgs = llmMessagesToUIMessages(result.data, result.pendingIds, chatActionsRef.current.messages);
691
+ if (uiMsgs.length > 0) {
692
+ chat.setMessages(uiMsgs);
693
+ saveMessages(sessionId, uiMsgs);
694
+ }
695
+ }
696
+ return true;
697
+ }
698
+ return false;
699
+ };
700
  while (true) {
701
  const { value, done } = await reader.read();
702
  if (done || signal.aborted) break;
 
704
  const lines = buf.split('\n');
705
  buf = lines.pop() || '';
706
  for (const line of lines) {
707
+ const trimmed = line.replace(/\r$/, '');
708
+ if (trimmed === '') {
709
+ try {
710
+ if (await dispatch()) return;
711
+ } catch { /* ignore parse errors */ }
712
+ continue;
713
+ }
714
+ if (trimmed.startsWith(':')) continue;
715
+ if (trimmed.startsWith('id:')) {
716
+ eventId = trimmed.slice(3).trim();
717
+ continue;
718
+ }
719
+ if (trimmed.startsWith('data:')) {
720
+ eventData += trimmed.slice(5).trimStart() + '\n';
721
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
722
  }
723
  }
724
  } catch {
frontend/src/lib/sse-chat-transport.ts CHANGED
@@ -42,35 +42,66 @@ function nextPartId(prefix: string): string {
42
  return `${prefix}-${Date.now()}-${++partIdCounter}`;
43
  }
44
 
 
 
 
 
45
  /** Parse an SSE text stream into AgentEvent objects. */
46
- function createSSEParserStream(): TransformStream<string, AgentEvent> {
47
  let buffer = '';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  return new TransformStream<string, AgentEvent>({
49
  transform(chunk, controller) {
50
  buffer += chunk;
51
  const lines = buffer.split('\n');
52
  // Keep the last (possibly incomplete) line in the buffer
53
  buffer = lines.pop() || '';
54
- for (const line of lines) {
55
- const trimmed = line.trim();
56
- if (trimmed.startsWith('data: ')) {
57
- try {
58
- const json = JSON.parse(trimmed.slice(6));
59
- controller.enqueue(json as AgentEvent);
60
- } catch {
61
- logger.warn('SSE parse error:', trimmed);
62
- }
 
 
63
  }
64
  }
65
  },
66
  flush(controller) {
67
- // Process any remaining data in buffer
68
- if (buffer.trim().startsWith('data: ')) {
69
- try {
70
- const json = JSON.parse(buffer.trim().slice(6));
71
- controller.enqueue(json as AgentEvent);
72
- } catch { /* ignore incomplete */ }
73
  }
 
74
  },
75
  });
76
  }
@@ -426,7 +457,7 @@ export class SSEChatTransport implements ChatTransport<UIMessage> {
426
  // Pipe: response bytes → text → SSE events → UIMessageChunks
427
  return response.body
428
  .pipeThrough(new TextDecoderStream())
429
- .pipeThrough(createSSEParserStream())
430
  .pipeThrough(createEventToChunkStream(this.sideChannel));
431
  }
432
 
@@ -441,7 +472,9 @@ export class SSEChatTransport implements ChatTransport<UIMessage> {
441
  if (!info.is_processing) return null;
442
 
443
  // Session is mid-turn — subscribe to its event broadcast.
444
- const response = await apiFetch(`/api/events/${this.sessionId}`, {
 
 
445
  headers: { 'Accept': 'text/event-stream' },
446
  });
447
  if (!response.ok || !response.body) return null;
@@ -450,7 +483,7 @@ export class SSEChatTransport implements ChatTransport<UIMessage> {
450
 
451
  return response.body
452
  .pipeThrough(new TextDecoderStream())
453
- .pipeThrough(createSSEParserStream())
454
  .pipeThrough(createEventToChunkStream(this.sideChannel));
455
  } catch {
456
  return null;
 
42
  return `${prefix}-${Date.now()}-${++partIdCounter}`;
43
  }
44
 
45
+ function lastEventKey(sessionId: string): string {
46
+ return `hf-agent-last-event:${sessionId}`;
47
+ }
48
+
49
  /** Parse an SSE text stream into AgentEvent objects. */
50
+ function createSSEParserStream(sessionId: string): TransformStream<string, AgentEvent> {
51
  let buffer = '';
52
+ let eventId: string | null = null;
53
+ let data = '';
54
+
55
+ const dispatch = (controller: TransformStreamDefaultController<AgentEvent>) => {
56
+ if (!data.trim()) {
57
+ eventId = null;
58
+ data = '';
59
+ return;
60
+ }
61
+ try {
62
+ const json = JSON.parse(data.trim()) as AgentEvent;
63
+ const seq = json.seq ?? (eventId ? Number(eventId) : undefined);
64
+ if (Number.isFinite(seq)) {
65
+ json.seq = seq;
66
+ localStorage.setItem(lastEventKey(sessionId), String(seq));
67
+ }
68
+ controller.enqueue(json);
69
+ } catch {
70
+ logger.warn('SSE parse error:', data.trim());
71
+ } finally {
72
+ eventId = null;
73
+ data = '';
74
+ }
75
+ };
76
+
77
  return new TransformStream<string, AgentEvent>({
78
  transform(chunk, controller) {
79
  buffer += chunk;
80
  const lines = buffer.split('\n');
81
  // Keep the last (possibly incomplete) line in the buffer
82
  buffer = lines.pop() || '';
83
+ for (const rawLine of lines) {
84
+ const line = rawLine.replace(/\r$/, '');
85
+ if (line === '') {
86
+ dispatch(controller);
87
+ continue;
88
+ }
89
+ if (line.startsWith(':')) continue;
90
+ if (line.startsWith('id:')) {
91
+ eventId = line.slice(3).trim();
92
+ } else if (line.startsWith('data:')) {
93
+ data += line.slice(5).trimStart() + '\n';
94
  }
95
  }
96
  },
97
  flush(controller) {
98
+ const line = buffer.replace(/\r$/, '');
99
+ if (line.startsWith('id:')) {
100
+ eventId = line.slice(3).trim();
101
+ } else if (line.startsWith('data:')) {
102
+ data += line.slice(5).trimStart() + '\n';
 
103
  }
104
+ dispatch(controller);
105
  },
106
  });
107
  }
 
457
  // Pipe: response bytes → text → SSE events → UIMessageChunks
458
  return response.body
459
  .pipeThrough(new TextDecoderStream())
460
+ .pipeThrough(createSSEParserStream(sessionId))
461
  .pipeThrough(createEventToChunkStream(this.sideChannel));
462
  }
463
 
 
472
  if (!info.is_processing) return null;
473
 
474
  // Session is mid-turn — subscribe to its event broadcast.
475
+ const lastSeq = localStorage.getItem(lastEventKey(this.sessionId));
476
+ const qs = lastSeq ? `?after=${encodeURIComponent(lastSeq)}` : '';
477
+ const response = await apiFetch(`/api/events/${this.sessionId}${qs}`, {
478
  headers: { 'Accept': 'text/event-stream' },
479
  });
480
  if (!response.ok || !response.body) return null;
 
483
 
484
  return response.body
485
  .pipeThrough(new TextDecoderStream())
486
+ .pipeThrough(createSSEParserStream(this.sessionId))
487
  .pipeThrough(createEventToChunkStream(this.sideChannel));
488
  } catch {
489
  return null;
frontend/src/store/sessionStore.ts CHANGED
@@ -20,6 +20,14 @@ interface SessionStore {
20
  markExpired: (id: string) => void;
21
  /** Clear the expired flag (used after restore-with-summary succeeds). */
22
  clearExpired: (id: string) => void;
 
 
 
 
 
 
 
 
23
  /** Atomically swap a session's id in the list + both localStorage caches.
24
  * Used when we rehydrate an expired session into a freshly-created backend
25
  * session — preserves title, timestamps, and messages. */
@@ -76,6 +84,45 @@ export const useSessionStore = create<SessionStore>()(
76
  }));
77
  },
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  renameSession: (oldId: string, newId: string) => {
80
  if (oldId === newId) return;
81
  moveMessages(oldId, newId);
 
20
  markExpired: (id: string) => void;
21
  /** Clear the expired flag (used after restore-with-summary succeeds). */
22
  clearExpired: (id: string) => void;
23
+ /** Merge durable server-side sessions into local sidebar metadata. */
24
+ mergeServerSessions: (sessions: Array<{
25
+ session_id: string;
26
+ title?: string | null;
27
+ created_at: string;
28
+ is_active?: boolean;
29
+ pending_approval?: unknown[] | null;
30
+ }>) => void;
31
  /** Atomically swap a session's id in the list + both localStorage caches.
32
  * Used when we rehydrate an expired session into a freshly-created backend
33
  * session — preserves title, timestamps, and messages. */
 
84
  }));
85
  },
86
 
87
+ mergeServerSessions: (serverSessions) => {
88
+ set((state) => {
89
+ const byId = new Map(state.sessions.map((s) => [s.id, s]));
90
+ const merged = [...state.sessions];
91
+ for (const server of serverSessions) {
92
+ const id = server.session_id;
93
+ if (!id) continue;
94
+ const existing = byId.get(id);
95
+ if (existing) {
96
+ const updated = {
97
+ ...existing,
98
+ title: server.title || existing.title,
99
+ isActive: server.is_active ?? existing.isActive,
100
+ needsAttention: Boolean(server.pending_approval?.length) || existing.needsAttention,
101
+ expired: false,
102
+ };
103
+ const idx = merged.findIndex((s) => s.id === id);
104
+ if (idx >= 0) merged[idx] = updated;
105
+ byId.set(id, updated);
106
+ continue;
107
+ }
108
+ const newSession: SessionMeta = {
109
+ id,
110
+ title: server.title || `Chat ${merged.length + 1}`,
111
+ createdAt: server.created_at || new Date().toISOString(),
112
+ isActive: server.is_active ?? true,
113
+ needsAttention: Boolean(server.pending_approval?.length),
114
+ expired: false,
115
+ };
116
+ merged.push(newSession);
117
+ byId.set(id, newSession);
118
+ }
119
+ return {
120
+ sessions: merged,
121
+ activeSessionId: state.activeSessionId || merged[merged.length - 1]?.id || null,
122
+ };
123
+ });
124
+ },
125
+
126
  renameSession: (oldId: string, newId: string) => {
127
  if (oldId === newId) return;
128
  moveMessages(oldId, newId);
frontend/src/types/events.ts CHANGED
@@ -24,6 +24,7 @@ export type EventType =
24
  export interface AgentEvent {
25
  event_type: EventType;
26
  data?: Record<string, unknown>;
 
27
  }
28
 
29
  export interface ReadyEventData {
 
24
  export interface AgentEvent {
25
  event_type: EventType;
26
  data?: Record<string, unknown>;
27
+ seq?: number;
28
  }
29
 
30
  export interface ReadyEventData {
pyproject.toml CHANGED
@@ -27,6 +27,7 @@ dependencies = [
27
  "httpx>=0.27.0",
28
  "websockets>=13.0",
29
  "apscheduler>=3.10,<4",
 
30
  ]
31
 
32
  [project.optional-dependencies]
 
27
  "httpx>=0.27.0",
28
  "websockets>=13.0",
29
  "apscheduler>=3.10,<4",
30
+ "pymongo>=4.17.0",
31
  ]
32
 
33
  [project.optional-dependencies]
tests/unit/test_session_manager_persistence.py ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regression tests for server-side session persistence restore/access."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import sys
7
+ from datetime import datetime, UTC
8
+ from pathlib import Path
9
+ from types import SimpleNamespace
10
+ from typing import Any
11
+
12
+ import pytest
13
+
14
+ _BACKEND_DIR = Path(__file__).resolve().parent.parent.parent / "backend"
15
+ if str(_BACKEND_DIR) not in sys.path:
16
+ sys.path.insert(0, str(_BACKEND_DIR))
17
+
18
+ from agent.core.session_persistence import NoopSessionStore # noqa: E402
19
+ from session_manager import AgentSession, SessionManager # noqa: E402
20
+
21
+
22
+ class FakeRuntimeSession:
23
+ def __init__(self, *, hf_token: str | None = None, model: str = "test-model"):
24
+ self.hf_token = hf_token
25
+ self.context_manager = SimpleNamespace(items=[])
26
+ self.pending_approval = None
27
+ self.turn_count = 0
28
+ self.config = SimpleNamespace(model_name=model)
29
+ self.notification_destinations = []
30
+
31
+
32
+ class RestoreStore(NoopSessionStore):
33
+ enabled = True
34
+
35
+ def __init__(
36
+ self,
37
+ *,
38
+ metadata: dict[str, Any] | None = None,
39
+ messages: list[dict[str, Any]] | None = None,
40
+ delay: float = 0,
41
+ ) -> None:
42
+ self.metadata = metadata or {
43
+ "session_id": "persisted-session",
44
+ "user_id": "owner",
45
+ "model": "test-model",
46
+ "created_at": datetime.now(UTC),
47
+ }
48
+ self.messages = messages or []
49
+ self.delay = delay
50
+ self.load_calls = 0
51
+
52
+ async def load_session(self, session_id: str, **_: Any) -> dict[str, Any] | None:
53
+ self.load_calls += 1
54
+ if self.delay:
55
+ await asyncio.sleep(self.delay)
56
+ metadata = dict(self.metadata)
57
+ metadata.setdefault("session_id", session_id)
58
+ metadata.setdefault("_id", session_id)
59
+ return {"metadata": metadata, "messages": self.messages}
60
+
61
+
62
+ def _manager_with_store(store: NoopSessionStore) -> SessionManager:
63
+ manager = object.__new__(SessionManager)
64
+ manager.config = SimpleNamespace(model_name="test-model")
65
+ manager.sessions = {}
66
+ manager._lock = asyncio.Lock()
67
+ manager.persistence_store = store
68
+ return manager
69
+
70
+
71
+ def _runtime_agent_session(
72
+ session_id: str,
73
+ *,
74
+ user_id: str = "owner",
75
+ hf_token: str | None = "owner-token",
76
+ ) -> AgentSession:
77
+ runtime_session = FakeRuntimeSession(hf_token=hf_token)
78
+ return AgentSession(
79
+ session_id=session_id,
80
+ session=runtime_session, # type: ignore[arg-type]
81
+ tool_router=object(), # type: ignore[arg-type]
82
+ submission_queue=asyncio.Queue(),
83
+ user_id=user_id,
84
+ hf_token=hf_token,
85
+ )
86
+
87
+
88
+ def _install_fake_runtime(manager: SessionManager) -> asyncio.Event:
89
+ stop = asyncio.Event()
90
+ manager.run_calls = 0 # type: ignore[attr-defined]
91
+
92
+ def fake_create_session_sync(**kwargs: Any):
93
+ return object(), FakeRuntimeSession(
94
+ hf_token=kwargs.get("hf_token"),
95
+ model=kwargs.get("model") or "test-model",
96
+ )
97
+
98
+ async def fake_run_session(*_: Any) -> None:
99
+ manager.run_calls += 1 # type: ignore[attr-defined]
100
+ await stop.wait()
101
+
102
+ manager._create_session_sync = fake_create_session_sync # type: ignore[method-assign]
103
+ manager._run_session = fake_run_session # type: ignore[method-assign]
104
+ return stop
105
+
106
+
107
+ async def _cancel_runtime_tasks(manager: SessionManager) -> None:
108
+ tasks = [
109
+ agent_session.task
110
+ for agent_session in manager.sessions.values()
111
+ if agent_session.task and not agent_session.task.done()
112
+ ]
113
+ for task in tasks:
114
+ task.cancel()
115
+ if tasks:
116
+ await asyncio.gather(*tasks, return_exceptions=True)
117
+
118
+
119
+ @pytest.mark.asyncio
120
+ async def test_existing_session_rejects_cross_user_token_overwrite():
121
+ manager = _manager_with_store(NoopSessionStore())
122
+ existing = _runtime_agent_session("s1", user_id="victim", hf_token="victim-token")
123
+ manager.sessions["s1"] = existing
124
+
125
+ result = await manager.ensure_session_loaded(
126
+ "s1", user_id="attacker", hf_token="attacker-token"
127
+ )
128
+
129
+ assert result is None
130
+ assert existing.hf_token == "victim-token"
131
+ assert existing.session.hf_token == "victim-token"
132
+
133
+
134
+ @pytest.mark.asyncio
135
+ async def test_existing_session_updates_token_after_access_check():
136
+ manager = _manager_with_store(NoopSessionStore())
137
+ existing = _runtime_agent_session("s1", user_id="owner", hf_token="old-token")
138
+ manager.sessions["s1"] = existing
139
+
140
+ result = await manager.ensure_session_loaded(
141
+ "s1", user_id="owner", hf_token="new-token"
142
+ )
143
+
144
+ assert result is existing
145
+ assert existing.hf_token == "new-token"
146
+ assert existing.session.hf_token == "new-token"
147
+
148
+
149
+ @pytest.mark.asyncio
150
+ async def test_concurrent_lazy_restore_starts_only_one_agent_task():
151
+ store = RestoreStore(delay=0.01)
152
+ manager = _manager_with_store(store)
153
+ stop = _install_fake_runtime(manager)
154
+
155
+ try:
156
+ first, second = await asyncio.gather(
157
+ manager.ensure_session_loaded("persisted-session", user_id="owner"),
158
+ manager.ensure_session_loaded("persisted-session", user_id="owner"),
159
+ )
160
+ await asyncio.sleep(0)
161
+
162
+ assert first is second
163
+ assert list(manager.sessions) == ["persisted-session"]
164
+ assert manager.run_calls == 1 # type: ignore[attr-defined]
165
+ assert not stop.is_set()
166
+ finally:
167
+ stop.set()
168
+ await _cancel_runtime_tasks(manager)
169
+
170
+
171
+ @pytest.mark.asyncio
172
+ async def test_lazy_restore_preserves_pending_approval_tool_calls():
173
+ store = RestoreStore(
174
+ metadata={
175
+ "session_id": "approval-session",
176
+ "user_id": "owner",
177
+ "model": "test-model",
178
+ "pending_approval": [
179
+ {
180
+ "id": "call_123",
181
+ "type": "function",
182
+ "function": {
183
+ "name": "create_file",
184
+ "arguments": '{"path":"app.py"}',
185
+ },
186
+ }
187
+ ],
188
+ }
189
+ )
190
+ manager = _manager_with_store(store)
191
+ stop = _install_fake_runtime(manager)
192
+
193
+ try:
194
+ restored = await manager.ensure_session_loaded("approval-session", user_id="owner")
195
+
196
+ assert restored is not None
197
+ tool_calls = restored.session.pending_approval["tool_calls"]
198
+ assert len(tool_calls) == 1
199
+ assert tool_calls[0].id == "call_123"
200
+ assert tool_calls[0].function.name == "create_file"
201
+ assert tool_calls[0].function.arguments == '{"path":"app.py"}'
202
+ finally:
203
+ stop.set()
204
+ await _cancel_runtime_tasks(manager)
205
+
206
+
207
+ @pytest.mark.asyncio
208
+ async def test_list_sessions_dev_uses_store_dev_visibility():
209
+ class ListStore(NoopSessionStore):
210
+ enabled = True
211
+
212
+ def __init__(self) -> None:
213
+ self.seen_user_id: str | None = None
214
+
215
+ async def list_sessions(self, user_id: str, **_: Any) -> list[dict[str, Any]]:
216
+ self.seen_user_id = user_id
217
+ if user_id == "dev":
218
+ return [
219
+ {
220
+ "session_id": "s1",
221
+ "user_id": "alice",
222
+ "model": "m",
223
+ "created_at": datetime.now(UTC),
224
+ },
225
+ {
226
+ "session_id": "s2",
227
+ "user_id": "bob",
228
+ "model": "m",
229
+ "created_at": datetime.now(UTC),
230
+ },
231
+ ]
232
+ return []
233
+
234
+ store = ListStore()
235
+ manager = _manager_with_store(store)
236
+
237
+ sessions = await manager.list_sessions(user_id="dev")
238
+
239
+ assert store.seen_user_id == "dev"
240
+ assert {session["session_id"] for session in sessions} == {"s1", "s2"}
tests/unit/test_session_persistence.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for the optional durable session store abstraction."""
2
+
3
+ import pytest
4
+
5
+ from agent.core.session_persistence import NoopSessionStore, _safe_message_doc
6
+
7
+
8
+ @pytest.mark.asyncio
9
+ async def test_noop_store_keeps_local_cli_and_tests_db_free():
10
+ store = NoopSessionStore()
11
+
12
+ await store.init()
13
+ await store.upsert_session(session_id="s1", user_id="u1", model="m")
14
+ await store.save_snapshot(
15
+ session_id="s1",
16
+ user_id="u1",
17
+ model="m",
18
+ messages=[{"role": "user", "content": "hello"}],
19
+ )
20
+
21
+ assert await store.load_session("s1") is None
22
+ assert await store.list_sessions("u1") == []
23
+ assert await store.append_event("s1", "processing", {}) is None
24
+ assert await store.try_increment_quota("u1", "2099-01-01", 1) is None
25
+
26
+
27
+ def test_unsafe_message_payload_is_replaced_with_marker():
28
+ marker = _safe_message_doc({"role": "assistant", "content": object()})
29
+
30
+ assert marker["role"] == "tool"
31
+ assert marker["ml_intern_persistence_error"] == "message_too_large_or_invalid"
tests/unit/test_user_quotas.py CHANGED
@@ -15,6 +15,7 @@ if str(_BACKEND_DIR) not in sys.path:
15
  sys.path.insert(0, str(_BACKEND_DIR))
16
 
17
  import user_quotas # noqa: E402
 
18
 
19
 
20
  @pytest.fixture(autouse=True)
@@ -74,6 +75,33 @@ async def test_concurrent_increments_under_lock_do_not_lose_writes():
74
  assert await user_quotas.get_claude_used_today("race") == 50
75
 
76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  @pytest.mark.asyncio
78
  async def test_refund_decrements_and_drops_entry_at_zero():
79
  await user_quotas.increment_claude("u1")
 
15
  sys.path.insert(0, str(_BACKEND_DIR))
16
 
17
  import user_quotas # noqa: E402
18
+ from agent.core.session_persistence import NoopSessionStore, _reset_store_for_tests # noqa: E402
19
 
20
 
21
  @pytest.fixture(autouse=True)
 
75
  assert await user_quotas.get_claude_used_today("race") == 50
76
 
77
 
78
+ @pytest.mark.asyncio
79
+ async def test_try_increment_returns_none_at_cap():
80
+ assert await user_quotas.try_increment_claude("freebie", 1) == 1
81
+ assert await user_quotas.try_increment_claude("freebie", 1) is None
82
+ assert await user_quotas.get_claude_used_today("freebie") == 1
83
+
84
+
85
+ @pytest.mark.asyncio
86
+ async def test_try_increment_delegates_cap_to_enabled_store():
87
+ class StoreAtCap(NoopSessionStore):
88
+ enabled = True
89
+
90
+ async def try_increment_quota(self, user_id: str, day: str, cap: int):
91
+ assert user_id == "mongo-user"
92
+ assert cap == 1
93
+ return None
94
+
95
+ async def get_quota(self, user_id: str, day: str):
96
+ return 1
97
+
98
+ _reset_store_for_tests(StoreAtCap())
99
+
100
+ assert await user_quotas.try_increment_claude("mongo-user", 1) is None
101
+ assert await user_quotas.get_claude_used_today("mongo-user") == 1
102
+ assert "mongo-user" not in user_quotas._claude_counts
103
+
104
+
105
  @pytest.mark.asyncio
106
  async def test_refund_decrements_and_drops_entry_at_zero():
107
  await user_quotas.increment_claude("u1")
uv.lock CHANGED
@@ -1786,6 +1786,7 @@ dependencies = [
1786
  { name = "nbformat" },
1787
  { name = "prompt-toolkit" },
1788
  { name = "pydantic" },
 
1789
  { name = "python-dotenv" },
1790
  { name = "requests" },
1791
  { name = "rich" },
@@ -1833,6 +1834,7 @@ requires-dist = [
1833
  { name = "pandas", marker = "extra == 'eval'", specifier = ">=2.3.3" },
1834
  { name = "prompt-toolkit", specifier = ">=3.0.0" },
1835
  { name = "pydantic", specifier = ">=2.12.3" },
 
1836
  { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" },
1837
  { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.2.0" },
1838
  { name = "python-dotenv", specifier = ">=1.2.1" },
@@ -2769,6 +2771,67 @@ crypto = [
2769
  { name = "cryptography" },
2770
  ]
2771
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2772
  [[package]]
2773
  name = "pyperclip"
2774
  version = "1.11.0"
 
1786
  { name = "nbformat" },
1787
  { name = "prompt-toolkit" },
1788
  { name = "pydantic" },
1789
+ { name = "pymongo" },
1790
  { name = "python-dotenv" },
1791
  { name = "requests" },
1792
  { name = "rich" },
 
1834
  { name = "pandas", marker = "extra == 'eval'", specifier = ">=2.3.3" },
1835
  { name = "prompt-toolkit", specifier = ">=3.0.0" },
1836
  { name = "pydantic", specifier = ">=2.12.3" },
1837
+ { name = "pymongo", specifier = ">=4.17.0" },
1838
  { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" },
1839
  { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.2.0" },
1840
  { name = "python-dotenv", specifier = ">=1.2.1" },
 
2771
  { name = "cryptography" },
2772
  ]
2773
 
2774
+ [[package]]
2775
+ name = "pymongo"
2776
+ version = "4.17.0"
2777
+ source = { registry = "https://pypi.org/simple" }
2778
+ dependencies = [
2779
+ { name = "dnspython" },
2780
+ ]
2781
+ sdist = { url = "https://files.pythonhosted.org/packages/ca/64/50be6fbac9c79fe2e4c17401a467da2d8764d82833d83cec325afe5cab32/pymongo-4.17.0.tar.gz", hash = "sha256:70ffa08ba641468cc068cf46c06b34f01a8ce3489f6411309fcb5ceabe6b2fc0", size = 2523370, upload-time = "2026-04-20T16:39:53.524Z" }
2782
+ wheels = [
2783
+ { url = "https://files.pythonhosted.org/packages/c4/e2/336d86f221cf1b56b2ed9330d4a3b98f9f38f0b37829ae9a9184617d5419/pymongo-4.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4141e6c6a339789b2974efa00ecd9409101672d77a0e3ee2cc3839eedf8ec4df", size = 874668, upload-time = "2026-04-20T16:37:41.39Z" },
2784
+ { url = "https://files.pythonhosted.org/packages/34/8e/75d3c6c935d187ab59c61e9c15d9aab3f274b563eaf1706e8cae5f508dec/pymongo-4.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e68c76b84e0c132d9dbf9307f12ff8185702328187a87b9aca8c941303873433", size = 875294, upload-time = "2026-04-20T16:37:43.432Z" },
2785
+ { url = "https://files.pythonhosted.org/packages/5f/ec/62e855744489dbcd54fd778aae4d80fa4c4819e8fb228ca0cf6f21a03997/pymongo-4.17.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ba2195d4f386f839a52a23ea1cfd60ffaaba78a3d7841db51b7e433001139918", size = 1496233, upload-time = "2026-04-20T16:37:45.518Z" },
2786
+ { url = "https://files.pythonhosted.org/packages/82/e8/93e4e5e5ce8fdf8929dabeefe24aafa5ce046028eed0dfa8eeb936e72c49/pymongo-4.17.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8446ff4bfcb6ec2a2e50998c860986a1e992136f998b7f53e7a717fb8aa5a0b9", size = 1522927, upload-time = "2026-04-20T16:37:47.492Z" },
2787
+ { url = "https://files.pythonhosted.org/packages/f7/ca/425dc1d21e0f17bdea0072fc463f662f7fa06d2852af52975c9eced3c07c/pymongo-4.17.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2a0d5ac205728c86e0a02192f1aa5f865b0d7d51f8df6101c01a69a7fc620d72", size = 1583468, upload-time = "2026-04-20T16:37:49.221Z" },
2788
+ { url = "https://files.pythonhosted.org/packages/b3/9d/f08b07eeffda1a43c1759f0fa625e88ae12360996eb56d42aad832fa7dff/pymongo-4.17.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:485c8a8eaa4c739f00a331fc73757898ee7c092c214a79e63866ff76aaf282ff", size = 1572787, upload-time = "2026-04-20T16:37:51.061Z" },
2789
+ { url = "https://files.pythonhosted.org/packages/e9/c2/6855a07aafa7b894929af23675b6fb9634800ce43122b76a62f6eeb8da2a/pymongo-4.17.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b2dfcc795f5b9fedbe179a11fdf6051581479d196582a3fe819a92a00e9b9969", size = 1526184, upload-time = "2026-04-20T16:37:53.358Z" },
2790
+ { url = "https://files.pythonhosted.org/packages/4e/05/c952bac7db71c1942ea3559fcd308b49754cc5004b455935fb4000d1f37b/pymongo-4.17.0-cp311-cp311-win32.whl", hash = "sha256:c2292144505fb12156b981bd440f3dc994a883da06ac726c0c8692ccdbc1c510", size = 852621, upload-time = "2026-04-20T16:37:55.28Z" },
2791
+ { url = "https://files.pythonhosted.org/packages/11/c0/c04da9f4c0c6252404598f4e394b862a58a9e866822a70ae261c8a018fdf/pymongo-4.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:2e190827834fce70ecdf9d46796c6dbc0ce08ea87dc2ff5bc6f3f5579b605cb9", size = 867852, upload-time = "2026-04-20T16:37:57.233Z" },
2792
+ { url = "https://files.pythonhosted.org/packages/1d/b2/c7b4870fbeef471e947d3e014676f5910d02e0197074d692ebcf24ec049a/pymongo-4.17.0-cp311-cp311-win_arm64.whl", hash = "sha256:a8f9c40a09bb7d4b9fc8b1da65ecf6efa79bda5cb2756f39d9b6940fac1d19ae", size = 855019, upload-time = "2026-04-20T16:37:58.983Z" },
2793
+ { url = "https://files.pythonhosted.org/packages/98/90/60bcb508840135d5ee46b51b1a950f548338aa8145a8366dbe6639ae51ac/pymongo-4.17.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53ffa94b2340dbf6b055e09a0090618c60482c158ecfc9565642fc996bf0944", size = 930529, upload-time = "2026-04-20T16:38:00.936Z" },
2794
+ { url = "https://files.pythonhosted.org/packages/a6/e9/313840f1e52c6dfac47f704428cbfbce59956ebe7633bffc92b03f74f0ad/pymongo-4.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6fe0de9d0f6791abce3471230b32b4817bf89d27b1182b6a550e1ec0fa72aa9a", size = 930665, upload-time = "2026-04-20T16:38:02.915Z" },
2795
+ { url = "https://files.pythonhosted.org/packages/78/35/9d3565ea45b1606f635c1e2cd2563c28d66caafdc50f7ad7d979fcd1b363/pymongo-4.17.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e537e95514dae1aaa718f481ec03151a0f0394bcd05f1322896d8fc1330cb729", size = 1762369, upload-time = "2026-04-20T16:38:05.375Z" },
2796
+ { url = "https://files.pythonhosted.org/packages/95/ee/149b0d4b1a11c38bff6f14c23d5814c9b0843fd6dc38ad40596bdb1a62d2/pymongo-4.17.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37a8385c29881b43eab31f584100fa0eaddedd5607adf010147ba1810118be90", size = 1798044, upload-time = "2026-04-20T16:38:07.195Z" },
2797
+ { url = "https://files.pythonhosted.org/packages/7b/d4/4cee4a7b8d8f6f0550ef6cd2fea42455c5ed619a220cb6ba4fb40d6a5bc8/pymongo-4.17.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f3ee3d241ed77a4fc99ce3cff3b289c3ebce37f61fdd7349d3592c23b82c8784", size = 1878567, upload-time = "2026-04-20T16:38:09.121Z" },
2798
+ { url = "https://files.pythonhosted.org/packages/45/ef/7fe366c84952619ee2f69973566c214775e083dd4df465751912153e4b72/pymongo-4.17.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9eb5d63a3c518cb0804ed678f5e2b875af032d89a7cf57a57360322cf6a4d222", size = 1864881, upload-time = "2026-04-20T16:38:10.896Z" },
2799
+ { url = "https://files.pythonhosted.org/packages/2f/35/b577d82c6d1be7aee7ac7e249bc86f7847998345042e5f8360de238e177b/pymongo-4.17.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e97e03fa13327c87e3fdc5656acd01e71817f0c1dc3221cd8f30de136bf4ec3", size = 1800349, upload-time = "2026-04-20T16:38:13.589Z" },
2800
+ { url = "https://files.pythonhosted.org/packages/b8/69/dafcf04f66e130ddd91aeb92e7a692480eda46dcd04ec1dbe82c06619e10/pymongo-4.17.0-cp312-cp312-win32.whl", hash = "sha256:6877214bff5f06f6884a9fc8d9016a4a7a5f51f537f5c51ac3a576f93e7dfb32", size = 900518, upload-time = "2026-04-20T16:38:15.541Z" },
2801
+ { url = "https://files.pythonhosted.org/packages/11/35/5c9262a459f988b4eb2605f70815240b77a0d4131136c4326d18f1822b89/pymongo-4.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:9828485f72f63c7d802e0ec41f71906f633c2692621ab3af55ca990186b091b1", size = 920335, upload-time = "2026-04-20T16:38:17.665Z" },
2802
+ { url = "https://files.pythonhosted.org/packages/8d/da/e9c7265ee176faccf4e52c4797837e794d93569a1046f6b19a4acc36e5ad/pymongo-4.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:1195370a77baf003b59b10e91ecc4706297197f0dd9d29c840cc556dc08f7cee", size = 903289, upload-time = "2026-04-20T16:38:19.33Z" },
2803
+ { url = "https://files.pythonhosted.org/packages/2a/6b/c1206879708b94e82fcd8b9653440ec271f79a3674d122192df383047f5a/pymongo-4.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:809ec74de3b9148ae43fa8df9faf53470f511c8d384f13b99d6f671f2a379f15", size = 985829, upload-time = "2026-04-20T16:38:21.031Z" },
2804
+ { url = "https://files.pythonhosted.org/packages/cb/cf/bb044ed85160e5c40f568c7c4f4e8ea16f40764ff5d302e5befbe8f6f814/pymongo-4.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a431b737816bf4cddd4fa0fcef04e424ad36b7692734a64150f872fb8f3208be", size = 985899, upload-time = "2026-04-20T16:38:23.409Z" },
2805
+ { url = "https://files.pythonhosted.org/packages/74/0a/f6dfd5ea3901e5d6888da8de8ba728971a1d447debab681cfc56f90d1208/pymongo-4.17.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4fab10f8403169ce92f3cea921609d9ee81107306caae06c08f592d4b8ad2b5", size = 2028569, upload-time = "2026-04-20T16:38:25.343Z" },
2806
+ { url = "https://files.pythonhosted.org/packages/4a/c5/081f59a1c02ae8c0dc73ae58e563838c44eec81aeafa7d0b93a637841c9b/pymongo-4.17.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20323b0b1c1d33770ad1fc68d429c757734ce9ad3594421c3d6618f10572b1b9", size = 2072916, upload-time = "2026-04-20T16:38:27.291Z" },
2807
+ { url = "https://files.pythonhosted.org/packages/31/42/6e41d434297ffe8b30d9c3717916591a4a7be9075a0dcc2fafdfaaaa62ed/pymongo-4.17.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5a5de048e6da5c18e27cc2437e8c15b3b0cdc8385c15b41178b0caa3322a09c2", size = 2173234, upload-time = "2026-04-20T16:38:29.474Z" },
2808
+ { url = "https://files.pythonhosted.org/packages/3d/cf/1e4a7db352ef9485831c7268dfe8402f0117b32a9ad54b16e810699e3617/pymongo-4.17.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dff3de1294fbbc1db0ba6b511f77b8e540601d092538a31312e99c8a91a78b1e", size = 2156784, upload-time = "2026-04-20T16:38:32.134Z" },
2809
+ { url = "https://files.pythonhosted.org/packages/12/10/6195be29962a61ebb5f4bd9e4c7519890b172f7968a0a0d880398c6ddb02/pymongo-4.17.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faf03e4c2aafd6de626dbd30ba246d369ae33f47f10629d1bbe40f72115027a6", size = 2074446, upload-time = "2026-04-20T16:38:34.004Z" },
2810
+ { url = "https://files.pythonhosted.org/packages/37/48/33410b8819837ed370c738587306bdf060b59cef11823be212f4a07703c5/pymongo-4.17.0-cp313-cp313-win32.whl", hash = "sha256:c9786665926a09630c5d420c79762cfadbff35a9438bcbc4c81a9fb5ab9228b7", size = 948435, upload-time = "2026-04-20T16:38:35.922Z" },
2811
+ { url = "https://files.pythonhosted.org/packages/6f/77/c0ed522f798a286b99acaa7914ed8d9c80ab091f97f57c59ffed72906e5e/pymongo-4.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:5960519b4d7168f1ecdd3ea10c81b2aedeb9423651aca953cfbc8e76705d3b38", size = 972847, upload-time = "2026-04-20T16:38:37.888Z" },
2812
+ { url = "https://files.pythonhosted.org/packages/97/f0/c39480a2db385fde23861d0c8acda41cdaf1d43e46579db72c5c013a2e81/pymongo-4.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:0ff6bd2f735ab5356541e3e57d5b7dbfbc3f2ee1ccb10b6b0f82d58af69d1d8e", size = 951575, upload-time = "2026-04-20T16:38:40.544Z" },
2813
+ { url = "https://files.pythonhosted.org/packages/da/49/2b0250762a89737ed6f9cea238331baca061b89a8ddd10dd17fee52c3970/pymongo-4.17.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff5aa3f1c7e3f08eb0e7a016c91ba468b1850ccfd63d9b1f12f56350f4974cef", size = 1040945, upload-time = "2026-04-20T16:38:42.783Z" },
2814
+ { url = "https://files.pythonhosted.org/packages/89/1c/7a9b5447a08be20e84b6e5b17330917e8d6d9507daa3cd099a9309f11ad7/pymongo-4.17.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e816db649ba5d7de0568cf3a9f287a9dc9aad21cf0ca667ab156a7ef47fca0b0", size = 1041187, upload-time = "2026-04-20T16:38:45.358Z" },
2815
+ { url = "https://files.pythonhosted.org/packages/78/a1/71704f61632dfc90407a5834fe5f6132854937c4a3648f6c05c351d85a45/pymongo-4.17.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c4fded3a9f1d6a687e36ebd384ac6d00b9b00de1969aa74048e7051ec2a713", size = 2294806, upload-time = "2026-04-20T16:38:47.734Z" },
2816
+ { url = "https://files.pythonhosted.org/packages/ad/b9/aff42be75108b96c2469b1d9329b912c15108f3e7ef32fdc86da8423c330/pymongo-4.17.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2db66aa8dd253a0fc1fad3b0d23d5b3993f7ebde02fbbd7727128debf2853675", size = 2348231, upload-time = "2026-04-20T16:38:50.371Z" },
2817
+ { url = "https://files.pythonhosted.org/packages/f2/30/44c115b8ba1479942c15fd9480eb29a7da0ba68acd56983423ba0deb4a94/pymongo-4.17.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3987e96e7c7be4083d42e8ac2cc6c0d5b78db9973c90fce42ae800b616ca6b20", size = 2467614, upload-time = "2026-04-20T16:38:52.665Z" },
2818
+ { url = "https://files.pythonhosted.org/packages/d2/84/21ee95c8bf0ca7acae7ec7eb365d740bf8fc0156c194baf2c3bdfcb85ec0/pymongo-4.17.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cee36b3c0d0354f880fa7a7fdcdaf2bb5e542c2281e25c1bfadf8cfe21eba7d2", size = 2445970, upload-time = "2026-04-20T16:38:55.175Z" },
2819
+ { url = "https://files.pythonhosted.org/packages/06/89/081d7f1809d5ca09d1e47e49f2111b245f5694de3a7af32cd3a353a6f43f/pymongo-4.17.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:320b34457b20bbcc79997801f95d25ce00472915ca5241167242b42c4359e027", size = 2348605, upload-time = "2026-04-20T16:38:57.557Z" },
2820
+ { url = "https://files.pythonhosted.org/packages/ea/c3/0d949f9d3f2a341c1f635c398c16615e96f89f51ff424ed81e914cf1a4de/pymongo-4.17.0-cp314-cp314-win32.whl", hash = "sha256:df4a644af9ae132d4bfdb2e9516ea51a615fd881caddfbfbd071cf1354844479", size = 1004119, upload-time = "2026-04-20T16:39:00.309Z" },
2821
+ { url = "https://files.pythonhosted.org/packages/f7/55/5c3a3db1048054c695c75c5964cc8bedc2247fdb5a75ef6fab4ec8bb013e/pymongo-4.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:c797f8a80957134f6dd9690367a0f8f5906d672119af2c6aa55f0c527b656bed", size = 1032314, upload-time = "2026-04-20T16:39:02.665Z" },
2822
+ { url = "https://files.pythonhosted.org/packages/e0/19/e235f39906134cb0ffd5574c5a59c355ef5380f0499644ab94994afbb109/pymongo-4.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:68fca71e05ee5da23a8d73cee8379dfb3d26e609a377cae731d742771ed96946", size = 1007627, upload-time = "2026-04-20T16:39:04.678Z" },
2823
+ { url = "https://files.pythonhosted.org/packages/1e/e0/c4c1a86791415b14c684fa0908f9da96de91594a3fd1fa1b8dc689fbb800/pymongo-4.17.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b4384700cffc3f1dd98e088bc0072dedf6d7d68a230bb4b972665cf69c071c1e", size = 1099151, upload-time = "2026-04-20T16:39:06.969Z" },
2824
+ { url = "https://files.pythonhosted.org/packages/81/4b/69c67f3e23fd9b23b9bedc7ebd23754881cc9d5c5d5b2a9811e96b07f475/pymongo-4.17.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:93641192644fa1ee0f34030e774fd31022a27ad11ba22cb1716142231524f8bd", size = 1099346, upload-time = "2026-04-20T16:39:08.996Z" },
2825
+ { url = "https://files.pythonhosted.org/packages/a2/19/a5208f62f9508a26d73acc69bd3821b8c8adae253679a3c26d2f9652f0d5/pymongo-4.17.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:75bc3aa5b94fdb7138d357ec6ca61cd97e0c79f4f7f0bd3efe9639b15cc50942", size = 2619034, upload-time = "2026-04-20T16:39:11.049Z" },
2826
+ { url = "https://files.pythonhosted.org/packages/77/27/426cba1ec5973082a56d4150798529bfdf4151c31391ed1fbbecb23ef2ac/pymongo-4.17.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e8f8e23c6df7c6d6929f5e734980b227706e73ee847517c9ba5af90f7fc466", size = 2689939, upload-time = "2026-04-20T16:39:13.617Z" },
2827
+ { url = "https://files.pythonhosted.org/packages/ef/2e/f70993d1255e33f6ee59a4ec4371cc65bff7a7e3fda7d55c3386f25287e8/pymongo-4.17.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:15d3f3d732aecac1f8d481bde4029755615639bd3076f258a2147210aec8515a", size = 2824994, upload-time = "2026-04-20T16:39:16.057Z" },
2828
+ { url = "https://files.pythonhosted.org/packages/b3/eb/87b0e988ba889e1fcc3430c2cfc166b251872c813e92b43174298bee17ff/pymongo-4.17.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5f62862d0f87be481fa1fe8cb811994486773c94a2b61e509285e3f2890763", size = 2801745, upload-time = "2026-04-20T16:39:18.476Z" },
2829
+ { url = "https://files.pythonhosted.org/packages/67/4c/3f83412d086f682d4d468761d66ddc49cf161e786ea74073045eb4491c60/pymongo-4.17.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64837adbbd72073301af51bb0fc80e3d7707fe5527cea1033ba0320f0b2f881b", size = 2684636, upload-time = "2026-04-20T16:39:20.878Z" },
2830
+ { url = "https://files.pythonhosted.org/packages/9e/d8/b75f6f4ab6c8beb50b0270a4f1e2530b5774f5e116563440e1677ca1820f/pymongo-4.17.0-cp314-cp314t-win32.whl", hash = "sha256:b93b22eedc62598cf5ee9d8c8007a8e9121c50fd88137012d8985500e9dc3151", size = 1056356, upload-time = "2026-04-20T16:39:22.996Z" },
2831
+ { url = "https://files.pythonhosted.org/packages/e4/5e/648c8a238eef18a25ed8a169ea6542d4a860bbec3e95b3d9badac2935c71/pymongo-4.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3689ea34f6b647c7d1e7bdc60fcfb214b2789ed1359a7fb96569c69f50e5f18f", size = 1090964, upload-time = "2026-04-20T16:39:24.989Z" },
2832
+ { url = "https://files.pythonhosted.org/packages/dc/cb/d9780b66939c4fc1f024bcc7be23a2abcfe06a9745ca8fa76dc73395482e/pymongo-4.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9543d8f84c2e5608565c08ac679774811e6730770d8a645439b073422a4276fb", size = 1058526, upload-time = "2026-04-20T16:39:27.924Z" },
2833
+ ]
2834
+
2835
  [[package]]
2836
  name = "pyperclip"
2837
  version = "1.11.0"