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' && (