* TaskRunTag migration. Removed unused TaskTag

* Show tags for a run in the run list

* API endpoint for searching tags

* Let’s not expose an API endpoint for tags at the moment

* Filter by tags working from the URL

* Tag async filter working

* We don’t need a “None” option because it’s multi-select

* When triggering a run you can add tags

* SDK runs.retrieve and runs.list with tag support

* Tidied imports

* Changed the run list query so we show all the tags even if a run only matches one of them

* Fix for dealing with weird characters in tags

* Run tags changeset

* Creating a project has a proper loading state (and blocks multiple)

* Improved the error message for tag length

* Added a tags icon for display on the run screen

* Convenient functions for creating and getting run tags

* tags.set() from inside the run function

* Replay a run passes tags through

* Less ridiculous tags for the catalog example

* Allow passing just a string for the tags

* Order by id because there’s an index on the primary key already

* Trim the tags earlier so we don’t accidentally error if a blank string is passed

* Renamed some thing from setTags to addTags

* Use findFirst for the tags project lookup

* Added an index for "TaskRunTag"("name", "id")

It massively improves the performance of run filtering based on tags

* Use `array_agg` for the run list tags so pagination works and we get a single result for each run

* Tidied imports

* More comprehensive test of tags with all triggering functions

* Support tags with tasks.trigger, tasks.batchTrigger and tasks poll variants

* Added tasks.batchTrigger tags

* Sort the tags in the UI so they’re always in the same order

* Added tooltips in the run table for delay, ttl and tags

* Added costInCents, baseCostInCents and durationMs to runs.retrieve and runs.list

* Added support for displaying a split tag if you use key_value or key:value format

* Fix code comment

* Tweaked the JSDoc to make the prefixing clearer

* Added tasks.triggerAndWait to the tags test task
This commit is contained in:
Matt Aitken
2024-07-23 16:48:09 +01:00
committed by GitHub
parent be3157ace7
commit 55264657d7
30 changed files with 1057 additions and 125 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@trigger.dev/sdk": patch
"@trigger.dev/core": patch
---
You can now add tags to runs and list runs using them
@@ -1,32 +1,37 @@
import { cn } from "~/utils/cn"; import { cn } from "~/utils/cn";
type CustomColor = {
background: string;
foreground: string;
};
export function Spinner({ export function Spinner({
className, className,
color = "blue", color = "blue",
}: { }: {
className?: string; className?: string;
color?: "blue" | "white" | "muted" | "dark"; color?: "blue" | "white" | "muted" | "dark" | CustomColor;
}) { }) {
const colors = { const colors = {
blue: { blue: {
light: "rgba(59, 130, 246, 0.4)", background: "rgba(59, 130, 246, 0.4)",
dark: "rgba(59, 130, 246)", foreground: "rgba(59, 130, 246)",
}, },
white: { white: {
light: "rgba(255, 255, 255, 0.4)", background: "rgba(255, 255, 255, 0.4)",
dark: "rgba(255, 255, 255)", foreground: "rgba(255, 255, 255)",
}, },
muted: { muted: {
light: "#1C2433", background: "#1C2433",
dark: "#3C4B62", foreground: "#3C4B62",
}, },
dark: { dark: {
light: "#15171A", background: "#15171A",
dark: "#272A2E", foreground: "#272A2E",
}, },
}; };
const currentColor = colors[color]; const currentColor = typeof color === "string" ? colors[color] : color;
return ( return (
<svg <svg
@@ -37,13 +42,33 @@ export function Spinner({
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
className={cn("animate-spin motion-reduce:hidden", className)} className={cn("animate-spin motion-reduce:hidden", className)}
> >
<rect x="2" y="2" width="16" height="16" rx="8" stroke={currentColor.light} strokeWidth="3" /> <rect
x="2"
y="2"
width="16"
height="16"
rx="8"
stroke={currentColor.background}
strokeWidth="3"
/>
<path <path
d="M10 18C5.58172 18 2 14.4183 2 10C2 5.58172 5.58172 2 10 2" d="M10 18C5.58172 18 2 14.4183 2 10C2 5.58172 5.58172 2 10 2"
stroke={currentColor.dark} stroke={currentColor.foreground}
strokeWidth="3" strokeWidth="3"
strokeLinecap="round" strokeLinecap="round"
/> />
</svg> </svg>
); );
} }
export function ButtonSpinner() {
return (
<Spinner
className="size-3"
color={{
foreground: "rgba(0, 0, 0, 1)",
background: "rgba(0, 0, 0, 0.25)",
}}
/>
);
}
@@ -4,9 +4,10 @@ import {
CalendarIcon, CalendarIcon,
CpuChipIcon, CpuChipIcon,
InboxStackIcon, InboxStackIcon,
TagIcon,
XMarkIcon, XMarkIcon,
} from "@heroicons/react/20/solid"; } from "@heroicons/react/20/solid";
import { Form } from "@remix-run/react"; import { Form, useFetcher } from "@remix-run/react";
import type { import type {
RuntimeEnvironment, RuntimeEnvironment,
TaskTriggerSource, TaskTriggerSource,
@@ -15,7 +16,7 @@ import type {
} from "@trigger.dev/database"; } from "@trigger.dev/database";
import { ListFilterIcon } from "lucide-react"; import { ListFilterIcon } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { startTransition, useCallback, useMemo, useState } from "react"; import { startTransition, useCallback, useEffect, useMemo, useState } from "react";
import { z } from "zod"; import { z } from "zod";
import { TaskIcon } from "~/assets/icons/TaskIcon"; import { TaskIcon } from "~/assets/icons/TaskIcon";
import { EnvironmentLabel, environmentTitle } from "~/components/environments/EnvironmentLabel"; import { EnvironmentLabel, environmentTitle } from "~/components/environments/EnvironmentLabel";
@@ -51,6 +52,10 @@ import {
import { TaskTriggerSourceIcon } from "./TaskTriggerSource"; import { TaskTriggerSourceIcon } from "./TaskTriggerSource";
import { DateTime } from "~/components/primitives/DateTime"; import { DateTime } from "~/components/primitives/DateTime";
import { BulkActionStatusCombo } from "./BulkAction"; 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); export const TaskAttemptStatus = z.enum(allTaskRunStatuses);
@@ -73,6 +78,10 @@ export const TaskRunListSearchFilters = z.object({
(value) => (typeof value === "string" ? [value] : value), (value) => (typeof value === "string" ? [value] : value),
TaskAttemptStatus.array().optional() TaskAttemptStatus.array().optional()
), ),
tags: z.preprocess(
(value) => (typeof value === "string" ? [value] : value),
z.string().array().optional()
),
period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()), period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()),
bulkId: z.string().optional(), bulkId: z.string().optional(),
from: z.coerce.number().optional(), from: z.coerce.number().optional(),
@@ -104,7 +113,8 @@ export function RunsFilters(props: RunFiltersProps) {
searchParams.has("environments") || searchParams.has("environments") ||
searchParams.has("tasks") || searchParams.has("tasks") ||
searchParams.has("period") || searchParams.has("period") ||
searchParams.has("bulkId"); searchParams.has("bulkId") ||
searchParams.has("tags");
return ( return (
<div className="flex flex-row flex-wrap items-center gap-1"> <div className="flex flex-row flex-wrap items-center gap-1">
@@ -133,6 +143,7 @@ const filterTypes = [
}, },
{ name: "environments", title: "Environment", icon: <CpuChipIcon className="size-4" /> }, { name: "environments", title: "Environment", icon: <CpuChipIcon className="size-4" /> },
{ name: "tasks", title: "Tasks", icon: <TaskIcon className="size-4" /> }, { 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: "created", title: "Created", icon: <CalendarIcon className="size-4" /> },
{ name: "bulk", title: "Bulk action", icon: <InboxStackIcon className="size-4" /> }, { name: "bulk", title: "Bulk action", icon: <InboxStackIcon className="size-4" /> },
] as const; ] as const;
@@ -209,6 +220,7 @@ function AppliedFilters({ possibleEnvironments, possibleTasks, bulkActions }: Ru
<AppliedStatusFilter /> <AppliedStatusFilter />
<AppliedEnvironmentFilter possibleEnvironments={possibleEnvironments} /> <AppliedEnvironmentFilter possibleEnvironments={possibleEnvironments} />
<AppliedTaskFilter possibleTasks={possibleTasks} /> <AppliedTaskFilter possibleTasks={possibleTasks} />
<AppliedTagsFilter />
<AppliedPeriodFilter /> <AppliedPeriodFilter />
<AppliedBulkActionsFilter bulkActions={bulkActions} /> <AppliedBulkActionsFilter bulkActions={bulkActions} />
</> </>
@@ -237,6 +249,8 @@ function Menu(props: MenuProps) {
return <CreatedDropdown onClose={() => props.setFilterType(undefined)} {...props} />; return <CreatedDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "bulk": case "bulk":
return <BulkActionsDropdown onClose={() => props.setFilterType(undefined)} {...props} />; return <BulkActionsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "tags":
return <TagsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
} }
} }
@@ -648,6 +662,124 @@ function AppliedBulkActionsFilter({ bulkActions }: Pick<RunFiltersProps, "bulkAc
); );
} }
function TagsDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const project = useProject();
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({
tags: values,
cursor: undefined,
direction: undefined,
});
};
const fetcher = useFetcher<typeof loader>();
useEffect(() => {
const searchParams = new URLSearchParams();
if (searchValue) {
searchParams.set("name", encodeURIComponent(searchValue));
}
fetcher.load(`/resources/projects/${project.slug}/runs/tags?${searchParams}`);
}, [searchValue]);
const filtered = useMemo(() => {
let items: string[] = [];
if (searchValue === "") {
items = values("tags");
}
if (fetcher.data === undefined) {
return matchSorter(items, searchValue);
}
items.push(...fetcher.data.tags.map((t) => t.name));
return matchSorter(Array.from(new Set(items)), searchValue);
}, [searchValue, fetcher.data]);
return (
<SelectProvider value={values("tags")} 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
value={searchValue}
render={(props) => (
<div className="flex items-center justify-stretch">
<input {...props} placeholder={"Filter by tags..."} />
{fetcher.state === "loading" && <Spinner color="muted" />}
</div>
)}
/>
<SelectList>
{filtered.length > 0
? filtered.map((tag, index) => (
<SelectItem key={tag} value={tag}>
{tag}
</SelectItem>
))
: null}
{filtered.length === 0 && fetcher.state !== "loading" && (
<SelectItem disabled>No tags found</SelectItem>
)}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
function AppliedTagsFilter() {
const { values, del } = useSearchParams();
const tags = values("tags");
if (tags.length === 0) {
return null;
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<TagsDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer" />}>
<AppliedFilter
label="Tags"
value={appliedSummary(values("tags"))}
onRemove={() => del(["tags", "cursor", "direction"])}
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
const timePeriods = [ const timePeriods = [
{ {
label: "All periods", label: "All periods",
@@ -3,6 +3,7 @@ import {
HandRaisedIcon, HandRaisedIcon,
InformationCircleIcon, InformationCircleIcon,
Squares2X2Icon, Squares2X2Icon,
TagIcon,
} from "@heroicons/react/20/solid"; } from "@heroicons/react/20/solid";
import { AttemptIcon } from "~/assets/icons/AttemptIcon"; import { AttemptIcon } from "~/assets/icons/AttemptIcon";
import { TaskIcon } from "~/assets/icons/TaskIcon"; import { TaskIcon } from "~/assets/icons/TaskIcon";
@@ -48,6 +49,8 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) {
return <ClockIcon className={cn(className, "text-teal-500")} />; return <ClockIcon className={cn(className, "text-teal-500")} />;
case "trace": case "trace":
return <Squares2X2Icon className={cn(className, "text-text-dimmed")} />; return <Squares2X2Icon className={cn(className, "text-text-dimmed")} />;
case "tag":
return <TagIcon className={cn(className, "text-text-dimmed")} />;
//log levels //log levels
case "debug": case "debug":
case "log": case "log":
@@ -0,0 +1,48 @@
import { useMemo } from "react";
import tagLeftPath from "./tag-left.svg";
type Tag = string | { key: string; value: string };
export function RunTag({ tag }: { tag: string }) {
const tagResult = useMemo(() => splitTag(tag), [tag]);
if (typeof tagResult === "string") {
return (
<span className="flex h-6 items-stretch">
<img src={tagLeftPath} alt="" className="block h-full w-[0.5625rem]" />
<span className="flex items-center rounded-r-sm border-y border-r border-charcoal-700 bg-charcoal-800 pr-1.5 text-text-dimmed">
{tag}
</span>
</span>
);
} else {
return (
<span className="flex h-6 items-stretch">
<img src={tagLeftPath} alt="" className="block h-full w-[0.5625rem]" />
<span className="flex items-center border-y border-r border-charcoal-700 bg-charcoal-800 pr-1.5 text-text-dimmed">
{tagResult.key}
</span>
<span className="flex items-center rounded-r-sm border-y border-r border-charcoal-700 bg-charcoal-750 px-1.5 text-text-dimmed">
{tagResult.value}
</span>
</span>
);
}
}
/** Takes a string and turns it into a tag
*
* If the string has 12 or fewer alpha characters followed by an underscore or colon then we return an object with a key and value
* Otherwise we return the original string
*/
function splitTag(tag: string): Tag {
if (tag.match(/^[a-zA-Z]{1,12}[_:]/)) {
const components = tag.split(/[_:]/);
if (components.length !== 2) {
return tag;
}
return { key: components[0], value: components[1] };
}
return tag;
}
@@ -5,16 +5,22 @@ import {
RectangleStackIcon, RectangleStackIcon,
StopCircleIcon, StopCircleIcon,
} from "@heroicons/react/20/solid"; } from "@heroicons/react/20/solid";
import { StopIcon } from "@heroicons/react/24/outline";
import { BeakerIcon, BookOpenIcon, CheckIcon } from "@heroicons/react/24/solid"; import { BeakerIcon, BookOpenIcon, CheckIcon } from "@heroicons/react/24/solid";
import { useLocation } from "@remix-run/react"; import { useLocation } from "@remix-run/react";
import { formatDuration, formatDurationMilliseconds } from "@trigger.dev/core/v3"; import { formatDuration, formatDurationMilliseconds } from "@trigger.dev/core/v3";
import { useCallback, useRef } from "react";
import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Checkbox } from "~/components/primitives/Checkbox";
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog"; import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
import { Header3 } from "~/components/primitives/Headers";
import { useSelectedItems } from "~/components/primitives/SelectedItemsProvider";
import { useEnvironments } from "~/hooks/useEnvironments"; import { useEnvironments } from "~/hooks/useEnvironments";
import { useFeatures } from "~/hooks/useFeatures";
import { useOrganization } from "~/hooks/useOrganizations"; import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject"; import { useProject } from "~/hooks/useProject";
import { useUser } from "~/hooks/useUser";
import { RunListAppliedFilters, RunListItem } from "~/presenters/v3/RunListPresenter.server"; import { RunListAppliedFilters, RunListItem } from "~/presenters/v3/RunListPresenter.server";
import { formatCurrencyAccurate, formatNumber } from "~/utils/numberFormatter";
import { docsPath, v3RunSpanPath, v3TestPath } from "~/utils/pathBuilder"; import { docsPath, v3RunSpanPath, v3TestPath } from "~/utils/pathBuilder";
import { EnvironmentLabel } from "../../environments/EnvironmentLabel"; import { EnvironmentLabel } from "../../environments/EnvironmentLabel";
import { DateTime } from "../../primitives/DateTime"; import { DateTime } from "../../primitives/DateTime";
@@ -31,18 +37,10 @@ import {
TableRow, TableRow,
} from "../../primitives/Table"; } from "../../primitives/Table";
import { CancelRunDialog } from "./CancelRunDialog"; import { CancelRunDialog } from "./CancelRunDialog";
import { LiveTimer } from "./LiveTimer";
import { ReplayRunDialog } from "./ReplayRunDialog"; import { ReplayRunDialog } from "./ReplayRunDialog";
import { TaskRunStatusCombo } from "./TaskRunStatus"; import { TaskRunStatusCombo } from "./TaskRunStatus";
import { LiveTimer } from "./LiveTimer"; import { RunTag } from "./RunTag";
import { useSelectedItems } from "~/components/primitives/SelectedItemsProvider";
import { Checkbox } from "~/components/primitives/Checkbox";
import { useCallback, useRef } from "react";
import { run } from "@remix-run/dev/dist/cli/run";
import { formatCurrency, formatCurrencyAccurate, formatNumber } from "~/utils/numberFormatter";
import { useFeatures } from "~/hooks/useFeatures";
import { useUser } from "~/hooks/useUser";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { Header3 } from "~/components/primitives/Headers";
type RunsTableProps = { type RunsTableProps = {
total: number; total: number;
@@ -173,8 +171,73 @@ export function TaskRunsTable({
)} )}
<TableHeaderCell>Test</TableHeaderCell> <TableHeaderCell>Test</TableHeaderCell>
<TableHeaderCell>Created at</TableHeaderCell> <TableHeaderCell>Created at</TableHeaderCell>
<TableHeaderCell>Delayed until</TableHeaderCell> <TableHeaderCell
<TableHeaderCell>TTL</TableHeaderCell> tooltip={
<div className="max-w-xs p-1">
<Paragraph variant="small" className="!text-wrap text-text-dimmed" spacing>
When you want to trigger a task now, but have it run at a later time, you can use
the delay option.
</Paragraph>
<Paragraph variant="small" className="!text-wrap text-text-dimmed" spacing>
Runs that are delayed and have not been enqueued yet will display in the dashboard
with a Delayed status.
</Paragraph>
<LinkButton
to={docsPath("v3/triggering")}
variant="tertiary/small"
LeadingIcon={BookOpenIcon}
>
Read docs
</LinkButton>
</div>
}
>
Delayed until
</TableHeaderCell>
<TableHeaderCell
tooltip={
<div className="max-w-xs p-1">
<Paragraph variant="small" className="!text-wrap text-text-dimmed" spacing>
You can set a TTL (time to live) when triggering a task, which will automatically
expire the run if it hasnt started within the specified time.
</Paragraph>
<Paragraph variant="small" className="!text-wrap text-text-dimmed" spacing>
All runs in development have a default ttl of 10 minutes. You can disable this by
setting the ttl option.
</Paragraph>
<LinkButton
to={docsPath("v3/triggering")}
variant="tertiary/small"
LeadingIcon={BookOpenIcon}
>
Read docs
</LinkButton>
</div>
}
>
TTL
</TableHeaderCell>
<TableHeaderCell
tooltip={
<div className="max-w-xs p-1">
<Paragraph variant="small" className="!text-wrap text-text-dimmed" spacing>
You can add tags to a run and then filter runs using them.
</Paragraph>
<Paragraph variant="small" className="!text-wrap text-text-dimmed" spacing>
You can add tags when triggering a run or inside the run function.
</Paragraph>
<LinkButton
to={docsPath("v3/tags")}
variant="tertiary/small"
LeadingIcon={BookOpenIcon}
>
Read docs
</LinkButton>
</div>
}
>
Tags
</TableHeaderCell>
<TableHeaderCell> <TableHeaderCell>
<span className="sr-only">Go to page</span> <span className="sr-only">Go to page</span>
</TableHeaderCell> </TableHeaderCell>
@@ -278,6 +341,11 @@ export function TaskRunsTable({
{run.delayUntil ? <DateTime date={run.delayUntil} /> : ""} {run.delayUntil ? <DateTime date={run.delayUntil} /> : ""}
</TableCell> </TableCell>
<TableCell to={path}>{run.ttl ?? ""}</TableCell> <TableCell to={path}>{run.ttl ?? ""}</TableCell>
<TableCell to={path}>
<div className="flex gap-1">
{run.tags.map((tag) => <RunTag key={tag} tag={tag} />) || ""}
</div>
</TableCell>
<RunActionsCell run={run} path={path} /> <RunActionsCell run={run} path={path} />
</TableRow> </TableRow>
); );
@@ -0,0 +1,9 @@
<svg width="9" height="25" viewBox="0 0 9 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_13512_47994" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="9" height="25">
<rect width="9" height="25" fill="#D9D9D9"/>
</mask>
<g mask="url(#mask0_13512_47994)">
<path d="M8.51694 0.5H10.5V24.5H8.51694C7.17088 24.5 5.94409 23.7281 5.36161 22.5146L1.69703 14.88C0.974863 13.3755 0.974863 11.6245 1.69703 10.12L5.3616 2.48544C5.94409 1.27194 7.17088 0.5 8.51694 0.5Z" fill="#1A1B1F" stroke="#272A2E" vectorEffect="non-scaling-stroke"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 598 B

+10 -4
View File
@@ -18,13 +18,13 @@ export function useSearchParams() {
} }
if (typeof value === "string") { if (typeof value === "string") {
search.set(param, value); search.set(param, encodeURIComponent(value));
continue; continue;
} }
search.delete(param); search.delete(param);
for (const v of value) { for (const v of value) {
search.append(param, v); search.append(param, encodeURIComponent(v));
} }
} }
}, },
@@ -54,14 +54,20 @@ export function useSearchParams() {
const value = useCallback( const value = useCallback(
(param: string) => { (param: string) => {
return search.get(param) ?? undefined; const val = search.get(param) ?? undefined;
if (val === undefined) {
return val;
}
return decodeURIComponent(val);
}, },
[location, search] [location, search]
); );
const values = useCallback( const values = useCallback(
(param: string) => { (param: string) => {
return search.getAll(param); const all = search.getAll(param);
return all.map((v) => decodeURIComponent(v));
}, },
[location, search] [location, search]
); );
@@ -0,0 +1,40 @@
import { prisma } from "~/db.server";
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
export async function createTag({ tag, projectId }: { tag: string; projectId: string }) {
if (tag.trim().length === 0) return;
return prisma.taskRunTag.upsert({
where: {
projectId_name: {
projectId: projectId,
name: tag,
},
},
create: {
name: tag,
friendlyId: generateFriendlyId("runtag"),
projectId: projectId,
},
update: {},
});
}
export async function getTagsForRunId({
friendlyId,
environmentId,
}: {
friendlyId: string;
environmentId: string;
}) {
const run = await prisma.taskRun.findFirst({
where: {
friendlyId,
runtimeEnvironmentId: environmentId,
},
select: {
tags: true,
},
});
return run?.tags ?? undefined;
}
@@ -35,6 +35,7 @@ export class ApiRetrieveRunPresenter extends BasePresenter {
}, },
lockedToVersion: true, lockedToVersion: true,
schedule: true, schedule: true,
tags: true,
}, },
}); });
@@ -119,6 +120,10 @@ export class ApiRetrieveRunPresenter extends BasePresenter {
isTest: taskRun.isTest, isTest: taskRun.isTest,
ttl: taskRun.ttl ?? undefined, ttl: taskRun.ttl ?? undefined,
expiredAt: taskRun.expiredAt ?? undefined, expiredAt: taskRun.expiredAt ?? undefined,
tags: taskRun.tags.map((t) => t.name).sort((a, b) => a.localeCompare(b)),
costInCents: taskRun.costInCents,
baseCostInCents: taskRun.baseCostInCents,
durationMs: taskRun.usageDurationMs,
schedule: taskRun.schedule schedule: taskRun.schedule
? { ? {
id: taskRun.schedule.friendlyId, id: taskRun.schedule.friendlyId,
@@ -127,7 +132,7 @@ export class ApiRetrieveRunPresenter extends BasePresenter {
? taskRun.schedule.deduplicationKey ? taskRun.schedule.deduplicationKey
: undefined, : undefined,
generator: { generator: {
type: "CRON", type: "CRON" as const,
expression: taskRun.schedule.generatorExpression, expression: taskRun.schedule.generatorExpression,
description: taskRun.schedule.generatorDescription, description: taskRun.schedule.generatorDescription,
}, },
@@ -64,6 +64,12 @@ const SearchParamsSchema = z.object({
.transform((value) => { .transform((value) => {
return value ? value.split(",") : undefined; return value ? value.split(",") : undefined;
}), }),
"filter[tag]": z
.string()
.optional()
.transform((value) => {
return value ? value.split(",") : undefined;
}),
"filter[bulkAction]": z.string().optional(), "filter[bulkAction]": z.string().optional(),
"filter[schedule]": z.string().optional(), "filter[schedule]": z.string().optional(),
"filter[isTest]": z "filter[isTest]": z
@@ -168,6 +174,10 @@ export class ApiRunListPresenter extends BasePresenter {
options.versions = $searchParams.data["filter[version]"]; options.versions = $searchParams.data["filter[version]"];
} }
if ($searchParams.data["filter[tag]"]) {
options.tags = $searchParams.data["filter[tag]"];
}
if ($searchParams.data["filter[bulkAction]"]) { if ($searchParams.data["filter[bulkAction]"]) {
options.bulkId = $searchParams.data["filter[bulkAction]"]; options.bulkId = $searchParams.data["filter[bulkAction]"];
} }
@@ -218,6 +228,10 @@ export class ApiRunListPresenter extends BasePresenter {
name: run.environment.slug, name: run.environment.slug,
user: run.environment.userName, user: run.environment.userName,
}, },
tags: run.tags,
costInCents: run.costInCents,
baseCostInCents: run.baseCostInCents,
durationMs: run.usageDurationMs,
...ApiRetrieveRunPresenter.apiBooleanHelpersFromRunStatus( ...ApiRetrieveRunPresenter.apiBooleanHelpersFromRunStatus(
ApiRetrieveRunPresenter.apiStatusFromRunStatus(run.status) ApiRetrieveRunPresenter.apiStatusFromRunStatus(run.status)
), ),
@@ -1,11 +1,11 @@
import { Prisma, TaskRunStatus, TaskTriggerSource } from "@trigger.dev/database"; import { Prisma, type TaskRunStatus, type TaskTriggerSource } from "@trigger.dev/database";
import parse from "parse-duration"; import parse from "parse-duration";
import { Direction } from "~/components/runs/RunStatuses"; import { type Direction } from "~/components/runs/RunStatuses";
import { FINISHED_STATUSES } from "~/components/runs/v3/TaskRunStatus"; import { FINISHED_STATUSES } from "~/components/runs/v3/TaskRunStatus";
import { sqlDatabaseSchema } from "~/db.server"; import { sqlDatabaseSchema } from "~/db.server";
import { displayableEnvironment } from "~/models/runtimeEnvironment.server"; import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
import { BasePresenter } from "./basePresenter.server";
import { isCancellableRunStatus } from "~/v3/taskStatus"; import { isCancellableRunStatus } from "~/v3/taskStatus";
import { BasePresenter } from "./basePresenter.server";
export type RunListOptions = { export type RunListOptions = {
userId?: string; userId?: string;
@@ -15,6 +15,7 @@ export type RunListOptions = {
versions?: string[]; versions?: string[];
statuses?: TaskRunStatus[]; statuses?: TaskRunStatus[];
environments?: string[]; environments?: string[];
tags?: string[];
scheduleId?: string; scheduleId?: string;
period?: string; period?: string;
bulkId?: string; bulkId?: string;
@@ -41,6 +42,7 @@ export class RunListPresenter extends BasePresenter {
versions, versions,
statuses, statuses,
environments, environments,
tags,
scheduleId, scheduleId,
period, period,
bulkId, bulkId,
@@ -63,6 +65,7 @@ export class RunListPresenter extends BasePresenter {
from !== undefined || from !== undefined ||
to !== undefined || to !== undefined ||
(scheduleId !== undefined && scheduleId !== "") || (scheduleId !== undefined && scheduleId !== "") ||
(tags !== undefined && tags.length > 0) ||
typeof isTest === "boolean"; typeof isTest === "boolean";
// Find the project scoped to the organization // Find the project scoped to the organization
@@ -95,10 +98,10 @@ export class RunListPresenter extends BasePresenter {
//get all possible tasks //get all possible tasks
const possibleTasksAsync = this._replica.$queryRaw< const possibleTasksAsync = this._replica.$queryRaw<
{ {
slug: string; slug: string;
triggerSource: TaskTriggerSource triggerSource: TaskTriggerSource;
}[] }[]
>` >`
SELECT DISTINCT(slug), "triggerSource" SELECT DISTINCT(slug), "triggerSource"
FROM ${sqlDatabaseSchema}."BackgroundWorkerTask" FROM ${sqlDatabaseSchema}."BackgroundWorkerTask"
@@ -150,7 +153,7 @@ export class RunListPresenter extends BasePresenter {
const periodMs = period ? parse(period) : undefined; const periodMs = period ? parse(period) : undefined;
//get the runs //get the runs
let runs = await this._replica.$queryRaw< const runs = await this._replica.$queryRaw<
{ {
id: string; id: string;
number: BigInt; number: BigInt;
@@ -170,7 +173,9 @@ export class RunListPresenter extends BasePresenter {
ttl: string | null; ttl: string | null;
expiredAt: Date | null; expiredAt: Date | null;
costInCents: number; costInCents: number;
baseCostInCents: number;
usageDurationMs: BigInt; usageDurationMs: BigInt;
tags: string[];
}[] }[]
>` >`
SELECT SELECT
@@ -191,66 +196,88 @@ export class RunListPresenter extends BasePresenter {
tr."idempotencyKey" AS "idempotencyKey", tr."idempotencyKey" AS "idempotencyKey",
tr."ttl" AS "ttl", tr."ttl" AS "ttl",
tr."expiredAt" AS "expiredAt", tr."expiredAt" AS "expiredAt",
tr."baseCostInCents" AS "baseCostInCents",
tr."costInCents" AS "costInCents", tr."costInCents" AS "costInCents",
tr."usageDurationMs" AS "usageDurationMs" tr."usageDurationMs" AS "usageDurationMs",
FROM array_remove(array_agg(tag.name), NULL) AS "tags"
FROM
${sqlDatabaseSchema}."TaskRun" tr ${sqlDatabaseSchema}."TaskRun" tr
LEFT JOIN LEFT JOIN
${sqlDatabaseSchema}."BackgroundWorker" bw ON tr."lockedToVersionId" = bw.id ${sqlDatabaseSchema}."BackgroundWorker" bw ON tr."lockedToVersionId" = bw.id
WHERE LEFT JOIN
-- project ${sqlDatabaseSchema}."_TaskRunToTaskRunTag" trtg ON tr.id = trtg."A"
tr."projectId" = ${project.id} LEFT JOIN
-- cursor ${sqlDatabaseSchema}."TaskRunTag" tag ON trtg."B" = tag.id
${ WHERE
cursor -- project
? direction === "forward" tr."projectId" = ${project.id}
? Prisma.sql`AND tr.id < ${cursor}` -- cursor
: Prisma.sql`AND tr.id > ${cursor}` ${
: Prisma.empty cursor
} ? direction === "forward"
-- filters ? Prisma.sql`AND tr.id < ${cursor}`
${ : Prisma.sql`AND tr.id > ${cursor}`
restrictToRunIds : Prisma.empty
? restrictToRunIds.length === 0 }
? Prisma.sql`AND tr.id = ''` -- filters
: Prisma.sql`AND tr.id IN (${Prisma.join(restrictToRunIds)})` ${
: Prisma.empty restrictToRunIds
} ? restrictToRunIds.length === 0
${ ? Prisma.sql`AND tr.id = ''`
tasks && tasks.length > 0 : Prisma.sql`AND tr.id IN (${Prisma.join(restrictToRunIds)})`
? Prisma.sql`AND tr."taskIdentifier" IN (${Prisma.join(tasks)})` : Prisma.empty
: Prisma.empty }
} ${
${ tasks && tasks.length > 0
statuses && statuses.length > 0 ? Prisma.sql`AND tr."taskIdentifier" IN (${Prisma.join(tasks)})`
? Prisma.sql`AND tr.status = ANY(ARRAY[${Prisma.join(statuses)}]::"TaskRunStatus"[])` : Prisma.empty
: Prisma.empty }
} ${
${ statuses && statuses.length > 0
environments && environments.length > 0 ? Prisma.sql`AND tr.status = ANY(ARRAY[${Prisma.join(statuses)}]::"TaskRunStatus"[])`
? Prisma.sql`AND tr."runtimeEnvironmentId" IN (${Prisma.join(environments)})` : Prisma.empty
: Prisma.empty }
} ${
${scheduleId ? Prisma.sql`AND tr."scheduleId" = ${scheduleId}` : Prisma.empty} environments && environments.length > 0
${typeof isTest === "boolean" ? Prisma.sql`AND tr."isTest" = ${isTest}` : Prisma.empty} ? Prisma.sql`AND tr."runtimeEnvironmentId" IN (${Prisma.join(environments)})`
${ : Prisma.empty
periodMs }
? Prisma.sql`AND tr."createdAt" >= NOW() - INTERVAL '1 millisecond' * ${periodMs}` ${scheduleId ? Prisma.sql`AND tr."scheduleId" = ${scheduleId}` : Prisma.empty}
: Prisma.empty ${typeof isTest === "boolean" ? Prisma.sql`AND tr."isTest" = ${isTest}` : Prisma.empty}
} ${
${ periodMs
from ? Prisma.sql`AND tr."createdAt" >= NOW() - INTERVAL '1 millisecond' * ${periodMs}`
? Prisma.sql`AND tr."createdAt" >= ${new Date(from).toISOString()}::timestamp` : Prisma.empty
: Prisma.empty }
} ${
${ from
to ? Prisma.sql`AND tr."createdAt" >= ${new Date(from).toISOString()}::timestamp`
? Prisma.sql`AND tr."createdAt" <= ${new Date(to).toISOString()}::timestamp` : Prisma.empty
: Prisma.empty }
} ${
ORDER BY to ? Prisma.sql`AND tr."createdAt" <= ${new Date(to).toISOString()}::timestamp` : Prisma.empty
${direction === "forward" ? Prisma.sql`tr.id DESC` : Prisma.sql`tr.id ASC`} }
LIMIT ${pageSize + 1}`; ${
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
}
GROUP BY
tr.id, bw.version
ORDER BY
${direction === "forward" ? Prisma.sql`tr.id DESC` : Prisma.sql`tr.id ASC`}
LIMIT ${pageSize + 1}`;
const hasMore = runs.length > pageSize; const hasMore = runs.length > pageSize;
@@ -312,7 +339,9 @@ export class RunListPresenter extends BasePresenter {
ttl: run.ttl ? run.ttl : undefined, ttl: run.ttl ? run.ttl : undefined,
expiredAt: run.expiredAt ? run.expiredAt.toISOString() : undefined, expiredAt: run.expiredAt ? run.expiredAt.toISOString() : undefined,
costInCents: run.costInCents, costInCents: run.costInCents,
baseCostInCents: run.baseCostInCents,
usageDurationMs: Number(run.usageDurationMs), usageDurationMs: Number(run.usageDurationMs),
tags: run.tags.sort((a, b) => a.localeCompare(b)),
}; };
}), }),
pagination: { pagination: {
@@ -0,0 +1,78 @@
import { logger } from "~/services/logger.server";
import { BasePresenter } from "./basePresenter.server";
export type TagListOptions = {
userId?: string;
projectId: string;
//filters
names?: string[];
environments?: string[];
//pagination
page?: number;
pageSize?: number;
};
const DEFAULT_PAGE_SIZE = 25;
export type TagList = Awaited<ReturnType<RunTagListPresenter["call"]>>;
export type TagListItem = TagList["tags"][number];
export class RunTagListPresenter extends BasePresenter {
public async call({
userId,
projectId,
names,
environments,
page = 1,
pageSize = DEFAULT_PAGE_SIZE,
}: TagListOptions) {
const hasFilters =
(names !== undefined && names.length > 0) ||
(environments !== undefined && environments.length > 0);
const tags = await this._replica.taskRunTag.findMany({
where: {
projectId,
OR:
names && names.length > 0
? names.map((name) => ({ name: { contains: name, mode: "insensitive" } }))
: undefined,
project: environments
? {
environments: {
some: {
id: {
in: environments,
},
},
},
}
: undefined,
},
orderBy: {
id: "desc",
},
take: pageSize + 1,
skip: (page - 1) * pageSize,
});
logger.log("tags", {
tags,
projectId,
names,
environments,
});
return {
tags: tags
.map((tag) => ({
id: tag.friendlyId,
name: tag.name,
}))
.slice(0, pageSize),
currentPage: page,
hasMore: tags.length > pageSize,
hasFilters,
};
}
}
@@ -56,9 +56,21 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
tasks: url.searchParams.getAll("tasks"), tasks: url.searchParams.getAll("tasks"),
period: url.searchParams.get("period") ?? undefined, period: url.searchParams.get("period") ?? undefined,
bulkId: url.searchParams.get("bulkId") ?? undefined, bulkId: url.searchParams.get("bulkId") ?? undefined,
tags: url.searchParams.getAll("tags").map((t) => decodeURIComponent(t)),
}; };
const { tasks, versions, statuses, environments, period, bulkId, from, to, cursor, direction } = const {
TaskRunListSearchFilters.parse(s); tasks,
versions,
statuses,
environments,
tags,
period,
bulkId,
from,
to,
cursor,
direction,
} = TaskRunListSearchFilters.parse(s);
const project = await findProjectBySlug(organizationSlug, projectParam, userId); const project = await findProjectBySlug(organizationSlug, projectParam, userId);
@@ -74,6 +86,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
versions, versions,
statuses, statuses,
environments, environments,
tags,
period, period,
bulkId, bulkId,
from, from,
@@ -2,7 +2,7 @@ import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod"; import { parse } from "@conform-to/zod";
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node"; import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node"; import { json } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react"; import { Form, useActionData, useNavigation } from "@remix-run/react";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import invariant from "tiny-invariant"; import invariant from "tiny-invariant";
import { z } from "zod"; import { z } from "zod";
@@ -17,6 +17,7 @@ import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup"; import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label"; import { Label } from "~/components/primitives/Label";
import { Select, SelectItem } from "~/components/primitives/Select"; import { Select, SelectItem } from "~/components/primitives/Select";
import { ButtonSpinner } from "~/components/primitives/Spinner";
import { prisma } from "~/db.server"; import { prisma } from "~/db.server";
import { featuresForRequest } from "~/features.server"; import { featuresForRequest } from "~/features.server";
import { useFeatures } from "~/hooks/useFeatures"; import { useFeatures } from "~/hooks/useFeatures";
@@ -135,6 +136,9 @@ export default function Page() {
}, },
}); });
const navigation = useNavigation();
const isLoading = navigation.state === "submitting" || navigation.state === "loading";
return ( return (
<MainCenteredContainer> <MainCenteredContainer>
<div> <div>
@@ -190,8 +194,13 @@ export default function Page() {
)} )}
<FormButtons <FormButtons
confirmButton={ confirmButton={
<Button type="submit" variant={"primary/small"}> <Button
Create type="submit"
variant={"primary/small"}
disabled={isLoading}
TrailingIcon={isLoading ? ButtonSpinner : undefined}
>
{isLoading ? "Creating…" : "Create"}
</Button> </Button>
} }
cancelButton={ cancelButton={
@@ -0,0 +1,102 @@
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { AddTagsRequestBody } from "@trigger.dev/core/v3";
import { z } from "zod";
import { prisma } from "~/db.server";
import { createTag, getTagsForRunId } from "~/models/taskRunTag.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
const ParamsSchema = z.object({
runId: z.string(),
});
export async function action({ request, params }: ActionFunctionArgs) {
// Ensure this is a POST request
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
}
// Authenticate the request
const authenticationResult = await authenticateApiRequest(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API Key" }, { status: 401 });
}
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
return json(
{ error: "Invalid request parameters", issues: parsedParams.error.issues },
{ status: 400 }
);
}
try {
const anyBody = await request.json();
const body = AddTagsRequestBody.safeParse(anyBody);
if (!body.success) {
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
}
const existingTags =
(await getTagsForRunId({
friendlyId: parsedParams.data.runId,
environmentId: authenticationResult.environment.id,
})) ?? [];
//remove duplicate tags from the new tags
const bodyTags = typeof body.data.tags === "string" ? [body.data.tags] : body.data.tags;
const newTags = bodyTags.filter((tag) => {
if (tag.trim().length === 0) return false;
return !existingTags.map((t) => t.name).includes(tag);
});
if (existingTags.length + newTags.length > 3) {
return json(
{
error: `Runs can only have 3 tags, you're trying to set ${
existingTags.length + newTags.length
}.`,
},
{ status: 422 }
);
}
if (newTags.length === 0) {
return json({ message: "No new tags to add" }, { status: 200 });
}
//create tags
let tagIds: string[] = existingTags.map((t) => t.id);
if (newTags.length > 0) {
for (const tag of newTags) {
const tagRecord = await createTag({
tag,
projectId: authenticationResult.environment.projectId,
});
if (tagRecord) {
tagIds.push(tagRecord.id);
}
}
}
const taskRun = await prisma.taskRun.update({
where: {
friendlyId: parsedParams.data.runId,
runtimeEnvironmentId: authenticationResult.environment.id,
},
data: {
tags: {
connect: tagIds.map((id) => ({ id })),
},
},
});
return json({ message: `Successfully set ${newTags.length} new tags.` }, { status: 200 });
} catch (error) {
return json(
{ error: error instanceof Error ? error.message : "Internal Server Error" },
{ status: 500 }
);
}
}
@@ -0,0 +1,32 @@
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { z } from "zod";
import { $replica } from "~/db.server";
import { RunTagListPresenter } from "~/presenters/v3/RunTagListPresenter.server";
import { requireUserId } from "~/services/session.server";
const Params = z.object({
projectParam: z.string(),
});
export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { projectParam } = Params.parse(params);
const project = await $replica.project.findFirst({
where: { slug: projectParam, deletedAt: null, organization: { members: { some: { userId } } } },
});
if (!project) {
throw new Response("Not Found", { status: 404 });
}
const search = new URL(request.url).searchParams;
const name = search.get("name");
const presenter = new RunTagListPresenter();
const result = await presenter.call({
projectId: project.id,
names: name ? [decodeURIComponent(name)] : undefined,
});
return result;
}
@@ -1,9 +1,10 @@
import { conditionallyImportPacket, parsePacket } from "@trigger.dev/core/v3"; import { conditionallyImportPacket, parsePacket, RunTags } from "@trigger.dev/core/v3";
import { TaskRun } from "@trigger.dev/database"; import { TaskRun } from "@trigger.dev/database";
import { findEnvironmentById } from "~/models/runtimeEnvironment.server"; import { findEnvironmentById } from "~/models/runtimeEnvironment.server";
import { logger } from "~/services/logger.server"; import { logger } from "~/services/logger.server";
import { BaseService } from "./baseService.server"; import { BaseService } from "./baseService.server";
import { OutOfEntitlementError, TriggerTaskService } from "./triggerTask.server"; import { OutOfEntitlementError, TriggerTaskService } from "./triggerTask.server";
import { getTagsForRunId } from "~/models/taskRunTag.server";
export class ReplayTaskRunService extends BaseService { export class ReplayTaskRunService extends BaseService {
public async call(existingTaskRun: TaskRun) { public async call(existingTaskRun: TaskRun) {
@@ -36,6 +37,11 @@ export class ReplayTaskRunService extends BaseService {
}); });
try { try {
const tags = await getTagsForRunId({
friendlyId: existingTaskRun.id,
environmentId: authenticatedEnvironment.id,
});
const triggerTaskService = new TriggerTaskService(); const triggerTaskService = new TriggerTaskService();
return await triggerTaskService.call( return await triggerTaskService.call(
existingTaskRun.taskIdentifier, existingTaskRun.taskIdentifier,
@@ -49,6 +55,7 @@ export class ReplayTaskRunService extends BaseService {
concurrencyKey: existingTaskRun.concurrencyKey ?? undefined, concurrencyKey: existingTaskRun.concurrencyKey ?? undefined,
test: existingTaskRun.isTest, test: existingTaskRun.isTest,
payloadType: payloadPacket.dataType, payloadType: payloadPacket.dataType,
tags: tags?.map((t) => t.name) as RunTags,
}, },
}, },
{ {
@@ -17,6 +17,7 @@ import { getEntitlement } from "~/services/platform.v3.server";
import { BaseService, ServiceValidationError } from "./baseService.server"; import { BaseService, ServiceValidationError } from "./baseService.server";
import { logger } from "~/services/logger.server"; import { logger } from "~/services/logger.server";
import { isFinalAttemptStatus, isFinalRunStatus } from "../taskStatus"; import { isFinalAttemptStatus, isFinalRunStatus } from "../taskStatus";
import { createTag } from "~/models/taskRunTag.server";
export type TriggerTaskServiceOptions = { export type TriggerTaskServiceOptions = {
idempotencyKey?: string; idempotencyKey?: string;
@@ -210,6 +211,22 @@ export class TriggerTaskService extends BaseService {
event.setAttribute("queueName", queueName); event.setAttribute("queueName", queueName);
span.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);
}
}
}
const taskRun = await tx.taskRun.create({ const taskRun = await tx.taskRun.create({
data: { data: {
status: delayUntil ? "DELAYED" : "PENDING", status: delayUntil ? "DELAYED" : "PENDING",
@@ -233,6 +250,12 @@ export class TriggerTaskService extends BaseService {
queuedAt: delayUntil ? undefined : new Date(), queuedAt: delayUntil ? undefined : new Date(),
maxAttempts: body.options?.maxAttempts, maxAttempts: body.options?.maxAttempts,
ttl, ttl,
tags:
tagIds.length === 0
? undefined
: {
connect: tagIds.map((id) => ({ id })),
},
}, },
}); });
+23 -1
View File
@@ -1,6 +1,8 @@
import { context, propagation } from "@opentelemetry/api"; import { context, propagation } from "@opentelemetry/api";
import { z } from "zod";
import { version } from "../../../package.json"; import { version } from "../../../package.json";
import { import {
AddTagsRequestBody,
BatchTaskRunExecutionResult, BatchTaskRunExecutionResult,
BatchTriggerTaskRequestBody, BatchTriggerTaskRequestBody,
BatchTriggerTaskResponse, BatchTriggerTaskResponse,
@@ -63,8 +65,8 @@ const DEFAULT_ZOD_FETCH_OPTIONS: ZodFetchOptions = {
}, },
}; };
export type { ApiRequestOptions };
export { isRequestOptions }; export { isRequestOptions };
export type { ApiRequestOptions };
/** /**
* Trigger.dev v3 API client * Trigger.dev v3 API client
@@ -289,6 +291,19 @@ export class ApiClient {
); );
} }
addTags(runId: string, body: AddTagsRequestBody, requestOptions?: ZodFetchOptions) {
return zodfetch(
z.object({ message: z.string() }),
`${this.baseUrl}/api/v1/runs/${runId}/tags`,
{
method: "POST",
headers: this.#getHeaders(false),
body: JSON.stringify(body),
},
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
);
}
createSchedule(options: CreateScheduleOptions, requestOptions?: ZodFetchOptions) { createSchedule(options: CreateScheduleOptions, requestOptions?: ZodFetchOptions) {
return zodfetch( return zodfetch(
ScheduleObject, ScheduleObject,
@@ -534,6 +549,13 @@ function createSearchQueryForListRuns(query?: ListRunsQueryParams): URLSearchPar
searchParams.append("filter[bulkAction]", query.bulkAction); searchParams.append("filter[bulkAction]", query.bulkAction);
} }
if (query.tag) {
searchParams.append(
"filter[tag]",
Array.isArray(query.tag) ? query.tag.join(",") : query.tag
);
}
if (query.schedule) { if (query.schedule) {
searchParams.append("filter[schedule]", query.schedule); searchParams.append("filter[schedule]", query.schedule);
} }
+1
View File
@@ -28,6 +28,7 @@ export interface ListRunsQueryParams extends CursorPageParams {
to?: Date | number; to?: Date | number;
period?: string; period?: string;
bulkAction?: string; bulkAction?: string;
tag?: Array<string> | string;
schedule?: string; schedule?: string;
isTest?: boolean; isTest?: boolean;
} }
+22
View File
@@ -55,6 +55,17 @@ export const CreateBackgroundWorkerResponse = z.object({
export type CreateBackgroundWorkerResponse = z.infer<typeof CreateBackgroundWorkerResponse>; export type CreateBackgroundWorkerResponse = z.infer<typeof CreateBackgroundWorkerResponse>;
//an array of 1, 2, or 3 strings
const RunTag = z.string().max(64, "Tags must be less than 64 characters");
export const RunTags = z.union([
RunTag,
z.tuple([RunTag]),
z.tuple([RunTag, RunTag]),
z.tuple([RunTag, RunTag, RunTag]),
]);
export type RunTags = z.infer<typeof RunTags>;
export const TriggerTaskRequestBody = z.object({ export const TriggerTaskRequestBody = z.object({
payload: z.any(), payload: z.any(),
context: z.any(), context: z.any(),
@@ -70,6 +81,7 @@ export const TriggerTaskRequestBody = z.object({
payloadType: z.string().optional(), payloadType: z.string().optional(),
delay: z.string().or(z.coerce.date()).optional(), delay: z.string().or(z.coerce.date()).optional(),
ttl: z.string().or(z.number().nonnegative().int()).optional(), ttl: z.string().or(z.number().nonnegative().int()).optional(),
tags: RunTags.optional(),
maxAttempts: z.number().int().optional(), maxAttempts: z.number().int().optional(),
}) })
.optional(), .optional(),
@@ -110,6 +122,12 @@ export const GetBatchResponseBody = z.object({
export type GetBatchResponseBody = z.infer<typeof GetBatchResponseBody>; export type GetBatchResponseBody = z.infer<typeof GetBatchResponseBody>;
export const AddTagsRequestBody = z.object({
tags: RunTags,
});
export type AddTagsRequestBody = z.infer<typeof AddTagsRequestBody>;
export const RescheduleRunRequestBody = z.object({ export const RescheduleRunRequestBody = z.object({
delay: z.string().or(z.coerce.date()), delay: z.string().or(z.coerce.date()),
}); });
@@ -452,6 +470,10 @@ const CommonRunFields = {
delayedUntil: z.coerce.date().optional(), delayedUntil: z.coerce.date().optional(),
ttl: z.string().optional(), ttl: z.string().optional(),
expiredAt: z.coerce.date().optional(), expiredAt: z.coerce.date().optional(),
tags: z.string().array(),
costInCents: z.number(),
baseCostInCents: z.number(),
durationMs: z.number(),
}; };
export const RetrieveRunResponse = z.object({ export const RetrieveRunResponse = z.object({
@@ -0,0 +1,59 @@
/*
Warnings:
- You are about to drop the `TaskTag` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `_TaskRunToTaskTag` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "TaskTag" DROP CONSTRAINT "TaskTag_projectId_fkey";
-- DropForeignKey
ALTER TABLE "_TaskRunToTaskTag" DROP CONSTRAINT "_TaskRunToTaskTag_A_fkey";
-- DropForeignKey
ALTER TABLE "_TaskRunToTaskTag" DROP CONSTRAINT "_TaskRunToTaskTag_B_fkey";
-- DropTable
DROP TABLE "TaskTag";
-- DropTable
DROP TABLE "_TaskRunToTaskTag";
-- CreateTable
CREATE TABLE "TaskRunTag" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"friendlyId" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TaskRunTag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_TaskRunToTaskRunTag" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "TaskRunTag_friendlyId_key" ON "TaskRunTag"("friendlyId");
-- CreateIndex
CREATE UNIQUE INDEX "TaskRunTag_projectId_name_key" ON "TaskRunTag"("projectId", "name");
-- CreateIndex
CREATE UNIQUE INDEX "_TaskRunToTaskRunTag_AB_unique" ON "_TaskRunToTaskRunTag"("A", "B");
-- CreateIndex
CREATE INDEX "_TaskRunToTaskRunTag_B_index" ON "_TaskRunToTaskRunTag"("B");
-- AddForeignKey
ALTER TABLE "TaskRunTag" ADD CONSTRAINT "TaskRunTag_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_TaskRunToTaskRunTag" ADD CONSTRAINT "_TaskRunToTaskRunTag_A_fkey" FOREIGN KEY ("A") REFERENCES "TaskRun"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_TaskRunToTaskRunTag" ADD CONSTRAINT "_TaskRunToTaskRunTag_B_fkey" FOREIGN KEY ("B") REFERENCES "TaskRunTag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "TaskRunTag_name_id_idx" ON "TaskRunTag"("name", "id");
+22 -19
View File
@@ -453,7 +453,7 @@ model Project {
backgroundWorkers BackgroundWorker[] backgroundWorkers BackgroundWorker[]
backgroundWorkerTasks BackgroundWorkerTask[] backgroundWorkerTasks BackgroundWorkerTask[]
taskRuns TaskRun[] taskRuns TaskRun[]
taskTags TaskTag[] runTags TaskRunTag[]
taskQueues TaskQueue[] taskQueues TaskQueue[]
environmentVariables EnvironmentVariable[] environmentVariables EnvironmentVariable[]
checkpoints Checkpoint[] checkpoints Checkpoint[]
@@ -1641,8 +1641,9 @@ model TaskRun {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
attempts TaskRunAttempt[] @relation("attempts") attempts TaskRunAttempt[] @relation("attempts")
tags TaskTag[] tags TaskRunTag[]
checkpoints Checkpoint[] checkpoints Checkpoint[]
startedAt DateTime? startedAt DateTime?
@@ -1735,6 +1736,24 @@ enum TaskRunStatus {
EXPIRED EXPIRED
} }
model TaskRunTag {
id String @id @default(cuid())
name String
friendlyId String @unique
runs TaskRun[]
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
projectId String
createdAt DateTime @default(now())
@@unique([projectId, name])
//Makes run filtering by tag faster
@@index([name, id])
}
model TaskRunDependency { model TaskRunDependency {
id String @id @default(cuid()) id String @id @default(cuid())
@@ -1775,22 +1794,6 @@ model TaskRunNumberCounter {
@@unique([taskIdentifier, environmentId]) @@unique([taskIdentifier, environmentId])
} }
model TaskTag {
id String @id @default(cuid())
name String
friendlyId String @unique
runs TaskRun[]
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
projectId String
createdAt DateTime @default(now())
@@unique([projectId, name])
}
model TaskRunAttempt { model TaskRunAttempt {
id String @id @default(cuid()) id String @id @default(cuid())
number Int @default(0) number Int @default(0)
+1
View File
@@ -6,6 +6,7 @@ export * from "./tasks";
export * from "./wait"; export * from "./wait";
export * from "./usage"; export * from "./usage";
export * from "./idempotencyKeys"; export * from "./idempotencyKeys";
export * from "./tags";
export type { Context }; export type { Context };
import type { Context } from "./shared"; import type { Context } from "./shared";
+23
View File
@@ -18,6 +18,7 @@ import {
QueueOptions, QueueOptions,
RetryOptions, RetryOptions,
RunFnParams, RunFnParams,
RunTags,
SemanticInternalAttributes, SemanticInternalAttributes,
StartFnParams, StartFnParams,
SuccessFnParams, SuccessFnParams,
@@ -440,6 +441,21 @@ export type TaskRunOptions = {
* **Note:** Runs in development have a default `ttl` of 10 minutes. You can override this by setting the `ttl` option. * **Note:** Runs in development have a default `ttl` of 10 minutes. You can override this by setting the `ttl` option.
*/ */
ttl?: string | number; ttl?: string | number;
/**
* Tags to attach to the run. Tags can be used to filter runs in the dashboard and using the SDK.
*
* You can set up to 3 tags per run, they must be less than 64 characters each.
*
* We recommend prefixing tags with a namespace using an underscore or colon, like `user_1234567` or `org:9876543`.
*
* @example
*
* ```ts
* await myTask.trigger({ foo: "bar" }, { tags: ["user:1234567", "org:9876543"] });
* ```
*/
tags?: RunTags;
}; };
type TaskRunConcurrencyOptions = Queue; type TaskRunConcurrencyOptions = Queue;
@@ -485,6 +501,7 @@ export function createTask<
idempotencyKey: await makeKey(options?.idempotencyKey), idempotencyKey: await makeKey(options?.idempotencyKey),
delay: options?.delay, delay: options?.delay,
ttl: options?.ttl, ttl: options?.ttl,
tags: options?.tags,
maxAttempts: options?.maxAttempts, maxAttempts: options?.maxAttempts,
}, },
}, },
@@ -547,6 +564,7 @@ export function createTask<
idempotencyKey: await makeKey(item.options?.idempotencyKey), idempotencyKey: await makeKey(item.options?.idempotencyKey),
delay: item.options?.delay, delay: item.options?.delay,
ttl: item.options?.ttl, ttl: item.options?.ttl,
tags: item.options?.tags,
maxAttempts: item.options?.maxAttempts, maxAttempts: item.options?.maxAttempts,
}, },
}; };
@@ -616,6 +634,7 @@ export function createTask<
idempotencyKey: await makeKey(options?.idempotencyKey), idempotencyKey: await makeKey(options?.idempotencyKey),
delay: options?.delay, delay: options?.delay,
ttl: options?.ttl, ttl: options?.ttl,
tags: options?.tags,
maxAttempts: options?.maxAttempts, maxAttempts: options?.maxAttempts,
}, },
}); });
@@ -701,6 +720,7 @@ export function createTask<
idempotencyKey: await makeKey(item.options?.idempotencyKey), idempotencyKey: await makeKey(item.options?.idempotencyKey),
delay: item.options?.delay, delay: item.options?.delay,
ttl: item.options?.ttl, ttl: item.options?.ttl,
tags: item.options?.tags,
maxAttempts: item.options?.maxAttempts, maxAttempts: item.options?.maxAttempts,
}, },
}; };
@@ -866,6 +886,7 @@ export async function trigger<TTask extends AnyTask>(
idempotencyKey: await makeKey(options?.idempotencyKey), idempotencyKey: await makeKey(options?.idempotencyKey),
delay: options?.delay, delay: options?.delay,
ttl: options?.ttl, ttl: options?.ttl,
tags: options?.tags,
maxAttempts: options?.maxAttempts, maxAttempts: options?.maxAttempts,
}, },
}, },
@@ -942,6 +963,7 @@ export async function triggerAndWait<TTask extends AnyTask>(
idempotencyKey: await makeKey(options?.idempotencyKey), idempotencyKey: await makeKey(options?.idempotencyKey),
delay: options?.delay, delay: options?.delay,
ttl: options?.ttl, ttl: options?.ttl,
tags: options?.tags,
maxAttempts: options?.maxAttempts, maxAttempts: options?.maxAttempts,
}, },
}, },
@@ -1051,6 +1073,7 @@ export async function batchTrigger<TTask extends AnyTask>(
idempotencyKey: await makeKey(item.options?.idempotencyKey), idempotencyKey: await makeKey(item.options?.idempotencyKey),
delay: item.options?.delay, delay: item.options?.delay,
ttl: item.options?.ttl, ttl: item.options?.ttl,
tags: item.options?.tags,
maxAttempts: item.options?.maxAttempts, maxAttempts: item.options?.maxAttempts,
}, },
}; };
+66
View File
@@ -0,0 +1,66 @@
import type { ApiRequestOptions, RunTags } from "@trigger.dev/core/v3";
import {
UnprocessableEntityError,
accessoryAttributes,
apiClientManager,
logger,
mergeRequestOptions,
taskContext,
} from "@trigger.dev/core/v3";
import { apiClientMissingError } from "./shared";
import { tracer } from "./tracer";
export const tags = {
add: addTags,
};
async function addTags(tags: RunTags, requestOptions?: ApiRequestOptions) {
const apiClient = apiClientManager.client;
if (!apiClient) {
throw apiClientMissingError();
}
const run = taskContext.ctx?.run;
if (!run) {
throw new Error(
"Can't set tags outside of a run. You can trigger a task and set tags in the options."
);
}
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "tags.set()",
icon: "tag",
attributes: {
...accessoryAttributes({
items: [
{
text: typeof tags === "string" ? tags : tags.join(", "),
variant: "normal",
},
],
style: "codepath",
}),
},
},
requestOptions
);
try {
await apiClient.addTags(run.id, { tags }, $requestOptions);
} catch (error) {
if (error instanceof UnprocessableEntityError) {
logger.error(error.message, {
existingTags: run.tags,
newTags: tags,
});
return;
}
logger.error("Failed to set tags", { error });
throw error;
}
}
@@ -1,4 +1,4 @@
import { logger, task, wait, tasks } from "@trigger.dev/sdk/v3"; import { logger, task, wait, tasks, tags } from "@trigger.dev/sdk/v3";
import { taskWithRetries } from "./retries"; import { taskWithRetries } from "./retries";
export const simpleParentTask = task({ export const simpleParentTask = task({
@@ -27,6 +27,9 @@ export const simpleChildTask = task({
run: async (payload: { message: string }, { ctx }) => { run: async (payload: { message: string }, { ctx }) => {
logger.log("Simple child task payload", { payload, ctx }); logger.log("Simple child task payload", { payload, ctx });
logger.log("Context tags", { tags: ctx.run.tags });
await tags.add("product:1");
await wait.for({ seconds: 10 }); await wait.for({ seconds: 10 });
}, },
}); });
+81
View File
@@ -0,0 +1,81 @@
import { RunTags } from "@trigger.dev/core/v3";
import { logger, runs, tags, task, tasks } from "@trigger.dev/sdk/v3";
import { simpleChildTask } from "./subtasks";
type Payload = {
tags: RunTags;
};
export const triggerRunsWithTags = task({
id: "trigger-runs-with-tags",
run: async (payload: Payload, { ctx }) => {
const { id } = await simpleChildTask.trigger(
{ message: "trigger from triggerRunsWithTags" },
{ tags: payload.tags }
);
await simpleChildTask.triggerAndWait(
{ message: "triggerAndWait from triggerRunsWithTags" },
{ tags: payload.tags }
);
await simpleChildTask.batchTrigger([
{
payload: { message: "batchTrigger 1 from triggerRunsWithTags" },
options: { tags: payload.tags },
},
{
payload: { message: "batchTrigger 2 from triggerRunsWithTags" },
options: { tags: payload.tags },
},
]);
const results = await simpleChildTask.batchTriggerAndWait([
{
payload: { message: "batchTriggerAndWait 1 from triggerRunsWithTags" },
options: { tags: payload.tags },
},
{
payload: { message: "batchTriggerAndWait 2 from triggerRunsWithTags" },
options: { tags: payload.tags },
},
]);
await tasks.trigger<typeof simpleChildTask>(
"simple-child-task",
{ message: "tasks.trigger from triggerRunsWithTags" },
{ tags: payload.tags }
);
await tasks.triggerAndWait<typeof simpleChildTask>(
"simple-child-task",
{ message: "tasks.triggerAndWait from triggerRunsWithTags" },
{ tags: payload.tags }
);
await tasks.batchTrigger<typeof simpleChildTask>("simple-child-task", [
{
payload: { message: "tasks.batchTrigger 1 from triggerRunsWithTags" },
options: { tags: payload.tags },
},
{
payload: { message: "tasks.batchTrigger 2 from triggerRunsWithTags" },
options: { tags: payload.tags },
},
]);
const run = await runs.retrieve(id);
logger.log("run", run);
logger.log("run usage", {
costInCents: run.costInCents,
baseCostInCents: run.baseCostInCents,
durationMs: run.durationMs,
});
const result2 = await runs.list({ tag: payload.tags });
logger.log("trigger runs ", { length: result2.data.length, data: result2.data });
logger.log("run usage", {
costInCents: result2.data[0].costInCents,
baseCostInCents: result2.data[0].baseCostInCents,
durationMs: result2.data[0].durationMs,
});
},
});