Skip to content

Commit b5d6a16

Browse files
feat(storage): BackendSessionStorage — KV-backed SessionStorage (#12)
Add an optional SessionStorage backed by an injected key/value backend (KVBackend protocol: stash/retrieve), for cross-host session continuity without writing the protocol by hand. Schema, 4h TTL, and legacy migration are shared with LocalFileStorage via extracted _serialize / _deserialize helpers. Backend errors never propagate: reads fall back to defaults, writes log-and-continue. attune-help imports no backend itself — the integrator injects one — so this adds no required dependency (ADR-002 intact). Export KVBackend + BackendSessionStorage from the package root; fix the README protocol example (get_session/set_session, not load/save). Implements specs/help-memory-tool (Option A) tasks 1-4, 6, 7. Task 5 ([memory] extra) dropped: attune_redis is bundled in attune-ai, not a standalone PyPI package, so there is nothing clean to pin — the backend is bring-your-own via injection. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4310cb6 commit b5d6a16

4 files changed

Lines changed: 346 additions & 21 deletions

File tree

README.md

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -213,16 +213,42 @@ HelpEngine(
213213

214214
### `SessionStorage` Protocol
215215

216-
Implement custom storage backends:
216+
Session depth state defaults to `LocalFileStorage` (per-user JSON files
217+
under `~/.attune-help/sessions/`, 4-hour TTL). Implement the protocol to
218+
plug in any backend:
217219

218220
```python
219221
from attune_help import SessionStorage
220222

221-
class RedisStorage(SessionStorage):
222-
def load(self, user_id: str) -> dict: ...
223-
def save(self, user_id: str, state: dict) -> None: ...
223+
class MyStorage(SessionStorage):
224+
def get_session(self, user_id: str) -> dict: ...
225+
def set_session(self, user_id: str, state: dict) -> None: ...
224226
```
225227

228+
#### `BackendSessionStorage` — bring your own key/value store
229+
230+
For cross-host continuity without writing the protocol yourself, inject
231+
any key/value backend (an `attune_redis` backend, attune's
232+
`MemoryBackend`, or a custom object exposing `stash`/`retrieve`).
233+
attune-help imports none of these, so this adds **no required
234+
dependency** (ADR-002 stays intact):
235+
236+
```python
237+
from attune_help import BackendSessionStorage, HelpEngine
238+
239+
class KVBackend: # your store — e.g. wrap Redis
240+
def stash(self, key: str, value: str) -> bool: ...
241+
def retrieve(self, key: str) -> str | None: ...
242+
243+
storage = BackendSessionStorage(my_backend) # same schema + 4h TTL
244+
engine = HelpEngine(storage=storage)
245+
```
246+
247+
Schema, TTL, and legacy migration match `LocalFileStorage` exactly —
248+
only the transport (a backend key instead of a file) differs. Backend
249+
errors never propagate into the runtime: reads fall back to defaults,
250+
writes log-and-continue.
251+
226252
## Staleness Detection
227253

228254
`attune-help` tracks whether your help templates are up to date with

src/attune_help/__init__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,19 @@
1515
TemplateContext,
1616
)
1717
from attune_help.preamble import get_preamble # noqa: F401
18-
from attune_help.storage import LocalFileStorage, SessionStorage
18+
from attune_help.storage import (
19+
BackendSessionStorage,
20+
KVBackend,
21+
LocalFileStorage,
22+
SessionStorage,
23+
)
1924

2025
__all__ = [
2126
# Engine
2227
"AudienceProfile",
28+
"BackendSessionStorage",
2329
"HelpEngine",
30+
"KVBackend",
2431
"LocalFileStorage",
2532
"PopulatedTemplate",
2633
"SessionStorage",

src/attune_help/storage.py

Lines changed: 140 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
4971
def _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+
96155
class 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

Comments
 (0)