From 7c03409c82949078cfe8be526c89f1501913f02e Mon Sep 17 00:00:00 2001 From: myftija Date: Thu, 10 Jul 2025 11:42:13 +0200 Subject: [PATCH 01/13] Rearrange the layout of the replay modal --- .../components/runs/v3/ReplayRunDialog.tsx | 181 ++++++++++-------- 1 file changed, 106 insertions(+), 75 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx index 207217504d..ccbfe99395 100644 --- a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx +++ b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx @@ -1,6 +1,6 @@ import { DialogClose } from "@radix-ui/react-dialog"; import { Form, useNavigation, useSubmit } from "@remix-run/react"; -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { type UseDataFunctionReturn, useTypedFetcher } from "remix-typedjson"; import { JSONEditor } from "~/components/code/JSONEditor"; import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; @@ -12,6 +12,7 @@ import { Label } from "~/components/primitives/Label"; import { Paragraph } from "~/components/primitives/Paragraph"; import { Select, SelectItem } from "~/components/primitives/Select"; import { Spinner, SpinnerWhite } from "~/components/primitives/Spinner"; +import { TabButton, TabContainer } from "~/components/primitives/Tabs"; import { type loader } from "~/routes/resources.taskruns.$runParam.replay"; type ReplayRunDialogProps = { @@ -21,7 +22,7 @@ type ReplayRunDialogProps = { export function ReplayRunDialog({ runFriendlyId, failedRedirect }: ReplayRunDialogProps) { return ( - + ); @@ -36,7 +37,7 @@ function ReplayContent({ runFriendlyId, failedRedirect }: ReplayRunDialogProps) }, [runFriendlyId]); return ( - <> +
Replay this run {isLoading ? (
@@ -51,7 +52,7 @@ function ReplayContent({ runFriendlyId, failedRedirect }: ReplayRunDialogProps) ) : ( <>Failed to get run data )} - +
); } @@ -93,82 +94,112 @@ function ReplayForm({ [currentJson] ); + const [tab, setTab] = useState<"payload" | "metadata">("payload"); + return ( -
submitForm(e)} className="pt-2"> - {editablePayload ? ( - <> - - Replaying will create a new run using the same or modified payload, executing against - the latest version in your selected environment. - - Payload -
- { - currentJson.current = v; - }} - showClearButton={false} - showCopyButton={false} - height="100%" - min-height="100%" - max-height="100%" - /> -
- - ) : null} - - - - + submitForm(e)} + className="flex grow flex-col gap-3" + > -
+ + + Replaying will create a new run using the same or modified payload, executing against the + latest version in your selected environment. + +
+
+ { + currentJson.current = v; + }} + height="100%" + min-height="100%" + max-height="100%" + additionalActions={ + +
+ { + setTab("payload"); + }} + > + Payload + + { + setTab("metadata"); + }} + > + Metadata + +
+
+ } + /> +
+
+ +
- +
+ + + + + +
); From 49ee04951f0fdb8ade5015e1c54f213ccfb11256 Mon Sep 17 00:00:00 2001 From: myftija Date: Thu, 10 Jul 2025 17:51:25 +0200 Subject: [PATCH 02/13] Add run options to the replay modal --- .../components/runs/v3/ReplayRunDialog.tsx | 444 +++++++++++++++--- .../route.tsx | 2 +- .../resources.taskruns.$runParam.replay.ts | 101 +++- apps/webapp/app/v3/replayTask.ts | 13 + apps/webapp/app/v3/testTask.ts | 92 ++-- 5 files changed, 541 insertions(+), 111 deletions(-) create mode 100644 apps/webapp/app/v3/replayTask.ts diff --git a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx index ccbfe99395..7074adec93 100644 --- a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx +++ b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx @@ -1,19 +1,46 @@ +import { conform, useForm } from "@conform-to/react"; +import { parse } from "@conform-to/zod"; import { DialogClose } from "@radix-ui/react-dialog"; -import { Form, useNavigation, useSubmit } from "@remix-run/react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { + Form, + useActionData, + useFetcher, + useNavigation, + useParams, + useSubmit, +} from "@remix-run/react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type UseDataFunctionReturn, useTypedFetcher } from "remix-typedjson"; +import { TaskIcon } from "~/assets/icons/TaskIcon"; import { JSONEditor } from "~/components/code/JSONEditor"; import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; import { Button } from "~/components/primitives/Buttons"; import { DialogContent, DialogHeader } from "~/components/primitives/Dialog"; -import { Header3 } from "~/components/primitives/Headers"; +import { DurationPicker } from "~/components/primitives/DurationPicker"; +import { Fieldset } from "~/components/primitives/Fieldset"; +import { FormError } from "~/components/primitives/FormError"; +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 { Paragraph } from "~/components/primitives/Paragraph"; +import { type loader as queuesLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "~/components/primitives/Resizable"; import { Select, SelectItem } from "~/components/primitives/Select"; import { Spinner, SpinnerWhite } from "~/components/primitives/Spinner"; import { TabButton, TabContainer } from "~/components/primitives/Tabs"; +import { TextLink } from "~/components/primitives/TextLink"; import { type loader } from "~/routes/resources.taskruns.$runParam.replay"; +import { docsPath } from "~/utils/pathBuilder"; +import { ReplayTaskData } from "~/v3/replayTask"; +import { RectangleStackIcon } from "@heroicons/react/20/solid"; +import { Badge } from "~/components/primitives/Badge"; +import { RunTagInput } from "./RunTagInput"; +import { MachinePresetName } from "@trigger.dev/core/v3"; type ReplayRunDialogProps = { runFriendlyId: string; @@ -22,7 +49,10 @@ type ReplayRunDialogProps = { export function ReplayRunDialog({ runFriendlyId, failedRedirect }: ReplayRunDialogProps) { return ( - + ); @@ -31,23 +61,44 @@ export function ReplayRunDialog({ runFriendlyId, failedRedirect }: ReplayRunDial function ReplayContent({ runFriendlyId, failedRedirect }: ReplayRunDialogProps) { const fetcher = useTypedFetcher(); const isLoading = fetcher.state === "loading"; + const queueFetcher = useTypedFetcher(); useEffect(() => { fetcher.load(`/resources/taskruns/${runFriendlyId}/replay`); }, [runFriendlyId]); + const params = useParams(); + 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 customQueues = useMemo(() => { + return queueFetcher.data?.queues ?? []; + }, [queueFetcher.data?.queues]); + return ( -
- Replay this run +
+ Replay this run {isLoading ? (
) : fetcher.data ? ( ) : ( <>Failed to get run data @@ -56,22 +107,31 @@ function ReplayContent({ runFriendlyId, failedRedirect }: ReplayRunDialogProps) ); } +const startingJson = "{\n\n}"; +const machinePresets = Object.values(MachinePresetName.enum); + function ReplayForm({ - payload, - payloadType, - environment, - environments, failedRedirect, runFriendlyId, -}: UseDataFunctionReturn & { failedRedirect: string; runFriendlyId: string }) { + replayData, + customQueues, +}: { + failedRedirect: string; + runFriendlyId: string; + replayData: UseDataFunctionReturn; + customQueues: UseDataFunctionReturn["queues"]; +}) { const navigation = useNavigation(); const submit = useSubmit(); - const currentJson = useRef(payload); + const currentPayloadJson = useRef(replayData.payload ?? startingJson); + const currentMetadataJson = useRef(replayData.metadata ?? startingJson); const formAction = `/resources/taskruns/${runFriendlyId}/replay`; + const isSubmitting = navigation.formAction === formAction; const editablePayload = - payloadType === "application/json" || payloadType === "application/super+json"; + replayData.payloadType === "application/json" || + replayData.payloadType === "application/super+json"; const submitForm = useCallback( (e: React.FormEvent) => { @@ -82,7 +142,7 @@ function ReplayForm({ }; if (editablePayload) { - data.payload = currentJson.current; + data.payload = currentPayloadJson.current; } submit(data, { @@ -91,17 +151,67 @@ function ReplayForm({ }); e.preventDefault(); }, - [currentJson] + [currentPayloadJson] ); const [tab, setTab] = useState<"payload" | "metadata">("payload"); + const { defaultTaskQueue } = replayData; + + const queues = + defaultTaskQueue && !customQueues.some((q) => q.id === defaultTaskQueue.id) + ? [defaultTaskQueue, ...customQueues] + : customQueues; + + const queueItems = queues.map((q) => ({ + value: q.type === "task" ? `task/${q.name}` : q.name, + label: q.name, + type: q.type, + paused: q.paused, + })); + + const lastSubmission = useActionData(); + const [ + form, + { + environment, + payload, + metadata, + delaySeconds, + ttlSeconds, + idempotencyKey, + idempotencyKeyTTLSeconds, + queue, + concurrencyKey, + maxAttempts, + maxDurationSeconds, + tags, + version, + machine, + }, + ] = useForm({ + id: "replay-task", + lastSubmission: lastSubmission as any, + onSubmit(event, { formData }) { + event.preventDefault(); + if (editablePayload) { + formData.set(payload.name, currentPayloadJson.current); + } + formData.set(metadata.name, currentMetadataJson.current); + + submit(formData, { method: "POST", action: formAction }); + }, + onValidate({ formData }) { + return parse(formData, { schema: ReplayTaskData }); + }, + }); + return (
submitForm(e)} - className="flex grow flex-col gap-3" + className="flex flex-1 flex-col overflow-hidden px-3" > @@ -109,46 +219,262 @@ function ReplayForm({ Replaying will create a new run using the same or modified payload, executing against the latest version in your selected environment. -
-
- { - currentJson.current = v; - }} - height="100%" - min-height="100%" - max-height="100%" - additionalActions={ - -
- { - setTab("payload"); - }} - > - Payload - - { - setTab("metadata"); - }} + + +
+ { + currentPayloadJson.current = v; + }} + height="100%" + min-height="100%" + max-height="100%" + additionalActions={ + +
+ { + setTab("payload"); + }} + > + Payload + + { + setTab("metadata"); + }} + > + Metadata + +
+
+ } + /> +
+
+ + +
+
+ + Options enable you to control the execution behavior of your task.{" "} + Read the docs. + + + + + Delays run by a specific duration. + {delaySeconds.error} + + + + + Expires the run if it hasn't started within the TTL. + {ttlSeconds.error} + + + + {replayData.allowArbitraryQueues ? ( + + ) : ( + + )} + Assign run to a specific queue. + {queue.error} + + + + + Add tags to easily filter runs. + {tags.error} + + + + { + // 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"; + } + }} + /> + Retries failed runs up to the specified number of attempts. + {maxAttempts.error} + + + + + Overrides the maximum compute time limit for the run. + {maxDurationSeconds.error} + + + + + {idempotencyKey.error} + + Specify an idempotency key to ensure that a task is only triggered once with the + same key. + + + + + + Keys expire after 30 days by default. + + {idempotencyKeyTTLSeconds.error} + + + + + + + Limits concurrency by creating a separate queue for each value of the key. + + {concurrencyKey.error} + + + + + Overrides the machine preset. + {machine.error} + + + + + {replayData.disableVersionSelection ? ( + Only the latest version is available in the development environment. + ) : ( + Runs task on a specific version. + )} + {version.error} + + {form.error} +
+
+
+
@@ -161,8 +487,8 @@ function ReplayForm({ id="environment" name="environment" placeholder="Select an environment" - defaultValue={environment.id} - items={environments} + defaultValue={replayData.environment.id} + items={replayData.environments} dropdownIcon variant="tertiary/medium" className="w-fit pl-1" @@ -173,7 +499,7 @@ function ReplayForm({ ], }} text={(value) => { - const env = environments.find((env) => env.id === value)!; + const env = replayData.environments.find((env) => env.id === value)!; return (
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 d869f08dc5..a0c48ab952 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 @@ -357,7 +357,7 @@ function StandardTaskForm({ const currentPayloadJson = useRef(defaultPayloadJson); const [defaultMetadataJson, setDefaultMetadataJson] = useState( - lastRun?.seedMetadata ?? "{}" + lastRun?.seedMetadata ?? startingJson ); const setMetadata = useCallback((code: string) => { setDefaultMetadataJson(code); diff --git a/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts b/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts index 431cc9f34c..e8400c6c8f 100644 --- a/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts +++ b/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts @@ -11,6 +11,9 @@ import { requireUserId } from "~/services/session.server"; import { sortEnvironments } from "~/utils/environmentSort"; import { v3RunSpanPath } from "~/utils/pathBuilder"; import { ReplayTaskRunService } from "~/v3/services/replayTaskRun.server"; +import parseDuration from "parse-duration"; +import { findCurrentWorkerDeployment } from "~/v3/models/workerDeployment.server"; +import { queueTypeFromType } from "~/presenters/v3/QueueRetrievePresenter.server"; const ParamSchema = z.object({ runParam: z.string(), @@ -24,7 +27,18 @@ export async function loader({ request, params }: LoaderFunctionArgs) { select: { payload: true, payloadType: true, + seedMetadata: true, + seedMetadataType: true, runtimeEnvironmentId: true, + concurrencyKey: true, + maxAttempts: true, + maxDurationInSeconds: true, + machinePreset: true, + ttl: true, + idempotencyKey: true, + runTags: true, + queue: true, + taskIdentifier: true, project: { select: { environments: { @@ -71,9 +85,82 @@ export async function loader({ request, params }: LoaderFunctionArgs) { throw new Response("Environment not found", { status: 404 }); } + const task = + environment.type !== "DEVELOPMENT" + ? (await findCurrentWorkerDeployment({ environmentId: environment.id }))?.worker?.tasks.find( + (t) => t.slug === run.taskIdentifier + ) + : await $replica.backgroundWorkerTask.findFirst({ + select: { + queueId: true, + }, + where: { + slug: run.taskIdentifier, + runtimeEnvironmentId: environment.id, + }, + orderBy: { + createdAt: "desc", + }, + }); + + const taskQueue = task?.queueId + ? await $replica.taskQueue.findFirst({ + where: { + runtimeEnvironmentId: environment.id, + id: task.queueId, + }, + select: { + friendlyId: true, + name: true, + type: true, + paused: true, + }, + }) + : undefined; + + const backgroundWorkers = await $replica.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"; + return typedjson({ + concurrencyKey: run.concurrencyKey, + maxAttempts: run.maxAttempts, + maxDurationSeconds: run.maxDurationInSeconds, + machinePreset: run.machinePreset, + ttlSeconds: run.ttl ? parseDuration(run.ttl, "s") ?? undefined : undefined, + idempotencyKey: run.idempotencyKey, + runTags: run.runTags, payload: await prettyPrintPacket(run.payload, run.payloadType), payloadType: run.payloadType, + queue: run.queue, + metadata: run.seedMetadata + ? await prettyPrintPacket(run.seedMetadata, run.seedMetadataType) + : undefined, + defaultTaskQueue: taskQueue + ? { + id: taskQueue.friendlyId, + name: taskQueue.name.replace(/^task\//, ""), + type: queueTypeFromType(taskQueue.type), + paused: taskQueue.paused, + } + : undefined, + latestVersions, + disableVersionSelection, + allowArbitraryQueues, environment: { ...displayableEnvironment(environment, userId), branchName: environment.branchName ?? undefined, @@ -176,13 +263,13 @@ export const action: ActionFunction = async ({ request, params }) => { }, }); return redirectWithErrorMessage(submission.value.failedRedirect, request, error.message); - } else { - logger.error("Failed to replay run", { error }); - return redirectWithErrorMessage( - submission.value.failedRedirect, - request, - JSON.stringify(error) - ); } + + logger.error("Failed to replay run", { error }); + return redirectWithErrorMessage( + submission.value.failedRedirect, + request, + JSON.stringify(error) + ); } }; diff --git a/apps/webapp/app/v3/replayTask.ts b/apps/webapp/app/v3/replayTask.ts new file mode 100644 index 0000000000..12241640a3 --- /dev/null +++ b/apps/webapp/app/v3/replayTask.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; +import { RunOptionsData } from "./testTask"; + +export const ReplayTaskData = z + .object({ + environment: z.string().optional(), + payload: z.string().optional(), + metadata: z.string().optional(), + failedRedirect: z.string(), + }) + .and(RunOptionsData); + +export type ReplayTaskData = z.infer; diff --git a/apps/webapp/app/v3/testTask.ts b/apps/webapp/app/v3/testTask.ts index b48b1fb59c..b37abb99bb 100644 --- a/apps/webapp/app/v3/testTask.ts +++ b/apps/webapp/app/v3/testTask.ts @@ -1,6 +1,53 @@ import { z } from "zod"; import { MachinePresetName } from "@trigger.dev/core/v3/schemas"; +export const RunOptionsData = z.object({ + 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(), +}); + export const TestTaskData = z .discriminatedUnion("triggerSource", [ z.object({ @@ -53,54 +100,11 @@ export const TestTaskData = z externalId: z.preprocess((val) => (val === "" ? undefined : val), z.string().optional()), }), ]) + .and(RunOptionsData) .and( 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(), }) ); From cfd85567ed886e9ccdb9a9144496055a017cae72 Mon Sep 17 00:00:00 2001 From: myftija Date: Thu, 10 Jul 2025 19:12:08 +0200 Subject: [PATCH 03/13] Handle payload and metadata correctly in the shared json editor --- .../components/runs/v3/ReplayRunDialog.tsx | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx index 7074adec93..0fc265c74b 100644 --- a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx +++ b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx @@ -123,8 +123,23 @@ function ReplayForm({ }) { const navigation = useNavigation(); const submit = useSubmit(); + + const [defaultPayloadJson, setDefaultPayloadJson] = useState( + replayData.payload ?? startingJson + ); + const setPayload = useCallback((code: string) => { + setDefaultPayloadJson(code); + }, []); const currentPayloadJson = useRef(replayData.payload ?? startingJson); + + const [defaultMetadataJson, setDefaultMetadataJson] = useState( + replayData.metadata ?? startingJson + ); + const setMetadata = useCallback((code: string) => { + setDefaultMetadataJson(code); + }, []); const currentMetadataJson = useRef(replayData.metadata ?? startingJson); + const formAction = `/resources/taskruns/${runFriendlyId}/replay`; const isSubmitting = navigation.formAction === formAction; @@ -227,11 +242,17 @@ function ReplayForm({
{ - currentPayloadJson.current = v; + if (!tab || tab === "payload") { + currentPayloadJson.current = v; + setDefaultPayloadJson(v); + } else { + currentMetadataJson.current = v; + setDefaultMetadataJson(v); + } }} height="100%" min-height="100%" From c8958ac8749fba89db21d3ee62c4d96e1ab79520 Mon Sep 17 00:00:00 2001 From: myftija Date: Fri, 11 Jul 2025 09:51:45 +0200 Subject: [PATCH 04/13] Apply run options in replays --- .../components/runs/v3/ReplayRunDialog.tsx | 40 ++----- .../resources.taskruns.$runParam.replay.ts | 25 ++-- apps/webapp/app/v3/replayTask.ts | 42 ++++++- .../app/v3/services/replayTaskRun.server.ts | 107 +++++++++--------- apps/webapp/app/v3/testTask.ts | 2 + 5 files changed, 117 insertions(+), 99 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx index 0fc265c74b..a19501e888 100644 --- a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx +++ b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx @@ -36,7 +36,7 @@ import { TabButton, TabContainer } from "~/components/primitives/Tabs"; import { TextLink } from "~/components/primitives/TextLink"; import { type loader } from "~/routes/resources.taskruns.$runParam.replay"; import { docsPath } from "~/utils/pathBuilder"; -import { ReplayTaskData } from "~/v3/replayTask"; +import { ReplayRunData } from "~/v3/replayTask"; import { RectangleStackIcon } from "@heroicons/react/20/solid"; import { Badge } from "~/components/primitives/Badge"; import { RunTagInput } from "./RunTagInput"; @@ -148,27 +148,6 @@ function ReplayForm({ replayData.payloadType === "application/json" || replayData.payloadType === "application/super+json"; - const submitForm = useCallback( - (e: React.FormEvent) => { - const formData = new FormData(e.currentTarget); - const data: Record = { - environment: formData.get("environment") as string, - failedRedirect: formData.get("failedRedirect") as string, - }; - - if (editablePayload) { - data.payload = currentPayloadJson.current; - } - - submit(data, { - action: formAction, - method: "post", - }); - e.preventDefault(); - }, - [currentPayloadJson] - ); - const [tab, setTab] = useState<"payload" | "metadata">("payload"); const { defaultTaskQueue } = replayData; @@ -217,7 +196,7 @@ function ReplayForm({ submit(formData, { method: "POST", action: formAction }); }, onValidate({ formData }) { - return parse(formData, { schema: ReplayTaskData }); + return parse(formData, { schema: ReplayRunData }); }, }); @@ -225,8 +204,8 @@ function ReplayForm({ submitForm(e)} className="flex flex-1 flex-col overflow-hidden px-3" + {...form.props} > @@ -242,16 +221,16 @@ function ReplayForm({
{ - if (!tab || tab === "payload") { + if (tab === "payload") { currentPayloadJson.current = v; - setDefaultPayloadJson(v); + setPayload(v); } else { currentMetadataJson.current = v; - setDefaultMetadataJson(v); + setMetadata(v); } }} height="100%" @@ -261,7 +240,7 @@ function ReplayForm({
{ setTab("payload"); @@ -505,8 +484,7 @@ function ReplayForm({ - Replaying will create a new run using the same or modified payload, executing against the - latest version in your selected environment. + Replaying will create a new run in the selected environment. You can modify the payload, + metadata and run options. Date: Fri, 11 Jul 2025 10:17:56 +0200 Subject: [PATCH 07/13] Move machine and version fields to the top for visibility --- .../components/runs/v3/ReplayRunDialog.tsx | 108 +++++++++--------- 1 file changed, 54 insertions(+), 54 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx index 3d81340942..76a4d233d0 100644 --- a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx +++ b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx @@ -272,20 +272,50 @@ function ReplayForm({ Read the docs. - - - Delays run by a specific duration. - {delaySeconds.error} + + + Overrides the machine preset. + {machine.error} - - - Expires the run if it hasn't started within the TTL. - {ttlSeconds.error} + + + {replayData.disableVersionSelection ? ( + Only the latest version is available in the development environment. + ) : ( + Runs task on a specific version. + )} + {version.error} - - - Overrides the machine preset. - {machine.error} + + + Delays run by a specific duration. + {delaySeconds.error} - - - {replayData.disableVersionSelection ? ( - Only the latest version is available in the development environment. - ) : ( - Runs task on a specific version. - )} - {version.error} + + + Expires the run if it hasn't started within the TTL. + {ttlSeconds.error} {form.error} From e0bb5cbd19b980abd41ff0ca24be2592f702daf9 Mon Sep 17 00:00:00 2001 From: myftija Date: Fri, 11 Jul 2025 10:26:38 +0200 Subject: [PATCH 08/13] Use the same field ordering in the test page --- .../route.tsx | 242 +++++++++--------- 1 file changed, 121 insertions(+), 121 deletions(-) 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 ebc25e0d34..ebdd4f828d 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 @@ -447,7 +447,7 @@ function StandardTaskForm({ setConcurrencyKeyValue(template.concurrencyKey ?? ""); setMaxAttemptsValue(template.maxAttempts ?? undefined); setMaxDurationValue(template.maxDurationSeconds ?? 0); - setMachineValue(template.machinePreset ?? ""); + setMachineValue(template.machinePreset ?? undefined); setTagsValue(template.tags ?? []); setQueueValue(template.queue ?? undefined); }} @@ -527,21 +527,55 @@ function StandardTaskForm({ Read the docs. - - - Delays run by a specific duration. - {delaySeconds.error} + + + Overrides the machine preset. + {machine.error} - - - Expires the run if it hasn't started within the TTL. - {ttlSeconds.error} + + + {disableVersionSelection ? ( + Only the latest version is available in the development environment. + ) : ( + Runs task on a specific version. + )} + {version.error} - - - Overrides the machine preset. - {machine.error} + + + Delays run by a specific duration. + {delaySeconds.error} - - - {disableVersionSelection ? ( - Only the latest version is available in the development environment. - ) : ( - Runs task on a specific version. - )} - {version.error} + + + Expires the run if it hasn't started within the TTL. + {ttlSeconds.error} {form.error} @@ -912,7 +912,7 @@ function ScheduledTaskForm({ setConcurrencyKeyValue(template.concurrencyKey ?? ""); setMaxAttemptsValue(template.maxAttempts ?? undefined); setMaxDurationValue(template.maxDurationSeconds ?? 0); - setMachineValue(template.machinePreset ?? ""); + setMachineValue(template.machinePreset ?? undefined); setTagsValue(template.tags ?? []); setQueueValue(template.queue ?? undefined); @@ -936,7 +936,7 @@ function ScheduledTaskForm({ setMaxDurationValue(run.maxDurationInSeconds); setTagsValue(run.runTags ?? []); setQueueValue(run.queue); - setMachineValue(run.machinePreset); + setMachineValue(run.machinePreset ?? undefined); }} />
@@ -1041,17 +1041,55 @@ function ScheduledTaskForm({ Read the docs. - + + + + {disableVersionSelection ? ( + Only the latest version is available in the development environment. + ) : ( + Runs task on a specific version. + )} + {version.error} - - - Overrides the machine preset. - {machine.error} - - -
From 21dda1ac1f8e233269f30eac39f3de025be22bd4 Mon Sep 17 00:00:00 2001 From: myftija Date: Fri, 11 Jul 2025 12:18:31 +0200 Subject: [PATCH 09/13] Reload queues and versions on env override --- .../components/runs/v3/ReplayRunDialog.tsx | 73 ++++++++++++------- .../resources.taskruns.$runParam.replay.ts | 25 +++++-- 2 files changed, 68 insertions(+), 30 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx index 76a4d233d0..1523e558d6 100644 --- a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx +++ b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx @@ -1,14 +1,7 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; import { DialogClose } from "@radix-ui/react-dialog"; -import { - Form, - useActionData, - useFetcher, - useNavigation, - useParams, - useSubmit, -} from "@remix-run/react"; +import { Form, useActionData, useNavigation, useParams, useSubmit } from "@remix-run/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type UseDataFunctionReturn, useTypedFetcher } from "remix-typedjson"; import { TaskIcon } from "~/assets/icons/TaskIcon"; @@ -59,13 +52,22 @@ export function ReplayRunDialog({ runFriendlyId, failedRedirect }: ReplayRunDial } function ReplayContent({ runFriendlyId, failedRedirect }: ReplayRunDialogProps) { - const fetcher = useTypedFetcher(); - const isLoading = fetcher.state === "loading"; + const replayDataFetcher = useTypedFetcher(); + const isLoading = replayDataFetcher.state === "loading"; const queueFetcher = useTypedFetcher(); + const [environmentIdOverride, setEnvironmentIdOverride] = useState(undefined); + useEffect(() => { - fetcher.load(`/resources/taskruns/${runFriendlyId}/replay`); - }, [runFriendlyId]); + const searchParams = new URLSearchParams(); + if (environmentIdOverride) { + searchParams.set("environmentIdOverride", environmentIdOverride); + } + + replayDataFetcher.load( + `/resources/taskruns/${runFriendlyId}/replay?${searchParams.toString()}` + ); + }, [runFriendlyId, environmentIdOverride]); const params = useParams(); useEffect(() => { @@ -74,13 +76,22 @@ function ReplayContent({ runFriendlyId, failedRedirect }: ReplayRunDialogProps) searchParams.set("type", "custom"); searchParams.set("per_page", "100"); + let envSlug = params.envParam; + + if (environmentIdOverride) { + const environmentOverride = replayDataFetcher.data?.environments.find( + (env) => env.id === environmentIdOverride + ); + envSlug = environmentOverride?.slug ?? envSlug; + } + queueFetcher.load( - `/resources/orgs/${params.organizationSlug}/projects/${params.projectParam}/env/${ - params.envParam - }/queues?${searchParams.toString()}` + `/resources/orgs/${params.organizationSlug}/projects/${ + params.projectParam + }/env/${envSlug}/queues?${searchParams.toString()}` ); } - }, [params.organizationSlug, params.projectParam, params.envParam]); + }, [params.organizationSlug, params.projectParam, params.envParam, environmentIdOverride]); const customQueues = useMemo(() => { return queueFetcher.data?.queues ?? []; @@ -89,16 +100,18 @@ function ReplayContent({ runFriendlyId, failedRedirect }: ReplayRunDialogProps) return (
Replay this run - {isLoading ? ( -
+ {isLoading && !replayDataFetcher.data ? ( +
- ) : fetcher.data ? ( + ) : replayDataFetcher.data ? ( ) : ( <>Failed to get run data @@ -115,11 +128,15 @@ function ReplayForm({ runFriendlyId, replayData, customQueues, + environmentIdOverride, + setEnvironmentIdOverride, }: { failedRedirect: string; runFriendlyId: string; replayData: UseDataFunctionReturn; customQueues: UseDataFunctionReturn["queues"]; + environmentIdOverride: string | undefined; + setEnvironmentIdOverride: (environment: string) => void; }) { const navigation = useNavigation(); const submit = useSubmit(); @@ -304,11 +321,15 @@ function ReplayForm({ dropdownIcon disabled={replayData.disableVersionSelection} > - {replayData.latestVersions.map((version, i) => ( - - {version} {i === 0 && "(latest)"} - - ))} + {replayData.latestVersions.length === 0 ? ( + No versions available + ) : ( + replayData.latestVersions.map((version, i) => ( + + {version} {i === 0 && "(latest)"} + + )) + )} {replayData.disableVersionSelection ? ( Only the latest version is available in the development environment. @@ -489,6 +510,8 @@ function ReplayForm({ defaultValue={replayData.environment.id} items={replayData.environments} dropdownIcon + value={environmentIdOverride} + setValue={setEnvironmentIdOverride} variant="tertiary/medium" className="w-fit pl-1" filter={{ diff --git a/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts b/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts index c2472c5d39..0c3eefefc5 100644 --- a/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts +++ b/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts @@ -20,9 +20,16 @@ const ParamSchema = z.object({ runParam: z.string(), }); +const QuerySchema = z.object({ + environmentIdOverride: z.string().optional(), +}); + export async function loader({ request, params }: LoaderFunctionArgs) { const userId = await requireUserId(request); const { runParam } = ParamSchema.parse(params); + const { environmentIdOverride } = QuerySchema.parse( + Object.fromEntries(new URL(request.url).searchParams) + ); const run = await $replica.taskRun.findFirst({ select: { @@ -81,16 +88,24 @@ export async function loader({ request, params }: LoaderFunctionArgs) { throw new Response("Not Found", { status: 404 }); } - const environment = run.project.environments.find((env) => env.id === run.runtimeEnvironmentId); + const runEnvironment = run.project.environments.find( + (env) => env.id === run.runtimeEnvironmentId + ); + const environmentOverride = run.project.environments.find( + (env) => env.id === environmentIdOverride + ); + const environment = environmentOverride ?? runEnvironment; if (!environment) { throw new Response("Environment not found", { status: 404 }); } const task = environment.type !== "DEVELOPMENT" - ? (await findCurrentWorkerDeployment({ environmentId: environment.id }))?.worker?.tasks.find( - (t) => t.slug === run.taskIdentifier - ) + ? ( + await findCurrentWorkerDeployment({ + environmentId: environment.id, + }) + )?.worker?.tasks.find((t) => t.slug === run.taskIdentifier) : await $replica.backgroundWorkerTask.findFirst({ select: { queueId: true, @@ -135,7 +150,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const latestVersions = backgroundWorkers.map((v) => v.version); const disableVersionSelection = environment.type === "DEVELOPMENT"; - const allowArbitraryQueues = backgroundWorkers[0]?.engine === "V1"; + const allowArbitraryQueues = backgroundWorkers.at(0)?.engine === "V1"; return typedjson({ concurrencyKey: run.concurrencyKey, From 92b6db32170836ac72d2b9487f5b4ce118940f9d Mon Sep 17 00:00:00 2001 From: myftija Date: Fri, 11 Jul 2025 13:12:15 +0200 Subject: [PATCH 10/13] Adapt json editor to fill full height --- apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx index 1523e558d6..fcea30f638 100644 --- a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx +++ b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx @@ -237,6 +237,7 @@ function ReplayForm({
Date: Fri, 11 Jul 2025 13:49:21 +0200 Subject: [PATCH 11/13] Clean up a few excessive ternaries --- .../resources.taskruns.$runParam.replay.ts | 136 ++++++++++-------- .../app/v3/services/replayTaskRun.server.ts | 54 ++++--- 2 files changed, 102 insertions(+), 88 deletions(-) diff --git a/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts b/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts index 0c3eefefc5..0e87a3d1bd 100644 --- a/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts +++ b/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts @@ -1,6 +1,6 @@ import { parse } from "@conform-to/zod"; import { type ActionFunction, json, type LoaderFunctionArgs } from "@remix-run/node"; -import { prettyPrintPacket } from "@trigger.dev/core/v3"; +import { type EnvironmentType, prettyPrintPacket } from "@trigger.dev/core/v3"; import { typedjson } from "remix-typedjson"; import { z } from "zod"; import { $replica, prisma } from "~/db.server"; @@ -99,54 +99,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) { throw new Response("Environment not found", { status: 404 }); } - const task = - environment.type !== "DEVELOPMENT" - ? ( - await findCurrentWorkerDeployment({ - environmentId: environment.id, - }) - )?.worker?.tasks.find((t) => t.slug === run.taskIdentifier) - : await $replica.backgroundWorkerTask.findFirst({ - select: { - queueId: true, - }, - where: { - slug: run.taskIdentifier, - runtimeEnvironmentId: environment.id, - }, - orderBy: { - createdAt: "desc", - }, - }); - - const taskQueue = task?.queueId - ? await $replica.taskQueue.findFirst({ - where: { - runtimeEnvironmentId: environment.id, - id: task.queueId, - }, - select: { - friendlyId: true, - name: true, - type: true, - paused: true, - }, - }) - : undefined; - - const backgroundWorkers = await $replica.backgroundWorker.findMany({ - where: { - runtimeEnvironmentId: environment.id, - }, - select: { - version: true, - engine: true, - }, - orderBy: { - createdAt: "desc", - }, - take: 20, // last 20 versions should suffice - }); + const [taskQueue, backgroundWorkers] = await Promise.all([ + findTaskQueue(environment, run.taskIdentifier), + listLatestBackgroundWorkers(environment), + ]); const latestVersions = backgroundWorkers.map((v) => v.version); const disableVersionSelection = environment.type === "DEVELOPMENT"; @@ -182,16 +138,13 @@ export async function loader({ request, params }: LoaderFunctionArgs) { branchName: environment.branchName ?? undefined, }, environments: sortEnvironments( - run.project.environments.map((environment) => { - return { - ...displayableEnvironment(environment, userId), - branchName: environment.branchName ?? undefined, - }; - }) - ).filter((env) => { - if (env.type === "PREVIEW" && !env.branchName) return false; - return true; - }), + run.project.environments + .filter((env) => env.type !== "PREVIEW" || env.branchName) + .map((env) => ({ + ...displayableEnvironment(env, userId), + branchName: env.branchName ?? undefined, + })) + ), }); } @@ -293,3 +246,68 @@ export const action: ActionFunction = async ({ request, params }) => { ); } }; + +async function findTask( + environment: { type: EnvironmentType; id: string }, + taskIdentifier: string +) { + if (environment.type === "DEVELOPMENT") { + return $replica.backgroundWorkerTask.findFirst({ + select: { + queueId: true, + }, + where: { + slug: taskIdentifier, + runtimeEnvironmentId: environment.id, + }, + orderBy: { + createdAt: "desc", + }, + }); + } + + const currentDeployment = await findCurrentWorkerDeployment({ + environmentId: environment.id, + }); + return currentDeployment?.worker?.tasks.find((t) => t.slug === taskIdentifier); +} + +async function findTaskQueue( + environment: { type: EnvironmentType; id: string }, + taskIdentifier: string +) { + const task = await findTask(environment, taskIdentifier); + + if (!task?.queueId) { + return undefined; + } + + return $replica.taskQueue.findFirst({ + where: { + runtimeEnvironmentId: environment.id, + id: task.queueId, + }, + select: { + friendlyId: true, + name: true, + type: true, + paused: true, + }, + }); +} + +function listLatestBackgroundWorkers(environment: { id: string }, limit = 20) { + return $replica.backgroundWorker.findMany({ + where: { + runtimeEnvironmentId: environment.id, + }, + select: { + version: true, + engine: true, + }, + orderBy: { + createdAt: "desc", + }, + take: limit, + }); +} diff --git a/apps/webapp/app/v3/services/replayTaskRun.server.ts b/apps/webapp/app/v3/services/replayTaskRun.server.ts index c46c8d7914..2c45fa2b03 100644 --- a/apps/webapp/app/v3/services/replayTaskRun.server.ts +++ b/apps/webapp/app/v3/services/replayTaskRun.server.ts @@ -35,37 +35,11 @@ export class ReplayTaskRunService extends BaseService { taskRunFriendlyId: existingTaskRun.friendlyId, }); - const getExistingPayload = async () => { - const existingPayloadPacket = await conditionallyImportPacket({ - data: existingTaskRun.payload, - dataType: existingTaskRun.payloadType, - }); - - return existingPayloadPacket.dataType === "application/json" - ? await parsePacket(existingPayloadPacket) - : existingPayloadPacket.data; - }; - - const payload = overrideOptions.payload ?? (await getExistingPayload()); - const metadata = - overrideOptions.metadata ?? - (existingTaskRun.seedMetadata - ? await parsePacket({ - data: existingTaskRun.seedMetadata, - dataType: existingTaskRun.seedMetadataType, - }) - : undefined); + const payload = overrideOptions.payload ?? (await this.getExistingPayload(existingTaskRun)); + const metadata = overrideOptions.metadata ?? (await this.getExistingMetadata(existingTaskRun)); + const tags = overrideOptions.tags ?? existingTaskRun.runTags; try { - const tags = - overrideOptions.tags ?? - ( - await getTagsForRunId({ - friendlyId: existingTaskRun.friendlyId, - environmentId: authenticatedEnvironment.id, - }) - )?.map((t) => t.name); - const taskQueue = await this._prisma.taskQueue.findFirst({ where: { runtimeEnvironmentId: authenticatedEnvironment.id, @@ -130,4 +104,26 @@ export class ReplayTaskRunService extends BaseService { return; } } + + private async getExistingPayload(existingTaskRun: TaskRun) { + const existingPayloadPacket = await conditionallyImportPacket({ + data: existingTaskRun.payload, + dataType: existingTaskRun.payloadType, + }); + + return existingPayloadPacket.dataType === "application/json" + ? await parsePacket(existingPayloadPacket) + : existingPayloadPacket.data; + } + + private async getExistingMetadata(existingTaskRun: TaskRun) { + if (!existingTaskRun.seedMetadata) { + return undefined; + } + + return parsePacket({ + data: existingTaskRun.seedMetadata, + dataType: existingTaskRun.seedMetadataType, + }); + } } From d70a429848993e304d76cb9e73f3ed4b4fa0a0e7 Mon Sep 17 00:00:00 2001 From: myftija Date: Fri, 11 Jul 2025 13:53:12 +0200 Subject: [PATCH 12/13] Avoid ui jump on env selection --- apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx index fcea30f638..e2e68a9038 100644 --- a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx +++ b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx @@ -503,7 +503,7 @@ function ReplayForm({
- +