From de5b71e2767fad7ceb8271b4825656b008865840 Mon Sep 17 00:00:00 2001 From: sufiyan733 Date: Tue, 22 Sep 2026 03:18:55 +0530 Subject: [PATCH] fix(webapp): display imperative schedules without active worker deployments --- .../imperative-schedules-ui-visibility.md | 6 + .../v3/ScheduleListPresenter.server.ts | 28 ++- .../webapp/test/ScheduleListPresenter.test.ts | 234 ++++++++++++++++++ 3 files changed, 262 insertions(+), 6 deletions(-) create mode 100644 .server-changes/imperative-schedules-ui-visibility.md create mode 100644 apps/webapp/test/ScheduleListPresenter.test.ts diff --git a/.server-changes/imperative-schedules-ui-visibility.md b/.server-changes/imperative-schedules-ui-visibility.md new file mode 100644 index 00000000000..d72b06cc8a0 --- /dev/null +++ b/.server-changes/imperative-schedules-ui-visibility.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Display imperative schedules created before initial deployment or worker task versioning in the schedules listing. diff --git a/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts b/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts index 9745d6ae063..f92508d44f6 100644 --- a/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts @@ -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"; @@ -43,6 +43,7 @@ type ScheduleListItem = { externalId: string | null; nextRun: Date; nextRunEffectiveAt: Date; + schedulePhase: number | null; lastRun: Date | undefined; active: boolean; environments: { @@ -60,7 +61,7 @@ export class ScheduleListPresenter extends BasePresenter { environmentId, tasks, search, - page, + page = 1, type, pageSize = DEFAULT_PAGE_SIZE, includeLastRun = false, @@ -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, @@ -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 })); @@ -179,7 +188,7 @@ export class ScheduleListPresenter extends BasePresenter { environmentId, }, }, - type: filterType, + type: effectiveFilterType, AND: search ? { OR: [ @@ -247,7 +256,7 @@ export class ScheduleListPresenter extends BasePresenter { environmentId, }, }, - type: filterType, + type: effectiveFilterType, AND: search ? { OR: [ @@ -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) { diff --git a/apps/webapp/test/ScheduleListPresenter.test.ts b/apps/webapp/test/ScheduleListPresenter.test.ts new file mode 100644 index 00000000000..e44e236e970 --- /dev/null +++ b/apps/webapp/test/ScheduleListPresenter.test.ts @@ -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)}`; + 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); + } + ); +});