Compare commits

...

32 Commits

Author SHA1 Message Date
Eric Allam f7bf7bc268 Release 3.3.2 (#1521)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 3s
🚀 Publish Trigger.dev Docker / units (push) Failing after 21s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
2024-12-02 14:14:14 +00:00
github-actions[bot] bb57426a0d chore: Update version for release (#1516)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-12-02 14:12:27 +00:00
Eric Allam 3ab7eb9c7a Add unique indexes for the oneTimeUseToken columns (#1517) 2024-12-02 13:45:38 +00:00
Eric Allam 2892efad04 Fix: actually wait for realtime streams to finished (#1520)
* Realtime streams fix waiting for streams to finished

* Add changeset
2024-12-02 13:40:18 +00:00
nicktrn 979ba51d2f Feat: configurable deploy timeouts (#1519)
* make deploy timeout configurable

* increase default deploy timeout to 8 minutes
2024-12-02 11:02:16 +00:00
Eric Allam 364ea565ed Use one-time use tokens when triggering or batch triggering from the frontend (#1515)
* Use one-time use tokens when triggering or batch triggering from the frontend

* Add changeset
2024-11-29 20:16:37 +00:00
Eric Allam 58252728f6 Release 3.3.1 (#1513)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 4s
🚀 Publish Trigger.dev Docker / units (push) Failing after 4s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
2024-11-29 13:00:10 +00:00
github-actions[bot] 5eaad0577e chore: Update version for release (#1508)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-11-29 12:56:50 +00:00
Matt Aitken 00f1103deb Show "root" runs by default, save the preference to a cookie (#1512)
* “Root only” toggle, off by default. Disabled when filtering by tasks

* Remove text transition on Switch because it didn’t match the container
2024-11-29 12:40:15 +00:00
Eric Allam c37622e7b6 Realtime: improve scope access to runs with tags and batches (#1511)
* JWT scopes for tags and batches can now access runs that have the tag or are in the batch

- useTaskTrigger can now submit options
- auto-generated batch trigger public access tokens no longer need each individual run ID scope

* Add changeset

* Added task scopes to work like tags and batches

Also removed scopes for tags when auto-generating a public access token as that could be dangerous.
2024-11-29 11:37:11 +00:00
Eric Allam d67023aa8f Fixed the missing icons in trigger spans (#1506) 2024-11-28 19:09:33 +00:00
Matt Aitken d9bfe55a8c Run filters: fix for “Batch ID” that should say “Schedule ID” 2024-11-28 17:00:17 +00:00
Eric Allam f4a18feca0 Docs: Batch Trigger upgrades (#1505)
* WIP batch docs

* More batch trigger v2 docs
2024-11-28 15:55:45 +00:00
Matt Aitken 0acd052061 Release 3.3.0
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / units (push) Failing after 4s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
2024-11-28 15:27:20 +00:00
github-actions[bot] 6bf3bbcfd7 chore: Update version for release (#1504)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-11-28 15:26:11 +00:00
James Ritchie 1077709e15 Side help panel (#1488)
* WIP adding a side help panel to the tasks page

* Optionally display the shortcut before the trailing icon in the button

* Updated the close icon

* WIP adding a new side help panel

* WIP adding content to the side help panel

* WIP new side help panel content

* Removed images as not needed any more

* Added content to the side help menu

* Help panel open/closed state stored as cookie

* Removed the icons from the docs and examples links
2024-11-28 11:49:52 +00:00
Willow (GHOST) 54017cbffa feat: always show alerts tab and lock email alerts when keys are missing (#1478) 2024-11-28 11:43:34 +00:00
James Ritchie 39ef733a34 Deploy page table rows stay selected when the side menu is open (#1492) 2024-11-28 11:35:08 +00:00
Fernando Coelho 736f577c25 Update Infisical instructions to new SDK (#1493)
* Update Infisical instructions to new SDK

Current documentation is using old Infisical SDK implementation.

New SDK has a different name, and authenticates on a method, not in the class constructor.

* Fix import and client secret env
2024-11-28 11:34:27 +00:00
James Ritchie b7de02ec14 Improved the text selection style (#1497) 2024-11-28 11:33:27 +00:00
James Ritchie 90db0f6e01 Adds realtime limits to the billing page pricing tiers (#1500) 2024-11-28 11:32:46 +00:00
Eric Allam 6df8069c0e Batch Trigger upgrades (#1502)
* WIP batch trigger v2

* Fix for the DateField being one month out… getUTCMonth() is zero indexed 🤦‍♂️

* Added a custom date range filter

* Deal with closing the custom date range

* Child runs filter

* Fix for the clear button untoggling the child runs

* WIP batchTriggerV2

* Finished removing rate limit from the webapp

* Added an index TaskRun to make useRealtimeBatch performant

* Renamed the period filter labels to be “Last X mins”

* Denormalize background worker columns into TaskRun

* Use the runTags column on TaskRun

* Add TaskRun ("projectId", "id" DESC) index

* Improved the v2 batch trigger endpoint to process items in parallel and also added a threshold, below which the processing of items is async

* Added a runId filter, and WIP for batchId filter

* WIP triggerAll

* Add new batch methods for triggering multiple different tasks in a single batch

* Disabled switch styling

* Batch filtering, force child runs to show if filtering by batch/run

* Added schedule ID filtering

* Force child runs to show when filtering by scheduleId, for consistency

* realtime: allow setting enabled: false on useApiClient

* Batches page

* Always complete batches, not only batchTriggerAndWait in deployed tasks

* Add batch.retrieve and allow filtering by batch in runs.list

* Renamed pending to “In progress”

* Tidied up the table a bit

* Deal with old batches: “Legacy batch”

* Added the Batch to the run inspector

* Fixed the migration that created the new idempotency key index on BatchTaskRun

* Fixed the name of the idempotencyKeyExpiresAt option and now default idempotency key TTL is 30 days, not 24 hours

* Timezone fix: wrong month in Usage page dropdown

* The DateField now defaults to local time, but can be overriden to use utc with an option

* Don’t allow the task icon to get squished

* BatchFilters removed unused imports

* In the batch filtering, use `id` instead of `batchId` in the URL

* BatchFilters: we don’t need a child tasks hidden input field

* Creates some common filter components/functions

* Fix for batchVersion check when filtering by batch status

* Add additional logging around telemetry and more attributes for trigger spans

* Show clear button for specific id filters

* Batch list: only allow environments that are part of this project

* Unnecessary optional chain

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Add JSDocs

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2024-11-28 11:25:00 +00:00
nicktrn b714d6fabf Release 3.2.2
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 4s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
2024-11-27 16:31:01 +00:00
github-actions[bot] 576715374e chore: Update version for release (#1503)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-11-27 16:29:30 +00:00
nicktrn 9046ea4f23 Fix: Prevent hung worker process on flush exceptions (#1501)
* deprecate cli logger warn and error methods

* replace broken error logger in deployed workers

* changeset
2024-11-27 16:22:26 +00:00
Eric Allam 7e209e0771 Stop using Response errors when validating API Keys (#1498)
* Stop using Response errors when validating API Keys, instead introduce a new "Result" type that has success and failure conditions. Adding in a way to progressively adopt because this touches everything.

* Make sure authenticateApiRequestWithFailure always returns a result
2024-11-25 15:19:05 +00:00
Eric Allam 3792394e5c Always await wrapResponse since it returns a Promise, to protect against UnhandledRejection errors (#1494) 2024-11-22 22:16:50 +00:00
Eric Allam 4305a23668 Fix: trigger queue updates (#1491)
* Fixed task trigger queue update logic to only update an existing queue if the concurrency limit changes, instead of on every single trigger task call

* Fix bad update from another branch
2024-11-22 10:39:34 +00:00
James Ritchie e7fc592cd9 Reinstate pagination on schedules page 2024-11-21 09:53:39 +00:00
James Ritchie cb12a73db6 Fix for the last task in a long list wasn’t on screen 2024-11-20 18:10:05 +00:00
James Ritchie 31fc9e12b7 Show the delete alerts button in the popover 2024-11-20 17:40:37 +00:00
James Ritchie 2bdbedcdd8 Fixed layout bug in the alerts page if you don’t have any alerts set 2024-11-20 14:55:22 +00:00
143 changed files with 8801 additions and 1641 deletions
+1
View File
@@ -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);
}
}
}
+392 -239
View File
@@ -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);
}
+6
View File
@@ -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>;
-2
View File
@@ -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,
};
}
+1 -1
View File
@@ -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 };
}
+5 -2
View File
@@ -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,
};
}
@@ -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,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" ? (
@@ -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}
@@ -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>
);
}
@@ -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>
@@ -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} />
@@ -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">
@@ -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>
@@ -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 });
+2 -1
View File
@@ -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 };
+15 -4
View File
@@ -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,
+2 -1
View File
@@ -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);
}
);
@@ -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>
);
}
+171 -7
View File
@@ -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;
}
+11
View File
@@ -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);
},
},
},
});
}
+8
View File
@@ -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;
}
+20
View File
@@ -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,
},
},
},
},
},
+4 -2
View File
@@ -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,
};
+90 -3
View File
@@ -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,
});
+346 -263
View File
@@ -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,
+44 -21
View File
@@ -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."
);
}
});
+7 -5
View File
@@ -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!,
});
+22 -9
View File
@@ -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(
+1 -1
View File
@@ -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}`);
}
+14 -23
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
]
},
{
+49
View File
@@ -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
View File
@@ -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
View File
@@ -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 its 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 its 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.
+4 -4
View File
@@ -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:
@@ -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;
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "BatchTaskRun_runtimeEnvironmentId_idempotencyKey_key" ON "BatchTaskRun"("runtimeEnvironmentId", "idempotencyKey");
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "BatchTaskRun" ADD COLUMN "idempotencyKeyExpiresAt" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "TaskRun" ADD COLUMN "idempotencyKeyExpiresAt" TIMESTAMP(3);
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "BatchTaskRun" ADD COLUMN "payload" TEXT,
ADD COLUMN "payloadType" TEXT NOT NULL DEFAULT 'application/json';
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "BatchTaskRun" ADD COLUMN "options" JSONB;
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX CONCURRENTLY IF NOT EXISTS "TaskRun_projectId_id_idx" ON "TaskRun"("projectId", "id" DESC);
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX CONCURRENTLY IF NOT EXISTS "TaskRun_runtimeEnvironmentId_batchId_idx" ON "TaskRun"("runtimeEnvironmentId", "batchId");
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "TaskRun" ADD COLUMN "taskVersion" TEXT;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "TaskRun" ADD COLUMN "cliVersion" TEXT,
ADD COLUMN "sdkVersion" TEXT;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "BatchTaskRun" ADD COLUMN "batchVersion" TEXT NOT NULL DEFAULT 'v1';
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "BatchTaskRun" ADD COLUMN "oneTimeUseToken" TEXT;
-- AlterTable
ALTER TABLE "TaskRun" ADD COLUMN "oneTimeUseToken" TEXT;
@@ -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");
@@ -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");
+44 -23
View File
@@ -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 {
+28
View File
@@ -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
+2 -2
View File
@@ -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