Skip to content

Commit 32b210e

Browse files
github-actions[bot]Marfuenclaude
authored
feat: add ability to set frequency on automations running
* feat(api): add isDueToday scheduler helper * test(api): use .spec.ts suffix and fix misleading test title Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(api): harden isDueToday exhaustive check and document UTC contract Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(db): add per-automation scheduleFrequency and lastRunAt * refactor(api): remove stale schedule field from browserbase DTOs and update service The BrowserAutomation.schedule column was dropped in the previous commit. TypeScript did not catch the dangling DTO entries or updateBrowserAutomation param because object spread does not trigger excess-property checks, but Prisma would throw "Unknown argument" at runtime if a caller supplied the field. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(api): respect integrationScheduleFrequency in integration orchestrator * fix(api): retry integration task on transient execution error Previously, integrationLastRunAt was written whenever the check loop completed, even if one or more checks returned status='error' (meaning the check could not execute). On weekly+ schedules this would push the retry out a full period. Distinguish 'error' (infra issue, retry) from 'failed' (legitimate finding, ran successfully). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(api): respect scheduleFrequency in browser automation orchestrator * docs(api): clarify browser orchestrator retry comment Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(api): accept scheduleFrequency in automation and task PATCH endpoints * feat(app): add SchedulePicker component * feat(app): add schedule picker to browser automation config * feat(app): add schedule edit dialog for evidence automations * feat(app): add integration schedule picker on task detail * feat(app): show schedule summary on automation cards * fix(app,api): repair test fixtures and guard missing schedule frequency Updates test fixtures for Task schema additions (integrationScheduleFrequency, integrationLastRunAt, previousStatus, archivedAt, lastCompletedAt), adds TaskFrequency to the @db mock in browserbase.controller.spec so the new @IsEnum(TaskFrequency) DTO decorator resolves at module load, and fixes an undefined-index access in AutomationOverview when the scheduleFrequency field is missing on stale client types. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(api): apply prettier to SALE-49 files Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address cubic review findings on schedule UI and DTOs - TaskIntegrationChecks: compute "Next run" from scheduleFrequency + lastRunAt rather than hardcoding daily 6 AM UTC (was misleading for non-daily schedules). - Task response DTOs (swagger.dto.ts + task-responses.dto.ts): expose integrationScheduleFrequency and integrationLastRunAt so API consumers see them in generated documentation. - ScheduleSummary: render locale-agnostic YYYY-MM-DD and anchor "now" to UTC midnight to prevent server/client hydration mismatches. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(app): daily period is 1 day in TaskIntegrationChecks next-run calc daily: 0 caused the "next run" display to always skip to tomorrow even when the task was due today. Align with ScheduleSummary's PERIOD_DAYS map. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(api): include integrationScheduleFrequency in createTask response The TaskResponseDto now declares integrationScheduleFrequency as a required field; the createTask service method must populate it from the persisted row to satisfy the contract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(app): inline schedule picker on automation overview, render label not enum Replaces EditScheduleDialog with an inline SchedulePicker on the automation Settings tab — fewer clicks, no Dialog focus-trap quirks when interacting with the DS Select. Also forces SelectValue to render the human label (e.g. "Daily") rather than falling back to the raw enum string when re-opened. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(app): metrics schedule + next-run reflect frequency in user's TZ - MetricsSection now takes scheduleFrequency + lastRunAt and re-derives the Schedule and Next Run labels per change. - "Every day at 9:00 AM UTC" → "Every day at 4:00 AM EST" (or whatever the user's locale resolves), and the same TZ is shown on Next Run for consistency. - Both labels are computed client-only to avoid SSR/CSR drift across timezones; placeholder em-dash during SSR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(app): use @trycompai/ui Select in SchedulePicker The DS Select uses base-ui internals whose portaled popover fights with the Radix-based Dialog focus trap (closes immediately on click in BrowserAutomationConfigDialog). @trycompai/ui Select is Radix-based like the Dialog, so the two co-operate. Same API shape so callers don't change. Test mock updated accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(app): show next-run as next tick (not now + period) when never run For null lastRunAt the orchestrator's isDueToday short-circuits to true, so the automation runs on the very next 09:00 UTC tick. The previous math added a full period to "now", over-projecting a never-run weekly automation a full week into the future. Apply the same fix to ScheduleSummary on automation cards. Also tighten the MetricsSection test's UTC-absence assertion to use a word-boundary regex so it can't pass on a label that legitimately contains "UTC". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Mariano <marfuen98@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8c37800 commit 32b210e

59 files changed

Lines changed: 2048 additions & 779 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/api/src/browserbase/browserbase.controller.spec.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ jest.mock('@db', () => ({
1010
}
1111
},
1212
},
13+
TaskFrequency: {
14+
daily: 'daily',
15+
weekly: 'weekly',
16+
monthly: 'monthly',
17+
quarterly: 'quarterly',
18+
yearly: 'yearly',
19+
},
1320
}));
1421

1522
jest.mock('../auth/auth.server', () => ({
@@ -35,7 +42,9 @@ import { PermissionGuard } from '../auth/permission.guard';
3542

3643
describe('BrowserbaseController.redirectToScreenshot', () => {
3744
let controller: BrowserbaseController;
38-
let service: jest.Mocked<Pick<BrowserbaseService, 'getScreenshotRedirectUrl'>>;
45+
let service: jest.Mocked<
46+
Pick<BrowserbaseService, 'getScreenshotRedirectUrl'>
47+
>;
3948

4049
beforeEach(async () => {
4150
service = {
@@ -73,7 +82,10 @@ describe('BrowserbaseController.redirectToScreenshot', () => {
7382
organizationId: 'org_1',
7483
download: false,
7584
});
76-
expect(res.redirect).toHaveBeenCalledWith(302, 'https://s3.example.com/fresh-signed');
85+
expect(res.redirect).toHaveBeenCalledWith(
86+
302,
87+
'https://s3.example.com/fresh-signed',
88+
);
7789
});
7890

7991
it('passes download=true to the service when the query param is "true"', async () => {

apps/api/src/browserbase/browserbase.service.spec.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,17 @@ jest.mock('@db', () => ({
88
browserAutomationRun: {
99
findUnique: jest.fn(),
1010
},
11+
browserAutomation: {
12+
create: jest.fn(),
13+
update: jest.fn(),
14+
},
15+
},
16+
TaskFrequency: {
17+
daily: 'daily',
18+
weekly: 'weekly',
19+
monthly: 'monthly',
20+
quarterly: 'quarterly',
21+
yearly: 'yearly',
1122
},
1223
}));
1324

@@ -17,7 +28,7 @@ jest.mock('@/app/s3', () => ({
1728
BUCKET_NAME: 'test-bucket',
1829
}));
1930

20-
import { db } from '@db';
31+
import { db, TaskFrequency } from '@db';
2132
import { getSignedUrl } from '@/app/s3';
2233

2334
describe('BrowserbaseService.getScreenshotRedirectUrl', () => {
@@ -126,3 +137,79 @@ describe('BrowserbaseService.getScreenshotRedirectUrl', () => {
126137
);
127138
});
128139
});
140+
141+
describe('BrowserbaseService schedule frequency passthrough', () => {
142+
let service: BrowserbaseService;
143+
144+
beforeEach(async () => {
145+
jest.clearAllMocks();
146+
const moduleRef = await Test.createTestingModule({
147+
providers: [BrowserbaseService],
148+
}).compile();
149+
service = moduleRef.get(BrowserbaseService);
150+
});
151+
152+
it('forwards scheduleFrequency when creating a browser automation', async () => {
153+
(db.browserAutomation.create as jest.Mock).mockResolvedValue({
154+
id: 'bau_1',
155+
});
156+
157+
await service.createBrowserAutomation({
158+
taskId: 'tsk_1',
159+
name: 'name',
160+
targetUrl: 'https://example.com',
161+
instruction: 'click',
162+
scheduleFrequency: TaskFrequency.weekly,
163+
});
164+
165+
expect(db.browserAutomation.create).toHaveBeenCalledWith(
166+
expect.objectContaining({
167+
data: expect.objectContaining({ scheduleFrequency: 'weekly' }),
168+
}),
169+
);
170+
});
171+
172+
it('omits scheduleFrequency when creating without the field', async () => {
173+
(db.browserAutomation.create as jest.Mock).mockResolvedValue({
174+
id: 'bau_1',
175+
});
176+
177+
await service.createBrowserAutomation({
178+
taskId: 'tsk_1',
179+
name: 'name',
180+
targetUrl: 'https://example.com',
181+
instruction: 'click',
182+
});
183+
184+
const call = (db.browserAutomation.create as jest.Mock).mock.calls[0][0];
185+
expect(call.data).not.toHaveProperty('scheduleFrequency');
186+
});
187+
188+
it('forwards scheduleFrequency when updating a browser automation', async () => {
189+
(db.browserAutomation.update as jest.Mock).mockResolvedValue({
190+
id: 'bau_1',
191+
});
192+
193+
await service.updateBrowserAutomation('bau_1', {
194+
scheduleFrequency: TaskFrequency.monthly,
195+
});
196+
197+
expect(db.browserAutomation.update).toHaveBeenCalledWith(
198+
expect.objectContaining({
199+
where: { id: 'bau_1' },
200+
data: expect.objectContaining({ scheduleFrequency: 'monthly' }),
201+
}),
202+
);
203+
});
204+
205+
it('omits scheduleFrequency when updating without the field', async () => {
206+
(db.browserAutomation.update as jest.Mock).mockResolvedValue({
207+
id: 'bau_1',
208+
});
209+
210+
await service.updateBrowserAutomation('bau_1', { name: 'renamed' });
211+
212+
const call = (db.browserAutomation.update as jest.Mock).mock.calls[0][0];
213+
expect(call.data).not.toHaveProperty('scheduleFrequency');
214+
});
215+
});

apps/api/src/browserbase/browserbase.service.ts

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@ import Browserbase from '@browserbasehq/sdk';
33
// Lazy-imported in createStagehand() to avoid Node v25 crash
44
// (SlowBuffer.prototype was removed — @browserbasehq/stagehand bundles buffer-equal-constant-time which uses it)
55
type Stagehand = import('@browserbasehq/stagehand').Stagehand;
6-
import { db } from '@db';
6+
import { db, TaskFrequency } from '@db';
77
import { z } from 'zod';
8-
import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
8+
import {
9+
GetObjectCommand,
10+
PutObjectCommand,
11+
S3Client,
12+
} from '@aws-sdk/client-s3';
913
import { BUCKET_NAME, getSignedUrl, s3Client } from '@/app/s3';
1014
import { renderOverlay } from './screenshot-overlay';
1115
import { isNoPageError, toRunErrorMessage } from './run-error-formatter';
@@ -353,7 +357,7 @@ export class BrowserbaseService {
353357
targetUrl: string;
354358
instruction: string;
355359
evaluationCriteria?: string;
356-
schedule?: string;
360+
scheduleFrequency?: TaskFrequency;
357361
}) {
358362
return db.browserAutomation.create({
359363
data: {
@@ -363,8 +367,10 @@ export class BrowserbaseService {
363367
targetUrl: data.targetUrl,
364368
instruction: data.instruction,
365369
evaluationCriteria: normalizeCriteria(data.evaluationCriteria),
366-
schedule: data.schedule,
367370
isEnabled: true, // Enable by default so scheduled runs work
371+
...(data.scheduleFrequency !== undefined
372+
? { scheduleFrequency: data.scheduleFrequency }
373+
: {}),
368374
},
369375
});
370376
}
@@ -402,18 +408,19 @@ export class BrowserbaseService {
402408
targetUrl?: string;
403409
instruction?: string;
404410
evaluationCriteria?: string;
405-
schedule?: string;
406411
isEnabled?: boolean;
412+
scheduleFrequency?: TaskFrequency;
407413
},
408414
) {
409-
const { evaluationCriteria, ...rest } = data;
415+
const { evaluationCriteria, scheduleFrequency, ...rest } = data;
410416
return db.browserAutomation.update({
411417
where: { id: automationId },
412418
data: {
413419
...rest,
414420
...(evaluationCriteria !== undefined
415421
? { evaluationCriteria: normalizeCriteria(evaluationCriteria) }
416422
: {}),
423+
...(scheduleFrequency !== undefined ? { scheduleFrequency } : {}),
417424
},
418425
});
419426
}
@@ -848,10 +855,15 @@ export class BrowserbaseService {
848855
capturedAt: new Date(),
849856
});
850857
} catch (overlayErr) {
851-
this.logger.warn('Screenshot overlay render failed; uploading raw image', {
852-
error:
853-
overlayErr instanceof Error ? overlayErr.message : String(overlayErr),
854-
});
858+
this.logger.warn(
859+
'Screenshot overlay render failed; uploading raw image',
860+
{
861+
error:
862+
overlayErr instanceof Error
863+
? overlayErr.message
864+
: String(overlayErr),
865+
},
866+
);
855867
}
856868

857869
// Optional evaluation: if the automation was configured with

apps/api/src/browserbase/dto/browserbase.dto.ts

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
22
import {
3+
IsEnum,
34
IsNotEmpty,
45
IsOptional,
56
IsString,
67
IsBoolean,
78
IsUrl,
89
} from 'class-validator';
10+
import { TaskFrequency } from '@db';
911
import { IsSafeUrl } from '../validators/url-safety.validator';
1012

1113
// ===== Session DTOs =====
@@ -90,10 +92,13 @@ export class CreateBrowserAutomationDto {
9092
@IsOptional()
9193
evaluationCriteria?: string;
9294

93-
@ApiPropertyOptional({ description: 'Cron schedule expression' })
94-
@IsString()
95+
@ApiPropertyOptional({
96+
enum: TaskFrequency,
97+
description: 'Automation schedule cadence',
98+
})
99+
@IsEnum(TaskFrequency)
95100
@IsOptional()
96-
schedule?: string;
101+
scheduleFrequency?: TaskFrequency;
97102
}
98103

99104
export class UpdateBrowserAutomationDto {
@@ -127,15 +132,18 @@ export class UpdateBrowserAutomationDto {
127132
@IsOptional()
128133
evaluationCriteria?: string;
129134

130-
@ApiPropertyOptional({ description: 'Cron schedule expression' })
131-
@IsString()
132-
@IsOptional()
133-
schedule?: string;
134-
135135
@ApiPropertyOptional({ description: 'Whether automation is enabled' })
136136
@IsBoolean()
137137
@IsOptional()
138138
isEnabled?: boolean;
139+
140+
@ApiPropertyOptional({
141+
enum: TaskFrequency,
142+
description: 'Automation schedule cadence',
143+
})
144+
@IsEnum(TaskFrequency)
145+
@IsOptional()
146+
scheduleFrequency?: TaskFrequency;
139147
}
140148

141149
// ===== Response DTOs =====
@@ -186,9 +194,6 @@ export class BrowserAutomationResponseDto {
186194
@ApiProperty()
187195
isEnabled: boolean;
188196

189-
@ApiPropertyOptional()
190-
schedule?: string;
191-
192197
@ApiProperty()
193198
createdAt: Date;
194199

apps/api/src/tasks/automations/automations.service.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,12 +87,17 @@ export class AutomationsService {
8787
throw new NotFoundException('Automation not found');
8888
}
8989

90+
const { scheduleFrequency, ...rest } = updateAutomationDto;
91+
9092
// Update the automation
9193
const automation = await db.evidenceAutomation.update({
9294
where: {
9395
id: automationId,
9496
},
95-
data: updateAutomationDto,
97+
data: {
98+
...rest,
99+
...(scheduleFrequency !== undefined ? { scheduleFrequency } : {}),
100+
},
96101
});
97102

98103
return {

apps/api/src/tasks/automations/dto/update-automation.dto.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { ApiProperty } from '@nestjs/swagger';
2-
import { IsString, IsOptional, IsBoolean } from 'class-validator';
1+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
2+
import { IsString, IsOptional, IsBoolean, IsEnum } from 'class-validator';
3+
import { TaskFrequency } from '@db';
34

45
export class UpdateAutomationDto {
56
@ApiProperty({
@@ -35,4 +36,12 @@ export class UpdateAutomationDto {
3536
@IsString()
3637
@IsOptional()
3738
evaluationCriteria?: string;
39+
40+
@ApiPropertyOptional({
41+
enum: TaskFrequency,
42+
description: 'Automation schedule cadence',
43+
})
44+
@IsEnum(TaskFrequency)
45+
@IsOptional()
46+
scheduleFrequency?: TaskFrequency;
3847
}

apps/api/src/tasks/dto/swagger.dto.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ export class CreateTaskDto {
2929
})
3030
frequency?: string;
3131

32+
@ApiProperty({
33+
description:
34+
'Cadence for running the integration check attached to this task',
35+
enum: ['daily', 'weekly', 'monthly', 'quarterly', 'yearly'],
36+
required: false,
37+
})
38+
integrationScheduleFrequency?: string;
39+
3240
@ApiProperty({
3341
description: 'Department assignment',
3442
enum: ['none', 'admin', 'gov', 'hr', 'it', 'itsm', 'qms'],
@@ -80,6 +88,14 @@ export class UpdateTaskDto {
8088
})
8189
frequency?: string;
8290

91+
@ApiProperty({
92+
description:
93+
'Cadence for running the integration check attached to this task',
94+
enum: ['daily', 'weekly', 'monthly', 'quarterly', 'yearly'],
95+
required: false,
96+
})
97+
integrationScheduleFrequency?: string;
98+
8399
@ApiProperty({
84100
description: 'Department assignment',
85101
enum: ['none', 'admin', 'gov', 'hr', 'it', 'itsm', 'qms'],
@@ -172,6 +188,18 @@ export class TaskResponseDto {
172188
})
173189
frequency: string | null;
174190

191+
@ApiProperty({
192+
description: 'Cadence for running the integration check attached to this task',
193+
enum: ['daily', 'weekly', 'monthly', 'quarterly', 'yearly'],
194+
})
195+
integrationScheduleFrequency: string;
196+
197+
@ApiProperty({
198+
description: 'Last successful integration check run timestamp',
199+
nullable: true,
200+
})
201+
integrationLastRunAt: Date | null;
202+
175203
@ApiProperty({
176204
description: 'Department assignment',
177205
enum: ['none', 'admin', 'gov', 'hr', 'it', 'itsm', 'qms'],

apps/api/src/tasks/dto/task-responses.dto.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,18 @@ export class TaskResponseDto {
8585
required: false,
8686
})
8787
taskTemplateId?: string | null;
88+
89+
@ApiProperty({
90+
description: 'Cadence for running the integration check attached to this task',
91+
enum: ['daily', 'weekly', 'monthly', 'quarterly', 'yearly'],
92+
example: 'daily',
93+
})
94+
integrationScheduleFrequency: string;
95+
96+
@ApiProperty({
97+
description: 'Last successful integration check run timestamp',
98+
nullable: true,
99+
required: false,
100+
})
101+
integrationLastRunAt?: Date | null;
88102
}

0 commit comments

Comments
 (0)