From bef5671fbddc407a2fd20d19a322bb72b5c5aab2 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Wed, 28 Oct 2026 11:37:14 -0700 Subject: [PATCH] fix(queue): skip scheduling HUBSPOT_SYNC when HUBSPOT_API_KEY is unset Without the key every HUBSPOT_SYNC run fails at HubSpot client construction and dead-letters (prod worker, last 24h). The scheduler now leaves the job out of its default schedule when the key is unset, empty or whitespace, and logs one startup line saying so. With a key set the schedule is unchanged, so the sync comes back once a key is added. --- .../queue/src/__tests__/scheduler.test.ts | 105 +++++++++++++++++- packages/outpost/queue/src/scheduler.ts | 22 +++- 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/packages/outpost/queue/src/__tests__/scheduler.test.ts b/packages/outpost/queue/src/__tests__/scheduler.test.ts index e6e8471..79574fc 100644 --- a/packages/outpost/queue/src/__tests__/scheduler.test.ts +++ b/packages/outpost/queue/src/__tests__/scheduler.test.ts @@ -1,7 +1,24 @@ -import { describe, it, expect } from 'vitest'; -import { DEFAULT_SCHEDULED_JOBS } from '../scheduler.js'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const { mockPrisma, mockCreateJob } = vi.hoisted(() => ({ + mockPrisma: { job: { findFirst: vi.fn() } }, + mockCreateJob: vi.fn(), +})); + +vi.mock('@copilotkit/outpost/db', () => ({ + prisma: mockPrisma, +})); + +vi.mock('../create-job.js', () => ({ + createJob: mockCreateJob, +})); + +import { DEFAULT_SCHEDULED_JOBS, Scheduler, resolveScheduledJobs } from '../scheduler.js'; import { JobType } from '../types.js'; +const HUBSPOT_SKIP_LINE = + '[Scheduler] Skipping HUBSPOT_SYNC: HUBSPOT_API_KEY is not set (set it to enable the HubSpot sync)'; + describe('DEFAULT_SCHEDULED_JOBS', () => { it('includes GITHUB_REACTION_POLL in the default scheduled jobs at a 24 hour interval', () => { const definition = DEFAULT_SCHEDULED_JOBS.find( @@ -22,3 +39,87 @@ describe('DEFAULT_SCHEDULED_JOBS', () => { expect(definition!.intervalMs).toBe(5 * 60 * 1000); }); }); + +describe('resolveScheduledJobs', () => { + let logSpy: ReturnType; + + beforeEach(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + it.each([ + ['unset', {}], + ['empty', { HUBSPOT_API_KEY: '' }], + ['whitespace-only', { HUBSPOT_API_KEY: ' ' }], + ])('drops HUBSPOT_SYNC and logs one skip line when HUBSPOT_API_KEY is %s', (_label, env) => { + // Without a key every HUBSPOT_SYNC run fails at client construction and + // dead-letters, so the job is not scheduled at all. + const definitions = resolveScheduledJobs(DEFAULT_SCHEDULED_JOBS, env); + + expect(definitions.map((d) => d.type)).not.toContain(JobType.HUBSPOT_SYNC); + expect(definitions).toHaveLength(DEFAULT_SCHEDULED_JOBS.length - 1); + expect(logSpy).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith(HUBSPOT_SKIP_LINE); + }); + + it('keeps every default job, HUBSPOT_SYNC included, when HUBSPOT_API_KEY is set', () => { + const definitions = resolveScheduledJobs(DEFAULT_SCHEDULED_JOBS, { + HUBSPOT_API_KEY: 'dummy-key', + }); + + expect(definitions).toEqual(DEFAULT_SCHEDULED_JOBS); + expect(logSpy).not.toHaveBeenCalled(); + }); +}); + +describe('Scheduler default definitions', () => { + const savedKey = process.env.HUBSPOT_API_KEY; + let logSpy: ReturnType; + + beforeEach(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + mockPrisma.job.findFirst.mockReset().mockResolvedValue(null); + mockCreateJob.mockReset().mockResolvedValue('job-id'); + }); + + afterEach(() => { + logSpy.mockRestore(); + if (savedKey === undefined) delete process.env.HUBSPOT_API_KEY; + else process.env.HUBSPOT_API_KEY = savedKey; + }); + + async function startAndCollectCreatedTypes(): Promise { + const scheduler = new Scheduler(); + scheduler.start(); + scheduler.stop(); + // start() fires each tick without awaiting; let them settle. + await vi.waitFor(() => + expect(mockCreateJob).toHaveBeenCalledTimes(mockPrisma.job.findFirst.mock.calls.length), + ); + return mockCreateJob.mock.calls.map((call) => call[0] as string); + } + + it('never creates a HUBSPOT_SYNC job when HUBSPOT_API_KEY is unset', async () => { + delete process.env.HUBSPOT_API_KEY; + + const created = await startAndCollectCreatedTypes(); + + expect(created).not.toContain(JobType.HUBSPOT_SYNC); + expect(created).toHaveLength(DEFAULT_SCHEDULED_JOBS.length - 1); + expect(logSpy).toHaveBeenCalledWith(HUBSPOT_SKIP_LINE); + }); + + it('creates a HUBSPOT_SYNC job as before when HUBSPOT_API_KEY is set', async () => { + process.env.HUBSPOT_API_KEY = 'dummy-key'; + + const created = await startAndCollectCreatedTypes(); + + expect(created).toContain(JobType.HUBSPOT_SYNC); + expect(created).toHaveLength(DEFAULT_SCHEDULED_JOBS.length); + expect(logSpy).not.toHaveBeenCalledWith(HUBSPOT_SKIP_LINE); + }); +}); diff --git a/packages/outpost/queue/src/scheduler.ts b/packages/outpost/queue/src/scheduler.ts index ca50116..06fee87 100644 --- a/packages/outpost/queue/src/scheduler.ts +++ b/packages/outpost/queue/src/scheduler.ts @@ -57,6 +57,26 @@ export const DEFAULT_SCHEDULED_JOBS: ScheduledJobDefinition[] = [ }, ]; +/** + * Drop scheduled jobs whose required configuration is missing. + * + * HUBSPOT_SYNC needs HUBSPOT_API_KEY: without it every run fails at client + * construction and dead-letters. It is left out of the schedule (so no job rows + * are created) and one startup line says so. Setting the key re-enables it on + * the next start. + */ +export function resolveScheduledJobs( + definitions: ScheduledJobDefinition[] = DEFAULT_SCHEDULED_JOBS, + env: NodeJS.ProcessEnv = process.env, +): ScheduledJobDefinition[] { + if (env.HUBSPOT_API_KEY?.trim()) return definitions; + + console.log( + '[Scheduler] Skipping HUBSPOT_SYNC: HUBSPOT_API_KEY is not set (set it to enable the HubSpot sync)', + ); + return definitions.filter((d) => d.type !== JobType.HUBSPOT_SYNC); +} + /** * A simple interval-based scheduler that creates recurring jobs. * @@ -70,7 +90,7 @@ export class Scheduler { private running = false; constructor(definitions?: ScheduledJobDefinition[]) { - this.definitions = definitions ?? DEFAULT_SCHEDULED_JOBS; + this.definitions = definitions ?? resolveScheduledJobs(); } /**