Skip to content

Commit 2ba481a

Browse files
committed
feat: surface cron windows in webapp, cli, sdk
1 parent bd99602 commit 2ba481a

23 files changed

Lines changed: 630 additions & 66 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@trigger.dev/core": patch
3+
"@trigger.dev/sdk": patch
4+
"trigger.dev": patch
5+
---
6+
7+
Define stable execution windows on declarative scheduled tasks. Schedule API responses now expose both the nominal CRON time and its assigned time, while deploy output and the dashboard show configured windows and upcoming assignments.

apps/webapp/app/components/schedules/ScheduleInspector.tsx

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,14 @@ export type ScheduleInspectorData = {
5555
cron: string;
5656
cronDescription: string;
5757
timezone: string;
58+
window?: string;
5859
externalId: string | null;
5960
deduplicationKey: string | null;
6061
userProvidedDeduplicationKey: boolean;
6162
active: boolean;
6263
environments: EnvironmentRow[];
6364
runs: RunRow[];
64-
nextRuns: Date[];
65+
nextRuns: Array<{ nominalAt: Date; effectiveAt: Date }>;
6566
};
6667

6768
type Props = {
@@ -142,6 +143,10 @@ export function ScheduleInspector({
142143
<Property.Label>Timezone</Property.Label>
143144
<Property.Value>{schedule.timezone}</Property.Value>
144145
</Property.Item>
146+
<Property.Item>
147+
<Property.Label>Window</Property.Label>
148+
<Property.Value>{schedule.window ?? "Default (60 seconds)"}</Property.Value>
149+
</Property.Item>
145150
<Property.Item className="gap-1">
146151
<Property.Label>Environment</Property.Label>
147152
<Property.Value>
@@ -195,12 +200,13 @@ export function ScheduleInspector({
195200
/>
196201
</div>
197202
<div className="flex flex-col gap-1 pt-2">
198-
<Header3 className="pb-1 pl-3">Next 5 runs</Header3>
203+
<Header3 className="pb-1 pl-3">Next 5 scheduled runs</Header3>
199204
<Table variant="bright">
200205
<TableHeader>
201206
<TableRow>
202-
{!isUtc && <TableHeaderCell>{schedule.timezone}</TableHeaderCell>}
203-
<TableHeaderCell>UTC</TableHeaderCell>
207+
{!isUtc && <TableHeaderCell>CRON ({schedule.timezone})</TableHeaderCell>}
208+
<TableHeaderCell>CRON (UTC)</TableHeaderCell>
209+
<TableHeaderCell>Assigned (UTC)</TableHeaderCell>
204210
</TableRow>
205211
</TableHeader>
206212
<TableBody>
@@ -210,21 +216,24 @@ export function ScheduleInspector({
210216
<TableRow key={index}>
211217
{!isUtc && (
212218
<TableCell>
213-
<DateTime date={run} timeZone={schedule.timezone} />
219+
<DateTime date={run.nominalAt} timeZone={schedule.timezone} />
214220
</TableCell>
215221
)}
216222
<TableCell>
217-
<DateTime date={run} timeZone="UTC" />
223+
<DateTime date={run.nominalAt} timeZone="UTC" />
224+
</TableCell>
225+
<TableCell>
226+
<DateTime date={run.effectiveAt} timeZone="UTC" />
218227
</TableCell>
219228
</TableRow>
220229
))
221230
) : (
222-
<TableBlankRow colSpan={isUtc ? 1 : 2}>
231+
<TableBlankRow colSpan={isUtc ? 2 : 3}>
223232
<PlaceholderText title="You found a bug" />
224233
</TableBlankRow>
225234
)
226235
) : (
227-
<TableBlankRow colSpan={isUtc ? 1 : 2}>
236+
<TableBlankRow colSpan={isUtc ? 2 : 3}>
228237
<PlaceholderText title="Schedule disabled" />
229238
</TableBlankRow>
230239
)}
@@ -249,8 +258,8 @@ export function ScheduleInspector({
249258
}
250259
panelClassName="max-w-full"
251260
>
252-
You can only edit a declarative schedule by updating your schedules.task and then
253-
running the CLI dev and deploy commands.
261+
You can only edit a declarative schedule, including its window, by updating your
262+
schedules.task and then running the CLI dev and deploy commands.
254263
</InfoPanel>
255264
</div>
256265
)}

apps/webapp/app/presenters/v3/EditSchedulePresenter.server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { filterOrphanedEnvironments } from "~/utils/environmentSort";
66
import { getTimezones } from "~/utils/timezones.server";
77
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
88
import { ServiceValidationError } from "~/v3/services/baseService.server";
9+
import { formatScheduleWindow } from "~/v3/scheduleWindow.server";
910

1011
type EditScheduleOptions = {
1112
userId: string;
@@ -124,6 +125,8 @@ export class EditSchedulePresenter {
124125
deduplicationKey: true,
125126
userProvidedDeduplicationKey: true,
126127
timezone: true,
128+
windowDurationSeconds: true,
129+
windowPercentage: true,
127130
taskIdentifier: true,
128131
instances: {
129132
select: {
@@ -144,6 +147,7 @@ export class EditSchedulePresenter {
144147
return {
145148
...schedule,
146149
cron: schedule.generatorExpression,
150+
window: formatScheduleWindow(schedule),
147151
environments: schedule.instances.flatMap((instance) => {
148152
const environment = possibleEnvironments.find((env) => env.id === instance.environmentId);
149153
if (!environment) {

apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,10 @@ import { getTaskIdentifiers } from "~/models/task.server";
55
import { getCurrentPlan, getPlans } from "~/services/platform.v3.server";
66
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
77
import { ServiceValidationError } from "~/v3/services/baseService.server";
8-
import { formatScheduleWindow } from "~/v3/scheduleWindow.server";
8+
import { calculateNextScheduleRunTimes, formatScheduleWindow } from "~/v3/scheduleWindow.server";
99
import { CheckScheduleService } from "~/v3/services/checkSchedule.server";
10-
import {
11-
calculateNextScheduledTimestampFromNow,
12-
previousScheduledTimestamp,
13-
} from "~/v3/utils/calculateNextSchedule.server";
10+
import { previousScheduledTimestamp } from "~/v3/utils/calculateNextSchedule.server";
11+
import { env } from "~/env.server";
1412
import { BasePresenter } from "./basePresenter.server";
1513

1614
type ScheduleListOptions = {
@@ -35,6 +33,7 @@ export type ScheduleListItem = {
3533
window?: string;
3634
externalId: string | null;
3735
nextRun: Date;
36+
nextRunEffectiveAt: Date;
3837
lastRun: Date | undefined;
3938
active: boolean;
4039
environments: {
@@ -223,6 +222,7 @@ export class ScheduleListPresenter extends BasePresenter {
223222
instances: {
224223
select: {
225224
environmentId: true,
225+
schedulePhase: true,
226226
},
227227
},
228228
active: true,
@@ -300,6 +300,23 @@ export class ScheduleListPresenter extends BasePresenter {
300300
}
301301
}
302302

303+
const instance = schedule.instances.find(
304+
(instance) => instance.environmentId === environmentId
305+
);
306+
if (!instance) {
307+
throw new Error(`Schedule instance not found for environment: ${environmentId}`);
308+
}
309+
const [nextRun] = calculateNextScheduleRunTimes({
310+
cron: schedule.generatorExpression,
311+
timezone: schedule.timezone,
312+
deduplicationKey: schedule.deduplicationKey,
313+
environmentId,
314+
schedulePhase: instance.schedulePhase,
315+
phaseSecret: env.ENCRYPTION_KEY,
316+
windowDurationSeconds: schedule.windowDurationSeconds,
317+
windowPercentage: schedule.windowPercentage,
318+
});
319+
303320
return {
304321
id: schedule.id,
305322
type: schedule.type,
@@ -314,10 +331,8 @@ export class ScheduleListPresenter extends BasePresenter {
314331
active: schedule.active,
315332
externalId: schedule.externalId,
316333
lastRun,
317-
nextRun: calculateNextScheduledTimestampFromNow(
318-
schedule.generatorExpression,
319-
schedule.timezone
320-
),
334+
nextRun: nextRun.nominalAt,
335+
nextRunEffectiveAt: nextRun.effectiveAt,
321336
environments: schedule.instances.map((instance) => {
322337
const environment = project.environments.find((env) => env.id === instance.environmentId);
323338
if (!environment) {

apps/webapp/app/presenters/v3/ViewSchedulePresenter.server.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import type { PrismaClient } from "~/db.server";
33
import { prisma } from "~/db.server";
44
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
55
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
6-
import { nextScheduledTimestamps } from "~/v3/utils/calculateNextSchedule.server";
76
import { NextRunListPresenter } from "./NextRunListPresenter.server";
87
import { scheduleWhereClause } from "~/models/schedules.server";
9-
import { formatScheduleWindow } from "~/v3/scheduleWindow.server";
8+
import { calculateNextScheduleRunTimes, formatScheduleWindow } from "~/v3/scheduleWindow.server";
9+
import { env } from "~/env.server";
1010

1111
type ViewScheduleOptions = {
1212
userId?: string;
@@ -52,6 +52,8 @@ export class ViewSchedulePresenter {
5252
},
5353
instances: {
5454
select: {
55+
environmentId: true,
56+
schedulePhase: true,
5557
environment: {
5658
select: {
5759
id: true,
@@ -82,8 +84,25 @@ export class ViewSchedulePresenter {
8284
return;
8385
}
8486

87+
const instance = schedule.instances.find(
88+
(instance) => instance.environmentId === environmentId
89+
);
90+
if (!instance) {
91+
return;
92+
}
93+
8594
const nextRuns = schedule.active
86-
? nextScheduledTimestamps(schedule.generatorExpression, schedule.timezone, new Date(), 5)
95+
? calculateNextScheduleRunTimes({
96+
cron: schedule.generatorExpression,
97+
timezone: schedule.timezone,
98+
deduplicationKey: schedule.deduplicationKey,
99+
environmentId,
100+
schedulePhase: instance.schedulePhase,
101+
phaseSecret: env.ENCRYPTION_KEY,
102+
windowDurationSeconds: schedule.windowDurationSeconds,
103+
windowPercentage: schedule.windowPercentage,
104+
count: 5,
105+
})
87106
: [];
88107

89108
const runs = includeRunHistory
@@ -101,6 +120,7 @@ export class ViewSchedulePresenter {
101120
timezone: schedule.timezone,
102121
cron: schedule.generatorExpression,
103122
cronDescription: schedule.generatorDescription,
123+
window: formatScheduleWindow(schedule),
104124
nextRuns,
105125
runs,
106126
environments: schedule.instances.map((instance) => {
@@ -146,14 +166,15 @@ export class ViewSchedulePresenter {
146166
type: result.schedule.type,
147167
task: result.schedule.taskIdentifier,
148168
active: result.schedule.active,
149-
nextRun: result.schedule.nextRuns[0],
169+
nextRun: result.schedule.nextRuns[0]?.nominalAt ?? null,
170+
nextRunEffectiveAt: result.schedule.nextRuns[0]?.effectiveAt ?? null,
150171
generator: {
151172
type: "CRON",
152173
expression: result.schedule.cron,
153174
description: result.schedule.cronDescription,
154175
},
155176
timezone: result.schedule.timezone,
156-
window: formatScheduleWindow(result.schedule),
177+
window: result.schedule.window,
157178
externalId: result.schedule.externalId ?? undefined,
158179
deduplicationKey: result.schedule.userProvidedDeduplicationKey
159180
? (result.schedule.deduplicationKey ?? undefined)

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -966,8 +966,10 @@ type ScheduleRow = {
966966
type: "DECLARATIVE" | "IMPERATIVE";
967967
cron: string;
968968
cronDescription: string;
969+
window?: string;
969970
externalId: string | null;
970971
nextRun: Date;
972+
nextRunEffectiveAt: Date;
971973
lastRun: Date | undefined;
972974
active: boolean;
973975
};
@@ -987,7 +989,7 @@ function SchedulesMiniTable({
987989
return (
988990
<Table variant={variant} showTopBorder={showTopBorder}>
989991
<TableBody>
990-
<TableBlankRow colSpan={6}>
992+
<TableBlankRow colSpan={9}>
991993
<Paragraph variant="small" className="flex items-center justify-center">
992994
No schedules attached to this task yet.
993995
</Paragraph>
@@ -1003,9 +1005,11 @@ function SchedulesMiniTable({
10031005
<TableRow>
10041006
<TableHeaderCell>Schedule ID</TableHeaderCell>
10051007
<TableHeaderCell>Type</TableHeaderCell>
1006-
<TableHeaderCell>Cron</TableHeaderCell>
1008+
<TableHeaderCell>CRON</TableHeaderCell>
1009+
<TableHeaderCell>Window</TableHeaderCell>
10071010
<TableHeaderCell>External ID</TableHeaderCell>
1008-
<TableHeaderCell>Next run</TableHeaderCell>
1011+
<TableHeaderCell>Next CRON time</TableHeaderCell>
1012+
<TableHeaderCell>Next assigned time</TableHeaderCell>
10091013
<TableHeaderCell>Last run</TableHeaderCell>
10101014
<TableHeaderCell>Status</TableHeaderCell>
10111015
</TableRow>
@@ -1030,6 +1034,9 @@ function SchedulesMiniTable({
10301034
<TableCell onClick={open}>
10311035
<span className="font-mono text-xs">{schedule.cron}</span>
10321036
</TableCell>
1037+
<TableCell onClick={open}>
1038+
<span className="text-xs">{schedule.window ?? "Default (60s)"}</span>
1039+
</TableCell>
10331040
<TableCell onClick={open}>
10341041
{schedule.externalId ? (
10351042
<span className="font-mono text-xs">{schedule.externalId}</span>
@@ -1040,6 +1047,9 @@ function SchedulesMiniTable({
10401047
<TableCell onClick={open}>
10411048
<RelativeDateTime date={schedule.nextRun} />
10421049
</TableCell>
1050+
<TableCell onClick={open}>
1051+
<RelativeDateTime date={schedule.nextRunEffectiveAt} />
1052+
</TableCell>
10431053
<TableCell onClick={open}>
10441054
{schedule.lastRun ? (
10451055
<RelativeDateTime date={schedule.lastRun} />

apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
2-
import { type GetDeploymentResponseBody } from "@trigger.dev/core/v3";
2+
import { BackgroundWorkerMetadata, type GetDeploymentResponseBody } from "@trigger.dev/core/v3";
33
import { z } from "zod";
44
import { prisma } from "~/db.server";
55
import { authenticateApiKeyWithScope } from "~/services/apiAuth.server";
66
import { logger } from "~/services/logger.server";
7+
import { env } from "~/env.server";
8+
import { calculateNextScheduleRunTimes, normalizeScheduleWindow } from "~/v3/scheduleWindow.server";
79

810
const ParamsSchema = z.object({
911
deploymentId: z.string(),
@@ -53,6 +55,43 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
5355
return json({ error: "Deployment not found" }, { status: 404 });
5456
}
5557

58+
const workerMetadata = deployment.worker
59+
? BackgroundWorkerMetadata.safeParse(deployment.worker.metadata)
60+
: undefined;
61+
const declarativeSchedules = workerMetadata?.success
62+
? workerMetadata.data.tasks.flatMap((task) => {
63+
if (
64+
!task.schedule ||
65+
(task.schedule.environments &&
66+
!task.schedule.environments.includes(authenticatedEnv.type))
67+
) {
68+
return [];
69+
}
70+
71+
const windowFields = normalizeScheduleWindow(task.schedule.window);
72+
const [nextRun] = calculateNextScheduleRunTimes({
73+
cron: task.schedule.cron,
74+
timezone: task.schedule.timezone,
75+
deduplicationKey: task.id,
76+
environmentId: authenticatedEnv.id,
77+
schedulePhase: null,
78+
phaseSecret: env.ENCRYPTION_KEY,
79+
...windowFields,
80+
});
81+
82+
return [
83+
{
84+
task: task.id,
85+
cron: task.schedule.cron,
86+
timezone: task.schedule.timezone,
87+
window: task.schedule.window,
88+
nextRun: nextRun.nominalAt,
89+
nextRunEffectiveAt: nextRun.effectiveAt,
90+
},
91+
];
92+
})
93+
: [];
94+
5695
return json({
5796
id: deployment.friendlyId,
5897
status: deployment.status,
@@ -75,6 +114,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
75114
filePath: task.filePath,
76115
exportName: task.exportName ?? "@deprecated",
77116
})),
117+
declarativeSchedules,
78118
}
79119
: undefined,
80120
integrationDeployments:

apps/webapp/app/routes/api.v1.schedules.$scheduleId.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
130130
deduplicationKey: schedule.deduplicationKey,
131131
environments: schedule.environments,
132132
nextRun: schedule.nextRun,
133+
nextRunEffectiveAt: schedule.nextRunEffectiveAt,
133134
};
134135

135136
return json(responseObject, { status: 200 });

0 commit comments

Comments
 (0)