|
1
|
""" |
|
2
|
SessionManager — branch-isolated session namespacing for agent swarms. |
|
3
|
|
|
4
|
Each session is tied to a git branch and owns a uniquely named FalkorDB graph |
|
5
|
so that multiple branches can be ingested and queried concurrently without |
|
6
|
graph data bleeding across branches. |
|
7
|
|
|
8
|
Usage: |
|
9
|
mgr = SessionManager("redis://localhost:6379") |
|
10
|
|
|
11
|
session_id = mgr.create_session("feature/my-branch", "agent-1") |
|
12
|
info = mgr.get_session(session_id) |
|
13
|
graph_name = mgr.session_graph_name(session_id) # e.g. "navegador:sess:abc123" |
|
14
|
|
|
15
|
for s in mgr.list_sessions(): |
|
16
|
print(s["session_id"], s["branch"]) |
|
17
|
|
|
18
|
mgr.end_session(session_id) |
|
19
|
""" |
|
20
|
|
|
21
|
from __future__ import annotations |
|
22
|
|
|
23
|
import hashlib |
|
24
|
import json |
|
25
|
import logging |
|
26
|
import time |
|
27
|
import uuid |
|
28
|
from typing import Any |
|
29
|
|
|
30
|
logger = logging.getLogger(__name__) |
|
31
|
|
|
32
|
_SESSIONS_KEY = "navegador:sessions" # Redis hash: session_id -> JSON |
|
33
|
_SESSION_INDEX_KEY = "navegador:sessions:ids" # Redis set: all session IDs |
|
34
|
|
|
35
|
|
|
36
|
def _make_session_id() -> str: |
|
37
|
return str(uuid.uuid4()) |
|
38
|
|
|
39
|
|
|
40
|
def _graph_name_from_session_id(session_id: str) -> str: |
|
41
|
"""Return a short, deterministic graph name for a session ID.""" |
|
42
|
short = hashlib.sha1(session_id.encode()).hexdigest()[:12] |
|
43
|
return f"navegador:sess:{short}" |
|
44
|
|
|
45
|
|
|
46
|
class SessionManager: |
|
47
|
""" |
|
48
|
Manage branch-isolated sessions for agent swarms. |
|
49
|
|
|
50
|
Each session has a unique graph namespace so agents working on different |
|
51
|
branches do not share graph state. |
|
52
|
|
|
53
|
Parameters |
|
54
|
---------- |
|
55
|
redis_url: |
|
56
|
URL of the Redis server. |
|
57
|
redis_client: |
|
58
|
Optional pre-built Redis client (for testing / DI). |
|
59
|
""" |
|
60
|
|
|
61
|
def __init__(self, redis_url: str, *, redis_client: Any = None) -> None: |
|
62
|
self.redis_url = redis_url |
|
63
|
self._redis = redis_client or self._connect_redis(redis_url) |
|
64
|
|
|
65
|
# ── Internal ────────────────────────────────────────────────────────────── |
|
66
|
|
|
67
|
@staticmethod |
|
68
|
def _connect_redis(url: str) -> Any: |
|
69
|
try: |
|
70
|
import redis # type: ignore[import] |
|
71
|
except ImportError as exc: |
|
72
|
raise ImportError("Install redis: pip install redis") from exc |
|
73
|
return redis.from_url(url) |
|
74
|
|
|
75
|
def _save_session(self, session_id: str, data: dict[str, Any]) -> None: |
|
76
|
pipe = self._redis.pipeline() |
|
77
|
pipe.hset(_SESSIONS_KEY, session_id, json.dumps(data)) |
|
78
|
pipe.sadd(_SESSION_INDEX_KEY, session_id) |
|
79
|
pipe.execute() |
|
80
|
|
|
81
|
def _load_session(self, session_id: str) -> dict[str, Any] | None: |
|
82
|
raw = self._redis.hget(_SESSIONS_KEY, session_id) |
|
83
|
if raw is None: |
|
84
|
return None |
|
85
|
text = raw.decode() if isinstance(raw, bytes) else raw |
|
86
|
return json.loads(text) |
|
87
|
|
|
88
|
# ── Public API ──────────────────────────────────────────────────────────── |
|
89
|
|
|
90
|
def create_session(self, branch: str, agent_id: str) -> str: |
|
91
|
""" |
|
92
|
Create a new isolated session for *agent_id* working on *branch*. |
|
93
|
|
|
94
|
Returns |
|
95
|
------- |
|
96
|
str |
|
97
|
The new session ID. |
|
98
|
""" |
|
99
|
session_id = _make_session_id() |
|
100
|
data: dict[str, Any] = { |
|
101
|
"session_id": session_id, |
|
102
|
"branch": branch, |
|
103
|
"agent_id": agent_id, |
|
104
|
"graph_name": _graph_name_from_session_id(session_id), |
|
105
|
"created_at": time.time(), |
|
106
|
"status": "active", |
|
107
|
} |
|
108
|
self._save_session(session_id, data) |
|
109
|
logger.info("Created session %s for branch %s / agent %s", session_id, branch, agent_id) |
|
110
|
return session_id |
|
111
|
|
|
112
|
def get_session(self, session_id: str) -> dict[str, Any]: |
|
113
|
""" |
|
114
|
Retrieve session metadata. |
|
115
|
|
|
116
|
Raises ``KeyError`` if the session does not exist. |
|
117
|
""" |
|
118
|
data = self._load_session(session_id) |
|
119
|
if data is None: |
|
120
|
raise KeyError(f"Session not found: {session_id}") |
|
121
|
return data |
|
122
|
|
|
123
|
def list_sessions(self) -> list[dict[str, Any]]: |
|
124
|
"""Return all sessions (active and ended).""" |
|
125
|
raw_ids = self._redis.smembers(_SESSION_INDEX_KEY) |
|
126
|
sessions = [] |
|
127
|
for raw_id in raw_ids: |
|
128
|
sid = raw_id.decode() if isinstance(raw_id, bytes) else raw_id |
|
129
|
data = self._load_session(sid) |
|
130
|
if data is not None: |
|
131
|
sessions.append(data) |
|
132
|
sessions.sort(key=lambda s: s.get("created_at", 0)) |
|
133
|
return sessions |
|
134
|
|
|
135
|
def end_session(self, session_id: str) -> None: |
|
136
|
"""Mark a session as ended (does not delete graph data).""" |
|
137
|
data = self.get_session(session_id) # raises KeyError if missing |
|
138
|
data["status"] = "ended" |
|
139
|
data["ended_at"] = time.time() |
|
140
|
self._save_session(session_id, data) |
|
141
|
logger.info("Session %s ended", session_id) |
|
142
|
|
|
143
|
def session_graph_name(self, session_id: str) -> str: |
|
144
|
""" |
|
145
|
Return the namespaced FalkorDB graph name for a session. |
|
146
|
|
|
147
|
The name is deterministic and safe to use as a FalkorDB graph key. |
|
148
|
""" |
|
149
|
data = self.get_session(session_id) |
|
150
|
return data["graph_name"] |
|
151
|
|