Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/imperative-schedules-ui-visibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---

Display imperative schedules created before initial deployment or worker task versioning in the schedules listing.
28 changes: 22 additions & 6 deletions apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { getCurrentPlan, getPlans } from "~/services/platform.v3.server";
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { formatResolvedScheduleWindow } from "~/v3/scheduleWindow.server";
import { type ScheduleWindowSource } from "@internal/schedule-engine";
import { calculateSchedulePhase, type ScheduleWindowSource } from "@internal/schedule-engine";
import { CheckScheduleService } from "~/v3/services/checkSchedule.server";
import { resolveScheduleTimings } from "~/v3/scheduleTimings.server";
import { env } from "~/env.server";
Expand Down Expand Up @@ -43,6 +43,7 @@ type ScheduleListItem = {
externalId: string | null;
nextRun: Date;
nextRunEffectiveAt: Date;
schedulePhase: number | null;
lastRun: Date | undefined;
active: boolean;
environments: {
Expand All @@ -60,7 +61,7 @@ export class ScheduleListPresenter extends BasePresenter {
environmentId,
tasks,
search,
page,
page = 1,
type,
pageSize = DEFAULT_PAGE_SIZE,
includeLastRun = false,
Expand Down Expand Up @@ -141,7 +142,10 @@ export class ScheduleListPresenter extends BasePresenter {

//get the latest BackgroundWorker
const latestWorker = await findCurrentWorkerFromEnvironment(environment, this._replica);
if (!latestWorker) {

// Declarative schedules only exist when backed by an active deployment. If the caller
// specifically filtered for declarative schedules and there is no active worker, return empty.
if (!latestWorker && filterType === "DECLARATIVE") {
return {
currentPage: 1,
totalPages: 1,
Expand All @@ -161,8 +165,13 @@ export class ScheduleListPresenter extends BasePresenter {
};
}

// Imperative schedules exist independently of worker deployments and must remain visible
// even before the first deployment or task version is deployed. When there is no active worker,
// only imperative schedules are returned.
const effectiveFilterType = !latestWorker ? "IMPERATIVE" : filterType;

//get all possible scheduled tasks
const allIdentifiers = await getTaskIdentifiers(environmentId);
const allIdentifiers = await getTaskIdentifiers(environmentId, this._replica);
const possibleTasks = allIdentifiers
.filter((t) => t.triggerSource === "SCHEDULED" && t.isInLatestDeployment)
.map((t) => ({ slug: t.slug }));
Expand All @@ -179,7 +188,7 @@ export class ScheduleListPresenter extends BasePresenter {
environmentId,
},
},
type: filterType,
type: effectiveFilterType,
AND: search
? {
OR: [
Expand Down Expand Up @@ -247,7 +256,7 @@ export class ScheduleListPresenter extends BasePresenter {
environmentId,
},
},
type: filterType,
type: effectiveFilterType,
AND: search
? {
OR: [
Expand Down Expand Up @@ -335,6 +344,13 @@ export class ScheduleListPresenter extends BasePresenter {
lastRun,
nextRun,
nextRunEffectiveAt,
schedulePhase:
instances[index].schedulePhase ??
calculateSchedulePhase({
secret: env.ENCRYPTION_KEY,
environmentId,
deduplicationKey: schedule.deduplicationKey,
}),
environments: schedule.instances.map((instance) => {
const environment = project.environments.find((env) => env.id === instance.environmentId);
if (!environment) {
Expand Down
234 changes: 234 additions & 0 deletions apps/webapp/test/ScheduleListPresenter.test.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Presenter test is not colocated

Repository guidance requires new tests beside their source file. Move this suite alongside ScheduleListPresenter.server.ts before merging.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import { containerTest } from "@internal/testcontainers";
import { MAX_SCHEDULE_PHASE } from "@internal/schedule-engine";
import type { PrismaClient } from "@trigger.dev/database";
import { describe, expect, vi } from "vitest";
import { ScheduleListPresenter } from "~/presenters/v3/ScheduleListPresenter.server";

vi.setConfig({ testTimeout: 60_000 });

async function seedProjectWithEnv(prisma: PrismaClient, slugBase: string) {
const slug = `${slugBase}_${Math.random().toString(36).slice(2, 10)}`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Fixtures use unseeded randomness

Math.random() makes fixture values irreproducible. Repository guidance requires seeded randomness in tests.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

const organization = await prisma.organization.create({
data: { title: slug, slug },
});
const project = await prisma.project.create({
data: {
name: slug,
slug,
organizationId: organization.id,
externalRef: slug,
},
});
const prodEnv = await prisma.runtimeEnvironment.create({
data: {
slug: "prod",
type: "PRODUCTION",
projectId: project.id,
organizationId: organization.id,
apiKey: `tr_prod_${slug}`,
pkApiKey: `pk_prod_${slug}`,
shortcode: `p${slug.slice(0, 4)}`,
},
});
return { organization, project, prodEnv };
}

async function seedSchedule(
prisma: PrismaClient,
projectId: string,
environmentId: string,
opts: {
friendlyId?: string;
taskIdentifier?: string;
type?: "IMPERATIVE" | "DECLARATIVE";
cron?: string;
schedulePhase?: number | null;
active?: boolean;
} = {}
) {
const schedule = await prisma.taskSchedule.create({
data: {
friendlyId: opts.friendlyId ?? `sched_${Math.random().toString(36).slice(2, 10)}`,
taskIdentifier: opts.taskIdentifier ?? "my-task",
projectId,
generatorExpression: opts.cron ?? "0 * * * *",
generatorDescription: "every hour",
type: opts.type ?? "IMPERATIVE",
active: opts.active ?? true,
},
});
const instance = await prisma.taskScheduleInstance.create({
data: {
taskScheduleId: schedule.id,
environmentId,
projectId,
schedulePhase: opts.schedulePhase ?? null,
active: opts.active ?? true,
},
});
return { schedule, instance };
}

describe("ScheduleListPresenter (imperative schedules visibility without active deployments)", () => {
containerTest(
"imperative schedules appear without active worker deployments, with appropriate indicator and phase",
async ({ prisma }) => {
const env = await seedProjectWithEnv(prisma, "no_deploy_imperative");

// Seed an imperative schedule with an explicit schedulePhase
const imperative = await seedSchedule(prisma, env.project.id, env.prodEnv.id, {
taskIdentifier: "unversioned-task",
type: "IMPERATIVE",
schedulePhase: 4200,
});

// Seed a declarative schedule without an active deployment
await seedSchedule(prisma, env.project.id, env.prodEnv.id, {
taskIdentifier: "declarative-task",
type: "DECLARATIVE",
});

const presenter = new ScheduleListPresenter(prisma, prisma);
const result = await presenter.call({
projectId: env.project.id,
environmentId: env.prodEnv.id,
});

// Imperative schedule is returned despite no BackgroundWorker or WorkerDeployment
expect(result.totalCount).toBe(1);
expect(result.schedules).toHaveLength(1);

const item = result.schedules[0];
expect(item.id).toBe(imperative.schedule.id);
expect(item.friendlyId).toBe(imperative.schedule.friendlyId);
expect(item.taskIdentifier).toBe("unversioned-task");
// Indicator: schedule type is IMPERATIVE and active is true
expect(item.type).toBe("IMPERATIVE");
expect(item.active).toBe(true);
// Phase: explicit schedulePhase is preserved and effective run times are calculated
expect(item.schedulePhase).toBe(4200);
expect(item.nextRun).toBeInstanceOf(Date);
expect(item.nextRunEffectiveAt).toBeInstanceOf(Date);

// Declarative schedules are excluded when no active deployment exists
const declarativeItem = result.schedules.find((s) => s.type === "DECLARATIVE");
expect(declarativeItem).toBeUndefined();
}
);

containerTest(
"deterministic schedulePhase is calculated when instance schedulePhase is null",
async ({ prisma }) => {
const env = await seedProjectWithEnv(prisma, "null_phase_imperative");

const imperative = await seedSchedule(prisma, env.project.id, env.prodEnv.id, {
taskIdentifier: "null-phase-task",
type: "IMPERATIVE",
schedulePhase: null,
});

const presenter = new ScheduleListPresenter(prisma, prisma);
const result = await presenter.call({
projectId: env.project.id,
environmentId: env.prodEnv.id,
});

expect(result.totalCount).toBe(1);
expect(result.schedules).toHaveLength(1);

const item = result.schedules[0];
expect(item.friendlyId).toBe(imperative.schedule.friendlyId);
expect(item.type).toBe("IMPERATIVE");
expect(typeof item.schedulePhase).toBe("number");
expect(item.schedulePhase).toBeGreaterThanOrEqual(0);
expect(item.schedulePhase).toBeLessThanOrEqual(MAX_SCHEDULE_PHASE);
expect(item.nextRunEffectiveAt).toBeInstanceOf(Date);
}
);

containerTest(
"filtering by type=imperative surfaces imperative schedules without deployment",
async ({ prisma }) => {
const env = await seedProjectWithEnv(prisma, "filter_imperative");

await seedSchedule(prisma, env.project.id, env.prodEnv.id, {
taskIdentifier: "imperative-task",
type: "IMPERATIVE",
});
await seedSchedule(prisma, env.project.id, env.prodEnv.id, {
taskIdentifier: "declarative-task",
type: "DECLARATIVE",
});

const presenter = new ScheduleListPresenter(prisma, prisma);
const result = await presenter.call({
projectId: env.project.id,
environmentId: env.prodEnv.id,
type: "imperative",
});

expect(result.totalCount).toBe(1);
expect(result.schedules).toHaveLength(1);
expect(result.schedules[0].type).toBe("IMPERATIVE");
}
);

containerTest(
"filtering by type=declarative returns empty when no deployment exists",
async ({ prisma }) => {
const env = await seedProjectWithEnv(prisma, "filter_declarative_no_deploy");

await seedSchedule(prisma, env.project.id, env.prodEnv.id, {
taskIdentifier: "imperative-task",
type: "IMPERATIVE",
});
await seedSchedule(prisma, env.project.id, env.prodEnv.id, {
taskIdentifier: "declarative-task",
type: "DECLARATIVE",
});

const presenter = new ScheduleListPresenter(prisma, prisma);
const result = await presenter.call({
projectId: env.project.id,
environmentId: env.prodEnv.id,
type: "declarative",
});

expect(result.totalCount).toBe(0);
expect(result.schedules).toHaveLength(0);
}
);

containerTest(
"filtering by taskIdentifier works for imperative schedules without deployment",
async ({ prisma }) => {
const env = await seedProjectWithEnv(prisma, "filter_by_task");

await seedSchedule(prisma, env.project.id, env.prodEnv.id, {
taskIdentifier: "target-task",
type: "IMPERATIVE",
});
await seedSchedule(prisma, env.project.id, env.prodEnv.id, {
taskIdentifier: "other-task",
type: "IMPERATIVE",
});

const presenter = new ScheduleListPresenter(prisma, prisma);
const matching = await presenter.call({
projectId: env.project.id,
environmentId: env.prodEnv.id,
tasks: ["target-task"],
});
expect(matching.totalCount).toBe(1);
expect(matching.schedules[0].taskIdentifier).toBe("target-task");

const nonMatching = await presenter.call({
projectId: env.project.id,
environmentId: env.prodEnv.id,
tasks: ["nonexistent-task"],
});
expect(nonMatching.totalCount).toBe(0);
expect(nonMatching.schedules).toHaveLength(0);
}
);
});