wulf-pulse/lib/services/analyzer/worker.ts
lorentz 8f8b5ab7be feat: AI ticket analyzer (phases 1-6)
Multi-stage LLM pipeline that produces structured analyses of Autotask
tickets from local Postgres. Migration 069 + Zod schemas, Stage 0
preprocessor, IT Glue redaction + search, Anthropic SDK wrapper, Stages
1/3/4 (Haiku/Sonnet/Opus), pipeline + cost circuit breaker, job worker
(opt-in autostart), 6 API routes, 3 frontend pages, share-row
persistence (email send deferred to phase 7). 128 vitest tests, tsc
clean. Build journal in docs/wulf-pulse-ticket-analyzer-build-notes.md.

Sync: adds syncTicketNotes() + ticket_notes to ordered/date-filtered
entities so the analyzer's local mirror stays current via scheduler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:59:40 -04:00

162 lines
5 KiB
TypeScript

/**
* 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<void> {
if (this.running) return;
this.running = true;
console.log('[ANALYZER-WORKER] starting; polling every 2s');
this.scheduleNextPoll(0);
}
async stop(): Promise<void> {
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<void> {
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 };