musehub_sessions.py
python
sha256:1dd27e6d5c24550177b01b0a171e8375c07cf046b549e7683364706b6522d3bc
docs(#139): mark all 11 checklist items resolved, document …
Sonnet 5
12 days ago
| 1 | """MuseHub session persistence service. |
| 2 | |
| 3 | Handles storage and retrieval of recording session records in the musehub_sessions |
| 4 | table. Sessions are pushed from the CLI (``muse session end``) and displayed in |
| 5 | the MuseHub web UI at ``/musehub/ui/{repo_id}/sessions/{session_id}``. |
| 6 | |
| 7 | Design notes: |
| 8 | - Upsert semantics: pushing the same session_id again is idempotent (updates |
| 9 | the existing record). This allows re-pushing sessions after editing notes. |
| 10 | - Sessions are returned newest-first (started_at DESC) to match the local |
| 11 | ``muse session log`` display order. |
| 12 | - ``commits`` cross-references musehub_commits by commit_id for UI deep-links, |
| 13 | but the foreign key is not enforced at DB level — commits may arrive out of |
| 14 | order relative to sessions. |
| 15 | """ |
| 16 | |
| 17 | import logging |
| 18 | from datetime import datetime, timezone |
| 19 | |
| 20 | from sqlalchemy import select |
| 21 | from sqlalchemy.ext.asyncio import AsyncSession |
| 22 | |
| 23 | from musehub.core.genesis import compute_session_id |
| 24 | from musehub.db.musehub_repo_models import MusehubSession |
| 25 | from musehub.models.musehub import SessionCreate, SessionResponse |
| 26 | |
| 27 | logger = logging.getLogger(__name__) |
| 28 | |
| 29 | |
| 30 | def _to_response(session: MusehubSession) -> SessionResponse: |
| 31 | """Convert an ORM session row to its wire representation.""" |
| 32 | duration: float | None = None |
| 33 | if session.ended_at is not None: |
| 34 | duration = (session.ended_at - session.started_at).total_seconds() |
| 35 | return SessionResponse( |
| 36 | session_id=session.session_id, |
| 37 | started_at=session.started_at, |
| 38 | ended_at=session.ended_at, |
| 39 | duration_seconds=duration, |
| 40 | participants=list(session.participants), |
| 41 | commits=list(session.commits), |
| 42 | notes=session.notes, |
| 43 | location=session.location, |
| 44 | intent=session.intent, |
| 45 | is_active=getattr(session, "is_active", session.ended_at is None), |
| 46 | created_at=session.created_at, |
| 47 | ) |
| 48 | |
| 49 | |
| 50 | async def upsert_session( |
| 51 | db: AsyncSession, |
| 52 | repo_id: str, |
| 53 | data: SessionCreate, |
| 54 | *, |
| 55 | author_identity_id: str = "", |
| 56 | ) -> SessionResponse: |
| 57 | """Create a new session record for the given repo.""" |
| 58 | started_at = data.started_at or datetime.now(tz=timezone.utc) |
| 59 | session_id = compute_session_id(repo_id, author_identity_id, started_at.isoformat()) |
| 60 | session = MusehubSession( |
| 61 | session_id=session_id, |
| 62 | repo_id=repo_id, |
| 63 | started_at=started_at, |
| 64 | participants=list(data.participants), |
| 65 | location=data.location, |
| 66 | intent=data.intent, |
| 67 | is_active=True, |
| 68 | created_at=datetime.now(tz=timezone.utc), |
| 69 | ) |
| 70 | db.add(session) |
| 71 | await db.flush() |
| 72 | logger.info("\u2705 Created session in repo %s", repo_id) |
| 73 | return _to_response(session) |
| 74 | |
| 75 | |
| 76 | async def list_sessions( |
| 77 | db: AsyncSession, |
| 78 | repo_id: str, |
| 79 | limit: int = 50, |
| 80 | cursor: str | None = None, |
| 81 | ) -> tuple[list[SessionResponse], int, str | None]: |
| 82 | """Return sessions for a repo sorted by started_at descending. |
| 83 | |
| 84 | Cursor-paginated: ``cursor`` is an ISO-8601 ``started_at`` timestamp from a |
| 85 | previous response. When provided, only sessions with ``started_at`` strictly |
| 86 | before the cursor instant are returned. |
| 87 | |
| 88 | Returns: |
| 89 | Tuple of ``(sessions, total, next_cursor)`` where ``next_cursor`` is |
| 90 | ``None`` on the last page. |
| 91 | """ |
| 92 | import datetime as _dt |
| 93 | from sqlalchemy import func |
| 94 | |
| 95 | total_result = await db.execute( |
| 96 | select(func.count(MusehubSession.session_id)).where( |
| 97 | MusehubSession.repo_id == repo_id |
| 98 | ) |
| 99 | ) |
| 100 | total = int(total_result.scalar_one()) |
| 101 | |
| 102 | stmt = ( |
| 103 | select(MusehubSession) |
| 104 | .where(MusehubSession.repo_id == repo_id) |
| 105 | .order_by(MusehubSession.started_at.desc()) |
| 106 | .limit(limit + 1) |
| 107 | ) |
| 108 | if cursor: |
| 109 | cursor_dt = _dt.datetime.fromisoformat(cursor) |
| 110 | stmt = stmt.where(MusehubSession.started_at < cursor_dt) |
| 111 | |
| 112 | result = await db.execute(stmt) |
| 113 | rows = result.scalars().all() |
| 114 | |
| 115 | has_more = len(rows) > limit |
| 116 | page_rows = rows[:limit] |
| 117 | next_cursor: str | None = None |
| 118 | if has_more and page_rows: |
| 119 | next_cursor = page_rows[-1].started_at.isoformat() |
| 120 | |
| 121 | return [_to_response(r) for r in page_rows], total, next_cursor |
| 122 | |
| 123 | |
| 124 | async def get_session( |
| 125 | db: AsyncSession, |
| 126 | repo_id: str, |
| 127 | session_id: str, |
| 128 | ) -> SessionResponse | None: |
| 129 | """Fetch a single session by ID within a repo. |
| 130 | |
| 131 | Returns ``None`` if the session does not exist or belongs to a different repo, |
| 132 | allowing the caller to issue a 404. |
| 133 | |
| 134 | Args: |
| 135 | db: Active async database session. |
| 136 | repo_id: The MuseHub repo to constrain the lookup. |
| 137 | session_id: The session ID. |
| 138 | |
| 139 | Returns: |
| 140 | ``SessionResponse`` or ``None``. |
| 141 | """ |
| 142 | q = select(MusehubSession).where( |
| 143 | MusehubSession.session_id == session_id, |
| 144 | MusehubSession.repo_id == repo_id, |
| 145 | ) |
| 146 | result = await db.execute(q) |
| 147 | row = result.scalar_one_or_none() |
| 148 | return _to_response(row) if row is not None else None |
File History
2 commits
sha256:1dd27e6d5c24550177b01b0a171e8375c07cf046b549e7683364706b6522d3bc
docs(#139): mark all 11 checklist items resolved, document …
Sonnet 5
12 days ago
sha256:649011bedd713e22f7dca4c4be94bdefbf3b10950d9fc80235928d0b0b823be8
docs: track issue #139 (two-column app-shell refactor) plan…
Sonnet 5
12 days ago