Skip to content

Commit 5c706b5

Browse files
committed
update effective schedule ux
1 parent 02a342a commit 5c706b5

5 files changed

Lines changed: 130 additions & 24 deletions

File tree

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

Lines changed: 58 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { authenticateApiKeyWithScope } from "~/services/apiAuth.server";
66
import { logger } from "~/services/logger.server";
77
import { env } from "~/env.server";
88
import { calculateNextScheduleRunTimes, normalizeScheduleWindow } from "~/v3/scheduleWindow.server";
9+
import { nextScheduledTimestamps } from "~/v3/utils/calculateNextSchedule.server";
910

1011
const ParamsSchema = z.object({
1112
deploymentId: z.string(),
@@ -58,39 +59,75 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
5859
const workerMetadata = deployment.worker
5960
? BackgroundWorkerMetadata.safeParse(deployment.worker.metadata)
6061
: undefined;
61-
const declarativeSchedules = workerMetadata?.success
62+
const declarativeTasks = workerMetadata?.success
6263
? workerMetadata.data.tasks.flatMap((task) => {
64+
const schedule = task.schedule;
6365
if (
64-
!task.schedule ||
65-
(task.schedule.environments &&
66-
!task.schedule.environments.includes(authenticatedEnv.type))
66+
!schedule ||
67+
(schedule.environments && !schedule.environments.includes(authenticatedEnv.type))
6768
) {
6869
return [];
6970
}
7071

71-
const windowFields = normalizeScheduleWindow(task.schedule.window);
72-
const [nextRun] = calculateNextScheduleRunTimes({
72+
return [{ id: task.id, schedule }];
73+
})
74+
: [];
75+
const persistedDeclarativeSchedules =
76+
declarativeTasks.length > 0
77+
? await prisma.taskSchedule.findMany({
78+
where: {
79+
type: "DECLARATIVE",
80+
projectId: authenticatedEnv.projectId,
81+
taskIdentifier: { in: declarativeTasks.map((task) => task.id) },
82+
instances: { some: { environmentId: authenticatedEnv.id } },
83+
},
84+
select: {
85+
taskIdentifier: true,
86+
deduplicationKey: true,
87+
generatorExpression: true,
88+
timezone: true,
89+
windowDurationSeconds: true,
90+
windowPercentage: true,
91+
instances: {
92+
where: { environmentId: authenticatedEnv.id },
93+
select: { schedulePhase: true },
94+
},
95+
},
96+
})
97+
: [];
98+
const declarativeSchedules = declarativeTasks.map((task) => {
99+
const windowFields = normalizeScheduleWindow(task.schedule.window);
100+
const persistedSchedule = persistedDeclarativeSchedules.find(
101+
(schedule) =>
102+
schedule.taskIdentifier === task.id &&
103+
schedule.generatorExpression === task.schedule.cron &&
104+
schedule.timezone === task.schedule.timezone &&
105+
schedule.windowDurationSeconds === windowFields.windowDurationSeconds &&
106+
schedule.windowPercentage === windowFields.windowPercentage
107+
);
108+
const registeredNextRun = persistedSchedule
109+
? calculateNextScheduleRunTimes({
73110
cron: task.schedule.cron,
74111
timezone: task.schedule.timezone,
75-
deduplicationKey: task.id,
112+
deduplicationKey: persistedSchedule.deduplicationKey,
76113
environmentId: authenticatedEnv.id,
77-
schedulePhase: null,
114+
schedulePhase: persistedSchedule.instances[0]?.schedulePhase ?? null,
78115
phaseSecret: env.ENCRYPTION_KEY,
79116
...windowFields,
80-
});
117+
})[0]
118+
: undefined;
81119

82-
return [
83-
{
84-
task: task.id,
85-
cron: task.schedule.cron,
86-
timezone: task.schedule.timezone,
87-
window: task.schedule.window,
88-
nextRun: nextRun.nominalAt,
89-
nextRunEffectiveAt: nextRun.effectiveAt,
90-
},
91-
];
92-
})
93-
: [];
120+
return {
121+
task: task.id,
122+
cron: task.schedule.cron,
123+
timezone: task.schedule.timezone,
124+
window: task.schedule.window,
125+
nextRun:
126+
registeredNextRun?.nominalAt ??
127+
nextScheduledTimestamps(task.schedule.cron, task.schedule.timezone, new Date())[0],
128+
nextRunEffectiveAt: registeredNextRun?.effectiveAt ?? null,
129+
};
130+
});
94131

95132
return json({
96133
id: deployment.friendlyId,

apps/webapp/test/schedules-api.e2e.full.test.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,27 @@ describe("Schedules API windows", () => {
155155
workerId: worker.id,
156156
},
157157
});
158+
await server.prisma.taskSchedule.create({
159+
data: {
160+
friendlyId: `schedule_${environment.id}`,
161+
projectId: project.id,
162+
taskIdentifier: TASK_IDENTIFIER,
163+
deduplicationKey: "persisted-schedule-identity",
164+
generatorExpression: "0 9 * * *",
165+
generatorDescription: "At 09:00",
166+
timezone: "UTC",
167+
type: "DECLARATIVE",
168+
windowDurationSeconds: 30 * 60,
169+
instances: {
170+
create: {
171+
environmentId: environment.id,
172+
projectId: project.id,
173+
schedulePhase: 0,
174+
},
175+
},
176+
},
177+
});
178+
158179
const response = await server.webapp.fetch(`/api/v1/deployments/${deployment.friendlyId}`, {
159180
headers: authHeaders(apiKey),
160181
});
@@ -168,7 +189,35 @@ describe("Schedules API windows", () => {
168189
timezone: "UTC",
169190
window: "30m",
170191
});
171-
expectAssignedTime(body.worker.declarativeSchedules[0], 30 * 60_000);
192+
expect(body.worker.declarativeSchedules[0].nextRunEffectiveAt).toBe(
193+
body.worker.declarativeSchedules[0].nextRun
194+
);
195+
});
196+
197+
it("reports an unassigned effective time until a declarative schedule is registered", async () => {
198+
const server = getTestServer();
199+
const { apiKey, project, environment } = await seedTestEnvironment(server.prisma);
200+
const worker = await seedScheduledTask(server.prisma, project.id, environment.id);
201+
const deployment = await server.prisma.workerDeployment.create({
202+
data: {
203+
friendlyId: `deployment_unregistered_${environment.id}`,
204+
shortCode: environment.shortcode,
205+
version: "20260811.1",
206+
contentHash: `hash_unregistered_${environment.id}`,
207+
status: "DEPLOYED",
208+
projectId: project.id,
209+
environmentId: environment.id,
210+
workerId: worker.id,
211+
},
212+
});
213+
214+
const response = await server.webapp.fetch(`/api/v1/deployments/${deployment.friendlyId}`, {
215+
headers: authHeaders(apiKey),
216+
});
217+
218+
expect(response.status).toBe(200);
219+
const body = await response.json();
220+
expect(body.worker.declarativeSchedules[0].nextRunEffectiveAt).toBeNull();
172221
});
173222

174223
it("returns safe errors for invalid windows", async () => {

packages/cli-v3/src/deploy/schedules.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,22 @@ describe("declarative schedule deploy output", () => {
2020
]);
2121
});
2222

23+
it("reports that an assigned time is pending registration", () => {
24+
expect(
25+
formatDeclarativeScheduleOutput([
26+
{
27+
task: "daily-report",
28+
cron: "0 9 * * *",
29+
timezone: "UTC",
30+
nextRun: new Date("2026-08-12T09:00:00.000Z"),
31+
nextRunEffectiveAt: null,
32+
},
33+
])
34+
).toContain(
35+
" daily-report: 0 9 * * * (UTC) | window default 60s | next nominal 2026-08-12 09:00:00 UTC | next assigned time pending registration"
36+
);
37+
});
38+
2339
it("nudges schedules using the default window", () => {
2440
const lines = formatDeclarativeScheduleOutput([
2541
{

packages/cli-v3/src/deploy/schedules.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,13 @@ export function formatDeclarativeScheduleOutput(schedules: DeclarativeScheduleSu
1313
const lines = ["Declarative schedules"];
1414

1515
for (const schedule of schedules) {
16+
const timing = schedule.nextRunEffectiveAt
17+
? `${formatTime(schedule.nextRun)} -> ${formatTime(schedule.nextRunEffectiveAt)}`
18+
: `next nominal ${formatTime(schedule.nextRun)} | next assigned time pending registration`;
1619
lines.push(
1720
` ${schedule.task}: ${schedule.cron} (${schedule.timezone}) | window ${
1821
schedule.window ?? "default 60s"
19-
} | ${formatTime(schedule.nextRun)} -> ${formatTime(schedule.nextRunEffectiveAt)}`
22+
} | ${timing}`
2023
);
2124
}
2225

packages/core/src/v3/schemas/api.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -827,7 +827,8 @@ export const GetDeploymentResponseBody = z.object({
827827
timezone: z.string(),
828828
window: ScheduleWindow.optional(),
829829
nextRun: z.coerce.date(),
830-
nextRunEffectiveAt: z.coerce.date(),
830+
/** Null until the deployment's schedule is registered in this environment. */
831+
nextRunEffectiveAt: z.coerce.date().nullable(),
831832
})
832833
)
833834
.optional(),

0 commit comments

Comments
 (0)