honcho/src/crud/session.py

771 lines
25 KiB
Python

from logging import getLogger
from typing import Any
from nanoid import generate as generate_nanoid
from sqlalchemy import Select, case, cast, func, insert, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.types import BigInteger, Boolean
from src import models, schemas
from src.config import settings
from src.exceptions import (
ConflictException,
ObserverException,
ResourceNotFoundException,
)
from src.utils.filter import apply_filter
from .peer import get_or_create_peers, get_peer
# Import workspace and peer functions that are needed
from .workspace import get_or_create_workspace
logger = getLogger(__name__)
def count_observers_in_config(
peer_configs: dict[str, schemas.SessionPeerConfig],
) -> int:
"""
Count the number of peers that will be observing others based on their configurations.
Args:
peer_configs: Dictionary of peer names to their session configurations
Returns:
Number of peers that will be observing others
"""
return sum(1 for config in peer_configs.values() if config.observe_others)
async def get_sessions(
workspace_name: str,
filters: dict[str, Any] | None = None,
) -> Select[tuple[models.Session]]:
"""
Get all sessions in a workspace.
"""
stmt = select(models.Session).where(models.Session.workspace_name == workspace_name)
stmt = apply_filter(stmt, models.Session, filters)
return stmt.order_by(models.Session.created_at)
async def get_or_create_session(
db: AsyncSession,
session: schemas.SessionCreate,
workspace_name: str,
*,
_retry: bool = False,
) -> models.Session:
"""
Get or create a session in a workspace with specified peers.
If the session already exists, the peers are added to the session.
Args:
db: Database session
session: Session creation schema
workspace_name: Name of the workspace
peer_names: List of peer names to add to the session
_retry: Whether to retry the operation
Returns:
The created session
Raises:
ResourceNotFoundException: If the session does not exist and create is false
ConflictException: If we fail to get or create the session
"""
stmt = (
select(models.Session)
.where(models.Session.workspace_name == workspace_name)
.where(models.Session.name == session.name)
)
result = await db.execute(stmt)
honcho_session = result.scalar_one_or_none()
# Check if session already exists
if honcho_session is None:
if session.peer_names:
# Count peers that will be observing others
observer_count = count_observers_in_config(session.peer_names)
if observer_count > settings.SESSION_OBSERVERS_LIMIT:
raise ObserverException(session.name, observer_count)
# Get or create workspace to ensure it exists
await get_or_create_workspace(
db,
schemas.WorkspaceCreate(name=workspace_name),
)
# Create honcho session
honcho_session = models.Session(
workspace_name=workspace_name,
name=session.name,
h_metadata=session.metadata or {},
configuration=session.configuration or {},
)
try:
db.add(honcho_session)
# Flush to ensure session exists in DB before adding peers
await db.flush()
except IntegrityError:
await db.rollback()
logger.debug(
"Race condition detected for session: %s, retrying get", session.name
)
if _retry:
raise ConflictException(
f"Unable to create or get session: {session.name}"
) from None
return await get_or_create_session(db, session, workspace_name, _retry=True)
else:
# Update existing session with metadata and feature flags if provided
if session.metadata is not None:
honcho_session.h_metadata = session.metadata
if session.configuration is not None:
honcho_session.configuration = session.configuration
# Add all peers to session
if session.peer_names:
await get_or_create_peers(
db,
workspace_name=workspace_name,
peers=[
schemas.PeerCreate(name=peer_name) for peer_name in session.peer_names
],
)
await _get_or_add_peers_to_session(
db,
workspace_name=workspace_name,
session_name=session.name,
peer_names=session.peer_names,
)
await db.commit()
return honcho_session
async def get_session(
db: AsyncSession,
session_name: str,
workspace_name: str,
) -> models.Session:
"""
Get a session in a workspace.
Args:
db: Database session
session_name: Name of the session
workspace_name: Name of the workspace
Returns:
The session
Raises:
ResourceNotFoundException: If the session does not exist
"""
stmt = (
select(models.Session)
.where(models.Session.workspace_name == workspace_name)
.where(models.Session.name == session_name)
)
result = await db.execute(stmt)
honcho_session = result.scalar_one_or_none()
if honcho_session is None:
raise ResourceNotFoundException(
f"Session {session_name} not found in workspace {workspace_name}"
)
return honcho_session
async def update_session(
db: AsyncSession,
session: schemas.SessionUpdate,
workspace_name: str,
session_name: str,
) -> models.Session:
"""
Update a session.
Args:
db: Database session
session: Session update schema
workspace_name: Name of the workspace
session_name: Name of the session
Returns:
The updated session
Raises:
ResourceNotFoundException: If the session does not exist or peer is not in session
"""
honcho_session = await get_or_create_session(
db, schemas.SessionCreate(name=session_name), workspace_name=workspace_name
)
if session.metadata is not None:
honcho_session.h_metadata = session.metadata
if session.configuration is not None:
honcho_session.configuration = session.configuration
await db.commit()
logger.debug("Session %s updated successfully", session_name)
return honcho_session
async def delete_session(
db: AsyncSession, workspace_name: str, session_name: str
) -> bool:
"""
Mark a session as inactive (soft delete).
Args:
db: Database session
workspace_name: Name of the workspace
session_name: Name of the session
Returns:
True if the session was deleted successfully
Raises:
ResourceNotFoundException: If the session does not exist
"""
stmt = (
select(models.Session)
.where(models.Session.workspace_name == workspace_name)
.where(models.Session.name == session_name)
)
result = await db.execute(stmt)
honcho_session = result.scalar_one_or_none()
if honcho_session is None:
logger.warning(
f"Session {session_name} not found in workspace {workspace_name}"
)
raise ResourceNotFoundException("Session not found")
honcho_session.is_active = False
await db.commit()
logger.debug("Session %s marked as inactive", session_name)
return True
async def clone_session(
db: AsyncSession,
workspace_name: str,
original_session_name: str,
cutoff_message_id: str | None = None,
) -> models.Session:
"""
Clone a session and its messages. If cutoff_message_id is provided,
only clone messages up to and including that message.
Args:
db: SQLAlchemy session
workspace_name: Name of the workspace the target session is in
original_session_name: Name of the session to clone
cutoff_message_id: Optional ID of the last message to include in the clone
Returns:
The newly created session
"""
# Get the original session
stmt = (
select(models.Session)
.where(models.Session.workspace_name == workspace_name)
.where(models.Session.name == original_session_name)
)
result = await db.execute(stmt)
original_session = result.scalar_one_or_none()
if original_session is None:
raise ResourceNotFoundException("Original session not found")
# If cutoff_message_id is provided, verify it belongs to the session
cutoff_message = None
if cutoff_message_id is not None:
stmt = select(models.Message).where(
models.Message.public_id == cutoff_message_id,
models.Message.session_name == original_session_name,
)
cutoff_message = await db.scalar(stmt)
if not cutoff_message:
raise ValueError(
"Message not found or doesn't belong to the specified session"
)
# Create new session
new_session = models.Session(
workspace_name=workspace_name,
name=generate_nanoid(),
h_metadata=original_session.h_metadata,
)
db.add(new_session)
await db.flush() # Flush to get the new session ID
# Build query for messages to clone
stmt = select(models.Message).where(
models.Message.session_name == original_session_name
)
if cutoff_message_id is not None and cutoff_message is not None:
stmt = stmt.where(models.Message.id <= cast(cutoff_message.id, BigInteger))
stmt = stmt.order_by(models.Message.id)
# Fetch messages to clone
messages_to_clone_scalars = await db.scalars(stmt)
messages_to_clone = messages_to_clone_scalars.all()
if not messages_to_clone:
return new_session
# Prepare bulk insert data
new_messages = [
{
"session_name": new_session.name,
"content": message.content,
"h_metadata": message.h_metadata,
"workspace_name": workspace_name,
"peer_name": message.peer_name,
"seq_in_session": message.seq_in_session,
}
for message in messages_to_clone
]
insert_stmt = insert(models.Message).returning(models.Message)
result = await db.execute(insert_stmt, new_messages)
# Clone peers from original session to new session
stmt = select(models.SessionPeer).where(
models.SessionPeer.session_name == original_session_name
)
result = await db.execute(stmt)
session_peers = result.scalars().all()
for session_peer in session_peers:
new_session_peer = models.SessionPeer(
session_name=new_session.name,
peer_name=session_peer.peer_name,
workspace_name=workspace_name,
)
db.add(new_session_peer)
await db.commit()
logger.debug("Session %s cloned successfully", original_session_name)
return new_session
async def remove_peers_from_session(
db: AsyncSession,
workspace_name: str,
session_name: str,
peer_names: set[str],
) -> bool:
"""
Remove specified peers from a session.
Args:
db: Database session
workspace_name: Name of the workspace
session_name: Name of the session
peer_names: Set of peer names to remove from the session
Returns:
True if peers were removed successfully
Raises:
ResourceNotFoundException: If the session does not exist
"""
# Verify session exists
stmt = (
select(models.Session)
.where(models.Session.workspace_name == workspace_name)
.where(models.Session.name == session_name)
)
result = await db.execute(stmt)
session = result.scalar_one_or_none()
if session is None:
raise ResourceNotFoundException(
f"Session {session_name} not found in workspace {workspace_name}"
)
# Soft delete specified session peers by setting left_at timestamp
update_stmt = (
update(models.SessionPeer)
.where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.workspace_name == workspace_name,
models.SessionPeer.peer_name.in_(peer_names),
models.SessionPeer.left_at.is_(None), # Only update active peers
)
.values(left_at=func.now())
)
result = await db.execute(update_stmt)
await db.commit()
return True
async def get_peers_from_session(
workspace_name: str,
session_name: str,
) -> Select[tuple[models.Peer]]:
"""
Get all peers from a session.
Args:
db: Database session
workspace_name: Name of the workspace
session_name: Name of the session
Returns:
Paginated list of Peer objects in the session
"""
# Get all active peers in the session (where left_at is NULL)
return (
select(models.Peer)
.join(models.SessionPeer, models.Peer.name == models.SessionPeer.peer_name)
.where(models.SessionPeer.session_name == session_name)
.where(models.Peer.workspace_name == workspace_name)
.where(models.SessionPeer.left_at.is_(None)) # Only active peers
)
async def get_session_peer_configuration(
workspace_name: str,
session_name: str,
) -> Select[tuple[str, dict[str, Any], dict[str, Any], bool]]:
"""
Get configuration from both SessionPeer and Peer tables for all peers in a session.
NOTE: does not filter for active peers. Will return peers that have left the session.
Args:
workspace_name: Name of the workspace
session_name: Name of the session
Returns:
Select statement returning peer_name, peer_configuration, session_peer_configuration,
and a boolean indicating if the peer is currently in the session
"""
stmt: Select[tuple[str, dict[str, Any], dict[str, Any], bool]] = (
select(
models.Peer.name.label("peer_name"),
models.Peer.configuration.label("peer_configuration"),
models.SessionPeer.configuration.label("session_peer_configuration"),
(models.SessionPeer.left_at.is_(None)).label("is_active"),
)
.join(models.SessionPeer, models.Peer.name == models.SessionPeer.peer_name)
.where(models.SessionPeer.session_name == session_name)
.where(models.Peer.workspace_name == workspace_name)
.where(models.SessionPeer.workspace_name == workspace_name)
)
return stmt
async def set_peers_for_session(
db: AsyncSession,
workspace_name: str,
session_name: str,
peer_names: dict[str, schemas.SessionPeerConfig],
) -> list[models.SessionPeer]:
"""
Set peers for a session, overwriting any existing peers.
If peers don't exist, they will be created.
Args:
db: Database session
workspace_name: Name of the workspace
session_name: Name of the session
peer_names: Set of peer names to set for the session
Returns:
List of SessionPeer objects for all peers in the session
Raises:
ResourceNotFoundException: If the session does not exist
"""
# Validate observer limit before making any changes
observer_count = count_observers_in_config(peer_names)
if observer_count > settings.SESSION_OBSERVERS_LIMIT:
raise ObserverException(session_name, observer_count)
# Verify session exists
stmt = (
select(models.Session)
.where(models.Session.workspace_name == workspace_name)
.where(models.Session.name == session_name)
)
result = await db.execute(stmt)
session = result.scalar_one_or_none()
if session is None:
raise ResourceNotFoundException(
f"Session {session_name} not found in workspace {workspace_name}"
)
# Soft delete specified session peers by setting left_at timestamp
update_stmt = (
update(models.SessionPeer)
.where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.workspace_name == workspace_name,
models.SessionPeer.left_at.is_(None), # Only update active peers
)
.values(left_at=func.now())
)
result = await db.execute(update_stmt)
# Get or create peers
await get_or_create_peers(
db,
workspace_name=workspace_name,
peers=[schemas.PeerCreate(name=peer_name) for peer_name in peer_names],
)
# Add new peers to session
peers = await _get_or_add_peers_to_session(
db,
workspace_name=workspace_name,
session_name=session_name,
peer_names=peer_names,
)
await db.commit()
return peers
async def _get_or_add_peers_to_session(
db: AsyncSession,
workspace_name: str,
session_name: str,
peer_names: dict[str, schemas.SessionPeerConfig],
) -> list[models.SessionPeer]:
"""
Add multiple peers to an existing session. If a peer already exists in the session,
it will be skipped gracefully.
Args:
db: Database session
session_name: Name of the session
peer_names: Set of peer names to add to the session
Returns:
List of all SessionPeer objects (both existing and newly created)
Raises:
ValueError: If adding peers would exceed the maximum limit
"""
# If no peers to add, skip the insert and just return existing active session peers
if not peer_names:
select_stmt = select(models.SessionPeer).where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.workspace_name == workspace_name,
models.SessionPeer.left_at.is_(None), # Only active peers
)
result = await db.execute(select_stmt)
return list(result.scalars().all())
# Only validate observer limit if we're adding peers with observe_others=True
new_observer_count = count_observers_in_config(peer_names)
if new_observer_count > 0:
# Use a single efficient query to count existing observers not being updated
# This uses PostgreSQL's JSONB operators to check the observe_others field directly
existing_observers_stmt = select(func.count()).where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.workspace_name == workspace_name,
models.SessionPeer.left_at.is_(None), # Only active peers
models.SessionPeer.peer_name.notin_(
peer_names.keys()
), # Exclude peers being updated
models.SessionPeer.configuration["observe_others"].astext.cast(
Boolean
), # Only observers
)
result = await db.execute(existing_observers_stmt)
existing_observer_count = result.scalar() or 0
total_observers = existing_observer_count + new_observer_count
if total_observers > settings.SESSION_OBSERVERS_LIMIT:
raise ObserverException(session_name, total_observers)
# Use upsert to handle both new peers and rejoining peers
stmt = pg_insert(models.SessionPeer).values(
[
{
"session_name": session_name,
"peer_name": peer_name,
"workspace_name": workspace_name,
"joined_at": func.now(),
"left_at": None,
"configuration": configuration.model_dump(),
}
for peer_name, configuration in peer_names.items()
]
)
# On conflict, update joined_at and clear left_at (rejoin scenario)
# If left_at is not None (peer has left the session): Use the new configuration (stmt.excluded.configuration)
# If left_at is None (peer is still active): Keep the existing configuration (models.SessionPeer.configuration)
stmt = stmt.on_conflict_do_update(
index_elements=["session_name", "peer_name", "workspace_name"],
set_={
"joined_at": func.now(),
"left_at": None,
"configuration": case(
(models.SessionPeer.left_at.is_not(None), stmt.excluded.configuration),
else_=models.SessionPeer.configuration,
),
},
)
await db.execute(stmt)
# Return all active session peers after the upsert
select_stmt = select(models.SessionPeer).where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.workspace_name == workspace_name,
models.SessionPeer.left_at.is_(None), # Only active peers
)
result = await db.execute(select_stmt)
return list(result.scalars().all())
async def get_peer_config(
db: AsyncSession,
workspace_name: str,
session_name: str,
peer_id: str,
) -> schemas.SessionPeerConfig:
"""
Get the configuration for a peer in a session.
Args:
db: Database session
workspace_name: Name of the workspace
session_name: Name of the session
peer_id: Name of the peer
Returns:
Configuration for the peer
Raises:
ResourceNotFoundException: If the session or peer does not exist
"""
# Get row from session_peer table
stmt = select(models.SessionPeer).where(
models.SessionPeer.workspace_name == workspace_name,
models.SessionPeer.session_name == session_name,
models.SessionPeer.peer_name == peer_id,
)
result = await db.execute(stmt)
session_peer = result.scalar_one_or_none()
if session_peer is None:
raise ResourceNotFoundException(
f"Session peer {peer_id} not found in session {session_name} in workspace {workspace_name}"
)
return schemas.SessionPeerConfig(**session_peer.configuration)
async def set_peer_config(
db: AsyncSession,
workspace_name: str,
session_name: str,
peer_name: str,
config: schemas.SessionPeerConfig,
) -> None:
"""
Set the configuration for a specific peer in a session.
Args:
db: Database session
workspace_name: Name of the workspace
session_name: Name of the session
peer_name: Name of the peer
config: The peer configuration to set
Raises:
ObserverException: If the update would exceed the observer limit
"""
# First, get the session and peer to ensure they exist
await get_session(db, session_name, workspace_name)
await get_peer(db, workspace_name, schemas.PeerCreate(name=peer_name))
# Check if a SessionPeer entry already exists
stmt = (
select(models.SessionPeer)
.where(models.SessionPeer.session_name == session_name)
.where(models.SessionPeer.peer_name == peer_name)
.where(models.SessionPeer.workspace_name == workspace_name)
)
result = await db.execute(stmt)
session_peer = result.scalar_one_or_none()
# Check if this update would exceed observer limits
if config.observe_others:
# Check if peer is already an observer
is_currently_observer = (
session_peer.configuration.get("observe_others", False)
if session_peer and session_peer.configuration
else False
)
# Only need to check limit if peer is becoming a new observer
if not is_currently_observer:
# Use a single efficient query to count existing observers
existing_observers_stmt = select(func.count()).where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.workspace_name == workspace_name,
models.SessionPeer.left_at.is_(None), # Only active peers
models.SessionPeer.peer_name
!= peer_name, # Exclude the peer being updated
models.SessionPeer.configuration["observe_others"].astext.cast(
Boolean
), # Only observers
)
result = await db.execute(existing_observers_stmt)
observer_count = result.scalar() or 0
# Add one for this peer becoming an observer
observer_count += 1
if observer_count > settings.SESSION_OBSERVERS_LIMIT:
raise ObserverException(session_name, observer_count)
update_data = config.model_dump(exclude_none=True)
if session_peer:
# Update existing configuration
if session_peer.configuration:
# Create a new dictionary and update it to ensure SQLAlchemy tracks the change
new_config = session_peer.configuration.copy()
new_config.update(update_data)
session_peer.configuration = new_config
else:
session_peer.configuration = update_data
else:
# Create a new SessionPeer entry
session_peer = models.SessionPeer(
session_name=session_name,
peer_name=peer_name,
workspace_name=workspace_name,
configuration=update_data,
)
db.add(session_peer)
await db.commit()