@@ -46,6 +46,28 @@ def set_session(self, user_id: str, state: dict[str, Any]) -> None:
4646 ...
4747
4848
49+ class KVBackend (Protocol ):
50+ """Minimal key/value backend for :class:`BackendSessionStorage`.
51+
52+ Any object with these two methods works — an ``attune_redis``
53+ backend, attune's ``MemoryBackend``, or a hand-rolled dict wrapper.
54+ attune-help imports none of those; the integrator injects an
55+ instance, keeping the runtime dependency-free (tech.md ADR-002).
56+
57+ ``stash`` returns ``True`` on a successful write, ``False`` (or
58+ raises) on failure. ``retrieve`` returns the stored string, or
59+ ``None`` when the key is absent.
60+ """
61+
62+ def stash (self , key : str , value : str ) -> bool :
63+ """Persist ``value`` under ``key``. Return True on success."""
64+ ...
65+
66+ def retrieve (self , key : str ) -> str | None :
67+ """Return the value for ``key``, or None if absent."""
68+ ...
69+
70+
4971def _defaults () -> dict [str , Any ]:
5072 """Fresh session defaults."""
5173 return {
@@ -93,6 +115,43 @@ def _migrate_legacy(data: dict[str, Any]) -> dict[str, Any]:
93115 }
94116
95117
118+ def _serialize (state : dict [str , Any ]) -> str :
119+ """Render a session dict as a timestamped JSON line.
120+
121+ Shared by the file and backend storages so both persist the exact
122+ same schema (``last_topic``, ``depth_level``, ``topics``, ``order``,
123+ ``timestamp``).
124+ """
125+ payload = {
126+ "last_topic" : state .get ("last_topic" ),
127+ "depth_level" : state .get ("depth_level" , 0 ),
128+ "topics" : state .get ("topics" , {}),
129+ "order" : state .get ("order" , []),
130+ "timestamp" : time .time (),
131+ }
132+ return json .dumps (payload ) + "\n "
133+
134+
135+ def _deserialize (raw : str | None , ttl_seconds : float ) -> dict [str , Any ]:
136+ """Parse a stored payload, applying TTL expiry and legacy migration.
137+
138+ Returns fresh defaults when ``raw`` is missing, malformed, or older
139+ than ``ttl_seconds``. Shared by the file and backend storages.
140+ """
141+ if raw is None :
142+ return _defaults ()
143+ try :
144+ data = json .loads (raw )
145+ except (json .JSONDecodeError , TypeError ):
146+ return _defaults ()
147+ if not isinstance (data , dict ):
148+ return _defaults ()
149+ ts = data .get ("timestamp" , 0 )
150+ if time .time () - ts > ttl_seconds :
151+ return _defaults ()
152+ return _migrate_legacy (data )
153+
154+
96155class LocalFileStorage :
97156 """File-based session storage (default implementation).
98157
@@ -182,11 +241,7 @@ def get_session(self, user_id: str) -> dict[str, Any]:
182241 try :
183242 if not path .exists ():
184243 return defaults
185- data = json .loads (path .read_text (encoding = "utf-8" ))
186- ts = data .get ("timestamp" , 0 )
187- if time .time () - ts > self ._ttl :
188- return defaults
189- return _migrate_legacy (data )
244+ return _deserialize (path .read_text (encoding = "utf-8" ), self ._ttl )
190245 except (json .JSONDecodeError , OSError , KeyError ):
191246 return defaults
192247
@@ -205,17 +260,86 @@ def set_session(self, user_id: str, state: dict[str, Any]) -> None:
205260 try :
206261 self ._dir .mkdir (parents = True , exist_ok = True )
207262 tmp = path .with_suffix (".json.tmp" )
208- payload = {
209- "last_topic" : state .get ("last_topic" ),
210- "depth_level" : state .get ("depth_level" , 0 ),
211- "topics" : state .get ("topics" , {}),
212- "order" : state .get ("order" , []),
213- "timestamp" : time .time (),
214- }
215- tmp .write_text (
216- json .dumps (payload ) + "\n " ,
217- encoding = "utf-8" ,
218- )
263+ tmp .write_text (_serialize (state ), encoding = "utf-8" )
219264 tmp .replace (path ) # replace() is cross-platform
220265 except OSError as e :
221266 logger .warning ("Session write failed: %s" , e )
267+
268+
269+ class BackendSessionStorage :
270+ """Session storage backed by an injected key/value backend.
271+
272+ A drop-in :class:`SessionStorage` that delegates persistence to any
273+ :class:`KVBackend` (an ``attune_redis`` backend, attune's
274+ ``MemoryBackend``, or a custom wrapper). Useful when session state
275+ must survive across hosts/processes rather than a single machine's
276+ ``~/.attune-help/sessions/`` directory.
277+
278+ Schema, TTL, and legacy migration are identical to
279+ :class:`LocalFileStorage` — only the transport differs (a backend
280+ key instead of a file). attune-help imports no backend itself, so
281+ this adds no required dependency (tech.md ADR-002).
282+
283+ Args:
284+ backend: The key/value backend to delegate to.
285+ key_prefix: Namespace prepended to every key. Defaults to
286+ ``"helpsess"``.
287+ ttl_seconds: Session time-to-live. Defaults to 4 hours, matching
288+ :class:`LocalFileStorage`.
289+
290+ Example::
291+
292+ from attune_help import BackendSessionStorage, HelpEngine
293+ storage = BackendSessionStorage(my_redis_backend)
294+ engine = HelpEngine(storage=storage)
295+ """
296+
297+ def __init__ (
298+ self ,
299+ backend : KVBackend ,
300+ * ,
301+ key_prefix : str = "helpsess" ,
302+ ttl_seconds : int = _SESSION_TTL_SECONDS ,
303+ ) -> None :
304+ self ._backend = backend
305+ self ._prefix = key_prefix
306+ self ._ttl = ttl_seconds
307+
308+ def _key (self , user_id : str ) -> str :
309+ """Backend key for a user's session."""
310+ return f"{ self ._prefix } :{ user_id } "
311+
312+ def get_session (self , user_id : str ) -> dict [str , Any ]:
313+ """Load session state from the backend.
314+
315+ Returns fresh defaults on a miss, a malformed/expired payload, or
316+ any backend error — never raises into the runtime (matches
317+ :class:`LocalFileStorage`).
318+
319+ Args:
320+ user_id: User identifier.
321+
322+ Returns:
323+ Session state dict, or fresh defaults.
324+ """
325+ try :
326+ raw = self ._backend .retrieve (self ._key (user_id ))
327+ except Exception as e : # noqa: BLE001 - backend errors must not propagate
328+ logger .warning ("Session backend read failed: %s" , e )
329+ return _defaults ()
330+ return _deserialize (raw , self ._ttl )
331+
332+ def set_session (self , user_id : str , state : dict [str , Any ]) -> None :
333+ """Persist session state to the backend.
334+
335+ Logs and no-ops on any backend failure (matches
336+ :class:`LocalFileStorage`); never raises into the runtime.
337+
338+ Args:
339+ user_id: User identifier.
340+ state: Session state dict to persist.
341+ """
342+ try :
343+ self ._backend .stash (self ._key (user_id ), _serialize (state ))
344+ except Exception as e : # noqa: BLE001 - backend errors must not propagate
345+ logger .warning ("Session backend write failed: %s" , e )
0 commit comments