"""MuseHub session persistence service. Handles storage and retrieval of recording session records in the musehub_sessions table. Sessions are pushed from the CLI (``muse session end``) and displayed in the MuseHub web UI at ``/musehub/ui/{repo_id}/sessions/{session_id}``. Design notes: - Upsert semantics: pushing the same session_id again is idempotent (updates the existing record). This allows re-pushing sessions after editing notes. - Sessions are returned newest-first (started_at DESC) to match the local ``muse session log`` display order. - ``commits`` cross-references musehub_commits by commit_id for UI deep-links, but the foreign key is not enforced at DB level — commits may arrive out of order relative to sessions. """ import logging from datetime import datetime, timezone from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from musehub.core.genesis import compute_session_id from musehub.db.musehub_repo_models import MusehubSession from musehub.models.musehub import SessionCreate, SessionResponse logger = logging.getLogger(__name__) def _to_response(session: MusehubSession) -> SessionResponse: """Convert an ORM session row to its wire representation.""" duration: float | None = None if session.ended_at is not None: duration = (session.ended_at - session.started_at).total_seconds() return SessionResponse( session_id=session.session_id, started_at=session.started_at, ended_at=session.ended_at, duration_seconds=duration, participants=list(session.participants), commits=list(session.commits), notes=session.notes, location=session.location, intent=session.intent, is_active=getattr(session, "is_active", session.ended_at is None), created_at=session.created_at, ) async def upsert_session( db: AsyncSession, repo_id: str, data: SessionCreate, *, author_identity_id: str = "", ) -> SessionResponse: """Create a new session record for the given repo.""" started_at = data.started_at or datetime.now(tz=timezone.utc) session_id = compute_session_id(repo_id, author_identity_id, started_at.isoformat()) session = MusehubSession( session_id=session_id, repo_id=repo_id, started_at=started_at, participants=list(data.participants), location=data.location, intent=data.intent, is_active=True, created_at=datetime.now(tz=timezone.utc), ) db.add(session) await db.flush() logger.info("\u2705 Created session in repo %s", repo_id) return _to_response(session) async def list_sessions( db: AsyncSession, repo_id: str, limit: int = 50, cursor: str | None = None, ) -> tuple[list[SessionResponse], int, str | None]: """Return sessions for a repo sorted by started_at descending. Cursor-paginated: ``cursor`` is an ISO-8601 ``started_at`` timestamp from a previous response. When provided, only sessions with ``started_at`` strictly before the cursor instant are returned. Returns: Tuple of ``(sessions, total, next_cursor)`` where ``next_cursor`` is ``None`` on the last page. """ import datetime as _dt from sqlalchemy import func total_result = await db.execute( select(func.count(MusehubSession.session_id)).where( MusehubSession.repo_id == repo_id ) ) total = int(total_result.scalar_one()) stmt = ( select(MusehubSession) .where(MusehubSession.repo_id == repo_id) .order_by(MusehubSession.started_at.desc()) .limit(limit + 1) ) if cursor: cursor_dt = _dt.datetime.fromisoformat(cursor) stmt = stmt.where(MusehubSession.started_at < cursor_dt) result = await db.execute(stmt) rows = result.scalars().all() has_more = len(rows) > limit page_rows = rows[:limit] next_cursor: str | None = None if has_more and page_rows: next_cursor = page_rows[-1].started_at.isoformat() return [_to_response(r) for r in page_rows], total, next_cursor async def get_session( db: AsyncSession, repo_id: str, session_id: str, ) -> SessionResponse | None: """Fetch a single session by ID within a repo. Returns ``None`` if the session does not exist or belongs to a different repo, allowing the caller to issue a 404. Args: db: Active async database session. repo_id: The MuseHub repo to constrain the lookup. session_id: The session ID. Returns: ``SessionResponse`` or ``None``. """ q = select(MusehubSession).where( MusehubSession.session_id == session_id, MusehubSession.repo_id == repo_id, ) result = await db.execute(q) row = result.scalar_one_or_none() return _to_response(row) if row is not None else None