diff --git a/apps/api/src/browserbase/browserbase.controller.spec.ts b/apps/api/src/browserbase/browserbase.controller.spec.ts index 1713edeffa..6e8cbe75e0 100644 --- a/apps/api/src/browserbase/browserbase.controller.spec.ts +++ b/apps/api/src/browserbase/browserbase.controller.spec.ts @@ -10,6 +10,13 @@ jest.mock('@db', () => ({ } }, }, + TaskFrequency: { + daily: 'daily', + weekly: 'weekly', + monthly: 'monthly', + quarterly: 'quarterly', + yearly: 'yearly', + }, })); jest.mock('../auth/auth.server', () => ({ @@ -35,7 +42,9 @@ import { PermissionGuard } from '../auth/permission.guard'; describe('BrowserbaseController.redirectToScreenshot', () => { let controller: BrowserbaseController; - let service: jest.Mocked>; + let service: jest.Mocked< + Pick + >; beforeEach(async () => { service = { @@ -73,7 +82,10 @@ describe('BrowserbaseController.redirectToScreenshot', () => { organizationId: 'org_1', download: false, }); - expect(res.redirect).toHaveBeenCalledWith(302, 'https://s3.example.com/fresh-signed'); + expect(res.redirect).toHaveBeenCalledWith( + 302, + 'https://s3.example.com/fresh-signed', + ); }); it('passes download=true to the service when the query param is "true"', async () => { diff --git a/apps/api/src/browserbase/browserbase.service.spec.ts b/apps/api/src/browserbase/browserbase.service.spec.ts index 5a06feaf39..7f8cdba32f 100644 --- a/apps/api/src/browserbase/browserbase.service.spec.ts +++ b/apps/api/src/browserbase/browserbase.service.spec.ts @@ -8,6 +8,17 @@ jest.mock('@db', () => ({ browserAutomationRun: { findUnique: jest.fn(), }, + browserAutomation: { + create: jest.fn(), + update: jest.fn(), + }, + }, + TaskFrequency: { + daily: 'daily', + weekly: 'weekly', + monthly: 'monthly', + quarterly: 'quarterly', + yearly: 'yearly', }, })); @@ -17,7 +28,7 @@ jest.mock('@/app/s3', () => ({ BUCKET_NAME: 'test-bucket', })); -import { db } from '@db'; +import { db, TaskFrequency } from '@db'; import { getSignedUrl } from '@/app/s3'; describe('BrowserbaseService.getScreenshotRedirectUrl', () => { @@ -126,3 +137,79 @@ describe('BrowserbaseService.getScreenshotRedirectUrl', () => { ); }); }); + +describe('BrowserbaseService schedule frequency passthrough', () => { + let service: BrowserbaseService; + + beforeEach(async () => { + jest.clearAllMocks(); + const moduleRef = await Test.createTestingModule({ + providers: [BrowserbaseService], + }).compile(); + service = moduleRef.get(BrowserbaseService); + }); + + it('forwards scheduleFrequency when creating a browser automation', async () => { + (db.browserAutomation.create as jest.Mock).mockResolvedValue({ + id: 'bau_1', + }); + + await service.createBrowserAutomation({ + taskId: 'tsk_1', + name: 'name', + targetUrl: 'https://example.com', + instruction: 'click', + scheduleFrequency: TaskFrequency.weekly, + }); + + expect(db.browserAutomation.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ scheduleFrequency: 'weekly' }), + }), + ); + }); + + it('omits scheduleFrequency when creating without the field', async () => { + (db.browserAutomation.create as jest.Mock).mockResolvedValue({ + id: 'bau_1', + }); + + await service.createBrowserAutomation({ + taskId: 'tsk_1', + name: 'name', + targetUrl: 'https://example.com', + instruction: 'click', + }); + + const call = (db.browserAutomation.create as jest.Mock).mock.calls[0][0]; + expect(call.data).not.toHaveProperty('scheduleFrequency'); + }); + + it('forwards scheduleFrequency when updating a browser automation', async () => { + (db.browserAutomation.update as jest.Mock).mockResolvedValue({ + id: 'bau_1', + }); + + await service.updateBrowserAutomation('bau_1', { + scheduleFrequency: TaskFrequency.monthly, + }); + + expect(db.browserAutomation.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'bau_1' }, + data: expect.objectContaining({ scheduleFrequency: 'monthly' }), + }), + ); + }); + + it('omits scheduleFrequency when updating without the field', async () => { + (db.browserAutomation.update as jest.Mock).mockResolvedValue({ + id: 'bau_1', + }); + + await service.updateBrowserAutomation('bau_1', { name: 'renamed' }); + + const call = (db.browserAutomation.update as jest.Mock).mock.calls[0][0]; + expect(call.data).not.toHaveProperty('scheduleFrequency'); + }); +}); diff --git a/apps/api/src/browserbase/browserbase.service.ts b/apps/api/src/browserbase/browserbase.service.ts index 8e388fbc13..da81adadea 100644 --- a/apps/api/src/browserbase/browserbase.service.ts +++ b/apps/api/src/browserbase/browserbase.service.ts @@ -3,9 +3,13 @@ import Browserbase from '@browserbasehq/sdk'; // Lazy-imported in createStagehand() to avoid Node v25 crash // (SlowBuffer.prototype was removed — @browserbasehq/stagehand bundles buffer-equal-constant-time which uses it) type Stagehand = import('@browserbasehq/stagehand').Stagehand; -import { db } from '@db'; +import { db, TaskFrequency } from '@db'; import { z } from 'zod'; -import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { + GetObjectCommand, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; import { BUCKET_NAME, getSignedUrl, s3Client } from '@/app/s3'; import { renderOverlay } from './screenshot-overlay'; import { isNoPageError, toRunErrorMessage } from './run-error-formatter'; @@ -353,7 +357,7 @@ export class BrowserbaseService { targetUrl: string; instruction: string; evaluationCriteria?: string; - schedule?: string; + scheduleFrequency?: TaskFrequency; }) { return db.browserAutomation.create({ data: { @@ -363,8 +367,10 @@ export class BrowserbaseService { targetUrl: data.targetUrl, instruction: data.instruction, evaluationCriteria: normalizeCriteria(data.evaluationCriteria), - schedule: data.schedule, isEnabled: true, // Enable by default so scheduled runs work + ...(data.scheduleFrequency !== undefined + ? { scheduleFrequency: data.scheduleFrequency } + : {}), }, }); } @@ -402,11 +408,11 @@ export class BrowserbaseService { targetUrl?: string; instruction?: string; evaluationCriteria?: string; - schedule?: string; isEnabled?: boolean; + scheduleFrequency?: TaskFrequency; }, ) { - const { evaluationCriteria, ...rest } = data; + const { evaluationCriteria, scheduleFrequency, ...rest } = data; return db.browserAutomation.update({ where: { id: automationId }, data: { @@ -414,6 +420,7 @@ export class BrowserbaseService { ...(evaluationCriteria !== undefined ? { evaluationCriteria: normalizeCriteria(evaluationCriteria) } : {}), + ...(scheduleFrequency !== undefined ? { scheduleFrequency } : {}), }, }); } @@ -848,10 +855,15 @@ export class BrowserbaseService { capturedAt: new Date(), }); } catch (overlayErr) { - this.logger.warn('Screenshot overlay render failed; uploading raw image', { - error: - overlayErr instanceof Error ? overlayErr.message : String(overlayErr), - }); + this.logger.warn( + 'Screenshot overlay render failed; uploading raw image', + { + error: + overlayErr instanceof Error + ? overlayErr.message + : String(overlayErr), + }, + ); } // Optional evaluation: if the automation was configured with diff --git a/apps/api/src/browserbase/dto/browserbase.dto.ts b/apps/api/src/browserbase/dto/browserbase.dto.ts index 00b637cf20..2a7334a782 100644 --- a/apps/api/src/browserbase/dto/browserbase.dto.ts +++ b/apps/api/src/browserbase/dto/browserbase.dto.ts @@ -1,11 +1,13 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { + IsEnum, IsNotEmpty, IsOptional, IsString, IsBoolean, IsUrl, } from 'class-validator'; +import { TaskFrequency } from '@db'; import { IsSafeUrl } from '../validators/url-safety.validator'; // ===== Session DTOs ===== @@ -90,10 +92,13 @@ export class CreateBrowserAutomationDto { @IsOptional() evaluationCriteria?: string; - @ApiPropertyOptional({ description: 'Cron schedule expression' }) - @IsString() + @ApiPropertyOptional({ + enum: TaskFrequency, + description: 'Automation schedule cadence', + }) + @IsEnum(TaskFrequency) @IsOptional() - schedule?: string; + scheduleFrequency?: TaskFrequency; } export class UpdateBrowserAutomationDto { @@ -127,15 +132,18 @@ export class UpdateBrowserAutomationDto { @IsOptional() evaluationCriteria?: string; - @ApiPropertyOptional({ description: 'Cron schedule expression' }) - @IsString() - @IsOptional() - schedule?: string; - @ApiPropertyOptional({ description: 'Whether automation is enabled' }) @IsBoolean() @IsOptional() isEnabled?: boolean; + + @ApiPropertyOptional({ + enum: TaskFrequency, + description: 'Automation schedule cadence', + }) + @IsEnum(TaskFrequency) + @IsOptional() + scheduleFrequency?: TaskFrequency; } // ===== Response DTOs ===== @@ -186,9 +194,6 @@ export class BrowserAutomationResponseDto { @ApiProperty() isEnabled: boolean; - @ApiPropertyOptional() - schedule?: string; - @ApiProperty() createdAt: Date; diff --git a/apps/api/src/tasks/automations/automations.service.ts b/apps/api/src/tasks/automations/automations.service.ts index 3afedd380f..86a6fd8ee9 100644 --- a/apps/api/src/tasks/automations/automations.service.ts +++ b/apps/api/src/tasks/automations/automations.service.ts @@ -87,12 +87,17 @@ export class AutomationsService { throw new NotFoundException('Automation not found'); } + const { scheduleFrequency, ...rest } = updateAutomationDto; + // Update the automation const automation = await db.evidenceAutomation.update({ where: { id: automationId, }, - data: updateAutomationDto, + data: { + ...rest, + ...(scheduleFrequency !== undefined ? { scheduleFrequency } : {}), + }, }); return { diff --git a/apps/api/src/tasks/automations/dto/update-automation.dto.ts b/apps/api/src/tasks/automations/dto/update-automation.dto.ts index 6c5e87ef47..96ce844067 100644 --- a/apps/api/src/tasks/automations/dto/update-automation.dto.ts +++ b/apps/api/src/tasks/automations/dto/update-automation.dto.ts @@ -1,5 +1,6 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsString, IsOptional, IsBoolean } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional, IsBoolean, IsEnum } from 'class-validator'; +import { TaskFrequency } from '@db'; export class UpdateAutomationDto { @ApiProperty({ @@ -35,4 +36,12 @@ export class UpdateAutomationDto { @IsString() @IsOptional() evaluationCriteria?: string; + + @ApiPropertyOptional({ + enum: TaskFrequency, + description: 'Automation schedule cadence', + }) + @IsEnum(TaskFrequency) + @IsOptional() + scheduleFrequency?: TaskFrequency; } diff --git a/apps/api/src/tasks/dto/swagger.dto.ts b/apps/api/src/tasks/dto/swagger.dto.ts index 19c73df0a5..76b21023ea 100644 --- a/apps/api/src/tasks/dto/swagger.dto.ts +++ b/apps/api/src/tasks/dto/swagger.dto.ts @@ -29,6 +29,14 @@ export class CreateTaskDto { }) frequency?: string; + @ApiProperty({ + description: + 'Cadence for running the integration check attached to this task', + enum: ['daily', 'weekly', 'monthly', 'quarterly', 'yearly'], + required: false, + }) + integrationScheduleFrequency?: string; + @ApiProperty({ description: 'Department assignment', enum: ['none', 'admin', 'gov', 'hr', 'it', 'itsm', 'qms'], @@ -80,6 +88,14 @@ export class UpdateTaskDto { }) frequency?: string; + @ApiProperty({ + description: + 'Cadence for running the integration check attached to this task', + enum: ['daily', 'weekly', 'monthly', 'quarterly', 'yearly'], + required: false, + }) + integrationScheduleFrequency?: string; + @ApiProperty({ description: 'Department assignment', enum: ['none', 'admin', 'gov', 'hr', 'it', 'itsm', 'qms'], @@ -172,6 +188,18 @@ export class TaskResponseDto { }) frequency: string | null; + @ApiProperty({ + description: 'Cadence for running the integration check attached to this task', + enum: ['daily', 'weekly', 'monthly', 'quarterly', 'yearly'], + }) + integrationScheduleFrequency: string; + + @ApiProperty({ + description: 'Last successful integration check run timestamp', + nullable: true, + }) + integrationLastRunAt: Date | null; + @ApiProperty({ description: 'Department assignment', enum: ['none', 'admin', 'gov', 'hr', 'it', 'itsm', 'qms'], diff --git a/apps/api/src/tasks/dto/task-responses.dto.ts b/apps/api/src/tasks/dto/task-responses.dto.ts index 3870fe3075..d80661ccf5 100644 --- a/apps/api/src/tasks/dto/task-responses.dto.ts +++ b/apps/api/src/tasks/dto/task-responses.dto.ts @@ -85,4 +85,18 @@ export class TaskResponseDto { required: false, }) taskTemplateId?: string | null; + + @ApiProperty({ + description: 'Cadence for running the integration check attached to this task', + enum: ['daily', 'weekly', 'monthly', 'quarterly', 'yearly'], + example: 'daily', + }) + integrationScheduleFrequency: string; + + @ApiProperty({ + description: 'Last successful integration check run timestamp', + nullable: true, + required: false, + }) + integrationLastRunAt?: Date | null; } diff --git a/apps/api/src/tasks/schemas/task.schemas.ts b/apps/api/src/tasks/schemas/task.schemas.ts index 5380d90987..907a29ef45 100644 --- a/apps/api/src/tasks/schemas/task.schemas.ts +++ b/apps/api/src/tasks/schemas/task.schemas.ts @@ -30,6 +30,7 @@ export const CreateTaskSchema = z.object({ description: z.string().min(1, 'Description is required'), status: TaskStatusSchema.optional().default('todo'), frequency: TaskFrequencySchema.optional(), + integrationScheduleFrequency: TaskFrequencySchema.optional(), department: DepartmentsSchema.optional().default('none'), order: z.number().int().min(0).optional().default(0), assigneeId: z.string().optional(), diff --git a/apps/api/src/tasks/tasks.controller.ts b/apps/api/src/tasks/tasks.controller.ts index 47984b0a99..61a4ae8e3b 100644 --- a/apps/api/src/tasks/tasks.controller.ts +++ b/apps/api/src/tasks/tasks.controller.ts @@ -712,6 +712,13 @@ export class TasksController { enum: ['daily', 'weekly', 'monthly', 'quarterly', 'yearly'], example: 'monthly', }, + integrationScheduleFrequency: { + type: 'string', + enum: ['daily', 'weekly', 'monthly', 'quarterly', 'yearly'], + example: 'daily', + description: + 'Cadence for running the integration check attached to this task', + }, department: { type: 'string', enum: ['none', 'admin', 'gov', 'hr', 'it', 'itsm', 'qms'], @@ -754,6 +761,7 @@ export class TasksController { assigneeId?: string | null; approverId?: string | null; frequency?: string; + integrationScheduleFrequency?: string; department?: string; reviewDate?: string; }, @@ -789,6 +797,9 @@ export class TasksController { assigneeId: body.assigneeId, approverId: body.approverId, frequency: body.frequency as TaskFrequency | undefined, + integrationScheduleFrequency: body.integrationScheduleFrequency as + | TaskFrequency + | undefined, department: body.department, reviewDate: parsedReviewDate, }, diff --git a/apps/api/src/tasks/tasks.service.ts b/apps/api/src/tasks/tasks.service.ts index cc4fda1350..5a3e57d00c 100644 --- a/apps/api/src/tasks/tasks.service.ts +++ b/apps/api/src/tasks/tasks.service.ts @@ -532,8 +532,8 @@ export class TasksService { organizationId, timelinesService: this.timelinesService, }).catch((err) => { - this.logger.warn('timeline auto-complete check failed', err); - }); + this.logger.warn('timeline auto-complete check failed', err); + }); return { deletedCount: result.count }; } catch (error) { @@ -558,6 +558,7 @@ export class TasksService { assigneeId?: string | null; approverId?: string | null; frequency?: TaskFrequency; + integrationScheduleFrequency?: TaskFrequency; department?: string; reviewDate?: Date | null; }, @@ -592,6 +593,7 @@ export class TasksService { assigneeId?: string | null; approverId?: string | null; frequency?: TaskFrequency; + integrationScheduleFrequency?: TaskFrequency; department?: string; reviewDate?: Date | null; } = {}; @@ -651,6 +653,10 @@ export class TasksService { updateData.frequency, ); } + if (updateData.integrationScheduleFrequency !== undefined) { + dataToUpdate.integrationScheduleFrequency = + updateData.integrationScheduleFrequency; + } if (updateData.department !== undefined) { dataToUpdate.department = updateData.department; } @@ -879,6 +885,8 @@ export class TasksService { createdAt: task.createdAt, updatedAt: task.updatedAt, taskTemplateId: task.taskTemplateId, + integrationScheduleFrequency: task.integrationScheduleFrequency, + integrationLastRunAt: task.integrationLastRunAt, }; } catch (error) { console.error('Error creating task:', error); diff --git a/apps/api/src/trigger/browser-automation/run-browser-automation.ts b/apps/api/src/trigger/browser-automation/run-browser-automation.ts index 5b9bb5a8c6..26b54945a2 100644 --- a/apps/api/src/trigger/browser-automation/run-browser-automation.ts +++ b/apps/api/src/trigger/browser-automation/run-browser-automation.ts @@ -292,6 +292,7 @@ export const runBrowserAutomation = task({ runId: result.runId, error: result.error, needsReauth: result.needsReauth, + evaluationStatus: result.evaluationStatus, }); // Mark task as failed if auth issue @@ -325,6 +326,23 @@ export const runBrowserAutomation = task({ } } + // Record a successful run on the automation so the orchestrator's + // schedule filter (`isDueToday`) can skip it on the next tick. "Executed" + // here means the automation actually ran — including runs whose evaluation + // legitimately returned `fail`. We skip the write when the automation + // genuinely couldn't execute (e.g. `needsReauth` / missing browser context + // / other transient infra errors) so the next orchestrator tick retries + // instead of waiting a full schedule period. + const executed = + result.success === true || result.evaluationStatus === 'fail'; + + if (executed) { + await db.browserAutomation.update({ + where: { id: automationId }, + data: { lastRunAt: new Date() }, + }); + } + return { success: result.success, runId: result.runId, diff --git a/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.spec.ts b/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.spec.ts new file mode 100644 index 0000000000..2ae7fb5a7d --- /dev/null +++ b/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.spec.ts @@ -0,0 +1,118 @@ +import { TaskFrequency } from '@trycompai/db'; +import { filterDueAutomations } from './run-browser-automations-schedule'; + +// Mock @db at the module boundary so importing the orchestrator does not try +// to connect to Postgres. We never call the scheduled `run` function itself +// (it's wrapped in `schedules.task({...})` and not independently invokable), +// we only exercise the pure helper it uses. +jest.mock('@db', () => ({ + db: { + browserAutomation: { findMany: jest.fn(), update: jest.fn() }, + }, + TaskFrequency: { + daily: 'daily', + weekly: 'weekly', + monthly: 'monthly', + quarterly: 'quarterly', + yearly: 'yearly', + }, +})); + +jest.mock('@trigger.dev/sdk', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, + schedules: { + task: (config: unknown) => config, + }, +})); + +jest.mock('./run-browser-automation', () => ({ + runBrowserAutomation: { batchTrigger: jest.fn() }, +})); + +const atUtc = (iso: string) => new Date(`${iso}T00:00:00.000Z`); + +describe('filterDueAutomations (browser automation orchestrator)', () => { + const now = atUtc('2026-04-24'); + + it('returns only automations whose schedule says they are due', () => { + const candidateAutomations = [ + // Daily → always due + { + id: 'ba_daily', + name: 'Daily login check', + taskId: 'tsk_a', + scheduleFrequency: TaskFrequency.daily, + lastRunAt: atUtc('2026-04-23'), + }, + // Weekly, ran 3 days ago → NOT due + { + id: 'ba_weekly_recent', + name: 'Recent weekly', + taskId: 'tsk_b', + scheduleFrequency: TaskFrequency.weekly, + lastRunAt: atUtc('2026-04-21'), + }, + // Weekly, ran 10 days ago → due + { + id: 'ba_weekly_stale', + name: 'Stale weekly', + taskId: 'tsk_c', + scheduleFrequency: TaskFrequency.weekly, + lastRunAt: atUtc('2026-04-14'), + }, + ]; + + const due = filterDueAutomations({ + automations: candidateAutomations, + now, + }); + + expect(due.map((a) => a.id)).toEqual(['ba_daily', 'ba_weekly_stale']); + }); + + it('treats a null lastRunAt as due (first run)', () => { + const candidateAutomations = [ + { + id: 'ba_never_run', + name: 'Never run', + taskId: 'tsk_a', + scheduleFrequency: TaskFrequency.weekly, + lastRunAt: null as Date | null, + }, + ]; + + const due = filterDueAutomations({ + automations: candidateAutomations, + now, + }); + + expect(due).toHaveLength(1); + expect(due[0]?.id).toBe('ba_never_run'); + }); + + it('returns an empty array when nothing is due', () => { + const candidateAutomations = [ + { + id: 'ba_monthly_recent', + name: 'Recent monthly', + taskId: 'tsk_a', + scheduleFrequency: TaskFrequency.monthly, + lastRunAt: atUtc('2026-04-10'), + }, + { + id: 'ba_quarterly_recent', + name: 'Recent quarterly', + taskId: 'tsk_b', + scheduleFrequency: TaskFrequency.quarterly, + lastRunAt: atUtc('2026-03-01'), + }, + ]; + + const due = filterDueAutomations({ + automations: candidateAutomations, + now, + }); + + expect(due).toEqual([]); + }); +}); diff --git a/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.ts b/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.ts index 429a6260ab..dedd4b5660 100644 --- a/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.ts +++ b/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.ts @@ -1,6 +1,29 @@ -import { db } from '@db'; +import { db, TaskFrequency } from '@db'; import { logger, schedules } from '@trigger.dev/sdk'; import { runBrowserAutomation } from './run-browser-automation'; +import { isDueToday } from '../shared/is-due-today'; + +/** + * Pure helper extracted for unit testing. Filters a list of candidate + * automations down to those whose schedule says they are due at `now`. + * + * Kept in-memory (Shape A from the plan) because the single source of truth + * for schedule math is `isDueToday`; duplicating it in SQL would create drift. + */ +export function filterDueAutomations< + T extends { + scheduleFrequency: TaskFrequency; + lastRunAt: Date | null; + }, +>({ automations, now }: { automations: T[]; now: Date }): T[] { + return automations.filter((a) => + isDueToday({ + scheduleFrequency: a.scheduleFrequency, + lastRunAt: a.lastRunAt, + now, + }), + ); +} /** * Daily scheduled task (orchestrator) that finds all enabled browser automations @@ -16,10 +39,17 @@ export const browserAutomationsSchedule = schedules.task({ lastRun: payload.lastTimestamp, }); + const now = new Date(); + // Find all enabled browser automations - const automations = await db.browserAutomation.findMany({ + const candidateAutomations = await db.browserAutomation.findMany({ where: { isEnabled: true }, - include: { + select: { + id: true, + name: true, + taskId: true, + scheduleFrequency: true, + lastRunAt: true, task: { select: { id: true, @@ -30,12 +60,34 @@ export const browserAutomationsSchedule = schedules.task({ }, }); - if (automations.length === 0) { + if (candidateAutomations.length === 0) { logger.info('No enabled browser automations found'); return { success: true, automationsTriggered: 0 }; } - logger.info(`Found ${automations.length} enabled browser automations`); + logger.info( + `Found ${candidateAutomations.length} enabled browser automations`, + ); + + // Filter by the automation's schedule. `lastRunAt` is only written when + // the automation actually executed (including legitimate 'fail' verdicts) + // inside `runBrowserAutomation`, so infra-level failures naturally retry + // on the next orchestrator tick (the "crude retry" behavior). + const automations = filterDueAutomations({ + automations: candidateAutomations, + now, + }); + + if (automations.length < candidateAutomations.length) { + logger.info( + `Skipped ${candidateAutomations.length - automations.length} automation(s) not due yet`, + ); + } + + if (automations.length === 0) { + logger.info('No browser automations due today'); + return { success: true, automationsTriggered: 0 }; + } // Build payloads for batch triggering const triggerPayloads = automations.map((automation) => ({ diff --git a/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.spec.ts b/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.spec.ts new file mode 100644 index 0000000000..9343a609c9 --- /dev/null +++ b/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.spec.ts @@ -0,0 +1,117 @@ +import { TaskFrequency } from '@trycompai/db'; +import { filterDueTasks } from './run-integration-checks-schedule'; + +// Mock @db at the module boundary so importing the orchestrator does not try +// to connect to Postgres. We never call the scheduled `run` function itself +// (it's wrapped in `schedules.task({...})` and not independently invokable), +// we only exercise the pure helper it uses. +jest.mock('@db', () => ({ + db: { + integrationConnection: { findMany: jest.fn() }, + task: { findMany: jest.fn(), update: jest.fn() }, + }, + TaskFrequency: { + daily: 'daily', + weekly: 'weekly', + monthly: 'monthly', + quarterly: 'quarterly', + yearly: 'yearly', + }, +})); + +jest.mock('@trycompai/integration-platform', () => ({ + getManifest: jest.fn(), +})); + +jest.mock('@trigger.dev/sdk', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, + schedules: { + task: (config: unknown) => config, + }, +})); + +jest.mock('./run-task-integration-checks', () => ({ + runTaskIntegrationChecks: { batchTrigger: jest.fn() }, +})); + +const atUtc = (iso: string) => new Date(`${iso}T00:00:00.000Z`); + +describe('filterDueTasks (integration orchestrator)', () => { + const now = atUtc('2026-04-24'); + + it('returns only tasks whose schedule says they are due', () => { + const candidateTasks = [ + // Daily → always due + { + id: 'tsk_daily', + title: 'Daily check', + taskTemplateId: 'tpl_a', + integrationScheduleFrequency: TaskFrequency.daily, + integrationLastRunAt: atUtc('2026-04-23'), + }, + // Weekly, ran 2 days ago → NOT due + { + id: 'tsk_weekly_recent', + title: 'Recent weekly', + taskTemplateId: 'tpl_b', + integrationScheduleFrequency: TaskFrequency.weekly, + integrationLastRunAt: atUtc('2026-04-22'), + }, + // Weekly, ran 10 days ago → due + { + id: 'tsk_weekly_stale', + title: 'Stale weekly', + taskTemplateId: 'tpl_c', + integrationScheduleFrequency: TaskFrequency.weekly, + integrationLastRunAt: atUtc('2026-04-14'), + }, + ]; + + const dueTasks = filterDueTasks({ tasks: candidateTasks, now }); + + expect(dueTasks.map((t) => t.id)).toEqual([ + 'tsk_daily', + 'tsk_weekly_stale', + ]); + }); + + it('treats a null integrationLastRunAt as due (first run)', () => { + const candidateTasks = [ + { + id: 'tsk_never_run', + title: 'Never run', + taskTemplateId: 'tpl_a', + integrationScheduleFrequency: TaskFrequency.yearly, + integrationLastRunAt: null as Date | null, + }, + ]; + + const dueTasks = filterDueTasks({ tasks: candidateTasks, now }); + + expect(dueTasks).toHaveLength(1); + expect(dueTasks[0]?.id).toBe('tsk_never_run'); + }); + + it('returns an empty array when nothing is due', () => { + const candidateTasks = [ + { + id: 'tsk_monthly_recent', + title: 'Recent monthly', + taskTemplateId: 'tpl_a', + integrationScheduleFrequency: TaskFrequency.monthly, + integrationLastRunAt: atUtc('2026-04-10'), + }, + { + id: 'tsk_quarterly_recent', + title: 'Recent quarterly', + taskTemplateId: 'tpl_b', + integrationScheduleFrequency: TaskFrequency.quarterly, + integrationLastRunAt: atUtc('2026-03-01'), + }, + ]; + + const dueTasks = filterDueTasks({ tasks: candidateTasks, now }); + + expect(dueTasks).toEqual([]); + }); +}); diff --git a/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.ts b/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.ts index 0a80d22fa6..600e03ef94 100644 --- a/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.ts +++ b/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.ts @@ -1,8 +1,31 @@ import { getManifest } from '@trycompai/integration-platform'; -import { db } from '@db'; +import { db, TaskFrequency } from '@db'; import { logger, schedules } from '@trigger.dev/sdk'; import { runTaskIntegrationChecks } from './run-task-integration-checks'; import { parseDisabledTaskChecks } from '../../integration-platform/utils/disabled-task-checks'; +import { isDueToday } from '../shared/is-due-today'; + +/** + * Pure helper extracted for unit testing. Filters a list of candidate tasks + * down to those whose schedule says they are due at `now`. + * + * Kept in-memory (Shape A from the plan) because the single source of truth + * for schedule math is `isDueToday`; duplicating it in SQL would create drift. + */ +export function filterDueTasks< + T extends { + integrationScheduleFrequency: TaskFrequency; + integrationLastRunAt: Date | null; + }, +>({ tasks, now }: { tasks: T[]; now: Date }): T[] { + return tasks.filter((t) => + isDueToday({ + scheduleFrequency: t.integrationScheduleFrequency, + lastRunAt: t.integrationLastRunAt, + now, + }), + ); +} /** * Daily scheduled task (orchestrator) that finds all tasks with integration checks @@ -18,6 +41,8 @@ export const integrationChecksSchedule = schedules.task({ lastRun: payload.lastTimestamp, }); + const now = new Date(); + // Get all active integration connections const activeConnections = await db.integrationConnection.findMany({ where: { status: 'active' }, @@ -63,7 +88,7 @@ export const integrationChecksSchedule = schedules.task({ } // Find tasks in this org that match these templates - const tasks = await db.task.findMany({ + const candidateTasks = await db.task.findMany({ where: { organizationId: connection.organizationId, taskTemplateId: { in: taskTemplateIds as string[] }, @@ -72,9 +97,22 @@ export const integrationChecksSchedule = schedules.task({ id: true, title: true, taskTemplateId: true, + integrationScheduleFrequency: true, + integrationLastRunAt: true, }, }); + // Filter by the task's integration schedule. `lastRunAt` is only written + // on success inside `runTaskIntegrationChecks`, so failures naturally + // retry on the next orchestrator tick (the "crude retry" behavior). + const tasks = filterDueTasks({ tasks: candidateTasks, now }); + + if (tasks.length < candidateTasks.length) { + logger.info( + `Skipped ${candidateTasks.length - tasks.length} task(s) not due yet for connection ${connection.id}`, + ); + } + // Per-task disabled checks are stored on the connection's metadata so // users can disconnect individual checks from individual tasks without // tearing down the whole integration. Resolve once per connection. diff --git a/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts b/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts index 57aef35305..e2c4b404a5 100644 --- a/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts +++ b/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts @@ -319,6 +319,7 @@ export const runTaskIntegrationChecks = task({ let totalFindings = 0; let totalPassing = 0; let hasFailedChecks = false; + let hasExecutionErrors = false; // Run only the checks that apply to this task try { @@ -347,6 +348,9 @@ export const runTaskIntegrationChecks = task({ if (checkResult.status === 'failed' || checkResult.status === 'error') { hasFailedChecks = true; } + if (checkResult.status === 'error') { + hasExecutionErrors = true; + } // Store check run const checkRun = await db.integrationCheckRun.create({ @@ -415,6 +419,19 @@ export const runTaskIntegrationChecks = task({ data: { lastSyncAt: new Date() }, }); + // Record a successful run on the task so the orchestrator's schedule + // filter (`isDueToday`) can skip it on the next tick. "Successful" here + // means every check executed — including checks that legitimately found + // violations (`status: 'failed'`). We skip the write only when a check + // couldn't execute (`status: 'error'`, e.g. transient provider error), + // so the next orchestrator tick retries instead of waiting a full period. + if (!hasExecutionErrors) { + await db.task.update({ + where: { id: taskId }, + data: { integrationLastRunAt: new Date() }, + }); + } + // Update task status based on check results // If any findings or check failures, mark as failed // If all checks pass with no findings, mark as done (only if not already done) diff --git a/apps/api/src/trigger/shared/is-due-today.spec.ts b/apps/api/src/trigger/shared/is-due-today.spec.ts new file mode 100644 index 0000000000..3920031e2c --- /dev/null +++ b/apps/api/src/trigger/shared/is-due-today.spec.ts @@ -0,0 +1,141 @@ +import { TaskFrequency } from '@trycompai/db'; +import { isDueToday } from './is-due-today'; + +const atUtc = (iso: string) => new Date(`${iso}T00:00:00.000Z`); + +describe('isDueToday', () => { + const now = atUtc('2026-04-24'); + + describe('daily', () => { + it('returns true when lastRunAt is null', () => { + expect( + isDueToday({ + scheduleFrequency: TaskFrequency.daily, + lastRunAt: null, + now, + }), + ).toBe(true); + }); + it('returns true even when it ran today', () => { + expect( + isDueToday({ + scheduleFrequency: TaskFrequency.daily, + lastRunAt: atUtc('2026-04-24'), + now, + }), + ).toBe(true); + }); + }); + + describe('weekly', () => { + it('returns true when lastRunAt is null', () => { + expect( + isDueToday({ + scheduleFrequency: TaskFrequency.weekly, + lastRunAt: null, + now, + }), + ).toBe(true); + }); + it('returns false when lastRunAt is 6 days ago', () => { + expect( + isDueToday({ + scheduleFrequency: TaskFrequency.weekly, + lastRunAt: atUtc('2026-04-18'), + now, + }), + ).toBe(false); + }); + it('returns true when lastRunAt is exactly 7 days ago', () => { + expect( + isDueToday({ + scheduleFrequency: TaskFrequency.weekly, + lastRunAt: atUtc('2026-04-17'), + now, + }), + ).toBe(true); + }); + it('returns true when lastRunAt is 14 days ago', () => { + expect( + isDueToday({ + scheduleFrequency: TaskFrequency.weekly, + lastRunAt: atUtc('2026-04-10'), + now, + }), + ).toBe(true); + }); + }); + + describe('monthly', () => { + it('returns true when lastRunAt crossed a calendar-month boundary', () => { + // 2026-03-26 → 2026-04-24: different calendar month → due + expect( + isDueToday({ + scheduleFrequency: TaskFrequency.monthly, + lastRunAt: atUtc('2026-03-26'), + now, + }), + ).toBe(true); + }); + it('returns false when lastRunAt is same calendar month', () => { + expect( + isDueToday({ + scheduleFrequency: TaskFrequency.monthly, + lastRunAt: atUtc('2026-04-01'), + now, + }), + ).toBe(false); + }); + it('returns true when lastRunAt is null', () => { + expect( + isDueToday({ + scheduleFrequency: TaskFrequency.monthly, + lastRunAt: null, + now, + }), + ).toBe(true); + }); + }); + + describe('quarterly', () => { + it('returns false when lastRunAt is 2 months ago', () => { + expect( + isDueToday({ + scheduleFrequency: TaskFrequency.quarterly, + lastRunAt: atUtc('2026-02-24'), + now, + }), + ).toBe(false); + }); + it('returns true when lastRunAt is 3 months ago', () => { + expect( + isDueToday({ + scheduleFrequency: TaskFrequency.quarterly, + lastRunAt: atUtc('2026-01-24'), + now, + }), + ).toBe(true); + }); + }); + + describe('yearly', () => { + it('returns false when lastRunAt is 11 months ago', () => { + expect( + isDueToday({ + scheduleFrequency: TaskFrequency.yearly, + lastRunAt: atUtc('2025-05-24'), + now, + }), + ).toBe(false); + }); + it('returns true when lastRunAt is 12 months ago', () => { + expect( + isDueToday({ + scheduleFrequency: TaskFrequency.yearly, + lastRunAt: atUtc('2025-04-24'), + now, + }), + ).toBe(true); + }); + }); +}); diff --git a/apps/api/src/trigger/shared/is-due-today.ts b/apps/api/src/trigger/shared/is-due-today.ts new file mode 100644 index 0000000000..c38008311d --- /dev/null +++ b/apps/api/src/trigger/shared/is-due-today.ts @@ -0,0 +1,51 @@ +import { TaskFrequency } from '@trycompai/db'; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +function calendarMonthsBetween(earlier: Date, later: Date): number { + const years = later.getUTCFullYear() - earlier.getUTCFullYear(); + const months = later.getUTCMonth() - earlier.getUTCMonth(); + return years * 12 + months; +} + +/** + * Returns whether an automation with the given schedule is due to run at `now`. + * + * `now` and `lastRunAt` are treated as UTC instants — weekly math uses fixed + * 86_400_000-ms days, monthly/quarterly/yearly use UTC calendar buckets. Callers + * should pass real `Date` values (any instant works); do NOT pass "midnight in + * local time" expecting DST-aware behavior. + * + * `null` lastRunAt always returns `true` (first run). + */ +export function isDueToday({ + scheduleFrequency, + lastRunAt, + now, +}: { + scheduleFrequency: TaskFrequency; + lastRunAt: Date | null; + now: Date; +}): boolean { + if (scheduleFrequency === TaskFrequency.daily) return true; + if (lastRunAt === null) return true; + + switch (scheduleFrequency) { + case TaskFrequency.weekly: { + const days = Math.floor( + (now.getTime() - lastRunAt.getTime()) / MS_PER_DAY, + ); + return days >= 7; + } + case TaskFrequency.monthly: + return calendarMonthsBetween(lastRunAt, now) >= 1; + case TaskFrequency.quarterly: + return calendarMonthsBetween(lastRunAt, now) >= 3; + case TaskFrequency.yearly: + return calendarMonthsBetween(lastRunAt, now) >= 12; + default: { + const _exhaustive: never = scheduleFrequency; + throw new Error(`Unhandled TaskFrequency: ${String(_exhaustive)}`); + } + } +} diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/hooks/use-task-automation.ts b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/hooks/use-task-automation.ts index bf4bf7b43e..45bb3d6958 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/hooks/use-task-automation.ts +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/hooks/use-task-automation.ts @@ -1,4 +1,5 @@ import { api } from '@/lib/api-client'; +import type { TaskFrequency } from '@db'; import { useParams } from 'next/navigation'; import useSWR from 'swr'; @@ -13,15 +14,21 @@ interface TaskAutomationData { updatedAt: string; evaluationCriteria?: string; isEnabled: boolean; + scheduleFrequency?: TaskFrequency; + lastRunAt?: string | null; } +type UpdateAutomationPayload = Partial< + Pick +>; + interface UseTaskAutomationReturn { automation: TaskAutomationData | undefined; isLoading: boolean; isError: boolean; error: Error | undefined; mutate: () => Promise; - updateAutomation: (body: Partial>) => Promise; + updateAutomation: (body: UpdateAutomationPayload) => Promise; deleteAutomation: () => Promise; } @@ -71,9 +78,7 @@ export function useTaskAutomation(overrideAutomationId?: string): UseTaskAutomat }, ); - const updateAutomation = async ( - body: Partial>, - ) => { + const updateAutomation = async (body: UpdateAutomationPayload) => { const realId = data?.id || automationId; const response = await api.patch( `/v1/tasks/${taskId}/automations/${realId}`, diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/AutomationOverview.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/AutomationOverview.tsx index 7802a16024..329135a712 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/AutomationOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/AutomationOverview.tsx @@ -3,7 +3,13 @@ import { RecentAuditLogs } from '@/components/RecentAuditLogs'; import { useAuditLogs } from '@/hooks/use-audit-logs'; import { Button } from '@trycompai/ui/button'; -import type { EvidenceAutomation, EvidenceAutomationRun, EvidenceAutomationVersion, Task } from '@db'; +import type { + EvidenceAutomation, + EvidenceAutomationRun, + EvidenceAutomationVersion, + Task, + TaskFrequency, +} from '@db'; import { Breadcrumb, Button as DSButton, @@ -28,6 +34,7 @@ import { toggleAutomationEnabled, } from '../../../../automation/[automationId]/actions/task-automation-actions'; import { DeleteAutomationDialog } from '../../../../automation/[automationId]/components/AutomationSettingsDialogs'; +import { SchedulePicker } from '@/components/schedule-picker'; import { useTaskAutomation } from '../../../../automation/[automationId]/hooks/use-task-automation'; import { AutomationRunsCard } from '../../../../components/AutomationRunsCard'; import { useAutomationRuns } from '../hooks/use-automation-runs'; @@ -57,6 +64,7 @@ export function AutomationOverview({ }>(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [isUpdatingSchedule, setIsUpdatingSchedule] = useState(false); const [isTogglingEnabled, setIsTogglingEnabled] = useState(false); const [isEditingName, setIsEditingName] = useState(false); const [nameValue, setNameValue] = useState(''); @@ -136,6 +144,19 @@ export function AutomationOverview({ } }; + const handleScheduleChange = async (value: TaskFrequency) => { + setIsUpdatingSchedule(true); + try { + await updateAutomation({ scheduleFrequency: value }); + toast.success('Schedule updated'); + await mutateAutomation(); + } catch { + toast.error('Failed to update schedule'); + } finally { + setIsUpdatingSchedule(false); + } + }; + const handleTestVersion = async () => { if (!selectedVersion) return; setIsTestingVersion(true); @@ -272,6 +293,8 @@ export function AutomationOverview({ @@ -372,6 +395,24 @@ export function AutomationOverview({
+ + + Schedule + + How often this automation runs + + +
+ +
+
+ +
+ Delete Automation @@ -400,6 +441,7 @@ export function AutomationOverview({ onOpenChange={setDeleteDialogOpen} onSuccess={mutateAutomation} /> + ); } diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/MetricsSection.test.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/MetricsSection.test.tsx index 805db6a275..578927f1b3 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/MetricsSection.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/MetricsSection.test.tsx @@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { MetricsSection } from './MetricsSection'; -describe('MetricsSection (CS-97)', () => { +describe('MetricsSection (SALE-49)', () => { beforeEach(() => { // shouldAdvanceTime lets React effects flush on their normal tick // while still letting us pin `new Date()` with vi.setSystemTime. @@ -13,88 +13,114 @@ describe('MetricsSection (CS-97)', () => { vi.useRealTimers(); }); - it('labels the schedule as 9:00 AM UTC (no ambiguous local time)', () => { - vi.setSystemTime(new Date('2026-04-16T07:00:00Z')); - render(); - expect(screen.getByText('Every day at 9:00 AM UTC')).toBeInTheDocument(); - }); - - it('uses an SSR-safe placeholder for the next run (defers date formatting to post-mount)', () => { - // Verify the initial JSX does NOT synchronously format a Date — that's - // the property that keeps SSR and hydration outputs identical. We - // simulate "server-side" rendering with renderToString and assert the - // Next Run cell specifically contains the em-dash placeholder rather - // than a formatted weekday/time. We scope the assertion to the Next Run - // card because Success Rate also renders `—` when there are no runs. + it('uses an SSR-safe placeholder for schedule + next run (defers date formatting to post-mount)', () => { const { renderToString } = require('react-dom/server') as typeof import('react-dom/server'); vi.setSystemTime(new Date('2026-04-16T07:00:00Z')); const html = renderToString( - , + , ); - // No weekday should appear anywhere in SSR output. + // No weekday and no UTC literal in SSR output — defers locale-dependent + // formatting to the client-only effect. expect(html).not.toMatch(/Mon|Tue|Wed|Thu|Fri|Sat|Sun/); + expect(html).not.toMatch(/UTC/); - // Locate the Next Run cell and assert its value paragraph contains `—`. - // Matches:

Next Run

- const nextRunCellMatch = html.match( - /Next Run[^<]*<\/p>\s*]*>([^<]*)<\/p>/, - ); - expect(nextRunCellMatch).not.toBeNull(); - expect(nextRunCellMatch?.[1]).toBe('—'); + // Both Schedule and Next Run cells should show the em-dash placeholder. + const scheduleCell = html.match(/Schedule[^<]*<\/p>\s*]*>([^<]*)<\/p>/); + const nextRunCell = html.match(/Next Run[^<]*<\/p>\s*]*>([^<]*)<\/p>/); + expect(scheduleCell?.[1]).toBe('—'); + expect(nextRunCell?.[1]).toBe('—'); }); - it('fills in the next-run label after mount with a concrete weekday, time, and timezone', async () => { + it('labels the daily schedule with the user\'s timezone (not UTC)', async () => { vi.setSystemTime(new Date('2026-04-16T07:00:00Z')); - render(); + render( + , + ); - // Old hardcoded literals must never appear. - expect(screen.queryByText('Every Day 9:00 AM')).not.toBeInTheDocument(); - expect(screen.queryByText('Tomorrow 9:00 AM')).not.toBeInTheDocument(); + // After mount: "Every day at