diff --git a/frontend/src/hooks/useTaskPolling.test.ts b/frontend/src/hooks/useTaskPolling.test.ts index d12df62..9912610 100644 --- a/frontend/src/hooks/useTaskPolling.test.ts +++ b/frontend/src/hooks/useTaskPolling.test.ts @@ -3,9 +3,13 @@ import { renderHook, act } from '@testing-library/react'; import { useTaskPolling } from './useTaskPolling'; // ── mock the api module ──────────────────────────────────────────────────── -vi.mock('@/services/api', () => ({ - getTaskStatus: vi.fn(), -})); +vi.mock('@/services/api', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getTaskStatus: vi.fn(), + }; +}); import { getTaskStatus } from '@/services/api'; const mockGetStatus = vi.mocked(getTaskStatus); @@ -122,6 +126,33 @@ describe('useTaskPolling', () => { expect(onError).toHaveBeenCalledWith('Ghostscript not found.'); }); + it('prefers a structured task error payload when SUCCESS contains a failed result', async () => { + mockGetStatus.mockResolvedValueOnce({ + task_id: 'task-4b', + state: 'SUCCESS', + error: { + error_code: 'OPENROUTER_MISSING_API_KEY', + user_message: 'AI features are temporarily unavailable. Our team has been notified.', + task_id: 'task-4b', + }, + result: { status: 'failed' }, + }); + + const onError = vi.fn(); + const { result } = renderHook(() => + useTaskPolling({ taskId: 'task-4b', intervalMs: 500, onError }) + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(result.current.isPolling).toBe(false); + expect(result.current.result).toBeNull(); + expect(result.current.error).toBe('AI features are temporarily unavailable. Our team has been notified.'); + expect(onError).toHaveBeenCalledWith('AI features are temporarily unavailable. Our team has been notified.'); + }); + // ── FAILURE state ────────────────────────────────────────────────────── it('stops polling and sets error on FAILURE state', async () => { mockGetStatus.mockResolvedValueOnce({ @@ -144,6 +175,31 @@ describe('useTaskPolling', () => { expect(onError).toHaveBeenCalledWith('Worker crashed.'); }); + it('normalizes a structured FAILURE payload into a safe string message', async () => { + mockGetStatus.mockResolvedValueOnce({ + task_id: 'task-5b', + state: 'FAILURE', + error: { + error_code: 'TASK_FAILURE', + user_message: 'Task processing failed. Please retry.', + task_id: 'task-5b', + }, + }); + + const onError = vi.fn(); + const { result } = renderHook(() => + useTaskPolling({ taskId: 'task-5b', intervalMs: 500, onError }) + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(result.current.isPolling).toBe(false); + expect(result.current.error).toBe('Task processing failed. Please retry.'); + expect(onError).toHaveBeenCalledWith('Task processing failed. Please retry.'); + }); + // ── network error ────────────────────────────────────────────────────── it('stops polling and sets error on network/API exception', async () => { mockGetStatus.mockRejectedValueOnce(new Error('Network error.')); diff --git a/frontend/src/hooks/useTaskPolling.ts b/frontend/src/hooks/useTaskPolling.ts index 3e54562..9319cd5 100644 --- a/frontend/src/hooks/useTaskPolling.ts +++ b/frontend/src/hooks/useTaskPolling.ts @@ -1,7 +1,12 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { toast } from 'sonner'; import i18n from '@/i18n'; -import { getTaskStatus, type TaskStatus, type TaskResult } from '@/services/api'; +import { + getTaskStatus, + getTaskErrorMessage, + type TaskStatus, + type TaskResult, +} from '@/services/api'; import { trackEvent } from '@/services/analytics'; interface UseTaskPollingOptions { @@ -54,6 +59,7 @@ export function useTaskPolling({ if (taskStatus.state === 'SUCCESS') { stopPolling(); const taskResult = taskStatus.result; + const fallbackError = i18n.t('common.errors.processingFailed'); if (taskResult?.status === 'completed') { setResult(taskResult); @@ -63,7 +69,10 @@ export function useTaskPolling({ trackEvent('task_completed', { task_id: taskId }); onComplete?.(taskResult); } else { - const errMsg = taskResult?.error || i18n.t('common.errors.processingFailed'); + const errMsg = getTaskErrorMessage( + taskStatus.error ?? taskResult?.user_message ?? taskResult?.error, + fallbackError + ); setError(errMsg); toast.error(errMsg); trackEvent('task_failed', { task_id: taskId, reason: 'result_failed' }); @@ -71,7 +80,10 @@ export function useTaskPolling({ } } else if (taskStatus.state === 'FAILURE') { stopPolling(); - const errMsg = taskStatus.error || i18n.t('common.errors.processingFailed'); + const errMsg = getTaskErrorMessage( + taskStatus.error, + i18n.t('common.errors.processingFailed') + ); setError(errMsg); toast.error(errMsg); trackEvent('task_failed', { task_id: taskId, reason: 'state_failure' }); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index a2cedb5..eac8bf0 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -187,12 +187,22 @@ export interface TaskResponse { message: string; } +export interface TaskErrorPayload { + error_code?: string; + user_message?: string; + task_id?: string; + trace_id?: string; + message?: string; + error?: string; + detail?: string; +} + export interface TaskStatus { task_id: string; state: 'PENDING' | 'PROCESSING' | 'SUCCESS' | 'FAILURE'; progress?: string; result?: TaskResult; - error?: string; + error?: string | TaskErrorPayload; } export interface TaskResult { @@ -200,6 +210,10 @@ export interface TaskResult { download_url?: string; filename?: string; error?: string; + error_code?: string; + user_message?: string; + task_id?: string; + trace_id?: string; original_size?: number; compressed_size?: number; reduction_percent?: number; @@ -229,6 +243,33 @@ export interface TaskResult { tables_found?: number; } +function isTaskErrorPayload(value: unknown): value is TaskErrorPayload { + return Boolean(value) && typeof value === 'object'; +} + +export function getTaskErrorMessage(error: unknown, fallback: string): string { + if (typeof error === 'string' && error.trim()) { + return error.trim(); + } + + if (isTaskErrorPayload(error)) { + const candidates = [ + error.user_message, + error.message, + error.error, + error.detail, + ]; + + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.trim()) { + return candidate.trim(); + } + } + } + + return fallback; +} + export interface AuthUser { id: number; email: string; diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 431b63c..1926206 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -28,23 +28,55 @@ if [ ! -f ".env" ]; then exit 1 fi -echo -e "${YELLOW}1/5 — Pulling latest code...${NC}" +echo -e "${YELLOW}1/7 — Pulling latest code...${NC}" git pull origin main 2>/dev/null || echo "Not a git repo or no remote, skipping pull." -echo -e "${YELLOW}2/5 — Building Docker images...${NC}" +echo -e "${YELLOW}2/7 — Building Docker images...${NC}" docker compose -f docker-compose.prod.yml build --no-cache -echo -e "${YELLOW}3/5 — Stopping old containers...${NC}" +echo -e "${YELLOW}3/7 — Stopping old containers...${NC}" docker compose -f docker-compose.prod.yml down --remove-orphans -echo -e "${YELLOW}4/5 — Starting services...${NC}" +echo -e "${YELLOW}4/7 — Starting services...${NC}" docker compose -f docker-compose.prod.yml up -d -echo -e "${YELLOW}5/5 — Waiting for health check...${NC}" +if [ "${SKIP_AI_RUNTIME_CHECKS:-0}" != "1" ]; then + echo -e "${YELLOW}5/7 — Verifying AI runtime in backend + worker...${NC}" + for service in backend celery_worker; do + if ! docker compose -f docker-compose.prod.yml exec -T "$service" python - <<'PY' +import importlib.util +import os +import sys + +issues = [] +api_key = os.getenv("OPENROUTER_API_KEY", "").strip() +if not api_key or api_key.startswith("replace-with-"): + issues.append("OPENROUTER_API_KEY is missing or still set to a placeholder") +if importlib.util.find_spec("vtracer") is None: + issues.append("vtracer is not installed in this container") + +if issues: + print("; ".join(issues)) + sys.exit(1) + +print("AI runtime OK") +PY + then + echo -e "${RED}✗ AI runtime check failed in ${service}.${NC}" + echo " Fix the container env/dependencies, then redeploy backend and celery_worker." + echo " Do not redeploy the frontend alone when backend routes, Celery tasks, or AI dependencies changed." + exit 1 + fi + done +else + echo -e "${YELLOW}5/7 — Skipping AI runtime checks (SKIP_AI_RUNTIME_CHECKS=1).${NC}" +fi + +echo -e "${YELLOW}6/7 — Waiting for health check...${NC}" sleep 10 # Health check -if curl -sf http://localhost/health > /dev/null 2>&1; then +if curl -sf http://localhost/api/health > /dev/null 2>&1; then echo -e "${GREEN}✓ Deployment successful! Service is healthy.${NC}" else echo -e "${RED}✗ Health check failed. Check logs:${NC}" @@ -52,6 +84,9 @@ else exit 1 fi +echo -e "${YELLOW}7/7 — Current containers:${NC}" +docker compose -f docker-compose.prod.yml ps + echo "" echo -e "${GREEN}Deployment complete!${NC}" echo " App: http://localhost"