"""Per-notebook session manager for OpenCode. Maps notebookPath -> OpenCode sessionID. Lazy create on first use. Async-safe via per-path asyncio.Lock. No automatic cleanup. """ from __future__ import annotations import asyncio import logging from typing import Callable from .opencode_client import OpenCodeClient log = logging.getLogger("opencode_bridge.session_manager") ClientFactory = Callable[[], OpenCodeClient] class SessionManager: """Tracks one OpenCode session per notebook path. Threading/async model: - Multiple coroutines may call get_or_create for the same notebook. - First call creates; subsequent calls return the same sessionID. - Per-notebook asyncio.Lock prevents double-create under concurrency. - Locks remain in the map; they may be needed again for the same path. """ def __init__(self, client_factory: ClientFactory) -> None: self._client_factory = client_factory self._sessions: dict[str, str] = {} # notebookPath -> sessionID self._locks: dict[str, asyncio.Lock] = {} # notebookPath -> lock self._titles: dict[str, str] = {} # notebookPath -> title (for debug) async def get_or_create(self, notebook_path: str) -> str: """Return session ID for the notebook, creating one if needed. Idempotent for the same path. Different paths get different sessions. """ existing = self._sessions.get(notebook_path) if existing is not None: return existing lock = self._locks.setdefault(notebook_path, asyncio.Lock()) async with lock: existing = self._sessions.get(notebook_path) if existing is not None: return existing client = self._client_factory() session = await client.create_session( title="jupyter:%s" % notebook_path ) sid = session["id"] self._sessions[notebook_path] = sid self._titles[notebook_path] = notebook_path log.info("created opencode session %s for %s", sid, notebook_path) return sid async def release(self, notebook_path: str) -> bool: """Delete session and remove from map. Returns True if a session existed.""" sid = self._sessions.pop(notebook_path, None) self._titles.pop(notebook_path, None) self._locks.pop(notebook_path, None) if sid is None: return False try: client = self._client_factory() return await client.delete_session(sid) except Exception: log.warning( "failed to delete opencode session %s for %s", sid, notebook_path ) return False def invalidate(self, notebook_path: str) -> bool: """Drop the cached sessionID without calling OpenCode. Returns True if removed. Use this when an upstream error indicates the session is dead (e.g., 404). """ sid = self._sessions.pop(notebook_path, None) self._titles.pop(notebook_path, None) return sid is not None def has_session(self, notebook_path: str) -> bool: return notebook_path in self._sessions def peek(self, notebook_path: str) -> Optional[str]: """Return the cached sessionID for the notebook, or None if no session has been created yet. Does NOT create one (unlike get_or_create) — used by the history endpoint to avoid spawning a session just to report that there is none.""" return self._sessions.get(notebook_path) def list_sessions(self) -> list[dict]: return [ {"notebookPath": path, "sessionId": sid} for path, sid in sorted(self._sessions.items()) ]