| 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'; |
|
|
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
|
|
| |
| const processingJobs = new Set<string>(); |
|
|
| |
| |
| |
| |
| const refundedJobs = new Map<string, NodeJS.Timeout>(); |
|
|
| function markAsRefunded(jobId: string): void { |
| |
| const existing = refundedJobs.get(jobId); |
| if (existing) clearTimeout(existing); |
| |
| const timer = setTimeout(() => refundedJobs.delete(jobId), 60 * 60 * 1000); |
| |
| 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; |
| } |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| const accountWaiters: Array<() => void> = []; |
|
|
| |
| function notifyNextWaiter(): void { |
| const next = accountWaiters.shift(); |
| if (next) next(); |
| } |
|
|
| |
| function waitForAccountRelease(timeoutMs: number): Promise<'notified' | 'timeout'> { |
| return new Promise((resolve) => { |
| const timer = setTimeout(() => { |
| |
| 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); |
| }); |
| } |
|
|
| |
| |
| |
| |
| export async function startWorkerManager(): Promise<void> { |
| running = true; |
| logger.info('Worker manager started', { |
| workerId: config.workerId, |
| maxWorkers: config.maxWorkers, |
| }); |
|
|
| |
| |
| |
| |
|
|
| while (running) { |
| try { |
| if (activeWorkers >= config.maxWorkers) { |
| await sleep(2000); |
| continue; |
| } |
|
|
| |
| const job = await claimPendingJob(config.workerId); |
|
|
| if (!job) { |
| |
| const jitter = config.pollIntervalMs + Math.random() * 2000; |
| await sleep(jitter); |
| continue; |
| } |
|
|
| |
| if (processingJobs.has(job.id)) { |
| logger.warn('Skipping job already in progress on this worker', { |
| jobId: job.id, |
| }); |
| |
| await updateJobStatus(job.id, 'pending').catch(() => {}); |
| await sleep(1000); |
| continue; |
| } |
|
|
| logger.info('Claimed job', { jobId: job.id, mode: job.mode, userId: job.user_id }); |
|
|
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| processingJobs.add(job.id); |
|
|
| |
| 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'); |
| } |
|
|
|
|
| |
| |
| |
| export function stopWorkerManager(): void { |
| running = false; |
| logger.info('Worker manager stop requested'); |
| } |
|
|
| |
| |
| |
| export function getActiveWorkerCount(): number { |
| return activeWorkers; |
| } |
|
|
| |
| |
| |
| 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; |
|
|
| |
| 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 { |
| |
| |
| 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; |
| } |
|
|
| |
| currentAttemptCount = await incrementJobAttempt(jobId); |
|
|
| |
| |
| |
| 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); |
|
|
| |
| await updateJobStatus(jobId, 'claiming_account'); |
| await emitEvent(jobId, null, 'info', 'claiming_account', 'Looking for available Turnitin account', { |
| accountPoolKey, |
| uiVariant: assignmentTarget.uiVariant, |
| }); |
|
|
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| await updateJobStatus(jobId, 'running', { |
| identity_id: identityId, |
| started_at: new Date().toISOString(), |
| }); |
| await emitEvent(jobId, identityId, 'info', 'running', `Starting job with account ${account.email}`); |
|
|
| |
| fs.mkdirSync(tmpDir, { recursive: true }); |
|
|
| |
| 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'); |
| } |
|
|
| |
| 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'); |
| } |
| } |
|
|
| |
| const password = getAccountPassword(); |
|
|
| |
| const filters = (job.filters as Record<string, unknown>) || {}; |
|
|
| |
| |
| |
| const lastCompletedStep = initialLastCompletedStep; |
| const effectiveMode = |
| lastCompletedStep && ['submitted', 'similarity', 'viewer', 'filters', 'download', 'receipt'].includes(lastCompletedStep) |
| ? 'resubmit' as const |
| : (job.mode as 'upload' | 'resubmit' | 'quota_check'); |
|
|
| if (lastCompletedStep) { |
| jobLog.info('Resuming job from previous step', { |
| lastCompletedStep, |
| effectiveMode, |
| }); |
| } |
|
|
| |
| 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); |
| }, |
| }; |
|
|
| |
| const result = await runTurnitinJob(jobInput); |
|
|
| |
| 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); |
| |
| 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; |
| } |
|
|
| |
| 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', |
| ); |
| } |
|
|
| |
| try { |
| |
| 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'); |
| } |
|
|
| |
| |
| |
| 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'); |
|
|
| |
| 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), |
| }); |
| } |
|
|
| |
| |
| |
| 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'); |
| } |
|
|
| |
| } catch (err) { |
| const errorMessage = err instanceof Error ? err.message : String(err); |
| const publicErrorMessage = compactWorkerMessage(errorMessage); |
| logger.error('Job failed', { jobId, error: publicErrorMessage }); |
|
|
| |
| |
| |
| |
| 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, |
| ); |
|
|
| |
| |
| |
| |
| let stepToSave: string | null = failedResultStep || null; |
| if (!stepToSave) { |
| |
| |
| 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, |
| ); |
|
|
| |
| 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 { |
| |
| } |
| } |
|
|
| |
| |
| 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; |
| } |
|
|
| |
| 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 { |
| |
| try { |
| if (fs.existsSync(tmpDir)) { |
| fs.rmSync(tmpDir, { recursive: true, force: true }); |
| } |
| } catch { |
| |
| } |
|
|
| |
| |
| |
| notifyNextWaiter(); |
| } |
| } |
|
|
| |
| |
| |
|
|
| 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) { |
| |
| |
| |
| 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 (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) { |
| |
| |
| 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, |
| }); |
| |
| await releaseAccount(account.id, 'available').catch(() => {}); |
| return null; |
| } |
| return account; |
| } |
|
|
| const poolState = await getAccountPoolState(accountPoolKey); |
|
|
| |
| 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.`; |
| |
| |
| |
| 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, |
| }); |
| } |
|
|
| |
| |
| |
| await waitForAccountRelease(ACCOUNT_WAIT_POLL_MS); |
| |
| continue; |
| } |
|
|
| |
| |
| |
| |
| if (wasRefunded(job.id)) { |
| jobLog.warn('Refund already issued for this job; skipping duplicate refund', { |
| jobId: job.id, |
| }); |
| |
| 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); |
|
|
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| 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), |
| }); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| 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), |
| }); |
| } |
| } |
|
|