import type { JobsOptions, TypedQueue } from "./types.js"; import { queueNames, type QueueName } from "./names.js"; import type { AiJob, BrowserAutomationJob, CleanupJob, ExportJob, FilePreviewJob, FileScanJob, ImportJob, NotificationJob, PluginInvokeJob, QueueJobDescriptor, QueueJsonValue, TransactionalMailJob, WebhookDeliveryJob, WorkflowExecutionContinueRequestedOutboxEvent, WorkflowExecutionRequestedOutboxEvent, WorkflowTriggerOutboxEvent, } from "./payloads.js"; import { JOB_PRIORITY_CRITICAL, priorityFromOverride, priorityLabel, resolveJobPriorityForTenant, type PriorityOverride } from "./priority.js"; type AiJobForKind = AiJob & { kind: Kind }; type BrowserAutomationJobForKind = BrowserAutomationJob & { kind: Kind }; export type TransactionalMailJobInput = Omit; export type WebhookDeliveryJobInput = Omit; export type NotificationJobInput = Omit; export type CleanupJobInput = Omit; export type AiChatCompletionJobInput = Omit, "kind">; export type AiEmbeddingJobInput = Omit, "kind">; export type AiBrowserTaskJobInput = Omit, "kind">; export type BrowserSessionRunJobInput = Omit, "kind">; export type BrowserSessionCleanupJobInput = Omit, "kind">; export type BrowserUsageFlushJobInput = Omit, "kind">; export type FileScanJobInput = Omit; export type FilePreviewJobInput = Omit; export type ExportJobInput = Omit; export type ImportJobInput = Omit; export type PluginInvokeJobInput = Omit; export type WorkflowTriggerOutboxEventInput = { tenantId: string | null; event: string; data: QueueJsonValue; userId: string | null; sourceId: string; }; export type WorkflowExecutionRequestedOutboxEventInput = { tenantId: string | null; workflowId: string; executionId: string; type: "manual" | "api"; input: QueueJsonValue; userId: string | null; sourceId: string; }; export type WorkflowExecutionContinueRequestedOutboxEventInput = WorkflowExecutionRequestedOutboxEventInput; const WORKFLOW_OUTBOX_SOURCE_ID_PATTERN = /^[A-Za-z0-9._:-]{1,234}$/u; function requireWorkflowOutboxText(value: string, fieldName: string, eventType: string): string { const trimmed = value.trim(); if (!trimmed) { throw new Error(`${eventType} outbox events require ${fieldName}.`); } return trimmed; } function requireWorkflowOutboxSourceId(value: string, eventType: string): string { const sourceId = requireWorkflowOutboxText(value, "sourceId", eventType); if (!WORKFLOW_OUTBOX_SOURCE_ID_PATTERN.test(sourceId)) { throw new Error(`${eventType} outbox events require sourceId to match ${WORKFLOW_OUTBOX_SOURCE_ID_PATTERN.source}.`); } return sourceId; } export type TransactionalMailJobDescriptor = QueueJobDescriptor & { jobName: "send-transactional-email"; payload: TransactionalMailJob; }; export type WebhookDeliveryJobDescriptor = QueueJobDescriptor & { jobName: "deliver-webhook"; payload: WebhookDeliveryJob; }; export type NotificationJobDescriptor = QueueJobDescriptor & { jobName: "send-notification"; payload: NotificationJob; }; export type CleanupJobDescriptor = QueueJobDescriptor & { jobName: "run-cleanup"; payload: CleanupJob; }; export type AiChatCompletionJobDescriptor = QueueJobDescriptor & { jobName: "chat-completion"; payload: AiJobForKind<"ai.chat-completion">; }; export type AiEmbeddingJobDescriptor = QueueJobDescriptor & { jobName: "create-embedding"; payload: AiJobForKind<"ai.embedding">; }; export type AiBrowserTaskJobDescriptor = QueueJobDescriptor & { jobName: "browser-task"; payload: AiJobForKind<"ai.browser-task">; }; export type BrowserSessionRunJobDescriptor = QueueJobDescriptor & { jobName: "browser-session-run"; payload: BrowserAutomationJobForKind<"browser.session-run">; }; export type BrowserSessionCleanupJobDescriptor = QueueJobDescriptor & { jobName: "browser-session-cleanup"; payload: BrowserAutomationJobForKind<"browser.session-cleanup">; }; export type BrowserUsageFlushJobDescriptor = QueueJobDescriptor & { jobName: "usage-flush"; payload: BrowserAutomationJobForKind<"usage.flush">; }; export type FileScanJobDescriptor = QueueJobDescriptor & { jobName: "scan-file"; payload: FileScanJob; }; export type FilePreviewJobDescriptor = QueueJobDescriptor & { jobName: "generate-file-preview"; payload: FilePreviewJob; }; export type ExportJobDescriptor = QueueJobDescriptor & { jobName: "run-export"; payload: ExportJob; }; export type ImportJobDescriptor = QueueJobDescriptor & { jobName: "run-import"; payload: ImportJob; }; export type PluginInvokeJobDescriptor = QueueJobDescriptor & { jobName: "invoke-plugin"; payload: PluginInvokeJob; }; export type EmailProducerJobDescriptor = TransactionalMailJobDescriptor | WebhookDeliveryJobDescriptor; export type AiProducerJobDescriptor = AiChatCompletionJobDescriptor | AiEmbeddingJobDescriptor | AiBrowserTaskJobDescriptor; export type BrowserAutomationProducerJobDescriptor = BrowserSessionRunJobDescriptor | BrowserSessionCleanupJobDescriptor | BrowserUsageFlushJobDescriptor; export type ProducerJobDescriptor = | EmailProducerJobDescriptor | NotificationJobDescriptor | CleanupJobDescriptor | AiProducerJobDescriptor | BrowserAutomationProducerJobDescriptor | FileScanJobDescriptor | FilePreviewJobDescriptor | ExportJobDescriptor | ImportJobDescriptor | PluginInvokeJobDescriptor; const retryJobOptionsByQueueAndJobName: { [Name in QueueName]: Record["jobName"], Pick>; } = { [queueNames.email]: { "send-transactional-email": { attempts: 3, backoff: { type: "exponential", delay: 1_000 } }, "deliver-webhook": { attempts: 5, backoff: { type: "exponential", delay: 2_000 } }, }, [queueNames.notifications]: { "send-notification": { attempts: 3, backoff: { type: "exponential", delay: 1_000 } }, }, [queueNames.maintenance]: { "run-cleanup": { attempts: 2, backoff: { type: "fixed", delay: 5_000 } }, // Cron-scheduled grace-period scan: each tick is idempotent (the // underlying SQL filter only finds overdue rows), but we still allow // one retry on transient Prisma/Redis hiccups before letting the // next 24h tick recover the run. "dsgvo-grace-period-scan": { attempts: 2, backoff: { type: "fixed", delay: 30_000 } }, }, [queueNames.aiChatbot]: { "chat-completion": { attempts: 3, backoff: { type: "exponential", delay: 2_000 } }, "create-embedding": { attempts: 3, backoff: { type: "exponential", delay: 2_000 } }, "browser-task": { attempts: 2, backoff: { type: "exponential", delay: 5_000 } }, }, [queueNames.browserAutomation]: { "browser-session-run": { attempts: 2, backoff: { type: "exponential", delay: 5_000 } }, "browser-session-cleanup": { attempts: 3, backoff: { type: "fixed", delay: 10_000 } }, "usage-flush": { attempts: 3, backoff: { type: "fixed", delay: 10_000 } }, }, [queueNames.fileScan]: { "scan-file": { attempts: 3, backoff: { type: "exponential", delay: 2_000 } }, }, [queueNames.filePreview]: { // Preview generation is best-effort: a couple of retries cover transient // storage/decoder hiccups, then the handler marks previewStatus=failed and // the user can re-trigger from the detail view. "generate-file-preview": { attempts: 3, backoff: { type: "exponential", delay: 3_000 } }, }, [queueNames.exports]: { "run-export": { attempts: 2, backoff: { type: "exponential", delay: 10_000 } }, }, [queueNames.imports]: { "run-import": { attempts: 2, backoff: { type: "exponential", delay: 10_000 } }, }, [queueNames.pluginInvoke]: { "invoke-plugin": { attempts: 3, backoff: { type: "exponential", delay: 2_000 } }, }, }; export function buildRetryJobOptions(descriptor: ProducerJobDescriptor): JobsOptions { const queueRetryOptions = retryJobOptionsByQueueAndJobName[descriptor.queueName] as Record< ProducerJobDescriptor["jobName"], Pick >; return { ...queueRetryOptions[descriptor.jobName], removeOnComplete: true, removeOnFail: 1_000, }; } export function buildTransactionalMailJob(input: TransactionalMailJobInput): TransactionalMailJobDescriptor { return { queueName: queueNames.email, jobName: "send-transactional-email", payload: { kind: "mail.transactional", ...input, }, }; } export function buildWebhookDeliveryJob(input: WebhookDeliveryJobInput): WebhookDeliveryJobDescriptor { return { queueName: queueNames.email, jobName: "deliver-webhook", payload: { kind: "webhook.deliver", ...input, }, }; } export function buildNotificationJob(input: NotificationJobInput): NotificationJobDescriptor { if (input.channel !== "email" && input.channel !== "in-app") { throw new Error(`Unsupported notification channel: ${input.channel}`); } if (!input.tenantId?.trim()) { throw new Error("Notification jobs require tenantId."); } return { queueName: queueNames.notifications, jobName: "send-notification", payload: { kind: "notification.send", ...input, }, }; } export function buildCleanupJob(input: CleanupJobInput): CleanupJobDescriptor { return { queueName: queueNames.maintenance, jobName: "run-cleanup", payload: { kind: "cleanup.run", ...input, }, }; } export function buildAiChatCompletionJob(input: AiChatCompletionJobInput): AiChatCompletionJobDescriptor { return { queueName: queueNames.aiChatbot, jobName: "chat-completion", payload: { kind: "ai.chat-completion", ...input, }, }; } export function buildAiEmbeddingJob(input: AiEmbeddingJobInput): AiEmbeddingJobDescriptor { return { queueName: queueNames.aiChatbot, jobName: "create-embedding", payload: { kind: "ai.embedding", ...input, }, }; } export function buildAiBrowserTaskJob(input: AiBrowserTaskJobInput): AiBrowserTaskJobDescriptor { return { queueName: queueNames.aiChatbot, jobName: "browser-task", payload: { kind: "ai.browser-task", ...input, }, }; } export function buildBrowserSessionRunJob(input: BrowserSessionRunJobInput): BrowserSessionRunJobDescriptor { return { queueName: queueNames.browserAutomation, jobName: "browser-session-run", payload: { kind: "browser.session-run", ...input, }, }; } export function buildBrowserSessionCleanupJob(input: BrowserSessionCleanupJobInput): BrowserSessionCleanupJobDescriptor { return { queueName: queueNames.browserAutomation, jobName: "browser-session-cleanup", payload: { kind: "browser.session-cleanup", ...input, }, }; } export function buildBrowserUsageFlushJob(input: BrowserUsageFlushJobInput): BrowserUsageFlushJobDescriptor { return { queueName: queueNames.browserAutomation, jobName: "usage-flush", payload: { kind: "usage.flush", ...input, }, }; } export function buildFileScanJob(input: FileScanJobInput): FileScanJobDescriptor { return { queueName: queueNames.fileScan, jobName: "scan-file", payload: { kind: "scan.file", ...input, }, }; } export function buildFilePreviewJob(input: FilePreviewJobInput): FilePreviewJobDescriptor { if (!input.fileId?.trim()) { throw new Error("File preview jobs require fileId."); } if (!input.tenantId?.trim()) { throw new Error("File preview jobs require tenantId."); } return { queueName: queueNames.filePreview, jobName: "generate-file-preview", payload: { kind: "preview.file", ...input, }, }; } export function buildExportJob(input: ExportJobInput): ExportJobDescriptor { return { queueName: queueNames.exports, jobName: "run-export", payload: { kind: "export.run", ...input, }, }; } export function buildImportJob(input: ImportJobInput): ImportJobDescriptor { return { queueName: queueNames.imports, jobName: "run-import", payload: { kind: "import.run", ...input, }, }; } export function buildPluginInvokeJob(input: PluginInvokeJobInput): PluginInvokeJobDescriptor { return { queueName: queueNames.pluginInvoke, jobName: "invoke-plugin", payload: { kind: "plugin.invoke", ...input, }, }; } export function buildWorkflowTriggerOutboxEvent(input: WorkflowTriggerOutboxEventInput): WorkflowTriggerOutboxEvent { const eventType = "workflow.trigger.event"; const event = requireWorkflowOutboxText(input.event, "event", eventType); const sourceId = requireWorkflowOutboxSourceId(input.sourceId, eventType); return { tenantId: input.tenantId, type: eventType, payload: { event, data: input.data, userId: input.userId, }, dispatchKey: `workflow-trigger:${sourceId}`, }; } export function buildWorkflowExecutionRequestedOutboxEvent(input: WorkflowExecutionRequestedOutboxEventInput): WorkflowExecutionRequestedOutboxEvent { const eventType = "workflow.execution.requested"; const workflowId = requireWorkflowOutboxText(input.workflowId, "workflowId", eventType); const executionId = requireWorkflowOutboxText(input.executionId, "executionId", eventType); const sourceId = requireWorkflowOutboxSourceId(input.sourceId, eventType); return { tenantId: input.tenantId, type: eventType, payload: { workflowId, executionId, type: input.type, input: input.input, userId: input.userId, }, dispatchKey: `workflow-execution:${sourceId}`, }; } export function buildWorkflowExecutionContinueRequestedOutboxEvent( input: WorkflowExecutionContinueRequestedOutboxEventInput ): WorkflowExecutionContinueRequestedOutboxEvent { const eventType = "workflow.execution.continue.requested"; const workflowId = requireWorkflowOutboxText(input.workflowId, "workflowId", eventType); const executionId = requireWorkflowOutboxText(input.executionId, "executionId", eventType); const sourceId = requireWorkflowOutboxSourceId(input.sourceId, eventType); return { tenantId: input.tenantId, type: eventType, payload: { workflowId, executionId, type: input.type, input: input.input, userId: input.userId, }, dispatchKey: `workflow-continuation:${sourceId}`, }; } export type QueueEnqueueOptions = JobsOptions & { idempotencyKey?: string; /** * Tenant scope for plan-based priority resolution. Pass `null` for * internal/system jobs (cron, retention, audit) which always run at * CRITICAL priority. Omitting `tenantId` leaves the priority untouched * unless `priorityOverride` is set. */ tenantId?: string | null; /** * Explicit priority hint that bypasses tenant-plan lookup. Use for * semantically critical jobs (password-reset mail, verification mail) * regardless of plan, and for explicit low-priority batches. */ priorityOverride?: PriorityOverride; }; /** * Optional enqueue observer. Producer-host apps (api / web / worker) can * register one during boot to forward priority/queue/jobName info to their * Prometheus registry. Kept as a module-level setter so the queue package * does not depend on prom-client or any app metrics bundle. */ export type EnqueueObserver = (event: { queueName: QueueName; jobName: string; priority: number; priorityLabel: ReturnType }) => void; let enqueueObserver: EnqueueObserver | null = null; export function setEnqueueObserver(observer: EnqueueObserver | null): void { enqueueObserver = observer; } export function getEnqueueObserver(): EnqueueObserver | null { return enqueueObserver; } export function buildIdempotentJobId(descriptor: ProducerJobDescriptor, options?: QueueEnqueueOptions): string | undefined { if (typeof options?.jobId === "string") { return options.jobId; } if (typeof options?.idempotencyKey === "string") { return options.idempotencyKey; } if (descriptor.payload.kind === "export.run") { return descriptor.payload.exportJobId; } if (descriptor.payload.kind === "import.run") { return descriptor.payload.importJobId; } const metadata = descriptor.payload.metadata; if (metadata && typeof metadata.idempotencyKey === "string") { return metadata.idempotencyKey; } return undefined; } async function resolvePriorityForEnqueue(options: QueueEnqueueOptions | undefined): Promise { if (!options) return undefined; if (typeof options.priority === "number") { // Explicit numeric priority always wins so power-users / tests can pin // a precise value. return options.priority; } if (options.priorityOverride !== undefined) { return priorityFromOverride(options.priorityOverride); } if (options.tenantId !== undefined) { if (options.tenantId === null) { // Explicit `null` tenantId → internal job → CRITICAL. Short-circuit // the resolver to avoid a useless feature-flag round-trip. return JOB_PRIORITY_CRITICAL; } return resolveJobPriorityForTenant(options.tenantId); } return undefined; } export async function enqueueJob(queue: TypedQueue, descriptor: QueueJobDescriptor, options?: QueueEnqueueOptions) { const producerDescriptor = descriptor as ProducerJobDescriptor; const callerOptions: JobsOptions = options ? { ...options } : {}; delete (callerOptions as QueueEnqueueOptions).idempotencyKey; delete (callerOptions as QueueEnqueueOptions).tenantId; delete (callerOptions as QueueEnqueueOptions).priorityOverride; const jobId = buildIdempotentJobId(producerDescriptor, options); const idempotentCompletionRetention: Pick = jobId && callerOptions.removeOnComplete === undefined ? { removeOnComplete: false } : {}; const resolvedPriority = await resolvePriorityForEnqueue(options); const priorityOptions: Pick = resolvedPriority !== undefined ? { priority: resolvedPriority } : {}; const enqueueOptions: JobsOptions = { ...buildRetryJobOptions(producerDescriptor), ...idempotentCompletionRetention, ...(jobId ? { jobId } : {}), ...priorityOptions, ...callerOptions, }; if (enqueueObserver && typeof enqueueOptions.priority === "number") { try { enqueueObserver({ queueName: descriptor.queueName as QueueName, jobName: descriptor.jobName, priority: enqueueOptions.priority, priorityLabel: priorityLabel(enqueueOptions.priority), }); } catch { // Observer must never block enqueue. Swallow. } } return queue.add(descriptor.jobName, descriptor.payload, enqueueOptions); }