986 lines
32 KiB
Python
986 lines
32 KiB
Python
from logging import getLogger
|
|
from typing import Any
|
|
|
|
from cashews import NOT_NONE
|
|
from nanoid import generate as generate_nanoid
|
|
from sqlalchemy import Select, and_, case, cast, delete, 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.cache.client import cache, get_cache_namespace
|
|
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
|
|
from .workspace import get_or_create_workspace
|
|
|
|
logger = getLogger(__name__)
|
|
|
|
SESSION_CACHE_KEY_TEMPLATE = "workspace:{workspace_name}:session:{session_name}"
|
|
SESSION_LOCK_PREFIX = f"{get_cache_namespace()}:lock"
|
|
|
|
|
|
def session_cache_key(workspace_name: str, session_name: str) -> str:
|
|
"""Generate cache key for session."""
|
|
return (
|
|
get_cache_namespace()
|
|
+ ":"
|
|
+ SESSION_CACHE_KEY_TEMPLATE.format(
|
|
workspace_name=workspace_name,
|
|
session_name=session_name,
|
|
)
|
|
)
|
|
|
|
|
|
@cache(
|
|
key=SESSION_CACHE_KEY_TEMPLATE,
|
|
ttl=f"{settings.CACHE.DEFAULT_TTL_SECONDS}s",
|
|
prefix=get_cache_namespace(),
|
|
condition=NOT_NONE,
|
|
)
|
|
@cache.locked(
|
|
key=SESSION_CACHE_KEY_TEMPLATE,
|
|
ttl=f"{settings.CACHE.DEFAULT_LOCK_TTL_SECONDS}s",
|
|
prefix=SESSION_LOCK_PREFIX,
|
|
)
|
|
async def _fetch_session(
|
|
db: AsyncSession,
|
|
workspace_name: str,
|
|
session_name: str,
|
|
) -> models.Session | None:
|
|
return await db.scalar(
|
|
select(models.Session)
|
|
.where(models.Session.workspace_name == workspace_name)
|
|
.where(models.Session.name == session_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 active sessions in a workspace.
|
|
"""
|
|
stmt = (
|
|
select(models.Session)
|
|
.where(models.Session.workspace_name == workspace_name)
|
|
.where(models.Session.is_active == True) # noqa: E712
|
|
)
|
|
|
|
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
|
|
"""
|
|
|
|
if not session.name:
|
|
raise ValueError("Session name must be provided")
|
|
|
|
honcho_session = await _fetch_session(db, workspace_name, session.name)
|
|
|
|
# Merge cached object into session if it exists (cached objects are detached)
|
|
if honcho_session is not None:
|
|
honcho_session = await db.merge(honcho_session, load=False)
|
|
|
|
# Reject operations on inactive sessions (marked for deletion)
|
|
if not honcho_session.is_active:
|
|
raise ResourceNotFoundException(
|
|
f"Session {session.name} not found in workspace {workspace_name}"
|
|
)
|
|
|
|
# Track if we need to update cache
|
|
needs_cache_update = False
|
|
|
|
# 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.model_dump(exclude_none=True)
|
|
if session.configuration
|
|
else {},
|
|
)
|
|
try:
|
|
db.add(honcho_session)
|
|
# Flush to ensure session exists in DB before adding peers and set flag to warm cache
|
|
await db.flush()
|
|
needs_cache_update = True
|
|
|
|
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
|
|
and honcho_session.h_metadata != session.metadata
|
|
):
|
|
honcho_session.h_metadata = session.metadata
|
|
needs_cache_update = True
|
|
if session.configuration is not None:
|
|
# Merge configuration instead of replacing to preserve existing keys
|
|
existing_config = (honcho_session.configuration or {}).copy()
|
|
incoming_config = session.configuration.model_dump(exclude_none=True)
|
|
merged_config = {**existing_config, **incoming_config}
|
|
if honcho_session.configuration != merged_config:
|
|
honcho_session.configuration = merged_config
|
|
needs_cache_update = True
|
|
|
|
# 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()
|
|
await db.refresh(honcho_session)
|
|
|
|
# Only update cache if session data changed or was newly created
|
|
if needs_cache_update:
|
|
cache_key = session_cache_key(workspace_name, session.name)
|
|
await cache.set(
|
|
cache_key, honcho_session, expire=settings.CACHE.DEFAULT_TTL_SECONDS
|
|
)
|
|
logger.debug(
|
|
"Session %s cache updated in workspace %s", session.name, workspace_name
|
|
)
|
|
|
|
return honcho_session
|
|
|
|
|
|
async def get_session(
|
|
db: AsyncSession,
|
|
session_name: str,
|
|
workspace_name: str,
|
|
*,
|
|
include_inactive: bool = False,
|
|
) -> models.Session:
|
|
"""
|
|
Get a session in a workspace.
|
|
|
|
Args:
|
|
db: Database session
|
|
session_name: Name of the session
|
|
workspace_name: Name of the workspace
|
|
include_inactive: If True, return sessions even if they are marked for deletion.
|
|
This should only be used for internal operations like the deletion task.
|
|
|
|
Returns:
|
|
The session
|
|
|
|
Raises:
|
|
ResourceNotFoundException: If the session does not exist or is inactive
|
|
"""
|
|
session = await _fetch_session(db, workspace_name, session_name)
|
|
|
|
if session is None:
|
|
raise ResourceNotFoundException(
|
|
f"Session {session_name} not found in workspace {workspace_name}"
|
|
)
|
|
|
|
# Check if session is active (unless include_inactive is True)
|
|
if not include_inactive and not session.is_active:
|
|
raise ResourceNotFoundException(
|
|
f"Session {session_name} not found in workspace {workspace_name}"
|
|
)
|
|
|
|
# Merge cached object into session (cached objects are detached)
|
|
session = await db.merge(session, load=False)
|
|
|
|
return 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
|
|
)
|
|
|
|
# Track if anything changed
|
|
needs_update = False
|
|
|
|
if session.metadata is not None and honcho_session.h_metadata != session.metadata:
|
|
honcho_session.h_metadata = session.metadata
|
|
needs_update = True
|
|
|
|
if session.configuration is not None:
|
|
# Merge configuration instead of replacing to preserve existing keys
|
|
base_config = (honcho_session.configuration or {}).copy()
|
|
merged_config = {
|
|
**base_config,
|
|
**session.configuration.model_dump(exclude_none=True),
|
|
}
|
|
if honcho_session.configuration != merged_config:
|
|
honcho_session.configuration = merged_config
|
|
needs_update = True
|
|
|
|
if not needs_update:
|
|
logger.debug(
|
|
"Session %s unchanged in workspace %s, skipping update",
|
|
session_name,
|
|
workspace_name,
|
|
)
|
|
return honcho_session
|
|
|
|
await db.commit()
|
|
await db.refresh(honcho_session)
|
|
|
|
# Only invalidate if we actually updated
|
|
cache_key = session_cache_key(workspace_name, session_name)
|
|
await cache.delete(cache_key)
|
|
|
|
logger.debug("Session %s updated successfully", session_name)
|
|
return honcho_session
|
|
|
|
|
|
async def _batch_delete_matching(
|
|
db: AsyncSession,
|
|
model: Any,
|
|
filter_conditions: list[Any],
|
|
batch_size: int = 5000,
|
|
) -> int:
|
|
"""
|
|
Delete records in batches that match the given filter conditions.
|
|
|
|
Args:
|
|
db: Database session
|
|
model: SQLAlchemy model class
|
|
filter_conditions: List of SQLAlchemy filter conditions
|
|
batch_size: Number of records to delete per batch
|
|
|
|
Returns:
|
|
Total number of records deleted
|
|
"""
|
|
total_deleted = 0
|
|
primary_key_column = model.__table__.primary_key.columns.values()[0]
|
|
|
|
while True:
|
|
subquery = (
|
|
select(primary_key_column).where(and_(*filter_conditions)).limit(batch_size)
|
|
)
|
|
delete_stmt = delete(model).where(primary_key_column.in_(subquery))
|
|
delete_result = await db.execute(delete_stmt)
|
|
batch_deleted = delete_result.rowcount or 0
|
|
total_deleted += batch_deleted
|
|
|
|
if batch_deleted == 0:
|
|
break
|
|
|
|
return total_deleted
|
|
|
|
|
|
async def delete_session(
|
|
db: AsyncSession, workspace_name: str, session_name: str
|
|
) -> bool:
|
|
"""
|
|
Delete a session and all associated data (hard delete).
|
|
|
|
This performs cascading deletes for all session-related data including:
|
|
- Active queue sessions
|
|
- Queue items
|
|
- Message embeddings (batched)
|
|
- Documents (theory-of-mind data, batched)
|
|
- Messages (batched)
|
|
- Session peer associations
|
|
- The session itself
|
|
|
|
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
|
|
"""
|
|
honcho_session = await get_session(
|
|
db, session_name, workspace_name, include_inactive=True
|
|
)
|
|
|
|
# Perform cascading deletes in order
|
|
# Order is important to avoid foreign key constraint violations
|
|
try:
|
|
# Delete ActiveQueueSession entries
|
|
# Work unit keys have format: {task_type}:{workspace_name}:{session_name}:{...}
|
|
await db.execute(
|
|
delete(models.ActiveQueueSession).where(
|
|
and_(
|
|
func.split_part(models.ActiveQueueSession.work_unit_key, ":", 2)
|
|
== workspace_name,
|
|
func.split_part(models.ActiveQueueSession.work_unit_key, ":", 3)
|
|
== session_name,
|
|
)
|
|
)
|
|
)
|
|
|
|
# Delete QueueItem entries
|
|
await db.execute(
|
|
delete(models.QueueItem).where(
|
|
models.QueueItem.session_id == honcho_session.id
|
|
)
|
|
)
|
|
|
|
# Delete MessageEmbedding entries in batches
|
|
await _batch_delete_matching(
|
|
db,
|
|
models.MessageEmbedding,
|
|
[
|
|
models.MessageEmbedding.session_name == session_name,
|
|
models.MessageEmbedding.workspace_name == workspace_name,
|
|
],
|
|
batch_size=5000,
|
|
)
|
|
|
|
# Delete Document entries associated with this session in batches
|
|
await _batch_delete_matching(
|
|
db,
|
|
models.Document,
|
|
[
|
|
models.Document.session_name == session_name,
|
|
models.Document.workspace_name == workspace_name,
|
|
],
|
|
batch_size=5000,
|
|
)
|
|
|
|
# Delete Message entries in batches
|
|
await _batch_delete_matching(
|
|
db,
|
|
models.Message,
|
|
[
|
|
models.Message.session_name == session_name,
|
|
models.Message.workspace_name == workspace_name,
|
|
],
|
|
batch_size=5000,
|
|
)
|
|
|
|
# Delete SessionPeer associations
|
|
await db.execute(
|
|
delete(models.SessionPeer).where(
|
|
models.SessionPeer.session_name == session_name,
|
|
models.SessionPeer.workspace_name == workspace_name,
|
|
)
|
|
)
|
|
|
|
# Finally, delete the session itself
|
|
await db.delete(honcho_session)
|
|
await db.commit()
|
|
logger.debug("Session %s and all associated data deleted", session_name)
|
|
except Exception as e:
|
|
logger.error("Failed to delete session %s: %s", session_name, e)
|
|
await db.rollback()
|
|
raise e
|
|
|
|
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 data. If cutoff_message_id is provided,
|
|
only clone messages up to and including that message.
|
|
|
|
The following data is copied to the new session:
|
|
- Session metadata
|
|
- Session configuration
|
|
- All messages (or up to cutoff_message_id) with their content, metadata, and peer associations
|
|
- Session-peer associations with their configurations (observe_me, observe_others)
|
|
|
|
The new session gets a unique ID (nanoid) and fresh timestamps.
|
|
|
|
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 (must be active)
|
|
stmt = (
|
|
select(models.Session)
|
|
.where(models.Session.workspace_name == workspace_name)
|
|
.where(models.Session.name == original_session_name)
|
|
.where(models.Session.is_active == True) # noqa: E712
|
|
)
|
|
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,
|
|
configuration=original_session.configuration,
|
|
)
|
|
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 (including their configurations)
|
|
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,
|
|
configuration=session_peer.configuration,
|
|
)
|
|
db.add(new_session_peer)
|
|
|
|
await db.commit()
|
|
await db.refresh(new_session)
|
|
logger.debug("Session %s cloned successfully", original_session_name)
|
|
|
|
# Cache will be populated on next read - read-through pattern
|
|
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
|
|
await get_session(db, session_name, 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())
|
|
)
|
|
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()
|