gabriel / musehub public

musehub_sessions.py file-level

at sha256:1 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:e fix(9A-4 F7): env alias + staging secrets for overseer provenance gate.… · aaronrene · Jul 13, 2026
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