POST /ai-sessions/{id}/handoffs/{hid}/claim previously required only an
authenticated user, so a viewer-role account user could claim escalations.
Codex review flagged this as wedge-relevant: the Escalation Mode race-
condition story (two seniors clicking Pick Up simultaneously) depends on
auth gating for audit integrity. Originally captured as a deferred TODO
during /plan-eng-review, then moved in-scope by /codex review.
Swap the dep to require_engineer_or_admin. One-line change. Two new tests:
- viewer_role gets 403 with "Engineer or admin access required"
- engineer/owner role still succeeds and claimed_at + claimed_by populate
Existing handoff create + queue tests unaffected.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
123 lines
4.1 KiB
Python
123 lines
4.1 KiB
Python
"""Handoff endpoints — unified park/escalate.
|
|
|
|
POST /ai-sessions/{id}/handoff — Create handoff
|
|
GET /ai-sessions/{id}/handoffs — Handoff history
|
|
POST /ai-sessions/{id}/handoffs/{hid}/claim — Claim session
|
|
GET /ai-sessions/queue — Team queue
|
|
"""
|
|
import logging
|
|
from typing import Annotated
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin
|
|
from app.models.user import User
|
|
from app.models.ai_session import AISession
|
|
from app.models.session_handoff import SessionHandoff
|
|
from app.services.handoff_manager import HandoffManager
|
|
from app.schemas.session_handoff import (
|
|
HandoffCreateRequest,
|
|
HandoffResponse,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Queue endpoint needs its own router (no session_id prefix)
|
|
queue_router = APIRouter(prefix="/ai-sessions", tags=["session-handoffs"])
|
|
|
|
# Session-scoped endpoints
|
|
router = APIRouter(prefix="/ai-sessions/{session_id}", tags=["session-handoffs"])
|
|
|
|
|
|
@router.post("/handoff", response_model=HandoffResponse, status_code=status.HTTP_201_CREATED)
|
|
async def create_handoff(
|
|
session_id: UUID,
|
|
body: HandoffCreateRequest,
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
) -> HandoffResponse:
|
|
"""Create a handoff (park or escalate)."""
|
|
result = await db.execute(
|
|
select(AISession).where(
|
|
AISession.id == session_id,
|
|
AISession.user_id == current_user.id,
|
|
)
|
|
)
|
|
session = result.scalar_one_or_none()
|
|
if not session:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
|
|
manager = HandoffManager(db)
|
|
try:
|
|
handoff = await manager.create_handoff(
|
|
session_id=session_id,
|
|
intent=body.intent,
|
|
engineer_notes=body.engineer_notes,
|
|
user_id=current_user.id,
|
|
priority=body.priority,
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
await db.commit()
|
|
return HandoffResponse.model_validate(handoff)
|
|
|
|
|
|
@router.get("/handoffs", response_model=list[HandoffResponse])
|
|
async def list_handoffs(
|
|
session_id: UUID,
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
) -> list[HandoffResponse]:
|
|
"""Get handoff history for a session."""
|
|
result = await db.execute(
|
|
select(SessionHandoff)
|
|
.where(SessionHandoff.session_id == session_id)
|
|
.order_by(SessionHandoff.created_at.desc())
|
|
)
|
|
handoffs = result.scalars().all()
|
|
return [HandoffResponse.model_validate(h) for h in handoffs]
|
|
|
|
|
|
@router.post("/handoffs/{handoff_id}/claim", response_model=HandoffResponse)
|
|
async def claim_handoff(
|
|
session_id: UUID,
|
|
handoff_id: UUID,
|
|
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
) -> HandoffResponse:
|
|
"""Claim a handed-off session.
|
|
|
|
Role-gated to engineer/admin/owner — viewers cannot claim. The race-condition
|
|
story (two seniors clicking Pick Up simultaneously) depends on auth gating
|
|
for audit integrity. Codex review flagged this as wedge-relevant; locked
|
|
in-scope for Escalation Mode v1.
|
|
"""
|
|
manager = HandoffManager(db)
|
|
try:
|
|
handoff = await manager.claim_session(
|
|
handoff_id=handoff_id,
|
|
claiming_user_id=current_user.id,
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
await db.commit()
|
|
return HandoffResponse.model_validate(handoff)
|
|
|
|
|
|
@queue_router.get("/queue")
|
|
async def get_queue(
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
) -> list[dict]:
|
|
"""Get team queue of parked + escalated sessions."""
|
|
manager = HandoffManager(db)
|
|
return await manager.get_queue(
|
|
team_id=current_user.team_id,
|
|
account_id=current_user.account_id,
|
|
)
|