"""FastAPI routes for scope resources. A scope is a named grouping of sessions that provides a visibility boundary within a peer. Internally a scope is a peer named ``scope.`` that observes its member sessions and never speaks; these routes are the facade that keeps the observer/observed mechanics hidden. All scopes routes require a workspace-level (or admin) key: scopes are an app-level admin surface, so peer- and session-scoped keys are rejected. Note: scope membership only affects messages ingested *after* the membership change. Conclusions already derived are neither backfilled on add nor reconciled on removal. """ import logging from fastapi import APIRouter, Body, Depends, Path, Query, Response from fastapi_pagination import Page from fastapi_pagination.ext.sqlalchemy import apaginate from sqlalchemy.ext.asyncio import AsyncSession from src import crud, schemas from src.dependencies import db, read_db from src.security import require_auth logger = logging.getLogger(__name__) router = APIRouter( prefix="/workspaces/{workspace_id}/scopes", tags=["scopes"], ) @router.post( "", response_model=schemas.Scope, dependencies=[Depends(require_auth(workspace_name="workspace_id"))], ) async def get_or_create_scope( response: Response, workspace_id: str = Path(...), scope: schemas.ScopeCreate = Body(..., description="Scope creation parameters"), db: AsyncSession = db, ): """ Get a Scope by ID or create a new Scope with the given ID. Returns 201 when the scope is created and 200 when it already exists. A pre-existing peer occupying the scope's reserved internal name is never adopted; that conflict returns 409. """ result = await crud.get_or_create_scopes(db, workspace_id, [scope]) await db.commit() await result.post_commit() response.status_code = 201 if result.created else 200 return result.resource[0] @router.post( "/list", response_model=Page[schemas.Scope], dependencies=[Depends(require_auth(workspace_name="workspace_id"))], ) async def get_scopes( workspace_id: str = Path(...), reverse: bool = Query(False, description="Whether to reverse the order of results"), db: AsyncSession = read_db, ): """Get all Scopes for a Workspace. Results are paginated.""" return await apaginate( db, await crud.get_scopes(workspace_name=workspace_id, reverse=reverse), ) @router.get( "/{scope_id}", response_model=schemas.Scope, dependencies=[Depends(require_auth(workspace_name="workspace_id"))], ) async def get_scope( workspace_id: str = Path(...), scope_id: str = Path(...), db: AsyncSession = read_db, ): """Get a single Scope by ID.""" return await crud.get_scope_or_raise(db, workspace_id, scope_id) @router.post( "/{scope_id}/sessions", status_code=204, response_model=None, dependencies=[Depends(require_auth(workspace_name="workspace_id"))], ) async def add_sessions_to_scope( workspace_id: str = Path(...), scope_id: str = Path(...), body: schemas.ScopeSessionsAdd = Body( ..., description="IDs of the sessions to add to the scope" ), db: AsyncSession = db, ): """ Add Sessions to a Scope. All named sessions must already exist (404 otherwise). Adding a session that is already a member is a no-op. List the resulting membership with `POST /scopes/{scope_id}/sessions/list`. Note: membership applies only to messages ingested after this call; conclusions already derived are not backfilled. """ await crud.add_sessions_to_scope( db, workspace_name=workspace_id, scope_name=scope_id, session_names=body.session_ids, ) @router.delete( "/{scope_id}/sessions/{session_id}", status_code=204, response_model=None, dependencies=[Depends(require_auth(workspace_name="workspace_id"))], ) async def remove_session_from_scope( workspace_id: str = Path(...), scope_id: str = Path(...), session_id: str = Path(...), db: AsyncSession = db, ): """ Remove a Session from a Scope. Note: conclusions already derived while the session was a member are left in place. """ await crud.remove_session_from_scope( db, workspace_name=workspace_id, scope_name=scope_id, session_name=session_id, ) @router.post( "/{scope_id}/sessions/list", response_model=Page[schemas.Session], dependencies=[Depends(require_auth(workspace_name="workspace_id"))], ) async def get_scope_sessions( workspace_id: str = Path(...), scope_id: str = Path(...), reverse: bool = Query(False, description="Whether to reverse the order of results"), db: AsyncSession = read_db, ): """Get the Sessions that are members of a Scope, paginated. Ordered by how long each session has been a member: longest-standing member first, or most recently added first when `reverse` is true. """ # Distinguishes an empty scope from one that does not exist; the query itself # returns an empty page either way. await crud.get_scope_or_raise(db, workspace_id, scope_id) return await apaginate( db, await crud.get_scope_sessions( workspace_name=workspace_id, scope_name=scope_id, reverse=reverse ), )