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:
2026-04-24 02:01:13 -04:00
parent f2fce27f0d
commit d4fae87236
3 changed files with 286 additions and 15 deletions

View File

@@ -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