Skip to content

Commit de5b71e

Browse files
committed
fix(webapp): display imperative schedules without active worker deployments
1 parent d8c3530 commit de5b71e

3 files changed

Lines changed: 262 additions & 6 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Display imperative schedules created before initial deployment or worker task versioning in the schedules listing.

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

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { getCurrentPlan, getPlans } from "~/services/platform.v3.server";
66
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
77
import { ServiceValidationError } from "~/v3/services/baseService.server";
88
import { formatResolvedScheduleWindow } from "~/v3/scheduleWindow.server";
9-
import { type ScheduleWindowSource } from "@internal/schedule-engine";
9+
import { calculateSchedulePhase, type ScheduleWindowSource } from "@internal/schedule-engine";
1010
import { CheckScheduleService } from "~/v3/services/checkSchedule.server";
1111
import { resolveScheduleTimings } from "~/v3/scheduleTimings.server";
1212
import { env } from "~/env.server";
@@ -43,6 +43,7 @@ type ScheduleListItem = {
4343
externalId: string | null;
4444
nextRun: Date;
4545
nextRunEffectiveAt: Date;
46+
schedulePhase: number | null;
4647
lastRun: Date | undefined;
4748
active: boolean;
4849
environments: {
@@ -60,7 +61,7 @@ export class ScheduleListPresenter extends BasePresenter {
6061
environmentId,
6162
tasks,
6263
search,
63-
page,
64+
page = 1,
6465
type,
6566
pageSize = DEFAULT_PAGE_SIZE,
6667
includeLastRun = false,
@@ -141,7 +142,10 @@ export class ScheduleListPresenter extends BasePresenter {
141142

142143
//get the latest BackgroundWorker
143144
const latestWorker = await findCurrentWorkerFromEnvironment(environment, this._replica);
144-
if (!latestWorker) {
145+
146+
// Declarative schedules only exist when backed by an active deployment. If the caller
147+
// specifically filtered for declarative schedules and there is no active worker, return empty.
148+
if (!latestWorker && filterType === "DECLARATIVE") {
145149
return {
146150
currentPage: 1,
147151
totalPages: 1,
@@ -161,8 +165,13 @@ export class ScheduleListPresenter extends BasePresenter {
161165
};
162166
}
163167

168+
// Imperative schedules exist independently of worker deployments and must remain visible
169+
// even before the first deployment or task version is deployed. When there is no active worker,
170+
// only imperative schedules are returned.
171+
const effectiveFilterType = !latestWorker ? "IMPERATIVE" : filterType;
172+
164173
//get all possible scheduled tasks
165-
const allIdentifiers = await getTaskIdentifiers(environmentId);
174+
const allIdentifiers = await getTaskIdentifiers(environmentId, this._replica);
166175
const possibleTasks = allIdentifiers
167176
.filter((t) => t.triggerSource === "SCHEDULED" && t.isInLatestDeployment)
168177
.map((t) => ({ slug: t.slug }));
@@ -179,7 +188,7 @@ export class ScheduleListPresenter extends BasePresenter {
179188
environmentId,
180189
},
181190
},
182-
type: filterType,
191+
type: effectiveFilterType,
183192
AND: search
184193
? {
185194
OR: [
@@ -247,7 +256,7 @@ export class ScheduleListPresenter extends BasePresenter {
247256
environmentId,
248257
},
249258
},
250-
type: filterType,
259+
type: effectiveFilterType,
251260
AND: search
252261
? {
253262
OR: [
@@ -335,6 +344,13 @@ export class ScheduleListPresenter extends BasePresenter {
335344
lastRun,
336345
nextRun,
337346
nextRunEffectiveAt,
347+
schedulePhase:
348+
instances[index].schedulePhase ??
349+
calculateSchedulePhase({
350+
secret: env.ENCRYPTION_KEY,
351+
environmentId,
352+
deduplicationKey: schedule.deduplicationKey,
353+
}),
338354
environments: schedule.instances.map((instance) => {
339355
const environment = project.environments.find((env) => env.id === instance.environmentId);
340356
if (!environment) {
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import { containerTest } from "@internal/testcontainers";
2+
import { MAX_SCHEDULE_PHASE } from "@internal/schedule-engine";
3+
import type { PrismaClient } from "@trigger.dev/database";
4+
import { describe, expect, vi } from "vitest";
5+
import { ScheduleListPresenter } from "~/presenters/v3/ScheduleListPresenter.server";
6+
7+
vi.setConfig({ testTimeout: 60_000 });
8+
9+
async function seedProjectWithEnv(prisma: PrismaClient, slugBase: string) {
10+
const slug = `${slugBase}_${Math.random().toString(36).slice(2, 10)}`;
11+
const organization = await prisma.organization.create({
12+
data: { title: slug, slug },
13+
});
14+
const project = await prisma.project.create({
15+
data: {
16+
name: slug,
17+
slug,
18+
organizationId: organization.id,
19+
externalRef: slug,
20+
},
21+
});
22+
const prodEnv = await prisma.runtimeEnvironment.create({
23+
data: {
24+
slug: "prod",
25+
type: "PRODUCTION",
26+
projectId: project.id,
27+
organizationId: organization.id,
28+
apiKey: `tr_prod_${slug}`,
29+
pkApiKey: `pk_prod_${slug}`,
30+
shortcode: `p${slug.slice(0, 4)}`,
31+
},
32+
});
33+
return { organization, project, prodEnv };
34+
}
35+
36+
async function seedSchedule(
37+
prisma: PrismaClient,
38+
projectId: string,
39+
environmentId: string,
40+
opts: {
41+
friendlyId?: string;
42+
taskIdentifier?: string;
43+
type?: "IMPERATIVE" | "DECLARATIVE";
44+
cron?: string;
45+
schedulePhase?: number | null;
46+
active?: boolean;
47+
} = {}
48+
) {
49+
const schedule = await prisma.taskSchedule.create({
50+
data: {
51+
friendlyId: opts.friendlyId ?? `sched_${Math.random().toString(36).slice(2, 10)}`,
52+
taskIdentifier: opts.taskIdentifier ?? "my-task",
53+
projectId,
54+
generatorExpression: opts.cron ?? "0 * * * *",
55+
generatorDescription: "every hour",
56+
type: opts.type ?? "IMPERATIVE",
57+
active: opts.active ?? true,
58+
},
59+
});
60+
const instance = await prisma.taskScheduleInstance.create({
61+
data: {
62+
taskScheduleId: schedule.id,
63+
environmentId,
64+
projectId,
65+
schedulePhase: opts.schedulePhase ?? null,
66+
active: opts.active ?? true,
67+
},
68+
});
69+
return { schedule, instance };
70+
}
71+
72+
describe("ScheduleListPresenter (imperative schedules visibility without active deployments)", () => {
73+
containerTest(
74+
"imperative schedules appear without active worker deployments, with appropriate indicator and phase",
75+
async ({ prisma }) => {
76+
const env = await seedProjectWithEnv(prisma, "no_deploy_imperative");
77+
78+
// Seed an imperative schedule with an explicit schedulePhase
79+
const imperative = await seedSchedule(prisma, env.project.id, env.prodEnv.id, {
80+
taskIdentifier: "unversioned-task",
81+
type: "IMPERATIVE",
82+
schedulePhase: 4200,
83+
});
84+
85+
// Seed a declarative schedule without an active deployment
86+
await seedSchedule(prisma, env.project.id, env.prodEnv.id, {
87+
taskIdentifier: "declarative-task",
88+
type: "DECLARATIVE",
89+
});
90+
91+
const presenter = new ScheduleListPresenter(prisma, prisma);
92+
const result = await presenter.call({
93+
projectId: env.project.id,
94+
environmentId: env.prodEnv.id,
95+
});
96+
97+
// Imperative schedule is returned despite no BackgroundWorker or WorkerDeployment
98+
expect(result.totalCount).toBe(1);
99+
expect(result.schedules).toHaveLength(1);
100+
101+
const item = result.schedules[0];
102+
expect(item.id).toBe(imperative.schedule.id);
103+
expect(item.friendlyId).toBe(imperative.schedule.friendlyId);
104+
expect(item.taskIdentifier).toBe("unversioned-task");
105+
// Indicator: schedule type is IMPERATIVE and active is true
106+
expect(item.type).toBe("IMPERATIVE");
107+
expect(item.active).toBe(true);
108+
// Phase: explicit schedulePhase is preserved and effective run times are calculated
109+
expect(item.schedulePhase).toBe(4200);
110+
expect(item.nextRun).toBeInstanceOf(Date);
111+
expect(item.nextRunEffectiveAt).toBeInstanceOf(Date);
112+
113+
// Declarative schedules are excluded when no active deployment exists
114+
const declarativeItem = result.schedules.find((s) => s.type === "DECLARATIVE");
115+
expect(declarativeItem).toBeUndefined();
116+
}
117+
);
118+
119+
containerTest(
120+
"deterministic schedulePhase is calculated when instance schedulePhase is null",
121+
async ({ prisma }) => {
122+
const env = await seedProjectWithEnv(prisma, "null_phase_imperative");
123+
124+
const imperative = await seedSchedule(prisma, env.project.id, env.prodEnv.id, {
125+
taskIdentifier: "null-phase-task",
126+
type: "IMPERATIVE",
127+
schedulePhase: null,
128+
});
129+
130+
const presenter = new ScheduleListPresenter(prisma, prisma);
131+
const result = await presenter.call({
132+
projectId: env.project.id,
133+
environmentId: env.prodEnv.id,
134+
});
135+
136+
expect(result.totalCount).toBe(1);
137+
expect(result.schedules).toHaveLength(1);
138+
139+
const item = result.schedules[0];
140+
expect(item.friendlyId).toBe(imperative.schedule.friendlyId);
141+
expect(item.type).toBe("IMPERATIVE");
142+
expect(typeof item.schedulePhase).toBe("number");
143+
expect(item.schedulePhase).toBeGreaterThanOrEqual(0);
144+
expect(item.schedulePhase).toBeLessThanOrEqual(MAX_SCHEDULE_PHASE);
145+
expect(item.nextRunEffectiveAt).toBeInstanceOf(Date);
146+
}
147+
);
148+
149+
containerTest(
150+
"filtering by type=imperative surfaces imperative schedules without deployment",
151+
async ({ prisma }) => {
152+
const env = await seedProjectWithEnv(prisma, "filter_imperative");
153+
154+
await seedSchedule(prisma, env.project.id, env.prodEnv.id, {
155+
taskIdentifier: "imperative-task",
156+
type: "IMPERATIVE",
157+
});
158+
await seedSchedule(prisma, env.project.id, env.prodEnv.id, {
159+
taskIdentifier: "declarative-task",
160+
type: "DECLARATIVE",
161+
});
162+
163+
const presenter = new ScheduleListPresenter(prisma, prisma);
164+
const result = await presenter.call({
165+
projectId: env.project.id,
166+
environmentId: env.prodEnv.id,
167+
type: "imperative",
168+
});
169+
170+
expect(result.totalCount).toBe(1);
171+
expect(result.schedules).toHaveLength(1);
172+
expect(result.schedules[0].type).toBe("IMPERATIVE");
173+
}
174+
);
175+
176+
containerTest(
177+
"filtering by type=declarative returns empty when no deployment exists",
178+
async ({ prisma }) => {
179+
const env = await seedProjectWithEnv(prisma, "filter_declarative_no_deploy");
180+
181+
await seedSchedule(prisma, env.project.id, env.prodEnv.id, {
182+
taskIdentifier: "imperative-task",
183+
type: "IMPERATIVE",
184+
});
185+
await seedSchedule(prisma, env.project.id, env.prodEnv.id, {
186+
taskIdentifier: "declarative-task",
187+
type: "DECLARATIVE",
188+
});
189+
190+
const presenter = new ScheduleListPresenter(prisma, prisma);
191+
const result = await presenter.call({
192+
projectId: env.project.id,
193+
environmentId: env.prodEnv.id,
194+
type: "declarative",
195+
});
196+
197+
expect(result.totalCount).toBe(0);
198+
expect(result.schedules).toHaveLength(0);
199+
}
200+
);
201+
202+
containerTest(
203+
"filtering by taskIdentifier works for imperative schedules without deployment",
204+
async ({ prisma }) => {
205+
const env = await seedProjectWithEnv(prisma, "filter_by_task");
206+
207+
await seedSchedule(prisma, env.project.id, env.prodEnv.id, {
208+
taskIdentifier: "target-task",
209+
type: "IMPERATIVE",
210+
});
211+
await seedSchedule(prisma, env.project.id, env.prodEnv.id, {
212+
taskIdentifier: "other-task",
213+
type: "IMPERATIVE",
214+
});
215+
216+
const presenter = new ScheduleListPresenter(prisma, prisma);
217+
const matching = await presenter.call({
218+
projectId: env.project.id,
219+
environmentId: env.prodEnv.id,
220+
tasks: ["target-task"],
221+
});
222+
expect(matching.totalCount).toBe(1);
223+
expect(matching.schedules[0].taskIdentifier).toBe("target-task");
224+
225+
const nonMatching = await presenter.call({
226+
projectId: env.project.id,
227+
environmentId: env.prodEnv.id,
228+
tasks: ["nonexistent-task"],
229+
});
230+
expect(nonMatching.totalCount).toBe(0);
231+
expect(nonMatching.schedules).toHaveLength(0);
232+
}
233+
);
234+
});

0 commit comments

Comments
 (0)