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:
@@ -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>
|
||||
);
|
||||
|
||||
+125
-125
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user