Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7bf7bc268 | |||
| bb57426a0d | |||
| 3ab7eb9c7a | |||
| 2892efad04 | |||
| 979ba51d2f | |||
| 364ea565ed | |||
| 58252728f6 | |||
| 5eaad0577e | |||
| 00f1103deb | |||
| c37622e7b6 | |||
| d67023aa8f | |||
| d9bfe55a8c | |||
| f4a18feca0 | |||
| 0acd052061 | |||
| 6bf3bbcfd7 | |||
| 1077709e15 | |||
| 54017cbffa | |||
| 39ef733a34 | |||
| 736f577c25 | |||
| b7de02ec14 | |||
| 90db0f6e01 | |||
| 6df8069c0e | |||
| b714d6fabf | |||
| 576715374e | |||
| 9046ea4f23 | |||
| 7e209e0771 | |||
| 3792394e5c | |||
| 4305a23668 | |||
| e7fc592cd9 | |||
| cb12a73db6 | |||
| 31fc9e12b7 | |||
| 2bdbedcdd8 |
@@ -0,0 +1 @@
|
||||
This is the repo for Trigger.dev, a background jobs platform written in TypeScript. Our webapp at apps/webapp is a Remix 2.1 app that uses Node.js v20. Our SDK is an isomorphic TypeScript SDK at packages/trigger-sdk. Always prefer using isomorphic code like fetch, ReadableStream, etc. instead of Node.js specific code. Our tests are all vitest. We use prisma in internal-packages/database for our database interactions using PostgreSQL. For TypeScript, we usually use types over interfaces. We use zod a lot in packages/core and in the webapp. Avoid enums. Use strict mode. No default exports, use function declarations.
|
||||
@@ -0,0 +1,15 @@
|
||||
export function SideMenuRightClosedIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect x="12" y="4" width="1" height="12" fill="currentColor" />
|
||||
<rect x="2.5" y="3.5" width="15" height="13" rx="2.5" stroke="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
RectangleStackIcon,
|
||||
ServerStackIcon,
|
||||
ShieldCheckIcon,
|
||||
Squares2X2Icon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { UserGroupIcon, UserPlusIcon } from "@heroicons/react/24/solid";
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
projectSetupPath,
|
||||
projectTriggersPath,
|
||||
v3ApiKeysPath,
|
||||
v3BatchesPath,
|
||||
v3BillingPath,
|
||||
v3ConcurrencyPath,
|
||||
v3DeploymentsPath,
|
||||
@@ -457,8 +459,6 @@ function V3ProjectSideMenu({
|
||||
project: SideMenuProject;
|
||||
organization: MatchedOrganization;
|
||||
}) {
|
||||
const { alertsEnabled } = useFeatures();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SideMenuHeader title={"Project"} />
|
||||
@@ -475,6 +475,13 @@ function V3ProjectSideMenu({
|
||||
activeIconColor="text-teal-500"
|
||||
to={v3RunsPath(organization, project)}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Batches"
|
||||
icon={Squares2X2Icon}
|
||||
activeIconColor="text-blue-500"
|
||||
to={v3BatchesPath(organization, project)}
|
||||
data-action="batches"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Test"
|
||||
icon={BeakerIcon}
|
||||
@@ -511,15 +518,13 @@ function V3ProjectSideMenu({
|
||||
to={v3DeploymentsPath(organization, project)}
|
||||
data-action="deployments"
|
||||
/>
|
||||
{alertsEnabled && (
|
||||
<SideMenuItem
|
||||
name="Alerts"
|
||||
icon={BellAlertIcon}
|
||||
activeIconColor="text-red-500"
|
||||
to={v3ProjectAlertsPath(organization, project)}
|
||||
data-action="alerts"
|
||||
/>
|
||||
)}
|
||||
<SideMenuItem
|
||||
name="Alerts"
|
||||
icon={BellAlertIcon}
|
||||
activeIconColor="text-red-500"
|
||||
to={v3ProjectAlertsPath(organization, project)}
|
||||
data-action="alerts"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Concurrency limits"
|
||||
icon={RectangleStackIcon}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const variants = {
|
||||
small: {
|
||||
size: "size-[1rem]",
|
||||
arrowHeadRight: "group-hover:translate-x-[3px]",
|
||||
arrowLineRight: "h-[1.5px] w-[7px] translate-x-1 top-[calc(50%-0.5px)]",
|
||||
arrowHeadLeft: "group-hover:translate-x-[3px]",
|
||||
arrowLineLeft: "h-[1.5px] w-[7px] translate-x-1 top-[calc(50%-0.5px)]",
|
||||
arrowHeadTopRight:
|
||||
"-translate-x-0 transition group-hover:translate-x-[3px] group-hover:translate-y-[-3px]",
|
||||
},
|
||||
medium: {
|
||||
size: "size-[1.1rem]",
|
||||
arrowHeadRight: "group-hover:translate-x-[3px]",
|
||||
arrowLineRight: "h-[1.5px] w-[9px] translate-x-1 top-[calc(50%-1px)]",
|
||||
arrowHeadLeft: "group-hover:translate-x-[-3px]",
|
||||
arrowLineLeft: "h-[1.5px] w-[9px] translate-x-1 top-[calc(50%-1px)]",
|
||||
arrowHeadTopRight:
|
||||
"-translate-x-0 transition group-hover:translate-x-[3px] group-hover:translate-y-[-3px]",
|
||||
},
|
||||
large: {
|
||||
size: "size-6",
|
||||
arrowHeadRight: "group-hover:translate-x-1",
|
||||
arrowLineRight: "h-[2.3px] w-[12px] translate-x-[6px] top-[calc(50%-1px)]",
|
||||
arrowHeadLeft: "group-hover:translate-x-1",
|
||||
arrowLineLeft: "h-[2.3px] w-[12px] translate-x-[6px] top-[calc(50%-1px)]",
|
||||
arrowHeadTopRight:
|
||||
"-translate-x-0 transition group-hover:translate-x-[3px] group-hover:translate-y-[-3px]",
|
||||
},
|
||||
"extra-large": {
|
||||
size: "size-8",
|
||||
arrowHeadRight: "group-hover:translate-x-1",
|
||||
arrowLineRight: "h-[3px] w-[16px] translate-x-[8px] top-[calc(50%-1.5px)]",
|
||||
arrowHeadLeft: "group-hover:translate-x-1",
|
||||
arrowLineLeft: "h-[3px] w-[16px] translate-x-[8px] top-[calc(50%-1.5px)]",
|
||||
arrowHeadTopRight:
|
||||
"-translate-x-0 transition group-hover:translate-x-[3px] group-hover:translate-y-[-3px]",
|
||||
},
|
||||
};
|
||||
|
||||
export const themes = {
|
||||
dark: {
|
||||
textStyle: "text-background-bright",
|
||||
arrowLine: "bg-background-bright",
|
||||
},
|
||||
dimmed: {
|
||||
textStyle: "text-text-dimmed",
|
||||
arrowLine: "bg-text-dimmed",
|
||||
},
|
||||
bright: {
|
||||
textStyle: "text-text-bright",
|
||||
arrowLine: "bg-text-bright",
|
||||
},
|
||||
primary: {
|
||||
textStyle: "text-text-dimmed group-hover:text-primary",
|
||||
arrowLine: "bg-text-dimmed group-hover:bg-primary",
|
||||
},
|
||||
blue: {
|
||||
textStyle: "text-text-dimmed group-hover:text-blue-500",
|
||||
arrowLine: "bg-text-dimmed group-hover:bg-blue-500",
|
||||
},
|
||||
rose: {
|
||||
textStyle: "text-text-dimmed group-hover:text-rose-500",
|
||||
arrowLine: "bg-text-dimmed group-hover:bg-rose-500",
|
||||
},
|
||||
amber: {
|
||||
textStyle: "text-text-dimmed group-hover:text-amber-500",
|
||||
arrowLine: "bg-text-dimmed group-hover:bg-amber-500",
|
||||
},
|
||||
apple: {
|
||||
textStyle: "text-text-dimmed group-hover:text-apple-500",
|
||||
arrowLine: "bg-text-dimmed group-hover:bg-apple-500",
|
||||
},
|
||||
lavender: {
|
||||
textStyle: "text-text-dimmed group-hover:text-lavender-500",
|
||||
arrowLine: "bg-text-dimmed group-hover:bg-lavender-500",
|
||||
},
|
||||
};
|
||||
|
||||
type Variants = keyof typeof variants;
|
||||
type Theme = keyof typeof themes;
|
||||
|
||||
type AnimatingArrowProps = {
|
||||
className?: string;
|
||||
variant?: Variants;
|
||||
theme?: Theme;
|
||||
direction?: "right" | "left" | "topRight";
|
||||
};
|
||||
|
||||
export function AnimatingArrow({
|
||||
className,
|
||||
variant = "medium",
|
||||
theme = "dimmed",
|
||||
direction = "right",
|
||||
}: AnimatingArrowProps) {
|
||||
const variantStyles = variants[variant];
|
||||
const themeStyles = themes[theme];
|
||||
|
||||
return (
|
||||
<span className={cn("relative -mr-1 ml-1 flex", variantStyles.size, className)}>
|
||||
{direction === "topRight" && (
|
||||
<>
|
||||
<svg
|
||||
className={cn(
|
||||
"absolute top-[5px] transition duration-200 ease-in-out",
|
||||
themeStyles.textStyle
|
||||
)}
|
||||
width="9"
|
||||
height="8"
|
||||
viewBox="0 0 9 8"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M1.5 7L7.5 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
className={cn(
|
||||
"absolute top-[5px] transition duration-300 ease-in-out",
|
||||
themeStyles.textStyle,
|
||||
variantStyles.arrowHeadTopRight
|
||||
)}
|
||||
width="9"
|
||||
height="8"
|
||||
viewBox="0 0 9 8"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M1 1H7.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<path d="M7.5 7L7.5 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<path d="M1 7.5L7.5 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
</>
|
||||
)}
|
||||
{direction === "right" && (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute rounded-full opacity-0 transition duration-300 ease-in-out group-hover:opacity-100",
|
||||
variantStyles.arrowLineRight,
|
||||
themeStyles.arrowLine
|
||||
)}
|
||||
/>
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
"absolute -translate-x-0.5 transition duration-300 ease-in-out",
|
||||
variantStyles.arrowHeadRight,
|
||||
variantStyles.size,
|
||||
themeStyles.textStyle
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{direction === "left" && (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute rounded-full opacity-0 transition duration-300 ease-in-out group-hover:opacity-100",
|
||||
variantStyles.arrowLineLeft,
|
||||
themeStyles.arrowLine
|
||||
)}
|
||||
/>
|
||||
<ChevronLeftIcon
|
||||
className={cn(
|
||||
"absolute translate-x-0.5 transition duration-300 ease-in-out",
|
||||
variantStyles.arrowHeadLeft,
|
||||
variantStyles.size,
|
||||
themeStyles.textStyle
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -174,6 +174,7 @@ export type ButtonContentPropsType = {
|
||||
className?: string;
|
||||
shortcut?: ShortcutDefinition;
|
||||
variant: keyof typeof variant;
|
||||
shortcutPosition?: "before-trailing-icon" | "after-trailing-icon";
|
||||
};
|
||||
|
||||
export function ButtonContent(props: ButtonContentPropsType) {
|
||||
@@ -237,6 +238,14 @@ export function ButtonContent(props: ButtonContentPropsType) {
|
||||
<>{text}</>
|
||||
))}
|
||||
|
||||
{shortcut && props.shortcutPosition === "before-trailing-icon" && (
|
||||
<ShortcutKey
|
||||
className={cn(shortcutClassName)}
|
||||
shortcut={shortcut}
|
||||
variant={variation.shortcutVariant ?? "medium"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{TrailingIcon &&
|
||||
(typeof TrailingIcon === "string" ? (
|
||||
<NamedIcon
|
||||
@@ -258,13 +267,15 @@ export function ButtonContent(props: ButtonContentPropsType) {
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
{shortcut && (
|
||||
<ShortcutKey
|
||||
className={cn(shortcutClassName)}
|
||||
shortcut={shortcut}
|
||||
variant={variation.shortcutVariant ?? "medium"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shortcut &&
|
||||
(!props.shortcutPosition || props.shortcutPosition === "after-trailing-icon") && (
|
||||
<ShortcutKey
|
||||
className={cn(shortcutClassName)}
|
||||
shortcut={shortcut}
|
||||
variant={variation.shortcutVariant ?? "medium"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BellAlertIcon } from "@heroicons/react/20/solid";
|
||||
import { BellAlertIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { CalendarDateTime, createCalendar } from "@internationalized/date";
|
||||
import { useDateField, useDateSegment } from "@react-aria/datepicker";
|
||||
import type { DateFieldState, DateSegment } from "@react-stately/datepicker";
|
||||
@@ -12,7 +12,7 @@ const variants = {
|
||||
small: {
|
||||
fieldStyles: "h-5 text-sm rounded-sm px-0.5",
|
||||
nowButtonVariant: "tertiary/small" as const,
|
||||
clearButtonVariant: "minimal/small" as const,
|
||||
clearButtonVariant: "tertiary/small" as const,
|
||||
},
|
||||
medium: {
|
||||
fieldStyles: "h-7 text-base rounded px-1",
|
||||
@@ -35,9 +35,12 @@ type DateFieldProps = {
|
||||
showNowButton?: boolean;
|
||||
showClearButton?: boolean;
|
||||
onValueChange?: (value: Date | undefined) => void;
|
||||
utc?: boolean;
|
||||
variant?: Variant;
|
||||
};
|
||||
|
||||
const deviceTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
export function DateField({
|
||||
label,
|
||||
defaultValue,
|
||||
@@ -50,10 +53,11 @@ export function DateField({
|
||||
showGuide = false,
|
||||
showNowButton = false,
|
||||
showClearButton = false,
|
||||
utc = false,
|
||||
variant = "small",
|
||||
}: DateFieldProps) {
|
||||
const [value, setValue] = useState<undefined | CalendarDateTime>(
|
||||
utcDateToCalendarDate(defaultValue)
|
||||
utc ? utcDateToCalendarDate(defaultValue) : dateToCalendarDate(defaultValue)
|
||||
);
|
||||
|
||||
const state = useDateFieldState({
|
||||
@@ -61,11 +65,11 @@ export function DateField({
|
||||
onChange: (value) => {
|
||||
if (value) {
|
||||
setValue(value);
|
||||
onValueChange?.(value.toDate("utc"));
|
||||
onValueChange?.(value.toDate(utc ? "utc" : deviceTimezone));
|
||||
}
|
||||
},
|
||||
minValue: utcDateToCalendarDate(minValue),
|
||||
maxValue: utcDateToCalendarDate(maxValue),
|
||||
minValue: utc ? utcDateToCalendarDate(minValue) : dateToCalendarDate(minValue),
|
||||
maxValue: utc ? utcDateToCalendarDate(maxValue) : dateToCalendarDate(maxValue),
|
||||
shouldForceLeadingZeros: true,
|
||||
granularity,
|
||||
locale: "en-US",
|
||||
@@ -78,7 +82,9 @@ export function DateField({
|
||||
useEffect(() => {
|
||||
if (state.value === undefined && defaultValue === undefined) return;
|
||||
|
||||
const calendarDate = utcDateToCalendarDate(defaultValue);
|
||||
const calendarDate = utc
|
||||
? utcDateToCalendarDate(defaultValue)
|
||||
: dateToCalendarDate(defaultValue);
|
||||
//unchanged
|
||||
if (state.value?.toDate("utc").getTime() === defaultValue?.getTime()) {
|
||||
return;
|
||||
@@ -134,23 +140,19 @@ export function DateField({
|
||||
<Button
|
||||
type="button"
|
||||
variant={variants[variant].nowButtonVariant}
|
||||
LeadingIcon={BellAlertIcon}
|
||||
leadingIconClassName="text-text-dimmed group-hover:text-text-bright"
|
||||
onClick={() => {
|
||||
const now = new Date();
|
||||
setValue(utcDateToCalendarDate(new Date()));
|
||||
setValue(utc ? utcDateToCalendarDate(now) : dateToCalendarDate(now));
|
||||
onValueChange?.(now);
|
||||
}}
|
||||
>
|
||||
<span className="text-text-dimmed transition group-hover:text-text-bright">Now</span>
|
||||
Now
|
||||
</Button>
|
||||
)}
|
||||
{showClearButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variants[variant].clearButtonVariant}
|
||||
LeadingIcon={"close"}
|
||||
leadingIconClassName="-mr-2"
|
||||
onClick={() => {
|
||||
setValue(undefined);
|
||||
onValueChange?.(undefined);
|
||||
@@ -181,7 +183,7 @@ function utcDateToCalendarDate(date?: Date) {
|
||||
return date
|
||||
? new CalendarDateTime(
|
||||
date.getUTCFullYear(),
|
||||
date.getUTCMonth(),
|
||||
date.getUTCMonth() + 1,
|
||||
date.getUTCDate(),
|
||||
date.getUTCHours(),
|
||||
date.getUTCMinutes(),
|
||||
@@ -190,6 +192,19 @@ function utcDateToCalendarDate(date?: Date) {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function dateToCalendarDate(date?: Date) {
|
||||
return date
|
||||
? new CalendarDateTime(
|
||||
date.getFullYear(),
|
||||
date.getMonth() + 1,
|
||||
date.getDate(),
|
||||
date.getHours(),
|
||||
date.getMinutes(),
|
||||
date.getSeconds()
|
||||
)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
type DateSegmentProps = {
|
||||
segment: DateSegment;
|
||||
state: DateFieldState;
|
||||
|
||||
@@ -28,7 +28,7 @@ export function PaginationControls({
|
||||
disabledClassName="opacity-30 cursor-default"
|
||||
>
|
||||
<ButtonContent variant="minimal/small" LeadingIcon={ChevronLeftIcon}>
|
||||
Previous
|
||||
Prev
|
||||
</ButtonContent>
|
||||
</LinkDisabled>
|
||||
|
||||
|
||||
@@ -440,7 +440,7 @@ export interface SelectItemProps extends Ariakit.SelectItemProps {
|
||||
}
|
||||
|
||||
const selectItemClasses =
|
||||
"group cursor-pointer px-1 pt-1 text-sm text-text-dimmed focus-custom last:pb-1";
|
||||
"group cursor-pointer px-1 pt-1 text-2sm text-text-dimmed focus-custom last:pb-1";
|
||||
|
||||
export function SelectItem({
|
||||
icon,
|
||||
@@ -613,7 +613,7 @@ export function SelectPopover({
|
||||
"z-50 flex flex-col overflow-clip rounded border border-charcoal-700 bg-background-bright shadow-md outline-none animate-in fade-in-40",
|
||||
"min-w-[max(180px,calc(var(--popover-anchor-width)+0.5rem))]",
|
||||
"max-w-[min(480px,var(--popover-available-width))]",
|
||||
"max-h-[min(520px,var(--popover-available-height))]",
|
||||
"max-h-[min(600px,var(--popover-available-height))]",
|
||||
"origin-[var(--popover-transform-origin)]",
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -13,10 +13,10 @@ const variations = {
|
||||
},
|
||||
small: {
|
||||
container:
|
||||
"flex items-center h-[1.5rem] gap-x-1.5 rounded hover:bg-tertiary pr-1 py-[0.1rem] pl-1.5 transition focus-custom",
|
||||
"flex items-center h-[1.5rem] gap-x-1.5 rounded hover:bg-tertiary disabled:hover:bg-transparent pr-1 py-[0.1rem] pl-1.5 transition focus-custom disabled:hover:text-charcoal-400 disabled:opacity-50 text-charcoal-400 hover:text-charcoal-200 disabled:hover:cursor-not-allowed hover:cursor-pointer",
|
||||
root: "h-3 w-6",
|
||||
thumb: "h-2.5 w-2.5 data-[state=checked]:translate-x-2.5 data-[state=unchecked]:translate-x-0",
|
||||
text: "text-xs text-charcoal-400 group-hover:text-charcoal-200 hover:cursor-pointer transition",
|
||||
text: "text-xs",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -214,6 +214,7 @@ export function AbsoluteTimeFrame({
|
||||
granularity="second"
|
||||
showNowButton
|
||||
showClearButton
|
||||
utc
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -227,6 +228,7 @@ export function AbsoluteTimeFrame({
|
||||
granularity="second"
|
||||
showNowButton
|
||||
showClearButton
|
||||
utc
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import { CalendarIcon, CpuChipIcon, Squares2X2Icon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import { Form } from "@remix-run/react";
|
||||
import type { BatchTaskRunStatus, RuntimeEnvironment } from "@trigger.dev/database";
|
||||
import { ListFilterIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
ComboBox,
|
||||
SelectButtonItem,
|
||||
SelectItem,
|
||||
SelectList,
|
||||
SelectPopover,
|
||||
SelectProvider,
|
||||
SelectTrigger,
|
||||
shortcutFromIndex,
|
||||
} from "~/components/primitives/Select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "~/components/primitives/Tooltip";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { Button } from "../../primitives/Buttons";
|
||||
import {
|
||||
allBatchStatuses,
|
||||
BatchStatusCombo,
|
||||
batchStatusTitle,
|
||||
descriptionForBatchStatus,
|
||||
} from "./BatchStatus";
|
||||
import {
|
||||
AppliedCustomDateRangeFilter,
|
||||
AppliedEnvironmentFilter,
|
||||
AppliedPeriodFilter,
|
||||
appliedSummary,
|
||||
CreatedAtDropdown,
|
||||
CustomDateRangeDropdown,
|
||||
EnvironmentsDropdown,
|
||||
FilterMenuProvider,
|
||||
} from "./SharedFilters";
|
||||
|
||||
export const BatchStatus = z.enum(allBatchStatuses);
|
||||
|
||||
export const BatchListFilters = z.object({
|
||||
cursor: z.string().optional(),
|
||||
direction: z.enum(["forward", "backward"]).optional(),
|
||||
environments: z.preprocess(
|
||||
(value) => (typeof value === "string" ? [value] : value),
|
||||
z.string().array().optional()
|
||||
),
|
||||
statuses: z.preprocess(
|
||||
(value) => (typeof value === "string" ? [value] : value),
|
||||
BatchStatus.array().optional()
|
||||
),
|
||||
period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()),
|
||||
id: z.string().optional(),
|
||||
from: z.coerce.number().optional(),
|
||||
to: z.coerce.number().optional(),
|
||||
});
|
||||
|
||||
export type BatchListFilters = z.infer<typeof BatchListFilters>;
|
||||
|
||||
type DisplayableEnvironment = Pick<RuntimeEnvironment, "type" | "id"> & {
|
||||
userName?: string;
|
||||
};
|
||||
|
||||
type BatchFiltersProps = {
|
||||
possibleEnvironments: DisplayableEnvironment[];
|
||||
hasFilters: boolean;
|
||||
};
|
||||
|
||||
export function BatchFilters(props: BatchFiltersProps) {
|
||||
const location = useOptimisticLocation();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const hasFilters =
|
||||
searchParams.has("statuses") ||
|
||||
searchParams.has("environments") ||
|
||||
searchParams.has("id") ||
|
||||
searchParams.has("period") ||
|
||||
searchParams.has("from") ||
|
||||
searchParams.has("to");
|
||||
|
||||
return (
|
||||
<div className="flex flex-row flex-wrap items-center gap-1">
|
||||
<FilterMenu {...props} />
|
||||
<AppliedFilters {...props} />
|
||||
{hasFilters && (
|
||||
<Form className="h-6">
|
||||
<Button variant="minimal/small" LeadingIcon={TrashIcon}>
|
||||
Clear all
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filterTypes = [
|
||||
{
|
||||
name: "statuses",
|
||||
title: "Status",
|
||||
icon: (
|
||||
<div className="flex size-4 items-center justify-center">
|
||||
<div className="size-3 rounded-full border-2 border-text-dimmed" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ name: "environments", title: "Environment", icon: <CpuChipIcon className="size-4" /> },
|
||||
{ name: "created", title: "Created", icon: <CalendarIcon className="size-4" /> },
|
||||
{ name: "daterange", title: "Custom date range", icon: <CalendarIcon className="size-4" /> },
|
||||
{ name: "batch", title: "Batch ID", icon: <Squares2X2Icon className="size-4" /> },
|
||||
] as const;
|
||||
|
||||
type FilterType = (typeof filterTypes)[number]["name"];
|
||||
|
||||
const shortcut = { key: "f" };
|
||||
|
||||
function FilterMenu(props: BatchFiltersProps) {
|
||||
const [filterType, setFilterType] = useState<FilterType | undefined>();
|
||||
|
||||
const filterTrigger = (
|
||||
<SelectTrigger
|
||||
icon={
|
||||
<div className="flex size-4 items-center justify-center">
|
||||
<ListFilterIcon className="size-3.5" />
|
||||
</div>
|
||||
}
|
||||
variant={"minimal/small"}
|
||||
shortcut={shortcut}
|
||||
tooltipTitle={"Filter runs"}
|
||||
>
|
||||
Filter
|
||||
</SelectTrigger>
|
||||
);
|
||||
|
||||
return (
|
||||
<FilterMenuProvider onClose={() => setFilterType(undefined)}>
|
||||
{(search, setSearch) => (
|
||||
<Menu
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
trigger={filterTrigger}
|
||||
filterType={filterType}
|
||||
setFilterType={setFilterType}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedFilters({ possibleEnvironments }: BatchFiltersProps) {
|
||||
return (
|
||||
<>
|
||||
<AppliedStatusFilter />
|
||||
<AppliedEnvironmentFilter possibleEnvironments={possibleEnvironments} />
|
||||
<AppliedPeriodFilter />
|
||||
<AppliedCustomDateRangeFilter />
|
||||
<AppliedBatchIdFilter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type MenuProps = {
|
||||
searchValue: string;
|
||||
clearSearchValue: () => void;
|
||||
trigger: React.ReactNode;
|
||||
filterType: FilterType | undefined;
|
||||
setFilterType: (filterType: FilterType | undefined) => void;
|
||||
} & BatchFiltersProps;
|
||||
|
||||
function Menu(props: MenuProps) {
|
||||
switch (props.filterType) {
|
||||
case undefined:
|
||||
return <MainMenu {...props} />;
|
||||
case "statuses":
|
||||
return <StatusDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "environments":
|
||||
return <EnvironmentsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "created":
|
||||
return <CreatedAtDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "daterange":
|
||||
return <CustomDateRangeDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "batch":
|
||||
return <BatchIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: MenuProps) {
|
||||
const filtered = useMemo(() => {
|
||||
return filterTypes.filter((item) => {
|
||||
if (item.name === "daterange") return false;
|
||||
return item.title.toLowerCase().includes(searchValue.toLowerCase());
|
||||
});
|
||||
}, [searchValue]);
|
||||
|
||||
return (
|
||||
<SelectProvider virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover>
|
||||
<ComboBox placeholder={"Filter by..."} shortcut={shortcut} value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered.map((type, index) => (
|
||||
<SelectButtonItem
|
||||
key={type.name}
|
||||
onClick={() => {
|
||||
clearSearchValue();
|
||||
setFilterType(type.name);
|
||||
}}
|
||||
icon={type.icon}
|
||||
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
|
||||
>
|
||||
{type.title}
|
||||
</SelectButtonItem>
|
||||
))}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const statuses = allBatchStatuses.map((status) => ({
|
||||
title: batchStatusTitle(status),
|
||||
value: status,
|
||||
}));
|
||||
|
||||
function StatusDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (values: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({ statuses: values, cursor: undefined, direction: undefined });
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return statuses.filter((item) => item.title.toLowerCase().includes(searchValue.toLowerCase()));
|
||||
}, [searchValue]);
|
||||
|
||||
return (
|
||||
<SelectProvider value={values("statuses")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ComboBox placeholder={"Filter by status..."} value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered.map((item, index) => (
|
||||
<SelectItem
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="group flex w-full flex-col py-0">
|
||||
<BatchStatusCombo status={item.value} iconClassName="animate-none" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={9}>
|
||||
<Paragraph variant="extra-small">
|
||||
{descriptionForBatchStatus(item.value)}
|
||||
</Paragraph>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedStatusFilter() {
|
||||
const { values, del } = useSearchParams();
|
||||
const statuses = values("statuses");
|
||||
|
||||
if (statuses.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<StatusDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Status"
|
||||
value={appliedSummary(
|
||||
statuses.map((v) => batchStatusTitle(v as BatchTaskRunStatus))
|
||||
)}
|
||||
onRemove={() => del(["statuses", "cursor", "direction"])}
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function BatchIdDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState<boolean | undefined>();
|
||||
const { value, replace } = useSearchParams();
|
||||
const batchIdValue = value("id");
|
||||
|
||||
const [batchId, setBatchId] = useState(batchIdValue);
|
||||
|
||||
const apply = useCallback(() => {
|
||||
clearSearchValue();
|
||||
replace({
|
||||
cursor: undefined,
|
||||
direction: undefined,
|
||||
id: batchId === "" ? undefined : batchId?.toString(),
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
}, [batchId, replace]);
|
||||
|
||||
let error: string | undefined = undefined;
|
||||
if (batchId) {
|
||||
if (!batchId.startsWith("batch_")) {
|
||||
error = "Batch IDs start with 'batch_'";
|
||||
} else if (batchId.length !== 27) {
|
||||
error = "Batch IDs are 27 characters long";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
hideOnEnter={false}
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
className="max-w-[min(32ch,var(--popover-available-width))]"
|
||||
>
|
||||
<div className="flex flex-col gap-4 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>Batch ID</Label>
|
||||
<Input
|
||||
placeholder="batch_"
|
||||
value={batchId ?? ""}
|
||||
onChange={(e) => setBatchId(e.target.value)}
|
||||
variant="small"
|
||||
className="w-[29ch] font-mono"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{error ? <FormError>{error}</FormError> : null}
|
||||
</div>
|
||||
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
|
||||
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={error !== undefined || !batchId}
|
||||
variant="secondary/small"
|
||||
shortcut={{
|
||||
modifiers: ["meta"],
|
||||
key: "Enter",
|
||||
enabledOnInputElements: true,
|
||||
}}
|
||||
onClick={() => apply()}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedBatchIdFilter() {
|
||||
const { value, del } = useSearchParams();
|
||||
|
||||
if (value("id") === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const batchId = value("id");
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<BatchIdDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Batch ID"
|
||||
value={batchId}
|
||||
onRemove={() => del(["id", "cursor", "direction"])}
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { CheckCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { BatchTaskRunStatus } from "@trigger.dev/database";
|
||||
import assertNever from "assert-never";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export const allBatchStatuses = ["PENDING", "COMPLETED"] as const satisfies Readonly<
|
||||
Array<BatchTaskRunStatus>
|
||||
>;
|
||||
|
||||
const descriptions: Record<BatchTaskRunStatus, string> = {
|
||||
PENDING: "The batch has child runs that have not yet completed.",
|
||||
COMPLETED: "All the batch child runs have finished.",
|
||||
};
|
||||
|
||||
export function descriptionForBatchStatus(status: BatchTaskRunStatus): string {
|
||||
return descriptions[status];
|
||||
}
|
||||
|
||||
export function BatchStatusCombo({
|
||||
status,
|
||||
className,
|
||||
iconClassName,
|
||||
}: {
|
||||
status: BatchTaskRunStatus;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<span className={cn("flex items-center gap-1", className)}>
|
||||
<BatchStatusIcon status={status} className={cn("h-4 w-4", iconClassName)} />
|
||||
<BatchStatusLabel status={status} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function BatchStatusLabel({ status }: { status: BatchTaskRunStatus }) {
|
||||
return <span className={batchStatusColor(status)}>{batchStatusTitle(status)}</span>;
|
||||
}
|
||||
|
||||
export function BatchStatusIcon({
|
||||
status,
|
||||
className,
|
||||
}: {
|
||||
status: BatchTaskRunStatus;
|
||||
className: string;
|
||||
}) {
|
||||
switch (status) {
|
||||
case "PENDING":
|
||||
return <Spinner className={cn(batchStatusColor(status), className)} />;
|
||||
case "COMPLETED":
|
||||
return <CheckCircleIcon className={cn(batchStatusColor(status), className)} />;
|
||||
default: {
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function batchStatusColor(status: BatchTaskRunStatus): string {
|
||||
switch (status) {
|
||||
case "PENDING":
|
||||
return "text-pending";
|
||||
case "COMPLETED":
|
||||
return "text-success";
|
||||
default: {
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function batchStatusTitle(status: BatchTaskRunStatus): string {
|
||||
switch (status) {
|
||||
case "PENDING":
|
||||
return "In progress";
|
||||
case "COMPLETED":
|
||||
return "Completed";
|
||||
default: {
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,34 @@
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
CalendarIcon,
|
||||
ClockIcon,
|
||||
CpuChipIcon,
|
||||
InboxStackIcon,
|
||||
FingerPrintIcon,
|
||||
Squares2X2Icon,
|
||||
TagIcon,
|
||||
XMarkIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Form, useFetcher } from "@remix-run/react";
|
||||
import type {
|
||||
RuntimeEnvironment,
|
||||
TaskTriggerSource,
|
||||
TaskRunStatus,
|
||||
BulkActionType,
|
||||
RuntimeEnvironment,
|
||||
TaskRunStatus,
|
||||
TaskTriggerSource,
|
||||
} from "@trigger.dev/database";
|
||||
import { ListFilterIcon } from "lucide-react";
|
||||
import { ListChecks, ListFilterIcon } from "lucide-react";
|
||||
import { matchSorter } from "match-sorter";
|
||||
import type { ReactNode } from "react";
|
||||
import { startTransition, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { TaskIcon } from "~/assets/icons/TaskIcon";
|
||||
import { EnvironmentLabel, environmentTitle } from "~/components/environments/EnvironmentLabel";
|
||||
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
ComboBox,
|
||||
ComboboxProvider,
|
||||
SelectButtonItem,
|
||||
SelectItem,
|
||||
SelectList,
|
||||
@@ -33,6 +37,8 @@ import {
|
||||
SelectTrigger,
|
||||
shortcutFromIndex,
|
||||
} from "~/components/primitives/Select";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { Switch } from "~/components/primitives/Switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -40,22 +46,29 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "~/components/primitives/Tooltip";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { type loader as tagsLoader } from "~/routes/resources.projects.$projectParam.runs.tags";
|
||||
import { Button } from "../../primitives/Buttons";
|
||||
import { BulkActionStatusCombo } from "./BulkAction";
|
||||
import {
|
||||
AppliedCustomDateRangeFilter,
|
||||
AppliedEnvironmentFilter,
|
||||
AppliedPeriodFilter,
|
||||
appliedSummary,
|
||||
CreatedAtDropdown,
|
||||
CustomDateRangeDropdown,
|
||||
EnvironmentsDropdown,
|
||||
FilterMenuProvider,
|
||||
} from "./SharedFilters";
|
||||
import {
|
||||
TaskRunStatusCombo,
|
||||
allTaskRunStatuses,
|
||||
filterableTaskRunStatuses,
|
||||
descriptionForTaskRunStatus,
|
||||
filterableTaskRunStatuses,
|
||||
runStatusTitle,
|
||||
TaskRunStatusCombo,
|
||||
} from "./TaskRunStatus";
|
||||
import { TaskTriggerSourceIcon } from "./TaskTriggerSource";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { BulkActionStatusCombo } from "./BulkAction";
|
||||
import { type loader } from "~/routes/resources.projects.$projectParam.runs.tags";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { matchSorter } from "match-sorter";
|
||||
|
||||
export const TaskAttemptStatus = z.enum(allTaskRunStatuses);
|
||||
|
||||
@@ -86,6 +99,10 @@ export const TaskRunListSearchFilters = z.object({
|
||||
bulkId: z.string().optional(),
|
||||
from: z.coerce.number().optional(),
|
||||
to: z.coerce.number().optional(),
|
||||
rootOnly: z.coerce.boolean().optional(),
|
||||
batchId: z.string().optional(),
|
||||
runId: z.string().optional(),
|
||||
scheduleId: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TaskRunListSearchFilters = z.infer<typeof TaskRunListSearchFilters>;
|
||||
@@ -102,6 +119,7 @@ type RunFiltersProps = {
|
||||
type: BulkActionType;
|
||||
createdAt: Date;
|
||||
}[];
|
||||
rootOnlyDefault: boolean;
|
||||
hasFilters: boolean;
|
||||
};
|
||||
|
||||
@@ -114,15 +132,24 @@ export function RunsFilters(props: RunFiltersProps) {
|
||||
searchParams.has("tasks") ||
|
||||
searchParams.has("period") ||
|
||||
searchParams.has("bulkId") ||
|
||||
searchParams.has("tags");
|
||||
searchParams.has("tags") ||
|
||||
searchParams.has("from") ||
|
||||
searchParams.has("to") ||
|
||||
searchParams.has("batchId") ||
|
||||
searchParams.has("runId") ||
|
||||
searchParams.has("scheduleId");
|
||||
|
||||
return (
|
||||
<div className="flex flex-row flex-wrap items-center gap-1">
|
||||
<FilterMenu {...props} />
|
||||
<RootOnlyToggle defaultValue={props.rootOnlyDefault} />
|
||||
<AppliedFilters {...props} />
|
||||
{hasFilters && (
|
||||
<Form>
|
||||
<Button variant="minimal/small" LeadingIcon={XMarkIcon}>
|
||||
<Form className="h-6">
|
||||
{searchParams.has("rootOnly") && (
|
||||
<input type="hidden" name="rootOnly" value={searchParams.get("rootOnly") as string} />
|
||||
)}
|
||||
<Button variant="minimal/small" LeadingIcon={TrashIcon}>
|
||||
Clear all
|
||||
</Button>
|
||||
</Form>
|
||||
@@ -145,7 +172,11 @@ const filterTypes = [
|
||||
{ name: "tasks", title: "Tasks", icon: <TaskIcon className="size-4" /> },
|
||||
{ name: "tags", title: "Tags", icon: <TagIcon className="size-4" /> },
|
||||
{ name: "created", title: "Created", icon: <CalendarIcon className="size-4" /> },
|
||||
{ name: "bulk", title: "Bulk action", icon: <InboxStackIcon className="size-4" /> },
|
||||
{ name: "daterange", title: "Custom date range", icon: <CalendarIcon className="size-4" /> },
|
||||
{ name: "run", title: "Run ID", icon: <FingerPrintIcon className="size-4" /> },
|
||||
{ name: "batch", title: "Batch ID", icon: <Squares2X2Icon className="size-4" /> },
|
||||
{ name: "schedule", title: "Schedule ID", icon: <ClockIcon className="size-4" /> },
|
||||
{ name: "bulk", title: "Bulk action", icon: <ListChecks className="size-4" /> },
|
||||
] as const;
|
||||
|
||||
type FilterType = (typeof filterTypes)[number]["name"];
|
||||
@@ -186,34 +217,6 @@ function FilterMenu(props: RunFiltersProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function FilterMenuProvider({
|
||||
children,
|
||||
onClose,
|
||||
}: {
|
||||
children: (search: string, setSearch: (value: string) => void) => React.ReactNode;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
|
||||
return (
|
||||
<ComboboxProvider
|
||||
resetValueOnHide
|
||||
setValue={(value) => {
|
||||
startTransition(() => {
|
||||
setSearchValue(value);
|
||||
});
|
||||
}}
|
||||
setOpen={(open) => {
|
||||
if (!open && onClose) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children(searchValue, setSearchValue)}
|
||||
</ComboboxProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedFilters({ possibleEnvironments, possibleTasks, bulkActions }: RunFiltersProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -222,6 +225,10 @@ function AppliedFilters({ possibleEnvironments, possibleTasks, bulkActions }: Ru
|
||||
<AppliedTaskFilter possibleTasks={possibleTasks} />
|
||||
<AppliedTagsFilter />
|
||||
<AppliedPeriodFilter />
|
||||
<AppliedCustomDateRangeFilter />
|
||||
<AppliedRunIdFilter />
|
||||
<AppliedBatchIdFilter />
|
||||
<AppliedScheduleIdFilter />
|
||||
<AppliedBulkActionsFilter bulkActions={bulkActions} />
|
||||
</>
|
||||
);
|
||||
@@ -246,19 +253,28 @@ function Menu(props: MenuProps) {
|
||||
case "tasks":
|
||||
return <TasksDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "created":
|
||||
return <CreatedDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
return <CreatedAtDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "daterange":
|
||||
return <CustomDateRangeDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "bulk":
|
||||
return <BulkActionsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "tags":
|
||||
return <TagsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "run":
|
||||
return <RunIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "batch":
|
||||
return <BatchIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "schedule":
|
||||
return <ScheduleIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: MenuProps) {
|
||||
const filtered = useMemo(() => {
|
||||
return filterTypes.filter((item) =>
|
||||
item.title.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
return filterTypes.filter((item) => {
|
||||
if (item.name === "daterange") return false;
|
||||
return item.title.toLowerCase().includes(searchValue.toLowerCase());
|
||||
});
|
||||
}, [searchValue]);
|
||||
|
||||
return (
|
||||
@@ -384,100 +400,6 @@ function AppliedStatusFilter() {
|
||||
);
|
||||
}
|
||||
|
||||
function EnvironmentsDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
possibleEnvironments,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
possibleEnvironments: DisplayableEnvironment[];
|
||||
}) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (values: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({ environments: values, cursor: undefined, direction: undefined });
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return possibleEnvironments.filter((item) => {
|
||||
const title = environmentTitle(item, item.userName);
|
||||
return title.toLowerCase().includes(searchValue.toLowerCase());
|
||||
});
|
||||
}, [searchValue, possibleEnvironments]);
|
||||
|
||||
return (
|
||||
<SelectProvider value={values("environments")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ComboBox placeholder={"Filter by environment..."} value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered.map((item, index) => (
|
||||
<SelectItem
|
||||
key={item.id}
|
||||
value={item.id}
|
||||
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
|
||||
>
|
||||
<EnvironmentLabel environment={item} userName={item.userName} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedEnvironmentFilter({
|
||||
possibleEnvironments,
|
||||
}: Pick<RunFiltersProps, "possibleEnvironments">) {
|
||||
const { values, del } = useSearchParams();
|
||||
|
||||
if (values("environments").length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<EnvironmentsDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Environment"
|
||||
value={appliedSummary(
|
||||
values("environments").map((v) => {
|
||||
const environment = possibleEnvironments.find((env) => env.id === v);
|
||||
return environment ? environmentTitle(environment, environment.userName) : v;
|
||||
})
|
||||
)}
|
||||
onRemove={() => del(["environments", "cursor", "direction"])}
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
possibleEnvironments={possibleEnvironments}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TasksDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
@@ -524,7 +446,9 @@ function TasksDropdown({
|
||||
<SelectItem
|
||||
key={item.slug}
|
||||
value={item.slug}
|
||||
icon={<TaskTriggerSourceIcon source={item.triggerSource} className="size-4" />}
|
||||
icon={
|
||||
<TaskTriggerSourceIcon source={item.triggerSource} className="size-4 flex-none" />
|
||||
}
|
||||
>
|
||||
{item.slug}
|
||||
</SelectItem>
|
||||
@@ -685,7 +609,7 @@ function TagsDropdown({
|
||||
});
|
||||
};
|
||||
|
||||
const fetcher = useFetcher<typeof loader>();
|
||||
const fetcher = useFetcher<typeof tagsLoader>();
|
||||
|
||||
useEffect(() => {
|
||||
const searchParams = new URLSearchParams();
|
||||
@@ -780,62 +704,34 @@ function AppliedTagsFilter() {
|
||||
);
|
||||
}
|
||||
|
||||
const timePeriods = [
|
||||
{
|
||||
label: "All periods",
|
||||
value: "all",
|
||||
},
|
||||
{
|
||||
label: "5 mins ago",
|
||||
value: "5m",
|
||||
},
|
||||
{
|
||||
label: "15 mins ago",
|
||||
value: "15m",
|
||||
},
|
||||
{
|
||||
label: "30 mins ago",
|
||||
value: "30m",
|
||||
},
|
||||
{
|
||||
label: "1 hour ago",
|
||||
value: "1h",
|
||||
},
|
||||
{
|
||||
label: "3 hours ago",
|
||||
value: "3h",
|
||||
},
|
||||
{
|
||||
label: "6 hours ago",
|
||||
value: "6h",
|
||||
},
|
||||
{
|
||||
label: "1 day ago",
|
||||
value: "1d",
|
||||
},
|
||||
{
|
||||
label: "3 days ago",
|
||||
value: "3d",
|
||||
},
|
||||
{
|
||||
label: "7 days ago",
|
||||
value: "7d",
|
||||
},
|
||||
{
|
||||
label: "10 days ago",
|
||||
value: "10d",
|
||||
},
|
||||
{
|
||||
label: "14 days ago",
|
||||
value: "14d",
|
||||
},
|
||||
{
|
||||
label: "30 days ago",
|
||||
value: "30d",
|
||||
},
|
||||
];
|
||||
function RootOnlyToggle({ defaultValue }: { defaultValue: boolean }) {
|
||||
const { value, values, replace } = useSearchParams();
|
||||
const searchValue = value("rootOnly");
|
||||
const rootOnly = searchValue !== undefined ? searchValue === "true" : defaultValue;
|
||||
|
||||
function CreatedDropdown({
|
||||
const batchId = value("batchId");
|
||||
const runId = value("runId");
|
||||
const scheduleId = value("scheduleId");
|
||||
const tasks = values("tasks");
|
||||
|
||||
const disabled = !!batchId || !!runId || !!scheduleId || tasks.length > 0;
|
||||
|
||||
return (
|
||||
<Switch
|
||||
disabled={disabled}
|
||||
variant="small"
|
||||
label="Root only"
|
||||
checked={disabled ? false : rootOnly}
|
||||
onCheckedChange={(checked) => {
|
||||
replace({
|
||||
rootOnly: checked ? "true" : "false",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RunIdDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
@@ -846,25 +742,34 @@ function CreatedDropdown({
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState<boolean | undefined>();
|
||||
const { value, replace } = useSearchParams();
|
||||
const runIdValue = value("runId");
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
const [runId, setRunId] = useState(runIdValue);
|
||||
|
||||
const apply = useCallback(() => {
|
||||
clearSearchValue();
|
||||
if (newValue === "all") {
|
||||
if (!value) return;
|
||||
replace({
|
||||
cursor: undefined,
|
||||
direction: undefined,
|
||||
runId: runId === "" ? undefined : runId?.toString(),
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
}, [runId, replace]);
|
||||
|
||||
let error: string | undefined = undefined;
|
||||
if (runId) {
|
||||
if (!runId.startsWith("run_")) {
|
||||
error = "Run IDs start with 'run_'";
|
||||
} else if (runId.length !== 25) {
|
||||
error = "Run IDs are 25 characters long";
|
||||
}
|
||||
|
||||
replace({ period: newValue, cursor: undefined, direction: undefined });
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return timePeriods.filter((item) =>
|
||||
item.label.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
}, [searchValue]);
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectProvider value={value("period")} setValue={handleChange} virtualFocus={true}>
|
||||
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
hideOnEnter={false}
|
||||
@@ -876,39 +781,63 @@ function CreatedDropdown({
|
||||
|
||||
return true;
|
||||
}}
|
||||
className="max-w-[min(32ch,var(--popover-available-width))]"
|
||||
>
|
||||
<ComboBox placeholder={"Filter by period..."} value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value} hideOnClick={false}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectList>
|
||||
<div className="flex flex-col gap-4 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>Run ID</Label>
|
||||
<Input
|
||||
placeholder="run_"
|
||||
value={runId ?? ""}
|
||||
onChange={(e) => setRunId(e.target.value)}
|
||||
variant="small"
|
||||
className="w-[27ch] font-mono"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{error ? <FormError>{error}</FormError> : null}
|
||||
</div>
|
||||
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
|
||||
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={error !== undefined || !runId}
|
||||
variant="secondary/small"
|
||||
shortcut={{
|
||||
modifiers: ["meta"],
|
||||
key: "Enter",
|
||||
enabledOnInputElements: true,
|
||||
}}
|
||||
onClick={() => apply()}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedPeriodFilter() {
|
||||
function AppliedRunIdFilter() {
|
||||
const { value, del } = useSearchParams();
|
||||
|
||||
if (value("period") === undefined || value("period") === "all") {
|
||||
if (value("runId") === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runId = value("runId");
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<CreatedDropdown
|
||||
<RunIdDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Created"
|
||||
value={
|
||||
timePeriods.find((t) => t.value === value("period"))?.label ?? value("period")
|
||||
}
|
||||
onRemove={() => del(["period", "cursor", "direction"])}
|
||||
label="Run ID"
|
||||
value={runId}
|
||||
onRemove={() => del(["runId", "cursor", "direction"])}
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
@@ -920,14 +849,238 @@ function AppliedPeriodFilter() {
|
||||
);
|
||||
}
|
||||
|
||||
function appliedSummary(values: string[], maxValues = 3) {
|
||||
if (values.length === 0) {
|
||||
function BatchIdDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState<boolean | undefined>();
|
||||
const { value, replace } = useSearchParams();
|
||||
const batchIdValue = value("batchId");
|
||||
|
||||
const [batchId, setBatchId] = useState(batchIdValue);
|
||||
|
||||
const apply = useCallback(() => {
|
||||
clearSearchValue();
|
||||
replace({
|
||||
cursor: undefined,
|
||||
direction: undefined,
|
||||
batchId: batchId === "" ? undefined : batchId?.toString(),
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
}, [batchId, replace]);
|
||||
|
||||
let error: string | undefined = undefined;
|
||||
if (batchId) {
|
||||
if (!batchId.startsWith("batch_")) {
|
||||
error = "Batch IDs start with 'batch_'";
|
||||
} else if (batchId.length !== 27) {
|
||||
error = "Batch IDs are 27 characters long";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
hideOnEnter={false}
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
className="max-w-[min(32ch,var(--popover-available-width))]"
|
||||
>
|
||||
<div className="flex flex-col gap-4 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>Batch ID</Label>
|
||||
<Input
|
||||
placeholder="batch_"
|
||||
value={batchId ?? ""}
|
||||
onChange={(e) => setBatchId(e.target.value)}
|
||||
variant="small"
|
||||
className="w-[29ch] font-mono"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{error ? <FormError>{error}</FormError> : null}
|
||||
</div>
|
||||
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
|
||||
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={error !== undefined || !batchId}
|
||||
variant="secondary/small"
|
||||
shortcut={{
|
||||
modifiers: ["meta"],
|
||||
key: "Enter",
|
||||
enabledOnInputElements: true,
|
||||
}}
|
||||
onClick={() => apply()}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedBatchIdFilter() {
|
||||
const { value, del } = useSearchParams();
|
||||
|
||||
if (value("batchId") === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (values.length > maxValues) {
|
||||
return `${values.slice(0, maxValues).join(", ")} + ${values.length - maxValues} more`;
|
||||
const batchId = value("batchId");
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<BatchIdDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Batch ID"
|
||||
value={batchId}
|
||||
onRemove={() => del(["batchId", "cursor", "direction"])}
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleIdDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState<boolean | undefined>();
|
||||
const { value, replace } = useSearchParams();
|
||||
const scheduleIdValue = value("scheduleId");
|
||||
|
||||
const [scheduleId, setScheduleId] = useState(scheduleIdValue);
|
||||
|
||||
const apply = useCallback(() => {
|
||||
clearSearchValue();
|
||||
replace({
|
||||
cursor: undefined,
|
||||
direction: undefined,
|
||||
scheduleId: scheduleId === "" ? undefined : scheduleId?.toString(),
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
}, [scheduleId, replace]);
|
||||
|
||||
let error: string | undefined = undefined;
|
||||
if (scheduleId) {
|
||||
if (!scheduleId.startsWith("sched")) {
|
||||
error = "Schedule IDs start with 'sched_'";
|
||||
} else if (scheduleId.length !== 27) {
|
||||
error = "Schedule IDs are 27 characters long";
|
||||
}
|
||||
}
|
||||
|
||||
return values.join(", ");
|
||||
return (
|
||||
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
hideOnEnter={false}
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
className="max-w-[min(32ch,var(--popover-available-width))]"
|
||||
>
|
||||
<div className="flex flex-col gap-4 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>Schedule ID</Label>
|
||||
<Input
|
||||
placeholder="sched_"
|
||||
value={scheduleId ?? ""}
|
||||
onChange={(e) => setScheduleId(e.target.value)}
|
||||
variant="small"
|
||||
className="w-[29ch] font-mono"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{error ? <FormError>{error}</FormError> : null}
|
||||
</div>
|
||||
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
|
||||
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={error !== undefined || !scheduleId}
|
||||
variant="secondary/small"
|
||||
shortcut={{
|
||||
modifiers: ["meta"],
|
||||
key: "Enter",
|
||||
enabledOnInputElements: true,
|
||||
}}
|
||||
onClick={() => apply()}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedScheduleIdFilter() {
|
||||
const { value, del } = useSearchParams();
|
||||
|
||||
if (value("scheduleId") === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scheduleId = value("scheduleId");
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<ScheduleIdDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Schedule ID"
|
||||
value={scheduleId}
|
||||
onRemove={() => del(["scheduleId", "cursor", "direction"])}
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import type { RuntimeEnvironment } from "@trigger.dev/database";
|
||||
import type { ReactNode } from "react";
|
||||
import { startTransition, useCallback, useMemo, useState } from "react";
|
||||
import { EnvironmentLabel, environmentTitle } from "~/components/environments/EnvironmentLabel";
|
||||
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
|
||||
import { DateField } from "~/components/primitives/DateField";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import {
|
||||
ComboBox,
|
||||
ComboboxProvider,
|
||||
SelectItem,
|
||||
SelectList,
|
||||
SelectPopover,
|
||||
SelectProvider,
|
||||
shortcutFromIndex,
|
||||
} from "~/components/primitives/Select";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { Button } from "../../primitives/Buttons";
|
||||
|
||||
export type DisplayableEnvironment = Pick<RuntimeEnvironment, "type" | "id"> & {
|
||||
userName?: string;
|
||||
};
|
||||
|
||||
export function FilterMenuProvider({
|
||||
children,
|
||||
onClose,
|
||||
}: {
|
||||
children: (search: string, setSearch: (value: string) => void) => React.ReactNode;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
|
||||
return (
|
||||
<ComboboxProvider
|
||||
resetValueOnHide
|
||||
setValue={(value) => {
|
||||
startTransition(() => {
|
||||
setSearchValue(value);
|
||||
});
|
||||
}}
|
||||
setOpen={(open) => {
|
||||
if (!open && onClose) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children(searchValue, setSearchValue)}
|
||||
</ComboboxProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function EnvironmentsDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
possibleEnvironments,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
possibleEnvironments: DisplayableEnvironment[];
|
||||
}) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (values: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({ environments: values, cursor: undefined, direction: undefined });
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return possibleEnvironments.filter((item) => {
|
||||
const title = environmentTitle(item, item.userName);
|
||||
return title.toLowerCase().includes(searchValue.toLowerCase());
|
||||
});
|
||||
}, [searchValue, possibleEnvironments]);
|
||||
|
||||
return (
|
||||
<SelectProvider value={values("environments")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ComboBox placeholder={"Filter by environment..."} value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered.map((item, index) => (
|
||||
<SelectItem
|
||||
key={item.id}
|
||||
value={item.id}
|
||||
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
|
||||
>
|
||||
<EnvironmentLabel environment={item} userName={item.userName} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppliedEnvironmentFilter({
|
||||
possibleEnvironments,
|
||||
}: {
|
||||
possibleEnvironments: DisplayableEnvironment[];
|
||||
}) {
|
||||
const { values, del } = useSearchParams();
|
||||
|
||||
if (values("environments").length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<EnvironmentsDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Environment"
|
||||
value={appliedSummary(
|
||||
values("environments").map((v) => {
|
||||
const environment = possibleEnvironments.find((env) => env.id === v);
|
||||
return environment ? environmentTitle(environment, environment.userName) : v;
|
||||
})
|
||||
)}
|
||||
onRemove={() => del(["environments", "cursor", "direction"])}
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
possibleEnvironments={possibleEnvironments}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const timePeriods = [
|
||||
{
|
||||
label: "Last 5 mins",
|
||||
value: "5m",
|
||||
},
|
||||
{
|
||||
label: "Last 30 mins",
|
||||
value: "30m",
|
||||
},
|
||||
{
|
||||
label: "Last 1 hour",
|
||||
value: "1h",
|
||||
},
|
||||
{
|
||||
label: "Last 6 hours",
|
||||
value: "6h",
|
||||
},
|
||||
{
|
||||
label: "Last 1 day",
|
||||
value: "1d",
|
||||
},
|
||||
{
|
||||
label: "Last 3 days",
|
||||
value: "3d",
|
||||
},
|
||||
{
|
||||
label: "Last 7 days",
|
||||
value: "7d",
|
||||
},
|
||||
{
|
||||
label: "Last 14 days",
|
||||
value: "14d",
|
||||
},
|
||||
{
|
||||
label: "Last 30 days",
|
||||
value: "30d",
|
||||
},
|
||||
{
|
||||
label: "All periods",
|
||||
value: "all",
|
||||
},
|
||||
];
|
||||
|
||||
export function CreatedAtDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
setFilterType,
|
||||
hideCustomRange,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
setFilterType?: (type: "daterange" | undefined) => void;
|
||||
hideCustomRange?: boolean;
|
||||
}) {
|
||||
const { value, replace } = useSearchParams();
|
||||
|
||||
const from = value("from");
|
||||
const to = value("to");
|
||||
const period = value("period");
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
clearSearchValue();
|
||||
if (newValue === "all") {
|
||||
if (!period && !from && !to) return;
|
||||
|
||||
replace({
|
||||
period: undefined,
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
cursor: undefined,
|
||||
direction: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (newValue === "custom") {
|
||||
setFilterType?.("daterange");
|
||||
return;
|
||||
}
|
||||
|
||||
replace({
|
||||
period: newValue,
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
cursor: undefined,
|
||||
direction: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return timePeriods.filter((item) =>
|
||||
item.label.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
}, [searchValue]);
|
||||
|
||||
return (
|
||||
<SelectProvider
|
||||
value={from || to ? "custom" : period ?? "all"}
|
||||
setValue={handleChange}
|
||||
virtualFocus={true}
|
||||
>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
hideOnEnter={false}
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ComboBox placeholder={"Filter by period..."} value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value} hideOnClick={false}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
{!hideCustomRange ? (
|
||||
<SelectItem value="custom" hideOnClick={false}>
|
||||
Custom date range
|
||||
</SelectItem>
|
||||
) : null}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppliedPeriodFilter() {
|
||||
const { value, del } = useSearchParams();
|
||||
|
||||
if (value("period") === undefined || value("period") === "all") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<CreatedAtDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Created"
|
||||
value={
|
||||
timePeriods.find((t) => t.value === value("period"))?.label ?? value("period")
|
||||
}
|
||||
onRemove={() => del(["period", "cursor", "direction"])}
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
hideCustomRange
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomDateRangeDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState<boolean | undefined>();
|
||||
const { value, replace } = useSearchParams();
|
||||
const fromSearch = dateFromString(value("from"));
|
||||
const toSearch = dateFromString(value("to"));
|
||||
const [from, setFrom] = useState(fromSearch);
|
||||
const [to, setTo] = useState(toSearch);
|
||||
|
||||
const apply = useCallback(() => {
|
||||
clearSearchValue();
|
||||
replace({
|
||||
period: undefined,
|
||||
cursor: undefined,
|
||||
direction: undefined,
|
||||
from: from?.getTime().toString(),
|
||||
to: to?.getTime().toString(),
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
}, [from, to, replace]);
|
||||
|
||||
return (
|
||||
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
hideOnEnter={false}
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>From (local time)</Label>
|
||||
<DateField
|
||||
label="From time"
|
||||
defaultValue={from}
|
||||
onValueChange={setFrom}
|
||||
granularity="second"
|
||||
showNowButton
|
||||
showClearButton
|
||||
variant="small"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>To (local time)</Label>
|
||||
<DateField
|
||||
label="To time"
|
||||
defaultValue={to}
|
||||
onValueChange={setTo}
|
||||
granularity="second"
|
||||
showNowButton
|
||||
showClearButton
|
||||
variant="small"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
|
||||
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary/small"
|
||||
shortcut={{
|
||||
modifiers: ["meta"],
|
||||
key: "Enter",
|
||||
enabledOnInputElements: true,
|
||||
}}
|
||||
onClick={() => apply()}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppliedCustomDateRangeFilter() {
|
||||
const { value, del } = useSearchParams();
|
||||
|
||||
if (value("from") === undefined && value("to") === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fromDate = dateFromString(value("from"));
|
||||
const toDate = dateFromString(value("to"));
|
||||
|
||||
const rangeType = fromDate && toDate ? "range" : fromDate ? "from" : "to";
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<CustomDateRangeDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label={
|
||||
rangeType === "range"
|
||||
? "Created"
|
||||
: rangeType === "from"
|
||||
? "Created after"
|
||||
: "Created before"
|
||||
}
|
||||
value={
|
||||
<>
|
||||
{rangeType === "range" ? (
|
||||
<span>
|
||||
<DateTime date={fromDate!} includeTime includeSeconds /> –{" "}
|
||||
<DateTime date={toDate!} includeTime includeSeconds />
|
||||
</span>
|
||||
) : rangeType === "from" ? (
|
||||
<DateTime date={fromDate!} includeTime includeSeconds />
|
||||
) : (
|
||||
<DateTime date={toDate!} includeTime includeSeconds />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
onRemove={() => del(["period", "from", "to", "cursor", "direction"])}
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function appliedSummary(values: string[], maxValues = 3) {
|
||||
if (values.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (values.length > maxValues) {
|
||||
return `${values.slice(0, maxValues).join(", ")} + ${values.length - maxValues} more`;
|
||||
}
|
||||
|
||||
return values.join(", ");
|
||||
}
|
||||
|
||||
function dateFromString(value: string | undefined | null): Date | undefined {
|
||||
if (!value) return;
|
||||
|
||||
//is it an int?
|
||||
const int = parseInt(value);
|
||||
if (!isNaN(int)) {
|
||||
return new Date(int);
|
||||
}
|
||||
|
||||
return new Date(value);
|
||||
}
|
||||
@@ -142,6 +142,10 @@ const EnvironmentSchema = z.object({
|
||||
CONTAINER_REGISTRY_PASSWORD: z.string().optional(),
|
||||
DEPLOY_REGISTRY_HOST: z.string().optional(),
|
||||
DEPLOY_REGISTRY_NAMESPACE: z.string().default("trigger"),
|
||||
DEPLOY_TIMEOUT_MS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.default(60 * 1000 * 8), // 8 minutes
|
||||
OBJECT_STORE_BASE_URL: z.string().optional(),
|
||||
OBJECT_STORE_ACCESS_KEY_ID: z.string().optional(),
|
||||
OBJECT_STORE_SECRET_ACCESS_KEY: z.string().optional(),
|
||||
@@ -233,10 +237,12 @@ const EnvironmentSchema = z.object({
|
||||
MAXIMUM_TRACE_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(25_000),
|
||||
TASK_PAYLOAD_OFFLOAD_THRESHOLD: z.coerce.number().int().default(524_288), // 512KB
|
||||
TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(3_145_728), // 3MB
|
||||
BATCH_TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(1_000_000), // 1MB
|
||||
TASK_RUN_METADATA_MAXIMUM_SIZE: z.coerce.number().int().default(4_096), // 4KB
|
||||
|
||||
MAXIMUM_DEV_QUEUE_SIZE: z.coerce.number().int().optional(),
|
||||
MAXIMUM_DEPLOYED_QUEUE_SIZE: z.coerce.number().int().optional(),
|
||||
MAX_BATCH_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500),
|
||||
});
|
||||
|
||||
export type Environment = z.infer<typeof EnvironmentSchema>;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { requestUrl } from "./utils/requestUrl.server";
|
||||
export type TriggerFeatures = {
|
||||
isManagedCloud: boolean;
|
||||
v3Enabled: boolean;
|
||||
alertsEnabled: boolean;
|
||||
};
|
||||
|
||||
function isManagedCloud(host: string): boolean {
|
||||
@@ -20,7 +19,6 @@ function featuresForHost(host: string): TriggerFeatures {
|
||||
return {
|
||||
isManagedCloud: isManagedCloud(host),
|
||||
v3Enabled: env.V3_ENABLED === "true",
|
||||
alertsEnabled: env.ALERT_FROM_EMAIL !== undefined && env.ALERT_RESEND_API_KEY !== undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,5 +5,5 @@ import type { TriggerFeatures } from "~/features.server";
|
||||
export function useFeatures(): TriggerFeatures {
|
||||
const routeMatch = useTypedRouteLoaderData<typeof loader>("root");
|
||||
|
||||
return routeMatch?.features ?? { isManagedCloud: false, v3Enabled: false, alertsEnabled: false };
|
||||
return routeMatch?.features ?? { isManagedCloud: false, v3Enabled: false };
|
||||
}
|
||||
|
||||
@@ -6,11 +6,10 @@ import type {
|
||||
import { TaskRunError, TaskRunErrorCodes } from "@trigger.dev/core/v3";
|
||||
|
||||
import type {
|
||||
BatchTaskRunItemStatus as BatchTaskRunItemStatusType,
|
||||
TaskRun,
|
||||
TaskRunAttempt,
|
||||
TaskRunAttemptStatus as TaskRunAttemptStatusType,
|
||||
TaskRunStatus as TaskRunStatusType,
|
||||
BatchTaskRunItemStatus as BatchTaskRunItemStatusType,
|
||||
} from "@trigger.dev/database";
|
||||
|
||||
import { assertNever } from "assert-never";
|
||||
@@ -50,6 +49,7 @@ export function executionResultForTaskRun(
|
||||
return {
|
||||
ok: true,
|
||||
id: taskRun.friendlyId,
|
||||
taskIdentifier: taskRun.taskIdentifier,
|
||||
output: attempt.output ?? undefined,
|
||||
outputType: attempt.outputType,
|
||||
} satisfies TaskRunSuccessfulExecutionResult;
|
||||
@@ -60,6 +60,7 @@ export function executionResultForTaskRun(
|
||||
return {
|
||||
ok: false,
|
||||
id: taskRun.friendlyId,
|
||||
taskIdentifier: taskRun.taskIdentifier,
|
||||
error: {
|
||||
type: "INTERNAL_ERROR",
|
||||
code: TaskRunErrorCodes.TASK_RUN_CANCELLED,
|
||||
@@ -92,6 +93,7 @@ export function executionResultForTaskRun(
|
||||
return {
|
||||
ok: false,
|
||||
id: taskRun.friendlyId,
|
||||
taskIdentifier: taskRun.taskIdentifier,
|
||||
error: {
|
||||
type: "INTERNAL_ERROR",
|
||||
code: TaskRunErrorCodes.CONFIGURED_INCORRECTLY,
|
||||
@@ -102,6 +104,7 @@ export function executionResultForTaskRun(
|
||||
return {
|
||||
ok: false,
|
||||
id: taskRun.friendlyId,
|
||||
taskIdentifier: taskRun.taskIdentifier,
|
||||
error: error.data,
|
||||
} satisfies TaskRunFailedExecutionResult;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import assertNever from "assert-never";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { generatePresignedUrl } from "~/v3/r2.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { $replica, prisma } from "~/db.server";
|
||||
|
||||
// Build 'select' object
|
||||
const commonRunSelect = {
|
||||
@@ -59,48 +59,46 @@ type CommonRelatedRun = Prisma.Result<
|
||||
"findFirstOrThrow"
|
||||
>;
|
||||
|
||||
type FoundRun = NonNullable<Awaited<ReturnType<typeof ApiRetrieveRunPresenter.findRun>>>;
|
||||
|
||||
export class ApiRetrieveRunPresenter extends BasePresenter {
|
||||
public static async findRun(friendlyId: string, env: AuthenticatedEnvironment) {
|
||||
return $replica.taskRun.findFirst({
|
||||
where: {
|
||||
friendlyId,
|
||||
runtimeEnvironmentId: env.id,
|
||||
},
|
||||
include: {
|
||||
attempts: true,
|
||||
lockedToVersion: true,
|
||||
schedule: true,
|
||||
tags: true,
|
||||
batch: {
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
},
|
||||
},
|
||||
parentTaskRun: {
|
||||
select: commonRunSelect,
|
||||
},
|
||||
rootTaskRun: {
|
||||
select: commonRunSelect,
|
||||
},
|
||||
childRuns: {
|
||||
select: {
|
||||
...commonRunSelect,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public async call(
|
||||
friendlyId: string,
|
||||
taskRun: FoundRun,
|
||||
env: AuthenticatedEnvironment
|
||||
): Promise<RetrieveRunResponse | undefined> {
|
||||
return this.traceWithEnv("call", env, async (span) => {
|
||||
const taskRun = await this._replica.taskRun.findFirst({
|
||||
where: {
|
||||
friendlyId,
|
||||
runtimeEnvironmentId: env.id,
|
||||
},
|
||||
include: {
|
||||
attempts: true,
|
||||
lockedToVersion: true,
|
||||
schedule: true,
|
||||
tags: true,
|
||||
batch: {
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
},
|
||||
},
|
||||
parentTaskRun: {
|
||||
select: commonRunSelect,
|
||||
},
|
||||
rootTaskRun: {
|
||||
select: commonRunSelect,
|
||||
},
|
||||
childRuns: {
|
||||
select: {
|
||||
...commonRunSelect,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!taskRun) {
|
||||
logger.debug("Task run not found", { friendlyId, envId: env.id });
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let $payload: any;
|
||||
let $payloadPresignedUrl: string | undefined;
|
||||
let $output: any;
|
||||
|
||||
@@ -119,6 +119,7 @@ export const ApiRunListSearchParams = z.object({
|
||||
"filter[createdAt][from]": CoercedDate,
|
||||
"filter[createdAt][to]": CoercedDate,
|
||||
"filter[createdAt][period]": z.string().optional(),
|
||||
"filter[batch]": z.string().optional(),
|
||||
});
|
||||
|
||||
type ApiRunListSearchParams = z.infer<typeof ApiRunListSearchParams>;
|
||||
@@ -209,6 +210,10 @@ export class ApiRunListPresenter extends BasePresenter {
|
||||
options.isTest = searchParams["filter[isTest]"];
|
||||
}
|
||||
|
||||
if (searchParams["filter[batch]"]) {
|
||||
options.batchId = searchParams["filter[batch]"];
|
||||
}
|
||||
|
||||
const presenter = new RunListPresenter();
|
||||
|
||||
logger.debug("Calling RunListPresenter", { options });
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { BatchTaskRunStatus, Prisma } from "@trigger.dev/database";
|
||||
import parse from "parse-duration";
|
||||
import { type Direction } from "~/components/runs/RunStatuses";
|
||||
import { sqlDatabaseSchema } from "~/db.server";
|
||||
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
|
||||
export type BatchListOptions = {
|
||||
userId?: string;
|
||||
projectId: string;
|
||||
//filters
|
||||
friendlyId?: string;
|
||||
statuses?: BatchTaskRunStatus[];
|
||||
environments?: string[];
|
||||
period?: string;
|
||||
from?: number;
|
||||
to?: number;
|
||||
//pagination
|
||||
direction?: Direction;
|
||||
cursor?: string;
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
|
||||
export type BatchList = Awaited<ReturnType<BatchListPresenter["call"]>>;
|
||||
export type BatchListItem = BatchList["batches"][0];
|
||||
export type BatchListAppliedFilters = BatchList["filters"];
|
||||
|
||||
export class BatchListPresenter extends BasePresenter {
|
||||
public async call({
|
||||
userId,
|
||||
projectId,
|
||||
friendlyId,
|
||||
statuses,
|
||||
environments,
|
||||
period,
|
||||
from,
|
||||
to,
|
||||
direction = "forward",
|
||||
cursor,
|
||||
pageSize = DEFAULT_PAGE_SIZE,
|
||||
}: BatchListOptions) {
|
||||
const hasStatusFilters = statuses && statuses.length > 0;
|
||||
|
||||
const hasFilters =
|
||||
hasStatusFilters ||
|
||||
(environments !== undefined && environments.length > 0) ||
|
||||
(period !== undefined && period !== "all") ||
|
||||
friendlyId !== undefined ||
|
||||
from !== undefined ||
|
||||
to !== undefined;
|
||||
|
||||
// Find the project scoped to the organization
|
||||
const project = await this._replica.project.findFirstOrThrow({
|
||||
select: {
|
||||
id: true,
|
||||
environments: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
slug: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
id: projectId,
|
||||
},
|
||||
});
|
||||
|
||||
let environmentIds = project.environments.map((e) => e.id);
|
||||
if (environments && environments.length > 0) {
|
||||
//if environments are passed in, we only include them if they're in the project
|
||||
environmentIds = environments.filter((e) => project.environments.some((pe) => pe.id === e));
|
||||
}
|
||||
|
||||
if (environmentIds.length === 0) {
|
||||
throw new Error("No matching environments found for the project");
|
||||
}
|
||||
|
||||
const periodMs = period ? parse(period) : undefined;
|
||||
|
||||
//get the batches
|
||||
const batches = await this._replica.$queryRaw<
|
||||
{
|
||||
id: string;
|
||||
friendlyId: string;
|
||||
runtimeEnvironmentId: string;
|
||||
status: BatchTaskRunStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
runCount: BigInt;
|
||||
batchVersion: string;
|
||||
}[]
|
||||
>`
|
||||
SELECT
|
||||
b.id,
|
||||
b."friendlyId",
|
||||
b."runtimeEnvironmentId",
|
||||
b.status,
|
||||
b."createdAt",
|
||||
b."updatedAt",
|
||||
b."runCount",
|
||||
b."batchVersion"
|
||||
FROM
|
||||
${sqlDatabaseSchema}."BatchTaskRun" b
|
||||
WHERE
|
||||
-- environments
|
||||
b."runtimeEnvironmentId" IN (${Prisma.join(environmentIds)})
|
||||
-- cursor
|
||||
${
|
||||
cursor
|
||||
? direction === "forward"
|
||||
? Prisma.sql`AND b.id < ${cursor}`
|
||||
: Prisma.sql`AND b.id > ${cursor}`
|
||||
: Prisma.empty
|
||||
}
|
||||
-- filters
|
||||
${friendlyId ? Prisma.sql`AND b."friendlyId" = ${friendlyId}` : Prisma.empty}
|
||||
${
|
||||
statuses && statuses.length > 0
|
||||
? Prisma.sql`AND b.status = ANY(ARRAY[${Prisma.join(
|
||||
statuses
|
||||
)}]::"BatchTaskRunStatus"[]) AND b."batchVersion" <> 'v1'`
|
||||
: Prisma.empty
|
||||
}
|
||||
${
|
||||
periodMs
|
||||
? Prisma.sql`AND b."createdAt" >= NOW() - INTERVAL '1 millisecond' * ${periodMs}`
|
||||
: Prisma.empty
|
||||
}
|
||||
${
|
||||
from
|
||||
? Prisma.sql`AND b."createdAt" >= ${new Date(from).toISOString()}::timestamp`
|
||||
: Prisma.empty
|
||||
}
|
||||
${to ? Prisma.sql`AND b."createdAt" <= ${new Date(to).toISOString()}::timestamp` : Prisma.empty}
|
||||
ORDER BY
|
||||
${direction === "forward" ? Prisma.sql`b.id DESC` : Prisma.sql`b.id ASC`}
|
||||
LIMIT ${pageSize + 1}`;
|
||||
|
||||
const hasMore = batches.length > pageSize;
|
||||
|
||||
//get cursors for next and previous pages
|
||||
let next: string | undefined;
|
||||
let previous: string | undefined;
|
||||
switch (direction) {
|
||||
case "forward":
|
||||
previous = cursor ? batches.at(0)?.id : undefined;
|
||||
if (hasMore) {
|
||||
next = batches[pageSize - 1]?.id;
|
||||
}
|
||||
break;
|
||||
case "backward":
|
||||
batches.reverse();
|
||||
if (hasMore) {
|
||||
previous = batches[1]?.id;
|
||||
next = batches[pageSize]?.id;
|
||||
} else {
|
||||
next = batches[pageSize - 1]?.id;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const batchesToReturn =
|
||||
direction === "backward" && hasMore
|
||||
? batches.slice(1, pageSize + 1)
|
||||
: batches.slice(0, pageSize);
|
||||
|
||||
return {
|
||||
batches: batchesToReturn.map((batch) => {
|
||||
const environment = project.environments.find(
|
||||
(env) => env.id === batch.runtimeEnvironmentId
|
||||
);
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(`Environment not found for Batch ${batch.id}`);
|
||||
}
|
||||
|
||||
const hasFinished = batch.status === "COMPLETED";
|
||||
|
||||
return {
|
||||
id: batch.id,
|
||||
friendlyId: batch.friendlyId,
|
||||
createdAt: batch.createdAt.toISOString(),
|
||||
updatedAt: batch.updatedAt.toISOString(),
|
||||
hasFinished,
|
||||
finishedAt: hasFinished ? batch.updatedAt.toISOString() : undefined,
|
||||
status: batch.status,
|
||||
environment: displayableEnvironment(environment, userId),
|
||||
runCount: Number(batch.runCount),
|
||||
batchVersion: batch.batchVersion,
|
||||
};
|
||||
}),
|
||||
pagination: {
|
||||
next,
|
||||
previous,
|
||||
},
|
||||
filters: {
|
||||
friendlyId,
|
||||
statuses: statuses || [],
|
||||
environments: environments || [],
|
||||
from,
|
||||
to,
|
||||
},
|
||||
hasFilters,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ export type RunListOptions = {
|
||||
from?: number;
|
||||
to?: number;
|
||||
isTest?: boolean;
|
||||
rootOnly?: boolean;
|
||||
batchId?: string;
|
||||
runId?: string;
|
||||
//pagination
|
||||
direction?: Direction;
|
||||
cursor?: string;
|
||||
@@ -47,6 +50,9 @@ export class RunListPresenter extends BasePresenter {
|
||||
period,
|
||||
bulkId,
|
||||
isTest,
|
||||
rootOnly,
|
||||
batchId,
|
||||
runId,
|
||||
from,
|
||||
to,
|
||||
direction = "forward",
|
||||
@@ -66,7 +72,10 @@ export class RunListPresenter extends BasePresenter {
|
||||
to !== undefined ||
|
||||
(scheduleId !== undefined && scheduleId !== "") ||
|
||||
(tags !== undefined && tags.length > 0) ||
|
||||
typeof isTest === "boolean";
|
||||
batchId !== undefined ||
|
||||
runId !== undefined ||
|
||||
typeof isTest === "boolean" ||
|
||||
rootOnly === true;
|
||||
|
||||
// Find the project scoped to the organization
|
||||
const project = await this._replica.project.findFirstOrThrow({
|
||||
@@ -141,6 +150,43 @@ export class RunListPresenter extends BasePresenter {
|
||||
}
|
||||
}
|
||||
|
||||
//batch id is a friendly id
|
||||
if (batchId) {
|
||||
const batch = await this._replica.batchTaskRun.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
friendlyId: batchId,
|
||||
},
|
||||
});
|
||||
|
||||
if (batch) {
|
||||
batchId = batch.id;
|
||||
}
|
||||
}
|
||||
|
||||
//scheduleId can be a friendlyId
|
||||
if (scheduleId && scheduleId.startsWith("sched_")) {
|
||||
const schedule = await this._replica.taskSchedule.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
friendlyId: scheduleId,
|
||||
},
|
||||
});
|
||||
|
||||
if (schedule) {
|
||||
scheduleId = schedule?.id;
|
||||
}
|
||||
}
|
||||
|
||||
//show all runs if we are filtering by batchId or runId
|
||||
if (batchId || runId || scheduleId || tasks?.length) {
|
||||
rootOnly = false;
|
||||
}
|
||||
|
||||
const periodMs = period ? parse(period) : undefined;
|
||||
|
||||
//get the runs
|
||||
@@ -166,9 +212,10 @@ export class RunListPresenter extends BasePresenter {
|
||||
costInCents: number;
|
||||
baseCostInCents: number;
|
||||
usageDurationMs: BigInt;
|
||||
tags: string[];
|
||||
tags: null | string[];
|
||||
depth: number;
|
||||
rootTaskRunId: string | null;
|
||||
batchId: string | null;
|
||||
}[]
|
||||
>`
|
||||
SELECT
|
||||
@@ -194,15 +241,11 @@ export class RunListPresenter extends BasePresenter {
|
||||
tr."usageDurationMs" AS "usageDurationMs",
|
||||
tr."depth" AS "depth",
|
||||
tr."rootTaskRunId" AS "rootTaskRunId",
|
||||
array_remove(array_agg(tag.name), NULL) AS "tags"
|
||||
tr."runTags" AS "tags"
|
||||
FROM
|
||||
${sqlDatabaseSchema}."TaskRun" tr
|
||||
LEFT JOIN
|
||||
${sqlDatabaseSchema}."BackgroundWorker" bw ON tr."lockedToVersionId" = bw.id
|
||||
LEFT JOIN
|
||||
${sqlDatabaseSchema}."_TaskRunToTaskRunTag" trtg ON tr.id = trtg."A"
|
||||
LEFT JOIN
|
||||
${sqlDatabaseSchema}."TaskRunTag" tag ON trtg."B" = tag.id
|
||||
WHERE
|
||||
-- project
|
||||
tr."projectId" = ${project.id}
|
||||
@@ -215,6 +258,8 @@ WHERE
|
||||
: Prisma.empty
|
||||
}
|
||||
-- filters
|
||||
${runId ? Prisma.sql`AND tr."friendlyId" = ${runId}` : Prisma.empty}
|
||||
${batchId ? Prisma.sql`AND tr."batchId" = ${batchId}` : Prisma.empty}
|
||||
${
|
||||
restrictToRunIds
|
||||
? restrictToRunIds.length === 0
|
||||
@@ -248,26 +293,16 @@ WHERE
|
||||
from
|
||||
? Prisma.sql`AND tr."createdAt" >= ${new Date(from).toISOString()}::timestamp`
|
||||
: Prisma.empty
|
||||
}
|
||||
}
|
||||
${
|
||||
to ? Prisma.sql`AND tr."createdAt" <= ${new Date(to).toISOString()}::timestamp` : Prisma.empty
|
||||
}
|
||||
${
|
||||
tags && tags.length > 0
|
||||
? Prisma.sql`AND (
|
||||
tr.id IN (
|
||||
SELECT
|
||||
trtg."A"
|
||||
FROM
|
||||
${sqlDatabaseSchema}."_TaskRunToTaskRunTag" trtg
|
||||
JOIN
|
||||
${sqlDatabaseSchema}."TaskRunTag" tag ON trtg."B" = tag.id
|
||||
WHERE
|
||||
tag.name IN (${Prisma.join(tags)})
|
||||
)
|
||||
)`
|
||||
: Prisma.empty
|
||||
}
|
||||
}
|
||||
${
|
||||
tags && tags.length > 0
|
||||
? Prisma.sql`AND tr."runTags" && ARRAY[${Prisma.join(tags)}]::text[]`
|
||||
: Prisma.empty
|
||||
}
|
||||
${rootOnly === true ? Prisma.sql`AND tr."rootTaskRunId" IS NULL` : Prisma.empty}
|
||||
GROUP BY
|
||||
tr.id, bw.version
|
||||
ORDER BY
|
||||
@@ -336,7 +371,7 @@ WHERE
|
||||
costInCents: run.costInCents,
|
||||
baseCostInCents: run.baseCostInCents,
|
||||
usageDurationMs: Number(run.usageDurationMs),
|
||||
tags: run.tags.sort((a, b) => a.localeCompare(b)),
|
||||
tags: run.tags ? run.tags.sort((a, b) => a.localeCompare(b)) : [],
|
||||
depth: run.depth,
|
||||
rootTaskRunId: run.rootTaskRunId,
|
||||
};
|
||||
|
||||
@@ -149,6 +149,11 @@ export class SpanPresenter extends BasePresenter {
|
||||
spanId: true,
|
||||
},
|
||||
},
|
||||
batch: {
|
||||
select: {
|
||||
friendlyId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
spanId,
|
||||
@@ -312,6 +317,7 @@ export class SpanPresenter extends BasePresenter {
|
||||
context: JSON.stringify(context, null, 2),
|
||||
metadata,
|
||||
maxDurationInSeconds: getMaxDuration(run.maxDurationInSeconds),
|
||||
batch: run.batch ? { friendlyId: run.batch.friendlyId } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+502
-175
@@ -4,14 +4,21 @@ import {
|
||||
ChatBubbleLeftRightIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
LightBulbIcon,
|
||||
UserPlusIcon,
|
||||
VideoCameraIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { useRevalidator } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/node";
|
||||
import { Link, useRevalidator, useSubmit } from "@remix-run/react";
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { DiscordIcon } from "@trigger.dev/companyicons";
|
||||
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
|
||||
import { TaskRunStatus } from "@trigger.dev/database";
|
||||
import { Fragment, Suspense, useEffect, useState } from "react";
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, TooltipProps } from "recharts";
|
||||
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { TaskIcon } from "~/assets/icons/TaskIcon";
|
||||
import { Feedback } from "~/components/Feedback";
|
||||
import { InitCommandV3, TriggerDevStepV3, TriggerLoginStepV3 } from "~/components/SetupCommands";
|
||||
import { StepContentContainer } from "~/components/StepContentContainer";
|
||||
@@ -19,15 +26,22 @@ import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { EnvironmentLabels } from "~/components/environments/EnvironmentLabel";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { AnimatingArrow } from "~/components/primitives/AnimatingArrow";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { formatDateTime } from "~/components/primitives/DateTime";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "~/components/primitives/Dialog";
|
||||
import { Header1, Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { PopoverMenuItem } from "~/components/primitives/Popover";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "~/components/primitives/Resizable";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { StepNumber } from "~/components/primitives/StepNumber";
|
||||
import {
|
||||
@@ -53,10 +67,16 @@ import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useTextFilter } from "~/hooks/useTextFilter";
|
||||
import { Task, TaskActivity, TaskListPresenter } from "~/presenters/v3/TaskListPresenter.server";
|
||||
import {
|
||||
getUsefulLinksPreference,
|
||||
setUsefulLinksPreference,
|
||||
uiPreferencesStorage,
|
||||
} from "~/services/preferences/uiPreferences.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
docsPath,
|
||||
inviteTeamMemberPath,
|
||||
ProjectParamSchema,
|
||||
v3RunsPath,
|
||||
v3TasksStreamingPath,
|
||||
@@ -76,12 +96,15 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
projectSlug: projectParam,
|
||||
});
|
||||
|
||||
const usefulLinksPreference = await getUsefulLinksPreference(request);
|
||||
|
||||
return typeddefer({
|
||||
tasks,
|
||||
userHasTasks,
|
||||
activity,
|
||||
runningStats,
|
||||
durations,
|
||||
usefulLinksPreference,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -92,10 +115,26 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
}
|
||||
};
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const formData = await request.formData();
|
||||
const showUsefulLinks = formData.get("showUsefulLinks") === "true";
|
||||
|
||||
const session = await setUsefulLinksPreference(showUsefulLinks, request);
|
||||
|
||||
return json(
|
||||
{ success: true },
|
||||
{
|
||||
headers: {
|
||||
"Set-Cookie": await uiPreferencesStorage.commitSession(session),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const { tasks, userHasTasks, activity, runningStats, durations } =
|
||||
const { tasks, userHasTasks, activity, runningStats, durations, usefulLinksPreference } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const { filterText, setFilterText, filteredItems } = useTextFilter<Task>({
|
||||
items: tasks,
|
||||
@@ -137,6 +176,16 @@ export default function Page() {
|
||||
// WARNING Don't put the revalidator in the useEffect deps array or bad things will happen
|
||||
}, [streamedEvents]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const [showUsefulLinks, setShowUsefulLinks] = useState(usefulLinksPreference ?? true);
|
||||
|
||||
// Create a submit handler to save the preference
|
||||
const submit = useSubmit();
|
||||
|
||||
const handleUsefulLinksToggle = (show: boolean) => {
|
||||
setShowUsefulLinks(show);
|
||||
submit({ showUsefulLinks: show.toString() }, { method: "post" });
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
@@ -168,183 +217,213 @@ export default function Page() {
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<div className={cn("grid h-full grid-rows-1")}>
|
||||
{hasTasks ? (
|
||||
<div className="flex flex-col">
|
||||
{!userHasTasks && <UserHasNoTasks />}
|
||||
<div className="max-h-full overflow-hidden">
|
||||
<div className="p-2">
|
||||
<Input
|
||||
placeholder="Search tasks"
|
||||
variant="tertiary"
|
||||
icon="search"
|
||||
fullWidth={true}
|
||||
value={filterText}
|
||||
onChange={(e) => setFilterText(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<Table containerClassName="max-h-full mb-[2.5rem]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Task ID</TableHeaderCell>
|
||||
<TableHeaderCell>Task</TableHeaderCell>
|
||||
<TableHeaderCell>Running</TableHeaderCell>
|
||||
<TableHeaderCell>Queued</TableHeaderCell>
|
||||
<TableHeaderCell>Activity (7d)</TableHeaderCell>
|
||||
<TableHeaderCell>Avg. duration</TableHeaderCell>
|
||||
<TableHeaderCell>Environments</TableHeaderCell>
|
||||
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredItems.length > 0 ? (
|
||||
filteredItems.map((task) => {
|
||||
const path = v3RunsPath(organization, project, {
|
||||
tasks: [task.slug],
|
||||
});
|
||||
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
|
||||
<ResizablePanel id="tasks-main" className="max-h-full">
|
||||
<div className={cn("grid h-full grid-rows-1")}>
|
||||
{hasTasks ? (
|
||||
<div className="flex min-w-0 max-w-full flex-col">
|
||||
{!userHasTasks && <UserHasNoTasks />}
|
||||
<div className="max-h-full overflow-hidden">
|
||||
<div className="flex items-center p-2">
|
||||
<Input
|
||||
placeholder="Search tasks"
|
||||
variant="tertiary"
|
||||
icon="search"
|
||||
fullWidth={true}
|
||||
value={filterText}
|
||||
onChange={(e) => setFilterText(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
{!showUsefulLinks && (
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
TrailingIcon={LightBulbIcon}
|
||||
onClick={() => handleUsefulLinksToggle(true)}
|
||||
className="px-2.5"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Table containerClassName="max-h-full pb-[2.5rem]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Task ID</TableHeaderCell>
|
||||
<TableHeaderCell>Task</TableHeaderCell>
|
||||
<TableHeaderCell>Running</TableHeaderCell>
|
||||
<TableHeaderCell>Queued</TableHeaderCell>
|
||||
<TableHeaderCell>Activity (7d)</TableHeaderCell>
|
||||
<TableHeaderCell>Avg. duration</TableHeaderCell>
|
||||
<TableHeaderCell>Environments</TableHeaderCell>
|
||||
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredItems.length > 0 ? (
|
||||
filteredItems.map((task) => {
|
||||
const path = v3RunsPath(organization, project, {
|
||||
tasks: [task.slug],
|
||||
});
|
||||
|
||||
const devYouEnvironment = task.environments.find(
|
||||
(e) => e.type === "DEVELOPMENT" && !e.userName
|
||||
);
|
||||
const firstDeployedEnvironment = task.environments
|
||||
.filter((e) => e.type !== "DEVELOPMENT")
|
||||
.at(0);
|
||||
const testEnvironment = devYouEnvironment ?? firstDeployedEnvironment;
|
||||
const devYouEnvironment = task.environments.find(
|
||||
(e) => e.type === "DEVELOPMENT" && !e.userName
|
||||
);
|
||||
const firstDeployedEnvironment = task.environments
|
||||
.filter((e) => e.type !== "DEVELOPMENT")
|
||||
.at(0);
|
||||
const testEnvironment = devYouEnvironment ?? firstDeployedEnvironment;
|
||||
|
||||
const testPath = testEnvironment
|
||||
? v3TestTaskPath(
|
||||
organization,
|
||||
project,
|
||||
{ taskIdentifier: task.slug },
|
||||
testEnvironment.slug
|
||||
)
|
||||
: v3TestPath(organization, project);
|
||||
const testPath = testEnvironment
|
||||
? v3TestTaskPath(
|
||||
organization,
|
||||
project,
|
||||
{ taskIdentifier: task.slug },
|
||||
testEnvironment.slug
|
||||
)
|
||||
: v3TestPath(organization, project);
|
||||
|
||||
return (
|
||||
<TableRow key={task.slug} className="group">
|
||||
<TableCell to={path}>
|
||||
<div className="flex items-center gap-2">
|
||||
<SimpleTooltip
|
||||
button={<TaskTriggerSourceIcon source={task.triggerSource} />}
|
||||
content={taskTriggerSourceDescription(task.triggerSource)}
|
||||
/>
|
||||
<span>{task.slug}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell to={path} className="py-0" actionClassName="py-0">
|
||||
<TaskFunctionName
|
||||
functionName={task.exportName}
|
||||
variant="extra-extra-small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell to={path} className="p-0">
|
||||
<Suspense
|
||||
fallback={
|
||||
<>
|
||||
<Spinner color="muted" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TypedAwait resolve={runningStats}>
|
||||
{(data) => {
|
||||
const taskData = data[task.slug];
|
||||
return taskData?.running ?? "0";
|
||||
}}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</TableCell>
|
||||
<TableCell to={path} className="p-0">
|
||||
<Suspense fallback={<></>}>
|
||||
<TypedAwait resolve={runningStats}>
|
||||
{(data) => {
|
||||
const taskData = data[task.slug];
|
||||
return taskData?.queued ?? "0";
|
||||
}}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</TableCell>
|
||||
<TableCell to={path} className="p-0" actionClassName="py-0">
|
||||
<Suspense fallback={<TaskActivityBlankState />}>
|
||||
<TypedAwait resolve={activity}>
|
||||
{(data) => {
|
||||
const taskData = data[task.slug];
|
||||
return (
|
||||
<>
|
||||
{taskData !== undefined ? (
|
||||
<div className="h-6 w-[5.125rem] rounded-sm">
|
||||
<TaskActivityGraph activity={taskData} />
|
||||
</div>
|
||||
) : (
|
||||
<TaskActivityBlankState />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</TableCell>
|
||||
<TableCell to={path} className="p-0">
|
||||
<Suspense fallback={<></>}>
|
||||
<TypedAwait resolve={durations}>
|
||||
{(data) => {
|
||||
const taskData = data[task.slug];
|
||||
return taskData
|
||||
? formatDurationMilliseconds(taskData * 1000, {
|
||||
style: "short",
|
||||
})
|
||||
: "–";
|
||||
}}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<EnvironmentLabels environments={task.environments} />
|
||||
</TableCell>
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
popoverContent={
|
||||
<>
|
||||
<PopoverMenuItem
|
||||
icon="runs"
|
||||
to={path}
|
||||
title="View runs"
|
||||
leadingIconClassName="text-teal-500"
|
||||
return (
|
||||
<TableRow key={task.slug} className="group">
|
||||
<TableCell to={path}>
|
||||
<div className="flex items-center gap-2">
|
||||
<SimpleTooltip
|
||||
button={<TaskTriggerSourceIcon source={task.triggerSource} />}
|
||||
content={taskTriggerSourceDescription(task.triggerSource)}
|
||||
/>
|
||||
<span>{task.slug}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell to={path} className="py-0" actionClassName="py-0">
|
||||
<TaskFunctionName
|
||||
functionName={task.exportName}
|
||||
variant="extra-extra-small"
|
||||
/>
|
||||
<PopoverMenuItem icon="beaker" to={testPath} title="Test task" />
|
||||
</>
|
||||
}
|
||||
hiddenButtons={
|
||||
<LinkButton
|
||||
variant="minimal/small"
|
||||
LeadingIcon={BeakerIcon}
|
||||
leadingIconClassName="text-text-bright"
|
||||
to={testPath}
|
||||
>
|
||||
Test
|
||||
</LinkButton>
|
||||
}
|
||||
/>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableBlankRow colSpan={8}>
|
||||
<Paragraph variant="small" className="flex items-center justify-center">
|
||||
No tasks match your filters
|
||||
</Paragraph>
|
||||
</TableBlankRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell to={path} className="p-0">
|
||||
<Suspense
|
||||
fallback={
|
||||
<>
|
||||
<Spinner color="muted" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TypedAwait resolve={runningStats}>
|
||||
{(data) => {
|
||||
const taskData = data[task.slug];
|
||||
return taskData?.running ?? "0";
|
||||
}}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</TableCell>
|
||||
<TableCell to={path} className="p-0">
|
||||
<Suspense fallback={<></>}>
|
||||
<TypedAwait resolve={runningStats}>
|
||||
{(data) => {
|
||||
const taskData = data[task.slug];
|
||||
return taskData?.queued ?? "0";
|
||||
}}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</TableCell>
|
||||
<TableCell to={path} className="p-0" actionClassName="py-0">
|
||||
<Suspense fallback={<TaskActivityBlankState />}>
|
||||
<TypedAwait resolve={activity}>
|
||||
{(data) => {
|
||||
const taskData = data[task.slug];
|
||||
return (
|
||||
<>
|
||||
{taskData !== undefined ? (
|
||||
<div className="h-6 w-[5.125rem] rounded-sm">
|
||||
<TaskActivityGraph activity={taskData} />
|
||||
</div>
|
||||
) : (
|
||||
<TaskActivityBlankState />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</TableCell>
|
||||
<TableCell to={path} className="p-0">
|
||||
<Suspense fallback={<></>}>
|
||||
<TypedAwait resolve={durations}>
|
||||
{(data) => {
|
||||
const taskData = data[task.slug];
|
||||
return taskData
|
||||
? formatDurationMilliseconds(taskData * 1000, {
|
||||
style: "short",
|
||||
})
|
||||
: "–";
|
||||
}}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<EnvironmentLabels environments={task.environments} />
|
||||
</TableCell>
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
popoverContent={
|
||||
<>
|
||||
<PopoverMenuItem
|
||||
icon="runs"
|
||||
to={path}
|
||||
title="View runs"
|
||||
leadingIconClassName="text-teal-500"
|
||||
/>
|
||||
<PopoverMenuItem
|
||||
icon="beaker"
|
||||
to={testPath}
|
||||
title="Test task"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
hiddenButtons={
|
||||
<LinkButton
|
||||
variant="minimal/small"
|
||||
LeadingIcon={BeakerIcon}
|
||||
leadingIconClassName="text-text-bright"
|
||||
to={testPath}
|
||||
>
|
||||
Test
|
||||
</LinkButton>
|
||||
}
|
||||
/>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableBlankRow colSpan={8}>
|
||||
<Paragraph variant="small" className="flex items-center justify-center">
|
||||
No tasks match your filters
|
||||
</Paragraph>
|
||||
</TableBlankRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<MainCenteredContainer className="max-w-prose">
|
||||
<CreateTaskInstructions />
|
||||
</MainCenteredContainer>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<MainCenteredContainer className="max-w-prose">
|
||||
<CreateTaskInstructions />
|
||||
</MainCenteredContainer>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
{hasTasks && showUsefulLinks ? (
|
||||
<>
|
||||
<ResizableHandle id="tasks-handle" />
|
||||
<ResizablePanel
|
||||
id="tasks-inspector"
|
||||
min="200px"
|
||||
default="400px"
|
||||
max="500px"
|
||||
className="w-full"
|
||||
>
|
||||
<HelpfulInfoHasTasks onClose={() => handleUsefulLinksToggle(false)} />
|
||||
</ResizablePanel>
|
||||
</>
|
||||
) : null}
|
||||
</ResizablePanelGroup>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
@@ -537,3 +616,251 @@ const CustomTooltip = ({ active, payload, label }: TooltipProps<number, string>)
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
function HelpfulInfoHasTasks({ onClose }: { onClose: () => void }) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const [isVideoDialogOpen, setIsVideoDialogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden bg-background-bright">
|
||||
<div className="overflow-y-scroll p-3 pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<div className="mb-2 flex items-center justify-between gap-2 border-b border-grid-dimmed pb-2">
|
||||
<Header2 className="flex items-center gap-2">
|
||||
<LightBulbIcon className="size-4 min-w-4 text-sun-500" />
|
||||
Helpful next steps
|
||||
</Header2>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="minimal/small"
|
||||
TrailingIcon={ExitIcon}
|
||||
shortcut={{ key: "esc" }}
|
||||
shortcutPosition="before-trailing-icon"
|
||||
className="pl-[0.375rem]"
|
||||
/>
|
||||
</div>
|
||||
<LinkWithIcon
|
||||
variant="withIcon"
|
||||
to={v3TestPath(organization, project)}
|
||||
description="Test your tasks"
|
||||
icon={<BeakerIcon className="size-5 text-lime-500" />}
|
||||
/>
|
||||
<LinkWithIcon
|
||||
variant="withIcon"
|
||||
to={inviteTeamMemberPath(organization)}
|
||||
description="Invite team members"
|
||||
icon={<UserPlusIcon className="size-5 text-amber-500" />}
|
||||
/>
|
||||
<div
|
||||
role="button"
|
||||
onClick={() => setIsVideoDialogOpen(true)}
|
||||
className={cn(
|
||||
"group flex w-full items-center justify-between gap-2 rounded-md p-1 pr-3 transition hover:bg-charcoal-750",
|
||||
variants["withIcon"].container
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={variants["withIcon"].iconContainer}>
|
||||
<VideoCameraIcon className="size-5 text-rose-500" />
|
||||
</div>
|
||||
<Paragraph variant="base" className="transition-colors group-hover:text-text-bright">
|
||||
Watch a 14 min walkthrough video
|
||||
</Paragraph>
|
||||
</div>
|
||||
<AnimatingArrow direction="right" theme="dimmed" />
|
||||
</div>
|
||||
<LinkWithIcon
|
||||
variant="withIcon"
|
||||
to="https://trigger.dev/discord"
|
||||
description="Join our Discord for help and support"
|
||||
icon={<DiscordIcon className="size-5" />}
|
||||
isExternal
|
||||
/>
|
||||
<div className="mb-2 flex items-center gap-2 border-b border-grid-dimmed pb-2 pt-6">
|
||||
<Header2 className="flex items-center gap-2">
|
||||
<BookOpenIcon className="size-5 text-blue-500" />
|
||||
From the docs
|
||||
</Header2>
|
||||
</div>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/writing-tasks-introduction")}
|
||||
description="How to write a task"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/tasks/scheduled")}
|
||||
description="Scheduled tasks (cron)"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon to={docsPath("/triggering")} description="How to trigger a task" isExternal />
|
||||
<LinkWithIcon to={docsPath("/cli-dev")} description="Running the CLI" isExternal />
|
||||
<LinkWithIcon
|
||||
to={docsPath("/how-it-works")}
|
||||
description="How Trigger.dev works"
|
||||
isExternal
|
||||
/>
|
||||
<div className="mb-2 flex items-center gap-2 border-b border-grid-dimmed pb-2 pt-6">
|
||||
<Header2 className="flex items-center gap-2">
|
||||
<TaskIcon className="size-4 text-blue-500" />
|
||||
Example tasks
|
||||
</Header2>
|
||||
</div>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/dall-e3-generate-image")}
|
||||
description="DALL·E 3 image generation"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/deepgram-transcribe-audio")}
|
||||
description="Deepgram audio transcription"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/fal-ai-image-to-cartoon")}
|
||||
description="Fal.ai image to cartoon"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/fal-ai-realtime")}
|
||||
description="Fal.ai with Realtime"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/ffmpeg-video-processing")}
|
||||
description="FFmpeg video processing"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/firecrawl-url-crawl")}
|
||||
description="Firecrawl URL crawl"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/libreoffice-pdf-conversion")}
|
||||
description="LibreOffice PDF conversion"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/open-ai-with-retrying")}
|
||||
description="OpenAI with retrying"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/pdf-to-image")}
|
||||
description="PDF to image"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon to={docsPath("/examples/puppeteer")} description="Puppeteer" isExternal />
|
||||
<LinkWithIcon to={docsPath("/examples/react-pdf")} description="React to PDF" isExternal />
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/resend-email-sequence")}
|
||||
description="Resend email sequence"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/scrape-hacker-news")}
|
||||
description="Scrape Hacker News"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/sentry-error-tracking")}
|
||||
description="Sentry error tracking"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/sharp-image-processing")}
|
||||
description="Sharp image processing"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/supabase-database-operations")}
|
||||
description="Supabase database operations"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/supabase-storage-upload")}
|
||||
description="Supabase Storage upload"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/vercel-ai-sdk")}
|
||||
description="Vercel AI SDK"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/vercel-sync-env-vars")}
|
||||
description="Vercel sync environment variables"
|
||||
isExternal
|
||||
/>
|
||||
</div>
|
||||
<Dialog open={isVideoDialogOpen} onOpenChange={setIsVideoDialogOpen}>
|
||||
<DialogContent className="sm:max-w-screen-lg">
|
||||
<DialogHeader className="mb-4 pt-1">
|
||||
<DialogTitle>Trigger.dev walkthrough</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="aspect-video">
|
||||
<iframe
|
||||
width="100%"
|
||||
height="100%"
|
||||
src="https://www.youtube.com/embed/YH_4c0K7fGM?si=BcX6MAt_V139sRw9"
|
||||
title="Trigger.dev walkthrough"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const variants = {
|
||||
withIcon: {
|
||||
container: "",
|
||||
iconContainer:
|
||||
"grid size-9 min-w-9 place-items-center rounded border border-transparent bg-charcoal-750 shadow transition group-hover:border-charcoal-650",
|
||||
},
|
||||
minimal: {
|
||||
container: "pl-3 py-2",
|
||||
iconContainer: "",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type LinkWithIconProps = {
|
||||
to: string;
|
||||
description: string;
|
||||
icon?: React.ReactNode;
|
||||
isExternal?: boolean;
|
||||
variant?: keyof typeof variants;
|
||||
};
|
||||
|
||||
function LinkWithIcon({
|
||||
to,
|
||||
description,
|
||||
icon,
|
||||
isExternal,
|
||||
variant = "minimal",
|
||||
}: LinkWithIconProps) {
|
||||
const variation = variants[variant];
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
target={isExternal ? "_blank" : undefined}
|
||||
rel={isExternal ? "noreferrer" : undefined}
|
||||
className={cn(
|
||||
"group flex w-full items-center justify-between gap-2 rounded-md p-1 pr-3 transition hover:bg-charcoal-750",
|
||||
variation.container
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{variant === "withIcon" && icon && <div className={variation.iconContainer}>{icon}</div>}
|
||||
<Paragraph variant="base" className="transition-colors group-hover:text-text-bright">
|
||||
{description}
|
||||
</Paragraph>
|
||||
</div>
|
||||
<AnimatingArrow direction={isExternal ? "topRight" : "right"} theme="dimmed" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
+23
-11
@@ -23,6 +23,7 @@ import { Label } from "~/components/primitives/Label";
|
||||
import SegmentedControl from "~/components/primitives/SegmentedControl";
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
|
||||
import { env } from "~/env.server";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
@@ -150,9 +151,13 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const url = new URL(request.url);
|
||||
const option = url.searchParams.get("option");
|
||||
|
||||
const emailAlertsEnabled =
|
||||
env.ALERT_FROM_EMAIL !== undefined && env.ALERT_RESEND_API_KEY !== undefined;
|
||||
|
||||
return typedjson({
|
||||
...results,
|
||||
option: option === "slack" ? ("SLACK" as const) : undefined,
|
||||
emailAlertsEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -200,7 +205,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
|
||||
export default function Page() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { slack, option } = useTypedLoaderData<typeof loader>();
|
||||
const { slack, option, emailAlertsEnabled } = useTypedLoaderData<typeof loader>();
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
const navigate = useNavigate();
|
||||
@@ -271,16 +276,23 @@ export default function Page() {
|
||||
</InputGroup>
|
||||
|
||||
{currentAlertChannel === "EMAIL" ? (
|
||||
<InputGroup fullWidth>
|
||||
<Label>Email</Label>
|
||||
<Input
|
||||
{...conform.input(channelValue)}
|
||||
placeholder="email@youremail.com"
|
||||
type="email"
|
||||
autoFocus
|
||||
/>
|
||||
<FormError id={channelValue.errorId}>{channelValue.error}</FormError>
|
||||
</InputGroup>
|
||||
emailAlertsEnabled ? (
|
||||
<InputGroup fullWidth>
|
||||
<Label>Email</Label>
|
||||
<Input
|
||||
{...conform.input(channelValue)}
|
||||
placeholder="email@youremail.com"
|
||||
type="email"
|
||||
autoFocus
|
||||
/>
|
||||
<FormError id={channelValue.errorId}>{channelValue.error}</FormError>
|
||||
</InputGroup>
|
||||
) : (
|
||||
<Callout variant="warning">
|
||||
Email integration is not available. Please contact your organization
|
||||
administrator.
|
||||
</Callout>
|
||||
)
|
||||
) : currentAlertChannel === "SLACK" ? (
|
||||
<InputGroup fullWidth>
|
||||
{slack.status === "READY" ? (
|
||||
|
||||
+12
-15
@@ -171,12 +171,7 @@ export default function Page() {
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<div
|
||||
className={cn(
|
||||
"grid max-h-full min-h-full",
|
||||
alertChannels.length === 0 ? "grid-rows-[1fr_auto]" : "grid-rows-[auto_1fr_auto]"
|
||||
)}
|
||||
>
|
||||
<div className="grid max-h-full min-h-full grid-rows-[auto_1fr_auto]">
|
||||
<div className="flex h-fit items-end justify-between p-2 pl-3">
|
||||
<Header2 className="">Project alerts</Header2>
|
||||
{alertChannels.length > 0 && !requiresUpgrade && (
|
||||
@@ -190,7 +185,7 @@ export default function Page() {
|
||||
</LinkButton>
|
||||
)}
|
||||
</div>
|
||||
<Table containerClassName={cn(alertChannels.length === 0 && "border-t-0")}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
@@ -234,18 +229,19 @@ export default function Page() {
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
popoverContent={
|
||||
alertChannel.enabled ? (
|
||||
<DisableAlertChannelButton id={alertChannel.id} />
|
||||
) : (
|
||||
<EnableAlertChannelButton id={alertChannel.id} />
|
||||
)
|
||||
<>
|
||||
{alertChannel.enabled ? (
|
||||
<DisableAlertChannelButton id={alertChannel.id} />
|
||||
) : (
|
||||
<EnableAlertChannelButton id={alertChannel.id} />
|
||||
)}
|
||||
<DeleteAlertChannelButton id={alertChannel.id} />
|
||||
</>
|
||||
}
|
||||
className={
|
||||
alertChannel.enabled ? "" : "group-hover/table-row:bg-charcoal-800/50"
|
||||
}
|
||||
>
|
||||
<DeleteAlertChannelButton id={alertChannel.id} />
|
||||
</TableCellMenu>
|
||||
/>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
@@ -356,6 +352,7 @@ function DeleteAlertChannelButton(props: { id: string }) {
|
||||
name="action"
|
||||
value="delete"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
type="submit"
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={TrashIcon}
|
||||
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
import { ExclamationCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { BookOpenIcon } from "@heroicons/react/24/solid";
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { formatDuration } from "@trigger.dev/core/v3/utils/durations";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { ListPagination } from "~/components/ListPagination";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import {
|
||||
Table,
|
||||
TableBlankRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { BatchFilters, BatchListFilters } from "~/components/runs/v3/BatchFilters";
|
||||
import {
|
||||
allBatchStatuses,
|
||||
BatchStatusCombo,
|
||||
descriptionForBatchStatus,
|
||||
} from "~/components/runs/v3/BatchStatus";
|
||||
import { LiveTimer } from "~/components/runs/v3/LiveTimer";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { BatchList, BatchListPresenter } from "~/presenters/v3/BatchListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { docsPath, ProjectParamSchema, v3BatchRunsPath } from "~/utils/pathBuilder";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug } = ProjectParamSchema.parse(params);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const s = {
|
||||
cursor: url.searchParams.get("cursor") ?? undefined,
|
||||
direction: url.searchParams.get("direction") ?? undefined,
|
||||
environments: url.searchParams.getAll("environments"),
|
||||
statuses: url.searchParams.getAll("statuses"),
|
||||
period: url.searchParams.get("period") ?? undefined,
|
||||
from: url.searchParams.get("from") ?? undefined,
|
||||
to: url.searchParams.get("to") ?? undefined,
|
||||
id: url.searchParams.get("id") ?? undefined,
|
||||
};
|
||||
const filters = BatchListFilters.parse(s);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
|
||||
if (!project) {
|
||||
return redirectWithErrorMessage("/", request, "Project not found");
|
||||
}
|
||||
|
||||
const presenter = new BatchListPresenter();
|
||||
const list = await presenter.call({
|
||||
userId,
|
||||
projectId: project.id,
|
||||
...filters,
|
||||
friendlyId: filters.id,
|
||||
});
|
||||
|
||||
return typedjson(list);
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { batches, hasFilters, filters, pagination } = useTypedLoaderData<typeof loader>();
|
||||
const project = useProject();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Batches" />
|
||||
<PageAccessories>
|
||||
<AdminDebugTooltip />
|
||||
|
||||
<LinkButton
|
||||
variant={"docs/small"}
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("/triggering")}
|
||||
>
|
||||
Batches docs
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-x-2 p-2">
|
||||
<BatchFilters possibleEnvironments={project.environments} hasFilters={hasFilters} />
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<ListPagination list={{ pagination }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BatchesTable
|
||||
batches={batches}
|
||||
filters={filters}
|
||||
hasFilters={hasFilters}
|
||||
pagination={pagination}
|
||||
/>
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function BatchesTable({ batches, hasFilters, filters }: BatchList) {
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state !== "idle";
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
|
||||
return (
|
||||
<Table className="max-h-full overflow-y-auto">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>ID</TableHeaderCell>
|
||||
<TableHeaderCell>Env</TableHeaderCell>
|
||||
<TableHeaderCell
|
||||
tooltip={
|
||||
<div className="flex flex-col divide-y divide-grid-dimmed">
|
||||
{allBatchStatuses.map((status) => (
|
||||
<div
|
||||
key={status}
|
||||
className="grid grid-cols-[8rem_1fr] gap-x-2 py-2 first:pt-1 last:pb-1"
|
||||
>
|
||||
<div className="mb-0.5 flex items-center gap-1.5 whitespace-nowrap">
|
||||
<BatchStatusCombo status={status} />
|
||||
</div>
|
||||
<Paragraph variant="extra-small" className="!text-wrap text-text-dimmed">
|
||||
{descriptionForBatchStatus(status)}
|
||||
</Paragraph>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
Status
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>Runs</TableHeaderCell>
|
||||
<TableHeaderCell>Duration</TableHeaderCell>
|
||||
<TableHeaderCell>Created</TableHeaderCell>
|
||||
<TableHeaderCell>Finished</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{batches.length === 0 && !hasFilters ? (
|
||||
<TableBlankRow colSpan={7}>
|
||||
{!isLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<Paragraph className="w-auto">No batches</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
</TableBlankRow>
|
||||
) : batches.length === 0 ? (
|
||||
<TableBlankRow colSpan={7}>
|
||||
<div className="flex items-center justify-center">
|
||||
<Paragraph className="w-auto">No batches match these filters</Paragraph>
|
||||
</div>
|
||||
</TableBlankRow>
|
||||
) : (
|
||||
batches.map((batch, index) => {
|
||||
const path = v3BatchRunsPath(organization, project, batch);
|
||||
return (
|
||||
<TableRow key={batch.id}>
|
||||
<TableCell to={path}>{batch.friendlyId}</TableCell>
|
||||
<TableCell to={path}>
|
||||
<EnvironmentLabel
|
||||
environment={batch.environment}
|
||||
userName={batch.environment.userName}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
{batch.batchVersion === "v1" ? (
|
||||
<SimpleTooltip
|
||||
content="Upgrade to the latest SDK for batch statuses to appear."
|
||||
disableHoverableContent
|
||||
button={
|
||||
<span className="flex items-center gap-1">
|
||||
<ExclamationCircleIcon className="size-4 text-slate-500" />
|
||||
<span>Legacy batch</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<SimpleTooltip
|
||||
content={descriptionForBatchStatus(batch.status)}
|
||||
disableHoverableContent
|
||||
button={<BatchStatusCombo status={batch.status} />}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell to={path}>{batch.runCount}</TableCell>
|
||||
<TableCell to={path} className="w-[1%]" actionClassName="pr-0 tabular-nums">
|
||||
{batch.finishedAt ? (
|
||||
formatDuration(new Date(batch.createdAt), new Date(batch.finishedAt), {
|
||||
style: "short",
|
||||
})
|
||||
) : (
|
||||
<LiveTimer startTime={new Date(batch.createdAt)} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<DateTime date={batch.createdAt} />
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
{batch.finishedAt ? <DateTime date={batch.finishedAt} /> : "–"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{isLoading && (
|
||||
<TableBlankRow
|
||||
colSpan={7}
|
||||
className="absolute left-0 top-0 flex h-full w-full items-center justify-center gap-2 bg-charcoal-900/90"
|
||||
>
|
||||
<Spinner /> <span className="text-text-dimmed">Loading…</span>
|
||||
</TableBlankRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
+6
-1
@@ -46,6 +46,7 @@ import {
|
||||
DeploymentListPresenter,
|
||||
} from "~/presenters/v3/DeploymentListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
ProjectParamSchema,
|
||||
docsPath,
|
||||
@@ -139,8 +140,12 @@ export default function Page() {
|
||||
deployment,
|
||||
currentPage
|
||||
);
|
||||
const isSelected = deploymentParam === deployment.shortCode;
|
||||
return (
|
||||
<TableRow key={deployment.id} className="group">
|
||||
<TableRow
|
||||
key={deployment.id}
|
||||
className={cn("group", isSelected ? "bg-grid-dimmed" : undefined)}
|
||||
>
|
||||
<TableCell to={path}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Paragraph variant="extra-small">{deployment.shortCode}</Paragraph>
|
||||
|
||||
+43
-6
@@ -35,9 +35,13 @@ import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
|
||||
import { BULK_ACTION_RUN_LIMIT } from "~/consts";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { RunListPresenter } from "~/presenters/v3/RunListPresenter.server";
|
||||
import {
|
||||
getRootOnlyFilterPreference,
|
||||
setRootOnlyFilterPreference,
|
||||
uiPreferencesStorage,
|
||||
} from "~/services/preferences/uiPreferences.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
@@ -54,6 +58,14 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { projectParam, organizationSlug } = ProjectParamSchema.parse(params);
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
let rootOnlyValue = false;
|
||||
if (url.searchParams.has("rootOnly")) {
|
||||
rootOnlyValue = url.searchParams.get("rootOnly") === "true";
|
||||
} else {
|
||||
rootOnlyValue = await getRootOnlyFilterPreference(request);
|
||||
}
|
||||
|
||||
const s = {
|
||||
cursor: url.searchParams.get("cursor") ?? undefined,
|
||||
direction: url.searchParams.get("direction") ?? undefined,
|
||||
@@ -63,6 +75,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
period: url.searchParams.get("period") ?? undefined,
|
||||
bulkId: url.searchParams.get("bulkId") ?? undefined,
|
||||
tags: url.searchParams.getAll("tags").map((t) => decodeURIComponent(t)),
|
||||
from: url.searchParams.get("from") ?? undefined,
|
||||
to: url.searchParams.get("to") ?? undefined,
|
||||
rootOnly: rootOnlyValue,
|
||||
runId: url.searchParams.get("runId") ?? undefined,
|
||||
batchId: url.searchParams.get("batchId") ?? undefined,
|
||||
scheduleId: url.searchParams.get("scheduleId") ?? undefined,
|
||||
};
|
||||
const {
|
||||
tasks,
|
||||
@@ -76,6 +94,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
to,
|
||||
cursor,
|
||||
direction,
|
||||
rootOnly,
|
||||
runId,
|
||||
batchId,
|
||||
scheduleId,
|
||||
} = TaskRunListSearchFilters.parse(s);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
@@ -97,21 +119,35 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
bulkId,
|
||||
from,
|
||||
to,
|
||||
batchId,
|
||||
runId,
|
||||
scheduleId,
|
||||
rootOnly,
|
||||
direction: direction,
|
||||
cursor: cursor,
|
||||
});
|
||||
|
||||
return typeddefer({
|
||||
data: list,
|
||||
});
|
||||
const session = await setRootOnlyFilterPreference(rootOnlyValue, request);
|
||||
const cookieValue = await uiPreferencesStorage.commitSession(session);
|
||||
|
||||
return typeddefer(
|
||||
{
|
||||
data: list,
|
||||
rootOnlyDefault: rootOnlyValue,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Set-Cookie": cookieValue,
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { data } = useTypedLoaderData<typeof loader>();
|
||||
const { data, rootOnlyDefault } = useTypedLoaderData<typeof loader>();
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state !== "idle";
|
||||
const project = useProject();
|
||||
const user = useUser();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -170,6 +206,7 @@ export default function Page() {
|
||||
possibleTasks={list.possibleTasks}
|
||||
bulkActions={list.bulkActions}
|
||||
hasFilters={list.hasFilters}
|
||||
rootOnlyDefault={rootOnlyDefault}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<ListPagination list={list} />
|
||||
|
||||
+3
-2
@@ -216,9 +216,10 @@ export default function Page() {
|
||||
<Header2 className={cn("whitespace-nowrap")}>{schedule.friendlyId}</Header2>
|
||||
<LinkButton
|
||||
to={`${v3SchedulesPath(organization, project)}${location.search}`}
|
||||
variant="minimal/medium"
|
||||
LeadingIcon={ExitIcon}
|
||||
variant="minimal/small"
|
||||
TrailingIcon={ExitIcon}
|
||||
shortcut={{ key: "esc" }}
|
||||
shortcutPosition="before-trailing-icon"
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-y-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
|
||||
+15
-2
@@ -201,9 +201,22 @@ export default function Page() {
|
||||
possibleEnvironments={possibleEnvironments}
|
||||
possibleTasks={possibleTasks}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<PaginationControls
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
showPageNumbers={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-fit max-h-full overflow-x-auto">
|
||||
<SchedulesTable schedules={schedules} hasFilters={hasFilters} />
|
||||
<div className="flex justify-end py-3">
|
||||
<PaginationControls currentPage={currentPage} totalPages={totalPages} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SchedulesTable schedules={schedules} hasFilters={hasFilters} />
|
||||
<div className="flex w-full items-start justify-between">
|
||||
<div className="flex h-fit w-full items-center gap-4 border-t border-grid-bright bg-background-bright p-[0.86rem] pl-4">
|
||||
<SimpleTooltip
|
||||
@@ -359,7 +372,7 @@ function SchedulesTable({
|
||||
const { scheduleParam } = useParams();
|
||||
|
||||
return (
|
||||
<Table containerClassName="max-h-full h-fit overflow-x-auto border-b border-grid-bright">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>ID</TableHeaderCell>
|
||||
|
||||
+2
@@ -412,6 +412,7 @@ function ScheduledTaskForm({
|
||||
granularity="second"
|
||||
showNowButton
|
||||
variant="medium"
|
||||
utc
|
||||
/>
|
||||
<Hint>
|
||||
This is the timestamp of the CRON, it will come through to your run in the
|
||||
@@ -436,6 +437,7 @@ function ScheduledTaskForm({
|
||||
showNowButton
|
||||
showClearButton
|
||||
variant="medium"
|
||||
utc
|
||||
/>
|
||||
<Hint>
|
||||
This is the timestamp of the previous run. You can use this in your code to find
|
||||
|
||||
@@ -87,6 +87,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
const monthDateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
timeZone: "utc",
|
||||
});
|
||||
|
||||
export default function Page() {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
batchId: z.string(),
|
||||
});
|
||||
|
||||
export const loader = createLoaderApiRoute(
|
||||
{
|
||||
params: ParamsSchema,
|
||||
allowJWT: true,
|
||||
corsStrategy: "all",
|
||||
findResource: (params, auth) => {
|
||||
return $replica.batchTaskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: params.batchId,
|
||||
runtimeEnvironmentId: auth.environment.id,
|
||||
},
|
||||
});
|
||||
},
|
||||
authorization: {
|
||||
action: "read",
|
||||
resource: (batch) => ({ batch: batch.friendlyId }),
|
||||
superScopes: ["read:runs", "read:all", "admin"],
|
||||
},
|
||||
},
|
||||
async ({ resource: batch }) => {
|
||||
return json({
|
||||
id: batch.friendlyId,
|
||||
status: batch.status,
|
||||
idempotencyKey: batch.idempotencyKey ?? undefined,
|
||||
createdAt: batch.createdAt,
|
||||
updatedAt: batch.updatedAt,
|
||||
runCount: batch.runCount,
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -45,6 +45,7 @@ export const loader = createLoaderApiRoute(
|
||||
params: ParamsSchema,
|
||||
allowJWT: true,
|
||||
corsStrategy: "all",
|
||||
findResource: async () => 1, // This is a dummy function, we don't need to find a resource
|
||||
},
|
||||
async ({ params, authentication }) => {
|
||||
const filename = params["*"];
|
||||
|
||||
@@ -61,8 +61,17 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
return json({ error: "An unknown error occurred" }, { status: 500 });
|
||||
}
|
||||
|
||||
const run = await ApiRetrieveRunPresenter.findRun(
|
||||
updatedRun.friendlyId,
|
||||
authenticationResult.environment
|
||||
);
|
||||
|
||||
if (!run) {
|
||||
return json({ error: "Run not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const presenter = new ApiRetrieveRunPresenter();
|
||||
const result = await presenter.call(updatedRun.friendlyId, authenticationResult.environment);
|
||||
const result = await presenter.call(run, authenticationResult.environment);
|
||||
|
||||
if (!result) {
|
||||
return json({ error: "Run not found" }, { status: 404 });
|
||||
|
||||
@@ -12,9 +12,10 @@ export const loader = createLoaderApiRoute(
|
||||
corsStrategy: "all",
|
||||
authorization: {
|
||||
action: "read",
|
||||
resource: (_, searchParams) => ({ tasks: searchParams["filter[taskIdentifier]"] }),
|
||||
resource: (_, __, searchParams) => ({ tasks: searchParams["filter[taskIdentifier]"] }),
|
||||
superScopes: ["read:runs", "read:all", "admin"],
|
||||
},
|
||||
findResource: async () => 1, // This is a dummy function, we don't need to find a resource
|
||||
},
|
||||
async ({ searchParams, authentication }) => {
|
||||
const presenter = new ApiRunListPresenter();
|
||||
|
||||
@@ -3,9 +3,10 @@ import { generateJWT as internal_generateJWT, TriggerTaskRequestBody } from "@tr
|
||||
import { TaskRun } from "@trigger.dev/database";
|
||||
import { z } from "zod";
|
||||
import { env } from "~/env.server";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { AuthenticatedEnvironment, getOneTimeUseToken } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { resolveIdempotencyKeyTTL } from "~/utils/idempotencyKeys.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { OutOfEntitlementError, TriggerTaskService } from "~/v3/services/triggerTask.server";
|
||||
|
||||
@@ -15,6 +16,7 @@ const ParamsSchema = z.object({
|
||||
|
||||
export const HeadersSchema = z.object({
|
||||
"idempotency-key": z.string().nullish(),
|
||||
"idempotency-key-ttl": z.string().nullish(),
|
||||
"trigger-version": z.string().nullish(),
|
||||
"x-trigger-span-parent-as-link": z.coerce.number().nullish(),
|
||||
"x-trigger-worker": z.string().nullish(),
|
||||
@@ -31,7 +33,7 @@ const { action, loader } = createActionApiRoute(
|
||||
allowJWT: true,
|
||||
maxContentLength: env.TASK_PAYLOAD_MAXIMUM_SIZE,
|
||||
authorization: {
|
||||
action: "write",
|
||||
action: "trigger",
|
||||
resource: (params) => ({ tasks: params.taskId }),
|
||||
superScopes: ["write:tasks", "admin"],
|
||||
},
|
||||
@@ -40,6 +42,7 @@ const { action, loader } = createActionApiRoute(
|
||||
async ({ body, headers, params, authentication }) => {
|
||||
const {
|
||||
"idempotency-key": idempotencyKey,
|
||||
"idempotency-key-ttl": idempotencyKeyTTL,
|
||||
"trigger-version": triggerVersion,
|
||||
"x-trigger-span-parent-as-link": spanParentAsLink,
|
||||
traceparent,
|
||||
@@ -56,9 +59,12 @@ const { action, loader } = createActionApiRoute(
|
||||
? { traceparent, tracestate }
|
||||
: undefined;
|
||||
|
||||
const oneTimeUseToken = await getOneTimeUseToken(authentication);
|
||||
|
||||
logger.debug("Triggering task", {
|
||||
taskId: params.taskId,
|
||||
idempotencyKey,
|
||||
idempotencyKeyTTL,
|
||||
triggerVersion,
|
||||
headers,
|
||||
options: body.options,
|
||||
@@ -66,11 +72,15 @@ const { action, loader } = createActionApiRoute(
|
||||
traceContext,
|
||||
});
|
||||
|
||||
const idempotencyKeyExpiresAt = resolveIdempotencyKeyTTL(idempotencyKeyTTL);
|
||||
|
||||
const run = await service.call(params.taskId, authentication.environment, body, {
|
||||
idempotencyKey: idempotencyKey ?? undefined,
|
||||
idempotencyKeyExpiresAt: idempotencyKeyExpiresAt,
|
||||
triggerVersion: triggerVersion ?? undefined,
|
||||
traceContext,
|
||||
spanParentAsLink: spanParentAsLink === 1,
|
||||
oneTimeUseToken,
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import {
|
||||
BatchTriggerTaskResponse,
|
||||
BatchTriggerTaskV2RequestBody,
|
||||
BatchTriggerTaskV2Response,
|
||||
generateJWT,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { env } from "~/env.server";
|
||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { HeadersSchema } from "./api.v1.tasks.$taskId.trigger";
|
||||
import { resolveIdempotencyKeyTTL } from "~/utils/idempotencyKeys.server";
|
||||
import { BatchTriggerV2Service } from "~/v3/services/batchTriggerV2.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { OutOfEntitlementError } from "~/v3/services/triggerTask.server";
|
||||
import { AuthenticatedEnvironment, getOneTimeUseToken } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
const { action, loader } = createActionApiRoute(
|
||||
{
|
||||
headers: HeadersSchema,
|
||||
body: BatchTriggerTaskV2RequestBody,
|
||||
allowJWT: true,
|
||||
maxContentLength: env.BATCH_TASK_PAYLOAD_MAXIMUM_SIZE,
|
||||
authorization: {
|
||||
action: "batchTrigger",
|
||||
resource: (_, __, ___, body) => ({
|
||||
tasks: Array.from(new Set(body.items.map((i) => i.task))),
|
||||
}),
|
||||
superScopes: ["write:tasks", "admin"],
|
||||
},
|
||||
corsStrategy: "all",
|
||||
},
|
||||
async ({ body, headers, params, authentication }) => {
|
||||
if (!body.items.length) {
|
||||
return json({ error: "Batch cannot be triggered with no items" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check the there are fewer than MAX_BATCH_V2_TRIGGER_ITEMS items
|
||||
if (body.items.length > env.MAX_BATCH_V2_TRIGGER_ITEMS) {
|
||||
return json(
|
||||
{
|
||||
error: `Batch size of ${body.items.length} is too large. Maximum allowed batch size is ${env.MAX_BATCH_V2_TRIGGER_ITEMS}.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
"idempotency-key": idempotencyKey,
|
||||
"idempotency-key-ttl": idempotencyKeyTTL,
|
||||
"trigger-version": triggerVersion,
|
||||
"x-trigger-span-parent-as-link": spanParentAsLink,
|
||||
"x-trigger-worker": isFromWorker,
|
||||
"x-trigger-client": triggerClient,
|
||||
traceparent,
|
||||
tracestate,
|
||||
} = headers;
|
||||
|
||||
const oneTimeUseToken = await getOneTimeUseToken(authentication);
|
||||
|
||||
logger.debug("Batch trigger request", {
|
||||
idempotencyKey,
|
||||
idempotencyKeyTTL,
|
||||
triggerVersion,
|
||||
spanParentAsLink,
|
||||
isFromWorker,
|
||||
triggerClient,
|
||||
traceparent,
|
||||
tracestate,
|
||||
});
|
||||
|
||||
const traceContext =
|
||||
traceparent && isFromWorker // If the request is from a worker, we should pass the trace context
|
||||
? { traceparent, tracestate }
|
||||
: undefined;
|
||||
|
||||
// By default, the idempotency key expires in 30 days
|
||||
const idempotencyKeyExpiresAt =
|
||||
resolveIdempotencyKeyTTL(idempotencyKeyTTL) ??
|
||||
new Date(Date.now() + 24 * 60 * 60 * 1000 * 30);
|
||||
|
||||
const service = new BatchTriggerV2Service();
|
||||
|
||||
try {
|
||||
const batch = await service.call(authentication.environment, body, {
|
||||
idempotencyKey: idempotencyKey ?? undefined,
|
||||
idempotencyKeyExpiresAt,
|
||||
triggerVersion: triggerVersion ?? undefined,
|
||||
traceContext,
|
||||
spanParentAsLink: spanParentAsLink === 1,
|
||||
oneTimeUseToken,
|
||||
});
|
||||
|
||||
const $responseHeaders = await responseHeaders(
|
||||
batch,
|
||||
authentication.environment,
|
||||
triggerClient
|
||||
);
|
||||
|
||||
return json(batch, { status: 202, headers: $responseHeaders });
|
||||
} catch (error) {
|
||||
logger.error("Batch trigger error", {
|
||||
error: {
|
||||
message: (error as Error).message,
|
||||
stack: (error as Error).stack,
|
||||
},
|
||||
});
|
||||
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return json({ error: error.message }, { status: 422 });
|
||||
} else if (error instanceof OutOfEntitlementError) {
|
||||
return json({ error: error.message }, { status: 422 });
|
||||
} else if (error instanceof Error) {
|
||||
return json(
|
||||
{ error: error.message },
|
||||
{ status: 500, headers: { "x-should-retry": "false" } }
|
||||
);
|
||||
}
|
||||
|
||||
return json({ error: "Something went wrong" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
async function responseHeaders(
|
||||
batch: BatchTriggerTaskV2Response,
|
||||
environment: AuthenticatedEnvironment,
|
||||
triggerClient?: string | null
|
||||
): Promise<Record<string, string>> {
|
||||
const claimsHeader = JSON.stringify({
|
||||
sub: environment.id,
|
||||
pub: true,
|
||||
});
|
||||
|
||||
if (triggerClient === "browser") {
|
||||
const claims = {
|
||||
sub: environment.id,
|
||||
pub: true,
|
||||
scopes: [`read:batch:${batch.id}`],
|
||||
};
|
||||
|
||||
const jwt = await generateJWT({
|
||||
secretKey: environment.apiKey,
|
||||
payload: claims,
|
||||
expirationTime: "1h",
|
||||
});
|
||||
|
||||
return {
|
||||
"x-trigger-jwt-claims": claimsHeader,
|
||||
"x-trigger-jwt": jwt,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
"x-trigger-jwt-claims": claimsHeader,
|
||||
};
|
||||
}
|
||||
|
||||
export { action, loader };
|
||||
@@ -12,18 +12,29 @@ export const loader = createLoaderApiRoute(
|
||||
params: ParamsSchema,
|
||||
allowJWT: true,
|
||||
corsStrategy: "all",
|
||||
findResource: (params, auth) => {
|
||||
return ApiRetrieveRunPresenter.findRun(params.runId, auth.environment);
|
||||
},
|
||||
authorization: {
|
||||
action: "read",
|
||||
resource: (params) => ({ runs: params.runId }),
|
||||
resource: (run) => ({
|
||||
runs: run.friendlyId,
|
||||
tags: run.runTags,
|
||||
batch: run.batch?.friendlyId,
|
||||
tasks: run.taskIdentifier,
|
||||
}),
|
||||
superScopes: ["read:runs", "read:all", "admin"],
|
||||
},
|
||||
},
|
||||
async ({ params, authentication }) => {
|
||||
async ({ authentication, resource }) => {
|
||||
const presenter = new ApiRetrieveRunPresenter();
|
||||
const result = await presenter.call(params.runId, authentication.environment);
|
||||
const result = await presenter.call(resource, authentication.environment);
|
||||
|
||||
if (!result) {
|
||||
return json({ error: "Run not found" }, { status: 404 });
|
||||
return json(
|
||||
{ error: "Run not found" },
|
||||
{ status: 404, headers: { "x-should-retry": "true" } }
|
||||
);
|
||||
}
|
||||
|
||||
return json(result);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { realtimeClient } from "~/services/realtimeClientGlobal.server";
|
||||
@@ -13,24 +12,21 @@ export const loader = createLoaderApiRoute(
|
||||
params: ParamsSchema,
|
||||
allowJWT: true,
|
||||
corsStrategy: "all",
|
||||
findResource: (params, auth) => {
|
||||
return $replica.batchTaskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: params.batchId,
|
||||
runtimeEnvironmentId: auth.environment.id,
|
||||
},
|
||||
});
|
||||
},
|
||||
authorization: {
|
||||
action: "read",
|
||||
resource: (params) => ({ batch: params.batchId }),
|
||||
resource: (batch) => ({ batch: batch.friendlyId }),
|
||||
superScopes: ["read:runs", "read:all", "admin"],
|
||||
},
|
||||
},
|
||||
async ({ params, authentication, request }) => {
|
||||
const batchRun = await $replica.batchTaskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: params.batchId,
|
||||
runtimeEnvironmentId: authentication.environment.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!batchRun) {
|
||||
return json({ error: "Batch not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
async ({ authentication, request, resource: batchRun }) => {
|
||||
return realtimeClient.streamBatch(
|
||||
request.url,
|
||||
authentication.environment,
|
||||
|
||||
@@ -13,24 +13,33 @@ export const loader = createLoaderApiRoute(
|
||||
params: ParamsSchema,
|
||||
allowJWT: true,
|
||||
corsStrategy: "all",
|
||||
findResource: async (params, authentication) => {
|
||||
return $replica.taskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: params.runId,
|
||||
runtimeEnvironmentId: authentication.environment.id,
|
||||
},
|
||||
include: {
|
||||
batch: {
|
||||
select: {
|
||||
friendlyId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
authorization: {
|
||||
action: "read",
|
||||
resource: (params) => ({ runs: params.runId }),
|
||||
resource: (run) => ({
|
||||
runs: run.friendlyId,
|
||||
tags: run.runTags,
|
||||
batch: run.batch?.friendlyId,
|
||||
tasks: run.taskIdentifier,
|
||||
}),
|
||||
superScopes: ["read:runs", "read:all", "admin"],
|
||||
},
|
||||
},
|
||||
async ({ params, authentication, request }) => {
|
||||
const run = await $replica.taskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: params.runId,
|
||||
runtimeEnvironmentId: authentication.environment.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
return json({ error: "Run not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
async ({ authentication, request, resource: run }) => {
|
||||
return realtimeClient.streamRun(
|
||||
request.url,
|
||||
authentication.environment,
|
||||
|
||||
@@ -16,9 +16,10 @@ export const loader = createLoaderApiRoute(
|
||||
searchParams: SearchParamsSchema,
|
||||
allowJWT: true,
|
||||
corsStrategy: "all",
|
||||
findResource: async () => 1, // This is a dummy value, it's not used
|
||||
authorization: {
|
||||
action: "read",
|
||||
resource: (_, searchParams) => searchParams,
|
||||
resource: (_, __, searchParams) => searchParams,
|
||||
superScopes: ["read:runs", "read:all", "admin"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -24,24 +24,33 @@ export const loader = createLoaderApiRoute(
|
||||
params: ParamsSchema,
|
||||
allowJWT: true,
|
||||
corsStrategy: "all",
|
||||
findResource: async (params, auth) => {
|
||||
return $replica.taskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: params.runId,
|
||||
runtimeEnvironmentId: auth.environment.id,
|
||||
},
|
||||
include: {
|
||||
batch: {
|
||||
select: {
|
||||
friendlyId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
authorization: {
|
||||
action: "read",
|
||||
resource: (params) => ({ runs: params.runId }),
|
||||
resource: (run) => ({
|
||||
runs: run.friendlyId,
|
||||
tags: run.runTags,
|
||||
batch: run.batch?.friendlyId,
|
||||
tasks: run.taskIdentifier,
|
||||
}),
|
||||
superScopes: ["read:runs", "read:all", "admin"],
|
||||
},
|
||||
},
|
||||
async ({ params, authentication, request }) => {
|
||||
const run = await $replica.taskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: params.runId,
|
||||
runtimeEnvironmentId: authentication.environment.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
return new Response("Run not found", { status: 404 });
|
||||
}
|
||||
|
||||
async ({ params, request, resource: run }) => {
|
||||
return realtimeStreams.streamResponse(run.friendlyId, params.streamId, request.signal);
|
||||
}
|
||||
);
|
||||
|
||||
+17
@@ -56,6 +56,8 @@ import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
|
||||
import {
|
||||
v3BatchPath,
|
||||
v3BatchRunsPath,
|
||||
v3RunDownloadLogsPath,
|
||||
v3RunPath,
|
||||
v3RunSpanPath,
|
||||
@@ -583,6 +585,21 @@ function RunBody({
|
||||
</>
|
||||
)
|
||||
) : null}
|
||||
{run.batch && (
|
||||
<Property.Item>
|
||||
<Property.Label>Batch</Property.Label>
|
||||
<Property.Value>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<TextLink to={v3BatchPath(organization, project, run.batch)}>
|
||||
{run.batch.friendlyId}
|
||||
</TextLink>
|
||||
}
|
||||
content={`Jump to ${run.batch.friendlyId}`}
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
<Property.Item>
|
||||
<Property.Label>Version</Property.Label>
|
||||
<Property.Value>
|
||||
|
||||
@@ -190,6 +190,11 @@ const pricingDefinitions = {
|
||||
content:
|
||||
"A single email address, Slack channel, or webhook URL that you want to send alerts to.",
|
||||
},
|
||||
realtime: {
|
||||
title: "Realtime connections",
|
||||
content:
|
||||
"Realtime allows you to send the live status and data from your runs to your frontend. This is the number of simultaneous Realtime connections that can be made.",
|
||||
},
|
||||
};
|
||||
|
||||
type PricingPlansProps = {
|
||||
@@ -494,6 +499,7 @@ export function TierFree({
|
||||
<LogRetention limits={plan.limits} />
|
||||
<SupportLevel limits={plan.limits} />
|
||||
<Alerts limits={plan.limits} />
|
||||
<RealtimeConnecurrency limits={plan.limits} />
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
@@ -608,6 +614,7 @@ export function TierHobby({
|
||||
<LogRetention limits={plan.limits} />
|
||||
<SupportLevel limits={plan.limits} />
|
||||
<Alerts limits={plan.limits} />
|
||||
<RealtimeConnecurrency limits={plan.limits} />
|
||||
</ul>
|
||||
</TierContainer>
|
||||
);
|
||||
@@ -678,6 +685,7 @@ export function TierPro({
|
||||
<LogRetention limits={plan.limits} />
|
||||
<SupportLevel limits={plan.limits} />
|
||||
<Alerts limits={plan.limits} />
|
||||
<RealtimeConnecurrency limits={plan.limits} />
|
||||
</ul>
|
||||
</TierContainer>
|
||||
);
|
||||
@@ -950,3 +958,18 @@ function Alerts({ limits }: { limits: Limits }) {
|
||||
</FeatureItem>
|
||||
);
|
||||
}
|
||||
|
||||
function RealtimeConnecurrency({ limits }: { limits: Limits }) {
|
||||
return (
|
||||
<FeatureItem checked>
|
||||
{limits.realtimeConcurrentConnections.number}
|
||||
{limits.realtimeConcurrentConnections.canExceed ? "+" : ""}{" "}
|
||||
<DefinitionTip
|
||||
title={pricingDefinitions.realtime.title}
|
||||
content={pricingDefinitions.realtime.content}
|
||||
>
|
||||
concurrent Realtime connections
|
||||
</DefinitionTip>
|
||||
</FeatureItem>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import { isPublicJWT, validatePublicJwtKey } from "./realtime/jwtAuth.server";
|
||||
|
||||
const ClaimsSchema = z.object({
|
||||
scopes: z.array(z.string()).optional(),
|
||||
// One-time use token
|
||||
otu: z.boolean().optional(),
|
||||
});
|
||||
|
||||
type Optional<T, K extends keyof T> = Prettify<Omit<T, K> & Partial<Pick<T, K>>>;
|
||||
@@ -29,30 +31,71 @@ export type AuthenticatedEnvironment = Optional<
|
||||
"orgMember"
|
||||
>;
|
||||
|
||||
export type ApiAuthenticationResult = {
|
||||
export type ApiAuthenticationResult =
|
||||
| ApiAuthenticationResultSuccess
|
||||
| ApiAuthenticationResultFailure;
|
||||
|
||||
export type ApiAuthenticationResultSuccess = {
|
||||
ok: true;
|
||||
apiKey: string;
|
||||
type: "PUBLIC" | "PRIVATE" | "PUBLIC_JWT";
|
||||
environment: AuthenticatedEnvironment;
|
||||
scopes?: string[];
|
||||
oneTimeUse?: boolean;
|
||||
};
|
||||
|
||||
export type ApiAuthenticationResultFailure = {
|
||||
ok: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated Use `authenticateApiRequestWithFailure` instead.
|
||||
*/
|
||||
export async function authenticateApiRequest(
|
||||
request: Request,
|
||||
options: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
|
||||
): Promise<ApiAuthenticationResult | undefined> {
|
||||
): Promise<ApiAuthenticationResultSuccess | undefined> {
|
||||
const apiKey = getApiKeyFromRequest(request);
|
||||
|
||||
if (!apiKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
return authenticateApiKey(apiKey, options);
|
||||
const authentication = await authenticateApiKey(apiKey, options);
|
||||
|
||||
return authentication;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is the same as `authenticateApiRequest` but it returns a failure result instead of undefined.
|
||||
* It should be used from now on to ensure that the API key is always validated and provide a failure result.
|
||||
*/
|
||||
export async function authenticateApiRequestWithFailure(
|
||||
request: Request,
|
||||
options: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
|
||||
): Promise<ApiAuthenticationResult> {
|
||||
const apiKey = getApiKeyFromRequest(request);
|
||||
|
||||
if (!apiKey) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Invalid API Key",
|
||||
};
|
||||
}
|
||||
|
||||
const authentication = await authenticateApiKeyWithFailure(apiKey, options);
|
||||
|
||||
return authentication;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `authenticateApiKeyWithFailure` instead.
|
||||
*/
|
||||
export async function authenticateApiKey(
|
||||
apiKey: string,
|
||||
options: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
|
||||
): Promise<ApiAuthenticationResult | undefined> {
|
||||
): Promise<ApiAuthenticationResultSuccess | undefined> {
|
||||
const result = getApiKeyResult(apiKey);
|
||||
|
||||
if (!result) {
|
||||
@@ -70,16 +113,24 @@ export async function authenticateApiKey(
|
||||
switch (result.type) {
|
||||
case "PUBLIC": {
|
||||
const environment = await findEnvironmentByPublicApiKey(result.apiKey);
|
||||
if (!environment) return;
|
||||
if (!environment) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
...result,
|
||||
environment,
|
||||
};
|
||||
}
|
||||
case "PRIVATE": {
|
||||
const environment = await findEnvironmentByApiKey(result.apiKey);
|
||||
if (!environment) return;
|
||||
if (!environment) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
...result,
|
||||
environment,
|
||||
};
|
||||
@@ -87,16 +138,100 @@ export async function authenticateApiKey(
|
||||
case "PUBLIC_JWT": {
|
||||
const validationResults = await validatePublicJwtKey(result.apiKey);
|
||||
|
||||
if (!validationResults) {
|
||||
if (!validationResults.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedClaims = ClaimsSchema.safeParse(validationResults.claims);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
...result,
|
||||
environment: validationResults.environment,
|
||||
scopes: parsedClaims.success ? parsedClaims.data.scopes : [],
|
||||
oneTimeUse: parsedClaims.success ? parsedClaims.data.otu : false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is the same as `authenticateApiKey` but it returns a failure result instead of undefined.
|
||||
* It should be used from now on to ensure that the API key is always validated and provide a failure result.
|
||||
*/
|
||||
export async function authenticateApiKeyWithFailure(
|
||||
apiKey: string,
|
||||
options: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
|
||||
): Promise<ApiAuthenticationResult> {
|
||||
const result = getApiKeyResult(apiKey);
|
||||
|
||||
if (!result) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Invalid API Key",
|
||||
};
|
||||
}
|
||||
|
||||
if (!options.allowPublicKey && result.type === "PUBLIC") {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Public API keys are not allowed for this request",
|
||||
};
|
||||
}
|
||||
|
||||
if (!options.allowJWT && result.type === "PUBLIC_JWT") {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Public JWT API keys are not allowed for this request",
|
||||
};
|
||||
}
|
||||
|
||||
switch (result.type) {
|
||||
case "PUBLIC": {
|
||||
const environment = await findEnvironmentByPublicApiKey(result.apiKey);
|
||||
if (!environment) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Invalid API Key",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
...result,
|
||||
environment,
|
||||
};
|
||||
}
|
||||
case "PRIVATE": {
|
||||
const environment = await findEnvironmentByApiKey(result.apiKey);
|
||||
if (!environment) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Invalid API Key",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
...result,
|
||||
environment,
|
||||
};
|
||||
}
|
||||
case "PUBLIC_JWT": {
|
||||
const validationResults = await validatePublicJwtKey(result.apiKey);
|
||||
|
||||
if (!validationResults.ok) {
|
||||
return validationResults;
|
||||
}
|
||||
|
||||
const parsedClaims = ClaimsSchema.safeParse(validationResults.claims);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
...result,
|
||||
environment: validationResults.environment,
|
||||
scopes: parsedClaims.success ? parsedClaims.data.scopes : [],
|
||||
oneTimeUse: parsedClaims.success ? parsedClaims.data.otu : false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -207,6 +342,10 @@ export async function authenticatedEnvironmentForAuthentication(
|
||||
|
||||
switch (auth.type) {
|
||||
case "apiKey": {
|
||||
if (!auth.result.ok) {
|
||||
throw json({ error: auth.result.error }, { status: 401 });
|
||||
}
|
||||
|
||||
if (auth.result.environment.project.externalRef !== projectRef) {
|
||||
throw json(
|
||||
{
|
||||
@@ -337,6 +476,14 @@ export async function validateJWTTokenAndRenew<T extends z.ZodTypeAny>(
|
||||
return;
|
||||
}
|
||||
|
||||
if (!authenticatedEnv.ok) {
|
||||
logger.error("Failed to renew JWT token, invalid API key", {
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = payloadSchema.safeParse(error.payload);
|
||||
|
||||
if (!payload.success) {
|
||||
@@ -389,3 +536,20 @@ function calculateJWTExpiration() {
|
||||
|
||||
return (Date.now() + DEFAULT_JWT_EXPIRATION_IN_MS) / 1000;
|
||||
}
|
||||
|
||||
export async function getOneTimeUseToken(
|
||||
auth: ApiAuthenticationResultSuccess
|
||||
): Promise<string | undefined> {
|
||||
if (auth.type !== "PUBLIC_JWT") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!auth.oneTimeUse) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Hash the API key to make it unique
|
||||
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(auth.apiKey));
|
||||
|
||||
return Buffer.from(hash).toString("hex");
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({
|
||||
allowJWT: true,
|
||||
});
|
||||
|
||||
if (!authenticatedEnv) {
|
||||
if (!authenticatedEnv || !authenticatedEnv.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type AuthorizationAction = "read" | "write"; // Add more actions as needed
|
||||
export type AuthorizationAction = "read" | "write" | string; // Add more actions as needed
|
||||
|
||||
const ResourceTypes = ["tasks", "tags", "runs", "batch"] as const;
|
||||
|
||||
@@ -88,34 +88,26 @@ export function checkAuthorization(
|
||||
for (const [resourceType, resourceValue] of Object.entries(filteredResource)) {
|
||||
const resourceValues = Array.isArray(resourceValue) ? resourceValue : [resourceValue];
|
||||
|
||||
let resourceAuthorized = false;
|
||||
for (const value of resourceValues) {
|
||||
// Check for specific resource permission
|
||||
const specificPermission = `${action}:${resourceType}:${value}`;
|
||||
// Check for general resource type permission
|
||||
const generalPermission = `${action}:${resourceType}`;
|
||||
|
||||
// If any permission matches, return authorized
|
||||
if (entity.scopes.includes(specificPermission) || entity.scopes.includes(generalPermission)) {
|
||||
resourceAuthorized = true;
|
||||
break;
|
||||
return { authorized: true };
|
||||
}
|
||||
}
|
||||
|
||||
// If any resource is not authorized, return false
|
||||
if (!resourceAuthorized) {
|
||||
return {
|
||||
authorized: false,
|
||||
reason: `Public Access Token is missing required permissions. Permissions required for ${resourceValues
|
||||
.map((v) => `'${action}:${resourceType}:${v}'`)
|
||||
.join(", ")} but token has the following permissions: ${entity.scopes
|
||||
.map((s) => `'${s}'`)
|
||||
.join(
|
||||
", "
|
||||
)}. See https://trigger.dev/docs/frontend/overview#authentication for more information.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// All resources are authorized
|
||||
return { authorized: true };
|
||||
// No matching permissions found
|
||||
return {
|
||||
authorized: false,
|
||||
reason: `Public Access Token is missing required permissions. Token has the following permissions: ${entity.scopes
|
||||
.map((s) => `'${s}'`)
|
||||
.join(
|
||||
", "
|
||||
)}. See https://trigger.dev/docs/frontend/overview#authentication for more information.`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createCookieSessionStorage } from "@remix-run/node";
|
||||
import { env } from "~/env.server";
|
||||
|
||||
export const uiPreferencesStorage = createCookieSessionStorage({
|
||||
cookie: {
|
||||
name: "__ui_prefs",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secrets: [env.SESSION_SECRET],
|
||||
secure: env.NODE_ENV === "production",
|
||||
maxAge: 60 * 60 * 24 * 365, // 1 year
|
||||
},
|
||||
});
|
||||
|
||||
export function getUiPreferencesSession(request: Request) {
|
||||
return uiPreferencesStorage.getSession(request.headers.get("Cookie"));
|
||||
}
|
||||
|
||||
export async function getUsefulLinksPreference(request: Request): Promise<boolean | undefined> {
|
||||
const session = await getUiPreferencesSession(request);
|
||||
return session.get("showUsefulLinks");
|
||||
}
|
||||
|
||||
export async function setUsefulLinksPreference(show: boolean, request: Request) {
|
||||
const session = await getUiPreferencesSession(request);
|
||||
session.set("showUsefulLinks", show);
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function getRootOnlyFilterPreference(request: Request): Promise<boolean> {
|
||||
const session = await getUiPreferencesSession(request);
|
||||
const rootOnly = session.get("rootOnly");
|
||||
if (rootOnly === undefined) {
|
||||
return false;
|
||||
}
|
||||
return rootOnly;
|
||||
}
|
||||
|
||||
export async function setRootOnlyFilterPreference(rootOnly: boolean, request: Request) {
|
||||
const session = await getUiPreferencesSession(request);
|
||||
session.set("rootOnly", rootOnly);
|
||||
return session;
|
||||
}
|
||||
@@ -1,8 +1,22 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { validateJWT } from "@trigger.dev/core/v3/jwt";
|
||||
import { findEnvironmentById } from "~/models/runtimeEnvironment.server";
|
||||
import { AuthenticatedEnvironment } from "../apiAuth.server";
|
||||
|
||||
export async function validatePublicJwtKey(token: string) {
|
||||
export type ValidatePublicJwtKeySuccess = {
|
||||
ok: true;
|
||||
environment: AuthenticatedEnvironment;
|
||||
claims: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ValidatePublicJwtKeyError = {
|
||||
ok: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export type ValidatePublicJwtKeyResult = ValidatePublicJwtKeySuccess | ValidatePublicJwtKeyError;
|
||||
|
||||
export async function validatePublicJwtKey(token: string): Promise<ValidatePublicJwtKeyResult> {
|
||||
// Get the sub claim from the token
|
||||
// Use the sub claim to find the environment
|
||||
// Validate the token against the environment.apiKey
|
||||
@@ -10,13 +24,13 @@ export async function validatePublicJwtKey(token: string) {
|
||||
const sub = extractJWTSub(token);
|
||||
|
||||
if (!sub) {
|
||||
throw json({ error: "Invalid Public Access Token, missing subject." }, { status: 401 });
|
||||
return { ok: false, error: "Invalid Public Access Token, missing subject." };
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentById(sub);
|
||||
|
||||
if (!environment) {
|
||||
throw json({ error: "Invalid Public Access Token, environment not found." }, { status: 401 });
|
||||
return { ok: false, error: "Invalid Public Access Token, environment not found." };
|
||||
}
|
||||
|
||||
const result = await validateJWT(token, environment.apiKey);
|
||||
@@ -24,35 +38,30 @@ export async function validatePublicJwtKey(token: string) {
|
||||
if (!result.ok) {
|
||||
switch (result.code) {
|
||||
case "ERR_JWT_EXPIRED": {
|
||||
throw json(
|
||||
{
|
||||
error:
|
||||
"Public Access Token has expired. See https://trigger.dev/docs/frontend/overview#authentication for more information.",
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
"Public Access Token has expired. See https://trigger.dev/docs/frontend/overview#authentication for more information.",
|
||||
};
|
||||
}
|
||||
case "ERR_JWT_CLAIM_INVALID": {
|
||||
throw json(
|
||||
{
|
||||
error: `Public Access Token is invalid: ${result.error}. See https://trigger.dev/docs/frontend/overview#authentication for more information.`,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error: `Public Access Token is invalid: ${result.error}. See https://trigger.dev/docs/frontend/overview#authentication for more information.`,
|
||||
};
|
||||
}
|
||||
default: {
|
||||
throw json(
|
||||
{
|
||||
error:
|
||||
"Public Access Token is invalid. See https://trigger.dev/docs/frontend/overview#authentication for more information.",
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
"Public Access Token is invalid. See https://trigger.dev/docs/frontend/overview#authentication for more information.",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
environment,
|
||||
claims: result.payload,
|
||||
};
|
||||
|
||||
@@ -52,7 +52,14 @@ export class RealtimeClient {
|
||||
batchId: string,
|
||||
clientVersion?: string
|
||||
) {
|
||||
return this.#streamRunsWhere(url, environment, `"batchId"='${batchId}'`, clientVersion);
|
||||
const whereClauses: string[] = [
|
||||
`"runtimeEnvironmentId"='${environment.id}'`,
|
||||
`"batchId"='${batchId}'`,
|
||||
];
|
||||
|
||||
const whereClause = whereClauses.join(" AND ");
|
||||
|
||||
return this.#streamRunsWhere(url, environment, whereClause, clientVersion);
|
||||
}
|
||||
|
||||
async streamRuns(
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import { ApiAuthenticationResult, authenticateApiRequest } from "../apiAuth.server";
|
||||
import {
|
||||
ApiAuthenticationResultSuccess,
|
||||
authenticateApiRequestWithFailure,
|
||||
} from "../apiAuth.server";
|
||||
import { ActionFunctionArgs, json, LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import { apiCors } from "~/utils/apiCors";
|
||||
@@ -18,16 +21,22 @@ import { safeJsonParse } from "~/utils/json";
|
||||
type ApiKeyRouteBuilderOptions<
|
||||
TParamsSchema extends z.AnyZodObject | undefined = undefined,
|
||||
TSearchParamsSchema extends z.AnyZodObject | undefined = undefined,
|
||||
THeadersSchema extends z.AnyZodObject | undefined = undefined
|
||||
THeadersSchema extends z.AnyZodObject | undefined = undefined,
|
||||
TResource = never
|
||||
> = {
|
||||
params?: TParamsSchema;
|
||||
searchParams?: TSearchParamsSchema;
|
||||
headers?: THeadersSchema;
|
||||
allowJWT?: boolean;
|
||||
corsStrategy?: "all" | "none";
|
||||
findResource: (
|
||||
params: TParamsSchema extends z.AnyZodObject ? z.infer<TParamsSchema> : undefined,
|
||||
authentication: ApiAuthenticationResultSuccess
|
||||
) => Promise<TResource | undefined>;
|
||||
authorization?: {
|
||||
action: AuthorizationAction;
|
||||
resource: (
|
||||
resource: NonNullable<TResource>,
|
||||
params: TParamsSchema extends z.AnyZodObject ? z.infer<TParamsSchema> : undefined,
|
||||
searchParams: TSearchParamsSchema extends z.AnyZodObject
|
||||
? z.infer<TSearchParamsSchema>
|
||||
@@ -41,24 +50,27 @@ type ApiKeyRouteBuilderOptions<
|
||||
type ApiKeyHandlerFunction<
|
||||
TParamsSchema extends z.AnyZodObject | undefined,
|
||||
TSearchParamsSchema extends z.AnyZodObject | undefined,
|
||||
THeadersSchema extends z.AnyZodObject | undefined = undefined
|
||||
THeadersSchema extends z.AnyZodObject | undefined = undefined,
|
||||
TResource = never
|
||||
> = (args: {
|
||||
params: TParamsSchema extends z.AnyZodObject ? z.infer<TParamsSchema> : undefined;
|
||||
searchParams: TSearchParamsSchema extends z.AnyZodObject
|
||||
? z.infer<TSearchParamsSchema>
|
||||
: undefined;
|
||||
headers: THeadersSchema extends z.AnyZodObject ? z.infer<THeadersSchema> : undefined;
|
||||
authentication: ApiAuthenticationResult;
|
||||
authentication: ApiAuthenticationResultSuccess;
|
||||
request: Request;
|
||||
resource: NonNullable<TResource>;
|
||||
}) => Promise<Response>;
|
||||
|
||||
export function createLoaderApiRoute<
|
||||
TParamsSchema extends z.AnyZodObject | undefined = undefined,
|
||||
TSearchParamsSchema extends z.AnyZodObject | undefined = undefined,
|
||||
THeadersSchema extends z.AnyZodObject | undefined = undefined
|
||||
THeadersSchema extends z.AnyZodObject | undefined = undefined,
|
||||
TResource = never
|
||||
>(
|
||||
options: ApiKeyRouteBuilderOptions<TParamsSchema, TSearchParamsSchema, THeadersSchema>,
|
||||
handler: ApiKeyHandlerFunction<TParamsSchema, TSearchParamsSchema, THeadersSchema>
|
||||
options: ApiKeyRouteBuilderOptions<TParamsSchema, TSearchParamsSchema, THeadersSchema, TResource>,
|
||||
handler: ApiKeyHandlerFunction<TParamsSchema, TSearchParamsSchema, THeadersSchema, TResource>
|
||||
) {
|
||||
return async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const {
|
||||
@@ -68,6 +80,7 @@ export function createLoaderApiRoute<
|
||||
allowJWT = false,
|
||||
corsStrategy = "none",
|
||||
authorization,
|
||||
findResource,
|
||||
} = options;
|
||||
|
||||
if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") {
|
||||
@@ -75,21 +88,29 @@ export function createLoaderApiRoute<
|
||||
}
|
||||
|
||||
try {
|
||||
const authenticationResult = await authenticateApiRequest(request, { allowJWT });
|
||||
const authenticationResult = await authenticateApiRequestWithFailure(request, { allowJWT });
|
||||
|
||||
if (!authenticationResult) {
|
||||
return wrapResponse(
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json({ error: "Invalid or Missing API key" }, { status: 401 }),
|
||||
corsStrategy !== "none"
|
||||
);
|
||||
}
|
||||
|
||||
if (!authenticationResult.ok) {
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json({ error: authenticationResult.error }, { status: 401 }),
|
||||
corsStrategy !== "none"
|
||||
);
|
||||
}
|
||||
|
||||
let parsedParams: any = undefined;
|
||||
if (paramsSchema) {
|
||||
const parsed = paramsSchema.safeParse(params);
|
||||
if (!parsed.success) {
|
||||
return wrapResponse(
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json(
|
||||
{ error: "Params Error", details: fromZodError(parsed.error).details },
|
||||
@@ -106,7 +127,7 @@ export function createLoaderApiRoute<
|
||||
const searchParams = Object.fromEntries(new URL(request.url).searchParams);
|
||||
const parsed = searchParamsSchema.safeParse(searchParams);
|
||||
if (!parsed.success) {
|
||||
return wrapResponse(
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json(
|
||||
{ error: "Query Error", details: fromZodError(parsed.error).details },
|
||||
@@ -123,7 +144,7 @@ export function createLoaderApiRoute<
|
||||
const rawHeaders = Object.fromEntries(request.headers);
|
||||
const headers = headersSchema.safeParse(rawHeaders);
|
||||
if (!headers.success) {
|
||||
return wrapResponse(
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json(
|
||||
{ error: "Headers Error", details: fromZodError(headers.error).details },
|
||||
@@ -135,13 +156,29 @@ export function createLoaderApiRoute<
|
||||
parsedHeaders = headers.data;
|
||||
}
|
||||
|
||||
// Find the resource
|
||||
const resource = await findResource(parsedParams, authenticationResult);
|
||||
|
||||
if (!resource) {
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json({ error: "Not found" }, { status: 404 }),
|
||||
corsStrategy !== "none"
|
||||
);
|
||||
}
|
||||
|
||||
if (authorization) {
|
||||
const { action, resource, superScopes } = authorization;
|
||||
const $resource = resource(parsedParams, parsedSearchParams, parsedHeaders);
|
||||
const { action, resource: authResource, superScopes } = authorization;
|
||||
const $authResource = authResource(
|
||||
resource,
|
||||
parsedParams,
|
||||
parsedSearchParams,
|
||||
parsedHeaders
|
||||
);
|
||||
|
||||
logger.debug("Checking authorization", {
|
||||
action,
|
||||
resource: $resource,
|
||||
resource: $authResource,
|
||||
superScopes,
|
||||
scopes: authenticationResult.scopes,
|
||||
});
|
||||
@@ -149,12 +186,12 @@ export function createLoaderApiRoute<
|
||||
const authorizationResult = checkAuthorization(
|
||||
authenticationResult,
|
||||
action,
|
||||
$resource,
|
||||
$authResource,
|
||||
superScopes
|
||||
);
|
||||
|
||||
if (!authorizationResult.authorized) {
|
||||
return wrapResponse(
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json(
|
||||
{
|
||||
@@ -176,17 +213,24 @@ export function createLoaderApiRoute<
|
||||
headers: parsedHeaders,
|
||||
authentication: authenticationResult,
|
||||
request,
|
||||
resource,
|
||||
});
|
||||
return wrapResponse(request, result, corsStrategy !== "none");
|
||||
return await wrapResponse(request, result, corsStrategy !== "none");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) {
|
||||
return wrapResponse(request, error, corsStrategy !== "none");
|
||||
try {
|
||||
if (error instanceof Response) {
|
||||
return await wrapResponse(request, error, corsStrategy !== "none");
|
||||
}
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json({ error: "Internal Server Error" }, { status: 500 }),
|
||||
corsStrategy !== "none"
|
||||
);
|
||||
} catch (innerError) {
|
||||
logger.error("[apiBuilder] Failed to handle error", { error, innerError });
|
||||
|
||||
return json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
return wrapResponse(
|
||||
request,
|
||||
json({ error: "Internal Server Error" }, { status: 500 }),
|
||||
corsStrategy !== "none"
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -240,7 +284,7 @@ export function createLoaderPATApiRoute<
|
||||
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return wrapResponse(
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json({ error: "Invalid or Missing API key" }, { status: 401 }),
|
||||
corsStrategy !== "none"
|
||||
@@ -251,7 +295,7 @@ export function createLoaderPATApiRoute<
|
||||
if (paramsSchema) {
|
||||
const parsed = paramsSchema.safeParse(params);
|
||||
if (!parsed.success) {
|
||||
return wrapResponse(
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json(
|
||||
{ error: "Params Error", details: fromZodError(parsed.error).details },
|
||||
@@ -268,7 +312,7 @@ export function createLoaderPATApiRoute<
|
||||
const searchParams = Object.fromEntries(new URL(request.url).searchParams);
|
||||
const parsed = searchParamsSchema.safeParse(searchParams);
|
||||
if (!parsed.success) {
|
||||
return wrapResponse(
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json(
|
||||
{ error: "Query Error", details: fromZodError(parsed.error).details },
|
||||
@@ -285,7 +329,7 @@ export function createLoaderPATApiRoute<
|
||||
const rawHeaders = Object.fromEntries(request.headers);
|
||||
const headers = headersSchema.safeParse(rawHeaders);
|
||||
if (!headers.success) {
|
||||
return wrapResponse(
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json(
|
||||
{ error: "Headers Error", details: fromZodError(headers.error).details },
|
||||
@@ -304,17 +348,22 @@ export function createLoaderPATApiRoute<
|
||||
authentication: authenticationResult,
|
||||
request,
|
||||
});
|
||||
return wrapResponse(request, result, corsStrategy !== "none");
|
||||
return await wrapResponse(request, result, corsStrategy !== "none");
|
||||
} catch (error) {
|
||||
console.error("Error in API route:", error);
|
||||
if (error instanceof Response) {
|
||||
return wrapResponse(request, error, corsStrategy !== "none");
|
||||
try {
|
||||
if (error instanceof Response) {
|
||||
return await wrapResponse(request, error, corsStrategy !== "none");
|
||||
}
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json({ error: "Internal Server Error" }, { status: 500 }),
|
||||
corsStrategy !== "none"
|
||||
);
|
||||
} catch (innerError) {
|
||||
logger.error("[apiBuilder] Failed to handle error", { error, innerError });
|
||||
|
||||
return json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
return wrapResponse(
|
||||
request,
|
||||
json({ error: "Internal Server Error" }, { status: 500 }),
|
||||
corsStrategy !== "none"
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -324,7 +373,24 @@ type ApiKeyActionRouteBuilderOptions<
|
||||
TSearchParamsSchema extends z.AnyZodObject | undefined = undefined,
|
||||
THeadersSchema extends z.AnyZodObject | undefined = undefined,
|
||||
TBodySchema extends z.AnyZodObject | undefined = undefined
|
||||
> = ApiKeyRouteBuilderOptions<TParamsSchema, TSearchParamsSchema, THeadersSchema> & {
|
||||
> = {
|
||||
params?: TParamsSchema;
|
||||
searchParams?: TSearchParamsSchema;
|
||||
headers?: THeadersSchema;
|
||||
allowJWT?: boolean;
|
||||
corsStrategy?: "all" | "none";
|
||||
authorization?: {
|
||||
action: AuthorizationAction;
|
||||
resource: (
|
||||
params: TParamsSchema extends z.AnyZodObject ? z.infer<TParamsSchema> : undefined,
|
||||
searchParams: TSearchParamsSchema extends z.AnyZodObject
|
||||
? z.infer<TSearchParamsSchema>
|
||||
: undefined,
|
||||
headers: THeadersSchema extends z.AnyZodObject ? z.infer<THeadersSchema> : undefined,
|
||||
body: TBodySchema extends z.AnyZodObject ? z.infer<TBodySchema> : undefined
|
||||
) => AuthorizationResources;
|
||||
superScopes?: string[];
|
||||
};
|
||||
maxContentLength?: number;
|
||||
body?: TBodySchema;
|
||||
};
|
||||
@@ -341,7 +407,7 @@ type ApiKeyActionHandlerFunction<
|
||||
: undefined;
|
||||
headers: THeadersSchema extends z.AnyZodObject ? z.infer<THeadersSchema> : undefined;
|
||||
body: TBodySchema extends z.AnyZodObject ? z.infer<TBodySchema> : undefined;
|
||||
authentication: ApiAuthenticationResult;
|
||||
authentication: ApiAuthenticationResultSuccess;
|
||||
request: Request;
|
||||
}) => Promise<Response>;
|
||||
|
||||
@@ -385,16 +451,24 @@ export function createActionApiRoute<
|
||||
|
||||
async function action({ request, params }: ActionFunctionArgs) {
|
||||
try {
|
||||
const authenticationResult = await authenticateApiRequest(request, { allowJWT });
|
||||
const authenticationResult = await authenticateApiRequestWithFailure(request, { allowJWT });
|
||||
|
||||
if (!authenticationResult) {
|
||||
return wrapResponse(
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json({ error: "Invalid or Missing API key" }, { status: 401 }),
|
||||
corsStrategy !== "none"
|
||||
);
|
||||
}
|
||||
|
||||
if (!authenticationResult.ok) {
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json({ error: authenticationResult.error }, { status: 401 }),
|
||||
corsStrategy !== "none"
|
||||
);
|
||||
}
|
||||
|
||||
if (maxContentLength) {
|
||||
const contentLength = request.headers.get("content-length");
|
||||
|
||||
@@ -407,7 +481,7 @@ export function createActionApiRoute<
|
||||
if (paramsSchema) {
|
||||
const parsed = paramsSchema.safeParse(params);
|
||||
if (!parsed.success) {
|
||||
return wrapResponse(
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json(
|
||||
{ error: "Params Error", details: fromZodError(parsed.error).details },
|
||||
@@ -424,7 +498,7 @@ export function createActionApiRoute<
|
||||
const searchParams = Object.fromEntries(new URL(request.url).searchParams);
|
||||
const parsed = searchParamsSchema.safeParse(searchParams);
|
||||
if (!parsed.success) {
|
||||
return wrapResponse(
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json(
|
||||
{ error: "Query Error", details: fromZodError(parsed.error).details },
|
||||
@@ -441,7 +515,7 @@ export function createActionApiRoute<
|
||||
const rawHeaders = Object.fromEntries(request.headers);
|
||||
const headers = headersSchema.safeParse(rawHeaders);
|
||||
if (!headers.success) {
|
||||
return wrapResponse(
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json(
|
||||
{ error: "Headers Error", details: fromZodError(headers.error).details },
|
||||
@@ -457,7 +531,7 @@ export function createActionApiRoute<
|
||||
if (bodySchema) {
|
||||
const rawBody = await request.text();
|
||||
if (rawBody.length === 0) {
|
||||
return wrapResponse(
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json({ error: "Request body is empty" }, { status: 400 }),
|
||||
corsStrategy !== "none"
|
||||
@@ -467,7 +541,7 @@ export function createActionApiRoute<
|
||||
const rawParsedJson = safeJsonParse(rawBody);
|
||||
|
||||
if (!rawParsedJson) {
|
||||
return wrapResponse(
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json({ error: "Invalid JSON" }, { status: 400 }),
|
||||
corsStrategy !== "none"
|
||||
@@ -476,7 +550,7 @@ export function createActionApiRoute<
|
||||
|
||||
const body = bodySchema.safeParse(rawParsedJson);
|
||||
if (!body.success) {
|
||||
return wrapResponse(
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json({ error: fromZodError(body.error).toString() }, { status: 400 }),
|
||||
corsStrategy !== "none"
|
||||
@@ -487,7 +561,7 @@ export function createActionApiRoute<
|
||||
|
||||
if (authorization) {
|
||||
const { action, resource, superScopes } = authorization;
|
||||
const $resource = resource(parsedParams, parsedSearchParams, parsedHeaders);
|
||||
const $resource = resource(parsedParams, parsedSearchParams, parsedHeaders, parsedBody);
|
||||
|
||||
logger.debug("Checking authorization", {
|
||||
action,
|
||||
@@ -496,10 +570,25 @@ export function createActionApiRoute<
|
||||
scopes: authenticationResult.scopes,
|
||||
});
|
||||
|
||||
if (!checkAuthorization(authenticationResult, action, $resource, superScopes)) {
|
||||
return wrapResponse(
|
||||
const authorizationResult = checkAuthorization(
|
||||
authenticationResult,
|
||||
action,
|
||||
$resource,
|
||||
superScopes
|
||||
);
|
||||
|
||||
if (!authorizationResult.authorized) {
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json({ error: "Unauthorized" }, { status: 403 }),
|
||||
json(
|
||||
{
|
||||
error: `Unauthorized: ${authorizationResult.reason}`,
|
||||
code: "unauthorized",
|
||||
param: "access_token",
|
||||
type: "authorization",
|
||||
},
|
||||
{ status: 403 }
|
||||
),
|
||||
corsStrategy !== "none"
|
||||
);
|
||||
}
|
||||
@@ -513,24 +602,36 @@ export function createActionApiRoute<
|
||||
authentication: authenticationResult,
|
||||
request,
|
||||
});
|
||||
return wrapResponse(request, result, corsStrategy !== "none");
|
||||
return await wrapResponse(request, result, corsStrategy !== "none");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) {
|
||||
return wrapResponse(request, error, corsStrategy !== "none");
|
||||
try {
|
||||
if (error instanceof Response) {
|
||||
return await wrapResponse(request, error, corsStrategy !== "none");
|
||||
}
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json({ error: "Internal Server Error" }, { status: 500 }),
|
||||
corsStrategy !== "none"
|
||||
);
|
||||
} catch (innerError) {
|
||||
logger.error("[apiBuilder] Failed to handle error", { error, innerError });
|
||||
|
||||
return json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
return wrapResponse(
|
||||
request,
|
||||
json({ error: "Internal Server Error" }, { status: 500 }),
|
||||
corsStrategy !== "none"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { loader, action };
|
||||
}
|
||||
|
||||
function wrapResponse(request: Request, response: Response, useCors: boolean) {
|
||||
async function wrapResponse(
|
||||
request: Request,
|
||||
response: Response,
|
||||
useCors: boolean
|
||||
): Promise<Response> {
|
||||
return useCors
|
||||
? apiCors(request, response, { exposedHeaders: ["x-trigger-jwt", "x-trigger-jwt-claims"] })
|
||||
? await apiCors(request, response, {
|
||||
exposedHeaders: ["x-trigger-jwt", "x-trigger-jwt-claims"],
|
||||
})
|
||||
: response;
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
CancelDevSessionRunsServiceOptions,
|
||||
} from "~/v3/services/cancelDevSessionRuns.server";
|
||||
import { logger } from "./logger.server";
|
||||
import { BatchProcessingOptions, BatchTriggerV2Service } from "~/v3/services/batchTriggerV2.server";
|
||||
|
||||
const workerCatalog = {
|
||||
indexEndpoint: z.object({
|
||||
@@ -197,6 +198,7 @@ const workerCatalog = {
|
||||
attemptId: z.string(),
|
||||
}),
|
||||
"v3.cancelDevSessionRuns": CancelDevSessionRunsServiceOptions,
|
||||
"v3.processBatchTaskRun": BatchProcessingOptions,
|
||||
};
|
||||
|
||||
const executionWorkerCatalog = {
|
||||
@@ -727,6 +729,15 @@ function getWorkerQueue() {
|
||||
return await service.call(payload);
|
||||
},
|
||||
},
|
||||
"v3.processBatchTaskRun": {
|
||||
priority: 0,
|
||||
maxAttempts: 5,
|
||||
handler: async (payload, job) => {
|
||||
const service = new BatchTriggerV2Service();
|
||||
|
||||
await service.processBatchTaskRun(payload);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,6 +13,14 @@
|
||||
@apply bg-background-dimmed text-text-dimmed;
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
|
||||
/* Text selection styles */
|
||||
::selection {
|
||||
@apply bg-text-bright/30 text-text-bright;
|
||||
}
|
||||
::-moz-selection {
|
||||
@apply bg-text-bright/30 text-text-bright;
|
||||
}
|
||||
|
||||
/* shadcn charts: https://ui.shadcn.com/docs/components/chart#add-a-grid */
|
||||
:root {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Resolve the TTL for an idempotency key.
|
||||
*
|
||||
* The TTL format is a string like "5m", "1h", "7d"
|
||||
*
|
||||
* @param ttl The TTL string
|
||||
* @returns The date when the key will expire
|
||||
* @throws If the TTL string is invalid
|
||||
*/
|
||||
export function resolveIdempotencyKeyTTL(ttl: string | undefined | null): Date | undefined {
|
||||
if (!ttl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const match = ttl.match(/^(\d+)([smhd])$/);
|
||||
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [, value, unit] = match;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
switch (unit) {
|
||||
case "s":
|
||||
now.setSeconds(now.getSeconds() + parseInt(value, 10));
|
||||
break;
|
||||
case "m":
|
||||
now.setMinutes(now.getMinutes() + parseInt(value, 10));
|
||||
break;
|
||||
case "h":
|
||||
now.setHours(now.getHours() + parseInt(value, 10));
|
||||
break;
|
||||
case "d":
|
||||
now.setDate(now.getDate() + parseInt(value, 10));
|
||||
break;
|
||||
}
|
||||
|
||||
return now;
|
||||
}
|
||||
@@ -437,6 +437,26 @@ export function v3NewSchedulePath(organization: OrgForPath, project: ProjectForP
|
||||
return `${v3ProjectPath(organization, project)}/schedules/new`;
|
||||
}
|
||||
|
||||
export function v3BatchesPath(organization: OrgForPath, project: ProjectForPath) {
|
||||
return `${v3ProjectPath(organization, project)}/batches`;
|
||||
}
|
||||
|
||||
export function v3BatchPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
batch: { friendlyId: string }
|
||||
) {
|
||||
return `${v3ProjectPath(organization, project)}/batches?id=${batch.friendlyId}`;
|
||||
}
|
||||
|
||||
export function v3BatchRunsPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
batch: { friendlyId: string }
|
||||
) {
|
||||
return `${v3ProjectPath(organization, project)}/runs?batchId=${batch.friendlyId}`;
|
||||
}
|
||||
|
||||
export function v3ProjectSettingsPath(organization: OrgForPath, project: ProjectForPath) {
|
||||
return `${v3ProjectPath(organization, project)}/settings`;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ async function handleWebSocketConnection(ws: WebSocket, req: IncomingMessage) {
|
||||
|
||||
const authenticationResult = await authenticateApiKey(apiKey);
|
||||
|
||||
if (!authenticationResult) {
|
||||
if (!authenticationResult || !authenticationResult.ok) {
|
||||
ws.close(1008, "Invalid API key");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -378,24 +378,15 @@ export class DevQueueConsumer {
|
||||
lockedById: backgroundTask.id,
|
||||
status: "EXECUTING",
|
||||
lockedToVersionId: backgroundWorker.id,
|
||||
taskVersion: backgroundWorker.version,
|
||||
sdkVersion: backgroundWorker.sdkVersion,
|
||||
cliVersion: backgroundWorker.cliVersion,
|
||||
startedAt: existingTaskRun.startedAt ?? new Date(),
|
||||
maxDurationInSeconds: getMaxDuration(
|
||||
existingTaskRun.maxDurationInSeconds,
|
||||
backgroundTask.maxDurationInSeconds
|
||||
),
|
||||
},
|
||||
include: {
|
||||
attempts: {
|
||||
take: 1,
|
||||
orderBy: { number: "desc" },
|
||||
},
|
||||
tags: true,
|
||||
batchItems: {
|
||||
include: {
|
||||
batchTaskRun: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!lockedTaskRun) {
|
||||
|
||||
@@ -407,6 +407,9 @@ export class SharedQueueConsumer {
|
||||
lockedAt: new Date(),
|
||||
lockedById: backgroundTask.id,
|
||||
lockedToVersionId: deployment.worker.id,
|
||||
taskVersion: deployment.worker.version,
|
||||
sdkVersion: deployment.worker.sdkVersion,
|
||||
cliVersion: deployment.worker.cliVersion,
|
||||
startedAt: existingTaskRun.startedAt ?? new Date(),
|
||||
baseCostInCents: env.CENTS_PER_RUN,
|
||||
machinePreset: machinePresetFromConfig(backgroundTask.machineConfig ?? {}).name,
|
||||
@@ -1035,6 +1038,7 @@ class SharedQueueTasks {
|
||||
id: attempt.taskRun.friendlyId,
|
||||
output: attempt.output ?? undefined,
|
||||
outputType: attempt.outputType,
|
||||
taskIdentifier: attempt.taskRun.taskIdentifier,
|
||||
};
|
||||
return success;
|
||||
} else {
|
||||
@@ -1042,6 +1046,7 @@ class SharedQueueTasks {
|
||||
ok,
|
||||
id: attempt.taskRun.friendlyId,
|
||||
error: attempt.error as TaskRunError,
|
||||
taskIdentifier: attempt.taskRun.taskIdentifier,
|
||||
};
|
||||
return failure;
|
||||
}
|
||||
@@ -1076,7 +1081,11 @@ class SharedQueueTasks {
|
||||
tags: true,
|
||||
batchItems: {
|
||||
include: {
|
||||
batchTaskRun: true,
|
||||
batchTaskRun: {
|
||||
select: {
|
||||
friendlyId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,7 +10,8 @@ export type QueueSizeGuardResult = {
|
||||
|
||||
export async function guardQueueSizeLimitsForEnv(
|
||||
environment: AuthenticatedEnvironment,
|
||||
marqs?: MarQS
|
||||
marqs?: MarQS,
|
||||
itemsToAdd: number = 1
|
||||
): Promise<QueueSizeGuardResult> {
|
||||
const maximumSize = getMaximumSizeForEnvironment(environment);
|
||||
|
||||
@@ -23,9 +24,10 @@ export async function guardQueueSizeLimitsForEnv(
|
||||
}
|
||||
|
||||
const queueSize = await marqs.lengthOfEnvQueue(environment);
|
||||
const projectedSize = queueSize + itemsToAdd;
|
||||
|
||||
return {
|
||||
isWithinLimits: queueSize < maximumSize,
|
||||
isWithinLimits: projectedSize <= maximumSize,
|
||||
maximumSize,
|
||||
queueSize,
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import { startActiveSpan } from "./tracer.server";
|
||||
import { IOPacket } from "@trigger.dev/core/v3";
|
||||
|
||||
export const r2 = singleton("r2", initializeR2);
|
||||
|
||||
@@ -18,13 +19,13 @@ function initializeR2() {
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadToObjectStore(
|
||||
export async function uploadPacketToObjectStore(
|
||||
filename: string,
|
||||
data: string,
|
||||
data: ReadableStream | string,
|
||||
contentType: string,
|
||||
environment: AuthenticatedEnvironment
|
||||
): Promise<string> {
|
||||
return await startActiveSpan("uploadToObjectStore()", async (span) => {
|
||||
return await startActiveSpan("uploadPacketToObjectStore()", async (span) => {
|
||||
if (!r2) {
|
||||
throw new Error("Object store credentials are not set");
|
||||
}
|
||||
@@ -60,6 +61,92 @@ export async function uploadToObjectStore(
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadPacketFromObjectStore(
|
||||
packet: IOPacket,
|
||||
environment: AuthenticatedEnvironment
|
||||
): Promise<IOPacket> {
|
||||
if (packet.dataType !== "application/store") {
|
||||
return packet;
|
||||
}
|
||||
|
||||
return await startActiveSpan("downloadPacketFromObjectStore()", async (span) => {
|
||||
if (!r2) {
|
||||
throw new Error("Object store credentials are not set");
|
||||
}
|
||||
|
||||
if (!env.OBJECT_STORE_BASE_URL) {
|
||||
throw new Error("Object store base URL is not set");
|
||||
}
|
||||
|
||||
span.setAttributes({
|
||||
projectRef: environment.project.externalRef,
|
||||
environmentSlug: environment.slug,
|
||||
filename: packet.data,
|
||||
});
|
||||
|
||||
const url = new URL(env.OBJECT_STORE_BASE_URL);
|
||||
url.pathname = `/packets/${environment.project.externalRef}/${environment.slug}/${packet.data}`;
|
||||
|
||||
logger.debug("Downloading from object store", { url: url.href });
|
||||
|
||||
const response = await r2.fetch(url.toString());
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download input from ${url}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.text();
|
||||
|
||||
const rawPacket = {
|
||||
data,
|
||||
dataType: "application/json",
|
||||
};
|
||||
|
||||
return rawPacket;
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadDataToObjectStore(
|
||||
filename: string,
|
||||
data: string,
|
||||
contentType: string,
|
||||
prefix?: string
|
||||
): Promise<string> {
|
||||
return await startActiveSpan("uploadDataToObjectStore()", async (span) => {
|
||||
if (!r2) {
|
||||
throw new Error("Object store credentials are not set");
|
||||
}
|
||||
|
||||
if (!env.OBJECT_STORE_BASE_URL) {
|
||||
throw new Error("Object store base URL is not set");
|
||||
}
|
||||
|
||||
span.setAttributes({
|
||||
prefix,
|
||||
filename,
|
||||
});
|
||||
|
||||
const url = new URL(env.OBJECT_STORE_BASE_URL);
|
||||
url.pathname = `${prefix}/${filename}`;
|
||||
|
||||
logger.debug("Uploading to object store", { url: url.href });
|
||||
|
||||
const response = await r2.fetch(url.toString(), {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
},
|
||||
body: data,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to upload data to ${url}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return url.href;
|
||||
});
|
||||
}
|
||||
|
||||
export async function generatePresignedRequest(
|
||||
projectRef: string,
|
||||
envSlug: string,
|
||||
|
||||
@@ -26,10 +26,9 @@ export class BatchTriggerTaskService extends BaseService {
|
||||
const existingBatch = options.idempotencyKey
|
||||
? await this._prisma.batchTaskRun.findUnique({
|
||||
where: {
|
||||
runtimeEnvironmentId_taskIdentifier_idempotencyKey: {
|
||||
runtimeEnvironmentId_idempotencyKey: {
|
||||
runtimeEnvironmentId: environment.id,
|
||||
idempotencyKey: options.idempotencyKey,
|
||||
taskIdentifier: taskId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
|
||||
@@ -0,0 +1,825 @@
|
||||
import {
|
||||
BatchTriggerTaskV2RequestBody,
|
||||
BatchTriggerTaskV2Response,
|
||||
IOPacket,
|
||||
packetRequiresOffloading,
|
||||
parsePacket,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { BatchTaskRun, Prisma, TaskRunAttempt } from "@trigger.dev/database";
|
||||
import { $transaction, PrismaClientOrTransaction } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { batchTaskRunItemStatusForRunStatus } from "~/models/taskRun.server";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { getEntitlement } from "~/services/platform.v3.server";
|
||||
import { workerQueue } from "~/services/worker.server";
|
||||
import { generateFriendlyId } from "../friendlyIdentifiers";
|
||||
import { marqs } from "../marqs/index.server";
|
||||
import { guardQueueSizeLimitsForEnv } from "../queueSizeLimits.server";
|
||||
import { downloadPacketFromObjectStore, uploadPacketToObjectStore } from "../r2.server";
|
||||
import { isFinalAttemptStatus, isFinalRunStatus } from "../taskStatus";
|
||||
import { startActiveSpan } from "../tracer.server";
|
||||
import { BaseService, ServiceValidationError } from "./baseService.server";
|
||||
import { OutOfEntitlementError, TriggerTaskService } from "./triggerTask.server";
|
||||
import { z } from "zod";
|
||||
|
||||
const PROCESSING_BATCH_SIZE = 50;
|
||||
const ASYNC_BATCH_PROCESS_SIZE_THRESHOLD = 20;
|
||||
|
||||
const BatchProcessingStrategy = z.enum(["sequential", "parallel"]);
|
||||
|
||||
type BatchProcessingStrategy = z.infer<typeof BatchProcessingStrategy>;
|
||||
|
||||
const CURRENT_STRATEGY: BatchProcessingStrategy = "parallel";
|
||||
|
||||
export const BatchProcessingOptions = z.object({
|
||||
batchId: z.string(),
|
||||
processingId: z.string(),
|
||||
range: z.object({ start: z.number().int(), count: z.number().int() }),
|
||||
attemptCount: z.number().int(),
|
||||
strategy: BatchProcessingStrategy,
|
||||
});
|
||||
|
||||
export type BatchProcessingOptions = z.infer<typeof BatchProcessingOptions>;
|
||||
|
||||
export type BatchTriggerTaskServiceOptions = {
|
||||
idempotencyKey?: string;
|
||||
idempotencyKeyExpiresAt?: Date;
|
||||
triggerVersion?: string;
|
||||
traceContext?: Record<string, string | undefined>;
|
||||
spanParentAsLink?: boolean;
|
||||
oneTimeUseToken?: string;
|
||||
};
|
||||
|
||||
export class BatchTriggerV2Service extends BaseService {
|
||||
public async call(
|
||||
environment: AuthenticatedEnvironment,
|
||||
body: BatchTriggerTaskV2RequestBody,
|
||||
options: BatchTriggerTaskServiceOptions = {}
|
||||
): Promise<BatchTriggerTaskV2Response> {
|
||||
try {
|
||||
return await this.traceWithEnv<BatchTriggerTaskV2Response>(
|
||||
"call()",
|
||||
environment,
|
||||
async (span) => {
|
||||
const existingBatch = options.idempotencyKey
|
||||
? await this._prisma.batchTaskRun.findUnique({
|
||||
where: {
|
||||
runtimeEnvironmentId_idempotencyKey: {
|
||||
runtimeEnvironmentId: environment.id,
|
||||
idempotencyKey: options.idempotencyKey,
|
||||
},
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (existingBatch) {
|
||||
if (
|
||||
existingBatch.idempotencyKeyExpiresAt &&
|
||||
existingBatch.idempotencyKeyExpiresAt < new Date()
|
||||
) {
|
||||
logger.debug("[BatchTriggerV2][call] Idempotency key has expired", {
|
||||
idempotencyKey: options.idempotencyKey,
|
||||
batch: {
|
||||
id: existingBatch.id,
|
||||
friendlyId: existingBatch.friendlyId,
|
||||
runCount: existingBatch.runCount,
|
||||
idempotencyKeyExpiresAt: existingBatch.idempotencyKeyExpiresAt,
|
||||
idempotencyKey: existingBatch.idempotencyKey,
|
||||
},
|
||||
});
|
||||
|
||||
// Update the existing batch to remove the idempotency key
|
||||
await this._prisma.batchTaskRun.update({
|
||||
where: { id: existingBatch.id },
|
||||
data: { idempotencyKey: null },
|
||||
});
|
||||
|
||||
// Don't return, just continue with the batch trigger
|
||||
} else {
|
||||
span.setAttribute("batchId", existingBatch.friendlyId);
|
||||
|
||||
return this.#respondWithExistingBatch(existingBatch, environment);
|
||||
}
|
||||
}
|
||||
|
||||
const batchId = generateFriendlyId("batch");
|
||||
|
||||
span.setAttribute("batchId", batchId);
|
||||
|
||||
const dependentAttempt = body?.dependentAttempt
|
||||
? await this._prisma.taskRunAttempt.findUnique({
|
||||
where: { friendlyId: body.dependentAttempt },
|
||||
include: {
|
||||
taskRun: {
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
dependentAttempt &&
|
||||
(isFinalAttemptStatus(dependentAttempt.status) ||
|
||||
isFinalRunStatus(dependentAttempt.taskRun.status))
|
||||
) {
|
||||
logger.debug("[BatchTriggerV2][call] Dependent attempt or run is in a terminal state", {
|
||||
dependentAttempt: dependentAttempt,
|
||||
batchId,
|
||||
});
|
||||
|
||||
throw new ServiceValidationError(
|
||||
"Cannot process batch as the parent run is already in a terminal state"
|
||||
);
|
||||
}
|
||||
|
||||
if (environment.type !== "DEVELOPMENT") {
|
||||
const result = await getEntitlement(environment.organizationId);
|
||||
if (result && result.hasAccess === false) {
|
||||
throw new OutOfEntitlementError();
|
||||
}
|
||||
}
|
||||
|
||||
const idempotencyKeys = body.items.map((i) => i.options?.idempotencyKey).filter(Boolean);
|
||||
|
||||
const cachedRuns =
|
||||
idempotencyKeys.length > 0
|
||||
? await this._prisma.taskRun.findMany({
|
||||
where: {
|
||||
runtimeEnvironmentId: environment.id,
|
||||
idempotencyKey: {
|
||||
in: body.items.map((i) => i.options?.idempotencyKey).filter(Boolean),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
friendlyId: true,
|
||||
idempotencyKey: true,
|
||||
idempotencyKeyExpiresAt: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
if (cachedRuns.length) {
|
||||
logger.debug("[BatchTriggerV2][call] Found cached runs", {
|
||||
cachedRuns,
|
||||
batchId,
|
||||
});
|
||||
}
|
||||
|
||||
// Now we need to create an array of all the run IDs, in order
|
||||
// If we have a cached run, that isn't expired, we should use that run ID
|
||||
// If we have a cached run, that is expired, we should generate a new run ID and save that cached run ID to a set of expired run IDs
|
||||
// If we don't have a cached run, we should generate a new run ID
|
||||
const expiredRunIds = new Set<string>();
|
||||
let cachedRunCount = 0;
|
||||
|
||||
const runs = body.items.map((item) => {
|
||||
const cachedRun = cachedRuns.find(
|
||||
(r) => r.idempotencyKey === item.options?.idempotencyKey
|
||||
);
|
||||
|
||||
if (cachedRun) {
|
||||
if (
|
||||
cachedRun.idempotencyKeyExpiresAt &&
|
||||
cachedRun.idempotencyKeyExpiresAt < new Date()
|
||||
) {
|
||||
expiredRunIds.add(cachedRun.friendlyId);
|
||||
|
||||
return {
|
||||
id: generateFriendlyId("run"),
|
||||
isCached: false,
|
||||
idempotencyKey: item.options?.idempotencyKey ?? undefined,
|
||||
taskIdentifier: item.task,
|
||||
};
|
||||
}
|
||||
|
||||
cachedRunCount++;
|
||||
|
||||
return {
|
||||
id: cachedRun.friendlyId,
|
||||
isCached: true,
|
||||
idempotencyKey: item.options?.idempotencyKey ?? undefined,
|
||||
taskIdentifier: item.task,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: generateFriendlyId("run"),
|
||||
isCached: false,
|
||||
idempotencyKey: item.options?.idempotencyKey ?? undefined,
|
||||
taskIdentifier: item.task,
|
||||
};
|
||||
});
|
||||
|
||||
// Calculate how many new runs we need to create
|
||||
const newRunCount = body.items.length - cachedRunCount;
|
||||
|
||||
if (newRunCount === 0) {
|
||||
logger.debug("[BatchTriggerV2][call] All runs are cached", {
|
||||
batchId,
|
||||
});
|
||||
|
||||
await this._prisma.batchTaskRun.create({
|
||||
data: {
|
||||
friendlyId: batchId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
idempotencyKey: options.idempotencyKey,
|
||||
idempotencyKeyExpiresAt: options.idempotencyKeyExpiresAt,
|
||||
dependentTaskAttemptId: dependentAttempt?.id,
|
||||
runCount: body.items.length,
|
||||
runIds: runs.map((r) => r.id),
|
||||
status: "COMPLETED",
|
||||
batchVersion: "v2",
|
||||
oneTimeUseToken: options.oneTimeUseToken,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: batchId,
|
||||
isCached: false,
|
||||
idempotencyKey: options.idempotencyKey ?? undefined,
|
||||
runs,
|
||||
};
|
||||
}
|
||||
|
||||
const queueSizeGuard = await guardQueueSizeLimitsForEnv(environment, marqs, newRunCount);
|
||||
|
||||
logger.debug("Queue size guard result", {
|
||||
newRunCount,
|
||||
queueSizeGuard,
|
||||
environment: {
|
||||
id: environment.id,
|
||||
type: environment.type,
|
||||
organization: environment.organization,
|
||||
project: environment.project,
|
||||
},
|
||||
});
|
||||
|
||||
if (!queueSizeGuard.isWithinLimits) {
|
||||
throw new ServiceValidationError(
|
||||
`Cannot trigger ${newRunCount} tasks as the queue size limit for this environment has been reached. The maximum size is ${queueSizeGuard.maximumSize}`
|
||||
);
|
||||
}
|
||||
|
||||
// Expire the cached runs that are no longer valid
|
||||
if (expiredRunIds.size) {
|
||||
logger.debug("Expiring cached runs", {
|
||||
expiredRunIds: Array.from(expiredRunIds),
|
||||
batchId,
|
||||
});
|
||||
|
||||
// TODO: is there a limit to the number of items we can update in a single query?
|
||||
await this._prisma.taskRun.updateMany({
|
||||
where: { friendlyId: { in: Array.from(expiredRunIds) } },
|
||||
data: { idempotencyKey: null },
|
||||
});
|
||||
}
|
||||
|
||||
// Upload to object store
|
||||
const payloadPacket = await this.#handlePayloadPacket(
|
||||
body.items,
|
||||
`batch/${batchId}`,
|
||||
environment
|
||||
);
|
||||
|
||||
const batch = await this.#createAndProcessBatchTaskRun(
|
||||
batchId,
|
||||
runs,
|
||||
payloadPacket,
|
||||
newRunCount,
|
||||
environment,
|
||||
body,
|
||||
options,
|
||||
dependentAttempt ?? undefined
|
||||
);
|
||||
|
||||
if (!batch) {
|
||||
throw new Error("Failed to create batch");
|
||||
}
|
||||
|
||||
return {
|
||||
id: batch.friendlyId,
|
||||
isCached: false,
|
||||
idempotencyKey: batch.idempotencyKey ?? undefined,
|
||||
runs,
|
||||
};
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
// Detect a prisma transaction Unique constraint violation
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.debug("BatchTriggerV2: Prisma transaction error", {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
meta: error.meta,
|
||||
});
|
||||
|
||||
if (error.code === "P2002") {
|
||||
const target = error.meta?.target;
|
||||
|
||||
if (
|
||||
Array.isArray(target) &&
|
||||
target.length > 0 &&
|
||||
typeof target[0] === "string" &&
|
||||
target[0].includes("oneTimeUseToken")
|
||||
) {
|
||||
throw new ServiceValidationError(
|
||||
"Cannot batch trigger with a one-time use token as it has already been used."
|
||||
);
|
||||
} else {
|
||||
throw new ServiceValidationError(
|
||||
"Cannot batch trigger as it has already been triggered with the same idempotency key."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async #createAndProcessBatchTaskRun(
|
||||
batchId: string,
|
||||
runs: Array<{
|
||||
id: string;
|
||||
isCached: boolean;
|
||||
idempotencyKey: string | undefined;
|
||||
taskIdentifier: string;
|
||||
}>,
|
||||
payloadPacket: IOPacket,
|
||||
newRunCount: number,
|
||||
environment: AuthenticatedEnvironment,
|
||||
body: BatchTriggerTaskV2RequestBody,
|
||||
options: BatchTriggerTaskServiceOptions = {},
|
||||
dependentAttempt?: TaskRunAttempt
|
||||
) {
|
||||
if (newRunCount <= ASYNC_BATCH_PROCESS_SIZE_THRESHOLD) {
|
||||
const batch = await this._prisma.batchTaskRun.create({
|
||||
data: {
|
||||
friendlyId: batchId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
idempotencyKey: options.idempotencyKey,
|
||||
idempotencyKeyExpiresAt: options.idempotencyKeyExpiresAt,
|
||||
dependentTaskAttemptId: dependentAttempt?.id,
|
||||
runCount: newRunCount,
|
||||
runIds: runs.map((r) => r.id),
|
||||
payload: payloadPacket.data,
|
||||
payloadType: payloadPacket.dataType,
|
||||
options,
|
||||
batchVersion: "v2",
|
||||
oneTimeUseToken: options.oneTimeUseToken,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await this.#processBatchTaskRunItems(
|
||||
batch,
|
||||
environment,
|
||||
0,
|
||||
PROCESSING_BATCH_SIZE,
|
||||
body.items,
|
||||
options
|
||||
);
|
||||
|
||||
switch (result.status) {
|
||||
case "COMPLETE": {
|
||||
logger.debug("[BatchTriggerV2][call] Batch inline processing complete", {
|
||||
batchId: batch.friendlyId,
|
||||
currentIndex: 0,
|
||||
});
|
||||
|
||||
return batch;
|
||||
}
|
||||
case "INCOMPLETE": {
|
||||
logger.debug("[BatchTriggerV2][call] Batch inline processing incomplete", {
|
||||
batchId: batch.friendlyId,
|
||||
currentIndex: result.workingIndex,
|
||||
});
|
||||
|
||||
// If processing inline does not finish for some reason, enqueue processing the rest of the batch
|
||||
await this.#enqueueBatchTaskRun({
|
||||
batchId: batch.id,
|
||||
processingId: "0",
|
||||
range: {
|
||||
start: result.workingIndex,
|
||||
count: PROCESSING_BATCH_SIZE,
|
||||
},
|
||||
attemptCount: 0,
|
||||
strategy: "sequential",
|
||||
});
|
||||
|
||||
return batch;
|
||||
}
|
||||
case "ERROR": {
|
||||
logger.error("[BatchTriggerV2][call] Batch inline processing error", {
|
||||
batchId: batch.friendlyId,
|
||||
currentIndex: result.workingIndex,
|
||||
error: result.error,
|
||||
});
|
||||
|
||||
await this.#enqueueBatchTaskRun({
|
||||
batchId: batch.id,
|
||||
processingId: "0",
|
||||
range: {
|
||||
start: result.workingIndex,
|
||||
count: PROCESSING_BATCH_SIZE,
|
||||
},
|
||||
attemptCount: 0,
|
||||
strategy: "sequential",
|
||||
});
|
||||
|
||||
return batch;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return await $transaction(this._prisma, async (tx) => {
|
||||
const batch = await tx.batchTaskRun.create({
|
||||
data: {
|
||||
friendlyId: batchId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
idempotencyKey: options.idempotencyKey,
|
||||
idempotencyKeyExpiresAt: options.idempotencyKeyExpiresAt,
|
||||
dependentTaskAttemptId: dependentAttempt?.id,
|
||||
runCount: body.items.length,
|
||||
runIds: runs.map((r) => r.id),
|
||||
payload: payloadPacket.data,
|
||||
payloadType: payloadPacket.dataType,
|
||||
options,
|
||||
batchVersion: "v2",
|
||||
oneTimeUseToken: options.oneTimeUseToken,
|
||||
},
|
||||
});
|
||||
|
||||
switch (CURRENT_STRATEGY) {
|
||||
case "sequential": {
|
||||
await this.#enqueueBatchTaskRun({
|
||||
batchId: batch.id,
|
||||
processingId: batchId,
|
||||
range: { start: 0, count: PROCESSING_BATCH_SIZE },
|
||||
attemptCount: 0,
|
||||
strategy: CURRENT_STRATEGY,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "parallel": {
|
||||
const ranges = Array.from({
|
||||
length: Math.ceil(newRunCount / PROCESSING_BATCH_SIZE),
|
||||
}).map((_, index) => ({
|
||||
start: index * PROCESSING_BATCH_SIZE,
|
||||
count: PROCESSING_BATCH_SIZE,
|
||||
}));
|
||||
|
||||
await Promise.all(
|
||||
ranges.map((range, index) =>
|
||||
this.#enqueueBatchTaskRun(
|
||||
{
|
||||
batchId: batch.id,
|
||||
processingId: `${index}`,
|
||||
range,
|
||||
attemptCount: 0,
|
||||
strategy: CURRENT_STRATEGY,
|
||||
},
|
||||
tx
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return batch;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async #respondWithExistingBatch(
|
||||
batch: BatchTaskRun,
|
||||
environment: AuthenticatedEnvironment
|
||||
): Promise<BatchTriggerTaskV2Response> {
|
||||
// Resolve the payload
|
||||
const payloadPacket = await downloadPacketFromObjectStore(
|
||||
{
|
||||
data: batch.payload ?? undefined,
|
||||
dataType: batch.payloadType,
|
||||
},
|
||||
environment
|
||||
);
|
||||
|
||||
const payload = await parsePacket(payloadPacket).then(
|
||||
(p) => p as BatchTriggerTaskV2RequestBody["items"]
|
||||
);
|
||||
|
||||
const runs = batch.runIds.map((id, index) => {
|
||||
const item = payload[index];
|
||||
|
||||
return {
|
||||
id,
|
||||
taskIdentifier: item.task,
|
||||
isCached: true,
|
||||
idempotencyKey: item.options?.idempotencyKey ?? undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: batch.friendlyId,
|
||||
idempotencyKey: batch.idempotencyKey ?? undefined,
|
||||
isCached: true,
|
||||
runs,
|
||||
};
|
||||
}
|
||||
|
||||
async processBatchTaskRun(options: BatchProcessingOptions) {
|
||||
logger.debug("[BatchTriggerV2][processBatchTaskRun] Processing batch", {
|
||||
options,
|
||||
});
|
||||
|
||||
const $attemptCount = options.attemptCount + 1;
|
||||
|
||||
const batch = await this._prisma.batchTaskRun.findFirst({
|
||||
where: { id: options.batchId },
|
||||
include: {
|
||||
runtimeEnvironment: {
|
||||
include: {
|
||||
project: true,
|
||||
organization: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!batch) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check to make sure the currentIndex is not greater than the runCount
|
||||
if (options.range.start >= batch.runCount) {
|
||||
logger.debug("[BatchTriggerV2][processBatchTaskRun] currentIndex is greater than runCount", {
|
||||
options,
|
||||
batchId: batch.friendlyId,
|
||||
runCount: batch.runCount,
|
||||
attemptCount: $attemptCount,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the payload
|
||||
const payloadPacket = await downloadPacketFromObjectStore(
|
||||
{
|
||||
data: batch.payload ?? undefined,
|
||||
dataType: batch.payloadType,
|
||||
},
|
||||
batch.runtimeEnvironment
|
||||
);
|
||||
|
||||
const payload = await parsePacket(payloadPacket);
|
||||
|
||||
if (!payload) {
|
||||
logger.debug("[BatchTriggerV2][processBatchTaskRun] Failed to parse payload", {
|
||||
options,
|
||||
batchId: batch.friendlyId,
|
||||
attemptCount: $attemptCount,
|
||||
});
|
||||
|
||||
throw new Error("Failed to parse payload");
|
||||
}
|
||||
|
||||
// Skip zod parsing
|
||||
const $payload = payload as BatchTriggerTaskV2RequestBody["items"];
|
||||
const $options = batch.options as BatchTriggerTaskServiceOptions;
|
||||
|
||||
const result = await this.#processBatchTaskRunItems(
|
||||
batch,
|
||||
batch.runtimeEnvironment,
|
||||
options.range.start,
|
||||
options.range.count,
|
||||
$payload,
|
||||
$options
|
||||
);
|
||||
|
||||
switch (result.status) {
|
||||
case "COMPLETE": {
|
||||
logger.debug("[BatchTriggerV2][processBatchTaskRun] Batch processing complete", {
|
||||
options,
|
||||
batchId: batch.friendlyId,
|
||||
attemptCount: $attemptCount,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
case "INCOMPLETE": {
|
||||
logger.debug("[BatchTriggerV2][processBatchTaskRun] Batch processing incomplete", {
|
||||
batchId: batch.friendlyId,
|
||||
currentIndex: result.workingIndex,
|
||||
attemptCount: $attemptCount,
|
||||
});
|
||||
|
||||
// Only enqueue the next batch task run if the strategy is sequential
|
||||
// if the strategy is parallel, we will already have enqueued the next batch task run
|
||||
if (options.strategy === "sequential") {
|
||||
await this.#enqueueBatchTaskRun({
|
||||
batchId: batch.id,
|
||||
processingId: options.processingId,
|
||||
range: {
|
||||
start: result.workingIndex,
|
||||
count: options.range.count,
|
||||
},
|
||||
attemptCount: 0,
|
||||
strategy: options.strategy,
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
case "ERROR": {
|
||||
logger.error("[BatchTriggerV2][processBatchTaskRun] Batch processing error", {
|
||||
batchId: batch.friendlyId,
|
||||
currentIndex: result.workingIndex,
|
||||
error: result.error,
|
||||
attemptCount: $attemptCount,
|
||||
});
|
||||
|
||||
// if the strategy is sequential, we will requeue processing with a count of the PROCESSING_BATCH_SIZE
|
||||
// if the strategy is parallel, we will requeue processing with a range starting at the workingIndex and a count that is the remainder of this "slice" of the batch
|
||||
if (options.strategy === "sequential") {
|
||||
await this.#enqueueBatchTaskRun({
|
||||
batchId: batch.id,
|
||||
processingId: options.processingId,
|
||||
range: {
|
||||
start: result.workingIndex,
|
||||
count: options.range.count, // This will be the same as the original count
|
||||
},
|
||||
attemptCount: $attemptCount,
|
||||
strategy: options.strategy,
|
||||
});
|
||||
} else {
|
||||
await this.#enqueueBatchTaskRun({
|
||||
batchId: batch.id,
|
||||
processingId: options.processingId,
|
||||
range: {
|
||||
start: result.workingIndex,
|
||||
// This will be the remainder of the slice
|
||||
// for example if the original range was 0-50 and the workingIndex is 25, the new range will be 25-25
|
||||
// if the original range was 51-100 and the workingIndex is 75, the new range will be 75-25
|
||||
count: options.range.count - result.workingIndex - options.range.start,
|
||||
},
|
||||
attemptCount: $attemptCount,
|
||||
strategy: options.strategy,
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #processBatchTaskRunItems(
|
||||
batch: BatchTaskRun,
|
||||
environment: AuthenticatedEnvironment,
|
||||
currentIndex: number,
|
||||
batchSize: number,
|
||||
items: BatchTriggerTaskV2RequestBody["items"],
|
||||
options?: BatchTriggerTaskServiceOptions
|
||||
): Promise<
|
||||
| { status: "COMPLETE" }
|
||||
| { status: "INCOMPLETE"; workingIndex: number }
|
||||
| { status: "ERROR"; error: string; workingIndex: number }
|
||||
> {
|
||||
// Grab the next PROCESSING_BATCH_SIZE runIds
|
||||
const runIds = batch.runIds.slice(currentIndex, currentIndex + batchSize);
|
||||
|
||||
logger.debug("[BatchTriggerV2][processBatchTaskRun] Processing batch items", {
|
||||
batchId: batch.friendlyId,
|
||||
currentIndex,
|
||||
runIds,
|
||||
runCount: batch.runCount,
|
||||
});
|
||||
|
||||
// Combine the "window" between currentIndex and currentIndex + PROCESSING_BATCH_SIZE with the runId and the item in the payload which is an array
|
||||
const itemsToProcess = runIds.map((runId, index) => ({
|
||||
runId,
|
||||
item: items[index + currentIndex],
|
||||
}));
|
||||
|
||||
let workingIndex = currentIndex;
|
||||
|
||||
for (const item of itemsToProcess) {
|
||||
try {
|
||||
await this.#processBatchTaskRunItem(batch, environment, item, workingIndex, options);
|
||||
|
||||
workingIndex++;
|
||||
} catch (error) {
|
||||
logger.error("[BatchTriggerV2][processBatchTaskRun] Failed to process item", {
|
||||
batchId: batch.friendlyId,
|
||||
currentIndex: workingIndex,
|
||||
error,
|
||||
});
|
||||
|
||||
return {
|
||||
status: "ERROR",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
workingIndex,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// if there are more items to process, requeue the batch
|
||||
if (workingIndex < batch.runCount) {
|
||||
return { status: "INCOMPLETE", workingIndex };
|
||||
}
|
||||
|
||||
return { status: "COMPLETE" };
|
||||
}
|
||||
|
||||
async #processBatchTaskRunItem(
|
||||
batch: BatchTaskRun,
|
||||
environment: AuthenticatedEnvironment,
|
||||
task: { runId: string; item: BatchTriggerTaskV2RequestBody["items"][number] },
|
||||
currentIndex: number,
|
||||
options?: BatchTriggerTaskServiceOptions
|
||||
) {
|
||||
logger.debug("[BatchTriggerV2][processBatchTaskRunItem] Processing item", {
|
||||
batchId: batch.friendlyId,
|
||||
runId: task.runId,
|
||||
currentIndex,
|
||||
});
|
||||
|
||||
const triggerTaskService = new TriggerTaskService();
|
||||
|
||||
const run = await triggerTaskService.call(
|
||||
task.item.task,
|
||||
environment,
|
||||
{
|
||||
...task.item,
|
||||
options: {
|
||||
...task.item.options,
|
||||
dependentBatch: batch.dependentTaskAttemptId ? batch.friendlyId : undefined, // Only set dependentBatch if dependentAttempt is set which means batchTriggerAndWait was called
|
||||
parentBatch: batch.dependentTaskAttemptId ? undefined : batch.friendlyId, // Only set parentBatch if dependentAttempt is NOT set which means batchTrigger was called
|
||||
},
|
||||
},
|
||||
{
|
||||
triggerVersion: options?.triggerVersion,
|
||||
traceContext: options?.traceContext,
|
||||
spanParentAsLink: options?.spanParentAsLink,
|
||||
batchId: batch.friendlyId,
|
||||
skipChecks: true,
|
||||
runId: task.runId,
|
||||
}
|
||||
);
|
||||
|
||||
if (!run) {
|
||||
throw new Error(`Failed to trigger run ${task.runId} for batch ${batch.friendlyId}`);
|
||||
}
|
||||
|
||||
await this._prisma.batchTaskRunItem.create({
|
||||
data: {
|
||||
batchTaskRunId: batch.id,
|
||||
taskRunId: run.id,
|
||||
status: batchTaskRunItemStatusForRunStatus(run.status),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async #enqueueBatchTaskRun(options: BatchProcessingOptions, tx?: PrismaClientOrTransaction) {
|
||||
await workerQueue.enqueue("v3.processBatchTaskRun", options, {
|
||||
tx,
|
||||
jobKey: `BatchTriggerV2Service.process:${options.batchId}:${options.processingId}`,
|
||||
});
|
||||
}
|
||||
|
||||
async #handlePayloadPacket(
|
||||
payload: any,
|
||||
pathPrefix: string,
|
||||
environment: AuthenticatedEnvironment
|
||||
) {
|
||||
return await startActiveSpan("handlePayloadPacket()", async (span) => {
|
||||
const packet = { data: JSON.stringify(payload), dataType: "application/json" };
|
||||
|
||||
if (!packet.data) {
|
||||
return packet;
|
||||
}
|
||||
|
||||
const { needsOffloading } = packetRequiresOffloading(
|
||||
packet,
|
||||
env.TASK_PAYLOAD_OFFLOAD_THRESHOLD
|
||||
);
|
||||
|
||||
if (!needsOffloading) {
|
||||
return packet;
|
||||
}
|
||||
|
||||
const filename = `${pathPrefix}/payload.json`;
|
||||
|
||||
await uploadPacketToObjectStore(filename, packet.data, packet.dataType, environment);
|
||||
|
||||
return {
|
||||
data: filename,
|
||||
dataType: "application/store",
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -131,30 +131,28 @@ export class CompleteAttemptService extends BaseService {
|
||||
taskRunAttempt: NonNullable<FoundAttempt>,
|
||||
env?: AuthenticatedEnvironment
|
||||
): Promise<"COMPLETED"> {
|
||||
await $transaction(this._prisma, async (tx) => {
|
||||
await tx.taskRunAttempt.update({
|
||||
where: { id: taskRunAttempt.id },
|
||||
data: {
|
||||
status: "COMPLETED",
|
||||
completedAt: new Date(),
|
||||
output: completion.output,
|
||||
outputType: completion.outputType,
|
||||
usageDurationMs: completion.usage?.durationMs,
|
||||
taskRun: {
|
||||
update: {
|
||||
output: completion.output,
|
||||
outputType: completion.outputType,
|
||||
},
|
||||
await this._prisma.taskRunAttempt.update({
|
||||
where: { id: taskRunAttempt.id },
|
||||
data: {
|
||||
status: "COMPLETED",
|
||||
completedAt: new Date(),
|
||||
output: completion.output,
|
||||
outputType: completion.outputType,
|
||||
usageDurationMs: completion.usage?.durationMs,
|
||||
taskRun: {
|
||||
update: {
|
||||
output: completion.output,
|
||||
outputType: completion.outputType,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const finalizeService = new FinalizeTaskRunService(tx);
|
||||
await finalizeService.call({
|
||||
id: taskRunAttempt.taskRunId,
|
||||
status: "COMPLETED_SUCCESSFULLY",
|
||||
completedAt: new Date(),
|
||||
});
|
||||
const finalizeService = new FinalizeTaskRunService();
|
||||
await finalizeService.call({
|
||||
id: taskRunAttempt.taskRunId,
|
||||
status: "COMPLETED_SUCCESSFULLY",
|
||||
completedAt: new Date(),
|
||||
});
|
||||
|
||||
// Now we need to "complete" the task run event/span
|
||||
|
||||
@@ -190,7 +190,6 @@ export async function createBackgroundTasks(
|
||||
},
|
||||
update: {
|
||||
concurrencyLimit,
|
||||
rateLimit: task.queue?.rateLimit,
|
||||
},
|
||||
create: {
|
||||
friendlyId: generateFriendlyId("queue"),
|
||||
@@ -198,7 +197,6 @@ export async function createBackgroundTasks(
|
||||
concurrencyLimit,
|
||||
runtimeEnvironmentId: worker.runtimeEnvironmentId,
|
||||
projectId: worker.projectId,
|
||||
rateLimit: task.queue?.rateLimit,
|
||||
type: task.queue?.name ? "NAMED" : "VIRTUAL",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -72,7 +72,11 @@ export class CreateTaskRunAttemptService extends BaseService {
|
||||
},
|
||||
batchItems: {
|
||||
include: {
|
||||
batchTaskRun: true,
|
||||
batchTaskRun: {
|
||||
select: {
|
||||
friendlyId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ import { BaseService } from "./baseService.server";
|
||||
import { ResumeDependentParentsService } from "./resumeDependentParents.server";
|
||||
import { ExpireEnqueuedRunService } from "./expireEnqueuedRun.server";
|
||||
import { socketIo } from "../handleSocketIo.server";
|
||||
import { ResumeBatchRunService } from "./resumeBatchRun.server";
|
||||
|
||||
type BaseInput = {
|
||||
id: string;
|
||||
@@ -81,6 +82,15 @@ export class FinalizeTaskRunService extends BaseService {
|
||||
await this.finalizeRunError(run, error);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.#finalizeBatch(run);
|
||||
} catch (finalizeBatchError) {
|
||||
logger.error("FinalizeTaskRunService: Failed to finalize batch", {
|
||||
runId: run.id,
|
||||
error: finalizeBatchError,
|
||||
});
|
||||
}
|
||||
|
||||
//resume any dependencies
|
||||
const resumeService = new ResumeDependentParentsService(this._prisma);
|
||||
const result = await resumeService.call({ id: run.id });
|
||||
@@ -135,6 +145,72 @@ export class FinalizeTaskRunService extends BaseService {
|
||||
return run as Output<T>;
|
||||
}
|
||||
|
||||
async #finalizeBatch(run: TaskRun) {
|
||||
if (!run.batchId) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug("FinalizeTaskRunService: Finalizing batch", { runId: run.id });
|
||||
|
||||
const environment = await this._prisma.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
id: run.runtimeEnvironmentId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
return;
|
||||
}
|
||||
|
||||
const batchItems = await this._prisma.batchTaskRunItem.findMany({
|
||||
where: {
|
||||
taskRunId: run.id,
|
||||
},
|
||||
include: {
|
||||
batchTaskRun: {
|
||||
select: {
|
||||
id: true,
|
||||
dependentTaskAttemptId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (batchItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (batchItems.length > 10) {
|
||||
logger.error("FinalizeTaskRunService: More than 10 batch items", {
|
||||
runId: run.id,
|
||||
batchItems: batchItems.length,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of batchItems) {
|
||||
// Don't do anything if this is a batchTriggerAndWait in a deployed task
|
||||
if (environment.type !== "DEVELOPMENT" && item.batchTaskRun.dependentTaskAttemptId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update the item to complete
|
||||
await this._prisma.batchTaskRunItem.update({
|
||||
where: {
|
||||
id: item.id,
|
||||
},
|
||||
data: {
|
||||
status: "COMPLETED",
|
||||
},
|
||||
});
|
||||
|
||||
// This won't resume because this batch does not have a dependent task attempt ID
|
||||
// or is in development, but this service will mark the batch as completed
|
||||
await ResumeBatchRunService.enqueue(item.batchTaskRunId, this._prisma);
|
||||
}
|
||||
}
|
||||
|
||||
async finalizeRunError(run: TaskRun, error: TaskRunError) {
|
||||
await this._prisma.taskRun.update({
|
||||
where: { id: run.id },
|
||||
|
||||
@@ -50,7 +50,7 @@ export class IndexDeploymentService extends BaseService {
|
||||
deployment.id,
|
||||
"DEPLOYING",
|
||||
"Could not index deployment in time",
|
||||
new Date(Date.now() + 180_000)
|
||||
new Date(Date.now() + env.DEPLOY_TIMEOUT_MS)
|
||||
);
|
||||
|
||||
const responses = await socketIo.providerNamespace.timeout(30_000).emitWithAck("INDEX", {
|
||||
|
||||
@@ -64,7 +64,7 @@ export class InitializeDeploymentService extends BaseService {
|
||||
deployment.id,
|
||||
"BUILDING",
|
||||
"Building timed out",
|
||||
new Date(Date.now() + 180_000) // 3 minutes
|
||||
new Date(Date.now() + env.DEPLOY_TIMEOUT_MS)
|
||||
);
|
||||
|
||||
const imageTag = `${payload.namespace ?? env.DEPLOY_REGISTRY_NAMESPACE}/${
|
||||
|
||||
@@ -13,26 +13,26 @@ export class ResumeBatchRunService extends BaseService {
|
||||
id: batchRunId,
|
||||
},
|
||||
include: {
|
||||
dependentTaskAttempt: {
|
||||
runtimeEnvironment: {
|
||||
include: {
|
||||
runtimeEnvironment: {
|
||||
include: {
|
||||
project: true,
|
||||
organization: true,
|
||||
},
|
||||
},
|
||||
taskRun: true,
|
||||
project: true,
|
||||
organization: true,
|
||||
},
|
||||
},
|
||||
items: {
|
||||
select: {
|
||||
status: true,
|
||||
taskRunAttemptId: true,
|
||||
},
|
||||
},
|
||||
items: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!batchRun || !batchRun.dependentTaskAttempt) {
|
||||
if (!batchRun) {
|
||||
logger.error(
|
||||
"ResumeBatchRunService: Batch run doesn't exist or doesn't have a dependent attempt",
|
||||
{
|
||||
batchRun,
|
||||
batchRunId,
|
||||
}
|
||||
);
|
||||
return;
|
||||
@@ -40,23 +40,28 @@ export class ResumeBatchRunService extends BaseService {
|
||||
|
||||
if (batchRun.status === "COMPLETED") {
|
||||
logger.debug("ResumeBatchRunService: Batch run is already completed", {
|
||||
batchRun: batchRun,
|
||||
batchRunId: batchRun.id,
|
||||
batchRun: {
|
||||
id: batchRun.id,
|
||||
status: batchRun.status,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (batchRun.items.some((item) => !finishedBatchRunStatuses.includes(item.status))) {
|
||||
logger.debug("ResumeBatchRunService: All items aren't yet completed", {
|
||||
batchRun: batchRun,
|
||||
batchRunId: batchRun.id,
|
||||
batchRun: {
|
||||
id: batchRun.id,
|
||||
status: batchRun.status,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// This batch has a dependent attempt and just finalized, we should resume that attempt
|
||||
const environment = batchRun.dependentTaskAttempt.runtimeEnvironment;
|
||||
|
||||
// If we are in development, we don't need to resume the dependent task (that will happen automatically)
|
||||
if (environment.type === "DEVELOPMENT") {
|
||||
// If we are in development, or there is no dependent attempt, we can just mark the batch as completed and return
|
||||
if (batchRun.runtimeEnvironment.type === "DEVELOPMENT" || !batchRun.dependentTaskAttemptId) {
|
||||
// We need to update the batchRun status so we don't resume it again
|
||||
await this._prisma.batchTaskRun.update({
|
||||
where: {
|
||||
@@ -69,12 +74,42 @@ export class ResumeBatchRunService extends BaseService {
|
||||
return;
|
||||
}
|
||||
|
||||
const dependentRun = batchRun.dependentTaskAttempt.taskRun;
|
||||
const dependentTaskAttempt = await this._prisma.taskRunAttempt.findFirst({
|
||||
where: {
|
||||
id: batchRun.dependentTaskAttemptId,
|
||||
},
|
||||
select: {
|
||||
status: true,
|
||||
id: true,
|
||||
taskRun: {
|
||||
select: {
|
||||
id: true,
|
||||
queue: true,
|
||||
taskIdentifier: true,
|
||||
concurrencyKey: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (batchRun.dependentTaskAttempt.status === "PAUSED" && batchRun.checkpointEventId) {
|
||||
if (!dependentTaskAttempt) {
|
||||
logger.error("ResumeBatchRunService: Dependent attempt not found", {
|
||||
batchRunId: batchRun.id,
|
||||
dependentTaskAttemptId: batchRun.dependentTaskAttemptId,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// This batch has a dependent attempt and just finalized, we should resume that attempt
|
||||
const environment = batchRun.runtimeEnvironment;
|
||||
|
||||
const dependentRun = dependentTaskAttempt.taskRun;
|
||||
|
||||
if (dependentTaskAttempt.status === "PAUSED" && batchRun.checkpointEventId) {
|
||||
logger.debug("ResumeBatchRunService: Attempt is paused and has a checkpoint event", {
|
||||
batchRunId: batchRun.id,
|
||||
dependentTaskAttempt: batchRun.dependentTaskAttempt,
|
||||
dependentTaskAttempt: dependentTaskAttempt,
|
||||
checkpointEventId: batchRun.checkpointEventId,
|
||||
});
|
||||
|
||||
@@ -83,7 +118,7 @@ export class ResumeBatchRunService extends BaseService {
|
||||
if (wasUpdated) {
|
||||
logger.debug("ResumeBatchRunService: Resuming dependent run with checkpoint", {
|
||||
batchRunId: batchRun.id,
|
||||
dependentTaskAttemptId: batchRun.dependentTaskAttempt.id,
|
||||
dependentTaskAttemptId: dependentTaskAttempt.id,
|
||||
});
|
||||
await marqs?.enqueueMessage(
|
||||
environment,
|
||||
@@ -92,19 +127,19 @@ export class ResumeBatchRunService extends BaseService {
|
||||
{
|
||||
type: "RESUME",
|
||||
completedAttemptIds: [],
|
||||
resumableAttemptId: batchRun.dependentTaskAttempt.id,
|
||||
resumableAttemptId: dependentTaskAttempt.id,
|
||||
checkpointEventId: batchRun.checkpointEventId,
|
||||
taskIdentifier: batchRun.dependentTaskAttempt.taskRun.taskIdentifier,
|
||||
projectId: batchRun.dependentTaskAttempt.runtimeEnvironment.projectId,
|
||||
environmentId: batchRun.dependentTaskAttempt.runtimeEnvironment.id,
|
||||
environmentType: batchRun.dependentTaskAttempt.runtimeEnvironment.type,
|
||||
taskIdentifier: dependentTaskAttempt.taskRun.taskIdentifier,
|
||||
projectId: environment.projectId,
|
||||
environmentId: environment.id,
|
||||
environmentType: environment.type,
|
||||
},
|
||||
dependentRun.concurrencyKey ?? undefined
|
||||
);
|
||||
} else {
|
||||
logger.debug("ResumeBatchRunService: with checkpoint was already completed", {
|
||||
batchRunId: batchRun.id,
|
||||
dependentTaskAttempt: batchRun.dependentTaskAttempt,
|
||||
dependentTaskAttempt: dependentTaskAttempt,
|
||||
checkpointEventId: batchRun.checkpointEventId,
|
||||
hasCheckpointEvent: !!batchRun.checkpointEventId,
|
||||
});
|
||||
@@ -112,17 +147,17 @@ export class ResumeBatchRunService extends BaseService {
|
||||
} else {
|
||||
logger.debug("ResumeBatchRunService: attempt is not paused or there's no checkpoint event", {
|
||||
batchRunId: batchRun.id,
|
||||
dependentTaskAttempt: batchRun.dependentTaskAttempt,
|
||||
dependentTaskAttempt: dependentTaskAttempt,
|
||||
checkpointEventId: batchRun.checkpointEventId,
|
||||
hasCheckpointEvent: !!batchRun.checkpointEventId,
|
||||
});
|
||||
|
||||
if (batchRun.dependentTaskAttempt.status === "PAUSED" && !batchRun.checkpointEventId) {
|
||||
if (dependentTaskAttempt.status === "PAUSED" && !batchRun.checkpointEventId) {
|
||||
// In case of race conditions the status can be PAUSED without a checkpoint event
|
||||
// When the checkpoint is created, it will continue the run
|
||||
logger.error("ResumeBatchRunService: attempt is paused but there's no checkpoint event", {
|
||||
batchRunId: batchRun.id,
|
||||
dependentTaskAttempt: batchRun.dependentTaskAttempt,
|
||||
dependentTaskAttempt: dependentTaskAttempt,
|
||||
checkpointEventId: batchRun.checkpointEventId,
|
||||
hasCheckpointEvent: !!batchRun.checkpointEventId,
|
||||
});
|
||||
@@ -134,24 +169,24 @@ export class ResumeBatchRunService extends BaseService {
|
||||
if (wasUpdated) {
|
||||
logger.debug("ResumeBatchRunService: Resuming dependent run without checkpoint", {
|
||||
batchRunId: batchRun.id,
|
||||
dependentTaskAttempt: batchRun.dependentTaskAttempt,
|
||||
dependentTaskAttempt: dependentTaskAttempt,
|
||||
checkpointEventId: batchRun.checkpointEventId,
|
||||
hasCheckpointEvent: !!batchRun.checkpointEventId,
|
||||
});
|
||||
await marqs?.replaceMessage(dependentRun.id, {
|
||||
type: "RESUME",
|
||||
completedAttemptIds: batchRun.items.map((item) => item.taskRunAttemptId).filter(Boolean),
|
||||
resumableAttemptId: batchRun.dependentTaskAttempt.id,
|
||||
resumableAttemptId: dependentTaskAttempt.id,
|
||||
checkpointEventId: batchRun.checkpointEventId ?? undefined,
|
||||
taskIdentifier: batchRun.dependentTaskAttempt.taskRun.taskIdentifier,
|
||||
projectId: batchRun.dependentTaskAttempt.runtimeEnvironment.projectId,
|
||||
environmentId: batchRun.dependentTaskAttempt.runtimeEnvironment.id,
|
||||
environmentType: batchRun.dependentTaskAttempt.runtimeEnvironment.type,
|
||||
taskIdentifier: dependentTaskAttempt.taskRun.taskIdentifier,
|
||||
projectId: environment.projectId,
|
||||
environmentId: environment.id,
|
||||
environmentType: environment.type,
|
||||
});
|
||||
} else {
|
||||
logger.debug("ResumeBatchRunService: without checkpoint was already completed", {
|
||||
batchRunId: batchRun.id,
|
||||
dependentTaskAttempt: batchRun.dependentTaskAttempt,
|
||||
dependentTaskAttempt: dependentTaskAttempt,
|
||||
checkpointEventId: batchRun.checkpointEventId,
|
||||
hasCheckpointEvent: !!batchRun.checkpointEventId,
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import { workerQueue } from "~/services/worker.server";
|
||||
import { marqs, sanitizeQueueName } from "~/v3/marqs/index.server";
|
||||
import { eventRepository } from "../eventRepository.server";
|
||||
import { generateFriendlyId } from "../friendlyIdentifiers";
|
||||
import { uploadToObjectStore } from "../r2.server";
|
||||
import { uploadPacketToObjectStore } from "../r2.server";
|
||||
import { startActiveSpan } from "../tracer.server";
|
||||
import { getEntitlement } from "~/services/platform.v3.server";
|
||||
import { BaseService, ServiceValidationError } from "./baseService.server";
|
||||
@@ -25,15 +25,21 @@ import { parseNaturalLanguageDuration } from "@trigger.dev/core/v3/apps";
|
||||
import { ExpireEnqueuedRunService } from "./expireEnqueuedRun.server";
|
||||
import { guardQueueSizeLimitsForEnv } from "../queueSizeLimits.server";
|
||||
import { clampMaxDuration } from "../utils/maxDuration";
|
||||
import { resolveIdempotencyKeyTTL } from "~/utils/idempotencyKeys.server";
|
||||
import { Prisma } from "@trigger.dev/database";
|
||||
|
||||
export type TriggerTaskServiceOptions = {
|
||||
idempotencyKey?: string;
|
||||
idempotencyKeyExpiresAt?: Date;
|
||||
triggerVersion?: string;
|
||||
traceContext?: Record<string, string | undefined>;
|
||||
spanParentAsLink?: boolean;
|
||||
parentAsLinkType?: "replay" | "trigger";
|
||||
batchId?: string;
|
||||
customIcon?: string;
|
||||
runId?: string;
|
||||
skipChecks?: boolean;
|
||||
oneTimeUseToken?: string;
|
||||
};
|
||||
|
||||
export class OutOfEntitlementError extends Error {
|
||||
@@ -52,7 +58,13 @@ export class TriggerTaskService extends BaseService {
|
||||
return await this.traceWithEnv("call()", environment, async (span) => {
|
||||
span.setAttribute("taskId", taskId);
|
||||
|
||||
// TODO: Add idempotency key expiring here
|
||||
const idempotencyKey = options.idempotencyKey ?? body.options?.idempotencyKey;
|
||||
const idempotencyKeyExpiresAt =
|
||||
options.idempotencyKeyExpiresAt ??
|
||||
resolveIdempotencyKeyTTL(body.options?.idempotencyKeyTTL) ??
|
||||
new Date(Date.now() + 24 * 60 * 60 * 1000 * 30); // 30 days
|
||||
|
||||
const delayUntil = await parseDelay(body.options?.delay);
|
||||
|
||||
const ttl =
|
||||
@@ -73,34 +85,52 @@ export class TriggerTaskService extends BaseService {
|
||||
: undefined;
|
||||
|
||||
if (existingRun) {
|
||||
span.setAttribute("runId", existingRun.friendlyId);
|
||||
if (
|
||||
existingRun.idempotencyKeyExpiresAt &&
|
||||
existingRun.idempotencyKeyExpiresAt < new Date()
|
||||
) {
|
||||
logger.debug("[TriggerTaskService][call] Idempotency key has expired", {
|
||||
idempotencyKey: options.idempotencyKey,
|
||||
run: existingRun,
|
||||
});
|
||||
|
||||
return existingRun;
|
||||
// Update the existing batch to remove the idempotency key
|
||||
await this._prisma.taskRun.update({
|
||||
where: { id: existingRun.id },
|
||||
data: { idempotencyKey: null },
|
||||
});
|
||||
} else {
|
||||
span.setAttribute("runId", existingRun.friendlyId);
|
||||
|
||||
return existingRun;
|
||||
}
|
||||
}
|
||||
|
||||
if (environment.type !== "DEVELOPMENT") {
|
||||
if (environment.type !== "DEVELOPMENT" && !options.skipChecks) {
|
||||
const result = await getEntitlement(environment.organizationId);
|
||||
if (result && result.hasAccess === false) {
|
||||
throw new OutOfEntitlementError();
|
||||
}
|
||||
}
|
||||
|
||||
const queueSizeGuard = await guardQueueSizeLimitsForEnv(environment, marqs);
|
||||
if (!options.skipChecks) {
|
||||
const queueSizeGuard = await guardQueueSizeLimitsForEnv(environment, marqs);
|
||||
|
||||
logger.debug("Queue size guard result", {
|
||||
queueSizeGuard,
|
||||
environment: {
|
||||
id: environment.id,
|
||||
type: environment.type,
|
||||
organization: environment.organization,
|
||||
project: environment.project,
|
||||
},
|
||||
});
|
||||
logger.debug("Queue size guard result", {
|
||||
queueSizeGuard,
|
||||
environment: {
|
||||
id: environment.id,
|
||||
type: environment.type,
|
||||
organization: environment.organization,
|
||||
project: environment.project,
|
||||
},
|
||||
});
|
||||
|
||||
if (!queueSizeGuard.isWithinLimits) {
|
||||
throw new ServiceValidationError(
|
||||
`Cannot trigger ${taskId} as the queue size limit for this environment has been reached. The maximum size is ${queueSizeGuard.maximumSize}`
|
||||
);
|
||||
if (!queueSizeGuard.isWithinLimits) {
|
||||
throw new ServiceValidationError(
|
||||
`Cannot trigger ${taskId} as the queue size limit for this environment has been reached. The maximum size is ${queueSizeGuard.maximumSize}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -113,7 +143,7 @@ export class TriggerTaskService extends BaseService {
|
||||
);
|
||||
}
|
||||
|
||||
const runFriendlyId = generateFriendlyId("run");
|
||||
const runFriendlyId = options?.runId ?? generateFriendlyId("run");
|
||||
|
||||
const payloadPacket = await this.#handlePayloadPacket(
|
||||
body.payload,
|
||||
@@ -247,280 +277,333 @@ export class TriggerTaskService extends BaseService {
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return await eventRepository.traceEvent(
|
||||
taskId,
|
||||
{
|
||||
context: options.traceContext,
|
||||
spanParentAsLink: options.spanParentAsLink,
|
||||
parentAsLinkType: options.parentAsLinkType,
|
||||
kind: "SERVER",
|
||||
environment,
|
||||
taskSlug: taskId,
|
||||
attributes: {
|
||||
properties: {
|
||||
[SemanticInternalAttributes.SHOW_ACTIONS]: true,
|
||||
try {
|
||||
return await eventRepository.traceEvent(
|
||||
taskId,
|
||||
{
|
||||
context: options.traceContext,
|
||||
spanParentAsLink: options.spanParentAsLink,
|
||||
parentAsLinkType: options.parentAsLinkType,
|
||||
kind: "SERVER",
|
||||
environment,
|
||||
taskSlug: taskId,
|
||||
attributes: {
|
||||
properties: {
|
||||
[SemanticInternalAttributes.SHOW_ACTIONS]: true,
|
||||
},
|
||||
style: {
|
||||
icon: options.customIcon ?? "task",
|
||||
},
|
||||
runIsTest: body.options?.test ?? false,
|
||||
batchId: options.batchId,
|
||||
idempotencyKey,
|
||||
},
|
||||
style: {
|
||||
icon: options.customIcon ?? "task",
|
||||
},
|
||||
runIsTest: body.options?.test ?? false,
|
||||
batchId: options.batchId,
|
||||
idempotencyKey,
|
||||
incomplete: true,
|
||||
immediate: true,
|
||||
},
|
||||
incomplete: true,
|
||||
immediate: true,
|
||||
},
|
||||
async (event, traceContext, traceparent) => {
|
||||
const run = await autoIncrementCounter.incrementInTransaction(
|
||||
`v3-run:${environment.id}:${taskId}`,
|
||||
async (num, tx) => {
|
||||
const lockedToBackgroundWorker = body.options?.lockToVersion
|
||||
? await tx.backgroundWorker.findUnique({
|
||||
where: {
|
||||
projectId_runtimeEnvironmentId_version: {
|
||||
projectId: environment.projectId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
version: body.options?.lockToVersion,
|
||||
async (event, traceContext, traceparent) => {
|
||||
const run = await autoIncrementCounter.incrementInTransaction(
|
||||
`v3-run:${environment.id}:${taskId}`,
|
||||
async (num, tx) => {
|
||||
const lockedToBackgroundWorker = body.options?.lockToVersion
|
||||
? await tx.backgroundWorker.findUnique({
|
||||
where: {
|
||||
projectId_runtimeEnvironmentId_version: {
|
||||
projectId: environment.projectId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
version: body.options?.lockToVersion,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
})
|
||||
: undefined;
|
||||
|
||||
let queueName = sanitizeQueueName(
|
||||
await this.#getQueueName(taskId, environment, body.options?.queue?.name)
|
||||
);
|
||||
let queueName = sanitizeQueueName(
|
||||
await this.#getQueueName(taskId, environment, body.options?.queue?.name)
|
||||
);
|
||||
|
||||
// Check that the queuename is not an empty string
|
||||
if (!queueName) {
|
||||
queueName = sanitizeQueueName(`task/${taskId}`);
|
||||
}
|
||||
// Check that the queuename is not an empty string
|
||||
if (!queueName) {
|
||||
queueName = sanitizeQueueName(`task/${taskId}`);
|
||||
}
|
||||
|
||||
event.setAttribute("queueName", queueName);
|
||||
span.setAttribute("queueName", queueName);
|
||||
event.setAttribute("queueName", queueName);
|
||||
span.setAttribute("queueName", queueName);
|
||||
|
||||
//upsert tags
|
||||
let tagIds: string[] = [];
|
||||
const bodyTags =
|
||||
typeof body.options?.tags === "string" ? [body.options.tags] : body.options?.tags;
|
||||
if (bodyTags && bodyTags.length > 0) {
|
||||
for (const tag of bodyTags) {
|
||||
const tagRecord = await createTag({
|
||||
tag,
|
||||
projectId: environment.projectId,
|
||||
});
|
||||
if (tagRecord) {
|
||||
tagIds.push(tagRecord.id);
|
||||
//upsert tags
|
||||
let tagIds: string[] = [];
|
||||
const bodyTags =
|
||||
typeof body.options?.tags === "string" ? [body.options.tags] : body.options?.tags;
|
||||
if (bodyTags && bodyTags.length > 0) {
|
||||
for (const tag of bodyTags) {
|
||||
const tagRecord = await createTag({
|
||||
tag,
|
||||
projectId: environment.projectId,
|
||||
});
|
||||
if (tagRecord) {
|
||||
tagIds.push(tagRecord.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const depth = dependentAttempt
|
||||
? dependentAttempt.taskRun.depth + 1
|
||||
: parentAttempt
|
||||
? parentAttempt.taskRun.depth + 1
|
||||
: dependentBatchRun?.dependentTaskAttempt
|
||||
? dependentBatchRun.dependentTaskAttempt.taskRun.depth + 1
|
||||
: 0;
|
||||
const depth = dependentAttempt
|
||||
? dependentAttempt.taskRun.depth + 1
|
||||
: parentAttempt
|
||||
? parentAttempt.taskRun.depth + 1
|
||||
: dependentBatchRun?.dependentTaskAttempt
|
||||
? dependentBatchRun.dependentTaskAttempt.taskRun.depth + 1
|
||||
: 0;
|
||||
|
||||
const taskRun = await tx.taskRun.create({
|
||||
data: {
|
||||
status: delayUntil ? "DELAYED" : "PENDING",
|
||||
number: num,
|
||||
friendlyId: runFriendlyId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
projectId: environment.projectId,
|
||||
idempotencyKey,
|
||||
taskIdentifier: taskId,
|
||||
payload: payloadPacket.data ?? "",
|
||||
payloadType: payloadPacket.dataType,
|
||||
context: body.context,
|
||||
traceContext: traceContext,
|
||||
traceId: event.traceId,
|
||||
spanId: event.spanId,
|
||||
parentSpanId:
|
||||
options.parentAsLinkType === "replay" ? undefined : traceparent?.spanId,
|
||||
lockedToVersionId: lockedToBackgroundWorker?.id,
|
||||
concurrencyKey: body.options?.concurrencyKey,
|
||||
queue: queueName,
|
||||
isTest: body.options?.test ?? false,
|
||||
delayUntil,
|
||||
queuedAt: delayUntil ? undefined : new Date(),
|
||||
maxAttempts: body.options?.maxAttempts,
|
||||
ttl,
|
||||
tags:
|
||||
tagIds.length === 0
|
||||
? undefined
|
||||
: {
|
||||
connect: tagIds.map((id) => ({ id })),
|
||||
},
|
||||
parentTaskRunId:
|
||||
dependentAttempt?.taskRun.id ??
|
||||
parentAttempt?.taskRun.id ??
|
||||
dependentBatchRun?.dependentTaskAttempt?.taskRun.id,
|
||||
parentTaskRunAttemptId:
|
||||
dependentAttempt?.id ??
|
||||
parentAttempt?.id ??
|
||||
dependentBatchRun?.dependentTaskAttempt?.id,
|
||||
rootTaskRunId:
|
||||
dependentAttempt?.taskRun.rootTaskRunId ??
|
||||
dependentAttempt?.taskRun.id ??
|
||||
parentAttempt?.taskRun.rootTaskRunId ??
|
||||
parentAttempt?.taskRun.id ??
|
||||
dependentBatchRun?.dependentTaskAttempt?.taskRun.rootTaskRunId ??
|
||||
dependentBatchRun?.dependentTaskAttempt?.taskRun.id,
|
||||
batchId: dependentBatchRun?.id ?? parentBatchRun?.id,
|
||||
resumeParentOnCompletion: !!(dependentAttempt ?? dependentBatchRun),
|
||||
depth,
|
||||
metadata: metadataPacket?.data,
|
||||
metadataType: metadataPacket?.dataType,
|
||||
seedMetadata: metadataPacket?.data,
|
||||
seedMetadataType: metadataPacket?.dataType,
|
||||
maxDurationInSeconds: body.options?.maxDuration
|
||||
? clampMaxDuration(body.options.maxDuration)
|
||||
: undefined,
|
||||
runTags: bodyTags,
|
||||
},
|
||||
});
|
||||
|
||||
event.setAttribute("runId", taskRun.friendlyId);
|
||||
span.setAttribute("runId", taskRun.friendlyId);
|
||||
|
||||
if (dependentAttempt) {
|
||||
await tx.taskRunDependency.create({
|
||||
const taskRun = await tx.taskRun.create({
|
||||
data: {
|
||||
taskRunId: taskRun.id,
|
||||
dependentAttemptId: dependentAttempt.id,
|
||||
},
|
||||
});
|
||||
} else if (dependentBatchRun) {
|
||||
await tx.taskRunDependency.create({
|
||||
data: {
|
||||
taskRunId: taskRun.id,
|
||||
dependentBatchRunId: dependentBatchRun.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (body.options?.queue) {
|
||||
const concurrencyLimit =
|
||||
typeof body.options.queue.concurrencyLimit === "number"
|
||||
? Math.max(0, body.options.queue.concurrencyLimit)
|
||||
: undefined;
|
||||
|
||||
let taskQueue = await tx.taskQueue.findFirst({
|
||||
where: {
|
||||
status: delayUntil ? "DELAYED" : "PENDING",
|
||||
number: num,
|
||||
friendlyId: runFriendlyId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
name: queueName,
|
||||
projectId: environment.projectId,
|
||||
idempotencyKey,
|
||||
idempotencyKeyExpiresAt: idempotencyKey ? idempotencyKeyExpiresAt : undefined,
|
||||
taskIdentifier: taskId,
|
||||
payload: payloadPacket.data ?? "",
|
||||
payloadType: payloadPacket.dataType,
|
||||
context: body.context,
|
||||
traceContext: traceContext,
|
||||
traceId: event.traceId,
|
||||
spanId: event.spanId,
|
||||
parentSpanId:
|
||||
options.parentAsLinkType === "replay" ? undefined : traceparent?.spanId,
|
||||
lockedToVersionId: lockedToBackgroundWorker?.id,
|
||||
taskVersion: lockedToBackgroundWorker?.version,
|
||||
sdkVersion: lockedToBackgroundWorker?.sdkVersion,
|
||||
cliVersion: lockedToBackgroundWorker?.cliVersion,
|
||||
concurrencyKey: body.options?.concurrencyKey,
|
||||
queue: queueName,
|
||||
isTest: body.options?.test ?? false,
|
||||
delayUntil,
|
||||
queuedAt: delayUntil ? undefined : new Date(),
|
||||
maxAttempts: body.options?.maxAttempts,
|
||||
ttl,
|
||||
tags:
|
||||
tagIds.length === 0
|
||||
? undefined
|
||||
: {
|
||||
connect: tagIds.map((id) => ({ id })),
|
||||
},
|
||||
parentTaskRunId:
|
||||
dependentAttempt?.taskRun.id ??
|
||||
parentAttempt?.taskRun.id ??
|
||||
dependentBatchRun?.dependentTaskAttempt?.taskRun.id,
|
||||
parentTaskRunAttemptId:
|
||||
dependentAttempt?.id ??
|
||||
parentAttempt?.id ??
|
||||
dependentBatchRun?.dependentTaskAttempt?.id,
|
||||
rootTaskRunId:
|
||||
dependentAttempt?.taskRun.rootTaskRunId ??
|
||||
dependentAttempt?.taskRun.id ??
|
||||
parentAttempt?.taskRun.rootTaskRunId ??
|
||||
parentAttempt?.taskRun.id ??
|
||||
dependentBatchRun?.dependentTaskAttempt?.taskRun.rootTaskRunId ??
|
||||
dependentBatchRun?.dependentTaskAttempt?.taskRun.id,
|
||||
batchId: dependentBatchRun?.id ?? parentBatchRun?.id,
|
||||
resumeParentOnCompletion: !!(dependentAttempt ?? dependentBatchRun),
|
||||
depth,
|
||||
metadata: metadataPacket?.data,
|
||||
metadataType: metadataPacket?.dataType,
|
||||
seedMetadata: metadataPacket?.data,
|
||||
seedMetadataType: metadataPacket?.dataType,
|
||||
maxDurationInSeconds: body.options?.maxDuration
|
||||
? clampMaxDuration(body.options.maxDuration)
|
||||
: undefined,
|
||||
runTags: bodyTags,
|
||||
oneTimeUseToken: options.oneTimeUseToken,
|
||||
},
|
||||
});
|
||||
|
||||
if (taskQueue) {
|
||||
taskQueue = await tx.taskQueue.update({
|
||||
event.setAttribute("runId", taskRun.friendlyId);
|
||||
span.setAttribute("runId", taskRun.friendlyId);
|
||||
|
||||
if (dependentAttempt) {
|
||||
await tx.taskRunDependency.create({
|
||||
data: {
|
||||
taskRunId: taskRun.id,
|
||||
dependentAttemptId: dependentAttempt.id,
|
||||
},
|
||||
});
|
||||
} else if (dependentBatchRun) {
|
||||
await tx.taskRunDependency.create({
|
||||
data: {
|
||||
taskRunId: taskRun.id,
|
||||
dependentBatchRunId: dependentBatchRun.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (body.options?.queue) {
|
||||
const concurrencyLimit =
|
||||
typeof body.options.queue.concurrencyLimit === "number"
|
||||
? Math.max(0, body.options.queue.concurrencyLimit)
|
||||
: undefined;
|
||||
|
||||
let taskQueue = await tx.taskQueue.findFirst({
|
||||
where: {
|
||||
id: taskQueue.id,
|
||||
},
|
||||
data: {
|
||||
concurrencyLimit,
|
||||
rateLimit: body.options.queue.rateLimit,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
taskQueue = await tx.taskQueue.create({
|
||||
data: {
|
||||
friendlyId: generateFriendlyId("queue"),
|
||||
name: queueName,
|
||||
concurrencyLimit,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
projectId: environment.projectId,
|
||||
rateLimit: body.options.queue.rateLimit,
|
||||
type: "NAMED",
|
||||
name: queueName,
|
||||
},
|
||||
});
|
||||
|
||||
const existingConcurrencyLimit =
|
||||
typeof taskQueue?.concurrencyLimit === "number"
|
||||
? taskQueue.concurrencyLimit
|
||||
: undefined;
|
||||
|
||||
if (taskQueue) {
|
||||
if (existingConcurrencyLimit !== concurrencyLimit) {
|
||||
taskQueue = await tx.taskQueue.update({
|
||||
where: {
|
||||
id: taskQueue.id,
|
||||
},
|
||||
data: {
|
||||
concurrencyLimit:
|
||||
typeof concurrencyLimit === "number" ? concurrencyLimit : null,
|
||||
},
|
||||
});
|
||||
|
||||
if (typeof taskQueue.concurrencyLimit === "number") {
|
||||
await marqs?.updateQueueConcurrencyLimits(
|
||||
environment,
|
||||
taskQueue.name,
|
||||
taskQueue.concurrencyLimit
|
||||
);
|
||||
} else {
|
||||
await marqs?.removeQueueConcurrencyLimits(environment, taskQueue.name);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const queueId = generateFriendlyId("queue");
|
||||
|
||||
taskQueue = await tx.taskQueue.create({
|
||||
data: {
|
||||
friendlyId: queueId,
|
||||
name: queueName,
|
||||
concurrencyLimit,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
projectId: environment.projectId,
|
||||
type: "NAMED",
|
||||
},
|
||||
});
|
||||
|
||||
if (typeof taskQueue.concurrencyLimit === "number") {
|
||||
await marqs?.updateQueueConcurrencyLimits(
|
||||
environment,
|
||||
taskQueue.name,
|
||||
taskQueue.concurrencyLimit
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof taskQueue.concurrencyLimit === "number") {
|
||||
await marqs?.updateQueueConcurrencyLimits(
|
||||
environment,
|
||||
taskQueue.name,
|
||||
taskQueue.concurrencyLimit
|
||||
if (taskRun.delayUntil) {
|
||||
await workerQueue.enqueue(
|
||||
"v3.enqueueDelayedRun",
|
||||
{ runId: taskRun.id },
|
||||
{ tx, runAt: delayUntil, jobKey: `v3.enqueueDelayedRun.${taskRun.id}` }
|
||||
);
|
||||
} else {
|
||||
await marqs?.removeQueueConcurrencyLimits(environment, taskQueue.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (taskRun.delayUntil) {
|
||||
await workerQueue.enqueue(
|
||||
"v3.enqueueDelayedRun",
|
||||
{ runId: taskRun.id },
|
||||
{ tx, runAt: delayUntil, jobKey: `v3.enqueueDelayedRun.${taskRun.id}` }
|
||||
);
|
||||
}
|
||||
if (!taskRun.delayUntil && taskRun.ttl) {
|
||||
const expireAt = parseNaturalLanguageDuration(taskRun.ttl);
|
||||
|
||||
if (!taskRun.delayUntil && taskRun.ttl) {
|
||||
const expireAt = parseNaturalLanguageDuration(taskRun.ttl);
|
||||
|
||||
if (expireAt) {
|
||||
await ExpireEnqueuedRunService.enqueue(taskRun.id, expireAt, tx);
|
||||
if (expireAt) {
|
||||
await ExpireEnqueuedRunService.enqueue(taskRun.id, expireAt, tx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return taskRun;
|
||||
},
|
||||
async (_, tx) => {
|
||||
const counter = await tx.taskRunNumberCounter.findUnique({
|
||||
where: {
|
||||
taskIdentifier_environmentId: {
|
||||
taskIdentifier: taskId,
|
||||
environmentId: environment.id,
|
||||
},
|
||||
},
|
||||
select: { lastNumber: true },
|
||||
});
|
||||
|
||||
return counter?.lastNumber;
|
||||
},
|
||||
this._prisma
|
||||
);
|
||||
|
||||
//release the concurrency for the env and org, if part of a (batch)triggerAndWait
|
||||
if (dependentAttempt) {
|
||||
const isSameTask = dependentAttempt.taskRun.taskIdentifier === taskId;
|
||||
await marqs?.releaseConcurrency(dependentAttempt.taskRun.id, isSameTask);
|
||||
}
|
||||
if (dependentBatchRun?.dependentTaskAttempt) {
|
||||
const isSameTask =
|
||||
dependentBatchRun.dependentTaskAttempt.taskRun.taskIdentifier === taskId;
|
||||
await marqs?.releaseConcurrency(
|
||||
dependentBatchRun.dependentTaskAttempt.taskRun.id,
|
||||
isSameTask
|
||||
);
|
||||
}
|
||||
|
||||
if (!run) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We need to enqueue the task run into the appropriate queue. This is done after the tx completes to prevent a race condition where the task run hasn't been created yet by the time we dequeue.
|
||||
if (run.status === "PENDING") {
|
||||
await marqs?.enqueueMessage(
|
||||
environment,
|
||||
run.queue,
|
||||
run.id,
|
||||
{
|
||||
type: "EXECUTE",
|
||||
taskIdentifier: taskId,
|
||||
projectId: environment.projectId,
|
||||
environmentId: environment.id,
|
||||
environmentType: environment.type,
|
||||
return taskRun;
|
||||
},
|
||||
body.options?.concurrencyKey
|
||||
);
|
||||
}
|
||||
async (_, tx) => {
|
||||
const counter = await tx.taskRunNumberCounter.findUnique({
|
||||
where: {
|
||||
taskIdentifier_environmentId: {
|
||||
taskIdentifier: taskId,
|
||||
environmentId: environment.id,
|
||||
},
|
||||
},
|
||||
select: { lastNumber: true },
|
||||
});
|
||||
|
||||
return run;
|
||||
return counter?.lastNumber;
|
||||
},
|
||||
this._prisma
|
||||
);
|
||||
|
||||
//release the concurrency for the env and org, if part of a (batch)triggerAndWait
|
||||
if (dependentAttempt) {
|
||||
const isSameTask = dependentAttempt.taskRun.taskIdentifier === taskId;
|
||||
await marqs?.releaseConcurrency(dependentAttempt.taskRun.id, isSameTask);
|
||||
}
|
||||
if (dependentBatchRun?.dependentTaskAttempt) {
|
||||
const isSameTask =
|
||||
dependentBatchRun.dependentTaskAttempt.taskRun.taskIdentifier === taskId;
|
||||
await marqs?.releaseConcurrency(
|
||||
dependentBatchRun.dependentTaskAttempt.taskRun.id,
|
||||
isSameTask
|
||||
);
|
||||
}
|
||||
|
||||
if (!run) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We need to enqueue the task run into the appropriate queue. This is done after the tx completes to prevent a race condition where the task run hasn't been created yet by the time we dequeue.
|
||||
if (run.status === "PENDING") {
|
||||
await marqs?.enqueueMessage(
|
||||
environment,
|
||||
run.queue,
|
||||
run.id,
|
||||
{
|
||||
type: "EXECUTE",
|
||||
taskIdentifier: taskId,
|
||||
projectId: environment.projectId,
|
||||
environmentId: environment.id,
|
||||
environmentType: environment.type,
|
||||
},
|
||||
body.options?.concurrencyKey
|
||||
);
|
||||
}
|
||||
|
||||
return run;
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
// Detect a prisma transaction Unique constraint violation
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.debug("TriggerTask: Prisma transaction error", {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
meta: error.meta,
|
||||
});
|
||||
|
||||
if (error.code === "P2002") {
|
||||
const target = error.meta?.target;
|
||||
|
||||
if (
|
||||
Array.isArray(target) &&
|
||||
target.length > 0 &&
|
||||
typeof target[0] === "string" &&
|
||||
target[0].includes("oneTimeUseToken")
|
||||
) {
|
||||
throw new ServiceValidationError(
|
||||
`Cannot trigger ${taskId} with a one-time use token as it has already been used.`
|
||||
);
|
||||
} else {
|
||||
throw new ServiceValidationError(
|
||||
`Cannot trigger ${taskId} as it has already been triggered with the same idempotency key.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -599,7 +682,7 @@ export class TriggerTaskService extends BaseService {
|
||||
|
||||
const filename = `${pathPrefix}/payload.json`;
|
||||
|
||||
await uploadToObjectStore(filename, packet.data, packet.dataType, environment);
|
||||
await uploadPacketToObjectStore(filename, packet.data, packet.dataType, environment);
|
||||
|
||||
return {
|
||||
data: filename,
|
||||
|
||||
@@ -10,6 +10,10 @@ describe("checkAuthorization", () => {
|
||||
scopes: ["read:runs:run_1234", "read:tasks", "read:tags:tag_5678"],
|
||||
};
|
||||
const publicJwtEntityNoPermissions: AuthorizationEntity = { type: "PUBLIC_JWT" };
|
||||
const publicJwtEntityWithTaskWritePermissions: AuthorizationEntity = {
|
||||
type: "PUBLIC_JWT",
|
||||
scopes: ["write:tasks:task-1"],
|
||||
};
|
||||
|
||||
describe("PRIVATE entity", () => {
|
||||
it("should always return authorized regardless of action or resource", () => {
|
||||
@@ -49,6 +53,28 @@ describe("checkAuthorization", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUBLIC_JWT entity with task write scope", () => {
|
||||
it("should return authorized for specific resource scope", () => {
|
||||
const result = checkAuthorization(publicJwtEntityWithTaskWritePermissions, "write", {
|
||||
tasks: "task-1",
|
||||
});
|
||||
expect(result.authorized).toBe(true);
|
||||
expect(result).not.toHaveProperty("reason");
|
||||
});
|
||||
|
||||
it("should return unauthorized with reason for unauthorized specific resources", () => {
|
||||
const result = checkAuthorization(publicJwtEntityWithTaskWritePermissions, "write", {
|
||||
tasks: "task-2",
|
||||
});
|
||||
expect(result.authorized).toBe(false);
|
||||
if (!result.authorized) {
|
||||
expect(result.reason).toBe(
|
||||
"Public Access Token is missing required permissions. Token has the following permissions: 'write:tasks:task-1'. See https://trigger.dev/docs/frontend/overview#authentication for more information."
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUBLIC_JWT entity with scope", () => {
|
||||
it("should return authorized for specific resource scope", () => {
|
||||
const result = checkAuthorization(publicJwtEntityWithPermissions, "read", {
|
||||
@@ -65,7 +91,7 @@ describe("checkAuthorization", () => {
|
||||
expect(result.authorized).toBe(false);
|
||||
if (!result.authorized) {
|
||||
expect(result.reason).toBe(
|
||||
"Public Access Token is missing required permissions. Permissions required for 'read:runs:run_5678' but token has the following permissions: 'read:runs:run_1234', 'read:tasks', 'read:tags:tag_5678'. See https://trigger.dev/docs/frontend/overview#authentication for more information."
|
||||
"Public Access Token is missing required permissions. Token has the following permissions: 'read:runs:run_1234', 'read:tasks', 'read:tags:tag_5678'. See https://trigger.dev/docs/frontend/overview#authentication for more information."
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -97,8 +123,8 @@ describe("checkAuthorization", () => {
|
||||
// @ts-expect-error
|
||||
nonexistent: "resource",
|
||||
});
|
||||
expect(result.authorized).toBe(true);
|
||||
expect(result).not.toHaveProperty("reason");
|
||||
expect(result.authorized).toBe(false);
|
||||
expect(result).toHaveProperty("reason");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,29 +193,26 @@ describe("checkAuthorization", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("should return unauthorized if any resource is not authorized", () => {
|
||||
it("should return authorized if any resource is authorized", () => {
|
||||
const result = checkAuthorization(publicJwtEntityWithPermissions, "read", {
|
||||
runs: "run_1234", // This is authorized
|
||||
tasks: "task_5678", // This is authorized (general permission)
|
||||
tags: "tag_3456", // This is not authorized
|
||||
});
|
||||
expect(result.authorized).toBe(false);
|
||||
if (!result.authorized) {
|
||||
expect(result.reason).toBe(
|
||||
"Public Access Token is missing required permissions. Permissions required for 'read:tags:tag_3456' but token has the following permissions: 'read:runs:run_1234', 'read:tasks', 'read:tags:tag_5678'. See https://trigger.dev/docs/frontend/overview#authentication for more information."
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should return authorized only if all resources are authorized", () => {
|
||||
const result = checkAuthorization(publicJwtEntityWithPermissions, "read", {
|
||||
runs: "run_1234", // This is authorized
|
||||
tasks: "task_5678", // This is authorized (general permission)
|
||||
tags: "tag_5678", // This is authorized
|
||||
});
|
||||
expect(result.authorized).toBe(true);
|
||||
expect(result).not.toHaveProperty("reason");
|
||||
});
|
||||
|
||||
it("should return unauthorized only if no resources are authorized", () => {
|
||||
const result = checkAuthorization(publicJwtEntityWithPermissions, "read", {
|
||||
runs: "run_5678", // Not authorized
|
||||
tags: "tag_3456", // Not authorized
|
||||
});
|
||||
expect(result.authorized).toBe(false);
|
||||
if (!result.authorized) {
|
||||
expect(result.reason).toContain("Public Access Token is missing required permissions");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Super scope", () => {
|
||||
@@ -244,7 +267,7 @@ describe("checkAuthorization", () => {
|
||||
expect(result.authorized).toBe(false);
|
||||
if (!result.authorized) {
|
||||
expect(result.reason).toBe(
|
||||
"Public Access Token is missing required permissions. Permissions required for 'read:tasks:task_1234' but token has the following permissions: 'read:all'. See https://trigger.dev/docs/frontend/overview#authentication for more information."
|
||||
"Public Access Token is missing required permissions. Token has the following permissions: 'read:all'. See https://trigger.dev/docs/frontend/overview#authentication for more information."
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -281,7 +304,7 @@ describe("checkAuthorization", () => {
|
||||
expect(result2.authorized).toBe(false);
|
||||
if (!result2.authorized) {
|
||||
expect(result2.reason).toBe(
|
||||
"Public Access Token is missing required permissions. Permissions required for 'read:runs:run_5678' but token has the following permissions: 'read:tasks', 'read:tags'. See https://trigger.dev/docs/frontend/overview#authentication for more information."
|
||||
"Public Access Token is missing required permissions. Token has the following permissions: 'read:tasks', 'read:tags'. See https://trigger.dev/docs/frontend/overview#authentication for more information."
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -314,7 +337,7 @@ describe("checkAuthorization", () => {
|
||||
expect(result.authorized).toBe(false);
|
||||
if (!result.authorized) {
|
||||
expect(result.reason).toBe(
|
||||
"Public Access Token is missing required permissions. Permissions required for 'read:runs:run_5678' but token has the following permissions: 'read:tasks'. See https://trigger.dev/docs/frontend/overview#authentication for more information."
|
||||
"Public Access Token is missing required permissions. Token has the following permissions: 'read:tasks'. See https://trigger.dev/docs/frontend/overview#authentication for more information."
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -97,18 +97,20 @@ In this example we're using env vars from [Infisical](https://infisical.com).
|
||||
```ts trigger.config.ts
|
||||
import { defineConfig } from "@trigger.dev/sdk/v3";
|
||||
import { syncEnvVars } from "@trigger.dev/build/extensions/core";
|
||||
import { InfisicalClient } from "@infisical/sdk";
|
||||
import { InfisicalSDK } from "@infisical/sdk";
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
extensions: [
|
||||
syncEnvVars(async (ctx) => {
|
||||
const client = new InfisicalClient({
|
||||
clientId: process.env.INFISICAL_CLIENT_ID,
|
||||
clientSecret: process.env.INFISICAL_CLIENT_SECRET,
|
||||
const client = new InfisicalSDK();
|
||||
|
||||
await client.auth().universalAuth.login({
|
||||
clientId: process.env.INFISICAL_CLIENT_ID!,
|
||||
clientSecret: process.env.INFISICAL_CLIENT_SECRET!,
|
||||
});
|
||||
|
||||
const secrets = await client.listSecrets({
|
||||
const { secrets } = await client.secrets().listSecrets({
|
||||
environment: ctx.environment,
|
||||
projectId: process.env.INFISICAL_PROJECT_ID!,
|
||||
});
|
||||
|
||||
@@ -11,18 +11,18 @@ You can use our [React hooks](/frontend/react-hooks) in your frontend applicatio
|
||||
To create a Public Access Token, you can use the `auth.createPublicToken` function in your **backend** code:
|
||||
|
||||
```tsx
|
||||
const publicToken = await auth.createPublicToken();
|
||||
const publicToken = await auth.createPublicToken(); // 👈 this public access token has no permissions, so is pretty useless!
|
||||
```
|
||||
|
||||
### Scopes
|
||||
|
||||
By default a Public Access Token has limited permissions. You can specify the scopes you need when creating a Public Access Token:
|
||||
By default a Public Access Token has no permissions. You must specify the scopes you need when creating a Public Access Token:
|
||||
|
||||
```ts
|
||||
const publicToken = await auth.createPublicToken({
|
||||
scopes: {
|
||||
read: {
|
||||
runs: true,
|
||||
runs: true, // ❌ this token can read all runs, possibly useful for debugging/testing
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -34,7 +34,7 @@ This will allow the token to read all runs, which is probably not what you want.
|
||||
const publicToken = await auth.createPublicToken({
|
||||
scopes: {
|
||||
read: {
|
||||
runs: ["run_1234", "run_5678"],
|
||||
runs: ["run_1234", "run_5678"], // ✅ this token can read only these runs
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -46,7 +46,7 @@ You can scope the token to only read certain tasks:
|
||||
const publicToken = await auth.createPublicToken({
|
||||
scopes: {
|
||||
read: {
|
||||
tasks: ["my-task-1", "my-task-2"],
|
||||
tasks: ["my-task-1", "my-task-2"], // 👈 this token can read all runs of these tasks
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -58,7 +58,7 @@ Or tags:
|
||||
const publicToken = await auth.createPublicToken({
|
||||
scopes: {
|
||||
read: {
|
||||
tags: ["my-tag-1", "my-tag-2"],
|
||||
tags: ["my-tag-1", "my-tag-2"], // 👈 this token can read all runs with these tags
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -70,13 +70,13 @@ Or a specific batch of runs:
|
||||
const publicToken = await auth.createPublicToken({
|
||||
scopes: {
|
||||
read: {
|
||||
batch: "batch_1234",
|
||||
batch: "batch_1234", // 👈 this token can read all runs in this batch
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
You can also combine scopes. For example, to read only certain tasks and tags:
|
||||
You can also combine scopes. For example, to read runs with specific tags and for specific tasks:
|
||||
|
||||
```ts
|
||||
const publicToken = await auth.createPublicToken({
|
||||
@@ -105,6 +105,19 @@ const publicToken = await auth.createPublicToken({
|
||||
|
||||
This will allow the token to trigger the specified tasks. `tasks` is the only write scope available at the moment.
|
||||
|
||||
We **strongly** recommend creating short-lived tokens for write scopes, as they can be used to trigger tasks from your frontend application:
|
||||
|
||||
```ts
|
||||
const publicToken = await auth.createPublicToken({
|
||||
scopes: {
|
||||
write: {
|
||||
tasks: ["my-task-1"], // ✅ this token can trigger this task
|
||||
},
|
||||
},
|
||||
expirationTime: "1m", // ✅ this token will expire after 1 minute
|
||||
});
|
||||
```
|
||||
|
||||
### Expiration
|
||||
|
||||
By default, Public Access Token's expire after 15 minutes. You can specify a different expiration time when creating a Public Access Token:
|
||||
@@ -133,7 +146,7 @@ const handle = await tasks.trigger("my-task", { some: "data" });
|
||||
console.log(handle.publicAccessToken);
|
||||
```
|
||||
|
||||
By default, tokens returned from the `trigger` function expire after 15 minutes and have a read scope for that specific run, and any tags associated with it. You can customize the expiration of the auto-generated tokens by passing a `publicTokenOptions` object to the `trigger` function:
|
||||
By default, tokens returned from the `trigger` function expire after 15 minutes and have a read scope for that specific run. You can customize the expiration of the auto-generated tokens by passing a `publicTokenOptions` object to the `trigger` function:
|
||||
|
||||
```ts
|
||||
const handle = await tasks.trigger(
|
||||
|
||||
@@ -144,7 +144,7 @@ export async function startRun() {
|
||||
const handle = await tasks.trigger<typeof exampleTask>("example", { foo: "bar" });
|
||||
|
||||
// Set the auto-generated publicAccessToken in a cookie
|
||||
cookies().set("publicAccessToken", handle.publicAccessToken);
|
||||
cookies().set("publicAccessToken", handle.publicAccessToken); // ✅ this token only has access to read this run
|
||||
|
||||
redirect(`/runs/${handle.id}`);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,16 @@ import LocalDevelopment from "/snippets/local-development-extensions.mdx";
|
||||
import ScrapingWarning from "/snippets/web-scraping-warning.mdx";
|
||||
|
||||
<div className="w-full h-full aspect-video">
|
||||
<iframe width="100%" height="100%" src="https://www.youtube.com/embed/6azvzrZITKY?si=muKtsBiS9TJGGKWg" title="YouTube video player" frameborder="0" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen/>
|
||||
<iframe
|
||||
width="100%"
|
||||
height="100%"
|
||||
src="https://www.youtube.com/embed/6azvzrZITKY?si=muKtsBiS9TJGGKWg"
|
||||
title="YouTube video player"
|
||||
frameborder="0"
|
||||
allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allowfullscreen
|
||||
/>
|
||||
</div>
|
||||
|
||||
## Overview
|
||||
@@ -125,12 +134,9 @@ export const summarizeHackerNews = schedules.task({
|
||||
.batchTriggerAndWait(
|
||||
articles.map((article) => ({
|
||||
payload: { title: article.title!, link: article.link! },
|
||||
idempotencyKey: article.link,
|
||||
}))
|
||||
)
|
||||
.then((batch) =>
|
||||
batch.runs.filter((run) => run.ok).map((run) => run.output)
|
||||
);
|
||||
.then((batch) => batch.runs.filter((run) => run.ok).map((run) => run.output));
|
||||
|
||||
// Send email using Resend
|
||||
await resend.emails.send({
|
||||
@@ -165,11 +171,7 @@ export const scrapeAndSummarizeArticle = task({
|
||||
// Prevent all assets from loading, images, stylesheets etc
|
||||
await page.setRequestInterception(true);
|
||||
page.on("request", (request) => {
|
||||
if (
|
||||
["script", "stylesheet", "image", "media", "font"].includes(
|
||||
request.resourceType()
|
||||
)
|
||||
) {
|
||||
if (["script", "stylesheet", "image", "media", "font"].includes(request.resourceType())) {
|
||||
request.abort();
|
||||
} else {
|
||||
request.continue();
|
||||
@@ -218,16 +220,7 @@ To prevent the main example from becoming too cluttered, we'll create a separate
|
||||
Notice how this file is imported into the main task code and passed to Resend to send the email.
|
||||
|
||||
```tsx summarize-hn-email.tsx
|
||||
import {
|
||||
Html,
|
||||
Head,
|
||||
Body,
|
||||
Container,
|
||||
Section,
|
||||
Heading,
|
||||
Text,
|
||||
Link,
|
||||
} from "@react-email/components";
|
||||
import { Html, Head, Body, Container, Section, Heading, Text, Link } from "@react-email/components";
|
||||
|
||||
interface Article {
|
||||
title: string;
|
||||
@@ -235,9 +228,7 @@ interface Article {
|
||||
summary: string | null;
|
||||
}
|
||||
|
||||
export const HNSummaryEmail: React.FC<{ articles: Article[] }> = ({
|
||||
articles,
|
||||
}) => (
|
||||
export const HNSummaryEmail: React.FC<{ articles: Article[] }> = ({ articles }) => (
|
||||
<Html>
|
||||
<Head />
|
||||
<Body style={{ fontFamily: "Arial, sans-serif", padding: "20px" }}>
|
||||
|
||||
+53
-13
@@ -5,11 +5,17 @@ description: "An API call or operation is “idempotent” if it has the same re
|
||||
|
||||
We currently support idempotency at the task level, meaning that if you trigger a task with the same `idempotencyKey` twice, the second request will not create a new task run.
|
||||
|
||||
<Warning>
|
||||
In version 3.3.0 and later, the `idempotencyKey` option is not available when using
|
||||
`triggerAndWait` or `batchTriggerAndWait`, due to a bug that would sometimes cause the parent task
|
||||
to become stuck. We are working on a fix for this issue.
|
||||
</Warning>
|
||||
|
||||
## `idempotencyKey` option
|
||||
|
||||
You can provide an `idempotencyKey` to ensure that a task is only triggered once with the same key. This is useful if you are triggering a task within another task that might be retried:
|
||||
|
||||
```typescript
|
||||
```ts
|
||||
import { idempotencyKeys, task } from "@trigger.dev/sdk/v3";
|
||||
|
||||
export const myTask = task({
|
||||
@@ -18,13 +24,14 @@ export const myTask = task({
|
||||
maxAttempts: 4,
|
||||
},
|
||||
run: async (payload: any) => {
|
||||
// By default, idempotency keys generated are unique to the run, to prevent retries from duplicating child tasks
|
||||
// This idempotency key will be unique to this task run, meaning the childTask will only be triggered once across all retries
|
||||
const idempotencyKey = await idempotencyKeys.create("my-task-key");
|
||||
|
||||
// childTask will only be triggered once with the same idempotency key
|
||||
await childTask.triggerAndWait(payload, { idempotencyKey });
|
||||
await childTask.trigger({ foo: "bar" }, { idempotencyKey });
|
||||
|
||||
// Do something else, that may throw an error and cause the task to be retried
|
||||
throw new Error("Something went wrong");
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -33,7 +40,7 @@ You can use the `idempotencyKeys.create` SDK function to create an idempotency k
|
||||
|
||||
We automatically inject the run ID when generating the idempotency key when running inside a task by default. You can turn it off by passing the `scope` option to `idempotencyKeys.create`:
|
||||
|
||||
```typescript
|
||||
```ts
|
||||
import { idempotencyKeys, task } from "@trigger.dev/sdk/v3";
|
||||
|
||||
export const myTask = task({
|
||||
@@ -42,21 +49,18 @@ export const myTask = task({
|
||||
maxAttempts: 4,
|
||||
},
|
||||
run: async (payload: any) => {
|
||||
// This idempotency key will be the same for all runs of this task
|
||||
// This idempotency key will be globally unique, meaning only a single task run will be triggered with this key
|
||||
const idempotencyKey = await idempotencyKeys.create("my-task-key", { scope: "global" });
|
||||
|
||||
// childTask will only be triggered once with the same idempotency key
|
||||
await childTask.triggerAndWait(payload, { idempotencyKey });
|
||||
|
||||
// This is the same as the above
|
||||
await childTask.triggerAndWait(payload, { idempotencyKey: "my-task-key" });
|
||||
await childTask.trigger({ foo: "bar" }, { idempotencyKey });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
If you are triggering a task from your backend code, you can use the `idempotencyKeys.create` SDK function to create an idempotency key.
|
||||
|
||||
```typescript
|
||||
```ts
|
||||
import { idempotencyKeys, tasks } from "@trigger.dev/sdk/v3";
|
||||
|
||||
// You can also pass an array of strings to create a idempotency key
|
||||
@@ -66,7 +70,7 @@ await tasks.trigger("my-task", { some: "data" }, { idempotencyKey });
|
||||
|
||||
You can also pass a string to the `idempotencyKey` option, without first creating it with `idempotencyKeys.create`.
|
||||
|
||||
```typescript
|
||||
```ts
|
||||
import { myTask } from "./trigger/myTasks";
|
||||
|
||||
// You can also pass an array of strings to create a idempotency key
|
||||
@@ -77,7 +81,7 @@ await myTask.trigger({ some: "data" }, { idempotencyKey: myUser.id });
|
||||
|
||||
You can pass the `idempotencyKey` when calling `batchTrigger` as well:
|
||||
|
||||
```typescript
|
||||
```ts
|
||||
import { tasks } from "@trigger.dev/sdk/v3";
|
||||
|
||||
await tasks.batchTrigger("my-task", [
|
||||
@@ -88,11 +92,47 @@ await tasks.batchTrigger("my-task", [
|
||||
]);
|
||||
```
|
||||
|
||||
## `idempotencyKeyTTL` option
|
||||
|
||||
By default idempotency keys are stored for 30 days. You can change this by passing the `idempotencyKeyTTL` option when triggering a task:
|
||||
|
||||
```ts
|
||||
import { idempotencyKeys, task, wait } from "@trigger.dev/sdk/v3";
|
||||
|
||||
export const myTask = task({
|
||||
id: "my-task",
|
||||
retry: {
|
||||
maxAttempts: 4,
|
||||
},
|
||||
run: async (payload: any) => {
|
||||
const idempotencyKey = await idempotencyKeys.create("my-task-key");
|
||||
|
||||
// The idempotency key will expire after 60 seconds
|
||||
await childTask.trigger({ foo: "bar" }, { idempotencyKey, idempotencyKeyTTL: "60s" });
|
||||
|
||||
await wait.for({ seconds: 61 });
|
||||
|
||||
// The idempotency key will have expired, so the childTask will be triggered again
|
||||
await childTask.trigger({ foo: "bar" }, { idempotencyKey });
|
||||
|
||||
// Do something else, that may throw an error and cause the task to be retried
|
||||
throw new Error("Something went wrong");
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
You can use the following units for the `idempotencyKeyTTL` option:
|
||||
|
||||
- `s` for seconds (e.g. `60s`)
|
||||
- `m` for minutes (e.g. `5m`)
|
||||
- `h` for hours (e.g. `2h`)
|
||||
- `d` for days (e.g. `3d`)
|
||||
|
||||
## Payload-based idempotency
|
||||
|
||||
We don't currently support payload-based idempotency, but you can implement it yourself by hashing the payload and using the hash as the idempotency key.
|
||||
|
||||
```typescript
|
||||
```ts
|
||||
import { idempotencyKeys, task } from "@trigger.dev/sdk/v3";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
|
||||
+48
-39
@@ -3,15 +3,15 @@ title: "Limits"
|
||||
description: "There are some hard and soft limits that you might hit."
|
||||
---
|
||||
|
||||
import RateLimitHitUseBatchTrigger from '/snippets/rate-limit-hit-use-batchtrigger.mdx';
|
||||
import RateLimitHitUseBatchTrigger from "/snippets/rate-limit-hit-use-batchtrigger.mdx";
|
||||
|
||||
## Concurrency limits
|
||||
|
||||
| Pricing tier | Limit |
|
||||
|:---------------- |:-------------------- |
|
||||
| Free | 5 concurrent runs |
|
||||
| Hobby | 25 concurrent runs |
|
||||
| Pro | 100+ concurrent runs |
|
||||
| Pricing tier | Limit |
|
||||
| :----------- | :------------------- |
|
||||
| Free | 5 concurrent runs |
|
||||
| Hobby | 25 concurrent runs |
|
||||
| Pro | 100+ concurrent runs |
|
||||
|
||||
If you need more than 100 concurrent runs on the Pro tier, you can request more by contacting us via [email](https://trigger.dev/contact) or [Discord](https://trigger.dev/discord).
|
||||
|
||||
@@ -20,28 +20,28 @@ If you need more than 100 concurrent runs on the Pro tier, you can request more
|
||||
Generally speaking each SDK call is an API call.
|
||||
|
||||
| Limit | Details |
|
||||
|:----- |:------------------------- |
|
||||
| :---- | :------------------------ |
|
||||
| API | 1,500 requests per minute |
|
||||
|
||||
<RateLimitHitUseBatchTrigger/>
|
||||
<RateLimitHitUseBatchTrigger />
|
||||
|
||||
## Queued tasks
|
||||
|
||||
The number of queued tasks by environment.
|
||||
|
||||
| Limit | Details |
|
||||
|:------- |:------------------ |
|
||||
| :------ | :----------------- |
|
||||
| Dev | At most 500 |
|
||||
| Staging | At most 10 million |
|
||||
| Prod | At most 10 million |
|
||||
|
||||
## Schedules
|
||||
|
||||
| Pricing tier | Limit |
|
||||
|:---------------- |:-------------------- |
|
||||
| Free | 5 per project |
|
||||
| Hobby | 100 per project |
|
||||
| Pro | 1,000+ per project |
|
||||
| Pricing tier | Limit |
|
||||
| :----------- | :----------------- |
|
||||
| Free | 5 per project |
|
||||
| Hobby | 100 per project |
|
||||
| Pro | 1,000+ per project |
|
||||
|
||||
When attaching schedules to tasks we strongly recommend you add them [in our dashboard](/tasks/scheduled#attaching-schedules-in-the-dashboard) if they're "static". That way you can control them easily per environment.
|
||||
|
||||
@@ -49,15 +49,29 @@ If you add them [dynamically using code](/management/schedules/create) make sure
|
||||
|
||||
If you're creating schedules for your user you will definitely need to request more schedules from us.
|
||||
|
||||
## Task payloads and outputs
|
||||
|
||||
| Limit | Details |
|
||||
| :--------------------- | :-------------------------------------------- |
|
||||
| Single trigger payload | Must not exceed 3MB |
|
||||
| Batch trigger payload | The total of all payloads must not exceed 1MB |
|
||||
| Task outputs | Must not exceed 10MB |
|
||||
|
||||
Payloads and outputs that exceed 512KB will be offloaded to object storage and a presigned URL will be provided to download the data when calling `runs.retrieve`. You don't need to do anything to handle this in your tasks however, as we will transparently upload/download these during operation.
|
||||
|
||||
## Batch size
|
||||
|
||||
A single batch can have a maximum of 500 items.
|
||||
|
||||
<SoftLimit />
|
||||
|
||||
## Log retention
|
||||
|
||||
| Pricing tier | Limit |
|
||||
|:---------------- |:--------- |
|
||||
| Free | 1 day |
|
||||
| Hobby | 7 days |
|
||||
| Pro | 30 days |
|
||||
| Pricing tier | Limit |
|
||||
| :----------- | :------ |
|
||||
| Free | 1 day |
|
||||
| Hobby | 7 days |
|
||||
| Pro | 30 days |
|
||||
|
||||
## Log size
|
||||
|
||||
@@ -66,25 +80,30 @@ We limit the size of logs to prevent oversized data potentially causing issues.
|
||||
<Expandable title="log limits">
|
||||
|
||||
#### Attribute Limits
|
||||
|
||||
- Span Attribute Count Limit: 256
|
||||
- Log Attribute Count Limit: 256
|
||||
- Span Attribute Value Length Limit: 1028 characters
|
||||
- Log Attribute Value Length Limit: 1028 characters
|
||||
|
||||
#### Event and Link Limits
|
||||
|
||||
- Span Event Count Limit: 10
|
||||
- Link Count Limit: 2
|
||||
- Attributes per Link Limit: 10
|
||||
- Attributes per Event Limit: 10
|
||||
|
||||
#### I/O Packet Length Limit
|
||||
|
||||
128 KB (131,072 bytes)
|
||||
|
||||
#### Attribute Clipping Behavior
|
||||
|
||||
- Attributes exceeding the value length limit (1028 characters) are discarded.
|
||||
- If the total number of attributes exceeds 256, additional attributes are not included.
|
||||
|
||||
#### Attribute Value Size Calculation
|
||||
|
||||
- Strings: Actual length of the string
|
||||
- Numbers: 8 bytes
|
||||
- Booleans: 4 bytes
|
||||
@@ -93,25 +112,15 @@ We limit the size of logs to prevent oversized data potentially causing issues.
|
||||
|
||||
</Expandable>
|
||||
|
||||
## Task payloads and outputs
|
||||
|
||||
| Limit | Details |
|
||||
|:--- |:--- |
|
||||
| Single trigger payload | Must not exceed 10MB |
|
||||
| Batch trigger payload | The total of all payloads must not exceed 10MB |
|
||||
| Task outputs | Must not exceed 10MB |
|
||||
|
||||
Payloads and outputs that exceed 512KB will be offloaded to object storage and a presigned URL will be provided to download the data when calling `runs.retrieve`. You don't need to do anything to handle this in your tasks however, as we will transparently upload/download these during operation.
|
||||
|
||||
## Alerts
|
||||
|
||||
An alert destination is a single email address, Slack channel, or webhook URL that you want to send alerts to. If you're on the Pro and need more than 100 alert destinations, you can request more by contacting us via [email](https://trigger.dev/contact) or [Discord](https://trigger.dev/discord).
|
||||
|
||||
| Pricing tier | Limit |
|
||||
|:---------------- |:----------------------- |
|
||||
| Free | 1 alert destination |
|
||||
| Hobby | 3 alert destinations |
|
||||
| Pro | 100+ alert destinations |
|
||||
| Pricing tier | Limit |
|
||||
| :----------- | :---------------------- |
|
||||
| Free | 1 alert destination |
|
||||
| Hobby | 3 alert destinations |
|
||||
| Pro | 100+ alert destinations |
|
||||
|
||||
## Machines
|
||||
|
||||
@@ -121,8 +130,8 @@ See the [machine configurations](/machines#machine-configurations) for more deta
|
||||
|
||||
## Team members
|
||||
|
||||
| Pricing tier | Limit |
|
||||
|:---------------- |:----------------- |
|
||||
| Free | 5 team members |
|
||||
| Hobby | 5 team members |
|
||||
| Pro | 25+ team members |
|
||||
| Pricing tier | Limit |
|
||||
| :----------- | :--------------- |
|
||||
| Free | 5 team members |
|
||||
| Hobby | 5 team members |
|
||||
| Pro | 25+ team members |
|
||||
|
||||
+2
-1
@@ -217,7 +217,8 @@
|
||||
"realtime/streams",
|
||||
"realtime/react-hooks",
|
||||
"realtime/subscribe-to-run",
|
||||
"realtime/subscribe-to-runs-with-tag"
|
||||
"realtime/subscribe-to-runs-with-tag",
|
||||
"realtime/subscribe-to-batch"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: runs.subscribeToBatch
|
||||
sidebarTitle: subscribeToBatch
|
||||
description: Subscribes to all changes for runs in a batch.
|
||||
---
|
||||
|
||||
import RunObject from "/snippets/realtime/run-object.mdx";
|
||||
|
||||
<RequestExample>
|
||||
|
||||
```ts Example
|
||||
import { runs } from "@trigger.dev/sdk/v3";
|
||||
|
||||
for await (const run of runs.subscribeToBatch("batch_1234")) {
|
||||
console.log(run);
|
||||
}
|
||||
```
|
||||
|
||||
</RequestExample>
|
||||
|
||||
This function subscribes to all changes for runs in a batch. It returns an async iterator that yields the a run object whenever a run in the batch is updated. The iterator does not complete on it's own, you must manually `break` the loop when you want to stop listening for updates.
|
||||
|
||||
### Authentication
|
||||
|
||||
This function supports both server-side and client-side authentication. For server-side authentication, use your API key. For client-side authentication, you must generate a public access token with one of the following scopes:
|
||||
|
||||
- `read:batch:<batchId>`
|
||||
- `read:runs` will provide access to all runs (not recommended for production use)
|
||||
|
||||
To generate a public access token, use the `auth.createPublicToken` function:
|
||||
|
||||
```ts
|
||||
import { auth } from "@trigger.dev/sdk/v3";
|
||||
|
||||
// Somewhere in your backend code
|
||||
const publicToken = await auth.createPublicToken({
|
||||
scopes: {
|
||||
read: {
|
||||
batch: ["batch_1234"],
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
The AsyncIterator yields an object with the following properties:
|
||||
|
||||
<RunObject />
|
||||
+128
-20
@@ -98,19 +98,21 @@ At this point, the run will have either an output (if successful) or an error (i
|
||||
|
||||
When triggering a task, you can provide an idempotency key to ensure the task is executed only once, even if triggered multiple times. This is useful for preventing duplicate executions in distributed systems.
|
||||
|
||||
```javascript
|
||||
yourTask.trigger({ foo: "bar" }, { idempotencyKey: "unique-key" });
|
||||
```ts
|
||||
await yourTask.trigger({ foo: "bar" }, { idempotencyKey: "unique-key" });
|
||||
```
|
||||
|
||||
- If a run with the same idempotency key is already in progress, the new trigger will be ignored.
|
||||
- If the run has already finished, the previous output or error will be returned.
|
||||
|
||||
See our [Idempotency docs](/idempotency) for more information.
|
||||
|
||||
### Canceling runs
|
||||
|
||||
You can cancel an in-progress run using the API or the dashboard:
|
||||
|
||||
```ts
|
||||
runs.cancel(runId);
|
||||
await runs.cancel(runId);
|
||||
```
|
||||
|
||||
When a run is canceled:
|
||||
@@ -128,7 +130,7 @@ When a run is canceled:
|
||||
You can set a TTL when triggering a run:
|
||||
|
||||
```ts
|
||||
yourTask.trigger({ foo: "bar" }, { ttl: "10m" });
|
||||
await yourTask.trigger({ foo: "bar" }, { ttl: "10m" });
|
||||
```
|
||||
|
||||
If the run hasn't started within the specified TTL, it will automatically expire. This is useful for time-sensitive tasks. Note that dev runs automatically have a 10-minute TTL.
|
||||
@@ -140,7 +142,7 @@ If the run hasn't started within the specified TTL, it will automatically expire
|
||||
You can schedule a run to start after a specified delay:
|
||||
|
||||
```ts
|
||||
yourTask.trigger({ foo: "bar" }, { delay: "1h" });
|
||||
await yourTask.trigger({ foo: "bar" }, { delay: "1h" });
|
||||
```
|
||||
|
||||
This is useful for tasks that need to be executed at a specific time in the future.
|
||||
@@ -152,7 +154,7 @@ This is useful for tasks that need to be executed at a specific time in the futu
|
||||
You can create a new run with the same payload as a previous run:
|
||||
|
||||
```ts
|
||||
runs.replay(runId);
|
||||
await runs.replay(runId);
|
||||
```
|
||||
|
||||
This is useful for re-running a task with the same input, especially for debugging or recovering from failures. The new run will use the latest version of the task.
|
||||
@@ -175,37 +177,143 @@ Similar to `triggerAndWait()`, the `batchTriggerAndWait()` function lets you bat
|
||||
|
||||
### Runs API
|
||||
|
||||
The runs API provides methods to interact with and manage runs:
|
||||
#### runs.list()
|
||||
|
||||
List runs in a specific environment. You can filter the runs by status, created at, task identifier, version, and more:
|
||||
|
||||
```ts
|
||||
// List all runs
|
||||
runs.list();
|
||||
import { runs } from "@trigger.dev/sdk/v3";
|
||||
|
||||
// Get a specific run by ID
|
||||
runs.retrieve(runId);
|
||||
// Get the first page of runs, returning up to 20 runs
|
||||
let page = await runs.list({ limit: 20 });
|
||||
|
||||
// Replay a run
|
||||
runs.replay(runId);
|
||||
for (const run of page.data) {
|
||||
console.log(run);
|
||||
}
|
||||
|
||||
// Reschedule a run
|
||||
runs.reschedule(runId, delay);
|
||||
|
||||
// Cancel a run
|
||||
runs.cancel(runId);
|
||||
// Keep getting the next page until there are no more runs
|
||||
while (page.hasNextPage()) {
|
||||
page = await page.getNextPage();
|
||||
// Do something with the next page of runs
|
||||
}
|
||||
```
|
||||
|
||||
These methods allow you to access detailed information about runs and their attempts, including payloads, outputs, parent runs, and child runs.
|
||||
You can also use an Async Iterator to get all runs:
|
||||
|
||||
```ts
|
||||
import { runs } from "@trigger.dev/sdk/v3";
|
||||
|
||||
for await (const run of runs.list({ limit: 20 })) {
|
||||
console.log(run);
|
||||
}
|
||||
```
|
||||
|
||||
You can provide multiple filters to the `list()` function to narrow down the results:
|
||||
|
||||
```ts
|
||||
import { runs } from "@trigger.dev/sdk/v3";
|
||||
|
||||
const response = await runs.list({
|
||||
status: ["QUEUED", "EXECUTING"], // Filter by status
|
||||
taskIdentifier: ["my-task", "my-other-task"], // Filter by task identifier
|
||||
from: new Date("2024-04-01T00:00:00Z"), // Filter by created at
|
||||
to: new Date(),
|
||||
version: "20241127.2", // Filter by deployment version,
|
||||
tag: ["tag1", "tag2"], // Filter by tags
|
||||
batch: "batch_1234", // Filter by batch ID
|
||||
schedule: "sched_1234", // Filter by schedule ID
|
||||
});
|
||||
```
|
||||
|
||||
#### runs.retrieve()
|
||||
|
||||
Fetch a single run by it's ID:
|
||||
|
||||
```ts
|
||||
import { runs } from "@trigger.dev/sdk/v3";
|
||||
|
||||
const run = await runs.retrieve(runId);
|
||||
```
|
||||
|
||||
You can provide the type of the task to correctly type the `run.payload` and `run.output`:
|
||||
|
||||
```ts
|
||||
import { runs } from "@trigger.dev/sdk/v3";
|
||||
import type { myTask } from "./trigger/myTask";
|
||||
|
||||
const run = await runs.retrieve<typeof myTask>(runId);
|
||||
|
||||
console.log(run.payload.foo); // string
|
||||
console.log(run.output.bar); // string
|
||||
```
|
||||
|
||||
If you have just triggered a run, you can pass the entire response object to `retrieve()` and the response will already be typed:
|
||||
|
||||
```ts
|
||||
import { runs, tasks } from "@trigger.dev/sdk/v3";
|
||||
import type { myTask } from "./trigger/myTask";
|
||||
|
||||
const response = await tasks.trigger<typeof myTask>({ foo: "bar" });
|
||||
const run = await runs.retrieve(response);
|
||||
|
||||
console.log(run.payload.foo); // string
|
||||
console.log(run.output.bar); // string
|
||||
```
|
||||
|
||||
#### runs.cancel()
|
||||
|
||||
Cancel a run:
|
||||
|
||||
```ts
|
||||
import { runs } from "@trigger.dev/sdk/v3";
|
||||
|
||||
await runs.cancel(runId);
|
||||
```
|
||||
|
||||
#### runs.replay()
|
||||
|
||||
Replay a run:
|
||||
|
||||
```ts
|
||||
import { runs } from "@trigger.dev/sdk/v3";
|
||||
|
||||
await runs.replay(runId);
|
||||
```
|
||||
|
||||
#### runs.reschedule()
|
||||
|
||||
Updates a delayed run with a new delay. Only valid when the run is in the DELAYED state.
|
||||
|
||||
```ts
|
||||
import { runs } from "@trigger.dev/sdk/v3";
|
||||
|
||||
await runs.reschedule(runId, { delay: "1h" });
|
||||
```
|
||||
|
||||
### Real-time updates
|
||||
|
||||
You can subscribe to run updates in real-time using the `subscribeToRun()` function:
|
||||
Subscribe to changes to a specific run in real-time:
|
||||
|
||||
```ts
|
||||
import { runs } from "@trigger.dev/sdk/v3";
|
||||
|
||||
for await (const run of runs.subscribeToRun(runId)) {
|
||||
console.log(run);
|
||||
}
|
||||
```
|
||||
|
||||
Similar to `runs.retrieve()`, you can provide the type of the task to correctly type the `run.payload` and `run.output`:
|
||||
|
||||
```ts
|
||||
import { runs } from "@trigger.dev/sdk/v3";
|
||||
import type { myTask } from "./trigger/myTask";
|
||||
|
||||
for await (const run of runs.subscribeToRun<typeof myTask>(runId)) {
|
||||
console.log(run.payload.foo); // string
|
||||
console.log(run.output?.bar); // string | undefined
|
||||
}
|
||||
```
|
||||
|
||||
For more on real-time updates, see the [Realtime](/realtime) documentation.
|
||||
|
||||
### Triggering runs for undeployed tasks
|
||||
|
||||
+337
-108
@@ -3,44 +3,38 @@ title: "Triggering"
|
||||
description: "Tasks need to be triggered in order to run."
|
||||
---
|
||||
|
||||
## Trigger functions
|
||||
|
||||
Trigger tasks **from your backend**:
|
||||
|
||||
| Function | This works | What it does |
|
||||
| :----------------------- | :--------- | :-------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `tasks.trigger()` | Anywhere | Triggers a task and gets a handle you can use to fetch and manage the run. [Read more](#tasks-trigger) |
|
||||
| `tasks.batchTrigger()` | Anywhere | Triggers a task multiple times and gets a handle you can use to fetch and manage the runs. [Read more](#tasks-batchtrigger) |
|
||||
| `tasks.triggerAndPoll()` | Anywhere | Triggers a task and then polls the run until it’s complete. [Read more](#tasks-triggerandpoll) |
|
||||
| Function | What it does | |
|
||||
| :----------------------- | :----------------------------------------------------------------------------------------------- | ----------------------------- |
|
||||
| `tasks.trigger()` | Triggers a task and returns a handle you can use to fetch and manage the run. | [Docs](#tasks-trigger) |
|
||||
| `tasks.batchTrigger()` | Triggers a single task in a batch and returns a handle you can use to fetch and manage the runs. | [Docs](#tasks-batchtrigger) |
|
||||
| `tasks.triggerAndPoll()` | Triggers a task and then polls the run until it’s complete. | [Docs](#tasks-triggerandpoll) |
|
||||
| `batch.trigger()` | Similar to `tasks.batchTrigger` but allows running multiple different tasks | [Docs](#batch-trigger) |
|
||||
|
||||
Trigger tasks **from inside a run**:
|
||||
Trigger tasks **from inside a another task**:
|
||||
|
||||
| Function | This works | What it does |
|
||||
| :------------------------------- | :---------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `yourTask.trigger()` | Anywhere | Triggers a task and gets a handle you can use to monitor and manage the run. It does not wait for the result. [Read more](#yourtask-trigger) |
|
||||
| `yourTask.batchTrigger()` | Anywhere | Triggers a task multiple times and gets a handle you can use to monitor and manage the runs. It does not wait for the results. [Read more](#yourtask-batchtrigger) |
|
||||
| `yourTask.triggerAndWait()` | Inside task | Triggers a task and then waits until it's complete. You get the result data to continue with. [Read more](#yourtask-triggerandwait) |
|
||||
| `yourTask.batchTriggerAndWait()` | Inside task | Triggers a task multiple times in parallel and then waits until they're all complete. You get the resulting data to continue with. [Read more](#yourtask-batchtriggerandwait) |
|
||||
|
||||
Additionally, [scheduled tasks](/tasks/scheduled) get **automatically** triggered on their schedule and webhooks when receiving a webhook.
|
||||
|
||||
## Scheduled tasks
|
||||
|
||||
You should attach one or more schedules to your `schedules.task()` to trigger it on a recurring schedule. [Read the scheduled tasks docs](/tasks/scheduled).
|
||||
|
||||
## Authentication
|
||||
|
||||
When you trigger a task from your backend code, you need to set the `TRIGGER_SECRET_KEY` environment variable. You can find the value on the API keys page in the Trigger.dev dashboard. [More info on API keys](/apikeys).
|
||||
| Function | What it does | |
|
||||
| :------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
|
||||
| `yourTask.trigger()` | Triggers a task and gets a handle you can use to monitor and manage the run. It does not wait for the result. | [Docs](#yourtask-trigger) |
|
||||
| `yourTask.batchTrigger()` | Triggers a task multiple times and gets a handle you can use to monitor and manage the runs. It does not wait for the results. | [Docs](#yourtask-batchtrigger) |
|
||||
| `yourTask.triggerAndWait()` | Triggers a task and then waits until it's complete. You get the result data to continue with. | [Docs](#yourtask-triggerandwait) |
|
||||
| `yourTask.batchTriggerAndWait()` | Triggers a task multiple times in parallel and then waits until they're all complete. You get the resulting data to continue with. | [Docs](#yourtask-batchtriggerandwait) |
|
||||
| `batch.triggerAndWait()` | Similar to `batch.trigger` but will wait on the triggered tasks to finish and return the results. | [Docs](#batch-triggerandwait) |
|
||||
| `batch.triggerByTask()` | Similar to `batch.trigger` but allows passing in task instances instead of task IDs. | [Docs](#batch-triggerbytask) |
|
||||
| `batch.triggerByTaskAndWait()` | Similar to `batch.triggerbyTask` but will wait on the triggered tasks to finish and return the results. | [Docs](#batch-triggerbytaskandwait) |
|
||||
|
||||
## Triggering from your backend
|
||||
|
||||
You can trigger any task from your backend code using the `tasks.trigger()` or `tasks.batchTrigger()` SDK functions.
|
||||
When you trigger a task from your backend code, you need to set the `TRIGGER_SECRET_KEY` environment variable. You can find the value on the API keys page in the Trigger.dev dashboard. [More info on API keys](/apikeys).
|
||||
|
||||
<Note>
|
||||
Do not trigger tasks directly from your frontend. If you do, you will leak your private
|
||||
Trigger.dev API key.
|
||||
If you are using Next.js Server Actions [you'll need to be careful with
|
||||
bundling](/guides/frameworks/nextjs#triggering-your-task-in-next-js).
|
||||
</Note>
|
||||
|
||||
You can use Next.js Server Actions but [you need to be careful with bundling](/guides/frameworks/nextjs#triggering-your-task-in-next-js).
|
||||
|
||||
### tasks.trigger()
|
||||
|
||||
Triggers a single run of a task with the payload you pass in, and any options you specify, without needing to import the task.
|
||||
@@ -51,9 +45,7 @@ Triggers a single run of a task with the payload you pass in, and any options yo
|
||||
application.
|
||||
</Note>
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```ts Next.js API route
|
||||
```ts Your backend
|
||||
import { tasks } from "@trigger.dev/sdk/v3";
|
||||
import type { emailSequence } from "~/trigger/emails";
|
||||
// 👆 **type-only** import
|
||||
@@ -74,45 +66,37 @@ export async function POST(request: Request) {
|
||||
}
|
||||
```
|
||||
|
||||
```ts Remix
|
||||
You can pass in options to the task using the second argument:
|
||||
|
||||
```ts Your backend
|
||||
import { tasks } from "@trigger.dev/sdk/v3";
|
||||
import type { emailSequence } from "~/trigger/emails";
|
||||
// 👆 **type-only** import
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
if (request.method !== "POST") {
|
||||
throw new Response("Method Not Allowed", { status: 405 });
|
||||
}
|
||||
|
||||
//app/email/route.ts
|
||||
export async function POST(request: Request) {
|
||||
//get the JSON from the request
|
||||
const data = await request.json();
|
||||
|
||||
// Pass the task type to `trigger()` as a generic argument, giving you full type checking
|
||||
const handle = await tasks.trigger<typeof emailSequence>("email-sequence", {
|
||||
to: data.email,
|
||||
name: data.name,
|
||||
});
|
||||
const handle = await tasks.trigger<typeof emailSequence>(
|
||||
"email-sequence",
|
||||
{
|
||||
to: data.email,
|
||||
name: data.name,
|
||||
},
|
||||
{ delay: "1h" } // 👈 Pass in the options here
|
||||
);
|
||||
|
||||
//return a success response with the handle
|
||||
return json(handle);
|
||||
return Response.json(handle);
|
||||
}
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
### tasks.batchTrigger()
|
||||
|
||||
Triggers multiple runs of a task with the payloads you pass in, and any options you specify, without needing to import the task.
|
||||
Triggers multiple runs of a single task with the payloads you pass in, and any options you specify, without needing to import the task.
|
||||
|
||||
<Note>
|
||||
By using `tasks.batchTrigger()`, you can pass in the task type as a generic argument, giving you
|
||||
full type checking. Make sure you use a `type` import so that your task code is not imported into
|
||||
your application.
|
||||
</Note>
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```ts Next.js API route
|
||||
```ts Your backend
|
||||
import { tasks } from "@trigger.dev/sdk/v3";
|
||||
import type { emailSequence } from "~/trigger/emails";
|
||||
// 👆 **type-only** import
|
||||
@@ -133,44 +117,62 @@ export async function POST(request: Request) {
|
||||
}
|
||||
```
|
||||
|
||||
```ts Remix
|
||||
You can pass in options to the `batchTrigger` function using the second argument:
|
||||
|
||||
```ts Your backend
|
||||
import { tasks } from "@trigger.dev/sdk/v3";
|
||||
import type { emailSequence } from "~/trigger/emails";
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
if (request.method !== "POST") {
|
||||
throw new Response("Method Not Allowed", { status: 405 });
|
||||
}
|
||||
|
||||
//app/email/route.ts
|
||||
export async function POST(request: Request) {
|
||||
//get the JSON from the request
|
||||
const data = await request.json();
|
||||
|
||||
// Pass the task type to `batchTrigger()` as a generic argument, giving you full type checking
|
||||
const batchHandle = await tasks.batchTrigger<typeof emailSequence>(
|
||||
"email-sequence",
|
||||
data.users.map((u) => ({ payload: { to: u.email, name: u.name } }))
|
||||
data.users.map((u) => ({ payload: { to: u.email, name: u.name } })),
|
||||
{ idempotencyKey: "my-idempotency-key" } // 👈 Pass in the options here
|
||||
);
|
||||
|
||||
//return a success response with the handle
|
||||
return json(batchHandle);
|
||||
return Response.json(batchHandle);
|
||||
}
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
You can also pass in options for each run in the batch:
|
||||
|
||||
```ts Your backend
|
||||
import { tasks } from "@trigger.dev/sdk/v3";
|
||||
import type { emailSequence } from "~/trigger/emails";
|
||||
|
||||
//app/email/route.ts
|
||||
export async function POST(request: Request) {
|
||||
//get the JSON from the request
|
||||
const data = await request.json();
|
||||
|
||||
// Pass the task type to `batchTrigger()` as a generic argument, giving you full type checking
|
||||
const batchHandle = await tasks.batchTrigger<typeof emailSequence>(
|
||||
"email-sequence",
|
||||
data.users.map((u) => ({ payload: { to: u.email, name: u.name }, options: { delay: "1h" } })) // 👈 Pass in options to each item like so
|
||||
);
|
||||
|
||||
//return a success response with the handle
|
||||
return Response.json(batchHandle);
|
||||
}
|
||||
```
|
||||
|
||||
### tasks.triggerAndPoll()
|
||||
|
||||
Triggers a single run of a task with the payload you pass in, and any options you specify, and then polls the run until it's complete.
|
||||
|
||||
<Note>
|
||||
By using `tasks.triggerAndPoll()`, you can pass in the task type as a generic argument, giving you
|
||||
full type checking. Make sure you use a `type` import so that your task code is not imported into
|
||||
your application.
|
||||
</Note>
|
||||
<Warning>
|
||||
We don't recommend using `triggerAndPoll()`, especially inside a web request, as it will block the
|
||||
request until the run is complete. Please see our [Realtime docs](/realtime) for a better way to
|
||||
handle this.
|
||||
</Warning>
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```ts Next.js API route
|
||||
```ts Your backend
|
||||
import { tasks } from "@trigger.dev/sdk/v3";
|
||||
import type { emailSequence } from "~/trigger/emails";
|
||||
|
||||
@@ -194,71 +196,83 @@ export async function POST(request: Request) {
|
||||
}
|
||||
```
|
||||
|
||||
```ts Remix
|
||||
import { tasks } from "@trigger.dev/sdk/v3";
|
||||
import type { emailSequence } from "~/trigger/emails";
|
||||
### batch.trigger()
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
if (request.method !== "POST") {
|
||||
throw new Response("Method Not Allowed", { status: 405 });
|
||||
}
|
||||
Triggers multiple runs of different tasks with the payloads you pass in, and any options you specify. This is useful when you need to trigger multiple tasks at once.
|
||||
|
||||
```ts Your backend
|
||||
import { batch } from "@trigger.dev/sdk/v3";
|
||||
import type { myTask1, myTask2 } from "~/trigger/myTasks";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
//get the JSON from the request
|
||||
const data = await request.json();
|
||||
|
||||
// Pass the task type to `triggerAndPoll()` as a generic argument, giving you full type checking
|
||||
const result = await tasks.triggerAndPoll<typeof emailSequence>(
|
||||
"email-sequence",
|
||||
{
|
||||
to: data.email,
|
||||
name: data.name,
|
||||
},
|
||||
{ pollIntervalMs: 5000 }
|
||||
);
|
||||
// Pass a union of the tasks to `trigger()` as a generic argument, giving you full type checking
|
||||
const result = await batch.trigger<typeof myTask1 | typeof myTask2>([
|
||||
// Because we're using a union, we can pass in multiple tasks by ID
|
||||
{ id: "my-task-1", payload: { some: data.some } },
|
||||
{ id: "my-task-2", payload: { other: data.other } },
|
||||
]);
|
||||
|
||||
//return a success response with the result
|
||||
return json(result);
|
||||
return Response.json(result);
|
||||
}
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
## Triggering from inside another task
|
||||
|
||||
<Note>
|
||||
The above code is just a demonstration of the API and is not recommended to use in an API route
|
||||
this way as it will block the request until the task is complete.
|
||||
</Note>
|
||||
The following functions should only be used when running inside a task, for one of the following reasons:
|
||||
|
||||
## Triggering from inside a run
|
||||
|
||||
Task instance methods are available on the `Task` object you receive when you define a task. We recommend you use these methods inside another task to trigger subtasks.
|
||||
- You need to **wait** for the result of the triggered task.
|
||||
- You need to import the task instance. Importing a task instance from your backend code is not recommended, as it can pull in a lot of unnecessary code and dependencies.
|
||||
|
||||
### yourTask.trigger()
|
||||
|
||||
Triggers a single run of a task with the payload you pass in, and any options you specify. It does NOT wait for the result.
|
||||
Triggers a single run of a task with the payload you pass in, and any options you specify.
|
||||
|
||||
If called from within a task, you can use the `AndWait` version to pause execution until the triggered run is complete.
|
||||
<Note>
|
||||
If you need to call `trigger()` on a task in a loop, use
|
||||
[`batchTrigger()`](#yourTask-batchtrigger) instead which will trigger up to 500 runs in a single
|
||||
call.
|
||||
</Note>
|
||||
|
||||
If you need to call `trigger()` on a task in a loop, use [`batchTrigger()`](/triggering#task-batchtrigger) instead which will trigger up to 100 tasks in a single call.
|
||||
|
||||
```ts /trigger/my-task.ts
|
||||
import { myOtherTask } from "~/trigger/my-other-task";
|
||||
```ts ./trigger/my-task.ts
|
||||
import { myOtherTask, runs } from "~/trigger/my-other-task";
|
||||
|
||||
export const myTask = task({
|
||||
id: "my-task",
|
||||
run: async (payload: string) => {
|
||||
const handle = await myOtherTask.trigger("some data");
|
||||
const handle = await myOtherTask.trigger({ foo: "some data" });
|
||||
|
||||
//...do other stuff
|
||||
const run = await runs.retrieve(handle);
|
||||
// Do something with the run
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
To pass options to the triggered task, you can use the second argument:
|
||||
|
||||
```ts ./trigger/my-task.ts
|
||||
import { myOtherTask, runs } from "~/trigger/my-other-task";
|
||||
|
||||
export const myTask = task({
|
||||
id: "my-task",
|
||||
run: async (payload: string) => {
|
||||
const handle = await myOtherTask.trigger({ foo: "some data" }, { delay: "1h" });
|
||||
|
||||
const run = await runs.retrieve(handle);
|
||||
// Do something with the run
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### yourTask.batchTrigger()
|
||||
|
||||
Triggers multiple runs of a task with the payloads you pass in, and any options you specify. It does NOT wait for the result.
|
||||
Triggers multiple runs of a single task with the payloads you pass in, and any options you specify.
|
||||
|
||||
```ts /trigger/my-task.ts
|
||||
import { myOtherTask } from "~/trigger/my-other-task";
|
||||
import { myOtherTask, batch } from "~/trigger/my-other-task";
|
||||
|
||||
export const myTask = task({
|
||||
id: "my-task",
|
||||
@@ -266,6 +280,43 @@ export const myTask = task({
|
||||
const batchHandle = await myOtherTask.batchTrigger([{ payload: "some data" }]);
|
||||
|
||||
//...do other stuff
|
||||
const batch = await batch.retrieve(batchHandle.id);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
If you need to pass options to `batchTrigger`, you can use the second argument:
|
||||
|
||||
```ts /trigger/my-task.ts
|
||||
import { myOtherTask, batch } from "~/trigger/my-other-task";
|
||||
|
||||
export const myTask = task({
|
||||
id: "my-task",
|
||||
run: async (payload: string) => {
|
||||
const batchHandle = await myOtherTask.batchTrigger([{ payload: "some data" }], {
|
||||
idempotencyKey: "my-task-key",
|
||||
});
|
||||
|
||||
//...do other stuff
|
||||
const batch = await batch.retrieve(batchHandle.id);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
You can also pass in options for each run in the batch:
|
||||
|
||||
```ts /trigger/my-task.ts
|
||||
import { myOtherTask, batch } from "~/trigger/my-other-task";
|
||||
|
||||
export const myTask = task({
|
||||
id: "my-task",
|
||||
run: async (payload: string) => {
|
||||
const batchHandle = await myOtherTask.batchTrigger([
|
||||
{ payload: "some data", options: { delay: "1h" } },
|
||||
]);
|
||||
|
||||
//...do other stuff
|
||||
const batch = await batch.retrieve(batchHandle.id);
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -504,6 +555,152 @@ export const batchParentTask = task({
|
||||
error.
|
||||
</Warning>
|
||||
|
||||
### batch.triggerAndWait()
|
||||
|
||||
You can batch trigger multiple different tasks and wait for all the results:
|
||||
|
||||
```ts /trigger/batch.ts
|
||||
import { batch, task } from "@trigger.dev/sdk/v3";
|
||||
|
||||
export const parentTask = task({
|
||||
id: "parent-task",
|
||||
run: async (payload: string) => {
|
||||
// 👇 Pass a union of all the tasks you want to trigger
|
||||
const results = await batch.triggerAndWait<typeof childTask1 | typeof childTask2>([
|
||||
{ id: "child-task-1", payload: { foo: "World" } }, // 👈 The payload is typed correctly based on the task `id`
|
||||
{ id: "child-task-2", payload: { bar: 42 } }, // 👈 The payload is typed correctly based on the task `id`
|
||||
]);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.ok) {
|
||||
// 👇 Narrow the type of the result based on the taskIdentifier
|
||||
switch (result.taskIdentifier) {
|
||||
case "child-task-1":
|
||||
console.log("Child task 1 output", result.output); // 👈 result.output is typed as a string
|
||||
break;
|
||||
case "child-task-2":
|
||||
console.log("Child task 2 output", result.output); // 👈 result.output is typed as a number
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
console.error("Error", result.error); // 👈 result.error is the error that caused the run to fail
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const childTask1 = task({
|
||||
id: "child-task-1",
|
||||
run: async (payload: { foo: string }) => {
|
||||
return `Hello ${payload}`;
|
||||
},
|
||||
});
|
||||
|
||||
export const childTask2 = task({
|
||||
id: "child-task-2",
|
||||
run: async (payload: { bar: number }) => {
|
||||
return bar + 1;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### batch.triggerByTask()
|
||||
|
||||
You can batch trigger multiple different tasks by passing in the task instances. This function is especially useful when you have a static set of tasks you want to trigger:
|
||||
|
||||
```ts /trigger/batch.ts
|
||||
import { batch, task, runs } from "@trigger.dev/sdk/v3";
|
||||
|
||||
export const parentTask = task({
|
||||
id: "parent-task",
|
||||
run: async (payload: string) => {
|
||||
const results = await batch.triggerByTask([
|
||||
{ task: childTask1, payload: { foo: "World" } }, // 👈 The payload is typed correctly based on the task instance
|
||||
{ task: childTask2, payload: { bar: 42 } }, // 👈 The payload is typed correctly based on the task instance
|
||||
]);
|
||||
|
||||
// 👇 results.runs is a tuple, allowing you to get type safety without needing to narrow
|
||||
const run1 = await runs.retrieve(results.runs[0]); // 👈 run1 is typed as the output of childTask1
|
||||
const run2 = await runs.retrieve(results.runs[1]); // 👈 run2 is typed as the output of childTask2
|
||||
},
|
||||
});
|
||||
|
||||
export const childTask1 = task({
|
||||
id: "child-task-1",
|
||||
run: async (payload: { foo: string }) => {
|
||||
return `Hello ${payload}`;
|
||||
},
|
||||
});
|
||||
|
||||
export const childTask2 = task({
|
||||
id: "child-task-2",
|
||||
run: async (payload: { bar: number }) => {
|
||||
return bar + 1;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### batch.triggerByTaskAndWait()
|
||||
|
||||
You can batch trigger multiple different tasks by passing in the task instances, and wait for all the results. This function is especially useful when you have a static set of tasks you want to trigger:
|
||||
|
||||
```ts /trigger/batch.ts
|
||||
import { batch, task, runs } from "@trigger.dev/sdk/v3";
|
||||
|
||||
export const parentTask = task({
|
||||
id: "parent-task",
|
||||
run: async (payload: string) => {
|
||||
const { runs } = await batch.triggerByTaskAndWait([
|
||||
{ task: childTask1, payload: { foo: "World" } }, // 👈 The payload is typed correctly based on the task instance
|
||||
{ task: childTask2, payload: { bar: 42 } }, // 👈 The payload is typed correctly based on the task instance
|
||||
]);
|
||||
|
||||
if (runs[0].ok) {
|
||||
console.log("Child task 1 output", runs[0].output); // 👈 runs[0].output is typed as the output of childTask1
|
||||
}
|
||||
|
||||
if (runs[1].ok) {
|
||||
console.log("Child task 2 output", runs[1].output); // 👈 runs[1].output is typed as the output of childTask2
|
||||
}
|
||||
|
||||
// 💭 A nice alternative syntax is to destructure the runs array:
|
||||
const {
|
||||
runs: [run1, run2],
|
||||
} = await batch.triggerByTaskAndWait([
|
||||
{ task: childTask1, payload: { foo: "World" } }, // 👈 The payload is typed correctly based on the task instance
|
||||
{ task: childTask2, payload: { bar: 42 } }, // 👈 The payload is typed correctly based on the task instance
|
||||
]);
|
||||
|
||||
if (run1.ok) {
|
||||
console.log("Child task 1 output", run1.output); // 👈 run1.output is typed as the output of childTask1
|
||||
}
|
||||
|
||||
if (run2.ok) {
|
||||
console.log("Child task 2 output", run2.output); // 👈 run2.output is typed as the output of childTask2
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const childTask1 = task({
|
||||
id: "child-task-1",
|
||||
run: async (payload: { foo: string }) => {
|
||||
return `Hello ${payload}`;
|
||||
},
|
||||
});
|
||||
|
||||
export const childTask2 = task({
|
||||
id: "child-task-2",
|
||||
run: async (payload: { bar: number }) => {
|
||||
return bar + 1;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Triggering from your frontend
|
||||
|
||||
If you want to trigger a task directly from a frontend application, you can use our [React
|
||||
hooks](/frontend/react-hooks#trigger-hooks).
|
||||
|
||||
## Options
|
||||
|
||||
All of the above functions accept an options object:
|
||||
@@ -623,7 +820,39 @@ export const myTask = task({
|
||||
const idempotencyKey = await idempotencyKeys.create("my-task-key");
|
||||
|
||||
// childTask will only be triggered once with the same idempotency key
|
||||
await childTask.triggerAndWait(payload, { idempotencyKey });
|
||||
await childTask.trigger(payload, { idempotencyKey });
|
||||
|
||||
// Do something else, that may throw an error and cause the task to be retried
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
For more information, see our [Idempotency](/idempotency) documentation.
|
||||
|
||||
<Warning>
|
||||
In version 3.3.0 and later, the `idempotencyKey` option is not available when using
|
||||
`triggerAndWait` or `batchTriggerAndWait`, due to a bug that would sometimes cause the parent task
|
||||
to become stuck. We are working on a fix for this issue.
|
||||
</Warning>
|
||||
|
||||
### `idempotencyKeyTTL`
|
||||
|
||||
Idempotency keys automatically expire after 30 days, but you can set a custom TTL for an idempotency key when triggering a task:
|
||||
|
||||
```typescript
|
||||
import { idempotencyKeys, task } from "@trigger.dev/sdk/v3";
|
||||
|
||||
export const myTask = task({
|
||||
id: "my-task",
|
||||
retry: {
|
||||
maxAttempts: 4,
|
||||
},
|
||||
run: async (payload: any) => {
|
||||
// By default, idempotency keys generated are unique to the run, to prevent retries from duplicating child tasks
|
||||
const idempotencyKey = await idempotencyKeys.create("my-task-key");
|
||||
|
||||
// childTask will only be triggered once with the same idempotency key
|
||||
await childTask.trigger(payload, { idempotencyKey, idempotencyKeyTTL: "1h" });
|
||||
|
||||
// Do something else, that may throw an error and cause the task to be retried
|
||||
},
|
||||
@@ -827,4 +1056,4 @@ export const myTask = task({
|
||||
|
||||
### Batch Triggering
|
||||
|
||||
When using `batchTrigger` or `batchTriggerAndWait`, the total size of all payloads cannot exceed 10MB. This means if you are doing a batch of 100 runs, each payload should be less than 100KB.
|
||||
When using triggering a batch, the total size of all payloads cannot exceed 1MB. This means if you are doing a batch of 100 runs, each payload should be less than 100KB. The max batch size is 500 runs.
|
||||
|
||||
@@ -753,7 +753,7 @@ paths:
|
||||
import { runs, configure } from "@trigger.dev/sdk/v3";
|
||||
|
||||
configure({
|
||||
secretKey: "tr_pat_1234" // always use an environment variable for this
|
||||
accessToken: "tr_pat_1234" // always use an environment variable for this
|
||||
});
|
||||
|
||||
// Get the first page of runs
|
||||
@@ -781,7 +781,7 @@ paths:
|
||||
import { runs, configure } from "@trigger.dev/sdk/v3";
|
||||
|
||||
configure({
|
||||
secretKey: "tr_pat_1234" // always use an environment variable for this
|
||||
accessToken: "tr_pat_1234" // always use an environment variable for this
|
||||
});
|
||||
|
||||
const response = await runs.list("proj_1234", {
|
||||
@@ -1503,7 +1503,7 @@ components:
|
||||
```typescript
|
||||
import { configure } from "@trigger.dev/sdk/v3";
|
||||
|
||||
configure({ secretKey: "tr_dev_1234" });
|
||||
configure({ accessToken: "tr_dev_1234" });
|
||||
```
|
||||
|
||||
personalAccessToken:
|
||||
@@ -1517,7 +1517,7 @@ components:
|
||||
```typescript
|
||||
import { configure } from "@trigger.dev/sdk/v3";
|
||||
|
||||
configure({ secretKey: "tr_pat_1234" });
|
||||
configure({ accessToken: "tr_pat_1234" });
|
||||
```
|
||||
schemas:
|
||||
TriggerTaskResponse:
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[runtimeEnvironmentId,idempotencyKey]` on the table `BatchTaskRun` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- DropIndex
|
||||
DROP INDEX "BatchTaskRun_runtimeEnvironmentId_taskIdentifier_idempotenc_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE
|
||||
"BatchTaskRun"
|
||||
ADD
|
||||
COLUMN "runCount" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD
|
||||
COLUMN "runIds" TEXT [] DEFAULT ARRAY [] :: TEXT [],
|
||||
ALTER COLUMN
|
||||
"taskIdentifier" DROP NOT NULL;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "BatchTaskRun_runtimeEnvironmentId_idempotencyKey_key" ON "BatchTaskRun"("runtimeEnvironmentId", "idempotencyKey");
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "BatchTaskRun" ADD COLUMN "idempotencyKeyExpiresAt" TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "TaskRun" ADD COLUMN "idempotencyKeyExpiresAt" TIMESTAMP(3);
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "BatchTaskRun" ADD COLUMN "payload" TEXT,
|
||||
ADD COLUMN "payloadType" TEXT NOT NULL DEFAULT 'application/json';
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "BatchTaskRun" ADD COLUMN "options" JSONB;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "TaskRun_projectId_id_idx" ON "TaskRun"("projectId", "id" DESC);
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "TaskRun_runtimeEnvironmentId_batchId_idx" ON "TaskRun"("runtimeEnvironmentId", "batchId");
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "TaskRun" ADD COLUMN "taskVersion" TEXT;
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "TaskRun" ADD COLUMN "cliVersion" TEXT,
|
||||
ADD COLUMN "sdkVersion" TEXT;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "BatchTaskRun" ADD COLUMN "batchVersion" TEXT NOT NULL DEFAULT 'v1';
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "BatchTaskRun" ADD COLUMN "oneTimeUseToken" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "TaskRun" ADD COLUMN "oneTimeUseToken" TEXT;
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[oneTimeUseToken]` on the table `TaskRun` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "TaskRun_oneTimeUseToken_key" ON "TaskRun"("oneTimeUseToken");
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[oneTimeUseToken]` on the table `BatchTaskRun` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "BatchTaskRun_oneTimeUseToken_key" ON "BatchTaskRun"("oneTimeUseToken");
|
||||
@@ -1661,8 +1661,9 @@ model TaskRun {
|
||||
|
||||
status TaskRunStatus @default(PENDING)
|
||||
|
||||
idempotencyKey String?
|
||||
taskIdentifier String
|
||||
idempotencyKey String?
|
||||
idempotencyKeyExpiresAt DateTime?
|
||||
taskIdentifier String
|
||||
|
||||
isTest Boolean @default(false)
|
||||
|
||||
@@ -1691,6 +1692,11 @@ model TaskRun {
|
||||
/// Denormized column that holds the raw tags
|
||||
runTags String[]
|
||||
|
||||
/// Denormalized version of the background worker task
|
||||
taskVersion String?
|
||||
sdkVersion String?
|
||||
cliVersion String?
|
||||
|
||||
checkpoints Checkpoint[]
|
||||
|
||||
startedAt DateTime?
|
||||
@@ -1716,6 +1722,9 @@ model TaskRun {
|
||||
expiredAt DateTime?
|
||||
maxAttempts Int?
|
||||
|
||||
/// optional token that can be used to authenticate the task run
|
||||
oneTimeUseToken String?
|
||||
|
||||
batchItems BatchTaskRunItem[]
|
||||
dependency TaskRunDependency?
|
||||
CheckpointRestoreEvent CheckpointRestoreEvent[]
|
||||
@@ -1781,6 +1790,7 @@ model TaskRun {
|
||||
|
||||
maxDurationInSeconds Int?
|
||||
|
||||
@@unique([oneTimeUseToken])
|
||||
@@unique([runtimeEnvironmentId, taskIdentifier, idempotencyKey])
|
||||
// Finding child runs
|
||||
@@index([parentTaskRunId])
|
||||
@@ -1790,6 +1800,7 @@ model TaskRun {
|
||||
@@index([projectId, createdAt, taskIdentifier])
|
||||
//Runs list
|
||||
@@index([projectId])
|
||||
@@index([projectId, id(sort: Desc)])
|
||||
@@index([projectId, taskIdentifier])
|
||||
@@index([projectId, status])
|
||||
@@index([projectId, taskIdentifier, status])
|
||||
@@ -1803,6 +1814,8 @@ model TaskRun {
|
||||
@@index([completedAt])
|
||||
// Schedule list page
|
||||
@@index([scheduleId, createdAt(sort: Desc)])
|
||||
// Finding runs in a batch
|
||||
@@index([runtimeEnvironmentId, batchId])
|
||||
}
|
||||
|
||||
enum TaskRunStatus {
|
||||
@@ -2133,32 +2146,40 @@ enum TaskQueueType {
|
||||
}
|
||||
|
||||
model BatchTaskRun {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
friendlyId String @unique
|
||||
idempotencyKey String?
|
||||
idempotencyKeyExpiresAt DateTime?
|
||||
runtimeEnvironment RuntimeEnvironment @relation(fields: [runtimeEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
status BatchTaskRunStatus @default(PENDING)
|
||||
runtimeEnvironmentId String
|
||||
runs TaskRun[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
friendlyId String @unique
|
||||
// new columns
|
||||
runIds String[] @default([])
|
||||
runCount Int @default(0)
|
||||
payload String?
|
||||
payloadType String @default("application/json")
|
||||
options Json?
|
||||
batchVersion String @default("v1")
|
||||
|
||||
status BatchTaskRunStatus @default(PENDING)
|
||||
/// optional token that can be used to authenticate the task run
|
||||
oneTimeUseToken String?
|
||||
|
||||
idempotencyKey String?
|
||||
taskIdentifier String
|
||||
|
||||
checkpointEvent CheckpointRestoreEvent? @relation(fields: [checkpointEventId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
checkpointEventId String? @unique
|
||||
|
||||
runtimeEnvironment RuntimeEnvironment @relation(fields: [runtimeEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
runtimeEnvironmentId String
|
||||
|
||||
dependentTaskAttempt TaskRunAttempt? @relation(fields: [dependentTaskAttemptId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
///all the below properties are engine v1 only
|
||||
items BatchTaskRunItem[]
|
||||
taskIdentifier String?
|
||||
checkpointEvent CheckpointRestoreEvent? @relation(fields: [checkpointEventId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
checkpointEventId String? @unique
|
||||
dependentTaskAttempt TaskRunAttempt? @relation(fields: [dependentTaskAttemptId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
dependentTaskAttemptId String?
|
||||
runDependencies TaskRunDependency[] @relation("dependentBatchRun")
|
||||
|
||||
items BatchTaskRunItem[]
|
||||
runDependencies TaskRunDependency[] @relation("dependentBatchRun")
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
TaskRun TaskRun[]
|
||||
|
||||
@@unique([runtimeEnvironmentId, taskIdentifier, idempotencyKey])
|
||||
@@unique([oneTimeUseToken])
|
||||
///this is used for all engine versions
|
||||
@@unique([runtimeEnvironmentId, idempotencyKey])
|
||||
}
|
||||
|
||||
enum BatchTaskRunStatus {
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
# @trigger.dev/build
|
||||
|
||||
## 3.3.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@3.3.2`
|
||||
|
||||
## 3.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@3.3.1`
|
||||
|
||||
## 3.3.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@3.3.0`
|
||||
|
||||
## 3.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@3.2.2`
|
||||
|
||||
## 3.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/build",
|
||||
"version": "3.2.1",
|
||||
"version": "3.3.2",
|
||||
"description": "trigger.dev build extensions",
|
||||
"license": "MIT",
|
||||
"publishConfig": {
|
||||
@@ -65,7 +65,7 @@
|
||||
"check-exports": "attw --pack ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@trigger.dev/core": "workspace:3.2.1",
|
||||
"@trigger.dev/core": "workspace:3.3.2",
|
||||
"pkg-types": "^1.1.3",
|
||||
"tinyglobby": "^0.2.2",
|
||||
"tsconfck": "3.1.3"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user