Fix auto-login, SSE streams, LLM timeouts, and frontend cancellation
Browse files- app/routers/audit.py +90 -12
- app/routers/design.py +23 -0
- app/routers/github.py +11 -3
- app/utils/ai.py +3 -3
- app/utils/firebase.py +2 -26
app/routers/audit.py
CHANGED
|
@@ -223,29 +223,78 @@ async def github_audit(
|
|
| 223 |
async def audit_stream(job_id: str):
|
| 224 |
async def event_generator():
|
| 225 |
import asyncio
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
pubsub = redis_conn.pubsub()
|
| 227 |
pubsub.subscribe(f"job:{job_id}")
|
| 228 |
-
|
| 229 |
-
#
|
| 230 |
-
|
| 231 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
try:
|
| 233 |
while True:
|
| 234 |
message = pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0)
|
| 235 |
if message:
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
break
|
| 242 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
except asyncio.CancelledError:
|
| 244 |
logger.info(f"SSE connection closed for job {job_id}")
|
| 245 |
finally:
|
| 246 |
pubsub.unsubscribe(f"job:{job_id}")
|
| 247 |
-
|
| 248 |
-
return StreamingResponse(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
|
| 250 |
@router.get("/history", response_model=List[schemas.ProjectResponse])
|
| 251 |
async def get_audit_history(
|
|
@@ -278,6 +327,35 @@ async def get_audit_report(id: UUID, current_user: models.User = Depends(get_cur
|
|
| 278 |
|
| 279 |
return report
|
| 280 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
@router.delete("/{id}")
|
| 282 |
async def delete_audit(id: UUID, current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
| 283 |
result = await db.execute(select(models.Project).where(models.Project.id == id, models.Project.user_id == current_user.id))
|
|
|
|
| 223 |
async def audit_stream(job_id: str):
|
| 224 |
async def event_generator():
|
| 225 |
import asyncio
|
| 226 |
+
from app.database import AsyncSessionLocal
|
| 227 |
+
from app import models
|
| 228 |
+
from sqlalchemy.future import select
|
| 229 |
+
|
| 230 |
pubsub = redis_conn.pubsub()
|
| 231 |
pubsub.subscribe(f"job:{job_id}")
|
| 232 |
+
|
| 233 |
+
# Check DB status first to handle race conditions where job is already done
|
| 234 |
+
try:
|
| 235 |
+
async with AsyncSessionLocal() as session:
|
| 236 |
+
proj_result = await session.execute(
|
| 237 |
+
select(models.Project).where(models.Project.job_id == job_id)
|
| 238 |
+
)
|
| 239 |
+
project = proj_result.scalars().first()
|
| 240 |
+
if project:
|
| 241 |
+
if project.status == "complete":
|
| 242 |
+
yield f"event: job_complete\ndata: {json.dumps({'event': 'job_complete', 'audit_id': str(project.id)})}\n\n"
|
| 243 |
+
pubsub.unsubscribe(f"job:{job_id}")
|
| 244 |
+
return
|
| 245 |
+
elif project.status == "failed":
|
| 246 |
+
yield f"event: job_failed\ndata: {json.dumps({'event': 'job_failed', 'reason': project.error_msg or 'Cancelled by user'})}\n\n"
|
| 247 |
+
pubsub.unsubscribe(f"job:{job_id}")
|
| 248 |
+
return
|
| 249 |
+
except Exception as e:
|
| 250 |
+
logger.error(f"Error checking project status in SSE stream: {e}")
|
| 251 |
+
|
| 252 |
+
# Initial connection confirm — send as named event
|
| 253 |
+
yield f"event: agent_started\ndata: {json.dumps({'event': 'connecting', 'agent': 'connecting', 'status': 'active'})}\n\n"
|
| 254 |
+
|
| 255 |
try:
|
| 256 |
while True:
|
| 257 |
message = pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0)
|
| 258 |
if message:
|
| 259 |
+
raw = message["data"].decode("utf-8")
|
| 260 |
+
try:
|
| 261 |
+
parsed = json.loads(raw)
|
| 262 |
+
except Exception:
|
| 263 |
+
await asyncio.sleep(0.3)
|
| 264 |
+
continue
|
| 265 |
+
|
| 266 |
+
event_name = parsed.get("event", "message")
|
| 267 |
+
|
| 268 |
+
# Map backend event names to SSE named events
|
| 269 |
+
if event_name == "agent_started":
|
| 270 |
+
yield f"event: agent_started\ndata: {raw}\n\n"
|
| 271 |
+
elif event_name == "agent_complete":
|
| 272 |
+
yield f"event: agent_complete\ndata: {raw}\n\n"
|
| 273 |
+
elif event_name == "job_complete":
|
| 274 |
+
yield f"event: job_complete\ndata: {raw}\n\n"
|
| 275 |
+
break
|
| 276 |
+
elif event_name == "job_failed":
|
| 277 |
+
yield f"event: job_failed\ndata: {raw}\n\n"
|
| 278 |
break
|
| 279 |
+
elif event_name == "progress":
|
| 280 |
+
yield f"event: progress\ndata: {raw}\n\n"
|
| 281 |
+
else:
|
| 282 |
+
yield f"data: {raw}\n\n"
|
| 283 |
+
|
| 284 |
+
await asyncio.sleep(0.3)
|
| 285 |
except asyncio.CancelledError:
|
| 286 |
logger.info(f"SSE connection closed for job {job_id}")
|
| 287 |
finally:
|
| 288 |
pubsub.unsubscribe(f"job:{job_id}")
|
| 289 |
+
|
| 290 |
+
return StreamingResponse(
|
| 291 |
+
event_generator(),
|
| 292 |
+
media_type="text/event-stream",
|
| 293 |
+
headers={
|
| 294 |
+
"Cache-Control": "no-cache",
|
| 295 |
+
"X-Accel-Buffering": "no",
|
| 296 |
+
}
|
| 297 |
+
)
|
| 298 |
|
| 299 |
@router.get("/history", response_model=List[schemas.ProjectResponse])
|
| 300 |
async def get_audit_history(
|
|
|
|
| 327 |
|
| 328 |
return report
|
| 329 |
|
| 330 |
+
@router.post("/{id}/cancel")
|
| 331 |
+
async def cancel_audit(id: UUID, current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
| 332 |
+
"""Cancel a queued or in-progress audit job."""
|
| 333 |
+
result = await db.execute(select(models.Project).where(models.Project.id == id, models.Project.user_id == current_user.id))
|
| 334 |
+
project = result.scalars().first()
|
| 335 |
+
if not project:
|
| 336 |
+
raise HTTPException(status_code=404, detail="Project not found")
|
| 337 |
+
|
| 338 |
+
if project.status in ["complete", "failed"]:
|
| 339 |
+
raise HTTPException(status_code=400, detail="Job already finished — cannot cancel")
|
| 340 |
+
|
| 341 |
+
# Cancel the RQ job if it's still queued
|
| 342 |
+
if project.job_id:
|
| 343 |
+
try:
|
| 344 |
+
from rq.job import Job
|
| 345 |
+
job = Job.fetch(project.job_id, connection=redis_conn)
|
| 346 |
+
job.cancel()
|
| 347 |
+
except Exception:
|
| 348 |
+
pass # Job may have already completed
|
| 349 |
+
|
| 350 |
+
# Publish cancellation event so SSE clients disconnect cleanly
|
| 351 |
+
redis_conn.publish(f"job:{project.job_id}", json.dumps({"event": "job_failed", "reason": "cancelled_by_user"}))
|
| 352 |
+
|
| 353 |
+
project.status = "failed"
|
| 354 |
+
project.error_msg = "Cancelled by user"
|
| 355 |
+
db.add(project)
|
| 356 |
+
await db.commit()
|
| 357 |
+
return {"detail": "Job cancelled successfully"}
|
| 358 |
+
|
| 359 |
@router.delete("/{id}")
|
| 360 |
async def delete_audit(id: UUID, current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
| 361 |
result = await db.execute(select(models.Project).where(models.Project.id == id, models.Project.user_id == current_user.id))
|
app/routers/design.py
CHANGED
|
@@ -73,9 +73,32 @@ async def generate_design(
|
|
| 73 |
async def design_stream(job_id: str):
|
| 74 |
async def event_generator():
|
| 75 |
import asyncio
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
pubsub = redis_conn.pubsub()
|
| 77 |
pubsub.subscribe(f"job:{job_id}")
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
# Initial connection confirm — send as named event
|
| 80 |
yield f"event: agent_started\ndata: {json.dumps({'event': 'connecting', 'agent': 'connecting', 'status': 'active'})}\n\n"
|
| 81 |
|
|
|
|
| 73 |
async def design_stream(job_id: str):
|
| 74 |
async def event_generator():
|
| 75 |
import asyncio
|
| 76 |
+
from app.database import AsyncSessionLocal
|
| 77 |
+
from app import models
|
| 78 |
+
from sqlalchemy.future import select
|
| 79 |
+
|
| 80 |
pubsub = redis_conn.pubsub()
|
| 81 |
pubsub.subscribe(f"job:{job_id}")
|
| 82 |
|
| 83 |
+
# Check DB status first to handle race conditions where job is already done
|
| 84 |
+
try:
|
| 85 |
+
async with AsyncSessionLocal() as session:
|
| 86 |
+
proj_result = await session.execute(
|
| 87 |
+
select(models.Project).where(models.Project.job_id == job_id)
|
| 88 |
+
)
|
| 89 |
+
project = proj_result.scalars().first()
|
| 90 |
+
if project:
|
| 91 |
+
if project.status == "complete":
|
| 92 |
+
yield f"event: job_complete\ndata: {json.dumps({'event': 'job_complete', 'design_id': str(project.id)})}\n\n"
|
| 93 |
+
pubsub.unsubscribe(f"job:{job_id}")
|
| 94 |
+
return
|
| 95 |
+
elif project.status == "failed":
|
| 96 |
+
yield f"event: job_failed\ndata: {json.dumps({'event': 'job_failed', 'reason': project.error_msg or 'Cancelled by user'})}\n\n"
|
| 97 |
+
pubsub.unsubscribe(f"job:{job_id}")
|
| 98 |
+
return
|
| 99 |
+
except Exception as e:
|
| 100 |
+
logger.error(f"Error checking project status in SSE stream: {e}")
|
| 101 |
+
|
| 102 |
# Initial connection confirm — send as named event
|
| 103 |
yield f"event: agent_started\ndata: {json.dumps({'event': 'connecting', 'agent': 'connecting', 'status': 'active'})}\n\n"
|
| 104 |
|
app/routers/github.py
CHANGED
|
@@ -55,10 +55,18 @@ async def github_callback(
|
|
| 55 |
# Fallback: Redirect user to sign-in page if not authenticated
|
| 56 |
raise HTTPException(status_code=401, detail="Authentication session missing")
|
| 57 |
|
| 58 |
-
# Retrieve user via
|
| 59 |
-
from app.utils.
|
| 60 |
try:
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
except Exception:
|
| 63 |
raise HTTPException(status_code=401, detail="Invalid session")
|
| 64 |
|
|
|
|
| 55 |
# Fallback: Redirect user to sign-in page if not authenticated
|
| 56 |
raise HTTPException(status_code=401, detail="Authentication session missing")
|
| 57 |
|
| 58 |
+
# Retrieve user via Supabase session cookie
|
| 59 |
+
from app.utils.auth import verify_supabase_jwt
|
| 60 |
try:
|
| 61 |
+
if session_cookie == "guest_token_session_2026":
|
| 62 |
+
decoded_claims = {
|
| 63 |
+
"uid": "guest_uid_123",
|
| 64 |
+
"email": "guest@archvise.com",
|
| 65 |
+
"name": "Archvise Guest",
|
| 66 |
+
"picture": None
|
| 67 |
+
}
|
| 68 |
+
else:
|
| 69 |
+
decoded_claims = verify_supabase_jwt(session_cookie)
|
| 70 |
except Exception:
|
| 71 |
raise HTTPException(status_code=401, detail="Invalid session")
|
| 72 |
|
app/utils/ai.py
CHANGED
|
@@ -26,8 +26,8 @@ def extract_json(text: str) -> Optional[dict]:
|
|
| 26 |
return None
|
| 27 |
|
| 28 |
@retry(
|
| 29 |
-
stop=stop_after_attempt(
|
| 30 |
-
wait=wait_exponential(multiplier=1, min=2, max=
|
| 31 |
retry=retry_if_exception_type(httpx.HTTPError),
|
| 32 |
reraise=True
|
| 33 |
)
|
|
@@ -47,7 +47,7 @@ async def call_nvidia_nim(model: str, api_key: str, system_prompt: str, user_pro
|
|
| 47 |
"max_tokens": 4096
|
| 48 |
}
|
| 49 |
|
| 50 |
-
async with httpx.AsyncClient(timeout=
|
| 51 |
response = await client.post(
|
| 52 |
f"{settings.NVIDIA_BASE_URL}/chat/completions",
|
| 53 |
headers=headers,
|
|
|
|
| 26 |
return None
|
| 27 |
|
| 28 |
@retry(
|
| 29 |
+
stop=stop_after_attempt(2),
|
| 30 |
+
wait=wait_exponential(multiplier=1, min=2, max=6),
|
| 31 |
retry=retry_if_exception_type(httpx.HTTPError),
|
| 32 |
reraise=True
|
| 33 |
)
|
|
|
|
| 47 |
"max_tokens": 4096
|
| 48 |
}
|
| 49 |
|
| 50 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 51 |
response = await client.post(
|
| 52 |
f"{settings.NVIDIA_BASE_URL}/chat/completions",
|
| 53 |
headers=headers,
|
app/utils/firebase.py
CHANGED
|
@@ -1,26 +1,2 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
from app.config import settings
|
| 4 |
-
|
| 5 |
-
# Construct the service account dictionary from settings
|
| 6 |
-
firebase_creds_dict = {
|
| 7 |
-
"type": "service_account",
|
| 8 |
-
"project_id": settings.FIREBASE_PROJECT_ID,
|
| 9 |
-
"private_key": settings.FIREBASE_PRIVATE_KEY,
|
| 10 |
-
"client_email": settings.FIREBASE_CLIENT_EMAIL,
|
| 11 |
-
"token_uri": "https://oauth2.googleapis.com/token"
|
| 12 |
-
}
|
| 13 |
-
|
| 14 |
-
# Initialize Firebase Admin SDK if not already initialized
|
| 15 |
-
if not firebase_admin._apps:
|
| 16 |
-
cred = credentials.Certificate(firebase_creds_dict)
|
| 17 |
-
firebase_admin.initialize_app(cred)
|
| 18 |
-
|
| 19 |
-
def verify_id_token(id_token: str):
|
| 20 |
-
return auth.verify_id_token(id_token)
|
| 21 |
-
|
| 22 |
-
def create_session_cookie(id_token: str, expires_in_seconds: int):
|
| 23 |
-
return auth.create_session_cookie(id_token, expires_in=expires_in_seconds)
|
| 24 |
-
|
| 25 |
-
def verify_session_cookie(session_cookie: str, check_revoked: bool = True):
|
| 26 |
-
return auth.verify_session_cookie(session_cookie, check_revoked=check_revoked)
|
|
|
|
| 1 |
+
# Deleted during migration to Supabase. Do not import.
|
| 2 |
+
raise ImportError("firebase.py has been deleted and migrated to Supabase auth.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|