feat(pilot): inline Script Builder session — idempotent create + auth + filtered list
POST /script-builder/sessions now supports origin='pilot_inline': - Requires ai_session_id; validates it against current user ownership. - Get-or-create: returns existing row for (user, ai_session_id) pair. - Partial unique index on the DB backs the invariant; races resolve to the single winner row. list_sessions + count_user_sessions default-scope to origin='standalone' so inline scratch sessions don't pollute the /script-builder dashboard or count against the 5-session cap. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,12 +3,14 @@ from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.rate_limit import limiter
|
||||
from app.api.deps import get_current_active_user
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.user import User
|
||||
from app.models.script_builder_session import ScriptBuilderSession
|
||||
from app.schemas.script_builder import (
|
||||
@@ -67,15 +69,85 @@ async def create_session(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> ScriptBuilderSessionDetail:
|
||||
"""Start a new Script Builder session."""
|
||||
"""Start a new Script Builder session.
|
||||
|
||||
When origin='pilot_inline', behaves as get-or-create: the same row is
|
||||
returned on repeated calls with the same (user, ai_session_id) pair.
|
||||
Inline sessions are excluded from the session cap and the list endpoint.
|
||||
"""
|
||||
# Phase 9: inline origin validation + authorization
|
||||
if data.origin == "pilot_inline":
|
||||
if data.ai_session_id is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="ai_session_id is required when origin='pilot_inline'",
|
||||
)
|
||||
# Ownership check: the pilot session must belong to the current user.
|
||||
ai_session = await db.scalar(
|
||||
select(AISession).where(
|
||||
AISession.id == data.ai_session_id,
|
||||
AISession.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
if ai_session is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Session not found",
|
||||
)
|
||||
|
||||
# Idempotent get-or-create: if a pilot_inline row already exists for
|
||||
# this (user, ai_session_id) pair, return it without creating a duplicate.
|
||||
existing = await db.scalar(
|
||||
select(ScriptBuilderSession).where(
|
||||
ScriptBuilderSession.user_id == current_user.id,
|
||||
ScriptBuilderSession.ai_session_id == data.ai_session_id,
|
||||
ScriptBuilderSession.origin == "pilot_inline",
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
# Re-fetch with message_records loaded
|
||||
session = await script_builder_service.get_session(db, existing.id, current_user.id)
|
||||
return _session_to_detail(session)
|
||||
|
||||
# Create the inline session — wrap in IntegrityError catch for races.
|
||||
try:
|
||||
session = await script_builder_service.create_session(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
account_id=current_user.account_id,
|
||||
team_id=current_user.team_id,
|
||||
language=data.language,
|
||||
origin=data.origin,
|
||||
ai_session_id=data.ai_session_id,
|
||||
)
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
# Race: another request won the unique index — re-read the winner row.
|
||||
existing = await db.scalar(
|
||||
select(ScriptBuilderSession).where(
|
||||
ScriptBuilderSession.user_id == current_user.id,
|
||||
ScriptBuilderSession.ai_session_id == data.ai_session_id,
|
||||
ScriptBuilderSession.origin == "pilot_inline",
|
||||
)
|
||||
)
|
||||
if existing is None:
|
||||
raise
|
||||
session = existing
|
||||
|
||||
# Re-fetch with message_records loaded
|
||||
session = await script_builder_service.get_session(db, session.id, current_user.id)
|
||||
return _session_to_detail(session)
|
||||
|
||||
# ── Standalone session ──────────────────────────────────────────────────
|
||||
# Acquire per-user advisory lock so concurrent create requests are serialized.
|
||||
# Without this, two simultaneous requests both read count < limit and both
|
||||
# insert, exceeding MAX_SESSIONS_PER_USER.
|
||||
user_lock_key = hash(str(current_user.id)) % (2**62)
|
||||
await db.execute(text("SELECT pg_advisory_xact_lock(:key)"), {"key": user_lock_key})
|
||||
|
||||
# Enforce max concurrent sessions
|
||||
count = await script_builder_service.count_user_sessions(db, current_user.id)
|
||||
# Enforce max concurrent sessions (inline sessions excluded from cap)
|
||||
count = await script_builder_service.count_user_sessions(db, current_user.id, include_inline=False)
|
||||
if count >= MAX_SESSIONS_PER_USER:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
@@ -88,6 +160,8 @@ async def create_session(
|
||||
account_id=current_user.account_id,
|
||||
team_id=current_user.team_id,
|
||||
language=data.language,
|
||||
origin=data.origin,
|
||||
ai_session_id=data.ai_session_id,
|
||||
)
|
||||
await db.commit()
|
||||
# Re-fetch with message_records loaded
|
||||
|
||||
@@ -148,6 +148,8 @@ async def create_session(
|
||||
team_id: UUID | None,
|
||||
language: str,
|
||||
initial_prompt: str | None = None,
|
||||
origin: str = "standalone",
|
||||
ai_session_id: UUID | None = None,
|
||||
) -> ScriptBuilderSession:
|
||||
"""Create a new Script Builder session."""
|
||||
session = ScriptBuilderSession(
|
||||
@@ -155,6 +157,8 @@ async def create_session(
|
||||
account_id=account_id,
|
||||
team_id=team_id,
|
||||
language=language,
|
||||
origin=origin,
|
||||
ai_session_id=ai_session_id,
|
||||
)
|
||||
db.add(session)
|
||||
await db.flush()
|
||||
@@ -295,15 +299,22 @@ async def list_sessions(
|
||||
user_id: UUID,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
*,
|
||||
include_inline: bool = False,
|
||||
) -> list[ScriptBuilderSession]:
|
||||
"""List user's builder sessions ordered by updated_at desc."""
|
||||
result = await db.execute(
|
||||
"""List user's builder sessions ordered by updated_at desc.
|
||||
|
||||
By default (include_inline=False) excludes pilot_inline sessions so the
|
||||
/script-builder dashboard only shows standalone sessions.
|
||||
"""
|
||||
stmt = (
|
||||
select(ScriptBuilderSession)
|
||||
.where(ScriptBuilderSession.user_id == user_id)
|
||||
.order_by(ScriptBuilderSession.updated_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
if not include_inline:
|
||||
stmt = stmt.where(ScriptBuilderSession.origin == "standalone")
|
||||
stmt = stmt.order_by(ScriptBuilderSession.updated_at.desc()).limit(limit).offset(offset)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@@ -321,13 +332,23 @@ async def delete_session(
|
||||
return True
|
||||
|
||||
|
||||
async def count_user_sessions(db: AsyncSession, user_id: UUID) -> int:
|
||||
"""Count active builder sessions for a user."""
|
||||
result = await db.execute(
|
||||
select(func.count(ScriptBuilderSession.id)).where(
|
||||
ScriptBuilderSession.user_id == user_id
|
||||
)
|
||||
async def count_user_sessions(
|
||||
db: AsyncSession,
|
||||
user_id: UUID,
|
||||
*,
|
||||
include_inline: bool = False,
|
||||
) -> int:
|
||||
"""Count active builder sessions for a user.
|
||||
|
||||
By default (include_inline=False) excludes pilot_inline sessions so they
|
||||
don't consume slots against the MAX_SESSIONS_PER_USER cap.
|
||||
"""
|
||||
stmt = select(func.count(ScriptBuilderSession.id)).where(
|
||||
ScriptBuilderSession.user_id == user_id
|
||||
)
|
||||
if not include_inline:
|
||||
stmt = stmt.where(ScriptBuilderSession.origin == "standalone")
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user