From 26fb9e077cb993203c664ae2a57b22b2b8e20222 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Tue, 11 Aug 2026 18:17:52 +0100 Subject: [PATCH 01/11] feat: surface cron windows in webapp, cli, sdk --- .changeset/smooth-schedule-windows.md | 7 ++ .../schedules/ScheduleInspector.tsx | 29 ++++-- .../v3/EditSchedulePresenter.server.ts | 4 + .../v3/ScheduleListPresenter.server.ts | 33 +++++-- .../v3/ViewSchedulePresenter.server.ts | 31 +++++- .../route.tsx | 16 ++- .../api.v1.deployments.$deploymentId.ts | 42 +++++++- .../routes/api.v1.schedules.$scheduleId.ts | 1 + apps/webapp/app/routes/api.v1.schedules.ts | 2 + .../route.tsx | 69 ++++++++++--- apps/webapp/app/v3/scheduleWindow.server.ts | 75 ++++++++++++-- apps/webapp/app/v3/schedules.ts | 2 +- .../app/v3/services/checkSchedule.server.ts | 3 +- .../v3/services/upsertTaskSchedule.server.ts | 38 +++++-- apps/webapp/test/scheduleWindow.test.ts | 45 +++++++++ .../test/schedules-api.e2e.full.test.ts | 98 ++++++++++++++++++- packages/cli-v3/src/commands/deploy.ts | 36 +++++++ packages/cli-v3/src/deploy/schedules.test.ts | 42 ++++++++ packages/cli-v3/src/deploy/schedules.ts | 41 ++++++++ packages/core/src/v3/schemas/api.ts | 15 +++ packages/core/src/v3/schemas/schemas.ts | 2 +- .../src/v3/schedules/index.test.ts | 55 +++++++++++ .../trigger-sdk/src/v3/schedules/index.ts | 10 +- 23 files changed, 630 insertions(+), 66 deletions(-) create mode 100644 .changeset/smooth-schedule-windows.md create mode 100644 packages/cli-v3/src/deploy/schedules.test.ts create mode 100644 packages/cli-v3/src/deploy/schedules.ts create mode 100644 packages/trigger-sdk/src/v3/schedules/index.test.ts diff --git a/.changeset/smooth-schedule-windows.md b/.changeset/smooth-schedule-windows.md new file mode 100644 index 0000000000..c583852bc8 --- /dev/null +++ b/.changeset/smooth-schedule-windows.md @@ -0,0 +1,7 @@ +--- +"@trigger.dev/core": patch +"@trigger.dev/sdk": patch +"trigger.dev": patch +--- + +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. diff --git a/apps/webapp/app/components/schedules/ScheduleInspector.tsx b/apps/webapp/app/components/schedules/ScheduleInspector.tsx index 1d0de51dc9..f90ddd16f7 100644 --- a/apps/webapp/app/components/schedules/ScheduleInspector.tsx +++ b/apps/webapp/app/components/schedules/ScheduleInspector.tsx @@ -55,13 +55,14 @@ export type ScheduleInspectorData = { cron: string; cronDescription: string; timezone: string; + window?: string; externalId: string | null; deduplicationKey: string | null; userProvidedDeduplicationKey: boolean; active: boolean; environments: EnvironmentRow[]; runs: RunRow[]; - nextRuns: Date[]; + nextRuns: Array<{ nominalAt: Date; effectiveAt: Date }>; }; type Props = { @@ -142,6 +143,10 @@ export function ScheduleInspector({ Timezone {schedule.timezone} + + Window + {schedule.window ?? "Default (60 seconds)"} + Environment @@ -195,12 +200,13 @@ export function ScheduleInspector({ />
- Next 5 runs + Next 5 scheduled runs - {!isUtc && {schedule.timezone}} - UTC + {!isUtc && CRON ({schedule.timezone})} + CRON (UTC) + Assigned (UTC) @@ -210,21 +216,24 @@ export function ScheduleInspector({ {!isUtc && ( - + )} - + + + + )) ) : ( - + ) ) : ( - + )} @@ -249,8 +258,8 @@ export function ScheduleInspector({ } panelClassName="max-w-full" > - You can only edit a declarative schedule by updating your schedules.task and then - running the CLI dev and deploy commands. + You can only edit a declarative schedule, including its window, by updating your + schedules.task and then running the CLI dev and deploy commands. )} diff --git a/apps/webapp/app/presenters/v3/EditSchedulePresenter.server.ts b/apps/webapp/app/presenters/v3/EditSchedulePresenter.server.ts index 610ebb24d2..3fd7fec8a6 100644 --- a/apps/webapp/app/presenters/v3/EditSchedulePresenter.server.ts +++ b/apps/webapp/app/presenters/v3/EditSchedulePresenter.server.ts @@ -6,6 +6,7 @@ import { filterOrphanedEnvironments } from "~/utils/environmentSort"; import { getTimezones } from "~/utils/timezones.server"; import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; +import { formatScheduleWindow } from "~/v3/scheduleWindow.server"; type EditScheduleOptions = { userId: string; @@ -124,6 +125,8 @@ export class EditSchedulePresenter { deduplicationKey: true, userProvidedDeduplicationKey: true, timezone: true, + windowDurationSeconds: true, + windowPercentage: true, taskIdentifier: true, instances: { select: { @@ -144,6 +147,7 @@ export class EditSchedulePresenter { return { ...schedule, cron: schedule.generatorExpression, + window: formatScheduleWindow(schedule), environments: schedule.instances.flatMap((instance) => { const environment = possibleEnvironments.find((env) => env.id === instance.environmentId); if (!environment) { diff --git a/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts b/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts index 22b9821bab..c0eb6fc7a3 100644 --- a/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts @@ -5,12 +5,10 @@ import { getTaskIdentifiers } from "~/models/task.server"; import { getCurrentPlan, getPlans } from "~/services/platform.v3.server"; import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; -import { formatScheduleWindow } from "~/v3/scheduleWindow.server"; +import { calculateNextScheduleRunTimes, formatScheduleWindow } from "~/v3/scheduleWindow.server"; import { CheckScheduleService } from "~/v3/services/checkSchedule.server"; -import { - calculateNextScheduledTimestampFromNow, - previousScheduledTimestamp, -} from "~/v3/utils/calculateNextSchedule.server"; +import { previousScheduledTimestamp } from "~/v3/utils/calculateNextSchedule.server"; +import { env } from "~/env.server"; import { BasePresenter } from "./basePresenter.server"; type ScheduleListOptions = { @@ -35,6 +33,7 @@ export type ScheduleListItem = { window?: string; externalId: string | null; nextRun: Date; + nextRunEffectiveAt: Date; lastRun: Date | undefined; active: boolean; environments: { @@ -223,6 +222,7 @@ export class ScheduleListPresenter extends BasePresenter { instances: { select: { environmentId: true, + schedulePhase: true, }, }, active: true, @@ -300,6 +300,23 @@ export class ScheduleListPresenter extends BasePresenter { } } + const instance = schedule.instances.find( + (instance) => instance.environmentId === environmentId + ); + if (!instance) { + throw new Error(`Schedule instance not found for environment: ${environmentId}`); + } + const [nextRun] = calculateNextScheduleRunTimes({ + cron: schedule.generatorExpression, + timezone: schedule.timezone, + deduplicationKey: schedule.deduplicationKey, + environmentId, + schedulePhase: instance.schedulePhase, + phaseSecret: env.ENCRYPTION_KEY, + windowDurationSeconds: schedule.windowDurationSeconds, + windowPercentage: schedule.windowPercentage, + }); + return { id: schedule.id, type: schedule.type, @@ -314,10 +331,8 @@ export class ScheduleListPresenter extends BasePresenter { active: schedule.active, externalId: schedule.externalId, lastRun, - nextRun: calculateNextScheduledTimestampFromNow( - schedule.generatorExpression, - schedule.timezone - ), + nextRun: nextRun.nominalAt, + nextRunEffectiveAt: nextRun.effectiveAt, environments: schedule.instances.map((instance) => { const environment = project.environments.find((env) => env.id === instance.environmentId); if (!environment) { diff --git a/apps/webapp/app/presenters/v3/ViewSchedulePresenter.server.ts b/apps/webapp/app/presenters/v3/ViewSchedulePresenter.server.ts index bc7d0388b0..6ca9bed0fc 100644 --- a/apps/webapp/app/presenters/v3/ViewSchedulePresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ViewSchedulePresenter.server.ts @@ -3,10 +3,10 @@ import type { PrismaClient } from "~/db.server"; import { prisma } from "~/db.server"; import { displayableEnvironment } from "~/models/runtimeEnvironment.server"; import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; -import { nextScheduledTimestamps } from "~/v3/utils/calculateNextSchedule.server"; import { NextRunListPresenter } from "./NextRunListPresenter.server"; import { scheduleWhereClause } from "~/models/schedules.server"; -import { formatScheduleWindow } from "~/v3/scheduleWindow.server"; +import { calculateNextScheduleRunTimes, formatScheduleWindow } from "~/v3/scheduleWindow.server"; +import { env } from "~/env.server"; type ViewScheduleOptions = { userId?: string; @@ -52,6 +52,8 @@ export class ViewSchedulePresenter { }, instances: { select: { + environmentId: true, + schedulePhase: true, environment: { select: { id: true, @@ -82,8 +84,25 @@ export class ViewSchedulePresenter { return; } + const instance = schedule.instances.find( + (instance) => instance.environmentId === environmentId + ); + if (!instance) { + return; + } + const nextRuns = schedule.active - ? nextScheduledTimestamps(schedule.generatorExpression, schedule.timezone, new Date(), 5) + ? calculateNextScheduleRunTimes({ + cron: schedule.generatorExpression, + timezone: schedule.timezone, + deduplicationKey: schedule.deduplicationKey, + environmentId, + schedulePhase: instance.schedulePhase, + phaseSecret: env.ENCRYPTION_KEY, + windowDurationSeconds: schedule.windowDurationSeconds, + windowPercentage: schedule.windowPercentage, + count: 5, + }) : []; const runs = includeRunHistory @@ -101,6 +120,7 @@ export class ViewSchedulePresenter { timezone: schedule.timezone, cron: schedule.generatorExpression, cronDescription: schedule.generatorDescription, + window: formatScheduleWindow(schedule), nextRuns, runs, environments: schedule.instances.map((instance) => { @@ -146,14 +166,15 @@ export class ViewSchedulePresenter { type: result.schedule.type, task: result.schedule.taskIdentifier, active: result.schedule.active, - nextRun: result.schedule.nextRuns[0], + nextRun: result.schedule.nextRuns[0]?.nominalAt ?? null, + nextRunEffectiveAt: result.schedule.nextRuns[0]?.effectiveAt ?? null, generator: { type: "CRON", expression: result.schedule.cron, description: result.schedule.cronDescription, }, timezone: result.schedule.timezone, - window: formatScheduleWindow(result.schedule), + window: result.schedule.window, externalId: result.schedule.externalId ?? undefined, deduplicationKey: result.schedule.userProvidedDeduplicationKey ? (result.schedule.deduplicationKey ?? undefined) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx index a8d40b56f1..a804f88579 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx @@ -972,8 +972,10 @@ type ScheduleRow = { type: "DECLARATIVE" | "IMPERATIVE"; cron: string; cronDescription: string; + window?: string; externalId: string | null; nextRun: Date; + nextRunEffectiveAt: Date; lastRun: Date | undefined; active: boolean; }; @@ -993,7 +995,7 @@ function SchedulesMiniTable({ return (
- + No schedules attached to this task yet. @@ -1009,9 +1011,11 @@ function SchedulesMiniTable({ Schedule ID Type - Cron + CRON + Window External ID - Next run + Next CRON time + Next assigned time Last run Status @@ -1036,6 +1040,9 @@ function SchedulesMiniTable({ {schedule.cron} + + {schedule.window ?? "Default (60s)"} + {schedule.externalId ? ( {schedule.externalId} @@ -1046,6 +1053,9 @@ function SchedulesMiniTable({ + + + {schedule.lastRun ? ( diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts index 6b7accd029..6976fac4a7 100644 --- a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts @@ -1,9 +1,11 @@ import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; -import { type GetDeploymentResponseBody } from "@trigger.dev/core/v3"; +import { BackgroundWorkerMetadata, type GetDeploymentResponseBody } from "@trigger.dev/core/v3"; import { z } from "zod"; import { prisma } from "~/db.server"; import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; +import { env } from "~/env.server"; +import { calculateNextScheduleRunTimes, normalizeScheduleWindow } from "~/v3/scheduleWindow.server"; const ParamsSchema = z.object({ deploymentId: z.string(), @@ -53,6 +55,43 @@ export async function loader({ request, params }: LoaderFunctionArgs) { return json({ error: "Deployment not found" }, { status: 404 }); } + const workerMetadata = deployment.worker + ? BackgroundWorkerMetadata.safeParse(deployment.worker.metadata) + : undefined; + const declarativeSchedules = workerMetadata?.success + ? workerMetadata.data.tasks.flatMap((task) => { + if ( + !task.schedule || + (task.schedule.environments && + !task.schedule.environments.includes(authenticatedEnv.type)) + ) { + return []; + } + + const windowFields = normalizeScheduleWindow(task.schedule.window); + const [nextRun] = calculateNextScheduleRunTimes({ + cron: task.schedule.cron, + timezone: task.schedule.timezone, + deduplicationKey: task.id, + environmentId: authenticatedEnv.id, + schedulePhase: null, + phaseSecret: env.ENCRYPTION_KEY, + ...windowFields, + }); + + return [ + { + task: task.id, + cron: task.schedule.cron, + timezone: task.schedule.timezone, + window: task.schedule.window, + nextRun: nextRun.nominalAt, + nextRunEffectiveAt: nextRun.effectiveAt, + }, + ]; + }) + : []; + return json({ id: deployment.friendlyId, status: deployment.status, @@ -75,6 +114,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { filePath: task.filePath, exportName: task.exportName ?? "@deprecated", })), + declarativeSchedules, } : undefined, integrationDeployments: diff --git a/apps/webapp/app/routes/api.v1.schedules.$scheduleId.ts b/apps/webapp/app/routes/api.v1.schedules.$scheduleId.ts index f98707eecb..c601d0e621 100644 --- a/apps/webapp/app/routes/api.v1.schedules.$scheduleId.ts +++ b/apps/webapp/app/routes/api.v1.schedules.$scheduleId.ts @@ -130,6 +130,7 @@ export async function action({ request, params }: ActionFunctionArgs) { deduplicationKey: schedule.deduplicationKey, environments: schedule.environments, nextRun: schedule.nextRun, + nextRunEffectiveAt: schedule.nextRunEffectiveAt, }; return json(responseObject, { status: 200 }); diff --git a/apps/webapp/app/routes/api.v1.schedules.ts b/apps/webapp/app/routes/api.v1.schedules.ts index 277033dd94..aaa49d1c02 100644 --- a/apps/webapp/app/routes/api.v1.schedules.ts +++ b/apps/webapp/app/routes/api.v1.schedules.ts @@ -72,6 +72,7 @@ export async function action({ request }: ActionFunctionArgs) { deduplicationKey: schedule.deduplicationKey, environments: schedule.environments, nextRun: schedule.nextRun, + nextRunEffectiveAt: schedule.nextRunEffectiveAt, }; return json(responseObject, { status: 200 }); @@ -130,6 +131,7 @@ export async function loader({ request }: LoaderFunctionArgs) { externalId: schedule.externalId, active: schedule.active, nextRun: schedule.nextRun, + nextRunEffectiveAt: schedule.nextRunEffectiveAt, environments: schedule.environments, })), pagination: { diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx index 61ce814dbd..e5840e6c72 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx @@ -52,6 +52,7 @@ import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; import { EnvironmentParamSchema, docsPath, v3EnvironmentPath } from "~/utils/pathBuilder"; import { CronPattern, UpsertSchedule } from "~/v3/schedules"; +import { ServiceValidationError } from "~/v3/services/baseService.server"; import { UpsertTaskScheduleService } from "~/v3/services/upsertTaskSchedule.server"; import { AIGeneratedCronField } from "../resources.orgs.$organizationSlug.projects.$projectParam.schedules.new.natural-language"; @@ -114,10 +115,21 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { message ); } catch (error: any) { - logger.error("Failed to create schedule", error); + if (!(error instanceof ServiceValidationError)) { + logger.error("Failed to create schedule", error); + } - const errorMessage = `Something went wrong. Please try again.`; + const errorMessage = + error instanceof ServiceValidationError + ? error.message + : `Something went wrong. Please try again.`; if (wantsJson) { + if (error instanceof ServiceValidationError) { + return json(submission.reply({ formErrors: [error.message] }), { + status: error.status ?? 422, + }); + } + return json({ ok: false as const, message: errorMessage }, { status: 500 }); } return redirectWithErrorMessage( @@ -174,18 +186,28 @@ export function UpsertScheduleForm({ const environment = useEnvironment(); const location = useLocation(); - const [form, { taskIdentifier, cron, timezone, externalId, environments, deduplicationKey }] = - useForm({ - // Disambiguate per-schedule so both sheets (create + edit) can - // coexist without duplicate DOM ids breaking `htmlFor` / conform. - id: schedule?.friendlyId ? `edit-schedule-${schedule.friendlyId}` : "create-schedule", - // TODO: type this - lastResult: lastSubmission as any, - shouldRevalidate: "onSubmit", - onValidate({ formData }) { - return parseWithZod(formData, { schema: UpsertSchedule }); - }, - }); + const [ + form, + { + taskIdentifier, + cron, + timezone, + window: scheduleWindow, + externalId, + environments, + deduplicationKey, + }, + ] = useForm({ + // Disambiguate per-schedule so both sheets (create + edit) can + // coexist without duplicate DOM ids breaking `htmlFor` / conform. + id: schedule?.friendlyId ? `edit-schedule-${schedule.friendlyId}` : "create-schedule", + // TODO: type this + lastResult: lastSubmission as any, + shouldRevalidate: "onSubmit", + onValidate({ formData }) { + return parseWithZod(formData, { schema: UpsertSchedule }); + }, + }); let cronPatternResult: CronPatternResult | undefined = undefined; let nextRuns: Date[] | undefined = undefined; @@ -335,9 +357,26 @@ export function UpsertScheduleForm({ {timezone.errors} + + + + + Assigns each run a stable time after its CRON time. Use minutes, hours, days, or a + percentage of the interval. Schedules always use at least a 60-second placement + range. + + {scheduleWindow.errors} + {nextRuns !== undefined && (
- Next 5 runs + Next 5 CRON times + Assigned times are calculated after the schedule is saved.
diff --git a/apps/webapp/app/v3/scheduleWindow.server.ts b/apps/webapp/app/v3/scheduleWindow.server.ts index e89123488e..b099c35a5d 100644 --- a/apps/webapp/app/v3/scheduleWindow.server.ts +++ b/apps/webapp/app/v3/scheduleWindow.server.ts @@ -1,5 +1,10 @@ -import { parseScheduleWindow } from "@internal/schedule-engine"; -import type { ScheduleWindow } from "@trigger.dev/core/v3"; +import { + calculateEffectiveScheduleTime, + calculateSchedulePhase, + parseScheduleWindow, + type NormalizedScheduleWindow, +} from "@internal/schedule-engine"; +import { nextScheduledTimestamps } from "./utils/calculateNextSchedule.server"; const SECONDS_PER_UNIT = { m: 60, @@ -11,9 +16,12 @@ export type ScheduleWindowDatabaseFields = { windowPercentage: number | null; }; -export function normalizeScheduleWindow( - window: ScheduleWindow | undefined -): ScheduleWindowDatabaseFields { +export type ScheduleRunTiming = { + nominalAt: Date; + effectiveAt: Date; +}; + +export function normalizeScheduleWindow(window: string | undefined): ScheduleWindowDatabaseFields { if (window === undefined) { return { windowDurationSeconds: null, @@ -39,7 +47,7 @@ export function normalizeScheduleWindow( export function formatScheduleWindow({ windowDurationSeconds, windowPercentage, -}: ScheduleWindowDatabaseFields): ScheduleWindow | undefined { +}: ScheduleWindowDatabaseFields): string | undefined { if (windowPercentage !== null) { return `${windowPercentage}%`; } @@ -59,8 +67,61 @@ export function formatScheduleWindow({ return `${windowDurationSeconds / SECONDS_PER_UNIT.m}m`; } +export function calculateNextScheduleRunTimes({ + cron, + timezone, + deduplicationKey, + environmentId, + schedulePhase, + phaseSecret, + windowDurationSeconds, + windowPercentage, + from = new Date(), + count = 1, +}: { + cron: string; + timezone: string | null; + deduplicationKey: string; + environmentId: string; + schedulePhase: number | null; + phaseSecret: string; + windowDurationSeconds: number | null; + windowPercentage: number | null; + from?: Date; + count?: number; +}): ScheduleRunTiming[] { + if (count <= 0) { + return []; + } + + const phase = + schedulePhase ?? + calculateSchedulePhase({ + secret: phaseSecret, + environmentId, + deduplicationKey, + }); + const window: NormalizedScheduleWindow | undefined = + windowPercentage !== null + ? { type: "percentage", percentage: windowPercentage } + : windowDurationSeconds !== null + ? { type: "duration", durationSeconds: windowDurationSeconds } + : undefined; + const nominalTimes = nextScheduledTimestamps(cron, timezone, from, count + 1); + + return nominalTimes.slice(0, count).map((nominalAt, index) => ({ + nominalAt, + effectiveAt: calculateEffectiveScheduleTime({ + nominalAt, + nextNominalAt: nominalTimes[index + 1], + schedulePhase: phase, + window, + }).effectiveAt, + })); +} + export function validateScheduleWindowSyntax( - window: ScheduleWindow | undefined + window: string | undefined ): { valid: true } | { valid: false; message: string } { if (window === undefined) { return { valid: true }; diff --git a/apps/webapp/app/v3/schedules.ts b/apps/webapp/app/v3/schedules.ts index bb1d3af55d..d2355a5f54 100644 --- a/apps/webapp/app/v3/schedules.ts +++ b/apps/webapp/app/v3/schedules.ts @@ -57,7 +57,7 @@ export const UpsertSchedule = z.object({ externalId: z.string().optional(), deduplicationKey: z.string().optional(), timezone: z.string().optional(), - window: ScheduleWindow.optional(), + window: z.preprocess((value) => (value === "" ? undefined : value), ScheduleWindow.optional()), }); export type UpsertSchedule = z.infer; diff --git a/apps/webapp/app/v3/services/checkSchedule.server.ts b/apps/webapp/app/v3/services/checkSchedule.server.ts index ea7be4e39e..946cfad080 100644 --- a/apps/webapp/app/v3/services/checkSchedule.server.ts +++ b/apps/webapp/app/v3/services/checkSchedule.server.ts @@ -5,7 +5,6 @@ import { resolveProjectScopedEnvironments } from "./resolveProjectScopedEnvironm import { getLimit } from "~/services/platform.v3.server"; import { getTimezones } from "~/utils/timezones.server"; import { env } from "~/env.server"; -import type { ScheduleWindow } from "@trigger.dev/core/v3"; import { boundedIn, type PrismaClientOrTransaction } from "@trigger.dev/database"; import { validateScheduleWindowSyntax } from "../scheduleWindow.server"; @@ -14,7 +13,7 @@ type Schedule = { timezone?: string; taskIdentifier: string; friendlyId?: string; - window?: ScheduleWindow; + window?: string; }; export class CheckScheduleService extends BaseService { diff --git a/apps/webapp/app/v3/services/upsertTaskSchedule.server.ts b/apps/webapp/app/v3/services/upsertTaskSchedule.server.ts index 567e8269d3..cf72873b37 100644 --- a/apps/webapp/app/v3/services/upsertTaskSchedule.server.ts +++ b/apps/webapp/app/v3/services/upsertTaskSchedule.server.ts @@ -3,12 +3,16 @@ import cronstrue from "cronstrue"; import { nanoid } from "nanoid"; import { generateFriendlyId } from "../friendlyIdentifiers"; import { type UpsertSchedule } from "../schedules"; -import { calculateNextScheduledTimestampFromNow } from "../utils/calculateNextSchedule.server"; import { BaseService, ServiceValidationError } from "./baseService.server"; import { CheckScheduleService } from "./checkSchedule.server"; import { scheduleEngine } from "../scheduleEngine.server"; -import { formatScheduleWindow, normalizeScheduleWindow } from "../scheduleWindow.server"; +import { + calculateNextScheduleRunTimes, + formatScheduleWindow, + normalizeScheduleWindow, +} from "../scheduleWindow.server"; import { scheduleWhereClause } from "~/models/schedules.server"; +import { env } from "~/env.server"; export type UpsertTaskScheduleServiceOptions = UpsertSchedule; @@ -81,7 +85,7 @@ export class UpsertTaskScheduleService extends BaseService { }, }); - return this.#createReturnObject(scheduleRecord, instances); + return this.#createReturnObject(scheduleRecord, instances, schedule.environments[0]); } async #createNewSchedule( @@ -237,7 +241,27 @@ export class UpsertTaskScheduleService extends BaseService { return { scheduleRecord }; } - #createReturnObject(taskSchedule: TaskSchedule, instances: InstanceWithEnvironment[]) { + #createReturnObject( + taskSchedule: TaskSchedule, + instances: InstanceWithEnvironment[], + environmentId: string + ) { + const instance = instances.find((instance) => instance.environmentId === environmentId); + if (!instance) { + throw new ServiceValidationError("Failed to find the schedule instance"); + } + + const [nextRun] = calculateNextScheduleRunTimes({ + cron: taskSchedule.generatorExpression, + timezone: taskSchedule.timezone, + deduplicationKey: taskSchedule.deduplicationKey, + environmentId: instance.environmentId, + schedulePhase: instance.schedulePhase, + phaseSecret: env.ENCRYPTION_KEY, + windowDurationSeconds: taskSchedule.windowDurationSeconds, + windowPercentage: taskSchedule.windowPercentage, + }); + return { id: taskSchedule.friendlyId, type: taskSchedule.type, @@ -251,10 +275,8 @@ export class UpsertTaskScheduleService extends BaseService { cronDescription: taskSchedule.generatorDescription, timezone: taskSchedule.timezone, window: formatScheduleWindow(taskSchedule), - nextRun: calculateNextScheduledTimestampFromNow( - taskSchedule.generatorExpression, - taskSchedule.timezone - ), + nextRun: nextRun.nominalAt, + nextRunEffectiveAt: nextRun.effectiveAt, environments: instances.map((instance) => ({ id: instance.environment.id, shortcode: instance.environment.shortcode, diff --git a/apps/webapp/test/scheduleWindow.test.ts b/apps/webapp/test/scheduleWindow.test.ts index afc4a0c088..df19bb8919 100644 --- a/apps/webapp/test/scheduleWindow.test.ts +++ b/apps/webapp/test/scheduleWindow.test.ts @@ -1,5 +1,7 @@ +import { SCHEDULE_PHASE_DENOMINATOR } from "@internal/schedule-engine"; import { describe, expect, it } from "vitest"; import { + calculateNextScheduleRunTimes, formatScheduleWindow, normalizeScheduleWindow, validateScheduleWindowSyntax, @@ -62,4 +64,47 @@ describe("schedule window persistence", () => { it("accepts an absolute window independently of the cron interval", () => { expect(validateScheduleWindowSyntax("30m")).toEqual({ valid: true }); }); + + it("calculates stable nominal and effective times", () => { + const [first, second] = calculateNextScheduleRunTimes({ + cron: "*/5 * * * *", + timezone: "UTC", + deduplicationKey: "five-minute-task", + environmentId: "env_123", + schedulePhase: SCHEDULE_PHASE_DENOMINATOR / 2, + phaseSecret: "test-secret", + windowDurationSeconds: null, + windowPercentage: 30, + from: new Date("2026-08-11T09:59:00.000Z"), + count: 2, + }); + + expect(first).toEqual({ + nominalAt: new Date("2026-08-11T10:00:00.000Z"), + effectiveAt: new Date("2026-08-11T10:00:45.000Z"), + }); + expect(second).toEqual({ + nominalAt: new Date("2026-08-11T10:05:00.000Z"), + effectiveAt: new Date("2026-08-11T10:05:45.000Z"), + }); + }); + + it("derives a stable phase when one has not been persisted", () => { + const input = { + cron: "0 * * * *", + timezone: "UTC", + deduplicationKey: "hourly-task", + environmentId: "env_123", + schedulePhase: null, + phaseSecret: "test-secret", + windowDurationSeconds: null, + windowPercentage: null, + from: new Date("2026-08-11T09:59:00.000Z"), + }; + + expect(calculateNextScheduleRunTimes(input)).toEqual(calculateNextScheduleRunTimes(input)); + expect(calculateNextScheduleRunTimes(input)[0].effectiveAt.getTime()).toBeGreaterThanOrEqual( + calculateNextScheduleRunTimes(input)[0].nominalAt.getTime() + ); + }); }); diff --git a/apps/webapp/test/schedules-api.e2e.full.test.ts b/apps/webapp/test/schedules-api.e2e.full.test.ts index 2924c30e10..565b728a73 100644 --- a/apps/webapp/test/schedules-api.e2e.full.test.ts +++ b/apps/webapp/test/schedules-api.e2e.full.test.ts @@ -29,15 +29,26 @@ describe("Schedules API windows", () => { timezone: "UTC", window: "30%", }); + expectAssignedTime(created, 18 * 60_000); const retrieveResponse = await server.webapp.fetch(`/api/v1/schedules/${created.id}`, { headers: authHeaders(apiKey), }); expect(retrieveResponse.status).toBe(200); - await expect(retrieveResponse.json()).resolves.toMatchObject({ + const retrieved = await retrieveResponse.json(); + expect(retrieved).toMatchObject({ id: created.id, window: "30%", }); + expectAssignedTime(retrieved, 18 * 60_000); + + const listResponse = await server.webapp.fetch("/api/v1/schedules", { + headers: authHeaders(apiKey), + }); + expect(listResponse.status).toBe(200); + const listed = await listResponse.json(); + expect(listed.data[0]).toMatchObject({ id: created.id }); + expectAssignedTime(listed.data[0], 18 * 60_000); const updateResponse = await server.webapp.fetch(`/api/v1/schedules/${created.id}`, { method: "PUT", @@ -49,10 +60,12 @@ describe("Schedules API windows", () => { }), }); expect(updateResponse.status).toBe(200); - await expect(updateResponse.json()).resolves.toMatchObject({ + const updated = await updateResponse.json(); + expect(updated).toMatchObject({ id: created.id, window: "2h", }); + expectAssignedTime(updated, 2 * 60 * 60_000); const clearResponse = await server.webapp.fetch(`/api/v1/schedules/${created.id}`, { method: "PUT", @@ -67,6 +80,26 @@ describe("Schedules API windows", () => { expect(cleared.id).toBe(created.id); expect(cleared).not.toHaveProperty("window"); + const deactivateResponse = await server.webapp.fetch( + `/api/v1/schedules/${created.id}/deactivate`, + { method: "POST", headers: authHeaders(apiKey) } + ); + expect(deactivateResponse.status).toBe(200); + await expect(deactivateResponse.json()).resolves.toMatchObject({ + active: false, + nextRun: null, + nextRunEffectiveAt: null, + }); + + const activateResponse = await server.webapp.fetch(`/api/v1/schedules/${created.id}/activate`, { + method: "POST", + headers: authHeaders(apiKey), + }); + expect(activateResponse.status).toBe(200); + const activated = await activateResponse.json(); + expect(activated.active).toBe(true); + expectAssignedTime(activated, 60_000); + const stored = await server.prisma.taskSchedule.findUniqueOrThrow({ where: { friendlyId: created.id }, select: { windowDurationSeconds: true, windowPercentage: true }, @@ -106,6 +139,38 @@ describe("Schedules API windows", () => { } }); + it("returns declarative schedule summaries with deployments", async () => { + const server = getTestServer(); + const { apiKey, project, environment } = await seedTestEnvironment(server.prisma); + const worker = await seedScheduledTask(server.prisma, project.id, environment.id); + const deployment = await server.prisma.workerDeployment.create({ + data: { + friendlyId: `deployment_${environment.id}`, + shortCode: environment.shortcode, + version: "20260811.1", + contentHash: `hash_${environment.id}`, + status: "DEPLOYED", + projectId: project.id, + environmentId: environment.id, + workerId: worker.id, + }, + }); + const response = await server.webapp.fetch(`/api/v1/deployments/${deployment.friendlyId}`, { + headers: authHeaders(apiKey), + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.worker.declarativeSchedules).toHaveLength(1); + expect(body.worker.declarativeSchedules[0]).toMatchObject({ + task: TASK_IDENTIFIER, + cron: "0 9 * * *", + timezone: "UTC", + window: "30m", + }); + expectAssignedTime(body.worker.declarativeSchedules[0], 30 * 60_000); + }); + it("returns safe errors for invalid windows", async () => { const server = getTestServer(); const { apiKey, project, environment } = await seedTestEnvironment(server.prisma); @@ -136,6 +201,17 @@ describe("Schedules API windows", () => { }); }); +function expectAssignedTime( + schedule: { nextRun: string; nextRunEffectiveAt: string }, + maximumDelayMs: number +) { + const nominalAt = new Date(schedule.nextRun).getTime(); + const effectiveAt = new Date(schedule.nextRunEffectiveAt).getTime(); + + expect(effectiveAt).toBeGreaterThanOrEqual(nominalAt); + expect(effectiveAt).toBeLessThan(nominalAt + maximumDelayMs); +} + function authHeaders(apiKey: string) { return { Authorization: `Bearer ${apiKey}`, @@ -153,7 +229,21 @@ async function seedScheduledTask( friendlyId: `worker_${runtimeEnvironmentId}`, contentHash: `hash_${runtimeEnvironmentId}`, version: "20260811.1", - metadata: {}, + metadata: { + packageVersion: "4.5.10", + contentHash: `hash_${runtimeEnvironmentId}`, + tasks: [ + { + id: TASK_IDENTIFIER, + filePath: "src/trigger/scheduled-task.ts", + schedule: { + cron: "0 9 * * *", + timezone: "UTC", + window: "30m", + }, + }, + ], + }, projectId, runtimeEnvironmentId, }, @@ -170,4 +260,6 @@ async function seedScheduledTask( triggerSource: "SCHEDULED", }, }); + + return worker; } diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index f82942d4e4..fdf5b06120 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -19,6 +19,10 @@ import type { CliApiClient } from "../apiClient.js"; import { buildWorker } from "../build/buildWorker.js"; import { resolveAlwaysExternal } from "../build/externals.js"; import { createContextArchive, getArchiveSize } from "../deploy/archiveContext.js"; +import { + formatDeclarativeScheduleOutput, + type DeclarativeScheduleSummary, +} from "../deploy/schedules.js"; import { S2 } from "@s2-dev/streamstore"; import { mkdir, readFile, unlink } from "node:fs/promises"; import { @@ -719,6 +723,8 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { $spinner.stop(`Successfully deployed version ${version}${deploySuffix}`); } + printDeclarativeSchedules(deploymentWithWorker.worker.declarativeSchedules ?? []); + const taskCount = deploymentWithWorker.worker?.tasks.length ?? 0; if (options.plain) { @@ -1328,6 +1334,8 @@ async function handleNativeBuildServerDeploy({ ); } + await printDeclarativeSchedulesForDeployment(apiClient, deployment.id); + if (!isLinksSupported) { log.info(`Test tasks: ${rawTestLink}`); } @@ -1433,6 +1441,34 @@ async function handleNativeBuildServerDeploy({ } } +function printDeclarativeSchedules(schedules: DeclarativeScheduleSummary[]) { + const lines = formatDeclarativeScheduleOutput(schedules); + if (lines.length === 0) { + return; + } + + console.log(); + console.log(lines.join("\n")); + console.log(); +} + +async function printDeclarativeSchedulesForDeployment( + apiClient: CliApiClient, + deploymentId: string +) { + const [error, result] = await tryCatch(apiClient.getDeployment(deploymentId)); + if (error) { + logger.debug("Failed to load declarative schedules after deployment", { error }); + return; + } + if (!result.success) { + logger.debug("Failed to load declarative schedules after deployment", { result }); + return; + } + + printDeclarativeSchedules(result.data.worker?.declarativeSchedules ?? []); +} + export function verifyDirectory(dir: string, projectPath: string) { if (dir !== "." && !isDirectory(projectPath)) { if (dir === "staging" || dir === "prod" || dir === "preview") { diff --git a/packages/cli-v3/src/deploy/schedules.test.ts b/packages/cli-v3/src/deploy/schedules.test.ts new file mode 100644 index 0000000000..3f77ab5ed9 --- /dev/null +++ b/packages/cli-v3/src/deploy/schedules.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { formatDeclarativeScheduleOutput } from "./schedules.js"; + +describe("declarative schedule deploy output", () => { + it("formats assigned times and explicit windows", () => { + expect( + formatDeclarativeScheduleOutput([ + { + task: "daily-report", + cron: "0 9 * * *", + timezone: "Europe/London", + window: "30m", + nextRun: new Date("2026-08-12T08:00:00.000Z"), + nextRunEffectiveAt: new Date("2026-08-12T08:17:45.000Z"), + }, + ]) + ).toEqual([ + "Declarative schedules", + " daily-report: 0 9 * * * (Europe/London) | window 30m | 2026-08-12 08:00:00 UTC -> 2026-08-12 08:17:45 UTC", + ]); + }); + + it("nudges schedules using the default window", () => { + const lines = formatDeclarativeScheduleOutput([ + { + task: "hourly-report", + cron: "0 * * * *", + timezone: "UTC", + nextRun: new Date("2026-08-12T09:00:00.000Z"), + nextRunEffectiveAt: new Date("2026-08-12T09:00:21.000Z"), + }, + ]); + + expect(lines).toContain( + 'Tip: 1 declarative schedule uses the default 60-second placement range. Add window: "30m" to the cron object to spread starts over a wider range.' + ); + }); + + it("returns no output when there are no declarative schedules", () => { + expect(formatDeclarativeScheduleOutput([])).toEqual([]); + }); +}); diff --git a/packages/cli-v3/src/deploy/schedules.ts b/packages/cli-v3/src/deploy/schedules.ts new file mode 100644 index 0000000000..587743fac9 --- /dev/null +++ b/packages/cli-v3/src/deploy/schedules.ts @@ -0,0 +1,41 @@ +import type { GetDeploymentResponseBody } from "@trigger.dev/core/v3"; + +type DeploymentWorker = NonNullable; +export type DeclarativeScheduleSummary = NonNullable< + DeploymentWorker["declarativeSchedules"] +>[number]; + +export function formatDeclarativeScheduleOutput(schedules: DeclarativeScheduleSummary[]): string[] { + if (schedules.length === 0) { + return []; + } + + const lines = ["Declarative schedules"]; + + for (const schedule of schedules) { + lines.push( + ` ${schedule.task}: ${schedule.cron} (${schedule.timezone}) | window ${ + schedule.window ?? "default 60s" + } | ${formatTime(schedule.nextRun)} -> ${formatTime(schedule.nextRunEffectiveAt)}` + ); + } + + const defaultWindowCount = schedules.filter((schedule) => schedule.window === undefined).length; + if (defaultWindowCount > 0) { + lines.push(""); + lines.push( + `Tip: ${defaultWindowCount} declarative schedule${defaultWindowCount === 1 ? "" : "s"} ${ + defaultWindowCount === 1 ? "uses" : "use" + } the default 60-second placement range. Add window: "30m" to the cron object to spread starts over a wider range.` + ); + } + + return lines; +} + +function formatTime(value: Date) { + return value + .toISOString() + .replace("T", " ") + .replace(/\.\d{3}Z$/, " UTC"); +} diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index f8de04bb4f..312997c9ba 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -819,6 +819,18 @@ export const GetDeploymentResponseBody = z.object({ exportName: z.string().optional(), }) ), + declarativeSchedules: z + .array( + z.object({ + task: z.string(), + cron: z.string(), + timezone: z.string(), + window: ScheduleWindow.optional(), + nextRun: z.coerce.date(), + nextRunEffectiveAt: z.coerce.date(), + }) + ) + .optional(), }) .optional(), integrationDeployments: z @@ -1079,7 +1091,10 @@ export const ScheduleObject = z.object({ generator: ScheduleGenerator, timezone: z.string(), window: ScheduleWindow.optional(), + /** The next nominal CRON time. */ nextRun: z.coerce.date().nullish(), + /** The stable assigned time for the next nominal CRON time. */ + nextRunEffectiveAt: z.coerce.date().nullish(), environments: z.array( z.object({ id: z.string(), diff --git a/packages/core/src/v3/schemas/schemas.ts b/packages/core/src/v3/schemas/schemas.ts index 7e95224f42..9a39eaa5c4 100644 --- a/packages/core/src/v3/schemas/schemas.ts +++ b/packages/core/src/v3/schemas/schemas.ts @@ -181,7 +181,7 @@ export type QueueManifest = z.infer; */ export const ScheduleWindow = z.string().min(1); -export type ScheduleWindow = z.infer; +export type ScheduleWindow = `${bigint}${"m" | "h" | "%"}`; export const ScheduleMetadata = z.object({ cron: z.string(), diff --git a/packages/trigger-sdk/src/v3/schedules/index.test.ts b/packages/trigger-sdk/src/v3/schedules/index.test.ts new file mode 100644 index 0000000000..f224b90c18 --- /dev/null +++ b/packages/trigger-sdk/src/v3/schedules/index.test.ts @@ -0,0 +1,55 @@ +import { resourceCatalog } from "@trigger.dev/core/v3"; +import { StandardResourceCatalog } from "@trigger.dev/core/v3/workers"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { task } from "./index.js"; + +describe("declarative schedule windows", () => { + beforeEach(() => { + resourceCatalog.disable(); + resourceCatalog.setGlobalResourceCatalog(new StandardResourceCatalog()); + resourceCatalog.setCurrentFileContext("scheduled.ts", "scheduled.ts"); + }); + + afterEach(() => { + resourceCatalog.clearCurrentFileContext(); + resourceCatalog.disable(); + }); + + it.each(["0m", "30m", "2h", "24h", "30%"] as const)( + "serializes the %s window into task metadata", + (window) => { + task({ + id: "daily-report", + cron: { + pattern: "0 9 * * *", + timezone: "Europe/London", + window, + environments: ["PRODUCTION"], + }, + run: async () => undefined, + }); + + expect(resourceCatalog.getTaskManifest("daily-report")?.schedule).toEqual({ + cron: "0 9 * * *", + timezone: "Europe/London", + window, + environments: ["PRODUCTION"], + }); + } + ); + + it("leaves the window undefined when it is omitted", () => { + task({ + id: "hourly-report", + cron: { pattern: "0 * * * *" }, + run: async () => undefined, + }); + + expect(resourceCatalog.getTaskManifest("hourly-report")?.schedule).toEqual({ + cron: "0 * * * *", + timezone: "UTC", + window: undefined, + environments: undefined, + }); + }); +}); diff --git a/packages/trigger-sdk/src/v3/schedules/index.ts b/packages/trigger-sdk/src/v3/schedules/index.ts index 7ab27e00e4..f1f00ade73 100644 --- a/packages/trigger-sdk/src/v3/schedules/index.ts +++ b/packages/trigger-sdk/src/v3/schedules/index.ts @@ -5,6 +5,7 @@ import type { InitOutput, OffsetLimitPagePromise, ScheduleObject, + ScheduleWindow, } from "@trigger.dev/core/v3"; import { TimezonesResult, @@ -31,11 +32,12 @@ export type ScheduleOptions< * "0 0 * * *" * ``` * - * 2. Or an object with a pattern, optional timezone, and optional environments + * 2. Or an object with a pattern, optional timezone, window, and environments * ```ts * { * pattern: "0 0 * * *", * timezone: "America/Los_Angeles", + * window: "30m", * environments: ["PRODUCTION", "STAGING"] * } * ``` @@ -47,6 +49,10 @@ export type ScheduleOptions< | { pattern: string; timezone?: string; + /** Optionally assign each run a stable time after its CRON time. + * Use a whole duration such as `"30m"` or `"2h"`, or a percentage such as `"30%"`. + */ + window?: ScheduleWindow; /** You can optionally specify which environments this schedule should run in. * When not specified, the schedule will run in all environments. * @@ -78,6 +84,7 @@ export function task Date: Thu, 13 Aug 2026 12:31:12 +0100 Subject: [PATCH 02/11] update effective schedule ux --- .../api.v1.deployments.$deploymentId.ts | 79 ++++++++++++++----- .../test/schedules-api.e2e.full.test.ts | 51 +++++++++++- packages/cli-v3/src/deploy/schedules.test.ts | 16 ++++ packages/cli-v3/src/deploy/schedules.ts | 5 +- packages/core/src/v3/schemas/api.ts | 3 +- 5 files changed, 130 insertions(+), 24 deletions(-) diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts index 6976fac4a7..14e69d28c0 100644 --- a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts @@ -6,6 +6,7 @@ import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { env } from "~/env.server"; import { calculateNextScheduleRunTimes, normalizeScheduleWindow } from "~/v3/scheduleWindow.server"; +import { nextScheduledTimestamps } from "~/v3/utils/calculateNextSchedule.server"; const ParamsSchema = z.object({ deploymentId: z.string(), @@ -58,39 +59,75 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const workerMetadata = deployment.worker ? BackgroundWorkerMetadata.safeParse(deployment.worker.metadata) : undefined; - const declarativeSchedules = workerMetadata?.success + const declarativeTasks = workerMetadata?.success ? workerMetadata.data.tasks.flatMap((task) => { + const schedule = task.schedule; if ( - !task.schedule || - (task.schedule.environments && - !task.schedule.environments.includes(authenticatedEnv.type)) + !schedule || + (schedule.environments && !schedule.environments.includes(authenticatedEnv.type)) ) { return []; } - const windowFields = normalizeScheduleWindow(task.schedule.window); - const [nextRun] = calculateNextScheduleRunTimes({ + return [{ id: task.id, schedule }]; + }) + : []; + const persistedDeclarativeSchedules = + declarativeTasks.length > 0 + ? await prisma.taskSchedule.findMany({ + where: { + type: "DECLARATIVE", + projectId: authenticatedEnv.projectId, + taskIdentifier: { in: declarativeTasks.map((task) => task.id) }, + instances: { some: { environmentId: authenticatedEnv.id } }, + }, + select: { + taskIdentifier: true, + deduplicationKey: true, + generatorExpression: true, + timezone: true, + windowDurationSeconds: true, + windowPercentage: true, + instances: { + where: { environmentId: authenticatedEnv.id }, + select: { schedulePhase: true }, + }, + }, + }) + : []; + const declarativeSchedules = declarativeTasks.map((task) => { + const windowFields = normalizeScheduleWindow(task.schedule.window); + const persistedSchedule = persistedDeclarativeSchedules.find( + (schedule) => + schedule.taskIdentifier === task.id && + schedule.generatorExpression === task.schedule.cron && + schedule.timezone === task.schedule.timezone && + schedule.windowDurationSeconds === windowFields.windowDurationSeconds && + schedule.windowPercentage === windowFields.windowPercentage + ); + const registeredNextRun = persistedSchedule + ? calculateNextScheduleRunTimes({ cron: task.schedule.cron, timezone: task.schedule.timezone, - deduplicationKey: task.id, + deduplicationKey: persistedSchedule.deduplicationKey, environmentId: authenticatedEnv.id, - schedulePhase: null, + schedulePhase: persistedSchedule.instances[0]?.schedulePhase ?? null, phaseSecret: env.ENCRYPTION_KEY, ...windowFields, - }); + })[0] + : undefined; - return [ - { - task: task.id, - cron: task.schedule.cron, - timezone: task.schedule.timezone, - window: task.schedule.window, - nextRun: nextRun.nominalAt, - nextRunEffectiveAt: nextRun.effectiveAt, - }, - ]; - }) - : []; + return { + task: task.id, + cron: task.schedule.cron, + timezone: task.schedule.timezone, + window: task.schedule.window, + nextRun: + registeredNextRun?.nominalAt ?? + nextScheduledTimestamps(task.schedule.cron, task.schedule.timezone, new Date())[0], + nextRunEffectiveAt: registeredNextRun?.effectiveAt ?? null, + }; + }); return json({ id: deployment.friendlyId, diff --git a/apps/webapp/test/schedules-api.e2e.full.test.ts b/apps/webapp/test/schedules-api.e2e.full.test.ts index 565b728a73..ac9cd8e9ee 100644 --- a/apps/webapp/test/schedules-api.e2e.full.test.ts +++ b/apps/webapp/test/schedules-api.e2e.full.test.ts @@ -155,6 +155,27 @@ describe("Schedules API windows", () => { workerId: worker.id, }, }); + await server.prisma.taskSchedule.create({ + data: { + friendlyId: `schedule_${environment.id}`, + projectId: project.id, + taskIdentifier: TASK_IDENTIFIER, + deduplicationKey: "persisted-schedule-identity", + generatorExpression: "0 9 * * *", + generatorDescription: "At 09:00", + timezone: "UTC", + type: "DECLARATIVE", + windowDurationSeconds: 30 * 60, + instances: { + create: { + environmentId: environment.id, + projectId: project.id, + schedulePhase: 0, + }, + }, + }, + }); + const response = await server.webapp.fetch(`/api/v1/deployments/${deployment.friendlyId}`, { headers: authHeaders(apiKey), }); @@ -168,7 +189,35 @@ describe("Schedules API windows", () => { timezone: "UTC", window: "30m", }); - expectAssignedTime(body.worker.declarativeSchedules[0], 30 * 60_000); + expect(body.worker.declarativeSchedules[0].nextRunEffectiveAt).toBe( + body.worker.declarativeSchedules[0].nextRun + ); + }); + + it("reports an unassigned effective time until a declarative schedule is registered", async () => { + const server = getTestServer(); + const { apiKey, project, environment } = await seedTestEnvironment(server.prisma); + const worker = await seedScheduledTask(server.prisma, project.id, environment.id); + const deployment = await server.prisma.workerDeployment.create({ + data: { + friendlyId: `deployment_unregistered_${environment.id}`, + shortCode: environment.shortcode, + version: "20260811.1", + contentHash: `hash_unregistered_${environment.id}`, + status: "DEPLOYED", + projectId: project.id, + environmentId: environment.id, + workerId: worker.id, + }, + }); + + const response = await server.webapp.fetch(`/api/v1/deployments/${deployment.friendlyId}`, { + headers: authHeaders(apiKey), + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.worker.declarativeSchedules[0].nextRunEffectiveAt).toBeNull(); }); it("returns safe errors for invalid windows", async () => { diff --git a/packages/cli-v3/src/deploy/schedules.test.ts b/packages/cli-v3/src/deploy/schedules.test.ts index 3f77ab5ed9..226bca7adf 100644 --- a/packages/cli-v3/src/deploy/schedules.test.ts +++ b/packages/cli-v3/src/deploy/schedules.test.ts @@ -20,6 +20,22 @@ describe("declarative schedule deploy output", () => { ]); }); + it("reports that an assigned time is pending registration", () => { + expect( + formatDeclarativeScheduleOutput([ + { + task: "daily-report", + cron: "0 9 * * *", + timezone: "UTC", + nextRun: new Date("2026-08-12T09:00:00.000Z"), + nextRunEffectiveAt: null, + }, + ]) + ).toContain( + " daily-report: 0 9 * * * (UTC) | window default 60s | next nominal 2026-08-12 09:00:00 UTC | next assigned time pending registration" + ); + }); + it("nudges schedules using the default window", () => { const lines = formatDeclarativeScheduleOutput([ { diff --git a/packages/cli-v3/src/deploy/schedules.ts b/packages/cli-v3/src/deploy/schedules.ts index 587743fac9..c78546e4ea 100644 --- a/packages/cli-v3/src/deploy/schedules.ts +++ b/packages/cli-v3/src/deploy/schedules.ts @@ -13,10 +13,13 @@ export function formatDeclarativeScheduleOutput(schedules: DeclarativeScheduleSu const lines = ["Declarative schedules"]; for (const schedule of schedules) { + const timing = schedule.nextRunEffectiveAt + ? `${formatTime(schedule.nextRun)} -> ${formatTime(schedule.nextRunEffectiveAt)}` + : `next nominal ${formatTime(schedule.nextRun)} | next assigned time pending registration`; lines.push( ` ${schedule.task}: ${schedule.cron} (${schedule.timezone}) | window ${ schedule.window ?? "default 60s" - } | ${formatTime(schedule.nextRun)} -> ${formatTime(schedule.nextRunEffectiveAt)}` + } | ${timing}` ); } diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 312997c9ba..a358477e80 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -827,7 +827,8 @@ export const GetDeploymentResponseBody = z.object({ timezone: z.string(), window: ScheduleWindow.optional(), nextRun: z.coerce.date(), - nextRunEffectiveAt: z.coerce.date(), + /** Null until the deployment's schedule is registered in this environment. */ + nextRunEffectiveAt: z.coerce.date().nullable(), }) ) .optional(), From 98616fdf8ee7e86651c35c7d2a7cb4cb669d9352 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Thu, 13 Aug 2026 12:58:42 +0100 Subject: [PATCH 03/11] fix instructions --- .../route.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx index e5840e6c72..943d12006d 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx @@ -367,7 +367,7 @@ export function UpsertScheduleForm({ defaultValue={schedule?.window} /> - Assigns each run a stable time after its CRON time. Use minutes, hours, days, or a + Assigns each run a stable time after its CRON time. Use minutes, hours, or a percentage of the interval. Schedules always use at least a 60-second placement range. From 3579024a096e25679177ead764541e15cc765e93 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Thu, 13 Aug 2026 14:24:44 +0100 Subject: [PATCH 04/11] improve window type --- apps/webapp/app/v3/scheduleWindow.server.ts | 20 +-- .../src/engine/scheduleTiming.ts | 49 ++----- .../src/v3/schemas/scheduleWindow.test.ts | 63 +++++++++ .../core/src/v3/schemas/scheduleWindow.ts | 129 ++++++++++++++++++ packages/core/src/v3/schemas/schemas.ts | 12 +- packages/trigger-sdk/src/v3/schedules/api.ts | 25 +++- .../trigger-sdk/src/v3/schedules/index.ts | 22 +-- .../src/v3/schedules/index.types.test.ts | 98 +++++++++++++ 8 files changed, 346 insertions(+), 72 deletions(-) create mode 100644 packages/core/src/v3/schemas/scheduleWindow.test.ts create mode 100644 packages/core/src/v3/schemas/scheduleWindow.ts create mode 100644 packages/trigger-sdk/src/v3/schedules/index.types.test.ts diff --git a/apps/webapp/app/v3/scheduleWindow.server.ts b/apps/webapp/app/v3/scheduleWindow.server.ts index b099c35a5d..c0bf8cea69 100644 --- a/apps/webapp/app/v3/scheduleWindow.server.ts +++ b/apps/webapp/app/v3/scheduleWindow.server.ts @@ -1,9 +1,9 @@ +import { calculateEffectiveScheduleTime, calculateSchedulePhase } from "@internal/schedule-engine"; import { - calculateEffectiveScheduleTime, - calculateSchedulePhase, + ScheduleWindow, parseScheduleWindow, type NormalizedScheduleWindow, -} from "@internal/schedule-engine"; +} from "@trigger.dev/core/v3"; import { nextScheduledTimestamps } from "./utils/calculateNextSchedule.server"; const SECONDS_PER_UNIT = { @@ -127,13 +127,13 @@ export function validateScheduleWindowSyntax( return { valid: true }; } - try { - parseScheduleWindow(window); + const result = ScheduleWindow.safeParse(window); + if (result.success) { return { valid: true }; - } catch (error) { - return { - valid: false, - message: error instanceof Error ? error.message : String(error), - }; } + + return { + valid: false, + message: result.error.issues[0]?.message ?? "Invalid schedule window", + }; } diff --git a/internal-packages/schedule-engine/src/engine/scheduleTiming.ts b/internal-packages/schedule-engine/src/engine/scheduleTiming.ts index 003240e952..3b8931cef3 100644 --- a/internal-packages/schedule-engine/src/engine/scheduleTiming.ts +++ b/internal-packages/schedule-engine/src/engine/scheduleTiming.ts @@ -1,16 +1,19 @@ +import { + MAX_ABSOLUTE_SCHEDULE_WINDOW_SECONDS, + parseScheduleWindow, + type NormalizedScheduleWindow, +} from "@trigger.dev/core/v3"; import { createHmac } from "node:crypto"; +export { MAX_ABSOLUTE_SCHEDULE_WINDOW_SECONDS, parseScheduleWindow }; +export type { NormalizedScheduleWindow }; + export const SCHEDULE_PHASE_DENOMINATOR = 2_147_483_648; export const MAX_SCHEDULE_PHASE = SCHEDULE_PHASE_DENOMINATOR - 1; export const MINIMUM_SCHEDULE_RANGE_MS = 60_000; -export const MAX_ABSOLUTE_SCHEDULE_WINDOW_SECONDS = 24 * 60 * 60; const PERCENTAGE_DENOMINATOR = 100; -export type NormalizedScheduleWindow = - | { type: "duration"; durationSeconds: number } - | { type: "percentage"; percentage: number }; - export type SchedulePhaseInput = { secret: string | Buffer; environmentId: string; @@ -28,42 +31,6 @@ export type EffectiveScheduleTime = { windowWasCappedToInterval: boolean; }; -/** - * Parses the public schedule-window syntax. - * - * Durations are non-negative whole minutes or hours up to 24 hours. - * Percentages are whole numbers from 0% through 100%. - */ -export function parseScheduleWindow(value: string): NormalizedScheduleWindow { - const durationMatch = /^(0|[1-9]\d*)([mh])$/.exec(value); - - if (durationMatch) { - const amount = Number(durationMatch[1]); - const unit = durationMatch[2] as "m" | "h"; - const unitSeconds = unit === "m" ? 60 : 3_600; - const durationSeconds = amount * unitSeconds; - - if ( - !Number.isSafeInteger(durationSeconds) || - durationSeconds > MAX_ABSOLUTE_SCHEDULE_WINDOW_SECONDS - ) { - throw new RangeError("Schedule window duration cannot exceed 24 hours"); - } - - return { type: "duration", durationSeconds }; - } - - const percentageMatch = /^(0|[1-9]\d?|100)%$/.exec(value); - - if (percentageMatch) { - return { type: "percentage", percentage: Number(percentageMatch[1]) }; - } - - throw new TypeError( - 'Schedule window must be a whole duration such as "0m", "30m", or "24h", or a percentage such as "30%"' - ); -} - export function validateScheduleWindow(window: NormalizedScheduleWindow): void { if (window.type === "duration") { if ( diff --git a/packages/core/src/v3/schemas/scheduleWindow.test.ts b/packages/core/src/v3/schemas/scheduleWindow.test.ts new file mode 100644 index 0000000000..3868a42618 --- /dev/null +++ b/packages/core/src/v3/schemas/scheduleWindow.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, expectTypeOf, it } from "vitest"; +import { + ScheduleWindow, + parseScheduleWindow, + type ValidatedScheduleWindow, +} from "./scheduleWindow.js"; + +describe("ScheduleWindow", () => { + it.each(["0m", "30m", "1440m", "0h", "2h", "24h", "0%", "30%", "100%"])( + "accepts %s", + (window) => { + expect(ScheduleWindow.safeParse(window).success).toBe(true); + } + ); + + it.each([ + "", + "00m", + "01m", + "1.5h", + "0d", + "1d", + "25h", + "1441m", + "30s", + "-1m", + "0.5%", + "101%", + "1e2%", + " 30m", + "30m ", + ])("rejects %j", (window) => { + expect(ScheduleWindow.safeParse(window).success).toBe(false); + }); + + it("normalizes valid windows", () => { + expect(parseScheduleWindow("30m")).toEqual({ + type: "duration", + durationSeconds: 1_800, + }); + expect(parseScheduleWindow("25%")).toEqual({ + type: "percentage", + percentage: 25, + }); + }); + + it("validates literal types without rejecting runtime strings", () => { + expectTypeOf>().toEqualTypeOf<"30m">(); + expectTypeOf>().toEqualTypeOf<"24h">(); + expectTypeOf>().toEqualTypeOf<"100%">(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf< + ValidatedScheduleWindow<"25h"> + >().toEqualTypeOf<"⛔ window duration cannot exceed 24 hours">(); + expectTypeOf< + ValidatedScheduleWindow<"101%"> + >().toEqualTypeOf<"⛔ percentage cannot exceed 100%">(); + expectTypeOf< + ValidatedScheduleWindow<"1d"> + >().toEqualTypeOf<'⛔ window must look like "30m", "2h", or "50%"'>(); + }); +}); diff --git a/packages/core/src/v3/schemas/scheduleWindow.ts b/packages/core/src/v3/schemas/scheduleWindow.ts new file mode 100644 index 0000000000..8e89c2a6ca --- /dev/null +++ b/packages/core/src/v3/schemas/scheduleWindow.ts @@ -0,0 +1,129 @@ +import { z } from "zod"; + +export const MAX_ABSOLUTE_SCHEDULE_WINDOW_SECONDS = 24 * 60 * 60; + +export type NormalizedScheduleWindow = + | { type: "duration"; durationSeconds: number } + | { type: "percentage"; percentage: number }; + +type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"; + +type DigitsBelow = { + "0": never; + "1": "0"; + "2": "0" | "1"; + "3": "0" | "1" | "2"; + "4": "0" | "1" | "2" | "3"; + "5": "0" | "1" | "2" | "3" | "4"; + "6": "0" | "1" | "2" | "3" | "4" | "5"; + "7": "0" | "1" | "2" | "3" | "4" | "5" | "6"; + "8": "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7"; + "9": "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8"; +}; + +type DigitLength< + Value extends string, + Result extends 0[] = [], +> = Value extends `${Digit}${infer Rest}` ? DigitLength : Result; + +type CompareEqualLength< + A extends string, + B extends string, +> = A extends `${infer ADigit extends Digit}${infer ARest}` + ? B extends `${infer BDigit extends Digit}${infer BRest}` + ? ADigit extends BDigit + ? CompareEqualLength + : ADigit extends DigitsBelow[BDigit] + ? "lt" + : "gt" + : "eq" + : "eq"; + +type DecimalStringLTE = + DigitLength extends DigitLength + ? CompareEqualLength extends "gt" + ? false + : true + : DigitLength extends [...DigitLength, ...0[]] + ? true + : false; + +type IsCanonicalUnsignedInteger = Value extends `${bigint}` + ? Value extends `-${string}` + ? false + : true + : false; + +/** The literal validation error for a configured schedule window, or `never` when valid. */ +export type ScheduleWindowError = string extends Window + ? never + : Window extends `${infer Amount}m` + ? IsCanonicalUnsignedInteger extends false + ? "⛔ window must be a whole non-negative number" + : DecimalStringLTE extends true + ? never + : "⛔ window duration cannot exceed 24 hours" + : Window extends `${infer Amount}h` + ? IsCanonicalUnsignedInteger extends false + ? "⛔ window must be a whole non-negative number" + : DecimalStringLTE extends true + ? never + : "⛔ window duration cannot exceed 24 hours" + : Window extends `${infer Amount}%` + ? IsCanonicalUnsignedInteger extends false + ? "⛔ percentage must be a whole non-negative number" + : DecimalStringLTE extends true + ? never + : "⛔ percentage cannot exceed 100%" + : '⛔ window must look like "30m", "2h", or "50%"'; + +/** + * Preserves valid schedule-window literals and replaces invalid literals with a descriptive type + * error. Wide `string` values pass through for authoritative runtime validation. + */ +export type ValidatedScheduleWindow = Window extends string + ? [ScheduleWindowError] extends [never] + ? Window + : ScheduleWindowError + : Window; + +/** Parses and normalizes the public schedule-window syntax. */ +export function parseScheduleWindow(value: string): NormalizedScheduleWindow { + const durationMatch = /^(0|[1-9]\d*)([mh])$/.exec(value); + + if (durationMatch) { + const amount = Number(durationMatch[1]); + const unit = durationMatch[2] as "m" | "h"; + const durationSeconds = amount * (unit === "m" ? 60 : 3_600); + + if ( + !Number.isSafeInteger(durationSeconds) || + durationSeconds > MAX_ABSOLUTE_SCHEDULE_WINDOW_SECONDS + ) { + throw new RangeError("Schedule window duration cannot exceed 24 hours"); + } + + return { type: "duration", durationSeconds }; + } + + const percentageMatch = /^(0|[1-9]\d?|100)%$/.exec(value); + if (percentageMatch) { + return { type: "percentage", percentage: Number(percentageMatch[1]) }; + } + + throw new TypeError( + 'Schedule window must be a whole duration such as "0m", "30m", or "24h", or a percentage such as "30%"' + ); +} + +/** Runtime authority for public schedule-window values. */ +export const ScheduleWindow = z.string().superRefine((value, ctx) => { + try { + parseScheduleWindow(value); + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: error instanceof Error ? error.message : String(error), + }); + } +}); diff --git a/packages/core/src/v3/schemas/schemas.ts b/packages/core/src/v3/schemas/schemas.ts index 9a39eaa5c4..72b6d9ed58 100644 --- a/packages/core/src/v3/schemas/schemas.ts +++ b/packages/core/src/v3/schemas/schemas.ts @@ -7,6 +7,9 @@ import { TaskRunExecution, V3TaskRunExecution, } from "./common.js"; +import { ScheduleWindow } from "./scheduleWindow.js"; + +export * from "./scheduleWindow.js"; /* WARNING: Never import anything from ./messages here. If it's needed in both, put it here instead. @@ -174,15 +177,6 @@ export const QueueManifest = z.object({ export type QueueManifest = z.infer; -/** - * A delay window after a nominal cron tick. - * - * The server's schedule timing domain validates and normalizes the public syntax. - */ -export const ScheduleWindow = z.string().min(1); - -export type ScheduleWindow = `${bigint}${"m" | "h" | "%"}`; - export const ScheduleMetadata = z.object({ cron: z.string(), timezone: z.string(), diff --git a/packages/trigger-sdk/src/v3/schedules/api.ts b/packages/trigger-sdk/src/v3/schedules/api.ts index 65fe4d4294..4264b0d3ef 100644 --- a/packages/trigger-sdk/src/v3/schedules/api.ts +++ b/packages/trigger-sdk/src/v3/schedules/api.ts @@ -1,6 +1,23 @@ -export type { - CreateScheduleOptions, - ScheduledTaskPayload, +import type { + CreateScheduleOptions as CoreCreateScheduleOptions, ListScheduleOptions, - UpdateScheduleOptions, + ScheduledTaskPayload, + UpdateScheduleOptions as CoreUpdateScheduleOptions, + ValidatedScheduleWindow, } from "@trigger.dev/core/v3"; + +export type { ListScheduleOptions, ScheduledTaskPayload }; + +export type CreateScheduleOptions = Omit< + CoreCreateScheduleOptions, + "window" +> & { + window?: ValidatedScheduleWindow; +}; + +export type UpdateScheduleOptions = Omit< + CoreUpdateScheduleOptions, + "window" +> & { + window?: ValidatedScheduleWindow; +}; diff --git a/packages/trigger-sdk/src/v3/schedules/index.ts b/packages/trigger-sdk/src/v3/schedules/index.ts index f1f00ade73..ad9f1538e8 100644 --- a/packages/trigger-sdk/src/v3/schedules/index.ts +++ b/packages/trigger-sdk/src/v3/schedules/index.ts @@ -5,7 +5,7 @@ import type { InitOutput, OffsetLimitPagePromise, ScheduleObject, - ScheduleWindow, + ValidatedScheduleWindow, } from "@trigger.dev/core/v3"; import { TimezonesResult, @@ -24,6 +24,7 @@ export type ScheduleOptions< TIdentifier extends string, TOutput, TInitOutput extends InitOutput, + TWindow extends string | undefined = string | undefined, > = TaskOptions & { /** You can optionally specify a CRON schedule on your task. You can also dynamically add a schedule in the dashboard or using the SDK functions. * @@ -52,7 +53,7 @@ export type ScheduleOptions< /** Optionally assign each run a stable time after its CRON time. * Use a whole duration such as `"30m"` or `"2h"`, or a percentage such as `"30%"`. */ - window?: ScheduleWindow; + window?: ValidatedScheduleWindow; /** You can optionally specify which environments this schedule should run in. * When not specified, the schedule will run in all environments. * @@ -70,8 +71,13 @@ export type ScheduleOptions< }; }; -export function task( - params: ScheduleOptions +export function task< + TIdentifier extends string, + TOutput, + TInitOutput extends InitOutput, + const TWindow extends string | undefined = undefined, +>( + params: ScheduleOptions ): Task { const task = createTask(params); @@ -111,8 +117,8 @@ export function task( + options: SchedulesAPI.CreateScheduleOptions, requestOptions?: ApiRequestOptions ): ApiPromise { const apiClient = apiClientManager.clientOrThrow(); @@ -185,9 +191,9 @@ export function retrieve( * @param options.externalId - An optional external identifier for the schedule * @returns The updated schedule */ -export function update( +export function update( scheduleId: string, - options: SchedulesAPI.UpdateScheduleOptions, + options: SchedulesAPI.UpdateScheduleOptions, requestOptions?: ApiRequestOptions ): ApiPromise { const apiClient = apiClientManager.clientOrThrow(); diff --git a/packages/trigger-sdk/src/v3/schedules/index.types.test.ts b/packages/trigger-sdk/src/v3/schedules/index.types.test.ts new file mode 100644 index 0000000000..9db62ef0bc --- /dev/null +++ b/packages/trigger-sdk/src/v3/schedules/index.types.test.ts @@ -0,0 +1,98 @@ +import { describe, it } from "vitest"; +import { create, task, update } from "./index.js"; + +declare const runtimeWindow: string; + +function assertDeclarativeScheduleWindowTypes() { + task({ + id: "valid-window", + cron: { pattern: "*/5 * * * *", window: "30m" }, + run: async () => undefined, + }); + task({ + id: "runtime-window", + cron: { pattern: "*/5 * * * *", window: runtimeWindow }, + run: async () => undefined, + }); + + task({ + id: "duration-too-large", + // @ts-expect-error Schedule windows cannot exceed 24 hours. + cron: { pattern: "*/5 * * * *", window: "25h" }, + run: async () => undefined, + }); + task({ + id: "percentage-too-large", + // @ts-expect-error Schedule window percentages cannot exceed 100%. + cron: { pattern: "*/5 * * * *", window: "101%" }, + run: async () => undefined, + }); + task({ + id: "unsupported-unit", + // @ts-expect-error Days are not a supported schedule-window unit. + cron: { pattern: "*/5 * * * *", window: "1d" }, + run: async () => undefined, + }); + task({ + id: "fractional-window", + // @ts-expect-error Schedule windows must use whole numbers. + cron: { pattern: "*/5 * * * *", window: "1.5m" }, + run: async () => undefined, + }); + task({ + id: "negative-window", + // @ts-expect-error Schedule windows must be non-negative. + cron: { pattern: "*/5 * * * *", window: "-1m" }, + run: async () => undefined, + }); + task({ + id: "leading-zero-window", + // @ts-expect-error Schedule windows must use canonical whole numbers. + cron: { pattern: "*/5 * * * *", window: "01m" }, + run: async () => undefined, + }); +} + +function assertImperativeScheduleWindowTypes() { + create({ + task: "scheduled-task", + cron: "*/5 * * * *", + deduplicationKey: "valid", + window: "50%", + }); + create({ + task: "scheduled-task", + cron: "*/5 * * * *", + deduplicationKey: "runtime", + window: runtimeWindow, + }); + update("schedule_123", { + task: "scheduled-task", + cron: "*/5 * * * *", + window: "1440m", + }); + + create({ + task: "scheduled-task", + cron: "*/5 * * * *", + deduplicationKey: "invalid", + // @ts-expect-error Schedule windows cannot exceed 24 hours. + window: "1441m", + }); + update("schedule_123", { + task: "scheduled-task", + cron: "*/5 * * * *", + // @ts-expect-error Schedule windows must use a supported unit. + window: "30s", + }); +} + +describe("schedule window types", () => { + it("validates declarative schedule literals", () => { + void assertDeclarativeScheduleWindowTypes; + }); + + it("validates imperative schedule literals", () => { + void assertImperativeScheduleWindowTypes; + }); +}); From 5dc78f69fe3a39e07ba180a281328eb7b620c6e9 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Thu, 13 Aug 2026 14:43:32 +0100 Subject: [PATCH 05/11] fix lint, format --- apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts index 14e69d28c0..9f2b87e3ac 100644 --- a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts @@ -1,7 +1,7 @@ import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; import { BackgroundWorkerMetadata, type GetDeploymentResponseBody } from "@trigger.dev/core/v3"; import { z } from "zod"; -import { prisma } from "~/db.server"; +import { boundedIn, prisma } from "~/db.server"; import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { env } from "~/env.server"; @@ -78,7 +78,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { where: { type: "DECLARATIVE", projectId: authenticatedEnv.projectId, - taskIdentifier: { in: declarativeTasks.map((task) => task.id) }, + taskIdentifier: { in: boundedIn(declarativeTasks.map((task) => task.id)) }, instances: { some: { environmentId: authenticatedEnv.id } }, }, select: { From ad9a7fa4600bbe24f593eec0f52bac9bb8891a36 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Thu, 13 Aug 2026 15:35:45 +0100 Subject: [PATCH 06/11] test(webapp): expect invalid schedule windows at API boundary --- apps/webapp/test/schedules-api.e2e.full.test.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/apps/webapp/test/schedules-api.e2e.full.test.ts b/apps/webapp/test/schedules-api.e2e.full.test.ts index ac9cd8e9ee..de8af9526f 100644 --- a/apps/webapp/test/schedules-api.e2e.full.test.ts +++ b/apps/webapp/test/schedules-api.e2e.full.test.ts @@ -225,14 +225,9 @@ describe("Schedules API windows", () => { const { apiKey, project, environment } = await seedTestEnvironment(server.prisma); await seedScheduledTask(server.prisma, project.id, environment.id); - const invalidRequests = [ - { window: 30, expectedStatus: 400 }, - { window: "30.5%", expectedStatus: 422 }, - { window: "1d", expectedStatus: 422 }, - { window: "25h", expectedStatus: 422 }, - ]; + const invalidWindows = [30, "30.5%", "1d", "25h"]; - for (const [index, { window, expectedStatus }] of invalidRequests.entries()) { + for (const [index, window] of invalidWindows.entries()) { const response = await server.webapp.fetch("/api/v1/schedules", { method: "POST", headers: authHeaders(apiKey), @@ -244,8 +239,7 @@ describe("Schedules API windows", () => { }), }); - expect(response.status).toBe(expectedStatus); - await expect(response.json()).resolves.toHaveProperty("error"); + expect(response.status).toBe(400); } }); }); From 9616d5d149f0d423c2132f2913c2ffe9ea4d4c53 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Thu, 13 Aug 2026 17:58:59 +0100 Subject: [PATCH 07/11] remove cli output --- .changeset/smooth-schedule-windows.md | 3 +- .../api.v1.deployments.$deploymentId.ts | 81 +-------------- .../test/schedules-api.e2e.full.test.ts | 99 +------------------ packages/cli-v3/src/commands/deploy.ts | 36 ------- packages/cli-v3/src/deploy/schedules.test.ts | 58 ----------- packages/cli-v3/src/deploy/schedules.ts | 44 --------- packages/core/src/v3/schemas/api.ts | 13 --- 7 files changed, 4 insertions(+), 330 deletions(-) delete mode 100644 packages/cli-v3/src/deploy/schedules.test.ts delete mode 100644 packages/cli-v3/src/deploy/schedules.ts diff --git a/.changeset/smooth-schedule-windows.md b/.changeset/smooth-schedule-windows.md index c583852bc8..6e7bba2eb7 100644 --- a/.changeset/smooth-schedule-windows.md +++ b/.changeset/smooth-schedule-windows.md @@ -1,7 +1,6 @@ --- "@trigger.dev/core": patch "@trigger.dev/sdk": patch -"trigger.dev": patch --- -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. +Define stable execution windows on declarative scheduled tasks. Schedule API responses now expose both the nominal CRON time and its assigned time, while the dashboard shows configured windows and upcoming assignments. diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts index 9f2b87e3ac..6b7accd029 100644 --- a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts @@ -1,12 +1,9 @@ import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; -import { BackgroundWorkerMetadata, type GetDeploymentResponseBody } from "@trigger.dev/core/v3"; +import { type GetDeploymentResponseBody } from "@trigger.dev/core/v3"; import { z } from "zod"; -import { boundedIn, prisma } from "~/db.server"; +import { prisma } from "~/db.server"; import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; -import { env } from "~/env.server"; -import { calculateNextScheduleRunTimes, normalizeScheduleWindow } from "~/v3/scheduleWindow.server"; -import { nextScheduledTimestamps } from "~/v3/utils/calculateNextSchedule.server"; const ParamsSchema = z.object({ deploymentId: z.string(), @@ -56,79 +53,6 @@ export async function loader({ request, params }: LoaderFunctionArgs) { return json({ error: "Deployment not found" }, { status: 404 }); } - const workerMetadata = deployment.worker - ? BackgroundWorkerMetadata.safeParse(deployment.worker.metadata) - : undefined; - const declarativeTasks = workerMetadata?.success - ? workerMetadata.data.tasks.flatMap((task) => { - const schedule = task.schedule; - if ( - !schedule || - (schedule.environments && !schedule.environments.includes(authenticatedEnv.type)) - ) { - return []; - } - - return [{ id: task.id, schedule }]; - }) - : []; - const persistedDeclarativeSchedules = - declarativeTasks.length > 0 - ? await prisma.taskSchedule.findMany({ - where: { - type: "DECLARATIVE", - projectId: authenticatedEnv.projectId, - taskIdentifier: { in: boundedIn(declarativeTasks.map((task) => task.id)) }, - instances: { some: { environmentId: authenticatedEnv.id } }, - }, - select: { - taskIdentifier: true, - deduplicationKey: true, - generatorExpression: true, - timezone: true, - windowDurationSeconds: true, - windowPercentage: true, - instances: { - where: { environmentId: authenticatedEnv.id }, - select: { schedulePhase: true }, - }, - }, - }) - : []; - const declarativeSchedules = declarativeTasks.map((task) => { - const windowFields = normalizeScheduleWindow(task.schedule.window); - const persistedSchedule = persistedDeclarativeSchedules.find( - (schedule) => - schedule.taskIdentifier === task.id && - schedule.generatorExpression === task.schedule.cron && - schedule.timezone === task.schedule.timezone && - schedule.windowDurationSeconds === windowFields.windowDurationSeconds && - schedule.windowPercentage === windowFields.windowPercentage - ); - const registeredNextRun = persistedSchedule - ? calculateNextScheduleRunTimes({ - cron: task.schedule.cron, - timezone: task.schedule.timezone, - deduplicationKey: persistedSchedule.deduplicationKey, - environmentId: authenticatedEnv.id, - schedulePhase: persistedSchedule.instances[0]?.schedulePhase ?? null, - phaseSecret: env.ENCRYPTION_KEY, - ...windowFields, - })[0] - : undefined; - - return { - task: task.id, - cron: task.schedule.cron, - timezone: task.schedule.timezone, - window: task.schedule.window, - nextRun: - registeredNextRun?.nominalAt ?? - nextScheduledTimestamps(task.schedule.cron, task.schedule.timezone, new Date())[0], - nextRunEffectiveAt: registeredNextRun?.effectiveAt ?? null, - }; - }); - return json({ id: deployment.friendlyId, status: deployment.status, @@ -151,7 +75,6 @@ export async function loader({ request, params }: LoaderFunctionArgs) { filePath: task.filePath, exportName: task.exportName ?? "@deprecated", })), - declarativeSchedules, } : undefined, integrationDeployments: diff --git a/apps/webapp/test/schedules-api.e2e.full.test.ts b/apps/webapp/test/schedules-api.e2e.full.test.ts index de8af9526f..a2f6ab72e3 100644 --- a/apps/webapp/test/schedules-api.e2e.full.test.ts +++ b/apps/webapp/test/schedules-api.e2e.full.test.ts @@ -139,87 +139,6 @@ describe("Schedules API windows", () => { } }); - it("returns declarative schedule summaries with deployments", async () => { - const server = getTestServer(); - const { apiKey, project, environment } = await seedTestEnvironment(server.prisma); - const worker = await seedScheduledTask(server.prisma, project.id, environment.id); - const deployment = await server.prisma.workerDeployment.create({ - data: { - friendlyId: `deployment_${environment.id}`, - shortCode: environment.shortcode, - version: "20260811.1", - contentHash: `hash_${environment.id}`, - status: "DEPLOYED", - projectId: project.id, - environmentId: environment.id, - workerId: worker.id, - }, - }); - await server.prisma.taskSchedule.create({ - data: { - friendlyId: `schedule_${environment.id}`, - projectId: project.id, - taskIdentifier: TASK_IDENTIFIER, - deduplicationKey: "persisted-schedule-identity", - generatorExpression: "0 9 * * *", - generatorDescription: "At 09:00", - timezone: "UTC", - type: "DECLARATIVE", - windowDurationSeconds: 30 * 60, - instances: { - create: { - environmentId: environment.id, - projectId: project.id, - schedulePhase: 0, - }, - }, - }, - }); - - const response = await server.webapp.fetch(`/api/v1/deployments/${deployment.friendlyId}`, { - headers: authHeaders(apiKey), - }); - - expect(response.status).toBe(200); - const body = await response.json(); - expect(body.worker.declarativeSchedules).toHaveLength(1); - expect(body.worker.declarativeSchedules[0]).toMatchObject({ - task: TASK_IDENTIFIER, - cron: "0 9 * * *", - timezone: "UTC", - window: "30m", - }); - expect(body.worker.declarativeSchedules[0].nextRunEffectiveAt).toBe( - body.worker.declarativeSchedules[0].nextRun - ); - }); - - it("reports an unassigned effective time until a declarative schedule is registered", async () => { - const server = getTestServer(); - const { apiKey, project, environment } = await seedTestEnvironment(server.prisma); - const worker = await seedScheduledTask(server.prisma, project.id, environment.id); - const deployment = await server.prisma.workerDeployment.create({ - data: { - friendlyId: `deployment_unregistered_${environment.id}`, - shortCode: environment.shortcode, - version: "20260811.1", - contentHash: `hash_unregistered_${environment.id}`, - status: "DEPLOYED", - projectId: project.id, - environmentId: environment.id, - workerId: worker.id, - }, - }); - - const response = await server.webapp.fetch(`/api/v1/deployments/${deployment.friendlyId}`, { - headers: authHeaders(apiKey), - }); - - expect(response.status).toBe(200); - const body = await response.json(); - expect(body.worker.declarativeSchedules[0].nextRunEffectiveAt).toBeNull(); - }); - it("returns safe errors for invalid windows", async () => { const server = getTestServer(); const { apiKey, project, environment } = await seedTestEnvironment(server.prisma); @@ -272,21 +191,7 @@ async function seedScheduledTask( friendlyId: `worker_${runtimeEnvironmentId}`, contentHash: `hash_${runtimeEnvironmentId}`, version: "20260811.1", - metadata: { - packageVersion: "4.5.10", - contentHash: `hash_${runtimeEnvironmentId}`, - tasks: [ - { - id: TASK_IDENTIFIER, - filePath: "src/trigger/scheduled-task.ts", - schedule: { - cron: "0 9 * * *", - timezone: "UTC", - window: "30m", - }, - }, - ], - }, + metadata: {}, projectId, runtimeEnvironmentId, }, @@ -303,6 +208,4 @@ async function seedScheduledTask( triggerSource: "SCHEDULED", }, }); - - return worker; } diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index fdf5b06120..f82942d4e4 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -19,10 +19,6 @@ import type { CliApiClient } from "../apiClient.js"; import { buildWorker } from "../build/buildWorker.js"; import { resolveAlwaysExternal } from "../build/externals.js"; import { createContextArchive, getArchiveSize } from "../deploy/archiveContext.js"; -import { - formatDeclarativeScheduleOutput, - type DeclarativeScheduleSummary, -} from "../deploy/schedules.js"; import { S2 } from "@s2-dev/streamstore"; import { mkdir, readFile, unlink } from "node:fs/promises"; import { @@ -723,8 +719,6 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { $spinner.stop(`Successfully deployed version ${version}${deploySuffix}`); } - printDeclarativeSchedules(deploymentWithWorker.worker.declarativeSchedules ?? []); - const taskCount = deploymentWithWorker.worker?.tasks.length ?? 0; if (options.plain) { @@ -1334,8 +1328,6 @@ async function handleNativeBuildServerDeploy({ ); } - await printDeclarativeSchedulesForDeployment(apiClient, deployment.id); - if (!isLinksSupported) { log.info(`Test tasks: ${rawTestLink}`); } @@ -1441,34 +1433,6 @@ async function handleNativeBuildServerDeploy({ } } -function printDeclarativeSchedules(schedules: DeclarativeScheduleSummary[]) { - const lines = formatDeclarativeScheduleOutput(schedules); - if (lines.length === 0) { - return; - } - - console.log(); - console.log(lines.join("\n")); - console.log(); -} - -async function printDeclarativeSchedulesForDeployment( - apiClient: CliApiClient, - deploymentId: string -) { - const [error, result] = await tryCatch(apiClient.getDeployment(deploymentId)); - if (error) { - logger.debug("Failed to load declarative schedules after deployment", { error }); - return; - } - if (!result.success) { - logger.debug("Failed to load declarative schedules after deployment", { result }); - return; - } - - printDeclarativeSchedules(result.data.worker?.declarativeSchedules ?? []); -} - export function verifyDirectory(dir: string, projectPath: string) { if (dir !== "." && !isDirectory(projectPath)) { if (dir === "staging" || dir === "prod" || dir === "preview") { diff --git a/packages/cli-v3/src/deploy/schedules.test.ts b/packages/cli-v3/src/deploy/schedules.test.ts deleted file mode 100644 index 226bca7adf..0000000000 --- a/packages/cli-v3/src/deploy/schedules.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { formatDeclarativeScheduleOutput } from "./schedules.js"; - -describe("declarative schedule deploy output", () => { - it("formats assigned times and explicit windows", () => { - expect( - formatDeclarativeScheduleOutput([ - { - task: "daily-report", - cron: "0 9 * * *", - timezone: "Europe/London", - window: "30m", - nextRun: new Date("2026-08-12T08:00:00.000Z"), - nextRunEffectiveAt: new Date("2026-08-12T08:17:45.000Z"), - }, - ]) - ).toEqual([ - "Declarative schedules", - " daily-report: 0 9 * * * (Europe/London) | window 30m | 2026-08-12 08:00:00 UTC -> 2026-08-12 08:17:45 UTC", - ]); - }); - - it("reports that an assigned time is pending registration", () => { - expect( - formatDeclarativeScheduleOutput([ - { - task: "daily-report", - cron: "0 9 * * *", - timezone: "UTC", - nextRun: new Date("2026-08-12T09:00:00.000Z"), - nextRunEffectiveAt: null, - }, - ]) - ).toContain( - " daily-report: 0 9 * * * (UTC) | window default 60s | next nominal 2026-08-12 09:00:00 UTC | next assigned time pending registration" - ); - }); - - it("nudges schedules using the default window", () => { - const lines = formatDeclarativeScheduleOutput([ - { - task: "hourly-report", - cron: "0 * * * *", - timezone: "UTC", - nextRun: new Date("2026-08-12T09:00:00.000Z"), - nextRunEffectiveAt: new Date("2026-08-12T09:00:21.000Z"), - }, - ]); - - expect(lines).toContain( - 'Tip: 1 declarative schedule uses the default 60-second placement range. Add window: "30m" to the cron object to spread starts over a wider range.' - ); - }); - - it("returns no output when there are no declarative schedules", () => { - expect(formatDeclarativeScheduleOutput([])).toEqual([]); - }); -}); diff --git a/packages/cli-v3/src/deploy/schedules.ts b/packages/cli-v3/src/deploy/schedules.ts deleted file mode 100644 index c78546e4ea..0000000000 --- a/packages/cli-v3/src/deploy/schedules.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { GetDeploymentResponseBody } from "@trigger.dev/core/v3"; - -type DeploymentWorker = NonNullable; -export type DeclarativeScheduleSummary = NonNullable< - DeploymentWorker["declarativeSchedules"] ->[number]; - -export function formatDeclarativeScheduleOutput(schedules: DeclarativeScheduleSummary[]): string[] { - if (schedules.length === 0) { - return []; - } - - const lines = ["Declarative schedules"]; - - for (const schedule of schedules) { - const timing = schedule.nextRunEffectiveAt - ? `${formatTime(schedule.nextRun)} -> ${formatTime(schedule.nextRunEffectiveAt)}` - : `next nominal ${formatTime(schedule.nextRun)} | next assigned time pending registration`; - lines.push( - ` ${schedule.task}: ${schedule.cron} (${schedule.timezone}) | window ${ - schedule.window ?? "default 60s" - } | ${timing}` - ); - } - - const defaultWindowCount = schedules.filter((schedule) => schedule.window === undefined).length; - if (defaultWindowCount > 0) { - lines.push(""); - lines.push( - `Tip: ${defaultWindowCount} declarative schedule${defaultWindowCount === 1 ? "" : "s"} ${ - defaultWindowCount === 1 ? "uses" : "use" - } the default 60-second placement range. Add window: "30m" to the cron object to spread starts over a wider range.` - ); - } - - return lines; -} - -function formatTime(value: Date) { - return value - .toISOString() - .replace("T", " ") - .replace(/\.\d{3}Z$/, " UTC"); -} diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index a358477e80..6cd100f7c3 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -819,19 +819,6 @@ export const GetDeploymentResponseBody = z.object({ exportName: z.string().optional(), }) ), - declarativeSchedules: z - .array( - z.object({ - task: z.string(), - cron: z.string(), - timezone: z.string(), - window: ScheduleWindow.optional(), - nextRun: z.coerce.date(), - /** Null until the deployment's schedule is registered in this environment. */ - nextRunEffectiveAt: z.coerce.date().nullable(), - }) - ) - .optional(), }) .optional(), integrationDeployments: z From 484f73825b1a801bf98f6af501ca8ec1f529b87d Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Thu, 13 Aug 2026 22:48:47 +0100 Subject: [PATCH 08/11] improve schedule window display --- .../route.tsx | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx index a804f88579..7c41323d56 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx @@ -879,6 +879,16 @@ function ScheduledTaskDetailSidebar({ )} + + Window + + {firstSchedule ? ( + (firstSchedule.window ?? "Default (60 seconds)") + ) : ( + + )} + + Created @@ -889,7 +899,7 @@ function ScheduledTaskDetailSidebar({ Next run {firstSchedule ? ( - + ) : ( )} @@ -974,7 +984,6 @@ type ScheduleRow = { cronDescription: string; window?: string; externalId: string | null; - nextRun: Date; nextRunEffectiveAt: Date; lastRun: Date | undefined; active: boolean; @@ -995,7 +1004,7 @@ function SchedulesMiniTable({ return (
- + No schedules attached to this task yet. @@ -1014,8 +1023,7 @@ function SchedulesMiniTable({ CRON Window External ID - Next CRON time - Next assigned time + Next run Last run Status @@ -1050,9 +1058,6 @@ function SchedulesMiniTable({ )} - - - From 9173bc32b9c1d62a595b2ca38ef24396148b5c8c Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Thu, 13 Aug 2026 23:23:32 +0100 Subject: [PATCH 09/11] improve imperative schedule ux --- .../route.tsx | 16 +--- .../route.tsx | 95 ++++++++++++++++--- .../src/v3/schemas/scheduleWindow.test.ts | 6 ++ .../core/src/v3/schemas/scheduleWindow.ts | 12 ++- 4 files changed, 99 insertions(+), 30 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx index 7c41323d56..60a3aaf104 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx @@ -867,7 +867,7 @@ function ScheduledTaskDetailSidebar({ - CRON + Cron {firstSchedule ? (
@@ -879,16 +879,6 @@ function ScheduledTaskDetailSidebar({ )} - - Window - - {firstSchedule ? ( - (firstSchedule.window ?? "Default (60 seconds)") - ) : ( - - )} - - Created @@ -1020,7 +1010,7 @@ function SchedulesMiniTable({ Schedule ID Type - CRON + Cron Window External ID Next run @@ -1049,7 +1039,7 @@ function SchedulesMiniTable({ {schedule.cron} - {schedule.window ?? "Default (60s)"} + {schedule.window} {schedule.externalId ? ( diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx index 943d12006d..74b1a1f295 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx @@ -1,5 +1,6 @@ import { getFormProps, getInputProps, getSelectProps, useForm } from "@conform-to/react"; import { parseWithZod } from "@conform-to/zod"; +import { ScheduleWindow } from "@trigger.dev/core/v3"; import { CheckIcon, XMarkIcon } from "@heroicons/react/20/solid"; import { type FetcherWithComponents, @@ -150,6 +151,15 @@ type CronPatternResult = error: string; }; +type ScheduleWindowResult = + | { + isValid: true; + } + | { + isValid: false; + error: string; + }; + export function UpsertScheduleForm({ schedule, possibleTasks, @@ -179,6 +189,7 @@ export function UpsertScheduleForm({ const [selectedTimezone, setSelectedTimezone] = useState(schedule?.timezone ?? "UTC"); const isUtc = selectedTimezone === "UTC"; const [cronPattern, setCronPattern] = useState(schedule?.cron ?? ""); + const [scheduleWindowValue, setScheduleWindowValue] = useState(schedule?.window ?? ""); const navigation = useNavigation(); const isLoading = submitFetcher ? submitFetcher.state !== "idle" : navigation.state !== "idle"; const organization = useOrganization(); @@ -210,8 +221,16 @@ export function UpsertScheduleForm({ }); let cronPatternResult: CronPatternResult | undefined = undefined; + let scheduleWindowResult: ScheduleWindowResult | undefined = undefined; let nextRuns: Date[] | undefined = undefined; + if (scheduleWindowValue !== "") { + const result = ScheduleWindow.safeParse(scheduleWindowValue); + scheduleWindowResult = result.success + ? { isValid: true } + : { isValid: false, error: result.error.errors[0].message }; + } + if (cronPattern !== "") { const result = CronPattern.safeParse(cronPattern); @@ -327,9 +346,19 @@ export function UpsertScheduleForm({ {cronPatternResult === undefined ? ( Enter a CRON pattern or use natural language above. ) : cronPatternResult.isValid ? ( - + ) : ( - + )} @@ -364,19 +393,45 @@ export function UpsertScheduleForm({ setScheduleWindowValue(event.target.value)} /> - - Assigns each run a stable time after its CRON time. Use minutes, hours, or a - percentage of the interval. Schedules always use at least a 60-second placement - range. - - {scheduleWindow.errors} + {scheduleWindowResult === undefined ? ( + + Assigns each run a stable time after its CRON time. Use minutes, hours, or a + percentage of the interval. + + ) : scheduleWindowResult.isValid ? ( + + ) : ( + + )} {nextRuns !== undefined && (
- Next 5 CRON times - Assigned times are calculated after the schedule is saved. + Next 5 runs + {scheduleWindowValue !== "" && ( + + Actual run times will get a fixed offset based on the window, displayed after + creation. + + )}
@@ -525,9 +580,21 @@ function buttonText(mode: "edit" | "new", isLoading: boolean) { } } -function ValidCronMessage({ isValid, message }: { isValid: boolean; message: string }) { +function ValidationMessage({ + id, + isValid, + validLabel, + invalidLabel, + message, +}: { + id?: string; + isValid: boolean; + validLabel: string; + invalidLabel: string; + message: string; +}) { return ( - + {isValid ? ( @@ -535,7 +602,7 @@ function ValidCronMessage({ isValid, message }: { isValid: boolean; message: str )} - {isValid ? "Valid pattern:" : "Invalid pattern:"} + {isValid ? validLabel : invalidLabel} {message} diff --git a/packages/core/src/v3/schemas/scheduleWindow.test.ts b/packages/core/src/v3/schemas/scheduleWindow.test.ts index 3868a42618..f5812b2ed0 100644 --- a/packages/core/src/v3/schemas/scheduleWindow.test.ts +++ b/packages/core/src/v3/schemas/scheduleWindow.test.ts @@ -33,6 +33,12 @@ describe("ScheduleWindow", () => { expect(ScheduleWindow.safeParse(window).success).toBe(false); }); + it("reports percentages above 100% precisely", () => { + expect(() => parseScheduleWindow("110%")).toThrow( + "Schedule window percentage cannot exceed 100%" + ); + }); + it("normalizes valid windows", () => { expect(parseScheduleWindow("30m")).toEqual({ type: "duration", diff --git a/packages/core/src/v3/schemas/scheduleWindow.ts b/packages/core/src/v3/schemas/scheduleWindow.ts index 8e89c2a6ca..ce89d76404 100644 --- a/packages/core/src/v3/schemas/scheduleWindow.ts +++ b/packages/core/src/v3/schemas/scheduleWindow.ts @@ -106,13 +106,19 @@ export function parseScheduleWindow(value: string): NormalizedScheduleWindow { return { type: "duration", durationSeconds }; } - const percentageMatch = /^(0|[1-9]\d?|100)%$/.exec(value); + const percentageMatch = /^(0|[1-9]\d*)%$/.exec(value); if (percentageMatch) { - return { type: "percentage", percentage: Number(percentageMatch[1]) }; + const percentage = Number(percentageMatch[1]); + + if (!Number.isSafeInteger(percentage) || percentage > 100) { + throw new RangeError("Schedule window percentage cannot exceed 100%"); + } + + return { type: "percentage", percentage }; } throw new TypeError( - 'Schedule window must be a whole duration such as "0m", "30m", or "24h", or a percentage such as "30%"' + 'Schedule window must be a whole duration such as "30m" or "2h", or a percentage such as "30%"' ); } From 1ecb593a168c99a3a2bb7f581bf60f7d421cd034 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Thu, 13 Aug 2026 23:31:59 +0100 Subject: [PATCH 10/11] improve inspector --- .../components/schedules/ScheduleInspector.tsx | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/apps/webapp/app/components/schedules/ScheduleInspector.tsx b/apps/webapp/app/components/schedules/ScheduleInspector.tsx index f90ddd16f7..7be4dca71b 100644 --- a/apps/webapp/app/components/schedules/ScheduleInspector.tsx +++ b/apps/webapp/app/components/schedules/ScheduleInspector.tsx @@ -145,7 +145,7 @@ export function ScheduleInspector({ Window - {schedule.window ?? "Default (60 seconds)"} + {schedule.window} Environment @@ -200,13 +200,12 @@ export function ScheduleInspector({ />
- Next 5 scheduled runs + Next 5 runs
- {!isUtc && CRON ({schedule.timezone})} - CRON (UTC) - Assigned (UTC) + {!isUtc && {schedule.timezone}} + UTC @@ -216,24 +215,21 @@ export function ScheduleInspector({ {!isUtc && ( - + )} - - - )) ) : ( - + ) ) : ( - + )} From 8058d83d9cc47af6b1fed1bdcfe6d69a03f4d008 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Thu, 13 Aug 2026 23:50:21 +0100 Subject: [PATCH 11/11] Update apps/webapp/app/components/schedules/ScheduleInspector.tsx Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/webapp/app/components/schedules/ScheduleInspector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/components/schedules/ScheduleInspector.tsx b/apps/webapp/app/components/schedules/ScheduleInspector.tsx index 7be4dca71b..d386043ba3 100644 --- a/apps/webapp/app/components/schedules/ScheduleInspector.tsx +++ b/apps/webapp/app/components/schedules/ScheduleInspector.tsx @@ -145,7 +145,7 @@ export function ScheduleInspector({ Window - {schedule.window} + {schedule.window ?? "–"} Environment