diff --git a/.changeset/odd-beds-wonder.md b/.changeset/odd-beds-wonder.md new file mode 100644 index 000000000..6bd33089d --- /dev/null +++ b/.changeset/odd-beds-wonder.md @@ -0,0 +1,6 @@ +--- +"@trigger.dev/sdk": patch +"@trigger.dev/core": patch +--- + +You can now add tags to runs and list runs using them diff --git a/apps/webapp/app/components/primitives/Spinner.tsx b/apps/webapp/app/components/primitives/Spinner.tsx index ea43fe209..1cf3c47ad 100644 --- a/apps/webapp/app/components/primitives/Spinner.tsx +++ b/apps/webapp/app/components/primitives/Spinner.tsx @@ -1,32 +1,37 @@ import { cn } from "~/utils/cn"; +type CustomColor = { + background: string; + foreground: string; +}; + export function Spinner({ className, color = "blue", }: { className?: string; - color?: "blue" | "white" | "muted" | "dark"; + color?: "blue" | "white" | "muted" | "dark" | CustomColor; }) { const colors = { blue: { - light: "rgba(59, 130, 246, 0.4)", - dark: "rgba(59, 130, 246)", + background: "rgba(59, 130, 246, 0.4)", + foreground: "rgba(59, 130, 246)", }, white: { - light: "rgba(255, 255, 255, 0.4)", - dark: "rgba(255, 255, 255)", + background: "rgba(255, 255, 255, 0.4)", + foreground: "rgba(255, 255, 255)", }, muted: { - light: "#1C2433", - dark: "#3C4B62", + background: "#1C2433", + foreground: "#3C4B62", }, dark: { - light: "#15171A", - dark: "#272A2E", + background: "#15171A", + foreground: "#272A2E", }, }; - const currentColor = colors[color]; + const currentColor = typeof color === "string" ? colors[color] : color; return ( - + ); } + +export function ButtonSpinner() { + return ( + + ); +} diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index ec705439b..70fd2a392 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -4,9 +4,10 @@ import { CalendarIcon, CpuChipIcon, InboxStackIcon, + TagIcon, XMarkIcon, } from "@heroicons/react/20/solid"; -import { Form } from "@remix-run/react"; +import { Form, useFetcher } from "@remix-run/react"; import type { RuntimeEnvironment, TaskTriggerSource, @@ -15,7 +16,7 @@ import type { } from "@trigger.dev/database"; import { ListFilterIcon } from "lucide-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 { TaskIcon } from "~/assets/icons/TaskIcon"; import { EnvironmentLabel, environmentTitle } from "~/components/environments/EnvironmentLabel"; @@ -51,6 +52,10 @@ import { 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); @@ -73,6 +78,10 @@ export const TaskRunListSearchFilters = z.object({ (value) => (typeof value === "string" ? [value] : value), 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()), bulkId: z.string().optional(), from: z.coerce.number().optional(), @@ -104,7 +113,8 @@ export function RunsFilters(props: RunFiltersProps) { searchParams.has("environments") || searchParams.has("tasks") || searchParams.has("period") || - searchParams.has("bulkId"); + searchParams.has("bulkId") || + searchParams.has("tags"); return (
@@ -133,6 +143,7 @@ const filterTypes = [ }, { name: "environments", title: "Environment", icon: }, { name: "tasks", title: "Tasks", icon: }, + { name: "tags", title: "Tags", icon: }, { name: "created", title: "Created", icon: }, { name: "bulk", title: "Bulk action", icon: }, ] as const; @@ -209,6 +220,7 @@ function AppliedFilters({ possibleEnvironments, possibleTasks, bulkActions }: Ru + @@ -237,6 +249,8 @@ function Menu(props: MenuProps) { return props.setFilterType(undefined)} {...props} />; case "bulk": return props.setFilterType(undefined)} {...props} />; + case "tags": + return props.setFilterType(undefined)} {...props} />; } } @@ -648,6 +662,124 @@ function AppliedBulkActionsFilter({ bulkActions }: Pick 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(); + + 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 ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + + return true; + }} + > + ( +
+ + {fetcher.state === "loading" && } +
+ )} + /> + + {filtered.length > 0 + ? filtered.map((tag, index) => ( + + {tag} + + )) + : null} + {filtered.length === 0 && fetcher.state !== "loading" && ( + No tags found + )} + +
+
+ ); +} + +function AppliedTagsFilter() { + const { values, del } = useSearchParams(); + + const tags = values("tags"); + + if (tags.length === 0) { + return null; + } + + return ( + + {(search, setSearch) => ( + }> + del(["tags", "cursor", "direction"])} + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} + const timePeriods = [ { label: "All periods", diff --git a/apps/webapp/app/components/runs/v3/RunIcon.tsx b/apps/webapp/app/components/runs/v3/RunIcon.tsx index 5fcc0d541..41e442bf7 100644 --- a/apps/webapp/app/components/runs/v3/RunIcon.tsx +++ b/apps/webapp/app/components/runs/v3/RunIcon.tsx @@ -3,6 +3,7 @@ import { HandRaisedIcon, InformationCircleIcon, Squares2X2Icon, + TagIcon, } from "@heroicons/react/20/solid"; import { AttemptIcon } from "~/assets/icons/AttemptIcon"; import { TaskIcon } from "~/assets/icons/TaskIcon"; @@ -48,6 +49,8 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) { return ; case "trace": return ; + case "tag": + return ; //log levels case "debug": case "log": diff --git a/apps/webapp/app/components/runs/v3/RunTag.tsx b/apps/webapp/app/components/runs/v3/RunTag.tsx new file mode 100644 index 000000000..875457dc2 --- /dev/null +++ b/apps/webapp/app/components/runs/v3/RunTag.tsx @@ -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 ( + + + + {tag} + + + ); + } else { + return ( + + + + {tagResult.key} + + + {tagResult.value} + + + ); + } +} + +/** 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; +} diff --git a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx index cc8a8bbbf..8573845fd 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx @@ -5,16 +5,22 @@ import { RectangleStackIcon, StopCircleIcon, } from "@heroicons/react/20/solid"; -import { StopIcon } from "@heroicons/react/24/outline"; import { BeakerIcon, BookOpenIcon, CheckIcon } from "@heroicons/react/24/solid"; import { useLocation } from "@remix-run/react"; import { formatDuration, formatDurationMilliseconds } from "@trigger.dev/core/v3"; +import { useCallback, useRef } from "react"; import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Checkbox } from "~/components/primitives/Checkbox"; 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 { useFeatures } from "~/hooks/useFeatures"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; +import { useUser } from "~/hooks/useUser"; import { RunListAppliedFilters, RunListItem } from "~/presenters/v3/RunListPresenter.server"; +import { formatCurrencyAccurate, formatNumber } from "~/utils/numberFormatter"; import { docsPath, v3RunSpanPath, v3TestPath } from "~/utils/pathBuilder"; import { EnvironmentLabel } from "../../environments/EnvironmentLabel"; import { DateTime } from "../../primitives/DateTime"; @@ -31,18 +37,10 @@ import { TableRow, } from "../../primitives/Table"; import { CancelRunDialog } from "./CancelRunDialog"; +import { LiveTimer } from "./LiveTimer"; import { ReplayRunDialog } from "./ReplayRunDialog"; import { TaskRunStatusCombo } from "./TaskRunStatus"; -import { LiveTimer } from "./LiveTimer"; -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"; +import { RunTag } from "./RunTag"; type RunsTableProps = { total: number; @@ -173,8 +171,73 @@ export function TaskRunsTable({ )} Test Created at - Delayed until - TTL + + + When you want to trigger a task now, but have it run at a later time, you can use + the delay option. + + + Runs that are delayed and have not been enqueued yet will display in the dashboard + with a “Delayed” status. + + + Read docs + +
+ } + > + Delayed until + + + + You can set a TTL (time to live) when triggering a task, which will automatically + expire the run if it hasn’t started within the specified time. + + + All runs in development have a default ttl of 10 minutes. You can disable this by + setting the ttl option. + + + Read docs + + + } + > + TTL + + + + You can add tags to a run and then filter runs using them. + + + You can add tags when triggering a run or inside the run function. + + + Read docs + + + } + > + Tags + Go to page @@ -278,6 +341,11 @@ export function TaskRunsTable({ {run.delayUntil ? : "–"} {run.ttl ?? "–"} + +
+ {run.tags.map((tag) => ) || "–"} +
+
); diff --git a/apps/webapp/app/components/runs/v3/tag-left.svg b/apps/webapp/app/components/runs/v3/tag-left.svg new file mode 100644 index 000000000..dfde0d245 --- /dev/null +++ b/apps/webapp/app/components/runs/v3/tag-left.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/webapp/app/hooks/useSearchParam.ts b/apps/webapp/app/hooks/useSearchParam.ts index 982b51ce6..0ed81fb3e 100644 --- a/apps/webapp/app/hooks/useSearchParam.ts +++ b/apps/webapp/app/hooks/useSearchParam.ts @@ -18,13 +18,13 @@ export function useSearchParams() { } if (typeof value === "string") { - search.set(param, value); + search.set(param, encodeURIComponent(value)); continue; } search.delete(param); for (const v of value) { - search.append(param, v); + search.append(param, encodeURIComponent(v)); } } }, @@ -54,14 +54,20 @@ export function useSearchParams() { const value = useCallback( (param: string) => { - return search.get(param) ?? undefined; + const val = search.get(param) ?? undefined; + if (val === undefined) { + return val; + } + + return decodeURIComponent(val); }, [location, search] ); const values = useCallback( (param: string) => { - return search.getAll(param); + const all = search.getAll(param); + return all.map((v) => decodeURIComponent(v)); }, [location, search] ); diff --git a/apps/webapp/app/models/taskRunTag.server.ts b/apps/webapp/app/models/taskRunTag.server.ts new file mode 100644 index 000000000..eb1b08481 --- /dev/null +++ b/apps/webapp/app/models/taskRunTag.server.ts @@ -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; +} diff --git a/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts index 2e09f7977..e97ceb949 100644 --- a/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts @@ -35,6 +35,7 @@ export class ApiRetrieveRunPresenter extends BasePresenter { }, lockedToVersion: true, schedule: true, + tags: true, }, }); @@ -119,6 +120,10 @@ export class ApiRetrieveRunPresenter extends BasePresenter { isTest: taskRun.isTest, ttl: taskRun.ttl ?? 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 ? { id: taskRun.schedule.friendlyId, @@ -127,7 +132,7 @@ export class ApiRetrieveRunPresenter extends BasePresenter { ? taskRun.schedule.deduplicationKey : undefined, generator: { - type: "CRON", + type: "CRON" as const, expression: taskRun.schedule.generatorExpression, description: taskRun.schedule.generatorDescription, }, diff --git a/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts index 2036409e3..21b4f956f 100644 --- a/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts @@ -64,6 +64,12 @@ const SearchParamsSchema = z.object({ .transform((value) => { return value ? value.split(",") : undefined; }), + "filter[tag]": z + .string() + .optional() + .transform((value) => { + return value ? value.split(",") : undefined; + }), "filter[bulkAction]": z.string().optional(), "filter[schedule]": z.string().optional(), "filter[isTest]": z @@ -168,6 +174,10 @@ export class ApiRunListPresenter extends BasePresenter { options.versions = $searchParams.data["filter[version]"]; } + if ($searchParams.data["filter[tag]"]) { + options.tags = $searchParams.data["filter[tag]"]; + } + if ($searchParams.data["filter[bulkAction]"]) { options.bulkId = $searchParams.data["filter[bulkAction]"]; } @@ -218,6 +228,10 @@ export class ApiRunListPresenter extends BasePresenter { name: run.environment.slug, user: run.environment.userName, }, + tags: run.tags, + costInCents: run.costInCents, + baseCostInCents: run.baseCostInCents, + durationMs: run.usageDurationMs, ...ApiRetrieveRunPresenter.apiBooleanHelpersFromRunStatus( ApiRetrieveRunPresenter.apiStatusFromRunStatus(run.status) ), diff --git a/apps/webapp/app/presenters/v3/RunListPresenter.server.ts b/apps/webapp/app/presenters/v3/RunListPresenter.server.ts index 0612225f9..a11f73e91 100644 --- a/apps/webapp/app/presenters/v3/RunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunListPresenter.server.ts @@ -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 { Direction } from "~/components/runs/RunStatuses"; +import { type Direction } from "~/components/runs/RunStatuses"; import { FINISHED_STATUSES } from "~/components/runs/v3/TaskRunStatus"; import { sqlDatabaseSchema } from "~/db.server"; import { displayableEnvironment } from "~/models/runtimeEnvironment.server"; -import { BasePresenter } from "./basePresenter.server"; import { isCancellableRunStatus } from "~/v3/taskStatus"; +import { BasePresenter } from "./basePresenter.server"; export type RunListOptions = { userId?: string; @@ -15,6 +15,7 @@ export type RunListOptions = { versions?: string[]; statuses?: TaskRunStatus[]; environments?: string[]; + tags?: string[]; scheduleId?: string; period?: string; bulkId?: string; @@ -41,6 +42,7 @@ export class RunListPresenter extends BasePresenter { versions, statuses, environments, + tags, scheduleId, period, bulkId, @@ -63,6 +65,7 @@ export class RunListPresenter extends BasePresenter { from !== undefined || to !== undefined || (scheduleId !== undefined && scheduleId !== "") || + (tags !== undefined && tags.length > 0) || typeof isTest === "boolean"; // Find the project scoped to the organization @@ -95,10 +98,10 @@ export class RunListPresenter extends BasePresenter { //get all possible tasks const possibleTasksAsync = this._replica.$queryRaw< - { - slug: string; - triggerSource: TaskTriggerSource - }[] + { + slug: string; + triggerSource: TaskTriggerSource; + }[] >` SELECT DISTINCT(slug), "triggerSource" FROM ${sqlDatabaseSchema}."BackgroundWorkerTask" @@ -150,7 +153,7 @@ export class RunListPresenter extends BasePresenter { const periodMs = period ? parse(period) : undefined; //get the runs - let runs = await this._replica.$queryRaw< + const runs = await this._replica.$queryRaw< { id: string; number: BigInt; @@ -170,7 +173,9 @@ export class RunListPresenter extends BasePresenter { ttl: string | null; expiredAt: Date | null; costInCents: number; + baseCostInCents: number; usageDurationMs: BigInt; + tags: string[]; }[] >` SELECT @@ -191,66 +196,88 @@ export class RunListPresenter extends BasePresenter { tr."idempotencyKey" AS "idempotencyKey", tr."ttl" AS "ttl", tr."expiredAt" AS "expiredAt", + tr."baseCostInCents" AS "baseCostInCents", tr."costInCents" AS "costInCents", - tr."usageDurationMs" AS "usageDurationMs" - FROM + tr."usageDurationMs" AS "usageDurationMs", + array_remove(array_agg(tag.name), NULL) AS "tags" +FROM ${sqlDatabaseSchema}."TaskRun" tr - LEFT JOIN +LEFT JOIN ${sqlDatabaseSchema}."BackgroundWorker" bw ON tr."lockedToVersionId" = bw.id - WHERE - -- project - tr."projectId" = ${project.id} - -- cursor - ${ - cursor - ? direction === "forward" - ? Prisma.sql`AND tr.id < ${cursor}` - : Prisma.sql`AND tr.id > ${cursor}` - : Prisma.empty - } - -- filters - ${ - restrictToRunIds - ? restrictToRunIds.length === 0 - ? Prisma.sql`AND tr.id = ''` - : Prisma.sql`AND tr.id IN (${Prisma.join(restrictToRunIds)})` - : Prisma.empty - } - ${ - tasks && tasks.length > 0 - ? Prisma.sql`AND tr."taskIdentifier" IN (${Prisma.join(tasks)})` - : Prisma.empty - } - ${ - statuses && statuses.length > 0 - ? Prisma.sql`AND tr.status = ANY(ARRAY[${Prisma.join(statuses)}]::"TaskRunStatus"[])` - : Prisma.empty - } - ${ - environments && environments.length > 0 - ? Prisma.sql`AND tr."runtimeEnvironmentId" IN (${Prisma.join(environments)})` - : Prisma.empty - } - ${scheduleId ? Prisma.sql`AND tr."scheduleId" = ${scheduleId}` : Prisma.empty} - ${typeof isTest === "boolean" ? Prisma.sql`AND tr."isTest" = ${isTest}` : Prisma.empty} - ${ - periodMs - ? Prisma.sql`AND tr."createdAt" >= NOW() - INTERVAL '1 millisecond' * ${periodMs}` - : Prisma.empty - } - ${ - 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 - } - ORDER BY - ${direction === "forward" ? Prisma.sql`tr.id DESC` : Prisma.sql`tr.id ASC`} - LIMIT ${pageSize + 1}`; +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} + -- cursor + ${ + cursor + ? direction === "forward" + ? Prisma.sql`AND tr.id < ${cursor}` + : Prisma.sql`AND tr.id > ${cursor}` + : Prisma.empty + } + -- filters + ${ + restrictToRunIds + ? restrictToRunIds.length === 0 + ? Prisma.sql`AND tr.id = ''` + : Prisma.sql`AND tr.id IN (${Prisma.join(restrictToRunIds)})` + : Prisma.empty + } + ${ + tasks && tasks.length > 0 + ? Prisma.sql`AND tr."taskIdentifier" IN (${Prisma.join(tasks)})` + : Prisma.empty + } + ${ + statuses && statuses.length > 0 + ? Prisma.sql`AND tr.status = ANY(ARRAY[${Prisma.join(statuses)}]::"TaskRunStatus"[])` + : Prisma.empty + } + ${ + environments && environments.length > 0 + ? Prisma.sql`AND tr."runtimeEnvironmentId" IN (${Prisma.join(environments)})` + : Prisma.empty + } + ${scheduleId ? Prisma.sql`AND tr."scheduleId" = ${scheduleId}` : Prisma.empty} + ${typeof isTest === "boolean" ? Prisma.sql`AND tr."isTest" = ${isTest}` : Prisma.empty} + ${ + periodMs + ? Prisma.sql`AND tr."createdAt" >= NOW() - INTERVAL '1 millisecond' * ${periodMs}` + : Prisma.empty + } + ${ + 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 + } + 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; @@ -312,7 +339,9 @@ export class RunListPresenter extends BasePresenter { ttl: run.ttl ? run.ttl : undefined, expiredAt: run.expiredAt ? run.expiredAt.toISOString() : undefined, costInCents: run.costInCents, + baseCostInCents: run.baseCostInCents, usageDurationMs: Number(run.usageDurationMs), + tags: run.tags.sort((a, b) => a.localeCompare(b)), }; }), pagination: { diff --git a/apps/webapp/app/presenters/v3/RunTagListPresenter.server.ts b/apps/webapp/app/presenters/v3/RunTagListPresenter.server.ts new file mode 100644 index 000000000..35235b96a --- /dev/null +++ b/apps/webapp/app/presenters/v3/RunTagListPresenter.server.ts @@ -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>; +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, + }; + } +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs._index/route.tsx index 79dd8a6e2..305ab3863 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs._index/route.tsx @@ -56,9 +56,21 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { tasks: url.searchParams.getAll("tasks"), period: url.searchParams.get("period") ?? 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 } = - TaskRunListSearchFilters.parse(s); + const { + tasks, + versions, + statuses, + environments, + tags, + period, + bulkId, + from, + to, + cursor, + direction, + } = TaskRunListSearchFilters.parse(s); const project = await findProjectBySlug(organizationSlug, projectParam, userId); @@ -74,6 +86,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { versions, statuses, environments, + tags, period, bulkId, from, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx index 043ea9887..a4725a1c2 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx @@ -2,7 +2,7 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; import type { ActionFunction, LoaderFunctionArgs } 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 invariant from "tiny-invariant"; import { z } from "zod"; @@ -17,6 +17,7 @@ import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; import { Select, SelectItem } from "~/components/primitives/Select"; +import { ButtonSpinner } from "~/components/primitives/Spinner"; import { prisma } from "~/db.server"; import { featuresForRequest } from "~/features.server"; 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 (
@@ -190,8 +194,13 @@ export default function Page() { )} - Create + } cancelButton={ diff --git a/apps/webapp/app/routes/api.v1.runs.$runId.tags.ts b/apps/webapp/app/routes/api.v1.runs.$runId.tags.ts new file mode 100644 index 000000000..1ae137975 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.runs.$runId.tags.ts @@ -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 } + ); + } +} diff --git a/apps/webapp/app/routes/resources.projects.$projectParam.runs.tags.tsx b/apps/webapp/app/routes/resources.projects.$projectParam.runs.tags.tsx new file mode 100644 index 000000000..2f3df1d14 --- /dev/null +++ b/apps/webapp/app/routes/resources.projects.$projectParam.runs.tags.tsx @@ -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; +} diff --git a/apps/webapp/app/v3/services/replayTaskRun.server.ts b/apps/webapp/app/v3/services/replayTaskRun.server.ts index 9a5baca24..50d04c26b 100644 --- a/apps/webapp/app/v3/services/replayTaskRun.server.ts +++ b/apps/webapp/app/v3/services/replayTaskRun.server.ts @@ -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 { findEnvironmentById } from "~/models/runtimeEnvironment.server"; import { logger } from "~/services/logger.server"; import { BaseService } from "./baseService.server"; import { OutOfEntitlementError, TriggerTaskService } from "./triggerTask.server"; +import { getTagsForRunId } from "~/models/taskRunTag.server"; export class ReplayTaskRunService extends BaseService { public async call(existingTaskRun: TaskRun) { @@ -36,6 +37,11 @@ export class ReplayTaskRunService extends BaseService { }); try { + const tags = await getTagsForRunId({ + friendlyId: existingTaskRun.id, + environmentId: authenticatedEnvironment.id, + }); + const triggerTaskService = new TriggerTaskService(); return await triggerTaskService.call( existingTaskRun.taskIdentifier, @@ -49,6 +55,7 @@ export class ReplayTaskRunService extends BaseService { concurrencyKey: existingTaskRun.concurrencyKey ?? undefined, test: existingTaskRun.isTest, payloadType: payloadPacket.dataType, + tags: tags?.map((t) => t.name) as RunTags, }, }, { diff --git a/apps/webapp/app/v3/services/triggerTask.server.ts b/apps/webapp/app/v3/services/triggerTask.server.ts index 88c42fc59..1d4248b9f 100644 --- a/apps/webapp/app/v3/services/triggerTask.server.ts +++ b/apps/webapp/app/v3/services/triggerTask.server.ts @@ -17,6 +17,7 @@ import { getEntitlement } from "~/services/platform.v3.server"; import { BaseService, ServiceValidationError } from "./baseService.server"; import { logger } from "~/services/logger.server"; import { isFinalAttemptStatus, isFinalRunStatus } from "../taskStatus"; +import { createTag } from "~/models/taskRunTag.server"; export type TriggerTaskServiceOptions = { idempotencyKey?: string; @@ -210,6 +211,22 @@ export class TriggerTaskService extends BaseService { 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); + } + } + } + const taskRun = await tx.taskRun.create({ data: { status: delayUntil ? "DELAYED" : "PENDING", @@ -233,6 +250,12 @@ export class TriggerTaskService extends BaseService { queuedAt: delayUntil ? undefined : new Date(), maxAttempts: body.options?.maxAttempts, ttl, + tags: + tagIds.length === 0 + ? undefined + : { + connect: tagIds.map((id) => ({ id })), + }, }, }); diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index fa5553bd2..bb9fc327d 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -1,6 +1,8 @@ import { context, propagation } from "@opentelemetry/api"; +import { z } from "zod"; import { version } from "../../../package.json"; import { + AddTagsRequestBody, BatchTaskRunExecutionResult, BatchTriggerTaskRequestBody, BatchTriggerTaskResponse, @@ -63,8 +65,8 @@ const DEFAULT_ZOD_FETCH_OPTIONS: ZodFetchOptions = { }, }; -export type { ApiRequestOptions }; export { isRequestOptions }; +export type { ApiRequestOptions }; /** * 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) { return zodfetch( ScheduleObject, @@ -534,6 +549,13 @@ function createSearchQueryForListRuns(query?: ListRunsQueryParams): URLSearchPar 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) { searchParams.append("filter[schedule]", query.schedule); } diff --git a/packages/core/src/v3/apiClient/types.ts b/packages/core/src/v3/apiClient/types.ts index c13980861..67b07d019 100644 --- a/packages/core/src/v3/apiClient/types.ts +++ b/packages/core/src/v3/apiClient/types.ts @@ -28,6 +28,7 @@ export interface ListRunsQueryParams extends CursorPageParams { to?: Date | number; period?: string; bulkAction?: string; + tag?: Array | string; schedule?: string; isTest?: boolean; } diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index c62c22b25..9b0161614 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -55,6 +55,17 @@ export const CreateBackgroundWorkerResponse = z.object({ export type CreateBackgroundWorkerResponse = z.infer; +//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; + export const TriggerTaskRequestBody = z.object({ payload: z.any(), context: z.any(), @@ -70,6 +81,7 @@ export const TriggerTaskRequestBody = z.object({ payloadType: z.string().optional(), delay: z.string().or(z.coerce.date()).optional(), ttl: z.string().or(z.number().nonnegative().int()).optional(), + tags: RunTags.optional(), maxAttempts: z.number().int().optional(), }) .optional(), @@ -110,6 +122,12 @@ export const GetBatchResponseBody = z.object({ export type GetBatchResponseBody = z.infer; +export const AddTagsRequestBody = z.object({ + tags: RunTags, +}); + +export type AddTagsRequestBody = z.infer; + export const RescheduleRunRequestBody = z.object({ delay: z.string().or(z.coerce.date()), }); @@ -452,6 +470,10 @@ const CommonRunFields = { delayedUntil: z.coerce.date().optional(), ttl: z.string().optional(), expiredAt: z.coerce.date().optional(), + tags: z.string().array(), + costInCents: z.number(), + baseCostInCents: z.number(), + durationMs: z.number(), }; export const RetrieveRunResponse = z.object({ diff --git a/packages/database/prisma/migrations/20240720101649_added_task_run_tag_removed_task_tag/migration.sql b/packages/database/prisma/migrations/20240720101649_added_task_run_tag_removed_task_tag/migration.sql new file mode 100644 index 000000000..9d10ad68a --- /dev/null +++ b/packages/database/prisma/migrations/20240720101649_added_task_run_tag_removed_task_tag/migration.sql @@ -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; diff --git a/packages/database/prisma/migrations/20240723104125_task_run_tag_name_id_index/migration.sql b/packages/database/prisma/migrations/20240723104125_task_run_tag_name_id_index/migration.sql new file mode 100644 index 000000000..d0033e404 --- /dev/null +++ b/packages/database/prisma/migrations/20240723104125_task_run_tag_name_id_index/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "TaskRunTag_name_id_idx" ON "TaskRunTag"("name", "id"); diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 12946b227..c37a2837d 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -453,7 +453,7 @@ model Project { backgroundWorkers BackgroundWorker[] backgroundWorkerTasks BackgroundWorkerTask[] taskRuns TaskRun[] - taskTags TaskTag[] + runTags TaskRunTag[] taskQueues TaskQueue[] environmentVariables EnvironmentVariable[] checkpoints Checkpoint[] @@ -1641,8 +1641,9 @@ model TaskRun { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - attempts TaskRunAttempt[] @relation("attempts") - tags TaskTag[] + attempts TaskRunAttempt[] @relation("attempts") + tags TaskRunTag[] + checkpoints Checkpoint[] startedAt DateTime? @@ -1735,6 +1736,24 @@ enum TaskRunStatus { 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 { id String @id @default(cuid()) @@ -1775,22 +1794,6 @@ model TaskRunNumberCounter { @@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 { id String @id @default(cuid()) number Int @default(0) diff --git a/packages/trigger-sdk/src/v3/index.ts b/packages/trigger-sdk/src/v3/index.ts index c1224949a..84b1365de 100644 --- a/packages/trigger-sdk/src/v3/index.ts +++ b/packages/trigger-sdk/src/v3/index.ts @@ -6,6 +6,7 @@ export * from "./tasks"; export * from "./wait"; export * from "./usage"; export * from "./idempotencyKeys"; +export * from "./tags"; export type { Context }; import type { Context } from "./shared"; diff --git a/packages/trigger-sdk/src/v3/shared.ts b/packages/trigger-sdk/src/v3/shared.ts index 16f58d67f..57658ee60 100644 --- a/packages/trigger-sdk/src/v3/shared.ts +++ b/packages/trigger-sdk/src/v3/shared.ts @@ -18,6 +18,7 @@ import { QueueOptions, RetryOptions, RunFnParams, + RunTags, SemanticInternalAttributes, StartFnParams, 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. */ 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; @@ -485,6 +501,7 @@ export function createTask< idempotencyKey: await makeKey(options?.idempotencyKey), delay: options?.delay, ttl: options?.ttl, + tags: options?.tags, maxAttempts: options?.maxAttempts, }, }, @@ -547,6 +564,7 @@ export function createTask< idempotencyKey: await makeKey(item.options?.idempotencyKey), delay: item.options?.delay, ttl: item.options?.ttl, + tags: item.options?.tags, maxAttempts: item.options?.maxAttempts, }, }; @@ -616,6 +634,7 @@ export function createTask< idempotencyKey: await makeKey(options?.idempotencyKey), delay: options?.delay, ttl: options?.ttl, + tags: options?.tags, maxAttempts: options?.maxAttempts, }, }); @@ -701,6 +720,7 @@ export function createTask< idempotencyKey: await makeKey(item.options?.idempotencyKey), delay: item.options?.delay, ttl: item.options?.ttl, + tags: item.options?.tags, maxAttempts: item.options?.maxAttempts, }, }; @@ -866,6 +886,7 @@ export async function trigger( idempotencyKey: await makeKey(options?.idempotencyKey), delay: options?.delay, ttl: options?.ttl, + tags: options?.tags, maxAttempts: options?.maxAttempts, }, }, @@ -942,6 +963,7 @@ export async function triggerAndWait( idempotencyKey: await makeKey(options?.idempotencyKey), delay: options?.delay, ttl: options?.ttl, + tags: options?.tags, maxAttempts: options?.maxAttempts, }, }, @@ -1051,6 +1073,7 @@ export async function batchTrigger( idempotencyKey: await makeKey(item.options?.idempotencyKey), delay: item.options?.delay, ttl: item.options?.ttl, + tags: item.options?.tags, maxAttempts: item.options?.maxAttempts, }, }; diff --git a/packages/trigger-sdk/src/v3/tags.ts b/packages/trigger-sdk/src/v3/tags.ts new file mode 100644 index 000000000..991eaab38 --- /dev/null +++ b/packages/trigger-sdk/src/v3/tags.ts @@ -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; + } +} diff --git a/references/v3-catalog/src/trigger/subtasks.ts b/references/v3-catalog/src/trigger/subtasks.ts index 7c6cf6f08..8472aa3ff 100644 --- a/references/v3-catalog/src/trigger/subtasks.ts +++ b/references/v3-catalog/src/trigger/subtasks.ts @@ -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"; export const simpleParentTask = task({ @@ -27,6 +27,9 @@ export const simpleChildTask = task({ run: async (payload: { message: string }, { 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 }); }, }); diff --git a/references/v3-catalog/src/trigger/tags.ts b/references/v3-catalog/src/trigger/tags.ts new file mode 100644 index 000000000..0cd1e764c --- /dev/null +++ b/references/v3-catalog/src/trigger/tags.ts @@ -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( + "simple-child-task", + { message: "tasks.trigger from triggerRunsWithTags" }, + { tags: payload.tags } + ); + await tasks.triggerAndWait( + "simple-child-task", + { message: "tasks.triggerAndWait from triggerRunsWithTags" }, + { tags: payload.tags } + ); + await tasks.batchTrigger("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, + }); + }, +});