Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 61 additions & 1 deletion backend/app/api/v1/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
8 changes: 8 additions & 0 deletions backend/app/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
)


Expand Down Expand Up @@ -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,
)
Expand Down
28 changes: 27 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
1 change: 1 addition & 0 deletions backend/app/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 28 additions & 2 deletions frontend/src/components/TailoredResumeList.jsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -84,6 +98,18 @@ export default function TailoredResumeList({ jobs, onPreviewResume, onRefresh })
</button>
</>
)}
{(job.status === 'failed' || job.status === 'pending') && (
<button
className="btn btn-secondary btn-sm"
onClick={() => handleRetry(job)}
disabled={retryingId === job.id}
title="Retry"
>
{retryingId === job.id
? <span className="spinner" style={{ width: 14, height: 14 }} />
: <><FiRefreshCw size={14} /> Retry</>}
</button>
)}
{job.status !== 'pending' && job.status !== 'processing' && (
<button
className="btn btn-ghost btn-icon btn-sm"
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/pages/HomePage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ export default function HomePage() {
setJobs((prev) => [job, ...prev]);
};

const handleRetry = (job) => {
setActiveJobId(job.id);
setActiveJobHasGithub(job.github_profile_id != null);
fetchData();
};

const handleComplete = useCallback(async () => {
try {
const res = await api.get(`/jobs/${activeJobId}`);
Expand All @@ -64,7 +70,7 @@ export default function HomePage() {
<h2 style={{ fontSize: 'var(--font-size-xl)', marginBottom: 'var(--space-4)', display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
<FiActivity size={20} color="var(--accent-secondary)" /> Previously Tailored Resumes
</h2>
<TailoredResumeList jobs={jobs} onPreviewResume={(id) => setPreviewJobId(id)} onRefresh={fetchData} />
<TailoredResumeList jobs={jobs} onPreviewResume={(id) => setPreviewJobId(id)} onRefresh={fetchData} onRetry={handleRetry} />
</section>

<section style={{ marginBottom: 'var(--space-10)' }}>
Expand Down