From f681bd4f531401e2a6d40776f117585eb4f2f4bf Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Thu, 30 Apr 2026 19:50:39 +0100 Subject: [PATCH] feat: add Stop Process button to cancel in-progress jobs Adds POST /jobs/{id}/cancel endpoint that revokes the Celery task and sets status to 'cancelled'. Shows a confirmation widget in the job detail sidebar for admin/production roles when the job is in an active processing state. Co-Authored-By: Claude Sonnet 4.6 --- backend/app/api/v1/routes_jobs.py | 101 +++++++++++++++++++++++++ backend/app/models/job.py | 1 + frontend/src/hooks/useJob.ts | 12 +++ frontend/src/lib/api.ts | 5 ++ frontend/src/routes/jobs/JobDetail.tsx | 71 ++++++++++++++++- 5 files changed, 188 insertions(+), 2 deletions(-) diff --git a/backend/app/api/v1/routes_jobs.py b/backend/app/api/v1/routes_jobs.py index 8b0c289..ccad6ce 100644 --- a/backend/app/api/v1/routes_jobs.py +++ b/backend/app/api/v1/routes_jobs.py @@ -2339,6 +2339,107 @@ async def retry_job( ) +CANCELLABLE_STATUSES = { + JobStatus.INGESTING.value, + JobStatus.AI_PROCESSING.value, + JobStatus.TRANSLATING.value, + JobStatus.TTS_GENERATING.value, + JobStatus.RENDERING_VIDEO.value, + JobStatus.RENDERING_QC.value, +} + + +@router.post("/{job_id}/cancel", response_model=JobResponse) +async def cancel_job( + job_id: str, + ctx: MembershipContext = Depends(get_membership_context), + db: AsyncIOMotorDatabase = Depends(get_database), +): + """Cancel a job that is currently being processed. + + Revokes the Celery task and sets the job status to cancelled. + Only allowed for jobs in active processing states. + """ + job_doc = await get_job_or_403(job_id, ctx, db) + current_user = ctx.user + + if job_doc["status"] not in CANCELLABLE_STATUSES: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Job cannot be cancelled in its current state ({job_doc['status']})", + ) + + now = datetime.utcnow() + + # Revoke Celery task if one is tracked + task_id = job_doc.get("task_id") + if task_id: + try: + celery_app.control.revoke(task_id, terminate=True) + except Exception as e: + logger.warning(f"Could not revoke task {task_id} for job {job_id}: {e}") + + result = await db.jobs.find_one_and_update( + {"_id": job_id}, + { + "$set": { + "status": JobStatus.CANCELLED.value, + "updated_at": now, + }, + "$push": { + "history": { + "at": now, + "status": JobStatus.CANCELLED.value, + "by": current_user.email, + "notes": "Cancelled by user", + } + }, + }, + return_document=True, + ) + + if not result: + raise HTTPException(status_code=404, detail="Job not found") + + try: + await log_job_action( + db=db, + user=current_user, + action=AuditAction.JOB_UPDATED, + resource_type="job", + resource_id=job_id, + resource_name=result.get("title"), + description="Job cancelled by user", + details={"previous_status": job_doc["status"]}, + ) + except Exception as e: + logger.warning(f"Failed to write audit log for job cancel {job_id}: {e}") + + try: + await connection_manager.broadcast_job_status_update( + job_id=job_id, + status=JobStatus.CANCELLED.value, + job_title=result.get("title"), + message=f"{result.get('title', 'Job')} was cancelled", + ) + except Exception as e: + logger.warning(f"Failed to broadcast cancel status for {job_id}: {e}") + + logger.info(f"Job {job_id} cancelled by {current_user.email}") + + return JobResponse( + id=str(result["_id"]), + title=result["title"], + status=result["status"], + source=result["source"], + requested_outputs=RequestedOutputs(**result["requested_outputs"]), + review=result.get("review", {"notes": "", "history": []}), + outputs=result.get("outputs"), + created_at=result["created_at"].isoformat(), + updated_at=result["updated_at"].isoformat(), + ) + + @router.get("/{job_id}/validate", response_model=AssetValidationResponse) async def validate_job_assets( job_id: str, diff --git a/backend/app/models/job.py b/backend/app/models/job.py index 9fbc564..56b0d13 100644 --- a/backend/app/models/job.py +++ b/backend/app/models/job.py @@ -25,6 +25,7 @@ class JobStatus(str, Enum): RENDERING_QC = "rendering_qc" # Re-rendering accessible video during QC review PENDING_FINAL_REVIEW = "pending_final_review" COMPLETED = "completed" + CANCELLED = "cancelled" @classmethod def is_approved(cls, status: str) -> bool: diff --git a/frontend/src/hooks/useJob.ts b/frontend/src/hooks/useJob.ts index e8174eb..d0af875 100644 --- a/frontend/src/hooks/useJob.ts +++ b/frontend/src/hooks/useJob.ts @@ -318,6 +318,18 @@ export function useRetryJob() { }); } +export function useCancelJob() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id }: { id: string }) => apiClient.cancelJob(id), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: ['jobs', id] }); + queryClient.invalidateQueries({ queryKey: ['jobs'] }); + }, + }); +} + export function useReturnToQC() { const queryClient = useQueryClient(); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4fae5ac..62b3f97 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -349,6 +349,11 @@ class ApiClient { return response.data; } + async cancelJob(id: string): Promise { + const response = await this.client.post(`/jobs/${id}/cancel`); + return response.data; + } + async retryJob(id: string, fromStep?: string): Promise { const params = fromStep ? { from_step: fromStep } : {}; const response = await this.client.post(`/jobs/${id}/retry`, null, { params }); diff --git a/frontend/src/routes/jobs/JobDetail.tsx b/frontend/src/routes/jobs/JobDetail.tsx index 32a6488..f68fd1f 100644 --- a/frontend/src/routes/jobs/JobDetail.tsx +++ b/frontend/src/routes/jobs/JobDetail.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { useParams, Link } from 'react-router-dom'; import { formatDistanceToNow } from 'date-fns'; -import { useJob, useJobDownloads, useJobVttContent, useRetryJob, useReturnToQC, useUpdateJob } from '../../hooks/useJob'; +import { useCancelJob, useJob, useJobDownloads, useJobVttContent, useRetryJob, useReturnToQC, useUpdateJob } from '../../hooks/useJob'; import { useAuthStore } from '../../lib/auth'; import type { JobFailure } from '../../types/api'; import { StatusBadge } from '../../components/StatusBadge'; @@ -15,6 +15,10 @@ const RETURN_TO_QC_ELIGIBLE_STATUSES = [ 'tts_failed', 'render_failed', 'approved_english', 'approved_source', ]; +const CANCELLABLE_STATUSES = new Set([ + 'ingesting', 'ai_processing', 'translating', 'tts_generating', 'rendering_video', 'rendering_qc', +]); + const FAILURE_STEP_TO_PIPELINE: Record = { ingestion: 'ingesting', @@ -44,6 +48,7 @@ const ProgressIndicator = ({ status, failure }: { status: string; failure?: JobF : status; const currentIndex = steps.indexOf(normalizedStatus); const isRejected = status === 'rejected'; + const isCancelled = status === 'cancelled'; const isFailed = status === 'tts_failed' || status === 'render_failed' || status === 'processing_failed'; // For failed states, find which step they failed at @@ -65,6 +70,17 @@ const ProgressIndicator = ({ status, failure }: { status: string; failure?: JobF ); } + if (isCancelled) { + return ( +
+

Job Cancelled

+

+ This job was stopped before completion. You can retry it from the failed state. +

+
+ ); + } + if (isFailed) { const failedStepLabel = failedAtStep?.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); return ( @@ -134,10 +150,12 @@ export function JobDetail() { // Get connection status from global WebSocket const { connectionStatus } = useGlobalWebSocket(); - // Retry mutations + // Retry / cancel mutations const retryJobMutation = useRetryJob(); + const cancelJobMutation = useCancelJob(); const updateJobMutation = useUpdateJob(); const toast = useToastContext(); + const [showCancelConfirm, setShowCancelConfirm] = useState(false); const [editingTitle, setEditingTitle] = useState(false); const [titleDraft, setTitleDraft] = useState(''); @@ -189,6 +207,18 @@ export function JobDetail() { }; const isFailedStatus = job?.status === 'tts_failed' || job?.status === 'render_failed' || job?.status === 'processing_failed'; + const canCancel = user && ['admin', 'production'].includes(user.role || '') && job?.status && CANCELLABLE_STATUSES.has(job.status); + + const handleCancelJob = async () => { + if (!id) return; + try { + await cancelJobMutation.mutateAsync({ id }); + toast.toastOnly.success('Job cancelled'); + setShowCancelConfirm(false); + } catch { + toast.toastOnly.error('Failed to cancel job'); + } + }; // Get video URL from downloads @@ -631,6 +661,43 @@ export function JobDetail() {
+ {/* Stop Process */} + {canCancel && ( +
+ {!showCancelConfirm ? ( + + ) : ( +
+

Stop this job?

+

The pipeline will be interrupted. You can retry it later.

+
+ + +
+
+ )} +
+ )} + {/* Error Display */} {job.error && (