diff --git a/backend/app/api/v1/jobs.py b/backend/app/api/v1/jobs.py index 86b4c73..31b988d 100644 --- a/backend/app/api/v1/jobs.py +++ b/backend/app/api/v1/jobs.py @@ -8,7 +8,7 @@ import asyncio from fastapi import APIRouter, Depends, HTTPException, status from fastapi.responses import StreamingResponse, Response -from sqlalchemy import select +from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_db @@ -128,6 +128,66 @@ async def delete_job( return Response(status_code=status.HTTP_204_NO_CONTENT) +@router.post("/{job_id}/retry", response_model=JobResponse) +async def retry_job( + job_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Re-run a failed or stuck job: reset to pending, clear error, relaunch crew.""" + result = await db.execute( + select(Job).where(Job.id == job_id, Job.user_id == current_user.id) + ) + job = result.scalar_one_or_none() + if not job: + raise HTTPException(status_code=404, detail="Job not found.") + if job.status == "completed": + raise HTTPException(status_code=409, detail="Completed jobs cannot be retried.") + if job.status == "processing": + raise HTTPException(status_code=409, detail="Job is already running.") + + # Re-fetch GitHub profile (optional; FK is SET NULL so it may be None already). + github_profile = None + if job.github_profile_id is not None: + gh_result = await db.execute( + select(GithubProfile).where( + GithubProfile.id == job.github_profile_id, + GithubProfile.user_id == current_user.id, + ) + ) + github_profile = gh_result.scalar_one_or_none() # gone → run without GitHub + + # Resume must still exist. + resume_result = await db.execute( + select(Resume).where(Resume.id == job.resume_id, Resume.user_id == current_user.id) + ) + resume = resume_result.scalar_one_or_none() + if not resume: + raise HTTPException( + status_code=409, + detail="The resume for this job no longer exists. Cannot retry.", + ) + + await db.execute( + update(Job).where(Job.id == job_id).values(status="pending", error_message=None) + ) + await db.commit() + await db.refresh(job) + + # Pre-seed progress so a re-attaching SSE stream doesn't see the stale + # terminal 'failed' step and close instantly. + progress_store[job_id] = {"current_step": "pending", "error": None} + + asyncio.create_task( + run_crew_for_job( + job_id=job.id, user_id=current_user.id, user_email=current_user.email, + github_profile=github_profile, resume_gcs_path=resume.gcs_path, + job_url=job.linkedin_job_url, + ) + ) + return job + + @router.get("/{job_id}/progress") async def stream_job_progress( job_id: int, current_user: User = Depends(get_current_user), diff --git a/backend/app/cli.py b/backend/app/cli.py index 3786d0e..7ab8202 100644 --- a/backend/app/cli.py +++ b/backend/app/cli.py @@ -106,11 +106,15 @@ def api() -> None: """Start the FastAPI app with autoreload (development).""" import uvicorn + # uvicorn excludes whole directory trees only when given absolute paths + # (it matches them against each changed file's absolute parents). Relative + # globs like ".venv/*" do NOT work. "*.log" still matches by filename. uvicorn.run( "app.main:app", host=DEFAULT_API_HOST, port=int(DEFAULT_API_PORT), reload=True, + reload_excludes=[str(BACKEND_DIR / ".venv"), str(BACKEND_DIR / "logs"), "*.log"], ) @@ -225,6 +229,10 @@ def dev() -> None: [ sys.executable, "-m", "uvicorn", "app.main:app", "--host", DEFAULT_API_HOST, "--port", DEFAULT_API_PORT, "--reload", + # Absolute dir paths so uvicorn excludes the whole tree (see api()). + "--reload-exclude", str(BACKEND_DIR / ".venv"), + "--reload-exclude", str(BACKEND_DIR / "logs"), + "--reload-exclude", "*.log", ], BACKEND_DIR, ) diff --git a/backend/app/main.py b/backend/app/main.py index 085a75e..0801ddc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,22 +5,48 @@ """ from contextlib import asynccontextmanager +from datetime import datetime, timedelta from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy import update from app.core.config import get_settings from app.core.database import engine, Base +from app.core.logging import get_logger from app.api.v1 import auth, github, resumes, jobs settings = get_settings() +logger = get_logger(__name__) @asynccontextmanager async def lifespan(app: FastAPI): - """Create database tables on startup and dispose the engine on shutdown.""" + """Create tables; best-effort fail jobs stuck >3h; dispose engine on shutdown.""" async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) + + # Best-effort: fail jobs left pending/processing for >3h (orphaned by a + # prior restart). Reuses THIS connection — no new session/checkout — and + # swallows all errors so it can never abort startup or kill the proxy. + try: + from app.models import Job + cutoff = datetime.utcnow() - timedelta(hours=3) + await conn.execute( + update(Job) + .where( + Job.status.in_(("pending", "processing")), + Job.created_at < cutoff, + ) + .values( + status="failed", + error_message="Job was stuck in progress for over 3 hours " + "(likely a server restart). Please retry.", + ) + ) + except Exception as exc: # noqa: BLE001 — never let cleanup break startup + logger.warning("Startup stuck-job cleanup skipped: %s", exc) + yield await engine.dispose() diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index ec8eece..0654a88 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -79,6 +79,7 @@ class TailorJobRequest(BaseModel): class JobResponse(BaseModel): id: int + github_profile_id: Optional[int] = None linkedin_job_url: str job_name: Optional[str] = None company_name: Optional[str] = None diff --git a/frontend/src/components/TailoredResumeList.jsx b/frontend/src/components/TailoredResumeList.jsx index 2aee95d..6252958 100644 --- a/frontend/src/components/TailoredResumeList.jsx +++ b/frontend/src/components/TailoredResumeList.jsx @@ -1,11 +1,25 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { FiEye, FiDownload, FiBookOpen, FiTrash2 } from 'react-icons/fi'; +import { FiEye, FiDownload, FiBookOpen, FiTrash2, FiRefreshCw } from 'react-icons/fi'; import api from '../services/api'; -export default function TailoredResumeList({ jobs, onPreviewResume, onRefresh }) { +export default function TailoredResumeList({ jobs, onPreviewResume, onRefresh, onRetry }) { const navigate = useNavigate(); const [deletingId, setDeletingId] = useState(null); + const [retryingId, setRetryingId] = useState(null); + + const handleRetry = async (job) => { + setRetryingId(job.id); + try { + const res = await api.post(`/jobs/${job.id}/retry`); + onRetry?.(res.data); + } catch (err) { + console.error('Retry failed:', err); + alert(err.response?.data?.detail || 'Failed to retry. Please try again.'); + } finally { + setRetryingId(null); + } + }; const handleDelete = async (job) => { const label = job.job_name @@ -84,6 +98,18 @@ export default function TailoredResumeList({ jobs, onPreviewResume, onRefresh }) )} + {(job.status === 'failed' || job.status === 'pending') && ( + + )} {job.status !== 'pending' && job.status !== 'processing' && (