/** * Analyzer job worker. * * Polls `analyzer_jobs` for queued rows, runs the full pipeline, persists the * result, and updates the job's status / result_analysis_id throughout. * * Concurrency model: a single in-process polling loop. Multiple Next.js * workers will all import this module, but `claimQueuedJob()` uses * `FOR UPDATE SKIP LOCKED` so they cooperate at the row level — each job is * processed exactly once. * * Auto-start: the worker self-initializes on first server-side import in * production. In dev / tests, set ANALYZER_WORKER_AUTOSTART=1 to opt in. */ import { claimQueuedJob, completeJob, failJob, insertAnalysis, updateJobStatus, } from './persistence'; import { loadTicketBundle, TicketNotFoundError } from './data-access'; import { runPipeline, type PipelineResult } from './pipeline'; const POLL_INTERVAL_MS = 2_000; class AnalyzerWorker { private timer: NodeJS.Timeout | null = null; private running = false; private inFlight = false; async start(): Promise { if (this.running) return; this.running = true; console.log('[ANALYZER-WORKER] starting; polling every 2s'); this.scheduleNextPoll(0); } async stop(): Promise { this.running = false; if (this.timer) { clearTimeout(this.timer); this.timer = null; } } private scheduleNextPoll(delay: number): void { if (!this.running) return; this.timer = setTimeout(() => { void this.poll(); }, delay); } private async poll(): Promise { if (!this.running) return; if (this.inFlight) { // Avoid overlap if a previous poll is still running. this.scheduleNextPoll(POLL_INTERVAL_MS); return; } this.inFlight = true; try { const claimed = await claimQueuedJob(); if (claimed) { await this.runJob(claimed.id, claimed.ticket_number, claimed.queued_by_user_id); } } catch (err) { console.error('[ANALYZER-WORKER] poll error:', err); } finally { this.inFlight = false; this.scheduleNextPoll(POLL_INTERVAL_MS); } } /** * Process a single job end-to-end. Public so tests + manual triggers can * call it directly without going through the poll loop. */ async runJob( jobId: string, ticketNumber: string, triggeredByUserId: string | null ): Promise<{ analysis_id: string | null; outcome: PipelineResult['outcome'] | 'failed' }> { try { const bundle = await loadTicketBundle(ticketNumber); const result = await runPipeline( { bundle, force: false }, {}, { onStage: (stage) => updateJobStatus(jobId, stage), } ); if (result.outcome === 'idempotent_short_circuit') { // Point the job at the existing analysis so the UI can navigate to it. await completeJob(jobId, result.existing_analysis_id); return { analysis_id: result.existing_analysis_id, outcome: 'idempotent_short_circuit', }; } const inserted = await insertAnalysis({ ticket_number: result.pre.header.ticket_number, autotask_ticket_id: result.pre.header.autotask_ticket_id, content_hash: result.pre.content_hash, triggered_by_user_id: triggeredByUserId, status: 'complete', haiku_used: result.meta.haiku_used, sonnet_used: result.meta.sonnet_used, opus_used: result.meta.opus_used, total_input_tokens: result.meta.total_input_tokens, total_output_tokens: result.meta.total_output_tokens, estimated_cost_usd: result.meta.estimated_cost_usd, analysis: result.analysis, filtered_noise_count: result.filtered_noise_count, model_traces: result.model_traces, }); await completeJob(jobId, inserted.id); return { analysis_id: inserted.id, outcome: 'complete' }; } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error( `[ANALYZER-WORKER] job ${jobId} (${ticketNumber}) failed:`, message ); const reason = err instanceof TicketNotFoundError ? `Ticket ${ticketNumber} not found in local mirror — confirm sync is current.` : message; await failJob(jobId, reason); return { analysis_id: null, outcome: 'failed' }; } } } export const analyzerWorker = new AnalyzerWorker(); /** * Should we auto-start the worker on import? * - Skip in browsers (typeof window check). * - Skip during vitest. * - Auto-start in production by default. * - Otherwise opt in via ANALYZER_WORKER_AUTOSTART=1. */ function shouldAutoStart(): boolean { if (typeof window !== 'undefined') return false; if (process.env.VITEST === 'true') return false; if (process.env.NODE_ENV === 'production') return true; return process.env.ANALYZER_WORKER_AUTOSTART === '1'; } if (shouldAutoStart()) { analyzerWorker.start().catch((err) => { console.error('[ANALYZER-WORKER] failed to auto-start:', err); }); } export const _WORKER_INTERNALS = { POLL_INTERVAL_MS };