theo-agent-dashboard

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

sessions.py (11113B)


      1 """Session routes — proxy to Hermes API with local topic enrichment."""
      2 
      3 from __future__ import annotations
      4 
      5 import logging
      6 from typing import Any
      7 
      8 from fastapi import APIRouter, HTTPException, Request
      9 
     10 from app.config import get_config
     11 from app.hermes_client import (
     12     HermesClient,
     13     _normalize_message,
     14     normalize_messages,
     15     _normalize_session,
     16     _unwrap_hermes_response,
     17 )
     18 from app.models import SessionCreate, SessionUpdate
     19 
     20 logger = logging.getLogger(__name__)
     21 router = APIRouter(prefix="/api/sessions", tags=["sessions"])
     22 
     23 
     24 def get_hermes_client():  # pragma: no mutate: block
     25     """Build a Hermes client from the current config."""
     26     cfg = get_config()
     27     return HermesClient(base_url=cfg.hermes_base_url, api_key=cfg.hermes_api_key)
     28 
     29 
     30 async def _proxy_call(client: HermesClient, coro: Any) -> Any:  # pragma: no mutate: block
     31     """Await a client coroutine, wrapping errors as 502."""
     32     try:
     33         return await coro
     34     except Exception as exc:
     35         logger.exception("Hermes API error")
     36         raise HTTPException(status_code=502, detail=f"Hermes: {exc}") from exc
     37 
     38 
     39 async def _enrich_sessions(
     40     sessions: list[dict], request: Request,
     41 ) -> list[dict]:
     42     """Attach topic_id, topic_name, and archived status from the local DB."""
     43     db = getattr(request.app.state, "db", None)
     44     if db is None:
     45         return sessions
     46     topic_map = await _load_topic_map(db)
     47     archived = await _load_archived_set(db)
     48     return [_attach_topic(_attach_archived(s, archived), topic_map) for s in sessions]
     49 
     50 
     51 async def _load_topic_map(db: Any) -> dict[str, tuple[str, str]]:
     52     """Map session_id → (topic_id, topic_name) from topic_sessions."""
     53     try:
     54         rows = await db.fetch_all(
     55             "SELECT ts.session_id, t.id, t.name "
     56             "FROM topic_sessions ts "
     57             "JOIN topics t ON t.id = ts.topic_id"
     58         )
     59         return {r[0]: (r[1], r[2]) for r in rows}
     60     except Exception:
     61         return {}
     62 
     63 
     64 async def _load_archived_set(db: Any) -> set[str]:
     65     """Return set of archived session IDs from the DB."""
     66     try:
     67         rows = await db.fetch_all("SELECT session_id FROM archived_sessions")
     68         return {r[0] for r in rows}
     69     except Exception:
     70         return set()
     71 
     72 
     73 def _attach_topic(session: dict, topic_map: dict) -> dict:  # pragma: no mutate: block
     74     """Add topic fields to a session dict if available."""
     75     sid = session.get("id", "")
     76     if sid in topic_map:
     77         tid, tname = topic_map[sid]
     78         session["topic_id"] = tid
     79         session["topic_name"] = tname
     80     return session
     81 
     82 
     83 def _attach_archived(session: dict, archived: set[str]) -> dict:
     84     """Add archived: true/false to a session dict."""
     85     session["archived"] = session.get("id", "") in archived
     86     return session
     87 
     88 
     89 def _extract_sessions(data: Any) -> list[dict]:
     90     """Extract a list of normalized sessions from a Hermes response.
     91 
     92     Hermes returns ``{"object": "list", "data": [...]}``.
     93     Also handles legacy ``{"sessions": [...]}`` and bare ``[...]``.
     94     """
     95     if data is None:
     96         return []
     97     if isinstance(data, list):
     98         return [_normalize_session(s) for s in data]
     99     if isinstance(data, dict):
    100         raw_list = data.get("data") or data.get("sessions") or []
    101         if isinstance(raw_list, list):
    102             return [_normalize_session(s) for s in raw_list]
    103     return []
    104 
    105 
    106 def _extract_single_session(data: Any) -> dict:
    107     """Extract a single normalized session from a Hermes response.
    108 
    109     Hermes returns ``{"object": "hermes.session", "session": {...}}``.
    110     """
    111     if data is None:
    112         return {}
    113     if isinstance(data, dict):
    114         inner = data.get("session") or data
    115         if isinstance(inner, dict):
    116             return _normalize_session(inner)
    117     return {}
    118 
    119 
    120 @router.get("")
    121 async def list_sessions(request: Request) -> dict:  # pragma: no mutate: block
    122     """List all sessions, enriched with local topic info."""
    123     client = get_hermes_client()
    124     data = await _proxy_call(client, client.list_sessions())
    125     sessions = _extract_sessions(data)
    126     enriched = await _enrich_sessions(sessions, request)
    127     return {"sessions": enriched}
    128 
    129 
    130 @router.post("")
    131 async def create_session(payload: SessionCreate, request: Request) -> dict:  # pragma: no mutate: block
    132     """Create a session via Hermes, optionally assign to a topic."""
    133     client = get_hermes_client()
    134     result = await _proxy_call(client, client.create_session(name=payload.name))
    135     if result is None:
    136         raise HTTPException(status_code=502, detail="Hermes returned no data")
    137     session = _extract_single_session(result)
    138     await _assign_topic(request, session.get("id"), payload.topic_id)
    139     return session
    140 
    141 
    142 async def _assign_topic(  # pragma: no mutate: block
    143     request: Request, session_id: str | None, topic_id: str | None,
    144 ) -> None:  # pragma: no mutate: block
    145     """Insert a row into topic_sessions if both IDs are present."""
    146     if not session_id or not topic_id:
    147         return
    148     db = getattr(request.app.state, "db", None)
    149     if db is None:
    150         return
    151     try:
    152         await db.execute(
    153             "INSERT INTO topic_sessions (topic_id, session_id) VALUES (?, ?)",
    154             (topic_id, session_id),
    155         )
    156     except Exception:
    157         logger.warning("Failed to assign topic %s to session %s", topic_id, session_id)
    158 
    159 
    160 @router.patch("/{session_id}")
    161 async def update_session(  # pragma: no mutate: block
    162     session_id: str, payload: SessionUpdate, request: Request,
    163 ) -> dict:  # pragma: no mutate: block
    164     """Update session fields via Hermes."""
    165     client = get_hermes_client()
    166     fields: dict[str, Any] = {
    167         k: v for k, v in payload.model_dump().items() if v is not None
    168     }
    169     result = await _proxy_call(
    170         client, client.update_session(session_id, **fields),
    171     )
    172     if result is None:
    173         raise HTTPException(status_code=502, detail="Hermes returned no data")
    174     return _extract_single_session(result)
    175 
    176 
    177 @router.delete("/{session_id}", status_code=204)
    178 async def delete_session(session_id: str) -> None:  # pragma: no mutate: block
    179     """Delete a session via Hermes."""
    180     client = get_hermes_client()
    181     await _proxy_call(client, client.delete_session(session_id))
    182 
    183 
    184 @router.get("/{session_id}/messages")
    185 async def get_messages(session_id: str, request: Request) -> dict:  # pragma: no mutate: block
    186     """Get messages for a session, enriched with attachment metadata."""
    187     client = get_hermes_client()
    188     data = await _proxy_call(client, client.get_messages(session_id))
    189     if data is None:
    190         messages: list = []
    191     else:
    192         # Hermes returns {"object": "list", "data": [...]}
    193         raw_list = (
    194             data.get("data", []) if isinstance(data, dict)
    195             else data if isinstance(data, list)
    196             else []
    197         )
    198         messages = normalize_messages(raw_list) if isinstance(raw_list, list) else []
    199 
    200     # Fetch attachments for this session from local DB
    201     attachments = await _load_session_attachments(request, session_id)
    202     return {"messages": messages, "attachments": attachments}
    203 
    204 
    205 async def _load_session_attachments(  # pragma: no mutate: block
    206     request: Request, session_id: str,
    207 ) -> list[dict]:
    208     """Return attachment metadata for a session, or empty list if unavailable."""
    209     db = getattr(request.app.state, "db", None)
    210     if db is None:
    211         return []
    212     try:
    213         rows = await db.fetch_all(
    214             "SELECT id, filename, content_type, size_bytes, created_at "
    215             "FROM attachments WHERE session_id = ? ORDER BY created_at",
    216             (session_id,),
    217         )
    218         return [
    219             {
    220                 "id": r[0],
    221                 "filename": r[1],
    222                 "content_type": r[2],
    223                 "size_bytes": r[3],
    224                 "created_at": r[4],
    225             }
    226             for r in rows
    227         ]
    228     except Exception:
    229         return []
    230 
    231 
    232 @router.post("/{session_id}/fork")
    233 async def fork_session(session_id: str, request: Request) -> dict:  # pragma: no mutate: block
    234     """Fork a session via Hermes and store branch locally."""
    235     client = get_hermes_client()
    236     result = await _proxy_call(client, client.fork_session(session_id))
    237     if result is None:
    238         raise HTTPException(status_code=502, detail="Hermes returned no data")
    239     session = _extract_single_session(result)
    240     session["parent_id"] = session_id
    241     await _store_branch(request, session.get("id"), session_id)
    242     return session
    243 
    244 
    245 async def _store_branch(  # pragma: no mutate: block
    246     request: Request, child_id: str | None, parent_id: str,
    247 ) -> None:  # pragma: no mutate: block
    248     """Insert a row into the branches table."""
    249     if not child_id:
    250         return
    251     db = getattr(request.app.state, "db", None)
    252     if db is None:
    253         return
    254     try:
    255         await db.execute(
    256             "INSERT INTO branches (session_id, parent_session_id, "
    257             "fork_message_index, created_at) VALUES (?, ?, 0, strftime('%s','now'))",
    258             (child_id, parent_id),
    259         )
    260     except Exception:
    261         logger.warning(
    262             "Failed to store branch %s for session %s", child_id, parent_id,
    263         )
    264 
    265 
    266 @router.post("/{session_id}/archive")
    267 async def archive_session(session_id: str, request: Request) -> dict:
    268     """Mark a session as archived in the local DB."""
    269     db = getattr(request.app.state, "db", None)
    270     if db is None:
    271         raise HTTPException(status_code=500, detail="Database not available")
    272     try:
    273         await db.execute(
    274             "INSERT OR IGNORE INTO archived_sessions (session_id) VALUES (?)",
    275             (session_id,),
    276         )
    277     except Exception as exc:
    278         raise HTTPException(status_code=500, detail=str(exc)) from exc
    279     return {"session_id": session_id, "archived": True}
    280 
    281 
    282 @router.delete("/{session_id}/archive", status_code=204)
    283 async def unarchive_session(session_id: str, request: Request) -> None:
    284     """Remove archived status for a session."""
    285     db = getattr(request.app.state, "db", None)
    286     if db is None:
    287         raise HTTPException(status_code=500, detail="Database not available")
    288     try:
    289         await db.execute(
    290             "DELETE FROM archived_sessions WHERE session_id = ?",
    291             (session_id,),
    292         )
    293     except Exception as exc:
    294         raise HTTPException(status_code=500, detail=str(exc)) from exc
    295 
    296 
    297 @router.get("/{session_id}/branches")
    298 async def list_branches(session_id: str, request: Request) -> dict:  # pragma: no mutate: block
    299     """List child sessions forked from this session."""
    300     db = getattr(request.app.state, "db", None)
    301     if db is None:
    302         return {"branches": []}
    303     try:
    304         rows = await db.fetch_all(
    305             "SELECT session_id, parent_session_id, fork_message_index "
    306             "FROM branches WHERE parent_session_id = ?",
    307             (session_id,),
    308         )
    309         branches = [
    310             {
    311                 "session_id": r[0],
    312                 "parent_session_id": r[1],
    313                 "fork_message_index": r[2],
    314             }
    315             for r in rows
    316         ]
    317         return {"branches": branches}
    318     except Exception:
    319         return {"branches": []}