feat: enable overriding run options when replaying (#2260)

* Rearrange the layout of the replay modal

* Add run options to the replay modal

* Handle payload and metadata correctly in the shared json editor

* Apply run options in replays

* Fix clear button issue in json editor

Memoization of the clear function caused some problems. Removing it should not cause performance issues.

* Update replay modal hint

* Move machine and version fields to the top for visibility

* Use the same field ordering in the test page

* Reload queues and versions on env override

* Adapt json editor to fill full height

* Clean up a few excessive ternaries

* Avoid ui jump on env selection

* Switch to sexy scrollbars for scheduled tasks in the test page
This commit is contained in:
Saadi Myftija
2025-07-11 16:54:20 +02:00
committed by GitHub
parent 4c635f72a2
commit 87c21ab50a
7 changed files with 913 additions and 360 deletions
@@ -125,13 +125,13 @@ export function JSONEditor(opts: JSONEditorProps) {
}
}, [defaultValue, view]);
const clear = useCallback(() => {
const clear = () => {
if (view === undefined) return;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: undefined },
});
onChange?.("");
}, [view]);
};
const copy = useCallback(() => {
if (view === undefined) return;
@@ -1,18 +1,39 @@
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 } from "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";
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 { ReplayRunData } 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;
@@ -21,154 +42,513 @@ type ReplayRunDialogProps = {
export function ReplayRunDialog({ runFriendlyId, failedRedirect }: ReplayRunDialogProps) {
return (
<DialogContent key={`replay`} className="md:max-w-xl">
<DialogContent
key={`replay`}
className="flex h-[85vh] max-h-[85vh] flex-col overflow-hidden px-0 md:max-w-3xl lg:max-w-5xl"
>
<ReplayContent runFriendlyId={runFriendlyId} failedRedirect={failedRedirect} />
</DialogContent>
);
}
function ReplayContent({ runFriendlyId, failedRedirect }: ReplayRunDialogProps) {
const fetcher = useTypedFetcher<typeof loader>();
const isLoading = fetcher.state === "loading";
const replayDataFetcher = useTypedFetcher<typeof loader>();
const isLoading = replayDataFetcher.state === "loading";
const queueFetcher = useTypedFetcher<typeof queuesLoader>();
const [environmentIdOverride, setEnvironmentIdOverride] = useState<string | undefined>(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(() => {
if (params.organizationSlug && params.projectParam && params.envParam) {
const searchParams = new URLSearchParams();
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/${envSlug}/queues?${searchParams.toString()}`
);
}
}, [params.organizationSlug, params.projectParam, params.envParam, environmentIdOverride]);
const customQueues = useMemo(() => {
return queueFetcher.data?.queues ?? [];
}, [queueFetcher.data?.queues]);
return (
<>
<DialogHeader>Replay this run</DialogHeader>
{isLoading ? (
<div className="grid place-items-center p-6">
<div className="flex flex-1 flex-col overflow-hidden">
<DialogHeader className="px-3">Replay this run</DialogHeader>
{isLoading && !replayDataFetcher.data ? (
<div className="flex h-full items-center justify-center p-6">
<Spinner />
</div>
) : fetcher.data ? (
) : replayDataFetcher.data ? (
<ReplayForm
{...fetcher.data}
replayData={replayDataFetcher.data}
failedRedirect={failedRedirect}
runFriendlyId={runFriendlyId}
customQueues={customQueues}
environmentIdOverride={environmentIdOverride}
setEnvironmentIdOverride={setEnvironmentIdOverride}
/>
) : (
<>Failed to get run data</>
)}
</>
</div>
);
}
const startingJson = "{\n\n}";
const machinePresets = Object.values(MachinePresetName.enum);
function ReplayForm({
payload,
payloadType,
environment,
environments,
failedRedirect,
runFriendlyId,
}: UseDataFunctionReturn<typeof loader> & { failedRedirect: string; runFriendlyId: string }) {
replayData,
customQueues,
environmentIdOverride,
setEnvironmentIdOverride,
}: {
failedRedirect: string;
runFriendlyId: string;
replayData: UseDataFunctionReturn<typeof loader>;
customQueues: UseDataFunctionReturn<typeof queuesLoader>["queues"];
environmentIdOverride: string | undefined;
setEnvironmentIdOverride: (environment: string) => void;
}) {
const navigation = useNavigation();
const submit = useSubmit();
const currentJson = useRef<string>(payload);
const [defaultPayloadJson, setDefaultPayloadJson] = useState<string>(
replayData.payload ?? startingJson
);
const setPayload = useCallback((code: string) => {
setDefaultPayloadJson(code);
}, []);
const currentPayloadJson = useRef<string>(replayData.payload ?? startingJson);
const [defaultMetadataJson, setDefaultMetadataJson] = useState<string>(
replayData.metadata ?? startingJson
);
const setMetadata = useCallback((code: string) => {
setDefaultMetadataJson(code);
}, []);
const currentMetadataJson = useRef<string>(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<HTMLFormElement>) => {
const formData = new FormData(e.currentTarget);
const data: Record<string, string> = {
environment: formData.get("environment") as string,
failedRedirect: formData.get("failedRedirect") as string,
};
const [tab, setTab] = useState<"payload" | "metadata">("payload");
if (editablePayload) {
data.payload = currentJson.current;
}
const { defaultTaskQueue } = replayData;
submit(data, {
action: formAction,
method: "post",
});
e.preventDefault();
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,
},
[currentJson]
);
] = 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: ReplayRunData });
},
});
return (
<Form action={formAction} method="post" onSubmit={(e) => submitForm(e)} className="pt-2">
{editablePayload ? (
<>
<Paragraph className="mb-3">
Replaying will create a new run using the same or modified payload, executing against
the latest version in your selected environment.
</Paragraph>
<Header3 spacing>Payload</Header3>
<div className="mb-3 max-h-[70vh] min-h-40 overflow-y-auto rounded-sm border border-grid-dimmed bg-charcoal-900 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<Form
action={formAction}
method="post"
className="flex flex-1 flex-col overflow-hidden px-3"
{...form.props}
>
<input type="hidden" name="failedRedirect" value={failedRedirect} />
<Paragraph className="pt-6">
Replaying will create a new run in the selected environment. You can modify the payload,
metadata and run options.
</Paragraph>
<ResizablePanelGroup
orientation="horizontal"
className="-mx-3 mt-3 w-auto flex-1 border-b border-t border-grid-dimmed"
>
<ResizablePanel id="payload" min="300px">
<div className="rounded-smbg-charcoal-900 mb-3 h-full min-h-40 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<JSONEditor
className="h-full"
autoFocus
defaultValue={currentJson.current}
defaultValue={tab === "payload" ? defaultPayloadJson : defaultMetadataJson}
readOnly={false}
basicSetup
onChange={(v) => {
currentJson.current = v;
if (tab === "payload") {
currentPayloadJson.current = v;
setPayload(v);
} else {
currentMetadataJson.current = v;
setMetadata(v);
}
}}
showClearButton={false}
showCopyButton={false}
height="100%"
min-height="100%"
max-height="100%"
additionalActions={
<TabContainer className="flex grow items-baseline justify-between self-end border-none">
<div className="flex gap-5">
<TabButton
isActive={tab === "payload"}
layoutId="replay-editor"
onClick={() => {
setTab("payload");
}}
>
Payload
</TabButton>
<TabButton
isActive={tab === "metadata"}
layoutId="replay-editor"
onClick={() => {
setTab("metadata");
}}
>
Metadata
</TabButton>
</div>
</TabContainer>
}
/>
</div>
</>
) : null}
<InputGroup>
<Label>Environment</Label>
<Select
id="environment"
name="environment"
placeholder="Select an environment"
defaultValue={environment.id}
items={environments}
dropdownIcon
variant="tertiary/medium"
className="w-fit pl-1"
filter={{
keys: [
(item) => item.type.replace(/\//g, " ").replace(/_/g, " "),
(item) => item.branchName?.replace(/\//g, " ").replace(/_/g, " ") ?? "",
],
}}
text={(value) => {
const env = environments.find((env) => env.id === value)!;
return (
<div className="flex items-center pl-1 pr-2">
<EnvironmentCombo environment={env} />
</div>
);
}}
>
{(matches) =>
matches.map((env) => (
<SelectItem key={env.id} value={env.id}>
<EnvironmentCombo environment={env} />
</SelectItem>
))
}
</Select>
</InputGroup>
<input type="hidden" name="failedRedirect" value={failedRedirect} />
<div className="mt-3 flex items-center justify-between gap-2 border-t border-grid-dimmed pt-3.5">
</ResizablePanel>
<ResizableHandle />
<ResizablePanel id="test-task-options" min="300px" default="300px" max="360px">
<div className="h-full overflow-y-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<Fieldset className="px-3 py-3">
<Hint>
Options enable you to control the execution behavior of your task.{" "}
<TextLink to={docsPath("triggering#options")}>Read the docs.</TextLink>
</Hint>
<InputGroup>
<Label htmlFor={machine.id} variant="small">
Machine
</Label>
<Select
{...conform.select(machine)}
variant="tertiary/small"
placeholder="Select machine type"
dropdownIcon
items={machinePresets}
defaultValue={replayData.machinePreset ?? undefined}
>
{machinePresets.map((machine) => (
<SelectItem key={machine} value={machine}>
{machine}
</SelectItem>
))}
</Select>
<Hint>Overrides the machine preset.</Hint>
<FormError id={machine.errorId}>{machine.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={version.id} variant="small">
Version
</Label>
<Select
{...conform.select(version)}
defaultValue="latest"
variant="tertiary/small"
placeholder="Select version"
dropdownIcon
disabled={replayData.disableVersionSelection}
>
{replayData.latestVersions.length === 0 ? (
<SelectItem disabled>No versions available</SelectItem>
) : (
replayData.latestVersions.map((version, i) => (
<SelectItem key={version} value={i === 0 ? "latest" : version}>
{version} {i === 0 && "(latest)"}
</SelectItem>
))
)}
</Select>
{replayData.disableVersionSelection ? (
<Hint>Only the latest version is available in the development environment.</Hint>
) : (
<Hint>Runs task on a specific version.</Hint>
)}
<FormError id={version.errorId}>{version.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={queue.id} variant="small">
Queue
</Label>
{replayData.allowArbitraryQueues ? (
<Input
{...conform.input(queue, { type: "text" })}
variant="small"
defaultValue={replayData.queue}
/>
) : (
<Select
name={queue.name}
id={queue.id}
placeholder="Select queue"
heading="Filter queues"
variant="tertiary/small"
dropdownIcon
items={queueItems}
filter={{ keys: ["label"] }}
defaultValue={replayData.queue}
>
{(matches) =>
matches.map((queueItem) => (
<SelectItem
key={queueItem.value}
value={queueItem.value}
className="max-w-[var(--popover-anchor-width)]"
icon={
queueItem.type === "task" ? (
<TaskIcon className="size-4 shrink-0 text-blue-500" />
) : (
<RectangleStackIcon className="size-4 shrink-0 text-purple-500" />
)
}
>
<div className="flex w-full min-w-0 items-center justify-between">
<span className="truncate">{queueItem.label}</span>
{queueItem.paused && (
<Badge variant="extra-small" className="ml-1 text-warning">
Paused
</Badge>
)}
</div>
</SelectItem>
))
}
</Select>
)}
<Hint>Assign run to a specific queue.</Hint>
<FormError id={queue.errorId}>{queue.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={tags.id} variant="small">
Tags
</Label>
<RunTagInput
name={tags.name}
id={tags.id}
variant="small"
defaultTags={replayData.runTags}
/>
<Hint>Add tags to easily filter runs.</Hint>
<FormError id={tags.errorId}>{tags.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={maxAttempts.id} variant="small">
Max attempts
</Label>
<Input
{...conform.input(maxAttempts, { type: "number" })}
className="[&::-webkit-inner-spin-button]:appearance-none"
variant="small"
min={1}
defaultValue={replayData.maxAttempts ?? 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";
}
}}
/>
<Hint>Retries failed runs up to the specified number of attempts.</Hint>
<FormError id={maxAttempts.errorId}>{maxAttempts.error}</FormError>
</InputGroup>
<InputGroup>
<Label variant="small">Max duration</Label>
<DurationPicker
name={maxDurationSeconds.name}
id={maxDurationSeconds.id}
defaultValueSeconds={replayData.maxDurationSeconds ?? undefined}
/>
<Hint>Overrides the maximum compute time limit for the run.</Hint>
<FormError id={maxDurationSeconds.errorId}>{maxDurationSeconds.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={idempotencyKey.id} variant="small">
Idempotency key
</Label>
<Input {...conform.input(idempotencyKey, { type: "text" })} variant="small" />
<FormError id={idempotencyKey.errorId}>{idempotencyKey.error}</FormError>
<Hint>
Specify an idempotency key to ensure that a task is only triggered once with the
same key.
</Hint>
</InputGroup>
<InputGroup>
<Label variant="small">Idempotency key TTL</Label>
<DurationPicker
name={idempotencyKeyTTLSeconds.name}
id={idempotencyKeyTTLSeconds.id}
/>
<Hint>Keys expire after 30 days by default.</Hint>
<FormError id={idempotencyKeyTTLSeconds.errorId}>
{idempotencyKeyTTLSeconds.error}
</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={concurrencyKey.id} variant="small">
Concurrency key
</Label>
<Input
{...conform.input(concurrencyKey, { type: "text" })}
variant="small"
defaultValue={replayData.concurrencyKey ?? undefined}
/>
<Hint>
Limits concurrency by creating a separate queue for each value of the key.
</Hint>
<FormError id={concurrencyKey.errorId}>{concurrencyKey.error}</FormError>
</InputGroup>
<InputGroup>
<Label variant="small">Delay</Label>
<DurationPicker name={delaySeconds.name} id={delaySeconds.id} />
<Hint>Delays run by a specific duration.</Hint>
<FormError id={delaySeconds.errorId}>{delaySeconds.error}</FormError>
</InputGroup>
<InputGroup>
<Label variant="small">TTL</Label>
<DurationPicker
name={ttlSeconds.name}
id={ttlSeconds.id}
defaultValueSeconds={replayData.ttlSeconds}
/>
<Hint>Expires the run if it hasn't started within the TTL.</Hint>
<FormError id={ttlSeconds.errorId}>{ttlSeconds.error}</FormError>
</InputGroup>
<FormError>{form.error}</FormError>
</Fieldset>
</div>
</ResizablePanel>
</ResizablePanelGroup>
<div className="flex items-center justify-between gap-2 border-t border-grid-dimmed pt-3.5">
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
<Button
type="submit"
variant="primary/medium"
LeadingIcon={isSubmitting ? SpinnerWhite : undefined}
disabled={isSubmitting}
shortcut={{ modifiers: ["mod"], key: "enter", enabledOnInputElements: true }}
>
{isSubmitting ? "Replaying..." : "Replay run"}
</Button>
<div className="flex items-center gap-3">
<InputGroup className="flex flex-row items-center gap-3">
<Label>Replay this run in</Label>
<Select
{...conform.select(environment)}
placeholder="Select an environment"
defaultValue={replayData.environment.id}
items={replayData.environments}
dropdownIcon
value={environmentIdOverride}
setValue={setEnvironmentIdOverride}
variant="tertiary/medium"
className="min-w-44"
filter={{
keys: [
(item) => item.type.replace(/\//g, " ").replace(/_/g, " "),
(item) => item.branchName?.replace(/\//g, " ").replace(/_/g, " ") ?? "",
],
}}
text={(value) => {
const env = replayData.environments.find((env) => env.id === value)!;
return (
<div className="flex items-center pl-1 pr-2">
<EnvironmentCombo environment={env} />
</div>
);
}}
>
{(matches) =>
matches.map((env) => (
<SelectItem key={env.id} value={env.id}>
<EnvironmentCombo environment={env} />
</SelectItem>
))
}
</Select>
</InputGroup>
<Button
type="submit"
variant="primary/medium"
LeadingIcon={isSubmitting ? SpinnerWhite : undefined}
disabled={isSubmitting}
shortcut={{ modifiers: ["mod"], key: "enter", enabledOnInputElements: true }}
>
{isSubmitting ? "Replaying..." : "Replay run"}
</Button>
</div>
</div>
</Form>
);
@@ -357,7 +357,7 @@ function StandardTaskForm({
const currentPayloadJson = useRef<string>(defaultPayloadJson);
const [defaultMetadataJson, setDefaultMetadataJson] = useState<string>(
lastRun?.seedMetadata ?? "{}"
lastRun?.seedMetadata ?? startingJson
);
const setMetadata = useCallback((code: string) => {
setDefaultMetadataJson(code);
@@ -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);
}}
@@ -481,10 +481,10 @@ function StandardTaskForm({
onChange={(v) => {
if (!tab || tab === "payload") {
currentPayloadJson.current = v;
setDefaultPayloadJson(v);
setPayload(v);
} else {
currentMetadataJson.current = v;
setDefaultMetadataJson(v);
setMetadata(v);
}
}}
height="100%"
@@ -527,21 +527,55 @@ function StandardTaskForm({
<TextLink to={docsPath("triggering#options")}>Read the docs.</TextLink>
</Hint>
<InputGroup>
<Label variant="small">Delay</Label>
<DurationPicker name={delaySeconds.name} id={delaySeconds.id} />
<Hint>Delays run by a specific duration.</Hint>
<FormError id={delaySeconds.errorId}>{delaySeconds.error}</FormError>
<Label htmlFor={machine.id} variant="small">
Machine
</Label>
<Select
{...conform.select(machine)}
variant="tertiary/small"
placeholder="Select machine type"
dropdownIcon
items={machinePresets}
defaultValue={undefined}
value={machineValue}
setValue={(e) => {
if (Array.isArray(e)) return;
setMachineValue(e);
}}
>
{machinePresets.map((machine) => (
<SelectItem key={machine} value={machine}>
{machine}
</SelectItem>
))}
</Select>
<Hint>Overrides the machine preset.</Hint>
<FormError id={machine.errorId}>{machine.error}</FormError>
</InputGroup>
<InputGroup>
<Label variant="small">TTL</Label>
<DurationPicker
name={ttlSeconds.name}
id={ttlSeconds.id}
value={ttlValue}
onChange={setTtlValue}
/>
<Hint>Expires the run if it hasn't started within the TTL.</Hint>
<FormError id={ttlSeconds.errorId}>{ttlSeconds.error}</FormError>
<Label htmlFor={version.id} variant="small">
Version
</Label>
<Select
{...conform.select(version)}
defaultValue="latest"
variant="tertiary/small"
placeholder="Select version"
dropdownIcon
disabled={disableVersionSelection}
>
{versions.map((version, i) => (
<SelectItem key={version} value={i === 0 ? "latest" : version}>
{version} {i === 0 && "(latest)"}
</SelectItem>
))}
</Select>
{disableVersionSelection ? (
<Hint>Only the latest version is available in the development environment.</Hint>
) : (
<Hint>Runs task on a specific version.</Hint>
)}
<FormError id={version.errorId}>{version.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={queue.id} variant="small">
@@ -689,55 +723,21 @@ function StandardTaskForm({
<FormError id={concurrencyKey.errorId}>{concurrencyKey.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={machine.id} variant="small">
Machine
</Label>
<Select
{...conform.select(machine)}
variant="tertiary/small"
placeholder="Select machine type"
dropdownIcon
items={machinePresets}
defaultValue={undefined}
value={machineValue}
setValue={(e) => {
if (Array.isArray(e)) return;
setMachineValue(e);
}}
>
{machinePresets.map((machine) => (
<SelectItem key={machine} value={machine}>
{machine}
</SelectItem>
))}
</Select>
<Hint>Overrides the machine preset.</Hint>
<FormError id={machine.errorId}>{machine.error}</FormError>
<Label variant="small">Delay</Label>
<DurationPicker name={delaySeconds.name} id={delaySeconds.id} />
<Hint>Delays run by a specific duration.</Hint>
<FormError id={delaySeconds.errorId}>{delaySeconds.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={version.id} variant="small">
Version
</Label>
<Select
{...conform.select(version)}
defaultValue="latest"
variant="tertiary/small"
placeholder="Select version"
dropdownIcon
disabled={disableVersionSelection}
>
{versions.map((version, i) => (
<SelectItem key={version} value={i === 0 ? "latest" : version}>
{version} {i === 0 && "(latest)"}
</SelectItem>
))}
</Select>
{disableVersionSelection ? (
<Hint>Only the latest version is available in the development environment.</Hint>
) : (
<Hint>Runs task on a specific version.</Hint>
)}
<FormError id={version.errorId}>{version.error}</FormError>
<Label variant="small">TTL</Label>
<DurationPicker
name={ttlSeconds.name}
id={ttlSeconds.id}
value={ttlValue}
onChange={setTtlValue}
/>
<Hint>Expires the run if it hasn't started within the TTL.</Hint>
<FormError id={ttlSeconds.errorId}>{ttlSeconds.error}</FormError>
</InputGroup>
<FormError>{form.error}</FormError>
</Fieldset>
@@ -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,12 +936,12 @@ function ScheduledTaskForm({
setMaxDurationValue(run.maxDurationInSeconds);
setTagsValue(run.runTags ?? []);
setQueueValue(run.queue);
setMachineValue(run.machinePreset);
setMachineValue(run.machinePreset ?? undefined);
}}
/>
</div>
</div>
<div className="grow overflow-y-scroll p-3">
<div className="grow overflow-y-scroll p-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<Fieldset>
<InputGroup>
<Label htmlFor={timestamp.id} variant="small">
@@ -1041,17 +1041,55 @@ function ScheduledTaskForm({
<TextLink to={docsPath("triggering#options")}>Read the docs.</TextLink>
</Hint>
<InputGroup>
<Label htmlFor={ttlSeconds.id} variant="small">
TTL
<Label htmlFor={machine.id} variant="small">
Machine
</Label>
<DurationPicker
name={ttlSeconds.name}
id={ttlSeconds.id}
value={ttlValue}
onChange={setTtlValue}
/>
<Hint>Expires the run if it hasn't started within the TTL.</Hint>
<FormError id={ttlSeconds.errorId}>{ttlSeconds.error}</FormError>
<Select
{...conform.select(machine)}
variant="tertiary/small"
placeholder="Select machine type"
dropdownIcon
items={machinePresets}
defaultValue={undefined}
value={machineValue}
setValue={(e) => {
if (Array.isArray(e)) return;
setMachineValue(e);
}}
>
{machinePresets.map((machine) => (
<SelectItem key={machine} value={machine}>
{machine}
</SelectItem>
))}
</Select>
<Hint>Overrides the machine preset.</Hint>
<FormError id={machine.errorId}>{machine.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={version.id} variant="small">
Version
</Label>
<Select
{...conform.select(version)}
defaultValue="latest"
variant="tertiary/small"
placeholder="Select version"
dropdownIcon
disabled={disableVersionSelection}
>
{versions.map((version, i) => (
<SelectItem key={version} value={i === 0 ? "latest" : version}>
{version} {i === 0 && "(latest)"}
</SelectItem>
))}
</Select>
{disableVersionSelection ? (
<Hint>Only the latest version is available in the development environment.</Hint>
) : (
<Hint>Runs task on a specific version.</Hint>
)}
<FormError id={version.errorId}>{version.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={queue.id} variant="small">
@@ -1198,55 +1236,17 @@ function ScheduledTaskForm({
<FormError id={concurrencyKey.errorId}>{concurrencyKey.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={machine.id} variant="small">
Machine
<Label htmlFor={ttlSeconds.id} variant="small">
TTL
</Label>
<Select
{...conform.select(machine)}
variant="tertiary/small"
placeholder="Select machine type"
dropdownIcon
items={machinePresets}
defaultValue={undefined}
value={machineValue}
setValue={(e) => {
if (Array.isArray(e)) return;
setMachineValue(e);
}}
>
{machinePresets.map((machine) => (
<SelectItem key={machine} value={machine}>
{machine}
</SelectItem>
))}
</Select>
<Hint>Overrides the machine preset.</Hint>
<FormError id={machine.errorId}>{machine.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={version.id} variant="small">
Version
</Label>
<Select
{...conform.select(version)}
defaultValue="latest"
variant="tertiary/small"
placeholder="Select version"
dropdownIcon
disabled={disableVersionSelection}
>
{versions.map((version, i) => (
<SelectItem key={version} value={i === 0 ? "latest" : version}>
{version} {i === 0 && "(latest)"}
</SelectItem>
))}
</Select>
{disableVersionSelection ? (
<Hint>Only the latest version is available in the development environment.</Hint>
) : (
<Hint>Runs task on a specific version.</Hint>
)}
<FormError id={version.errorId}>{version.error}</FormError>
<DurationPicker
name={ttlSeconds.name}
id={ttlSeconds.id}
value={ttlValue}
onChange={setTtlValue}
/>
<Hint>Expires the run if it hasn't started within the TTL.</Hint>
<FormError id={ttlSeconds.errorId}>{ttlSeconds.error}</FormError>
</InputGroup>
</Fieldset>
</div>
@@ -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";
@@ -11,20 +11,42 @@ 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";
import { ReplayRunData } from "~/v3/replayTask";
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: {
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: {
@@ -66,52 +88,78 @@ 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 [taskQueue, backgroundWorkers] = await Promise.all([
findTaskQueue(environment, run.taskIdentifier),
listLatestBackgroundWorkers(environment),
]);
const latestVersions = backgroundWorkers.map((v) => v.version);
const disableVersionSelection = environment.type === "DEVELOPMENT";
const allowArbitraryQueues = backgroundWorkers.at(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,
},
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,
}))
),
});
}
const FormSchema = z.object({
environment: z.string().optional(),
payload: z.string().optional(),
failedRedirect: z.string(),
});
export const action: ActionFunction = async ({ request, params }) => {
const userId = await requireUserId(request);
const { runParam } = ParamSchema.parse(params);
const formData = await request.formData();
const submission = parse(formData, { schema: FormSchema });
const submission = parse(formData, { schema: ReplayRunData });
if (!submission.value) {
return json(submission);
}
try {
const taskRun = await prisma.taskRun.findUnique({
const taskRun = await prisma.taskRun.findFirst({
where: {
friendlyId: runParam,
},
@@ -137,6 +185,18 @@ export const action: ActionFunction = async ({ request, params }) => {
const newRun = await replayRunService.call(taskRun, {
environmentId: submission.value.environment,
payload: submission.value.payload,
metadata: submission.value.metadata,
tags: submission.value.tags,
queue: submission.value.queue,
concurrencyKey: submission.value.concurrencyKey,
maxAttempts: submission.value.maxAttempts,
maxDurationSeconds: submission.value.maxDurationSeconds,
machine: submission.value.machine,
delaySeconds: submission.value.delaySeconds,
idempotencyKey: submission.value.idempotencyKey,
idempotencyKeyTTLSeconds: submission.value.idempotencyKeyTTLSeconds,
ttlSeconds: submission.value.ttlSeconds,
version: submission.value.version,
});
if (!newRun) {
@@ -176,13 +236,78 @@ 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)
);
}
};
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,
});
}
+47
View File
@@ -0,0 +1,47 @@
import { z } from "zod";
import { RunOptionsData } from "./testTask";
export const ReplayRunData = z
.object({
environment: z.string().optional(),
payload: z
.string()
.optional()
.transform((val, ctx) => {
if (!val) {
return {};
}
try {
return JSON.parse(val);
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Payload must be a valid JSON string",
});
return z.NEVER;
}
}),
metadata: z
.string()
.optional()
.transform((val, ctx) => {
if (!val) {
return {};
}
try {
return JSON.parse(val);
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Metadata must be a valid JSON string",
});
return z.NEVER;
}
}),
failedRedirect: z.string(),
})
.and(RunOptionsData);
export type ReplayRunData = z.infer<typeof ReplayRunData>;
@@ -1,27 +1,26 @@
import {
type MachinePresetName,
conditionallyImportPacket,
IOPacket,
parsePacket,
RunTags,
stringifyIO,
} from "@trigger.dev/core/v3";
import { replaceSuperJsonPayload } from "@trigger.dev/core/v3/utils/ioSerialization";
import { TaskRun } from "@trigger.dev/database";
import { type TaskRun } from "@trigger.dev/database";
import { findEnvironmentById } from "~/models/runtimeEnvironment.server";
import { getTagsForRunId } from "~/models/taskRunTag.server";
import { logger } from "~/services/logger.server";
import { BaseService } from "./baseService.server";
import { OutOfEntitlementError, TriggerTaskService } from "./triggerTask.server";
import { type RunOptionsData } from "../testTask";
type OverrideOptions = {
environmentId?: string;
payload?: string;
};
payload?: unknown;
metadata?: unknown;
} & RunOptionsData;
export class ReplayTaskRunService extends BaseService {
public async call(existingTaskRun: TaskRun, overrideOptions?: OverrideOptions) {
public async call(existingTaskRun: TaskRun, overrideOptions: OverrideOptions = {}) {
const authenticatedEnvironment = await findEnvironmentById(
overrideOptions?.environmentId ?? existingTaskRun.runtimeEnvironmentId
overrideOptions.environmentId ?? existingTaskRun.runtimeEnvironmentId
);
if (!authenticatedEnvironment) {
return;
@@ -36,57 +35,15 @@ export class ReplayTaskRunService extends BaseService {
taskRunFriendlyId: existingTaskRun.friendlyId,
});
let payloadPacket: IOPacket;
if (overrideOptions?.payload) {
if (existingTaskRun.payloadType === "application/super+json") {
const newPayload = await replaceSuperJsonPayload(
existingTaskRun.payload,
overrideOptions.payload
);
payloadPacket = await stringifyIO(newPayload);
} else {
payloadPacket = await conditionallyImportPacket({
data: overrideOptions.payload,
dataType: existingTaskRun.payloadType,
});
}
} else {
payloadPacket = await conditionallyImportPacket({
data: existingTaskRun.payload,
dataType: existingTaskRun.payloadType,
});
}
const parsedPayload =
payloadPacket.dataType === "application/json"
? await parsePacket(payloadPacket)
: payloadPacket.data;
logger.info("Replaying task run payload", {
taskRunId: existingTaskRun.id,
taskRunFriendlyId: existingTaskRun.friendlyId,
payloadPacketType: payloadPacket.dataType,
});
const 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 = await getTagsForRunId({
friendlyId: existingTaskRun.friendlyId,
environmentId: authenticatedEnvironment.id,
});
//get the queue from the original run, so we can use the same settings on the replay
const taskQueue = await this._prisma.taskQueue.findFirst({
where: {
runtimeEnvironmentId: authenticatedEnvironment.id,
name: existingTaskRun.queue,
name: overrideOptions.queue ?? existingTaskRun.queue,
},
});
@@ -95,18 +52,34 @@ export class ReplayTaskRunService extends BaseService {
existingTaskRun.taskIdentifier,
authenticatedEnvironment,
{
payload: parsedPayload,
payload,
options: {
queue: taskQueue
? {
name: taskQueue.name,
}
: undefined,
concurrencyKey: existingTaskRun.concurrencyKey ?? undefined,
test: existingTaskRun.isTest,
payloadType: payloadPacket.dataType,
tags: tags?.map((t) => t.name) as RunTags,
metadata,
tags,
metadata: metadata,
delay: overrideOptions.delaySeconds
? new Date(Date.now() + overrideOptions.delaySeconds * 1000)
: undefined,
ttl: overrideOptions.ttlSeconds,
idempotencyKey: overrideOptions.idempotencyKey,
idempotencyKeyTTL: overrideOptions.idempotencyKeyTTLSeconds
? `${overrideOptions.idempotencyKeyTTLSeconds}s`
: undefined,
concurrencyKey:
overrideOptions.concurrencyKey ?? existingTaskRun.concurrencyKey ?? undefined,
maxAttempts: overrideOptions.maxAttempts,
maxDuration: overrideOptions.maxDurationSeconds,
machine:
overrideOptions.machine ??
(existingTaskRun.machinePreset as MachinePresetName) ??
undefined,
lockToVersion:
overrideOptions.version === "latest" ? undefined : overrideOptions.version,
},
},
{
@@ -131,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,
});
}
}
+50 -44
View File
@@ -1,6 +1,55 @@
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 type RunOptionsData = z.infer<typeof RunOptionsData>;
export const TestTaskData = z
.discriminatedUnion("triggerSource", [
z.object({
@@ -53,54 +102,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(),
})
);