Skip to content
6 changes: 6 additions & 0 deletions .changeset/smooth-schedule-windows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@trigger.dev/core": patch
"@trigger.dev/sdk": patch
---

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.
6 changes: 3 additions & 3 deletions apps/webapp/app/components/runs/v3/TaskRunsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -418,13 +418,13 @@ export function TaskRunsTable({
{run.isPending ? (
"–"
) : run.startedAt ? (
formatDuration(new Date(run.createdAt), new Date(run.startedAt), {
formatDuration(new Date(run.triggeredAt), new Date(run.startedAt), {
style: "short",
})
) : run.isCancellable ? (
<LiveTimer startTime={new Date(run.createdAt)} />
<LiveTimer startTime={new Date(run.triggeredAt)} />
) : (
formatDuration(new Date(run.createdAt), new Date(run.updatedAt), {
formatDuration(new Date(run.triggeredAt), new Date(run.updatedAt), {
style: "short",
})
)}
Expand Down
15 changes: 10 additions & 5 deletions apps/webapp/app/components/schedules/ScheduleInspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -142,6 +143,10 @@ export function ScheduleInspector({
<Property.Label>Timezone</Property.Label>
<Property.Value>{schedule.timezone}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Window</Property.Label>
<Property.Value>{schedule.window ?? "-"}</Property.Value>
</Property.Item>
Comment thread
carderne marked this conversation as resolved.
Comment thread
carderne marked this conversation as resolved.
<Property.Item className="gap-1">
<Property.Label>Environment</Property.Label>
<Property.Value>
Expand Down Expand Up @@ -210,11 +215,11 @@ export function ScheduleInspector({
<TableRow key={index}>
{!isUtc && (
<TableCell>
<DateTime date={run} timeZone={schedule.timezone} />
<DateTime date={run.effectiveAt} timeZone={schedule.timezone} />
</TableCell>
)}
<TableCell>
<DateTime date={run} timeZone="UTC" />
<DateTime date={run.effectiveAt} timeZone="UTC" />
</TableCell>
Comment thread
carderne marked this conversation as resolved.
</TableRow>
))
Expand Down Expand Up @@ -249,8 +254,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.
</InfoPanel>
</div>
)}
Expand Down
4 changes: 4 additions & 0 deletions apps/webapp/app/presenters/v3/EditSchedulePresenter.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -124,6 +125,8 @@ export class EditSchedulePresenter {
deduplicationKey: true,
userProvidedDeduplicationKey: true,
timezone: true,
windowDurationSeconds: true,
windowPercentage: true,
taskIdentifier: true,
instances: {
select: {
Expand All @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { regionForDisplay } from "~/runEngine/concerns/workerQueueSplit.server";
import { machinePresetFromRun } from "~/v3/machinePresets.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { isCancellableRunStatus, isFinalRunStatus, isPendingRunStatus } from "~/v3/taskStatus";
import { runTriggeredAt } from "~/v3/runTimestamps";

// Positive-only cache: only envs known to have runs are stored (empty envs are re-checked),
// so "has runs" is monotonic and the TTL can be very long. Tiered memory + Redis.
Expand Down Expand Up @@ -299,12 +300,14 @@ export class NextRunListPresenter {
const hasFinished = isFinalRunStatus(run.status);

const startedAt = run.startedAt ?? run.lockedAt;
const triggeredAt = runTriggeredAt(run);

return {
id: run.id,
number: 1,
friendlyId: run.friendlyId,
createdAt: run.createdAt.toISOString(),
triggeredAt: triggeredAt.toISOString(),
updatedAt: run.updatedAt.toISOString(),
startedAt: startedAt ? startedAt.toISOString() : undefined,
delayUntil: run.delayUntil ? run.delayUntil.toISOString() : undefined,
Expand Down
8 changes: 6 additions & 2 deletions apps/webapp/app/presenters/v3/RunPresenter.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { env } from "~/env.server";
import { getEventRepositoryForStore } from "~/v3/eventRepository/index.server";
import { runStore } from "~/v3/runStore.server";
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
import { runTriggeredAt } from "~/v3/runTimestamps";

type Result = Awaited<ReturnType<RunPresenter["call"]>>;
export type Run = Result["run"];
Expand Down Expand Up @@ -92,6 +93,8 @@ export class RunPresenter {
friendlyId: true,
status: true,
startedAt: true,
queueTimestamp: true,
scheduleId: true,
completedAt: true,
logsDeletedAt: true,
annotations: true,
Expand Down Expand Up @@ -151,6 +154,7 @@ export class RunPresenter {
}

const showLogs = showDeletedLogs || !run.logsDeletedAt;
const triggeredAt = runTriggeredAt(run);

const runData = {
id: run.id,
Expand Down Expand Up @@ -240,7 +244,7 @@ export class RunPresenter {
message: run.taskIdentifier,
style: { icon: "task", variant: "primary" },
events: [],
startTime: run.createdAt,
startTime: triggeredAt,
duration: 0,
isError:
run.status === "COMPLETED_WITH_ERRORS" ||
Expand Down Expand Up @@ -364,7 +368,7 @@ export class RunPresenter {
rootStartedAt: tree?.data.startTime,
startedAt: run.startedAt,
queuedDuration: run.startedAt
? millisecondsToNanoseconds(run.startedAt.getTime() - run.createdAt.getTime())
? millisecondsToNanoseconds(run.startedAt.getTime() - triggeredAt.getTime())
: undefined,
overridesBySpanId: traceSummary.overridesBySpanId,
linkedRunIdBySpanId,
Expand Down
33 changes: 24 additions & 9 deletions apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -35,6 +33,7 @@ export type ScheduleListItem = {
window?: string;
externalId: string | null;
nextRun: Date;
nextRunEffectiveAt: Date;
lastRun: Date | undefined;
active: boolean;
environments: {
Expand Down Expand Up @@ -223,6 +222,7 @@ export class ScheduleListPresenter extends BasePresenter {
instances: {
select: {
environmentId: true,
schedulePhase: true,
},
},
active: true,
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand Down
4 changes: 3 additions & 1 deletion apps/webapp/app/presenters/v3/SpanPresenter.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { findRunByIdWithMollifierFallback } from "~/v3/mollifier/readFallback.se
import { buildSyntheticSpanRun } from "~/v3/mollifier/syntheticSpanRun.server";
import { engine } from "~/v3/runEngine.server";
import { runStore } from "~/v3/runStore.server";
import { runTriggeredAt } from "~/v3/runTimestamps";
import { getTaskEventStoreTableForRun, type TaskEventStoreTable } from "~/v3/taskEventStore.server";
import { isFailedRunStatus, isFinalRunStatus } from "~/v3/taskStatus";
import { BasePresenter } from "./basePresenter.server";
Expand Down Expand Up @@ -387,7 +388,7 @@ export class SpanPresenter extends BasePresenter {
friendlyId: run.friendlyId,
status: run.status,
statusReason: run.statusReason ?? undefined,
createdAt: run.createdAt,
createdAt: runTriggeredAt(run),
startedAt: run.startedAt,
executedAt: run.executedAt,
updatedAt: run.updatedAt,
Expand Down Expand Up @@ -559,6 +560,7 @@ export class SpanPresenter extends BasePresenter {
startedAt: true,
executedAt: true,
createdAt: true,
queueTimestamp: true,
updatedAt: true,
queuedAt: true,
completedAt: true,
Expand Down
31 changes: 26 additions & 5 deletions apps/webapp/app/presenters/v3/ViewSchedulePresenter.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -52,6 +52,8 @@ export class ViewSchedulePresenter {
},
instances: {
select: {
environmentId: true,
schedulePhase: true,
environment: {
select: {
id: true,
Expand Down Expand Up @@ -82,8 +84,25 @@ export class ViewSchedulePresenter {
return;
}

const instance = schedule.instances.find(
(instance) => instance.environmentId === environmentId
);
if (!instance && schedule.instances.length > 0) {
return;
}
Comment thread
carderne marked this conversation as resolved.
Comment thread
carderne marked this conversation as resolved.

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 ?? null,
phaseSecret: env.ENCRYPTION_KEY,
windowDurationSeconds: schedule.windowDurationSeconds,
windowPercentage: schedule.windowPercentage,
count: 5,
})
: [];

const runs = includeRunHistory
Expand All @@ -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) => {
Expand Down Expand Up @@ -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)
Expand Down
Loading