diff --git a/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts b/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts index 79db1b4b78..23fe6bca94 100644 --- a/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts @@ -1,11 +1,19 @@ import { ScheduledTaskPayload, parsePacket, prettyPrintPacket } from "@trigger.dev/core/v3"; -import { type RuntimeEnvironmentType, type TaskRunStatus } from "@trigger.dev/database"; +import { + type TaskRunTemplate, + type RuntimeEnvironmentType, + type TaskRunStatus, +} from "@trigger.dev/database"; import { type PrismaClient, prisma, sqlDatabaseSchema } from "~/db.server"; import { getTimezones } from "~/utils/timezones.server"; import { findCurrentWorkerDeployment } from "~/v3/models/workerDeployment.server"; import { queueTypeFromType } from "./QueueRetrievePresenter.server"; import parse from "parse-duration"; +export type RunTemplate = TaskRunTemplate & { + scheduledTaskPayload?: ScheduledRun["payload"]; +}; + type TestTaskOptions = { userId: string; projectId: string; @@ -40,6 +48,7 @@ export type TestTaskResult = latestVersions: string[]; disableVersionSelection: boolean; allowArbitraryQueues: boolean; + taskRunTemplates: TaskRunTemplate[]; } | { foundTask: true; @@ -51,6 +60,7 @@ export type TestTaskResult = latestVersions: string[]; disableVersionSelection: boolean; allowArbitraryQueues: boolean; + taskRunTemplates: TaskRunTemplate[]; } | { foundTask: false; @@ -163,6 +173,18 @@ export class TestTaskPresenter { take: 20, // last 20 versions should suffice }); + const taskRunTemplates = await this.#prismaClient.taskRunTemplate.findMany({ + where: { + projectId, + taskSlug: task.slug, + triggerSource: task.triggerSource, + }, + orderBy: { + createdAt: "desc", + }, + take: 50, + }); + const latestVersions = backgroundWorkers.map((v) => v.version); const disableVersionSelection = environment.type === "DEVELOPMENT"; @@ -247,6 +269,13 @@ export class TestTaskPresenter { latestVersions, disableVersionSelection, allowArbitraryQueues, + taskRunTemplates: await Promise.all( + taskRunTemplates.map(async (t) => ({ + ...t, + payload: await prettyPrintPacket(t.payload, t.payloadType), + metadata: t.metadata ? await prettyPrintPacket(t.metadata, t.metadataType) : null, + })) + ), }; case "SCHEDULED": { const possibleTimezones = getTimezones(); @@ -266,7 +295,7 @@ export class TestTaskPresenter { runs: ( await Promise.all( latestRuns.map(async (r) => { - const payload = await getScheduleTaskRunPayload(r); + const payload = await getScheduleTaskRunPayload(r.payload, r.payloadType); if (payload.success) { return { @@ -281,6 +310,21 @@ export class TestTaskPresenter { latestVersions, disableVersionSelection, allowArbitraryQueues, + taskRunTemplates: await Promise.all( + taskRunTemplates.map(async (t) => { + const scheduledTaskPayload = t.payload + ? await getScheduleTaskRunPayload(t.payload, t.payloadType) + : undefined; + + return { + ...t, + scheduledTaskPayload: + scheduledTaskPayload && scheduledTaskPayload.success + ? scheduledTaskPayload.data + : undefined, + }; + }) + ), }; } default: { @@ -290,11 +334,11 @@ export class TestTaskPresenter { } } -async function getScheduleTaskRunPayload(run: RawRun) { - const payload = await parsePacket({ data: run.payload, dataType: run.payloadType }); - if (!payload.timezone) { - payload.timezone = "UTC"; +async function getScheduleTaskRunPayload(payload: string, payloadType: string) { + const packet = await parsePacket({ data: payload, dataType: payloadType }); + if (!packet.timezone) { + packet.timezone = "UTC"; } - const parsed = ScheduledTaskPayload.safeParse(payload); + const parsed = ScheduledTaskPayload.safeParse(packet); return parsed; } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx index 5246bd08fa..d869f08dc5 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx @@ -1,6 +1,13 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; -import { BeakerIcon, RectangleStackIcon } from "@heroicons/react/20/solid"; +import { + BeakerIcon, + StarIcon, + RectangleStackIcon, + TrashIcon, + CheckCircleIcon, +} from "@heroicons/react/20/solid"; +import { AnimatePresence, motion } from "framer-motion"; import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; import { useCallback, useEffect, useRef, useState, useMemo } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; @@ -30,7 +37,7 @@ import { TextLink } from "~/components/primitives/TextLink"; import { TimezoneList } from "~/components/scheduled/timezones"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useSearchParams } from "~/hooks/useSearchParam"; -import { useParams, Form, useActionData, useFetcher } from "@remix-run/react"; +import { useParams, Form, useActionData, useFetcher, useSubmit } from "@remix-run/react"; import { redirectBackWithErrorMessage, redirectWithErrorMessage, @@ -43,6 +50,7 @@ import { type StandardRun, type StandardTaskResult, type ScheduledTaskResult, + type RunTemplate, TestTaskPresenter, } from "~/presenters/v3/TestTaskPresenter.server"; import { logger } from "~/services/logger.server"; @@ -59,7 +67,14 @@ import { TaskRunStatusCombo } from "~/components/runs/v3/TaskRunStatus"; import { ClockRotateLeftIcon } from "~/assets/icons/ClockRotateLeftIcon"; import { MachinePresetName } from "@trigger.dev/core/v3"; import { TaskTriggerSourceIcon } from "~/components/runs/v3/TaskTriggerSource"; -import { Callout } from "~/components/primitives/Callout"; +import { TaskRunTemplateService } from "~/v3/services/taskRunTemplate.server"; +import { DeleteTaskRunTemplateService } from "~/v3/services/deleteTaskRunTemplate.server"; +import { DeleteTaskRunTemplateData, RunTemplateData } from "~/v3/taskRunTemplate"; +import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; +import { DialogClose, DialogDescription } from "@radix-ui/react-dialog"; +import { FormButtons } from "~/components/primitives/FormButtons"; + +type FormAction = "create-template" | "delete-template" | "run-scheduled" | "run-standard"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); @@ -102,14 +117,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { export const action: ActionFunction = async ({ request, params }) => { const userId = await requireUserId(request); - const { organizationSlug, projectParam, envParam, taskParam } = v3TaskParamsSchema.parse(params); - - const formData = await request.formData(); - const submission = parse(formData, { schema: TestTaskData }); - - if (!submission.value) { - return json(submission); - } + const { organizationSlug, projectParam, envParam } = v3TaskParamsSchema.parse(params); const project = await findProjectBySlug(organizationSlug, projectParam, userId); if (!project) { @@ -122,46 +130,115 @@ export const action: ActionFunction = async ({ request, params }) => { return redirectBackWithErrorMessage(request, "Environment not found"); } - if (environment.archivedAt) { - return redirectBackWithErrorMessage(request, "Can't run a test on an archived environment"); - } + const formData = await request.formData(); + const formAction = formData.get("formAction") as FormAction; - const testService = new TestTaskService(); - try { - const run = await testService.call(environment, submission.value); + switch (formAction) { + case "create-template": { + const submission = parse(formData, { schema: RunTemplateData }); + if (!submission.value) { + return json({ + ...submission, + formAction, + }); + } - if (!run) { - return redirectBackWithErrorMessage( - request, - "Unable to start a test run: Something went wrong" - ); + const templateService = new TaskRunTemplateService(); + try { + const template = await templateService.call(environment, submission.value); + + return json({ + ...submission, + success: true, + templateLabel: template.label, + formAction, + }); + } catch (e) { + logger.error("Failed to create template", { error: e instanceof Error ? e.message : e }); + return redirectBackWithErrorMessage(request, "Failed to create template"); + } } + case "delete-template": { + const submission = parse(formData, { schema: DeleteTaskRunTemplateData }); - return redirectWithSuccessMessage( - v3RunSpanPath( - { slug: organizationSlug }, - { slug: projectParam }, - { slug: envParam }, - { friendlyId: run.friendlyId }, - { spanId: run.spanId } - ), - request, - "Test run created" - ); - } catch (e) { - if (e instanceof OutOfEntitlementError) { - return redirectBackWithErrorMessage( - request, - "Unable to start a test run: You have exceeded your free credits" - ); + if (!submission.value) { + return json({ + ...submission, + formAction, + }); + } + + const deleteService = new DeleteTaskRunTemplateService(); + try { + await deleteService.call(environment, submission.value.templateId); + + return json({ + ...submission, + success: true, + formAction, + }); + } catch (e) { + logger.error("Failed to delete template", { error: e instanceof Error ? e.message : e }); + return redirectBackWithErrorMessage(request, "Failed to delete template"); + } } + case "run-scheduled": + case "run-standard": { + const submission = parse(formData, { schema: TestTaskData }); - logger.error("Failed to start a test run", { error: e instanceof Error ? e.message : e }); + if (!submission.value) { + return json({ + ...submission, + formAction, + }); + } - return redirectBackWithErrorMessage( - request, - "Unable to start a test run: Something went wrong" - ); + if (environment.archivedAt) { + return redirectBackWithErrorMessage(request, "Can't run a test on an archived environment"); + } + + const testService = new TestTaskService(); + try { + const run = await testService.call(environment, submission.value); + + if (!run) { + return redirectBackWithErrorMessage( + request, + "Unable to start a test run: Something went wrong" + ); + } + + return redirectWithSuccessMessage( + v3RunSpanPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam }, + { friendlyId: run.friendlyId }, + { spanId: run.spanId } + ), + request, + "Test run created" + ); + } catch (e) { + if (e instanceof OutOfEntitlementError) { + return redirectBackWithErrorMessage( + request, + "Unable to start a test run: You have exceeded your free credits" + ); + } + + logger.error("Failed to start a test run", { error: e instanceof Error ? e.message : e }); + + return redirectBackWithErrorMessage( + request, + "Unable to start a test run: Something went wrong" + ); + } + } + default: { + formAction satisfies never; + return redirectBackWithErrorMessage(request, "Failed to process request"); + } } }; @@ -199,6 +276,7 @@ export default function Page() { }, [queueFetcher.data?.queues, defaultTaskQueue]); const { triggerSource } = result; + switch (triggerSource) { case "STANDARD": { return ( @@ -207,6 +285,7 @@ export default function Page() { queues={queues} runs={result.runs} versions={result.latestVersions} + templates={result.taskRunTemplates} disableVersionSelection={result.disableVersionSelection} allowArbitraryQueues={result.allowArbitraryQueues} /> @@ -219,6 +298,7 @@ export default function Page() { queues={queues} runs={result.runs} versions={result.latestVersions} + templates={result.taskRunTemplates} possibleTimezones={result.possibleTimezones} disableVersionSelection={result.disableVersionSelection} allowArbitraryQueues={result.allowArbitraryQueues} @@ -239,6 +319,7 @@ function StandardTaskForm({ queues, runs, versions, + templates, disableVersionSelection, allowArbitraryQueues, }: { @@ -246,6 +327,7 @@ function StandardTaskForm({ queues: Required["queue"][]; runs: StandardRun[]; versions: string[]; + templates: RunTemplate[]; disableVersionSelection: boolean; allowArbitraryQueues: boolean; }) { @@ -253,8 +335,17 @@ function StandardTaskForm({ const { value, replace } = useSearchParams(); const tab = value("tab"); - const lastSubmission = useActionData(); - const lastRun = runs[0]; + const submit = useSubmit(); + const actionData = useActionData(); + const lastSubmission = + actionData && + typeof actionData === "object" && + "formAction" in actionData && + actionData.formAction === ("run-standard" satisfies FormAction) + ? actionData + : undefined; + + const lastRun = runs.at(0); const [defaultPayloadJson, setDefaultPayloadJson] = useState( lastRun?.payload ?? startingJson @@ -295,7 +386,8 @@ function StandardTaskForm({ paused: q.paused, })); - const fetcher = useFetcher(); + const [showTemplateCreatedSuccessMessage, setShowTemplateCreatedSuccessMessage] = useState(false); + const [ form, { @@ -326,7 +418,7 @@ function StandardTaskForm({ formData.set(payload.name, currentPayloadJson.current); formData.set(metadata.name, currentMetadataJson.current); - fetcher.submit(formData, { method: "POST" }); + submit(formData, { method: "POST" }); }, onValidate({ formData }) { return parse(formData, { schema: TestTaskData }); @@ -346,6 +438,21 @@ function StandardTaskForm({
+ { + setPayload(template.payload ?? ""); + setMetadata(template.metadata ?? ""); + setTtlValue(template.ttlSeconds ?? 0); + setConcurrencyKeyValue(template.concurrencyKey ?? ""); + setMaxAttemptsValue(template.maxAttempts ?? undefined); + setMaxDurationValue(template.maxDurationSeconds ?? 0); + setMachineValue(template.machinePreset ?? ""); + setTagsValue(template.tags ?? []); + setQueueValue(template.queue ?? undefined); + }} + showTemplateCreatedSuccessMessage={showTemplateCreatedSuccessMessage} + /> { @@ -645,11 +752,30 @@ function StandardTaskForm({
+ currentPayloadJson.current} + getCurrentMetadata={() => currentMetadataJson.current} + setShowCreatedSuccessMessage={setShowTemplateCreatedSuccessMessage} + /> @@ -665,6 +791,7 @@ function ScheduledTaskForm({ possibleTimezones, queues, versions, + templates, disableVersionSelection, allowArbitraryQueues, }: { @@ -673,24 +800,24 @@ function ScheduledTaskForm({ possibleTimezones: string[]; queues: Required["queue"][]; versions: string[]; + templates: RunTemplate[]; disableVersionSelection: boolean; allowArbitraryQueues: boolean; }) { const environment = useEnvironment(); - const lastSubmission = useActionData(); - const lastRun = runs[0]; + const lastRun = runs.at(0); const [timestampValue, setTimestampValue] = useState( - lastRun.payload.timestamp ?? new Date() + lastRun?.payload?.timestamp ?? new Date() ); const [lastTimestampValue, setLastTimestampValue] = useState( - lastRun.payload.lastTimestamp + lastRun?.payload?.lastTimestamp ); const [externalIdValue, setExternalIdValue] = useState( - lastRun.payload.externalId + lastRun?.payload?.externalId ); - const [timezoneValue, setTimezoneValue] = useState(lastRun.payload.timezone ?? "UTC"); + const [timezoneValue, setTimezoneValue] = useState(lastRun?.payload?.timezone ?? "UTC"); const [ttlValue, setTtlValue] = useState(lastRun?.ttlSeconds); const [concurrencyKeyValue, setConcurrencyKeyValue] = useState( lastRun?.concurrencyKey @@ -705,6 +832,8 @@ function ScheduledTaskForm({ ); const [tagsValue, setTagsValue] = useState(lastRun?.runTags ?? []); + const [showTemplateCreatedSuccessMessage, setShowTemplateCreatedSuccessMessage] = useState(false); + const queueItems = queues.map((q) => ({ value: q.type === "task" ? `task/${q.name}` : q.name, label: q.name, @@ -712,6 +841,15 @@ function ScheduledTaskForm({ paused: q.paused, })); + const actionData = useActionData(); + const lastSubmission = + actionData && + typeof actionData === "object" && + "formAction" in actionData && + actionData.formAction === ("run-scheduled" satisfies FormAction) + ? actionData + : undefined; + const [ form, { @@ -767,6 +905,24 @@ function ScheduledTaskForm({
+ { + setTtlValue(template.ttlSeconds ?? 0); + setConcurrencyKeyValue(template.concurrencyKey ?? ""); + setMaxAttemptsValue(template.maxAttempts ?? undefined); + setMaxDurationValue(template.maxDurationSeconds ?? 0); + setMachineValue(template.machinePreset ?? ""); + setTagsValue(template.tags ?? []); + setQueueValue(template.queue ?? undefined); + + setTimestampValue(template.scheduledTaskPayload?.timestamp); + setLastTimestampValue(template.scheduledTaskPayload?.lastTimestamp); + setExternalIdValue(template.scheduledTaskPayload?.externalId); + setTimezoneValue(template.scheduledTaskPayload?.timezone ?? "UTC"); + }} + showTemplateCreatedSuccessMessage={showTemplateCreatedSuccessMessage} + /> { @@ -1102,11 +1258,34 @@ function ScheduledTaskForm({
+ ""} + getCurrentMetadata={() => ""} + setShowCreatedSuccessMessage={setShowTemplateCreatedSuccessMessage} + /> @@ -1169,3 +1348,371 @@ function RecentRunsPopover({ ); } + +function RunTemplatesPopover({ + templates, + onTemplateSelected, + showTemplateCreatedSuccessMessage, +}: { + templates: RunTemplate[]; + onTemplateSelected: (run: RunTemplate) => void; + showTemplateCreatedSuccessMessage: boolean; +}) { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [templateIdToDelete, setTemplateIdToDelete] = useState(); + + const actionData = useActionData(); + const lastSubmission = + actionData && + typeof actionData === "object" && + "formAction" in actionData && + actionData.formAction === ("delete-template" satisfies FormAction) + ? actionData + : undefined; + + useEffect(() => { + if (lastSubmission && "success" in lastSubmission && lastSubmission.success === true) { + setIsDeleteDialogOpen(false); + } + }, [lastSubmission]); + + const [deleteForm, { templateId }] = useForm({ + id: "delete-template", + onValidate({ formData }) { + return parse(formData, { schema: DeleteTaskRunTemplateData }); + }, + }); + + return ( +
+ + + + + +
+
+ {templates.map((template) => ( +
+ +
+ ))} +
+
+
+
+ + + {showTemplateCreatedSuccessMessage && ( + + Template saved + successfully + + )} + + + + + Delete template + + Are you sure you want to delete the template? This can't be reversed. + +
+ +
+ + +
+
+
+
+
+ ); +} + +function CreateTemplateModal({ + rawTestTaskFormData, + getCurrentPayload, + getCurrentMetadata, + setShowCreatedSuccessMessage, +}: { + rawTestTaskFormData: { + environmentId: string; + taskIdentifier: string; + triggerSource: string; + delaySeconds?: string; + ttlSeconds?: string; + queue?: string; + concurrencyKey?: string; + maxAttempts?: string; + maxDurationSeconds?: string; + tags?: string; + machine?: string; + externalId?: string; + timestamp?: string; + timezone?: string; + lastTimestamp?: string; + }; + getCurrentPayload: () => string; + getCurrentMetadata: () => string; + setShowCreatedSuccessMessage: (value: boolean) => void; +}) { + const submit = useSubmit(); + const [isModalOpen, setIsModalOpen] = useState(false); + + const actionData = useActionData(); + const lastSubmission = + actionData && + typeof actionData === "object" && + "formAction" in actionData && + actionData.formAction === ("create-template" satisfies FormAction) + ? actionData + : undefined; + + useEffect(() => { + if (lastSubmission && "success" in lastSubmission && lastSubmission.success === true) { + setIsModalOpen(false); + setShowCreatedSuccessMessage(true); + setTimeout(() => { + setShowCreatedSuccessMessage(false); + }, 2000); + } + }, [lastSubmission]); + + const [ + form, + { + label, + environmentId, + payload, + metadata, + taskIdentifier, + delaySeconds, + ttlSeconds, + queue, + concurrencyKey, + maxAttempts, + maxDurationSeconds, + triggerSource, + tags, + machine, + externalId, + timestamp, + lastTimestamp, + timezone, + }, + ] = useForm({ + id: "save-template", + lastSubmission: lastSubmission as any, + onSubmit(event, { formData }) { + event.preventDefault(); + + formData.set(payload.name, getCurrentPayload()); + formData.set(metadata.name, getCurrentMetadata()); + + submit(formData, { method: "POST" }); + }, + onValidate({ formData }) { + return parse(formData, { schema: RunTemplateData }); + }, + shouldRevalidate: "onInput", + }); + + return ( + + + + } + cancelButton={ + + + + } + /> + + + + + + ); +} diff --git a/apps/webapp/app/v3/services/deleteTaskRunTemplate.server.ts b/apps/webapp/app/v3/services/deleteTaskRunTemplate.server.ts new file mode 100644 index 0000000000..72d2ef7dba --- /dev/null +++ b/apps/webapp/app/v3/services/deleteTaskRunTemplate.server.ts @@ -0,0 +1,19 @@ +import { BaseService } from "./baseService.server"; +import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; + +export class DeleteTaskRunTemplateService extends BaseService { + public async call(environment: AuthenticatedEnvironment, templateId: string) { + try { + await this._prisma.taskRunTemplate.delete({ + where: { + id: templateId, + projectId: environment.projectId, + }, + }); + } catch (e) { + throw new Error( + `Error deleting template: ${e instanceof Error ? e.message : JSON.stringify(e)}` + ); + } + } +} diff --git a/apps/webapp/app/v3/services/taskRunTemplate.server.ts b/apps/webapp/app/v3/services/taskRunTemplate.server.ts new file mode 100644 index 0000000000..827bffd021 --- /dev/null +++ b/apps/webapp/app/v3/services/taskRunTemplate.server.ts @@ -0,0 +1,92 @@ +import { packetRequiresOffloading, stringifyIO } from "@trigger.dev/core/v3"; +import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { BaseService } from "./baseService.server"; +import { env } from "~/env.server"; +import { handleMetadataPacket } from "~/utils/packets"; +import { type RunTemplateData } from "../taskRunTemplate"; + +export class TaskRunTemplateService extends BaseService { + public async call(environment: AuthenticatedEnvironment, data: RunTemplateData) { + const { triggerSource } = data; + + switch (triggerSource) { + case "STANDARD": { + const packet = { data: JSON.stringify(data.payload), dataType: "application/json" }; + + const { needsOffloading } = packetRequiresOffloading( + packet, + env.TASK_PAYLOAD_OFFLOAD_THRESHOLD + ); + + if (needsOffloading) { + // we currently disallow large payloads in task run templates + throw new Error("Payload too large"); + } + + const metadataPacket = data.metadata + ? handleMetadataPacket(data.metadata, "application/json") + : undefined; + + const taskRunTemplate = await this._prisma.taskRunTemplate.create({ + data: { + taskSlug: data.taskIdentifier, + triggerSource: "STANDARD", + label: data.label, + payload: packet.data, + payloadType: packet.dataType, + metadata: metadataPacket?.data, + metadataType: metadataPacket?.dataType, + queue: data.queue, + ttlSeconds: data.ttlSeconds, + concurrencyKey: data.concurrencyKey, + maxAttempts: data.maxAttempts, + maxDurationSeconds: data.maxDurationSeconds, + tags: data.tags ?? [], + machinePreset: data.machine, + projectId: environment.projectId, + organizationId: environment.organizationId, + }, + }); + + return taskRunTemplate; + } + case "SCHEDULED": { + const payload = { + scheduleId: "sched_1234", + type: "IMPERATIVE", + timestamp: data.timestamp, + lastTimestamp: data.lastTimestamp, + timezone: data.timezone, + externalId: data.externalId, + upcoming: [], + }; + const payloadPacket = await stringifyIO(payload); + + const taskRunTemplate = await this._prisma.taskRunTemplate.create({ + data: { + taskSlug: data.taskIdentifier, + triggerSource: "SCHEDULED", + label: data.label, + payload: payloadPacket.data, + payloadType: payloadPacket.dataType, + queue: data.queue, + ttlSeconds: data.ttlSeconds, + concurrencyKey: data.concurrencyKey, + maxAttempts: data.maxAttempts, + maxDurationSeconds: data.maxDurationSeconds, + tags: data.tags ?? [], + machinePreset: data.machine, + projectId: environment.projectId, + organizationId: environment.organizationId, + }, + }); + + return taskRunTemplate; + } + default: { + triggerSource satisfies never; + throw new Error("Invalid trigger source"); + } + } + } +} diff --git a/apps/webapp/app/v3/services/testTask.server.ts b/apps/webapp/app/v3/services/testTask.server.ts index b6e6410743..e2e52078b4 100644 --- a/apps/webapp/app/v3/services/testTask.server.ts +++ b/apps/webapp/app/v3/services/testTask.server.ts @@ -7,8 +7,9 @@ import { TriggerTaskService } from "./triggerTask.server"; export class TestTaskService extends BaseService { public async call(environment: AuthenticatedEnvironment, data: TestTaskData) { const triggerTaskService = new TriggerTaskService(); + const { triggerSource } = data; - switch (data.triggerSource) { + switch (triggerSource) { case "STANDARD": { const result = await triggerTaskService.call(data.taskIdentifier, environment, { payload: data.payload, @@ -72,8 +73,10 @@ export class TestTaskService extends BaseService { return result?.run; } - default: + default: { + triggerSource satisfies never; throw new Error("Invalid trigger source"); + } } } } diff --git a/apps/webapp/app/v3/taskRunTemplate.ts b/apps/webapp/app/v3/taskRunTemplate.ts new file mode 100644 index 0000000000..46bbc797eb --- /dev/null +++ b/apps/webapp/app/v3/taskRunTemplate.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { TestTaskData } from "./testTask"; + +export const RunTemplateData = TestTaskData.and( + z.object({ + label: z.string().max(42, "Labels can be at most 42 characters long"), + }) +); + +export type RunTemplateData = z.infer; + +export const DeleteTaskRunTemplateData = z.object({ + templateId: z.string(), +}); diff --git a/internal-packages/database/prisma/migrations/20250709162608_add_task_run_template_model/migration.sql b/internal-packages/database/prisma/migrations/20250709162608_add_task_run_template_model/migration.sql new file mode 100644 index 0000000000..f84703d936 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250709162608_add_task_run_template_model/migration.sql @@ -0,0 +1,25 @@ +CREATE TABLE "TaskRunTemplate" ( + "id" TEXT NOT NULL, + "taskSlug" TEXT NOT NULL, + "triggerSource" "TaskTriggerSource" NOT NULL, + "label" TEXT NOT NULL, + "payload" TEXT, + "payloadType" TEXT NOT NULL DEFAULT 'application/json', + "metadata" TEXT, + "metadataType" TEXT NOT NULL DEFAULT 'application/json', + "queue" TEXT, + "concurrencyKey" TEXT, + "ttlSeconds" INTEGER, + "maxAttempts" INTEGER, + "maxDurationSeconds" INTEGER, + "tags" TEXT[], + "machinePreset" TEXT, + "projectId" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TaskRunTemplate_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "TaskRunTemplate_projectId_taskSlug_triggerSource_createdAt_idx" ON "TaskRunTemplate"("projectId", "taskSlug", "triggerSource", "createdAt" DESC); diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 86bcf07f48..9d7f648c5d 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -716,6 +716,35 @@ model TaskRun { @@index([status, runtimeEnvironmentId, createdAt, id(sort: Desc)]) } +model TaskRunTemplate { + id String @id @default(cuid()) + + taskSlug String + triggerSource TaskTriggerSource + + label String + + payload String? + payloadType String @default("application/json") + metadata String? + metadataType String @default("application/json") + queue String? + concurrencyKey String? + ttlSeconds Int? + maxAttempts Int? + maxDurationSeconds Int? + tags String[] + machinePreset String? + + projectId String + organizationId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([projectId, taskSlug, triggerSource, createdAt(sort: Desc)]) +} + enum TaskRunStatus { /// /// NON-FINAL STATUSES