Spaces:
Sleeping
Sleeping
| import { config } from '../config'; | |
| import { logger } from '../utils/logger'; | |
| import { sleep } from '../utils/retry'; | |
| import { createContext } from './browser-pool'; | |
| import { getAccountPassword } from '../crypto/password'; | |
| import { supabase } from '../db/client'; | |
| // DB imports (will be created by subagent) | |
| import { claimPendingJob, completeJobIfActive, updateJobStatus, updateJobFields, incrementJobAttempt, getJobById, TurnitinJob } from '../db/jobs'; | |
| import { | |
| claimAvailableAccount, | |
| claimSpecificAccountForResume, | |
| countAvailableAccounts, | |
| getAccountPoolState, | |
| getTurnitinAccountById, | |
| releaseAccount, | |
| updateAccountQuota, | |
| type TurnitinAccount, | |
| } from '../db/accounts'; | |
| import { insertJobEvent } from '../db/events'; | |
| import { downloadInputFile, uploadReceiptPdf, uploadReportPdf, downloadStorageState, uploadStorageState } from '../db/storage'; | |
| import { cancelJob, refundFailedJob } from '../db/tickets'; | |
| import { MODERN_ONE_POOL_KEY, runTurnitinJob, RunTurnitinJobInput, RunTurnitinJobResult } from '../engine/turnitin'; | |
| import * as path from 'path'; | |
| import * as fs from 'fs'; | |
| import * as os from 'os'; | |
| let activeWorkers = 0; | |
| let running = false; | |
| const ACCOUNT_WAIT_POLL_MS = Number(process.env.ACCOUNT_WAIT_POLL_MS || 15000); | |
| const ACCOUNT_WAIT_MAX_MS = Number(process.env.ACCOUNT_WAIT_MAX_MS || 30 * 60 * 1000); | |
| const RESUME_PROTECTED_STEPS = ['submitted', 'similarity', 'viewer', 'filters', 'download', 'receipt']; | |
| const RESUME_ACCOUNT_RETRY_DELAY_MS = Number(process.env.RESUME_ACCOUNT_RETRY_DELAY_MS || 30000); | |
| const DEFAULT_ACCOUNT_POOL_KEY = 'modern_lti'; | |
| const LEGACY_ACCOUNT_POOL_KEY = 'legacy_carta'; | |
| const LEGACY_ACCOUNT_QUOTA_LIMIT = 4; | |
| function accountQuotaLimitForPool(poolKey: string, account: TurnitinAccount): number { | |
| if (poolKey === MODERN_ONE_POOL_KEY) return 1; | |
| if (poolKey === LEGACY_ACCOUNT_POOL_KEY) return LEGACY_ACCOUNT_QUOTA_LIMIT; | |
| return account.turnitin_quota_limit || 3; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // In-memory sets to prevent double processing and double refund. | |
| // These are per-process so each HF Space worker keeps its own bookkeeping. | |
| // --------------------------------------------------------------------------- | |
| /** Jobs currently being processed by this worker process. */ | |
| const processingJobs = new Set<string>(); | |
| /** | |
| * Jobs that have already been refunded by this worker process. | |
| * NEW-BUG-4 FIX: Entries are auto-deleted after 1 hour to prevent unbounded growth. | |
| */ | |
| const refundedJobs = new Map<string, NodeJS.Timeout>(); | |
| function markAsRefunded(jobId: string): void { | |
| // Clear existing timer if re-refunding (shouldn't happen, but be safe) | |
| const existing = refundedJobs.get(jobId); | |
| if (existing) clearTimeout(existing); | |
| // Auto-remove after 1 hour | |
| const timer = setTimeout(() => refundedJobs.delete(jobId), 60 * 60 * 1000); | |
| // Prevent timer from keeping the process alive during shutdown | |
| if (timer.unref) timer.unref(); | |
| refundedJobs.set(jobId, timer); | |
| } | |
| function wasRefunded(jobId: string): boolean { | |
| return refundedJobs.has(jobId); | |
| } | |
| async function refundFailedTicketOnce( | |
| jobId: string, | |
| reason: string, | |
| jobLog: ReturnType<typeof logger.child>, | |
| ): Promise<boolean> { | |
| if (wasRefunded(jobId)) { | |
| jobLog.info('Refund already attempted by this worker process', { jobId }); | |
| return false; | |
| } | |
| try { | |
| const refunded = await refundFailedJob(jobId, reason); | |
| markAsRefunded(jobId); | |
| jobLog.info('Failed job refund checked', { jobId, refunded }); | |
| return refunded; | |
| } catch (error) { | |
| jobLog.warn('Failed job refund RPC failed', { | |
| jobId, | |
| error: error instanceof Error ? compactWorkerMessage(error.message) : String(error), | |
| }); | |
| return false; | |
| } | |
| } | |
| type AssignmentTargetConfig = RunTurnitinJobInput['assignmentTarget']; | |
| function normalizeUiVariant(value: unknown): AssignmentTargetConfig['uiVariant'] { | |
| return value === 'legacy_carta' ? 'legacy_carta' : 'modern_lti'; | |
| } | |
| function defaultAssignmentTarget(): AssignmentTargetConfig { | |
| return { | |
| targetUrl: config.turnitinTargetUrl, | |
| classTitle: config.turnitinClassTitle, | |
| assignmentTitle: config.turnitinAssignmentTitle as string | null, | |
| assignmentLaunchUrl: null, | |
| uiVariant: 'modern_lti', | |
| accountPoolKey: DEFAULT_ACCOUNT_POOL_KEY, | |
| }; | |
| } | |
| async function loadAssignmentTarget( | |
| assignmentTargetId: string, | |
| jobLog: ReturnType<typeof logger.child>, | |
| ): Promise<AssignmentTargetConfig> { | |
| const assignmentTarget = defaultAssignmentTarget(); | |
| if (!assignmentTargetId) return assignmentTarget; | |
| try { | |
| const { data, error } = await supabase | |
| .from('turnitin_assignment_targets') | |
| .select('*') | |
| .eq('id', assignmentTargetId) | |
| .single(); | |
| if (error) throw error; | |
| if (!data) return assignmentTarget; | |
| const uiVariant = normalizeUiVariant(data.ui_variant); | |
| assignmentTarget.targetUrl = data.target_url || assignmentTarget.targetUrl; | |
| assignmentTarget.classTitle = data.class_title || assignmentTarget.classTitle; | |
| assignmentTarget.assignmentTitle = data.assignment_title || null; | |
| assignmentTarget.assignmentLaunchUrl = data.assignment_launch_url || null; | |
| assignmentTarget.uiVariant = uiVariant; | |
| assignmentTarget.accountPoolKey = | |
| data.account_pool_key || (uiVariant === 'legacy_carta' ? 'legacy_carta' : DEFAULT_ACCOUNT_POOL_KEY); | |
| return assignmentTarget; | |
| } catch (error) { | |
| jobLog.warn('Failed to fetch assignment target, using defaults', { | |
| assignmentTargetId, | |
| error: error instanceof Error ? error.message : String(error), | |
| }); | |
| return assignmentTarget; | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Job queue with backpressure — prevents thundering herd | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * Pending account claim resolvers, FIFO. | |
| * When a job needs an account and none is free, it pushes a resolve callback | |
| * here. When any job finishes and releases an account, we pop the first waiter | |
| * to signal it to retry its claim. | |
| */ | |
| const accountWaiters: Array<() => void> = []; | |
| /** Signal one waiting job that an account may now be available. */ | |
| function notifyNextWaiter(): void { | |
| const next = accountWaiters.shift(); | |
| if (next) next(); | |
| } | |
| /** Wait for a notification that an account was released. */ | |
| function waitForAccountRelease(timeoutMs: number): Promise<'notified' | 'timeout'> { | |
| return new Promise((resolve) => { | |
| const timer = setTimeout(() => { | |
| // Remove ourselves from the queue on timeout | |
| const idx = accountWaiters.indexOf(onNotify); | |
| if (idx >= 0) accountWaiters.splice(idx, 1); | |
| resolve('timeout'); | |
| }, timeoutMs); | |
| if (timer.unref) timer.unref(); | |
| const onNotify = () => { | |
| clearTimeout(timer); | |
| resolve('notified'); | |
| }; | |
| accountWaiters.push(onNotify); | |
| }); | |
| } | |
| /** | |
| * Start the worker polling loop. | |
| * Claims jobs from Supabase and processes them with Playwright. | |
| */ | |
| export async function startWorkerManager(): Promise<void> { | |
| running = true; | |
| logger.info('Worker manager started', { | |
| workerId: config.workerId, | |
| maxWorkers: config.maxWorkers, | |
| }); | |
| // BUG-2 FIX: Stale job recovery is now handled EXCLUSIVELY by stale-recovery.ts | |
| // cron job, which runs every 5 minutes. Removing the duplicate inline loop here | |
| // prevents two Space workers from simultaneously resetting a job that the other | |
| // worker is still actively processing. | |
| while (running) { | |
| try { | |
| if (activeWorkers >= config.maxWorkers) { | |
| await sleep(2000); | |
| continue; | |
| } | |
| // Try to claim a pending job | |
| const job = await claimPendingJob(config.workerId); | |
| if (!job) { | |
| // No jobs available, wait before polling again | |
| const jitter = config.pollIntervalMs + Math.random() * 2000; | |
| await sleep(jitter); | |
| continue; | |
| } | |
| // ── Guard: skip job if already being processed by this worker ── | |
| if (processingJobs.has(job.id)) { | |
| logger.warn('Skipping job already in progress on this worker', { | |
| jobId: job.id, | |
| }); | |
| // Release the claim — set it back to pending so a healthy worker can take it. | |
| await updateJobStatus(job.id, 'pending').catch(() => {}); | |
| await sleep(1000); | |
| continue; | |
| } | |
| logger.info('Claimed job', { jobId: job.id, mode: job.mode, userId: job.user_id }); | |
| // ── BUG-3 FIX: Defensive guard — verify the claimed job actually belongs ── | |
| // to this worker. If the Supabase RPC `claim_turnitin_job` accidentally | |
| // returns a job owned by another worker (e.g. one stuck in 'waiting_account'), | |
| // processing it here would cause double-submit. Re-read the job from DB to | |
| // ensure worker_id matches before proceeding. | |
| const freshJob = await getJobById(job.id); | |
| if (freshJob && freshJob.worker_id && freshJob.worker_id !== config.workerId) { | |
| logger.warn('Claimed job belongs to a different worker; releasing', { | |
| jobId: job.id, | |
| ownWorker: config.workerId, | |
| actualWorker: freshJob.worker_id, | |
| }); | |
| await sleep(1000); | |
| continue; | |
| } | |
| // Mark as in-progress | |
| processingJobs.add(job.id); | |
| // Spawn async task for this job (non-blocking) | |
| activeWorkers++; | |
| processJob(job) | |
| .catch((err) => { | |
| logger.error('Unhandled error in job processing', { | |
| jobId: job.id, | |
| error: err instanceof Error ? err.message : String(err), | |
| }); | |
| }) | |
| .finally(() => { | |
| activeWorkers--; | |
| processingJobs.delete(job.id); | |
| }); | |
| } catch (err) { | |
| logger.error('Worker manager loop error', { | |
| error: err instanceof Error ? err.message : String(err), | |
| }); | |
| await sleep(5000); | |
| } | |
| } | |
| logger.info('Worker manager stopped'); | |
| } | |
| /** | |
| * Stop the worker polling loop gracefully. | |
| */ | |
| export function stopWorkerManager(): void { | |
| running = false; | |
| logger.info('Worker manager stop requested'); | |
| } | |
| /** | |
| * Get current active worker count. | |
| */ | |
| export function getActiveWorkerCount(): number { | |
| return activeWorkers; | |
| } | |
| /** | |
| * Process a single job end-to-end. | |
| */ | |
| async function processJob(job: TurnitinJob): Promise<void> { | |
| const jobId = job.id; | |
| const jobLog = logger.child({ job: jobId }); | |
| let identityId: string | null = null; | |
| let claimedAccount: TurnitinAccount | null = null; | |
| let initialLastCompletedStep: string | undefined; | |
| let currentAttemptCount = (job.attempt_count as number) || 0; | |
| const maxAttempts = (job.max_attempts as number) || 3; | |
| // NEW-BUG-3 FIX: Declare tmpDir outside try so the finally block can always clean up. | |
| const tmpDir = path.join(os.tmpdir(), `turnitin-job-${jobId}`); | |
| const assignmentTargetId = job.assignment_target_id as string; | |
| let assignmentTarget = defaultAssignmentTarget(); | |
| let accountPoolKey = DEFAULT_ACCOUNT_POOL_KEY; | |
| try { | |
| // ── Re-check job status before processing ── | |
| // Another worker may have already picked this job up or cancelled it. | |
| const freshJob = await getJobById(jobId); | |
| if (!freshJob || !['claiming_account', 'pending'].includes(freshJob.status)) { | |
| jobLog.warn('Job no longer claimable; skipping', { | |
| currentStatus: freshJob?.status ?? 'not_found', | |
| }); | |
| return; | |
| } | |
| // Increment attempt count | |
| currentAttemptCount = await incrementJobAttempt(jobId); | |
| // If a previous attempt already submitted the file, keep retrying with | |
| // the same Turnitin account. A different account generally cannot access | |
| // the existing report viewer URL and must not submit the file again. | |
| initialLastCompletedStep = freshJob.last_completed_step || undefined; | |
| const resumeNeedsSameAccount = Boolean( | |
| initialLastCompletedStep && | |
| RESUME_PROTECTED_STEPS.includes(initialLastCompletedStep) && | |
| freshJob.identity_id, | |
| ); | |
| assignmentTarget = await loadAssignmentTarget(assignmentTargetId, jobLog); | |
| accountPoolKey = | |
| assignmentTarget.accountPoolKey || | |
| (assignmentTarget.uiVariant === 'legacy_carta' ? 'legacy_carta' : DEFAULT_ACCOUNT_POOL_KEY); | |
| // Claim an available Turnitin account | |
| await updateJobStatus(jobId, 'claiming_account'); | |
| await emitEvent(jobId, null, 'info', 'claiming_account', 'Looking for available Turnitin account', { | |
| accountPoolKey, | |
| uiVariant: assignmentTarget.uiVariant, | |
| }); | |
| // Before submission, every retry may safely rotate to another account. | |
| // Keeping a pre-submit job attached to an account that has since become | |
| // quota_limited makes it wait forever even when the pool has free accounts. | |
| // Post-submit checkpoints remain pinned to the original account to avoid | |
| // uploading the same file again. | |
| const account = resumeNeedsSameAccount | |
| ? await claimSpecificAccountForResume(freshJob.identity_id as string, config.workerId) | |
| : await claimAccountForJob(job, identityId, jobLog, accountPoolKey); | |
| if (!account) { | |
| if (resumeNeedsSameAccount) { | |
| const previousAccount = await getTurnitinAccountById( | |
| freshJob.identity_id as string, | |
| ).catch(() => null); | |
| if ( | |
| !previousAccount || | |
| ['disabled', 'login_failed'].includes(previousAccount.turnitin_status) | |
| ) { | |
| throw new Error( | |
| 'The Turnitin account that owns the submitted file is no longer available for report recovery.', | |
| ); | |
| } | |
| const message = | |
| 'Previous submission is still locked by its Turnitin account; retrying shortly.'; | |
| await updateJobStatus(jobId, 'waiting_account', { | |
| error_message: message, | |
| attempt_count: Math.max(0, currentAttemptCount - 1), | |
| next_retry_at: new Date(Date.now() + RESUME_ACCOUNT_RETRY_DELAY_MS).toISOString(), | |
| }); | |
| await emitEvent(jobId, freshJob.identity_id, 'warning', 'waiting_account', message); | |
| } | |
| return; | |
| } | |
| claimedAccount = account; | |
| identityId = account.id; | |
| jobLog.info('Account claimed', { accountId: identityId, email: account.email }); | |
| if ( | |
| accountPoolKey === MODERN_ONE_POOL_KEY && | |
| ( | |
| account.turnitin_quota_limit !== 1 || | |
| typeof account.turnitin_quota_remaining !== 'number' || | |
| account.turnitin_quota_remaining > 1 | |
| ) | |
| ) { | |
| await updateAccountQuota(identityId, { | |
| turnitin_quota_limit: 1, | |
| turnitin_quota_remaining: | |
| typeof account.turnitin_quota_remaining === 'number' | |
| ? Math.min(1, account.turnitin_quota_remaining) | |
| : 1, | |
| }).catch(() => {}); | |
| account.turnitin_quota_limit = 1; | |
| account.turnitin_quota_remaining = | |
| typeof account.turnitin_quota_remaining === 'number' | |
| ? Math.min(1, account.turnitin_quota_remaining) | |
| : 1; | |
| } | |
| if ( | |
| accountPoolKey === LEGACY_ACCOUNT_POOL_KEY && | |
| ( | |
| account.turnitin_quota_limit !== LEGACY_ACCOUNT_QUOTA_LIMIT || | |
| typeof account.turnitin_quota_remaining !== 'number' || | |
| account.turnitin_quota_remaining > LEGACY_ACCOUNT_QUOTA_LIMIT | |
| ) | |
| ) { | |
| await updateAccountQuota(identityId, { | |
| turnitin_quota_limit: LEGACY_ACCOUNT_QUOTA_LIMIT, | |
| turnitin_quota_remaining: | |
| typeof account.turnitin_quota_remaining === 'number' | |
| ? Math.min(LEGACY_ACCOUNT_QUOTA_LIMIT, account.turnitin_quota_remaining) | |
| : LEGACY_ACCOUNT_QUOTA_LIMIT, | |
| }).catch(() => {}); | |
| account.turnitin_quota_limit = LEGACY_ACCOUNT_QUOTA_LIMIT; | |
| account.turnitin_quota_remaining = | |
| typeof account.turnitin_quota_remaining === 'number' | |
| ? Math.min(LEGACY_ACCOUNT_QUOTA_LIMIT, account.turnitin_quota_remaining) | |
| : LEGACY_ACCOUNT_QUOTA_LIMIT; | |
| } | |
| // Update job with account reference | |
| await updateJobStatus(jobId, 'running', { | |
| identity_id: identityId, | |
| started_at: new Date().toISOString(), | |
| }); | |
| await emitEvent(jobId, identityId, 'info', 'running', `Starting job with account ${account.email}`); | |
| // Create temp directory for this job | |
| fs.mkdirSync(tmpDir, { recursive: true }); | |
| // Download input file from Supabase Storage, except for quota_check jobs. | |
| const localInputPath = job.mode === 'quota_check' | |
| ? '' | |
| : path.join(tmpDir, job.input_file_name as string); | |
| if (job.mode !== 'quota_check') { | |
| await downloadInputFile(job.input_file_path as string, localInputPath); | |
| await emitEvent(jobId, identityId, 'info', 'file_downloaded', 'Input file downloaded'); | |
| } | |
| // Get storage state for session reuse | |
| let storageStatePath: string | undefined; | |
| if (account.turnitin_session_storage_path) { | |
| const storageState = await downloadStorageState(account.turnitin_session_storage_path); | |
| if (storageState) { | |
| storageStatePath = path.join(tmpDir, 'storageState.json'); | |
| fs.writeFileSync(storageStatePath, storageState, 'utf-8'); | |
| } | |
| } | |
| // Get password | |
| const password = getAccountPassword(); | |
| // Parse filters from job | |
| const filters = (job.filters as Record<string, unknown>) || {}; | |
| // ── Determine resume point ── | |
| // If a previous attempt already uploaded the file, we must NOT upload | |
| // again. The engine's `resumeAfterStep` tells it to skip earlier steps. | |
| const lastCompletedStep = initialLastCompletedStep; | |
| const effectiveMode = | |
| lastCompletedStep && ['submitted', 'similarity', 'viewer', 'filters', 'download', 'receipt'].includes(lastCompletedStep) | |
| ? 'resubmit' as const // force resubmit because file is already there | |
| : (job.mode as 'upload' | 'resubmit' | 'quota_check'); | |
| if (lastCompletedStep) { | |
| jobLog.info('Resuming job from previous step', { | |
| lastCompletedStep, | |
| effectiveMode, | |
| }); | |
| } | |
| // Prepare job input | |
| const jobInput: RunTurnitinJobInput = { | |
| account: { | |
| id: identityId, | |
| email: account.email, | |
| password, | |
| quotaLimit: accountQuotaLimitForPool(accountPoolKey, account), | |
| quotaRemaining: | |
| typeof account.turnitin_quota_remaining === 'number' | |
| ? account.turnitin_quota_remaining | |
| : null, | |
| }, | |
| assignmentTarget, | |
| inputFilePath: localInputPath, | |
| inputFileName: job.input_file_name as string, | |
| inputFileSize: job.input_file_size ?? undefined, | |
| outputDir: tmpDir, | |
| mode: effectiveMode, | |
| filters: { | |
| excludeBibliography: filters.excludeBibliography as boolean | undefined, | |
| excludeQuotes: filters.excludeQuotes as boolean | undefined, | |
| excludeCitations: filters.excludeCitations as boolean | undefined, | |
| excludeSmallMatches: filters.excludeSmallMatches as boolean | undefined, | |
| smallMatchMode: filters.smallMatchMode as 'words' | 'percent' | 'off' | null | undefined, | |
| smallMatchThreshold: filters.smallMatchThreshold as number | null | undefined, | |
| }, | |
| storageStatePath, | |
| resumeAfterStep: lastCompletedStep, | |
| resumeViewerUrl: freshJob.viewer_url, | |
| attemptCount: currentAttemptCount, | |
| onEvent: async (event) => { | |
| await emitEvent(jobId, identityId, event.level, event.step, event.message, event.metadata); | |
| await persistJobProgressFromEvent(jobId, event); | |
| }, | |
| }; | |
| // Run the Playwright job | |
| const result = await runTurnitinJob(jobInput); | |
| // Handle quota limit | |
| if (result.quotaLimit) { | |
| jobLog.warn('Account quota limited', { limit: result.quotaLimit }); | |
| await updateAccountQuota(identityId, { | |
| turnitin_status: 'quota_limited', | |
| turnitin_quota_remaining: 0, | |
| turnitin_quota_message: result.quotaLimit.message, | |
| turnitin_quota_detected_at: new Date().toISOString(), | |
| turnitin_next_retry_at: | |
| accountPoolKey === MODERN_ONE_POOL_KEY | |
| ? null | |
| : new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), | |
| }); | |
| await releaseAccount(identityId, 'quota_limited', result.quotaLimit.message); | |
| const availableAccounts = await countAvailableAccounts(accountPoolKey); | |
| // Retry with a different account only when one is immediately available. | |
| if (currentAttemptCount < maxAttempts && availableAccounts > 0) { | |
| await updateJobStatus(jobId, 'pending'); | |
| await safeUpdateLastCompletedStep(jobId, result.lastCompletedStep); | |
| await emitEvent(jobId, identityId, 'warning', 'quota_limited', 'Account quota limited, retrying with different account'); | |
| } else { | |
| await updateJobStatus(jobId, 'failed', { | |
| error_message: result.quotaLimit.message, | |
| finished_at: new Date().toISOString(), | |
| }); | |
| const refunded = await refundFailedTicketOnce( | |
| jobId, | |
| result.quotaLimit.message, | |
| jobLog, | |
| ); | |
| await safeUpdateLastCompletedStep(jobId, result.lastCompletedStep); | |
| await emitEvent(jobId, identityId, 'error', 'quota_limited', result.quotaLimit.message, { | |
| refunded, | |
| }); | |
| } | |
| return; | |
| } | |
| // Upload PDF report if available | |
| let outputPdfPath: string | undefined; | |
| let outputPdfExpiresAt: string | undefined; | |
| if (result.outputPdfPath && fs.existsSync(result.outputPdfPath)) { | |
| const uploadResult = await uploadReportPdf( | |
| job.user_id as string, | |
| jobId, | |
| result.outputPdfPath | |
| ); | |
| outputPdfPath = uploadResult.storagePath; | |
| outputPdfExpiresAt = uploadResult.expiresAt; | |
| await emitEvent(jobId, identityId, 'info', 'pdf_uploaded', 'PDF report uploaded to storage'); | |
| } | |
| let receiptPdfPath: string | undefined; | |
| let receiptPdfExpiresAt: string | undefined; | |
| if (result.receiptPdfPath && fs.existsSync(result.receiptPdfPath)) { | |
| const uploadResult = await uploadReceiptPdf( | |
| job.user_id as string, | |
| jobId, | |
| result.receiptPdfPath, | |
| ); | |
| receiptPdfPath = uploadResult.storagePath; | |
| receiptPdfExpiresAt = uploadResult.expiresAt; | |
| await emitEvent( | |
| jobId, | |
| identityId, | |
| 'info', | |
| 'receipt_uploaded', | |
| 'Digital Receipt uploaded to storage', | |
| ); | |
| } | |
| // Save storage state for session reuse | |
| try { | |
| // The engine should have saved the storage state; we read and upload it | |
| const stateFile = path.join(tmpDir, 'storageState.json'); | |
| if (fs.existsSync(stateFile)) { | |
| const stateJson = fs.readFileSync(stateFile, 'utf-8'); | |
| const storagePath = await uploadStorageState(identityId, stateJson); | |
| await updateAccountQuota(identityId, { | |
| turnitin_session_storage_path: storagePath, | |
| }); | |
| } | |
| } catch { | |
| jobLog.warn('Failed to save storage state'); | |
| } | |
| // An administrator may fail/cancel a job while Playwright is already in | |
| // the viewer. Persist completion only if the job is still active so an | |
| // in-flight callback cannot revive a terminal job. | |
| const completionPersisted = await completeJobIfActive(jobId, { | |
| viewer_url: result.viewerUrl, | |
| similarity_percent: result.similarityPercent, | |
| output_pdf_path: outputPdfPath, | |
| output_pdf_expires_at: outputPdfExpiresAt, | |
| receipt_pdf_path: receiptPdfPath, | |
| receipt_pdf_expires_at: receiptPdfExpiresAt, | |
| error_message: null, | |
| finished_at: new Date().toISOString(), | |
| }); | |
| if (!completionPersisted) { | |
| const terminalArtifacts: Partial<TurnitinJob> = {}; | |
| if (outputPdfPath) terminalArtifacts.output_pdf_path = outputPdfPath; | |
| if (outputPdfExpiresAt) terminalArtifacts.output_pdf_expires_at = outputPdfExpiresAt; | |
| if (receiptPdfPath) terminalArtifacts.receipt_pdf_path = receiptPdfPath; | |
| if (receiptPdfExpiresAt) { | |
| terminalArtifacts.receipt_pdf_expires_at = receiptPdfExpiresAt; | |
| } | |
| if (Object.keys(terminalArtifacts).length > 0) { | |
| await safeUpdateJobFields(jobId, terminalArtifacts); | |
| } | |
| } | |
| if (result.submissionDetails) { | |
| await safeUpdateJobFields(jobId, { | |
| submission_details: result.submissionDetails as Record<string, unknown>, | |
| }); | |
| } | |
| await safeUpdateLastCompletedStep(jobId, result.lastCompletedStep || 'download'); | |
| // Insert submission record | |
| try { | |
| const { supabase } = await import('../db/client'); | |
| await supabase.from('turnitin_submissions').insert({ | |
| job_id: jobId, | |
| identity_id: identityId, | |
| assignment_target_id: assignmentTargetId, | |
| input_file_name: job.input_file_name as string, | |
| input_file_size: job.input_file_size, | |
| input_file_sha256: job.input_file_sha256, | |
| viewer_url: result.viewerUrl, | |
| similarity_percent: result.similarityPercent, | |
| submission_details: result.submissionDetails as Record<string, unknown> | undefined, | |
| filters_applied: job.filters, | |
| pdf_path: outputPdfPath, | |
| pdf_expires_at: outputPdfExpiresAt, | |
| receipt_pdf_path: receiptPdfPath, | |
| receipt_pdf_expires_at: receiptPdfExpiresAt, | |
| submitted_at: result.submittedAt, | |
| }); | |
| } catch (err) { | |
| jobLog.error('Failed to insert submission record', { | |
| error: err instanceof Error ? err.message : String(err), | |
| }); | |
| } | |
| // Release account. A final-submission warning means this successful submit | |
| // likely consumed the last available submission, so keep the account out of | |
| // rotation until the cooldown window passes. | |
| if (result.permanentLimit) { | |
| await updateAccountQuota(identityId, { | |
| turnitin_status: 'quota_limited', | |
| turnitin_quota_limit: accountQuotaLimitForPool(accountPoolKey, account), | |
| turnitin_quota_remaining: 0, | |
| turnitin_quota_message: result.permanentLimit.message, | |
| turnitin_quota_detected_at: new Date().toISOString(), | |
| turnitin_next_retry_at: null, | |
| turnitin_last_success_at: new Date().toISOString(), | |
| }); | |
| await releaseAccount(identityId, 'quota_limited', result.permanentLimit.message); | |
| } else if (result.quotaCooldown) { | |
| await updateAccountQuota(identityId, { | |
| turnitin_status: 'cooling_down', | |
| turnitin_quota_remaining: null, | |
| turnitin_quota_message: result.quotaCooldown.message, | |
| turnitin_quota_detected_at: new Date().toISOString(), | |
| turnitin_next_retry_at: result.quotaCooldown.nextRetryAt, | |
| turnitin_last_success_at: new Date().toISOString(), | |
| }); | |
| await releaseAccount(identityId, 'cooling_down', result.quotaCooldown.message); | |
| } else if (result.quotaWarning) { | |
| await updateAccountQuota(identityId, { | |
| turnitin_status: 'quota_limited', | |
| turnitin_quota_remaining: 0, | |
| turnitin_quota_message: result.quotaWarning, | |
| turnitin_quota_detected_at: new Date().toISOString(), | |
| turnitin_next_retry_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), | |
| }); | |
| await releaseAccount(identityId, 'quota_limited', result.quotaWarning); | |
| } else { | |
| const remainingFromSubmissionCount = | |
| typeof result.submissionCount === 'number' | |
| ? Math.max(0, accountQuotaLimitForPool(accountPoolKey, account) - result.submissionCount) | |
| : null; | |
| const currentRemaining = | |
| typeof account.turnitin_quota_remaining === 'number' | |
| ? account.turnitin_quota_remaining | |
| : accountQuotaLimitForPool(accountPoolKey, account); | |
| const nextRemaining = | |
| remainingFromSubmissionCount ?? Math.max(0, currentRemaining - 1); | |
| if (assignmentTarget.uiVariant === LEGACY_ACCOUNT_POOL_KEY && nextRemaining <= 0) { | |
| const cooldownMessage = | |
| 'Legacy Turnitin account reached 4 submissions. Account is permanently limited because the class should be dropped.'; | |
| await updateAccountQuota(identityId, { | |
| turnitin_status: 'quota_limited', | |
| turnitin_quota_remaining: 0, | |
| turnitin_quota_message: cooldownMessage, | |
| turnitin_quota_detected_at: new Date().toISOString(), | |
| turnitin_next_retry_at: null, | |
| turnitin_last_success_at: new Date().toISOString(), | |
| }); | |
| await releaseAccount(identityId, 'quota_limited', cooldownMessage); | |
| } else if (accountPoolKey === MODERN_ONE_POOL_KEY && nextRemaining <= 0) { | |
| const oneUseMessage = | |
| 'Modern one-use account consumed its single allowed submission. Account is permanently limited.'; | |
| await updateAccountQuota(identityId, { | |
| turnitin_status: 'quota_limited', | |
| turnitin_quota_limit: 1, | |
| turnitin_quota_remaining: 0, | |
| turnitin_quota_message: oneUseMessage, | |
| turnitin_quota_detected_at: new Date().toISOString(), | |
| turnitin_next_retry_at: null, | |
| turnitin_last_success_at: new Date().toISOString(), | |
| }); | |
| await releaseAccount(identityId, 'quota_limited', oneUseMessage); | |
| } else { | |
| await updateAccountQuota(identityId, { | |
| turnitin_status: 'available', | |
| turnitin_quota_remaining: nextRemaining, | |
| turnitin_quota_message: null, | |
| turnitin_next_retry_at: null, | |
| turnitin_last_success_at: new Date().toISOString(), | |
| }); | |
| await releaseAccount(identityId, 'available'); | |
| } | |
| } | |
| if (completionPersisted) { | |
| await emitEvent(jobId, identityId, 'info', 'completed', `Job completed. Similarity: ${result.similarityPercent ?? 'N/A'}%`); | |
| jobLog.info('Job completed successfully', { | |
| similarity: result.similarityPercent, | |
| viewerUrl: result.viewerUrl, | |
| }); | |
| } else { | |
| await emitEvent( | |
| jobId, | |
| identityId, | |
| 'warning', | |
| 'terminal_status_preserved', | |
| 'Worker cleanup finished after the job was stopped; terminal status preserved.', | |
| ); | |
| jobLog.warn('Worker finished after job entered a terminal state; completion was not persisted'); | |
| } | |
| // Cleanup temp directory (moved to finally, see below) | |
| } catch (err) { | |
| const errorMessage = err instanceof Error ? err.message : String(err); | |
| const publicErrorMessage = compactWorkerMessage(errorMessage); | |
| logger.error('Job failed', { jobId, error: publicErrorMessage }); | |
| // Try to extract lastCompletedStep from the engine result (it's set | |
| // on the result object even before throwing because the steps record | |
| // progress incrementally). We also check the error object itself in | |
| // case the engine attached the step there. | |
| const failedResultStep: string | undefined = | |
| (err as any)?.lastCompletedStep || | |
| undefined; | |
| const failedViewerUrl: string | undefined = (err as any)?.viewerUrl || undefined; | |
| const failedSimilarityPercent: number | undefined = | |
| typeof (err as any)?.similarityPercent === 'number' | |
| ? (err as any).similarityPercent | |
| : undefined; | |
| const isQuotaLimit = | |
| err instanceof Error && | |
| (err.name === 'SubmissionQuotaLimitError' || | |
| /reached your limit|submission quota limit/i.test(errorMessage)); | |
| const isTargetUnavailable = | |
| /waiting for locator\('td\.class_name a|waiting for locator\('tr\.assignment-row|Class title|class.*not found|assignment.*not found|Summer Reading 2026/i.test( | |
| errorMessage, | |
| ); | |
| // Determine whether the file was already uploaded on THIS attempt. | |
| // If so, subsequent retries MUST NOT re-upload. | |
| // We detect this from the job status transitions that went through | |
| // the emitEvent calls above (e.g. the 'submitted' event). | |
| let stepToSave: string | null = failedResultStep || null; | |
| if (!stepToSave) { | |
| // Fallback: if the error happened after similarity/viewer/download | |
| // the job status will have been updated through updateJobStatus. | |
| const latestJob = await getJobById(jobId).catch(() => null); | |
| stepToSave = latestJob?.last_completed_step || null; | |
| } | |
| const failureProgress: Partial<TurnitinJob> = {}; | |
| if (stepToSave) failureProgress.last_completed_step = stepToSave; | |
| if (failedViewerUrl) failureProgress.viewer_url = failedViewerUrl; | |
| if (typeof failedSimilarityPercent === 'number') { | |
| failureProgress.similarity_percent = failedSimilarityPercent; | |
| } | |
| if (Object.keys(failureProgress).length > 0) { | |
| await safeUpdateJobFields(jobId, failureProgress); | |
| } | |
| const isPostSubmitResume = Boolean( | |
| stepToSave && RESUME_PROTECTED_STEPS.includes(stepToSave), | |
| ); | |
| const availableAccounts = | |
| isQuotaLimit || isTargetUnavailable | |
| ? await countAvailableAccounts(accountPoolKey).catch(() => 0) | |
| : 1; | |
| const shouldRetry = | |
| currentAttemptCount < maxAttempts && | |
| (!(isQuotaLimit || isTargetUnavailable) || availableAccounts > 0); | |
| const submissionConsumedThisAttempt = Boolean( | |
| isPostSubmitResume && !initialLastCompletedStep, | |
| ); | |
| const knownRemaining = | |
| typeof claimedAccount?.turnitin_quota_remaining === 'number' | |
| ? claimedAccount.turnitin_quota_remaining | |
| : null; | |
| const remainingAfterConsumedSubmit = | |
| knownRemaining === null ? null : Math.max(0, knownRemaining - 1); | |
| const remainingAfterJob = submissionConsumedThisAttempt | |
| ? remainingAfterConsumedSubmit | |
| : knownRemaining; | |
| const legacyNeedsClassCleanup = Boolean( | |
| accountPoolKey === LEGACY_ACCOUNT_POOL_KEY && | |
| isPostSubmitResume && | |
| !shouldRetry && | |
| remainingAfterJob === 0, | |
| ); | |
| // Release account if claimed | |
| if (identityId) { | |
| const isLoginError = errorMessage.toLowerCase().includes('login'); | |
| try { | |
| if (isQuotaLimit) { | |
| await updateAccountQuota(identityId, { | |
| turnitin_status: 'quota_limited', | |
| turnitin_quota_remaining: 0, | |
| turnitin_quota_message: errorMessage, | |
| turnitin_quota_detected_at: new Date().toISOString(), | |
| turnitin_next_retry_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), | |
| turnitin_last_error: errorMessage, | |
| }); | |
| await releaseAccount(identityId, 'quota_limited', errorMessage); | |
| } else if (isTargetUnavailable) { | |
| await updateAccountQuota(identityId, { | |
| turnitin_status: 'quota_limited', | |
| turnitin_quota_remaining: 0, | |
| turnitin_quota_message: | |
| 'Target class or assignment is not available. This account is treated as limit because the class may have been dropped.', | |
| turnitin_quota_detected_at: new Date().toISOString(), | |
| turnitin_next_retry_at: null, | |
| turnitin_last_error: errorMessage, | |
| }); | |
| await releaseAccount(identityId, 'quota_limited', errorMessage); | |
| } else if (isLoginError) { | |
| const loginLimitMessage = | |
| 'Turnitin login failed. Account removed from rotation because the credential is invalid or the account no longer exists.'; | |
| await updateAccountQuota(identityId, { | |
| turnitin_status: 'quota_limited', | |
| turnitin_quota_remaining: 0, | |
| turnitin_quota_message: loginLimitMessage, | |
| turnitin_quota_detected_at: new Date().toISOString(), | |
| turnitin_next_retry_at: null, | |
| turnitin_last_error: errorMessage, | |
| }).catch(() => {}); | |
| await releaseAccount(identityId, 'quota_limited', errorMessage); | |
| } else { | |
| let nextStatus = 'available'; | |
| if (isPostSubmitResume) { | |
| if (shouldRetry) { | |
| nextStatus = 'cooling_down'; | |
| } else if (legacyNeedsClassCleanup) { | |
| nextStatus = 'cooling_down'; | |
| } else if (remainingAfterJob === 0) { | |
| nextStatus = 'quota_limited'; | |
| } else if (remainingAfterJob === null) { | |
| nextStatus = 'cooling_down'; | |
| } | |
| } | |
| if (submissionConsumedThisAttempt) { | |
| await updateAccountQuota(identityId, { | |
| turnitin_status: nextStatus, | |
| turnitin_quota_remaining: remainingAfterConsumedSubmit, | |
| turnitin_quota_message: | |
| legacyNeedsClassCleanup | |
| ? 'Legacy final submission was consumed, but report processing failed. Class cleanup is pending.' | |
| : remainingAfterConsumedSubmit === null | |
| ? 'Quota needs refresh after a failed post-submit attempt.' | |
| : null, | |
| turnitin_next_retry_at: | |
| legacyNeedsClassCleanup | |
| ? new Date().toISOString() | |
| : nextStatus === 'cooling_down' || nextStatus === 'quota_limited' | |
| ? new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() | |
| : null, | |
| turnitin_last_error: errorMessage, | |
| }).catch(() => {}); | |
| } else if (nextStatus === 'cooling_down') { | |
| await updateAccountQuota(identityId, { | |
| turnitin_status: 'cooling_down', | |
| turnitin_next_retry_at: legacyNeedsClassCleanup | |
| ? new Date().toISOString() | |
| : claimedAccount?.turnitin_next_retry_at, | |
| turnitin_last_error: errorMessage, | |
| turnitin_quota_message: | |
| legacyNeedsClassCleanup | |
| ? 'Legacy final submission was consumed, but report processing failed. Class cleanup is pending.' | |
| : 'Reserved for retry after submitted file reached report viewer.', | |
| }).catch(() => {}); | |
| } else if (nextStatus === 'quota_limited') { | |
| await updateAccountQuota(identityId, { | |
| turnitin_status: 'quota_limited', | |
| turnitin_quota_remaining: 0, | |
| turnitin_quota_message: | |
| 'Submitted file consumed the remaining quota before report processing failed.', | |
| turnitin_next_retry_at: | |
| accountPoolKey === MODERN_ONE_POOL_KEY | |
| ? null | |
| : new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), | |
| turnitin_last_error: errorMessage, | |
| }).catch(() => {}); | |
| } | |
| await releaseAccount(identityId, nextStatus, errorMessage); | |
| } | |
| } catch { | |
| // Ignore release errors | |
| } | |
| } | |
| // A manual failure/cancellation can happen while Playwright is still | |
| // unwinding. Preserve that terminal decision after account cleanup. | |
| const latestJob = await getJobById(jobId).catch(() => null); | |
| if (latestJob && ['failed', 'cancelled'].includes(latestJob.status)) { | |
| jobLog.info('Job already terminal after worker failure; retry status not changed', { | |
| status: latestJob.status, | |
| }); | |
| return; | |
| } | |
| // Check if we should retry | |
| if (shouldRetry) { | |
| await updateJobStatus(jobId, 'pending', { | |
| error_message: publicErrorMessage, | |
| }); | |
| await safeUpdateLastCompletedStep(jobId, stepToSave); | |
| await emitEvent(jobId, identityId, 'error', 'failed_retry', `Attempt failed, will retry: ${publicErrorMessage}`); | |
| } else { | |
| await updateJobStatus(jobId, 'failed', { | |
| error_message: publicErrorMessage, | |
| finished_at: new Date().toISOString(), | |
| }); | |
| const refunded = await refundFailedTicketOnce(jobId, publicErrorMessage, jobLog); | |
| await safeUpdateLastCompletedStep(jobId, stepToSave); | |
| await emitEvent( | |
| jobId, | |
| identityId, | |
| 'error', | |
| 'failed_final', | |
| refunded | |
| ? `Job failed after ${currentAttemptCount} attempt(s): ${publicErrorMessage}. Ticket refunded.` | |
| : `Job failed after ${currentAttemptCount} attempt(s): ${publicErrorMessage}`, | |
| { refunded }, | |
| ); | |
| } | |
| } finally { | |
| // NEW-BUG-3 FIX: Always clean up temp dir regardless of success or error | |
| try { | |
| if (fs.existsSync(tmpDir)) { | |
| fs.rmSync(tmpDir, { recursive: true, force: true }); | |
| } | |
| } catch { | |
| // Ignore cleanup errors | |
| } | |
| // Notify the next waiting job that an account may have been released. | |
| // This is part of the backpressure system — instead of all waiters | |
| // polling simultaneously (thundering herd), they wait in a FIFO queue. | |
| notifyNextWaiter(); | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Account claiming with bounded wait & single-refund guarantee | |
| // --------------------------------------------------------------------------- | |
| async function claimAccountForJob( | |
| job: TurnitinJob, | |
| identityId: string | null, | |
| jobLog: ReturnType<typeof logger.child>, | |
| accountPoolKey: string, | |
| ): Promise<TurnitinAccount | null> { | |
| const startedAt = Date.now(); | |
| let lastWaitingEventAt = 0; | |
| while (true) { | |
| // ── Re-check that the job is still ours ── | |
| // CRITICAL: also check worker_id to catch the case where another Space | |
| // worker already picked this job while we were sleeping. | |
| const freshJob = await getJobById(job.id).catch(() => null); | |
| if (!freshJob) { | |
| jobLog.warn('Job disappeared while waiting for account', { jobId: job.id }); | |
| return null; | |
| } | |
| if (['cancelled', 'completed', 'failed'].includes(freshJob.status)) { | |
| jobLog.info('Job moved to terminal state while waiting for account; stopping', { | |
| jobId: job.id, | |
| status: freshJob.status, | |
| }); | |
| return null; | |
| } | |
| // If another worker took ownership of this job, stop immediately. | |
| if (freshJob.worker_id && freshJob.worker_id !== config.workerId) { | |
| jobLog.warn('Job was taken by another worker while waiting for account; stopping', { | |
| jobId: job.id, | |
| ownedBy: freshJob.worker_id, | |
| thisWorker: config.workerId, | |
| }); | |
| return null; | |
| } | |
| const account = await claimAvailableAccount(config.workerId, accountPoolKey); | |
| if (account) { | |
| // Double-check ownership one final time to prevent double-processing | |
| // that can happen when both workers exit the wait loop simultaneously. | |
| const jobAfterClaim = await getJobById(job.id).catch(() => null); | |
| if ( | |
| !jobAfterClaim || | |
| ['cancelled', 'completed', 'failed'].includes(jobAfterClaim.status) || | |
| (jobAfterClaim.worker_id && jobAfterClaim.worker_id !== config.workerId) | |
| ) { | |
| jobLog.warn('Job ownership lost right after claiming account; releasing account and stopping', { | |
| jobId: job.id, | |
| status: jobAfterClaim?.status, | |
| ownedBy: jobAfterClaim?.worker_id, | |
| }); | |
| // Release the just-claimed account back to the pool | |
| await releaseAccount(account.id, 'available').catch(() => {}); | |
| return null; | |
| } | |
| return account; | |
| } | |
| const poolState = await getAccountPoolState(accountPoolKey); | |
| // ── Wait only if there are running accounts AND we haven't timed out ── | |
| if (poolState.running > 0 && Date.now() - startedAt < ACCOUNT_WAIT_MAX_MS) { | |
| const message = `No free account right now. Waiting for ${poolState.running} running account(s) to finish.`; | |
| // Keep status as waiting_account — do NOT reset to claiming_account here. | |
| // Resetting to claiming_account would allow another worker's claim_turnitin_job | |
| // RPC to pick up this job, causing double processing. | |
| await updateJobStatus(job.id, 'waiting_account', { | |
| error_message: message, | |
| }); | |
| if (Date.now() - lastWaitingEventAt > 60000) { | |
| lastWaitingEventAt = Date.now(); | |
| await emitEvent(job.id, identityId, 'warning', 'waiting_account', message, { | |
| poolState, | |
| }); | |
| } | |
| // BACKPRESSURE: Instead of fixed-interval polling (sleep 15s), wait | |
| // in the FIFO queue for a notification from a finishing job. Fall back | |
| // to the old sleep interval if no notification arrives. | |
| await waitForAccountRelease(ACCOUNT_WAIT_POLL_MS); | |
| // Do NOT reset to claiming_account — keep as waiting_account | |
| continue; | |
| } | |
| // ── No accounts available and nothing running (or timed out) ── | |
| // Cancel the job and refund the ticket once. The database RPC is | |
| // idempotent, while the local marker only prevents duplicate attempts | |
| // after a successful cancellation/refund. | |
| if (wasRefunded(job.id)) { | |
| jobLog.warn('Refund already issued for this job; skipping duplicate refund', { | |
| jobId: job.id, | |
| }); | |
| // Set job to failed terminal state so neither worker picks it up again. | |
| await updateJobStatus(job.id, 'failed', { | |
| error_message: 'All Turnitin accounts are out of quota or cooling down. Ticket already refunded.', | |
| finished_at: new Date().toISOString(), | |
| }); | |
| return null; | |
| } | |
| const cancelMessage = | |
| poolState.total === 0 | |
| ? 'No Turnitin accounts are configured. Ticket refunded.' | |
| : 'All Turnitin accounts are out of quota or cooling down. Ticket refunded.'; | |
| jobLog.warn('No claimable Turnitin accounts; cancelling and refunding ticket', { | |
| poolState, | |
| }); | |
| try { | |
| const cancelled = await cancelJob(job.user_id as string, job.id); | |
| if (cancelled) { | |
| markAsRefunded(job.id); | |
| } | |
| await emitEvent(job.id, identityId, 'error', 'no_account', cancelMessage, { | |
| poolState, | |
| refunded: cancelled, | |
| }); | |
| } catch (error) { | |
| const message = | |
| error instanceof Error ? compactWorkerMessage(error.message) : String(error); | |
| // If the cancel RPC failed because the job is in a wrong state (e.g. | |
| // already cancelled by the other worker), just mark it failed. | |
| jobLog.error('Failed to cancel job', { | |
| userId: job.user_id, | |
| jobId: job.id, | |
| error: message, | |
| }); | |
| await updateJobStatus(job.id, 'failed', { | |
| error_message: `${cancelMessage} Refund note: ${message}`, | |
| finished_at: new Date().toISOString(), | |
| }).catch(() => {}); | |
| const refunded = await refundFailedTicketOnce( | |
| job.id, | |
| `${cancelMessage} Fallback after cancel error: ${message}`, | |
| jobLog, | |
| ); | |
| await emitEvent( | |
| job.id, | |
| identityId, | |
| 'error', | |
| 'no_account', | |
| refunded | |
| ? `${cancelMessage} Refund recovered after cancel error.` | |
| : `${cancelMessage} Refund note: ${message}`, | |
| { poolState, refunded }, | |
| ); | |
| } | |
| return null; | |
| } | |
| } | |
| function compactWorkerMessage(message: string): string { | |
| const firstLine = message.split('\n').map((line) => line.trim()).find(Boolean) || message; | |
| if (/locator\.waitFor: Timeout/i.test(firstLine)) { | |
| const selector = firstLine.match(/locator\('([^']+)'/i)?.[1]; | |
| return selector | |
| ? `Turnitin page element did not appear in time: ${selector}` | |
| : 'Turnitin page element did not appear in time.'; | |
| } | |
| if (/Upload file was selected, but no Upload\/Confirm\/Submit button was found/i.test(firstLine)) { | |
| return 'File was selected, but Turnitin did not show a usable upload confirmation button.'; | |
| } | |
| return firstLine.length > 260 ? `${firstLine.slice(0, 257)}...` : firstLine; | |
| } | |
| /** | |
| * Helper to emit a job event. | |
| */ | |
| async function emitEvent( | |
| jobId: string, | |
| identityId: string | null, | |
| level: 'info' | 'warning' | 'error', | |
| step: string, | |
| message: string, | |
| metadata?: Record<string, unknown> | |
| ): Promise<void> { | |
| try { | |
| await insertJobEvent({ | |
| job_id: jobId, | |
| identity_id: identityId, | |
| level, | |
| step, | |
| message, | |
| metadata: metadata || {}, | |
| }); | |
| } catch (err) { | |
| logger.error('Failed to insert job event', { | |
| jobId, | |
| step, | |
| error: err instanceof Error ? err.message : String(err), | |
| }); | |
| } | |
| } | |
| type EngineEvent = { | |
| level: 'info' | 'warning' | 'error'; | |
| step: string; | |
| message: string; | |
| metadata?: Record<string, unknown>; | |
| }; | |
| async function persistJobProgressFromEvent( | |
| jobId: string, | |
| event: EngineEvent, | |
| ): Promise<void> { | |
| const fields: Partial<TurnitinJob> = {}; | |
| if (event.step === 'submitted' && event.level === 'info') { | |
| fields.last_completed_step = 'submitted'; | |
| } else if ( | |
| event.step === 'submission_details' && | |
| event.level === 'info' && | |
| event.metadata | |
| ) { | |
| fields.submission_details = event.metadata; | |
| } else if ( | |
| event.step === 'similarity' && | |
| event.level === 'info' && | |
| /^Similarity:/i.test(event.message) | |
| ) { | |
| fields.last_completed_step = 'similarity'; | |
| if (typeof event.metadata?.similarityPercent === 'number') { | |
| fields.similarity_percent = event.metadata.similarityPercent; | |
| } | |
| } else if ( | |
| event.step === 'similarity' && | |
| event.level === 'info' && | |
| typeof event.metadata?.similarityPercent === 'number' | |
| ) { | |
| fields.similarity_percent = event.metadata.similarityPercent; | |
| } else if ( | |
| event.step === 'viewer' && | |
| event.level === 'info' && | |
| typeof event.metadata?.viewerUrl === 'string' | |
| ) { | |
| fields.last_completed_step = 'viewer'; | |
| fields.viewer_url = event.metadata.viewerUrl; | |
| } else if ( | |
| event.step === 'filters' && | |
| event.level === 'info' && | |
| /Filter validation passed/i.test(event.message) | |
| ) { | |
| fields.last_completed_step = 'filters'; | |
| } else if ( | |
| event.step === 'download' && | |
| event.level === 'info' && | |
| /PDF downloaded successfully/i.test(event.message) | |
| ) { | |
| fields.last_completed_step = 'download'; | |
| } else if ( | |
| event.step === 'receipt' && | |
| event.level === 'info' && | |
| /Digital Receipt downloaded successfully/i.test(event.message) | |
| ) { | |
| fields.last_completed_step = 'receipt'; | |
| } | |
| if (Object.keys(fields).length > 0) { | |
| await safeUpdateJobFields(jobId, fields); | |
| } | |
| } | |
| async function safeUpdateJobFields( | |
| jobId: string, | |
| fields: Partial<TurnitinJob>, | |
| ): Promise<void> { | |
| try { | |
| await updateJobFields(jobId, fields); | |
| } catch (err) { | |
| logger.warn('safeUpdateJobFields: could not persist job progress', { | |
| jobId, | |
| fields: Object.keys(fields), | |
| error: err instanceof Error ? err.message : String(err), | |
| }); | |
| } | |
| } | |
| /** | |
| * Safely persist the last completed step to the job row. | |
| * Logs a warning on failure (e.g. column not yet migrated or DB unreachable) | |
| * but never throws — step tracking must not crash the main job flow. | |
| */ | |
| async function safeUpdateLastCompletedStep( | |
| jobId: string, | |
| step: string | null | undefined, | |
| ): Promise<void> { | |
| if (!step) return; | |
| try { | |
| const { supabase } = await import('../db/client'); | |
| const { error } = await supabase | |
| .from('turnitin_jobs') | |
| .update({ last_completed_step: step, updated_at: new Date().toISOString() }) | |
| .eq('id', jobId); | |
| if (error) { | |
| logger.warn('safeUpdateLastCompletedStep: could not persist step (column may not exist yet)', { | |
| jobId, | |
| step, | |
| error: error.message, | |
| }); | |
| } | |
| } catch (err) { | |
| logger.warn('safeUpdateLastCompletedStep: unexpected error', { | |
| jobId, | |
| step, | |
| error: err instanceof Error ? err.message : String(err), | |
| }); | |
| } | |
| } | |