diff --git a/apps/webapp/app/assets/icons/ClockRotateLeftIcon.tsx b/apps/webapp/app/assets/icons/ClockRotateLeftIcon.tsx new file mode 100644 index 0000000000..edef4f87b7 --- /dev/null +++ b/apps/webapp/app/assets/icons/ClockRotateLeftIcon.tsx @@ -0,0 +1,15 @@ +export function ClockRotateLeftIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/components/code/JSONEditor.tsx b/apps/webapp/app/components/code/JSONEditor.tsx index db85a9cd9f..f40e1ef413 100644 --- a/apps/webapp/app/components/code/JSONEditor.tsx +++ b/apps/webapp/app/components/code/JSONEditor.tsx @@ -21,6 +21,7 @@ export interface JSONEditorProps extends Omit { showClearButton?: boolean; linterEnabled?: boolean; allowEmpty?: boolean; + additionalActions?: React.ReactNode; } const languages = { @@ -64,6 +65,7 @@ export function JSONEditor(opts: JSONEditorProps) { showClearButton = true, linterEnabled, allowEmpty, + additionalActions, } = { ...defaultProps, ...opts, @@ -152,6 +154,7 @@ export function JSONEditor(opts: JSONEditorProps) { > {showButtons && ( + {additionalActions && additionalActions} {showClearButton && ( void; + variant?: "small" | "medium"; + showClearButton?: boolean; +} + +export function DurationPicker({ + name, + defaultValueSeconds: defaultValue = 0, + value: controlledValue, + onChange, + variant = "small", + showClearButton = true, +}: DurationPickerProps) { + // Use controlled value if provided, otherwise use default + const initialValue = controlledValue ?? defaultValue; + + const defaultHours = Math.floor(initialValue / 3600); + const defaultMinutes = Math.floor((initialValue % 3600) / 60); + const defaultSeconds = initialValue % 60; + + const [hours, setHours] = useState(defaultHours); + const [minutes, setMinutes] = useState(defaultMinutes); + const [seconds, setSeconds] = useState(defaultSeconds); + + const minuteRef = useRef(null); + const hourRef = useRef(null); + const secondRef = useRef(null); + + const totalSeconds = hours * 3600 + minutes * 60 + seconds; + + const isEmpty = hours === 0 && minutes === 0 && seconds === 0; + + // Sync internal state with external value changes + useEffect(() => { + if (controlledValue !== undefined && controlledValue !== totalSeconds) { + const newHours = Math.floor(controlledValue / 3600); + const newMinutes = Math.floor((controlledValue % 3600) / 60); + const newSeconds = controlledValue % 60; + + setHours(newHours); + setMinutes(newMinutes); + setSeconds(newSeconds); + } + }, [controlledValue]); + + useEffect(() => { + onChange?.(totalSeconds); + }, [totalSeconds, onChange]); + + const handleHoursChange = (e: React.ChangeEvent) => { + const value = parseInt(e.target.value) || 0; + setHours(Math.max(0, value)); + }; + + const handleMinutesChange = (e: React.ChangeEvent) => { + const value = parseInt(e.target.value) || 0; + if (value >= 60) { + setHours((prev) => prev + Math.floor(value / 60)); + setMinutes(value % 60); + return; + } + + setMinutes(Math.max(0, Math.min(59, value))); + }; + + const handleSecondsChange = (e: React.ChangeEvent) => { + const value = parseInt(e.target.value) || 0; + if (value >= 60) { + setMinutes((prev) => { + const newMinutes = prev + Math.floor(value / 60); + if (newMinutes >= 60) { + setHours((prevHours) => prevHours + Math.floor(newMinutes / 60)); + return newMinutes % 60; + } + return newMinutes; + }); + setSeconds(value % 60); + return; + } + + setSeconds(Math.max(0, Math.min(59, value))); + }; + + const handleKeyDown = ( + e: React.KeyboardEvent, + nextRef?: React.RefObject, + prevRef?: React.RefObject + ) => { + if (e.key === "Tab") { + return; + } + + if (e.key === "ArrowRight" && nextRef) { + e.preventDefault(); + nextRef.current?.focus(); + nextRef.current?.select(); + return; + } + + if (e.key === "ArrowLeft" && prevRef) { + e.preventDefault(); + prevRef.current?.focus(); + prevRef.current?.select(); + return; + } + }; + + const clearDuration = () => { + setHours(0); + setMinutes(0); + setSeconds(0); + hourRef.current?.focus(); + }; + + return ( + + + + + + handleKeyDown(e, minuteRef)} + onFocus={(e) => e.target.select()} + type="number" + min={0} + inputMode="numeric" + /> + + h + + + + handleKeyDown(e, secondRef, hourRef)} + onFocus={(e) => e.target.select()} + type="number" + min={0} + max={59} + inputMode="numeric" + /> + + m + + + + handleKeyDown(e, undefined, minuteRef)} + onFocus={(e) => e.target.select()} + type="number" + min={0} + max={59} + inputMode="numeric" + /> + + s + + + + + {showClearButton && ( + + Clear + + )} + + ); +} diff --git a/apps/webapp/app/components/primitives/Label.tsx b/apps/webapp/app/components/primitives/Label.tsx index 000b407911..a9f15f68e3 100644 --- a/apps/webapp/app/components/primitives/Label.tsx +++ b/apps/webapp/app/components/primitives/Label.tsx @@ -4,7 +4,7 @@ import { InfoIconTooltip, SimpleTooltip } from "./Tooltip"; const variants = { small: { - text: "font-sans text-sm font-normal text-text-bright leading-tight flex items-center gap-1", + text: "font-sans text-[0.8125rem] font-normal text-text-bright leading-tight flex items-center gap-1", }, medium: { text: "font-sans text-sm text-text-bright leading-tight flex items-center gap-1", diff --git a/apps/webapp/app/components/primitives/Select.tsx b/apps/webapp/app/components/primitives/Select.tsx index 2853f0b59c..75c6adeed8 100644 --- a/apps/webapp/app/components/primitives/Select.tsx +++ b/apps/webapp/app/components/primitives/Select.tsx @@ -327,6 +327,7 @@ export function SelectTrigger({ className )} ref={ref} + disabled={disabled} {...props} /> } @@ -615,7 +616,7 @@ export function SelectPopover({ unmountOnHide={unmountOnHide} className={cn( "z-50 flex flex-col overflow-clip rounded border border-charcoal-700 bg-background-bright shadow-md outline-none animate-in fade-in-40", - "min-w-[max(180px,calc(var(--popover-anchor-width)+0.5rem))]", + "min-w-[max(180px,var(--popover-anchor-width))]", "max-w-[min(480px,var(--popover-available-width))]", "max-h-[min(600px,var(--popover-available-height))]", "origin-[var(--popover-transform-origin)]", diff --git a/apps/webapp/app/components/runs/v3/RunTag.tsx b/apps/webapp/app/components/runs/v3/RunTag.tsx index c7aab7cb09..14baeca1a3 100644 --- a/apps/webapp/app/components/runs/v3/RunTag.tsx +++ b/apps/webapp/app/components/runs/v3/RunTag.tsx @@ -3,11 +3,21 @@ import tagLeftPath from "./tag-left.svg"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { Link } from "@remix-run/react"; import { cn } from "~/utils/cn"; -import { ClipboardCheckIcon, ClipboardIcon } from "lucide-react"; +import { ClipboardCheckIcon, ClipboardIcon, XIcon } from "lucide-react"; type Tag = string | { key: string; value: string }; -export function RunTag({ tag, to, tooltip }: { tag: string; to?: string; tooltip?: string }) { +export function RunTag({ + tag, + to, + tooltip, + action = { type: "copy" }, +}: { + tag: string; + action?: { type: "copy" } | { type: "delete"; onDelete: (tag: string) => void }; + to?: string; + tooltip?: string; +}) { const tagResult = useMemo(() => splitTag(tag), [tag]); const [isHovered, setIsHovered] = useState(false); @@ -57,7 +67,11 @@ export function RunTag({ tag, to, tooltip }: { tag: string; to?: string; tooltip return ( setIsHovered(false)}> {tagContent} - + {action.type === "delete" ? ( + + ) : ( + + )} ); } @@ -105,6 +119,45 @@ function CopyButton({ textToCopy, isHovered }: { textToCopy: string; isHovered: ); } +function DeleteButton({ + tag, + onDelete, + isHovered, +}: { + tag: string; + onDelete: (tag: string) => void; + isHovered: boolean; +}) { + const handleDelete = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onDelete(tag); + }, + [tag, onDelete] + ); + + return ( + e.stopPropagation()} + className={cn( + "absolute -right-6 top-0 z-10 size-6 items-center justify-center rounded-r-sm border-y border-r border-charcoal-650 bg-charcoal-750", + isHovered ? "flex" : "hidden", + "text-text-dimmed hover:border-charcoal-600 hover:bg-charcoal-700 hover:text-rose-400" + )} + > + + + } + content="Remove tag" + disableHoverableContent + /> + ); +} + /** Takes a string and turns it into a tag * * If the string has 12 or fewer alpha characters followed by an underscore or colon then we return an object with a key and value diff --git a/apps/webapp/app/components/runs/v3/RunTagInput.tsx b/apps/webapp/app/components/runs/v3/RunTagInput.tsx new file mode 100644 index 0000000000..25d818f402 --- /dev/null +++ b/apps/webapp/app/components/runs/v3/RunTagInput.tsx @@ -0,0 +1,133 @@ +import { useCallback, useState, useEffect, type KeyboardEvent } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { Input } from "~/components/primitives/Input"; +import { RunTag } from "./RunTag"; + +interface TagInputProps { + id?: string; // used for the hidden input for form submission + name?: string; // used for the hidden input for form submission + defaultTags?: string[]; + tags?: string[]; + placeholder?: string; + variant?: "small" | "medium"; + maxTags?: number; + maxTagLength?: number; + onTagsChange?: (tags: string[]) => void; +} + +export function RunTagInput({ + id, + name, + defaultTags = [], + tags: controlledTags, + placeholder = "Type and press Enter to add tags", + variant = "small", + maxTags = 10, + maxTagLength = 128, + onTagsChange, +}: TagInputProps) { + // Use controlled tags if provided, otherwise use default + const initialTags = controlledTags ?? defaultTags; + + const [tags, setTags] = useState(initialTags); + const [inputValue, setInputValue] = useState(""); + + // Sync internal state with external tag changes + useEffect(() => { + if (controlledTags !== undefined) { + setTags(controlledTags); + } + }, [controlledTags]); + + const addTag = useCallback( + (tagText: string) => { + const trimmedTag = tagText.trim(); + if (trimmedTag && !tags.includes(trimmedTag) && tags.length < maxTags) { + const newTags = [...tags, trimmedTag]; + setTags(newTags); + onTagsChange?.(newTags); + } + setInputValue(""); + }, + [tags, onTagsChange, maxTags] + ); + + const removeTag = useCallback( + (tagToRemove: string) => { + const newTags = tags.filter((tag) => tag !== tagToRemove); + setTags(newTags); + onTagsChange?.(newTags); + }, + [tags, onTagsChange] + ); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + addTag(inputValue); + } else if (e.key === "Backspace" && inputValue === "" && tags.length > 0) { + removeTag(tags[tags.length - 1]); + } else if (e.key === ",") { + e.preventDefault(); + } + }, + [inputValue, addTag, removeTag, tags] + ); + + const maxTagsReached = tags.length >= maxTags; + + return ( + + + + setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={maxTagsReached ? `A maximum of ${maxTags} tags is allowed` : placeholder} + variant={variant} + disabled={maxTagsReached} + maxLength={maxTagLength} + /> + + {tags.length > 0 && ( + + + {tags.map((tag, i) => ( + + + + ))} + + + )} + + ); +} diff --git a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts index 93531934bd..0e13fc6167 100644 --- a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts @@ -3,9 +3,16 @@ import { determineEngineVersion } from "~/v3/engineVersion.server"; import { engine } from "~/v3/runEngine.server"; import { BasePresenter } from "./basePresenter.server"; import { toQueueItem } from "./QueueRetrievePresenter.server"; +import { TaskQueueType } from "@trigger.dev/database"; const DEFAULT_ITEMS_PER_PAGE = 25; const MAX_ITEMS_PER_PAGE = 100; + +const typeToDBQueueType: Record<"task" | "custom", TaskQueueType> = { + task: TaskQueueType.VIRTUAL, + custom: TaskQueueType.NAMED, +}; + export class QueueListPresenter extends BasePresenter { private readonly perPage: number; @@ -18,13 +25,15 @@ export class QueueListPresenter extends BasePresenter { environment, query, page, + type, }: { environment: AuthenticatedEnvironment; query?: string; page: number; perPage?: number; + type?: "task" | "custom"; }) { - const hasFilters = query !== undefined && query.length > 0; + const hasFilters = (query !== undefined && query.length > 0) || type !== undefined; // Get total count for pagination const totalQueues = await this._replica.taskQueue.count({ @@ -37,6 +46,7 @@ export class QueueListPresenter extends BasePresenter { mode: "insensitive", } : undefined, + type: type ? typeToDBQueueType[type] : undefined, }, }); @@ -70,7 +80,7 @@ export class QueueListPresenter extends BasePresenter { return { success: true as const, - queues: await this.getQueuesWithPagination(environment, query, page), + queues: await this.getQueuesWithPagination(environment, query, page, type), pagination: { currentPage: page, totalPages: Math.ceil(totalQueues / this.perPage), @@ -84,7 +94,8 @@ export class QueueListPresenter extends BasePresenter { private async getQueuesWithPagination( environment: AuthenticatedEnvironment, query: string | undefined, - page: number + page: number, + type: "task" | "custom" | undefined ) { const queues = await this._replica.taskQueue.findMany({ where: { @@ -96,6 +107,7 @@ export class QueueListPresenter extends BasePresenter { mode: "insensitive", } : undefined, + type: type ? typeToDBQueueType[type] : undefined, }, select: { friendlyId: true, diff --git a/apps/webapp/app/presenters/v3/QueueRetrievePresenter.server.ts b/apps/webapp/app/presenters/v3/QueueRetrievePresenter.server.ts index 12e8e86291..04cc26a5ad 100644 --- a/apps/webapp/app/presenters/v3/QueueRetrievePresenter.server.ts +++ b/apps/webapp/app/presenters/v3/QueueRetrievePresenter.server.ts @@ -82,7 +82,7 @@ export class QueueRetrievePresenter extends BasePresenter { } } -function queueTypeFromType(type: TaskQueueType) { +export function queueTypeFromType(type: TaskQueueType) { switch (type) { case "NAMED": return "custom" as const; diff --git a/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts b/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts index 6109cd1b17..79db1b4b78 100644 --- a/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts @@ -2,10 +2,9 @@ import { ScheduledTaskPayload, parsePacket, prettyPrintPacket } from "@trigger.d import { type RuntimeEnvironmentType, type TaskRunStatus } from "@trigger.dev/database"; import { type PrismaClient, prisma, sqlDatabaseSchema } from "~/db.server"; import { getTimezones } from "~/utils/timezones.server"; -import { - type BackgroundWorkerTaskSlim, - findCurrentWorkerDeployment, -} from "~/v3/models/workerDeployment.server"; +import { findCurrentWorkerDeployment } from "~/v3/models/workerDeployment.server"; +import { queueTypeFromType } from "./QueueRetrievePresenter.server"; +import parse from "parse-duration"; type TestTaskOptions = { userId: string; @@ -24,31 +23,51 @@ type Task = { friendlyId: string; }; -export type TestTask = +type Queue = { + id: string; + name: string; + type: "custom" | "task"; + paused: boolean; +}; + +export type TestTaskResult = | { + foundTask: true; triggerSource: "STANDARD"; + queue?: Queue; task: Task; runs: StandardRun[]; + latestVersions: string[]; + disableVersionSelection: boolean; + allowArbitraryQueues: boolean; } | { + foundTask: true; triggerSource: "SCHEDULED"; + queue?: Queue; task: Task; possibleTimezones: string[]; runs: ScheduledRun[]; - }; - -export type TestTaskResult = - | { - foundTask: true; - task: TestTask; + latestVersions: string[]; + disableVersionSelection: boolean; + allowArbitraryQueues: boolean; } | { foundTask: false; }; +export type StandardTaskResult = Extract< + TestTaskResult, + { foundTask: true; triggerSource: "STANDARD" } +>; +export type ScheduledTaskResult = Extract< + TestTaskResult, + { foundTask: true; triggerSource: "SCHEDULED" } +>; + type RawRun = { id: string; - number: BigInt; + queue: string; friendlyId: string; createdAt: Date; status: TaskRunStatus; @@ -57,20 +76,28 @@ type RawRun = { runtimeEnvironmentId: string; seedMetadata?: string; seedMetadataType?: string; + concurrencyKey?: string; + maxAttempts?: number; + maxDurationInSeconds?: number; + machinePreset?: string; + ttl?: string; + idempotencyKey?: string; + runTags: string[]; }; -export type StandardRun = Omit & { - number: number; +export type StandardRun = Omit & { + metadata?: string; + ttlSeconds?: number; }; -export type ScheduledRun = Omit & { - number: number; +export type ScheduledRun = Omit & { payload: { timestamp: Date; lastTimestamp?: Date; externalId?: string; timezone: string; }; + ttlSeconds?: number; }; export class TestTaskPresenter { @@ -86,23 +113,20 @@ export class TestTaskPresenter { environment, taskIdentifier, }: TestTaskOptions): Promise { - let task: BackgroundWorkerTaskSlim | null = null; - if (environment.type !== "DEVELOPMENT") { - const deployment = await findCurrentWorkerDeployment({ environmentId: environment.id }); - if (deployment) { - task = deployment.worker?.tasks.find((t) => t.slug === taskIdentifier) ?? null; - } - } else { - task = await this.#prismaClient.backgroundWorkerTask.findFirst({ - where: { - slug: taskIdentifier, - runtimeEnvironmentId: environment.id, - }, - orderBy: { - createdAt: "desc", - }, - }); - } + const task = + environment.type !== "DEVELOPMENT" + ? ( + await findCurrentWorkerDeployment({ environmentId: environment.id }) + )?.worker?.tasks.find((t) => t.slug === taskIdentifier) + : await this.#prismaClient.backgroundWorkerTask.findFirst({ + where: { + slug: taskIdentifier, + runtimeEnvironmentId: environment.id, + }, + orderBy: { + createdAt: "desc", + }, + }); if (!task) { return { @@ -110,6 +134,40 @@ export class TestTaskPresenter { }; } + const taskQueue = task.queueId + ? await this.#prismaClient.taskQueue.findFirst({ + where: { + runtimeEnvironmentId: environment.id, + id: task.queueId, + }, + select: { + friendlyId: true, + name: true, + type: true, + paused: true, + }, + }) + : undefined; + + const backgroundWorkers = await this.#prismaClient.backgroundWorker.findMany({ + where: { + runtimeEnvironmentId: environment.id, + }, + select: { + version: true, + engine: true, + }, + orderBy: { + createdAt: "desc", + }, + take: 20, // last 20 versions should suffice + }); + + const latestVersions = backgroundWorkers.map((v) => v.version); + + const disableVersionSelection = environment.type === "DEVELOPMENT"; + const allowArbitraryQueues = backgroundWorkers[0]?.engine === "V1"; + const latestRuns = await this.#prismaClient.$queryRaw` WITH taskruns AS ( SELECT @@ -129,7 +187,7 @@ export class TestTaskPresenter { ) SELECT taskr.id, - taskr.number, + taskr."queue", taskr."friendlyId", taskr."taskIdentifier", taskr."createdAt", @@ -138,7 +196,13 @@ export class TestTaskPresenter { taskr."payloadType", taskr."seedMetadata", taskr."seedMetadataType", - taskr."runtimeEnvironmentId" + taskr."runtimeEnvironmentId", + taskr."concurrencyKey", + taskr."maxAttempts", + taskr."maxDurationInSeconds", + taskr."machinePreset", + taskr."ttl", + taskr."runTags" FROM taskruns AS taskr WHERE @@ -157,52 +221,71 @@ export class TestTaskPresenter { case "STANDARD": return { foundTask: true, - task: { - triggerSource: "STANDARD", - task: taskWithEnvironment, - runs: await Promise.all( - latestRuns.map(async (r) => { - const number = Number(r.number); - - return { + triggerSource: "STANDARD", + queue: taskQueue + ? { + id: taskQueue.friendlyId, + name: taskQueue.name.replace(/^task\//, ""), + type: queueTypeFromType(taskQueue.type), + paused: taskQueue.paused, + } + : undefined, + task: taskWithEnvironment, + runs: await Promise.all( + latestRuns.map( + async (r) => + ({ ...r, - number, payload: await prettyPrintPacket(r.payload, r.payloadType), metadata: r.seedMetadata ? await prettyPrintPacket(r.seedMetadata, r.seedMetadataType) : undefined, - }; - }) - ), - }, + ttlSeconds: r.ttl ? parse(r.ttl, "s") ?? undefined : undefined, + } satisfies StandardRun) + ) + ), + latestVersions, + disableVersionSelection, + allowArbitraryQueues, }; - case "SCHEDULED": + case "SCHEDULED": { const possibleTimezones = getTimezones(); return { foundTask: true, - task: { - triggerSource: "SCHEDULED", - task: taskWithEnvironment, - possibleTimezones, - runs: ( - await Promise.all( - latestRuns.map(async (r) => { - const number = Number(r.number); - - const payload = await getScheduleTaskRunPayload(r); - - if (payload.success) { - return { - ...r, - number, - payload: payload.data, - }; - } - }) - ) - ).filter(Boolean), - }, + triggerSource: "SCHEDULED", + queue: taskQueue + ? { + id: taskQueue.friendlyId, + name: taskQueue.name.replace(/^task\//, ""), + type: queueTypeFromType(taskQueue.type), + paused: taskQueue.paused, + } + : undefined, + task: taskWithEnvironment, + possibleTimezones, + runs: ( + await Promise.all( + latestRuns.map(async (r) => { + const payload = await getScheduleTaskRunPayload(r); + + if (payload.success) { + return { + ...r, + payload: payload.data, + ttlSeconds: r.ttl ? parse(r.ttl, "s") ?? undefined : undefined, + } satisfies ScheduledRun; + } + }) + ) + ).filter(Boolean), + latestVersions, + disableVersionSelection, + allowArbitraryQueues, }; + } + default: { + return task.triggerSource satisfies never; + } } } } 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 c9d59a126b..0541957143 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,38 +1,37 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; -import { BeakerIcon } from "@heroicons/react/20/solid"; -import { Form, useActionData, useSubmit } from "@remix-run/react"; +import { BeakerIcon, RectangleStackIcon } from "@heroicons/react/20/solid"; import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; -import { type TaskRunStatus } from "@trigger.dev/database"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState, useMemo } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { TaskIcon } from "~/assets/icons/TaskIcon"; import { JSONEditor } from "~/components/code/JSONEditor"; -import { EnvironmentCombo, EnvironmentLabel } from "~/components/environments/EnvironmentLabel"; +import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; +import { Badge } from "~/components/primitives/Badge"; import { Button } from "~/components/primitives/Buttons"; -import { Callout } from "~/components/primitives/Callout"; import { DateField } from "~/components/primitives/DateField"; -import { DateTime } from "~/components/primitives/DateTime"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormError } from "~/components/primitives/FormError"; -import { Header2 } from "~/components/primitives/Headers"; import { Hint } from "~/components/primitives/Hint"; import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; +import { DurationPicker } from "~/components/primitives/DurationPicker"; import { Paragraph } from "~/components/primitives/Paragraph"; -import { RadioButtonCircle } from "~/components/primitives/RadioButton"; +import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover"; +import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup, } from "~/components/primitives/Resizable"; -import { Select } from "~/components/primitives/Select"; +import { Select, SelectItem } from "~/components/primitives/Select"; import { TabButton, TabContainer } from "~/components/primitives/Tabs"; import { TextLink } from "~/components/primitives/TextLink"; -import { TaskRunStatusCombo } from "~/components/runs/v3/TaskRunStatus"; 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 { redirectBackWithErrorMessage, redirectWithErrorMessage, @@ -43,7 +42,8 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { type ScheduledRun, type StandardRun, - type TestTask, + type StandardTaskResult, + type ScheduledTaskResult, TestTaskPresenter, } from "~/presenters/v3/TestTaskPresenter.server"; import { logger } from "~/services/logger.server"; @@ -53,6 +53,13 @@ import { docsPath, v3RunSpanPath, v3TaskParamsSchema, v3TestPath } from "~/utils import { TestTaskService } from "~/v3/services/testTask.server"; import { OutOfEntitlementError } from "~/v3/services/triggerTask.server"; import { TestTaskData } from "~/v3/testTask"; +import { RunTagInput } from "~/components/runs/v3/RunTagInput"; +import { type loader as queuesLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues"; +import { DateTime } from "~/components/primitives/DateTime"; +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"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); @@ -165,41 +172,92 @@ export default function Page() { return ; } - switch (result.task.triggerSource) { + const params = useParams(); + const queueFetcher = useFetcher(); + + useEffect(() => { + if (params.organizationSlug && params.projectParam && params.envParam) { + const searchParams = new URLSearchParams(); + searchParams.set("type", "custom"); + searchParams.set("per_page", "100"); + + queueFetcher.load( + `/resources/orgs/${params.organizationSlug}/projects/${params.projectParam}/env/${ + params.envParam + }/queues?${searchParams.toString()}` + ); + } + }, [params.organizationSlug, params.projectParam, params.envParam]); + + const defaultTaskQueue = result.queue; + const queues = useMemo(() => { + const customQueues = queueFetcher.data?.queues ?? []; + + return defaultTaskQueue && !customQueues.some((q) => q.id === defaultTaskQueue.id) + ? [defaultTaskQueue, ...customQueues] + : customQueues; + }, [queueFetcher.data?.queues, defaultTaskQueue]); + + const { triggerSource } = result; + switch (triggerSource) { case "STANDARD": { - return ; + return ( + + ); } case "SCHEDULED": { return ( ); } + default: { + return triggerSource satisfies never; + } } } const startingJson = "{\n\n}"; +const machinePresets = Object.values(MachinePresetName.enum); -function StandardTaskForm({ task, runs }: { task: TestTask["task"]; runs: StandardRun[] }) { +function StandardTaskForm({ + task, + queues, + runs, + versions, + disableVersionSelection, + allowArbitraryQueues, +}: { + task: StandardTaskResult["task"]; + queues: Required["queue"][]; + runs: StandardRun[]; + versions: string[]; + disableVersionSelection: boolean; + allowArbitraryQueues: boolean; +}) { const environment = useEnvironment(); const { value, replace } = useSearchParams(); const tab = value("tab"); - //form submission - const submit = useSubmit(); const lastSubmission = useActionData(); - - //recent runs - const [selectedCodeSampleId, setSelectedCodeSampleId] = useState(runs.at(0)?.id); - const selectedCodeSample = runs.find((r) => r.id === selectedCodeSampleId); - const selectedCodeSamplePayload = selectedCodeSample?.payload; - const selectedCodeSampleMetadata = selectedCodeSample?.seedMetadata; + const lastRun = runs[0]; const [defaultPayloadJson, setDefaultPayloadJson] = useState( - selectedCodeSamplePayload ?? startingJson + lastRun?.payload ?? startingJson ); const setPayload = useCallback((code: string) => { setDefaultPayloadJson(code); @@ -208,7 +266,7 @@ function StandardTaskForm({ task, runs }: { task: TestTask["task"]; runs: Standa const currentPayloadJson = useRef(defaultPayloadJson); const [defaultMetadataJson, setDefaultMetadataJson] = useState( - selectedCodeSampleMetadata ?? "{}" + lastRun?.seedMetadata ?? "{}" ); const setMetadata = useCallback((code: string) => { setDefaultMetadataJson(code); @@ -216,140 +274,376 @@ function StandardTaskForm({ task, runs }: { task: TestTask["task"]; runs: Standa const currentMetadataJson = useRef(defaultMetadataJson); - const submitForm = useCallback( - (e: React.FormEvent) => { - submit( - { - triggerSource: "STANDARD", - payload: currentPayloadJson.current, - metadata: currentMetadataJson.current, - taskIdentifier: task.taskIdentifier, - environmentId: environment.id, - }, - { - action: "", - method: "post", - } - ); - e.preventDefault(); - }, - [currentPayloadJson, currentMetadataJson, task] + const [ttlValue, setTtlValue] = useState(lastRun?.ttlSeconds); + const [concurrencyKeyValue, setConcurrencyKeyValue] = useState( + lastRun?.concurrencyKey + ); + const [queueValue, setQueueValue] = useState(lastRun?.queue); + const [machineValue, setMachineValue] = useState(lastRun?.machinePreset); + const [maxAttemptsValue, setMaxAttemptsValue] = useState( + lastRun?.maxAttempts ); + const [maxDurationValue, setMaxDurationValue] = useState( + lastRun?.maxDurationInSeconds + ); + const [tagsValue, setTagsValue] = useState(lastRun?.runTags ?? []); + + const queueItems = queues.map((q) => ({ + value: q.type === "task" ? `task/${q.name}` : q.name, + label: q.name, + type: q.type, + paused: q.paused, + })); - const [form, { environmentId, payload }] = useForm({ + const fetcher = useFetcher(); + const [ + form, + { + environmentId, + payload, + metadata, + taskIdentifier, + delaySeconds, + ttlSeconds, + idempotencyKey, + idempotencyKeyTTLSeconds, + queue, + concurrencyKey, + maxAttempts, + maxDurationSeconds, + triggerSource, + tags, + version, + machine, + }, + ] = useForm({ id: "test-task", // TODO: type this lastSubmission: lastSubmission as any, + onSubmit(event, { formData }) { + event.preventDefault(); + + formData.set(payload.name, currentPayloadJson.current); + formData.set(metadata.name, currentMetadataJson.current); + + fetcher.submit(formData, { method: "POST" }); + }, onValidate({ formData }) { return parse(formData, { schema: TestTaskData }); }, }); return ( - submitForm(e)} - > - - - - - - { - replace({ tab: "payload" }); - }} - > - Payload - + + + + + + + + + {task.taskIdentifier} + + + + { + setPayload(run.payload); + run.seedMetadata && setMetadata(run.seedMetadata); + setTtlValue(run.ttlSeconds); + setConcurrencyKeyValue(run.concurrencyKey); + setMaxAttemptsValue(run.maxAttempts); + setMaxDurationValue(run.maxDurationInSeconds); + setTagsValue(run.runTags ?? []); + setQueueValue(run.queue); + setMachineValue(run.machinePreset); + }} + /> + + - { - replace({ tab: "metadata" }); - }} - > - Metadata - - + + + { - currentPayloadJson.current = v; - - //deselect the example if it's been edited - if (selectedCodeSampleId) { - if (v !== selectedCodeSamplePayload) { - setDefaultPayloadJson(v); - setSelectedCodeSampleId(undefined); - } - } - }} - height="100%" - autoFocus={!tab || tab === "payload"} - className={cn("h-full overflow-auto", tab === "metadata" && "hidden")} - /> - { - currentMetadataJson.current = v; - - //deselect the example if it's been edited - if (selectedCodeSampleId) { - if (v !== selectedCodeSampleMetadata) { - setDefaultMetadataJson(v); - setSelectedCodeSampleId(undefined); - } + if (!tab || tab === "payload") { + currentPayloadJson.current = v; + setDefaultPayloadJson(v); + } else { + currentMetadataJson.current = v; + setDefaultMetadataJson(v); } }} height="100%" - autoFocus={tab === "metadata"} - placeholder="" - className={cn("h-full overflow-auto", tab !== "metadata" && "hidden")} + autoFocus={true} + className={cn("h-full overflow-auto")} + additionalActions={ + + + { + replace({ tab: "payload" }); + }} + > + Payload + + { + replace({ tab: "metadata" }); + }} + > + Metadata + + + + } /> - - { - const run = runs.find((r) => r.id === id); - if (!run) return; - setPayload(run.payload); - run.seedMetadata && setMetadata(run.seedMetadata); - setSelectedCodeSampleId(id); - }} - /> + + + + + Delay + + {delaySeconds.error} + + + TTL + + The run will expire if it hasn't started within the TTL (time to live). + {ttlSeconds.error} + + + + Queue + + {allowArbitraryQueues ? ( + setQueueValue(e.target.value)} + /> + ) : ( + + {(matches) => + matches.map((queueItem) => ( + + ) : ( + + ) + } + > + + {queueItem.label} + {queueItem.paused && ( + + Paused + + )} + + + )) + } + + )} + {queue.error} + + + + Tags + + + Tags enable you to easily filter runs. + {tags.error} + + + + Max attempts + + + setMaxAttemptsValue(e.target.value ? parseInt(e.target.value) : undefined) + } + onKeyDown={(e) => { + // only allow entering integers > 1 + if (["-", "+", ".", "e", "E"].includes(e.key)) { + e.preventDefault(); + } + }} + onBlur={(e) => { + const value = parseInt(e.target.value); + if (value < 1 && e.target.value !== "") { + e.target.value = "1"; + } + }} + /> + {maxAttempts.error} + + + Max duration + + {maxDurationSeconds.error} + + + + Idempotency key + + + {idempotencyKey.error} + + Specify an idempotency key to ensure that a task is only triggered once with the + same key. + + + + Idempotency key TTL + + By default, idempotency keys expire after 30 days. + + {idempotencyKeyTTLSeconds.error} + + + + + Concurrency key + + setConcurrencyKeyValue(e.target.value)} + /> + + Concurrency keys enable you limit concurrency by creating a separate queue for + each value of the key. + + {concurrencyKey.error} + + + + Machine + + { + if (Array.isArray(e)) return; + setMachineValue(e); + }} + > + {machinePresets.map((machine) => ( + + {machine} + + ))} + + This lets you override the machine preset specified in the task. + {machine.error} + + + + Version + + + {versions.map((version, i) => ( + + {version} {i === 0 && "(latest)"} + + ))} + + {disableVersionSelection && ( + Only the latest version is available in the development environment. + )} + {version.error} + + {form.error} + + - - - This test will run in - - + + + + This test will run in + + + + + Run test + - - Run test - ); @@ -359,32 +653,54 @@ function ScheduledTaskForm({ task, runs, possibleTimezones, + queues, + versions, + disableVersionSelection, + allowArbitraryQueues, }: { - task: TestTask["task"]; + task: ScheduledTaskResult["task"]; runs: ScheduledRun[]; possibleTimezones: string[]; + queues: Required["queue"][]; + versions: string[]; + disableVersionSelection: boolean; + allowArbitraryQueues: boolean; }) { const environment = useEnvironment(); const lastSubmission = useActionData(); - const [selectedCodeSampleId, setSelectedCodeSampleId] = useState(runs.at(0)?.id); - const [timestampValue, setTimestampValue] = useState(); - const [lastTimestampValue, setLastTimestampValue] = useState(); - const [externalIdValue, setExternalIdValue] = useState(); - const [timezoneValue, setTimezoneValue] = useState("UTC"); - //set initial values - useEffect(() => { - const initialRun = runs.find((r) => r.id === selectedCodeSampleId); - if (!initialRun) { - setTimestampValue(new Date()); - return; - } + const lastRun = runs[0]; - setTimestampValue(initialRun.payload.timestamp); - setLastTimestampValue(initialRun.payload.lastTimestamp); - setExternalIdValue(initialRun.payload.externalId); - setTimezoneValue(initialRun.payload.timezone); - }, [selectedCodeSampleId]); + const [timestampValue, setTimestampValue] = useState( + lastRun.payload.timestamp ?? new Date() + ); + const [lastTimestampValue, setLastTimestampValue] = useState( + lastRun.payload.lastTimestamp + ); + const [externalIdValue, setExternalIdValue] = useState( + lastRun.payload.externalId + ); + const [timezoneValue, setTimezoneValue] = useState(lastRun.payload.timezone ?? "UTC"); + const [ttlValue, setTtlValue] = useState(lastRun?.ttlSeconds); + const [concurrencyKeyValue, setConcurrencyKeyValue] = useState( + lastRun?.concurrencyKey + ); + const [queueValue, setQueueValue] = useState(lastRun?.queue); + const [machineValue, setMachineValue] = useState(lastRun?.machinePreset); + const [maxAttemptsValue, setMaxAttemptsValue] = useState( + lastRun?.maxAttempts + ); + const [maxDurationValue, setMaxDurationValue] = useState( + lastRun?.maxDurationInSeconds + ); + const [tagsValue, setTagsValue] = useState(lastRun?.runTags ?? []); + + const queueItems = queues.map((q) => ({ + value: q.type === "task" ? `task/${q.name}` : q.name, + label: q.name, + type: q.type, + paused: q.paused, + })); const [ form, @@ -396,6 +712,16 @@ function ScheduledTaskForm({ taskIdentifier, environmentId, timezone, + ttlSeconds, + idempotencyKey, + idempotencyKeyTTLSeconds, + queue, + concurrencyKey, + maxAttempts, + maxDurationSeconds, + tags, + version, + machine, }, ] = useForm({ id: "test-task-scheduled", @@ -407,7 +733,7 @@ function ScheduledTaskForm({ }); return ( - + - - - - - - Timestamp UTC - - setTimestampValue(val)} - granularity="second" - showNowButton - variant="medium" - utc - /> - - This is the timestamp of the CRON, it will come through to your run in the - payload. - - {timestamp.error} - - - - Last timestamp UTC - - - setLastTimestampValue(val)} - granularity="second" - showNowButton - showClearButton - variant="medium" - utc - /> - - This is the timestamp of the previous run. You can use this in your code to find - new data since the previous run. This can be undefined if there hasn't been a - previous run. - - {lastTimestamp.error} - - - Timezone - { - if (Array.isArray(e)) return; - setTimezoneValue(e); - }} - items={possibleTimezones} - filter={{ keys: [(item) => item.replace(/\//g, " ").replace(/_/g, " ")] }} - dropdownIcon - variant="tertiary/medium" - > - {(matches) => } - - - The Timestamp and Last timestamp are in UTC so this just changes the timezone - string that comes through in the payload. - - {timezone.error} - - - - External ID - - setExternalIdValue(e.target.value)} - /> - - Optionally, you can specify your own IDs (like a user ID) and then use it inside - the run function of your task. This allows you to have per-user CRON tasks.{" "} - Read the docs. - - {externalId.error} - - - - - - - + + + + {task.taskIdentifier} + + + + { - const run = runs.find((r) => r.id === id); - if (!run) return; - setSelectedCodeSampleId(id); + onRunSelected={(run) => { setTimestampValue(run.payload.timestamp); setLastTimestampValue(run.payload.lastTimestamp); setExternalIdValue(run.payload.externalId); + setTimezoneValue(run.payload.timezone); + setTtlValue(run.ttlSeconds); + setConcurrencyKeyValue(run.concurrencyKey); + setMaxAttemptsValue(run.maxAttempts); + setMaxDurationValue(run.maxDurationInSeconds); + setTagsValue(run.runTags ?? []); + setQueueValue(run.queue); + setMachineValue(run.machinePreset); }} /> - - - - - - This test will run in - - - - Run test - + + + + + + Timestamp UTC + + + setTimestampValue(val)} + granularity="second" + showNowButton + variant="small" + utc + /> + + This is the timestamp of the CRON, it will come through to your run in the payload. + + {timestamp.error} + + + + Last timestamp UTC + + + setLastTimestampValue(val)} + granularity="second" + showNowButton + showClearButton + variant="small" + utc + /> + + This is the timestamp of the previous run. You can use this in your code to find new + data since the previous run. + + {lastTimestamp.error} + + + + Timezone + + { + if (Array.isArray(e)) return; + setTimezoneValue(e); + }} + items={possibleTimezones} + filter={{ keys: [(item) => item.replace(/\//g, " ").replace(/_/g, " ")] }} + dropdownIcon + variant="tertiary/small" + > + {(matches) => } + + + The Timestamp and Last timestamp are in UTC so this just changes the timezone string + that comes through in the payload. + + {timezone.error} + + + + External ID + + setExternalIdValue(e.target.value)} + variant="small" + /> + + Optionally, you can specify your own IDs (like a user ID) and then use it inside the + run function of your task.{" "} + Read the docs. + + {externalId.error} + + + + + TTL + + + The run will expire if it hasn't started within the TTL (time to live). + {ttlSeconds.error} + + + + Queue + + {allowArbitraryQueues ? ( + setQueueValue(e.target.value)} + /> + ) : ( + + {(matches) => + matches.map((queueItem) => ( + + ) : ( + + ) + } + > + + {queueItem.label} + {queueItem.paused && ( + + Paused + + )} + + + )) + } + + )} + {queue.error} + + + + Tags + + + Tags enable you to easily filter runs. + {tags.error} + + + + Max attempts + + + setMaxAttemptsValue(e.target.value ? parseInt(e.target.value) : undefined) + } + onKeyDown={(e) => { + // only allow entering integers > 1 + if (["-", "+", ".", "e", "E"].includes(e.key)) { + e.preventDefault(); + } + }} + onBlur={(e) => { + const value = parseInt(e.target.value); + if (value < 1 && e.target.value !== "") { + e.target.value = "1"; + } + }} + /> + {maxAttempts.error} + + + + Max duration + + + {maxDurationSeconds.error} + + + + Idempotency key + + + {idempotencyKey.error} + + Specify an idempotency key to ensure that a task is only triggered once with the same + key. + + + + + Idempotency key TTL + + + By default, idempotency keys expire after 30 days. + + {idempotencyKeyTTLSeconds.error} + + + + + Concurrency key + + setConcurrencyKeyValue(e.target.value)} + /> + + Concurrency keys enable you limit concurrency by creating a separate queue for each + value of the key. + + {concurrencyKey.error} + + + + Machine + + { + if (Array.isArray(e)) return; + setMachineValue(e); + }} + > + {machinePresets.map((machine) => ( + + {machine} + + ))} + + This lets you override the machine preset specified in the task. + {machine.error} + + + + Version + + + {versions.map((version, i) => ( + + {version} {i === 0 && "(latest)"} + + ))} + + {disableVersionSelection && ( + Only the latest version is available in the development environment. + )} + {version.error} + + + + + + + + This test will run in + + + + + Run test + + ); } -function RecentPayloads({ +function RecentRunsPopover({ runs, - selectedId, - onSelected, + onRunSelected, }: { - runs: { - id: string; - createdAt: Date; - number: number; - status: TaskRunStatus; - }[]; - selectedId?: string; - onSelected: (id: string) => void; + runs: T[]; + onRunSelected: (run: T) => void; }) { + const [isRecentRunsPopoverOpen, setIsRecentRunsPopoverOpen] = useState(false); + return ( - - - Recent payloads - - {runs.length === 0 ? ( - - Recent payloads will show here once you've completed a Run. - - ) : ( - - {runs.map((run) => ( - { - onSelected(run.id); - }} - className="flex items-center gap-2 px-2 py-2" - > - - - - - - - Run #{run.number} - + + + + Recent runs + + + + + + {runs.map((run) => ( + { + onRunSelected(run); + setIsRecentRunsPopoverOpen(false); + }} + className="flex w-full items-center gap-2 rounded-sm px-2 py-2 outline-none transition-colors focus-custom hover:bg-charcoal-900 " + > + + + + + + + Run {run.friendlyId.slice(-8)} + + + - - - ))} + + ))} + - )} - + + ); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test/route.tsx index 4d33289493..9bd9443e95 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test/route.tsx @@ -138,7 +138,7 @@ function TaskSelector({ ({ + id: queue.id, + name: queue.name, + type: queue.type, + paused: queue.paused, + })), + currentPage: result.pagination.currentPage, + hasMore: result.pagination.currentPage < result.pagination.totalPages, + hasFilters: result.hasFilters, + }; +} diff --git a/apps/webapp/app/v3/models/workerDeployment.server.ts b/apps/webapp/app/v3/models/workerDeployment.server.ts index cdec37d1b7..66e1024eb8 100644 --- a/apps/webapp/app/v3/models/workerDeployment.server.ts +++ b/apps/webapp/app/v3/models/workerDeployment.server.ts @@ -54,6 +54,7 @@ type WorkerDeploymentWithWorkerTasks = Prisma.WorkerDeploymentGetPayload<{ machineConfig: true; maxDurationInSeconds: true; queueConfig: true; + queueId: true; }; }; }; diff --git a/apps/webapp/app/v3/services/testTask.server.ts b/apps/webapp/app/v3/services/testTask.server.ts index 740f64156e..b6e6410743 100644 --- a/apps/webapp/app/v3/services/testTask.server.ts +++ b/apps/webapp/app/v3/services/testTask.server.ts @@ -1,6 +1,6 @@ import { stringifyIO } from "@trigger.dev/core/v3"; -import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; -import { TestTaskData } from "../testTask"; +import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { type TestTaskData } from "../testTask"; import { BaseService } from "./baseService.server"; import { TriggerTaskService } from "./triggerTask.server"; @@ -15,6 +15,19 @@ export class TestTaskService extends BaseService { options: { test: true, metadata: data.metadata, + delay: data.delaySeconds ? new Date(Date.now() + data.delaySeconds * 1000) : undefined, + ttl: data.ttlSeconds, + idempotencyKey: data.idempotencyKey, + idempotencyKeyTTL: data.idempotencyKeyTTLSeconds + ? `${data.idempotencyKeyTTLSeconds}s` + : undefined, + queue: data.queue ? { name: data.queue } : undefined, + concurrencyKey: data.concurrencyKey, + maxAttempts: data.maxAttempts, + maxDuration: data.maxDurationSeconds, + tags: data.tags, + machine: data.machine, + lockToVersion: data.version === "latest" ? undefined : data.version, }, }); @@ -37,7 +50,22 @@ export class TestTaskService extends BaseService { environment, { payload: payloadPacket.data, - options: { payloadType: payloadPacket.dataType, test: true }, + options: { + payloadType: payloadPacket.dataType, + test: true, + ttl: data.ttlSeconds, + idempotencyKey: data.idempotencyKey, + idempotencyKeyTTL: data.idempotencyKeyTTLSeconds + ? `${data.idempotencyKeyTTLSeconds}s` + : undefined, + queue: data.queue ? { name: data.queue } : undefined, + concurrencyKey: data.concurrencyKey, + maxAttempts: data.maxAttempts, + maxDuration: data.maxDurationSeconds, + tags: data.tags, + machine: data.machine, + lockToVersion: data.version === "latest" ? undefined : data.version, + }, }, { customIcon: "scheduled" } ); diff --git a/apps/webapp/app/v3/testTask.ts b/apps/webapp/app/v3/testTask.ts index 764f6b09f7..b48b1fb59c 100644 --- a/apps/webapp/app/v3/testTask.ts +++ b/apps/webapp/app/v3/testTask.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { MachinePresetName } from "@trigger.dev/core/v3/schemas"; export const TestTaskData = z .discriminatedUnion("triggerSource", [ @@ -56,6 +57,50 @@ export const TestTaskData = z z.object({ taskIdentifier: z.string(), environmentId: z.string(), + delaySeconds: z + .number() + .min(0) + .optional() + .transform((val) => (val === 0 ? undefined : val)), + ttlSeconds: z + .number() + .min(0) + .optional() + .transform((val) => (val === 0 ? undefined : val)), + idempotencyKey: z.string().optional(), + idempotencyKeyTTLSeconds: z + .number() + .min(0) + .optional() + .transform((val) => (val === 0 ? undefined : val)), + queue: z.string().optional(), + concurrencyKey: z.string().optional(), + maxAttempts: z.number().min(1).optional(), + machine: MachinePresetName.optional(), + maxDurationSeconds: z + .number() + .min(0) + .optional() + .transform((val) => (val === 0 ? undefined : val)), + tags: z + .string() + .optional() + .transform((val) => { + if (!val || val.trim() === "") { + return undefined; + } + return val + .split(",") + .map((tag) => tag.trim()) + .filter((tag) => tag.length > 0); + }) + .refine((tags) => !tags || tags.length <= 10, { + message: "Maximum 10 tags allowed", + }) + .refine((tags) => !tags || tags.every((tag) => tag.length <= 128), { + message: "Each tag must be at most 128 characters long", + }), + version: z.string().optional(), }) );