diff --git a/.gitignore b/.gitignore index 2e3a5ed3d5..260bfed29c 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,6 @@ apps/**/public/build /packages/cli-v3/src/package.json .husky /packages/react-hooks/src/package.json +/packages/core/src/package.json +/packages/trigger-sdk/src/package.json +/packages/python/src/package.json diff --git a/apps/webapp/app/assets/icons/StatusIcon.tsx b/apps/webapp/app/assets/icons/StatusIcon.tsx new file mode 100644 index 0000000000..9499b50d57 --- /dev/null +++ b/apps/webapp/app/assets/icons/StatusIcon.tsx @@ -0,0 +1,9 @@ +import { cn } from "~/utils/cn"; + +export function StatusIcon({ className }: { className?: string }) { + return ( +
+
+
+ ); +} diff --git a/apps/webapp/app/assets/icons/TriggerIcon.tsx b/apps/webapp/app/assets/icons/TriggerIcon.tsx new file mode 100644 index 0000000000..da73b84291 --- /dev/null +++ b/apps/webapp/app/assets/icons/TriggerIcon.tsx @@ -0,0 +1,5 @@ +import { BoltIcon } from "@heroicons/react/20/solid"; + +export function TriggerIcon({ className }: { className?: string }) { + return ; +} diff --git a/apps/webapp/app/assets/icons/WaitpointTokenIcon.tsx b/apps/webapp/app/assets/icons/WaitpointTokenIcon.tsx new file mode 100644 index 0000000000..34ba9438c8 --- /dev/null +++ b/apps/webapp/app/assets/icons/WaitpointTokenIcon.tsx @@ -0,0 +1,12 @@ +export function WaitpointTokenIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx index c730c797c1..974ba05550 100644 --- a/apps/webapp/app/components/BlankStatePanels.tsx +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -35,6 +35,7 @@ import { StepNumber } from "./primitives/StepNumber"; import { TextLink } from "./primitives/TextLink"; import { InitCommandV3, PackageManagerProvider, TriggerDevStepV3 } from "./SetupCommands"; import { StepContentContainer } from "./StepContentContainer"; +import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; export function HasNoTasksDev() { return ( @@ -412,6 +413,30 @@ export function QueuesHasNoTasks() { ); } +export function NoWaitpointTokens() { + return ( + + Waitpoint docs + + } + > + + Waitpoint tokens are used to pause runs until you complete the token so the run can + continue. + + + You can build approval workflows using them, as well as other use cases. + + + ); +} + function SwitcherPanel() { const organization = useOrganization(); const project = useProject(); diff --git a/apps/webapp/app/components/code/CodeBlock.tsx b/apps/webapp/app/components/code/CodeBlock.tsx index ba5101a143..eb133105b9 100644 --- a/apps/webapp/app/components/code/CodeBlock.tsx +++ b/apps/webapp/app/components/code/CodeBlock.tsx @@ -2,7 +2,7 @@ import { ArrowsPointingOutIcon } from "@heroicons/react/20/solid"; import { Clipboard, ClipboardCheck } from "lucide-react"; import type { Language, PrismTheme } from "prism-react-renderer"; import { Highlight, Prism } from "prism-react-renderer"; -import { forwardRef, ReactNode, useCallback, useState } from "react"; +import { forwardRef, ReactNode, useCallback, useEffect, useState } from "react"; import { cn } from "~/utils/cn"; import { Button } from "../primitives/Buttons"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../primitives/Dialog"; @@ -422,6 +422,32 @@ function HighlightCode({ className, preClassName, }: HighlightCodeProps) { + const [isLoaded, setIsLoaded] = useState(false); + + useEffect(() => { + // This ensures the language definitions are loaded + Promise.all([ + //@ts-ignore + import("prismjs/components/prism-json"), + //@ts-ignore + import("prismjs/components/prism-typescript"), + ]).then(() => setIsLoaded(true)); + }, []); + + if (!isLoaded) { + return ( +
+
{code}
+
+ ); + } + return ( {({ diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 20c95b7e4e..7c5a45274f 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -54,6 +54,7 @@ import { v3SchedulesPath, v3TestPath, v3UsagePath, + v3WaitpointTokensPath, } from "~/utils/pathBuilder"; import connectedImage from "../../assets/images/cli-connected.png"; import disconnectedImage from "../../assets/images/cli-disconnected.png"; @@ -80,6 +81,7 @@ import { HelpAndFeedback } from "./HelpAndFeedbackPopover"; import { SideMenuHeader } from "./SideMenuHeader"; import { SideMenuItem } from "./SideMenuItem"; import { SideMenuSection } from "./SideMenuSection"; +import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; type SideMenuUser = Pick & { isImpersonating: boolean }; export type SideMenuProject = Pick< @@ -211,6 +213,15 @@ export function SideMenu({ />
+ + + + - + {project.name ?? "Select a project"} @@ -308,7 +319,7 @@ function ProjectSelector({
- +
{organization.title} @@ -472,7 +483,7 @@ function SwitchOrganizations({ key={org.id} to={organizationPath(org)} title={org.title} - icon={} + icon={} leadingIconClassName="text-text-dimmed" isSelected={org.id === organization.id} /> diff --git a/apps/webapp/app/components/primitives/AnimatedNumber.tsx b/apps/webapp/app/components/primitives/AnimatedNumber.tsx index 743a899242..a585670502 100644 --- a/apps/webapp/app/components/primitives/AnimatedNumber.tsx +++ b/apps/webapp/app/components/primitives/AnimatedNumber.tsx @@ -1,13 +1,16 @@ -import { motion, useSpring, useTransform } from "framer-motion"; +import { motion, useSpring, useTransform, useMotionValue, animate } from "framer-motion"; import { useEffect } from "react"; export function AnimatedNumber({ value }: { value: number }) { - let spring = useSpring(value, { mass: 0.8, stiffness: 75, damping: 15 }); - let display = useTransform(spring, (current) => Math.round(current).toLocaleString()); + const motionValue = useMotionValue(value); + let display = useTransform(motionValue, (current) => Math.round(current).toLocaleString()); useEffect(() => { - spring.set(value); - }, [spring, value]); + animate(motionValue, value, { + duration: 0.5, + ease: "easeInOut", + }); + }, [value]); return {display}; } diff --git a/apps/webapp/app/components/primitives/Avatar.tsx b/apps/webapp/app/components/primitives/Avatar.tsx index 33998885fa..051b7f12a4 100644 --- a/apps/webapp/app/components/primitives/Avatar.tsx +++ b/apps/webapp/app/components/primitives/Avatar.tsx @@ -7,9 +7,7 @@ import { StarIcon, } from "@heroicons/react/20/solid"; import { type Prisma } from "@trigger.dev/database"; -import { useLayoutEffect, useRef, useState } from "react"; import { z } from "zod"; -import { useOrganization } from "~/hooks/useOrganizations"; import { logger } from "~/services/logger.server"; import { cn } from "~/utils/cn"; @@ -53,22 +51,30 @@ export function parseAvatar(json: Prisma.JsonValue, defaultAvatar: Avatar): Avat export function Avatar({ avatar, - className, + size, includePadding, + orgName, }: { avatar: Avatar; - className?: string; + /** Size in rems of the icon */ + size: number; includePadding?: boolean; + orgName: string; }) { switch (avatar.type) { case "icon": - return ; + return ; case "letters": return ( - + ); case "image": - return ; + return ; } } @@ -101,65 +107,49 @@ export const defaultAvatar: Avatar = { hex: defaultAvatarHex, }; +function styleFromSize(size: number) { + return { + width: `${size}rem`, + height: `${size}rem`, + }; +} + function AvatarLetters({ avatar, - className, + size, includePadding, + orgName, }: { avatar: LettersAvatar; - className?: string; + size: number; includePadding?: boolean; + orgName: string; }) { - const organization = useOrganization(); - const containerRef = useRef(null); - const textRef = useRef(null); - const [fontSize, setFontSize] = useState("1rem"); - - useLayoutEffect(() => { - if (containerRef.current) { - const containerWidth = containerRef.current.offsetWidth; - // Set font size to 60% of container width (adjust as needed) - setFontSize(`${containerWidth * 0.6}px`); - } - - // Optional: Create a ResizeObserver for dynamic resizing - const resizeObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - if (entry.target === containerRef.current) { - const containerWidth = entry.contentRect.width; - setFontSize(`${containerWidth * 0.6}px`); - } - } - }); - - if (containerRef.current) { - resizeObserver.observe(containerRef.current); - } - - return () => { - resizeObserver.disconnect(); - }; - }, []); - - const letters = organization.title.slice(0, 2); - - const classes = cn("grid place-items-center", className); + const letters = orgName.slice(0, 2); + const style = { backgroundColor: avatar.hex, }; + const scaleFactor = includePadding ? 0.8 : 1; + return ( - + {/* This is the square container */} - + {letters} @@ -169,29 +159,28 @@ function AvatarLetters({ function AvatarIcon({ avatar, - className, + size, includePadding, }: { avatar: IconAvatar; - className?: string; + size: number; includePadding?: boolean; }) { - const classes = cn("aspect-square", className); const style = { color: avatar.hex, }; const IconComponent = avatarIcons[avatar.name]; return ( - + ); } -function AvatarImage({ avatar, className }: { avatar: ImageAvatar; className?: string }) { +function AvatarImage({ avatar, size }: { avatar: ImageAvatar; size: number }) { return ( - + Organization avatar ); diff --git a/apps/webapp/app/components/primitives/CopyableText.tsx b/apps/webapp/app/components/primitives/CopyableText.tsx new file mode 100644 index 0000000000..71eb4aa1b1 --- /dev/null +++ b/apps/webapp/app/components/primitives/CopyableText.tsx @@ -0,0 +1,61 @@ +import { useCallback, useState } from "react"; +import { SimpleTooltip } from "~/components/primitives/Tooltip"; +import { ClipboardCheckIcon, ClipboardIcon } from "lucide-react"; +import { cn } from "~/utils/cn"; + +export function CopyableText({ value, className }: { value: string; className?: string }) { + const [isHovered, setIsHovered] = useState(false); + const [copied, setCopied] = useState(false); + + const copy = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => { + setCopied(false); + }, 1500); + }, + [value] + ); + + return ( + setIsHovered(false)} + > + setIsHovered(true)}>{value} + e.stopPropagation()} + className={cn( + "absolute -right-6 top-0 z-10 size-6 font-sans", + isHovered ? "flex" : "hidden" + )} + > + + {copied ? ( + + ) : ( + + )} + + } + content={copied ? "Copied!" : "Copy text"} + className="font-sans" + disableHoverableContent + /> + + + ); +} diff --git a/apps/webapp/app/components/runs/v3/PacketDisplay.tsx b/apps/webapp/app/components/runs/v3/PacketDisplay.tsx new file mode 100644 index 0000000000..430402c89e --- /dev/null +++ b/apps/webapp/app/components/runs/v3/PacketDisplay.tsx @@ -0,0 +1,51 @@ +import { CloudArrowDownIcon } from "@heroicons/react/20/solid"; +import { CodeBlock } from "~/components/code/CodeBlock"; +import { LinkButton } from "~/components/primitives/Buttons"; +import { Paragraph } from "~/components/primitives/Paragraph"; + +export function PacketDisplay({ + data, + dataType, + title, +}: { + data: string; + dataType: string; + title: string; +}) { + switch (dataType) { + case "application/store": { + return ( +
+ + {title} + + + Download + +
+ ); + } + case "text/plain": { + return ( + + ); + } + default: { + return ( + + ); + } + } +} diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index 04b4c01cc2..591b917bd8 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -61,6 +61,7 @@ import { TaskRunStatusCombo, } from "./TaskRunStatus"; import { TaskTriggerSourceIcon } from "./TaskTriggerSource"; +import { StatusIcon } from "~/assets/icons/StatusIcon"; export const TaskAttemptStatus = z.enum(allTaskRunStatuses); @@ -148,11 +149,7 @@ const filterTypes = [ { name: "statuses", title: "Status", - icon: ( -
-
-
- ), + icon: , }, { name: "tasks", title: "Tasks", icon: }, { name: "tags", title: "Tags", icon: }, diff --git a/apps/webapp/app/components/runs/v3/RunIcon.tsx b/apps/webapp/app/components/runs/v3/RunIcon.tsx index 7c43fcfae6..dc5f63bc61 100644 --- a/apps/webapp/app/components/runs/v3/RunIcon.tsx +++ b/apps/webapp/app/components/runs/v3/RunIcon.tsx @@ -13,6 +13,7 @@ import { tablerIcons } from "~/utils/tablerIcons"; import tablerSpritePath from "~/components/primitives/tabler-sprite.svg"; import { TaskCachedIcon } from "~/assets/icons/TaskCachedIcon"; import { PauseIcon } from "~/assets/icons/PauseIcon"; +import { TriggerIcon } from "~/assets/icons/TriggerIcon"; type TaskIconProps = { name: string | undefined; @@ -65,6 +66,8 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) { return ; case "queue": return ; + case "trigger": + 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 index 7e377979d1..c7aab7cb09 100644 --- a/apps/webapp/app/components/runs/v3/RunTag.tsx +++ b/apps/webapp/app/components/runs/v3/RunTag.tsx @@ -1,65 +1,138 @@ -import { useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; import tagLeftPath from "./tag-left.svg"; +import { SimpleTooltip } from "~/components/primitives/Tooltip"; +import { Link } from "@remix-run/react"; +import { cn } from "~/utils/cn"; +import { ClipboardCheckIcon, ClipboardIcon } from "lucide-react"; type Tag = string | { key: string; value: string }; -export function RunTag({ tag }: { tag: string }) { +export function RunTag({ tag, to, tooltip }: { tag: string; to?: string; tooltip?: string }) { const tagResult = useMemo(() => splitTag(tag), [tag]); + const [isHovered, setIsHovered] = useState(false); - if (typeof tagResult === "string") { - return ( - - - - {tag} - - - ); - } else { - return ( - - - - {tagResult.key} - - - {tagResult.value} + // Render the basic tag content + const renderTagContent = () => { + if (typeof tagResult === "string") { + return ( + <> + + + {tag} + + + ); + } else { + return ( + <> + + + {tagResult.key} + + + {tagResult.value} + + + ); + } + }; + + // The main tag content, optionally wrapped in a Link and SimpleTooltip + const tagContent = to ? ( + setIsHovered(true)}> + {renderTagContent()} + + } + content={tooltip || `Filter by ${tag}`} + disableHoverableContent + /> + ) : ( + setIsHovered(true)}> + {renderTagContent()} + + ); + + return ( +
setIsHovered(false)}> + {tagContent} + +
+ ); +} + +function CopyButton({ textToCopy, isHovered }: { textToCopy: string; isHovered: boolean }) { + const [copied, setCopied] = useState(false); + + const copy = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + navigator.clipboard.writeText(textToCopy); + setCopied(true); + setTimeout(() => { + setCopied(false); + }, 1500); + }, + [textToCopy] + ); + + return ( + e.stopPropagation()} + className={cn( + "absolute -right-6 top-0 z-10 size-6 items-center justify-center rounded-r-sm border-y border-r border-charcoal-650 bg-charcoal-750", + isHovered ? "flex" : "hidden", + copied + ? "text-green-500" + : "text-text-dimmed hover:border-charcoal-600 hover:bg-charcoal-700 hover:text-text-bright" + )} + > + {copied ? ( + + ) : ( + + )}
-
- ); - } + } + content={copied ? "Copied!" : "Copy tag"} + disableHoverableContent + /> + ); } /** 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 - * + * * Special handling for common ID formats and values with special characters. */ export function splitTag(tag: string): Tag { const match = tag.match(/^([a-zA-Z0-9]{1,12})[_:](.*?)$/); if (!match) return tag; - + const [, key, value] = match; - + const colonCount = (tag.match(/:/g) || []).length; const underscoreCount = (tag.match(/_/g) || []).length; - + const hasMultipleColons = colonCount > 1 && !tag.includes("_"); const hasMultipleUnderscores = underscoreCount > 1 && !tag.includes(":"); const isLikelyID = hasMultipleColons || hasMultipleUnderscores; - + if (!isLikelyID) return { key, value }; - + const isAlphabeticKey = key.match(/^[a-zA-Z]+$/) !== null; - const hasSpecialFormatChars = value.includes("-") || - value.includes("T") || - value.includes("Z") || - value.includes("/"); + const hasSpecialFormatChars = + value.includes("-") || value.includes("T") || value.includes("Z") || value.includes("/"); const isSpecialFormat = isAlphabeticKey && hasSpecialFormatChars; - + if (isSpecialFormat) return { key, value }; - + return tag; } diff --git a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx index 1e594e119c..33fb88b2e0 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx @@ -384,7 +384,7 @@ export function TaskRunsTable({ {run.delayUntil ? : "–"} {run.ttl ?? "–"} - +
{run.tags.map((tag) => ) || "–"}
diff --git a/apps/webapp/app/components/runs/v3/WaitpointDetails.tsx b/apps/webapp/app/components/runs/v3/WaitpointDetails.tsx new file mode 100644 index 0000000000..95331c6e37 --- /dev/null +++ b/apps/webapp/app/components/runs/v3/WaitpointDetails.tsx @@ -0,0 +1,130 @@ +import { DateTime, DateTimeAccurate } from "~/components/primitives/DateTime"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import * as Property from "~/components/primitives/PropertyTable"; +import { TextLink } from "~/components/primitives/TextLink"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { type WaitpointDetail } from "~/presenters/v3/WaitpointPresenter.server"; +import { ForceTimeout } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route"; +import { v3WaitpointTokenPath, v3WaitpointTokensPath } from "~/utils/pathBuilder"; +import { PacketDisplay } from "./PacketDisplay"; +import { WaitpointStatusCombo } from "./WaitpointStatus"; +import { RunTag } from "./RunTag"; + +export function WaitpointDetailTable({ + waitpoint, + linkToList = false, +}: { + waitpoint: WaitpointDetail; + linkToList?: boolean; +}) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + const hasExpired = + waitpoint.idempotencyKeyExpiresAt && waitpoint.idempotencyKeyExpiresAt < new Date(); + + return ( + + + Status + + + + + + ID + + {linkToList ? ( + + {waitpoint.id} + + ) : ( + waitpoint.id + )} + + + + Idempotency key + +
+
+ {waitpoint.userProvidedIdempotencyKey + ? waitpoint.inactiveIdempotencyKey ?? waitpoint.idempotencyKey + : "–"} +
+
+ {waitpoint.idempotencyKeyExpiresAt ? ( + <> + {hasExpired ? "Expired" : "Expires at"}:{" "} + + + ) : null} +
+
+
+
+ {waitpoint.type === "MANUAL" && ( + <> + + Timeout + +
+
+ {waitpoint.completedAfter ? ( + <> + + + ) : ( + "–" + )} + {waitpoint.status === "WAITING" && } +
+ + {waitpoint.status === "TIMED_OUT" + ? "The waitpoint timed out" + : waitpoint.status === "COMPLETED" + ? "The waitpoint completed before this timeout was reached" + : "The waitpoint is still waiting"} + +
+
+
+ + Tags + +
+ {waitpoint.tags.map((tag) => ( + + ))} +
+
+
+ + )} + + Completed + + {waitpoint.completedAt ? : "–"} + + + {waitpoint.status === "WAITING" ? null : waitpoint.status === "TIMED_OUT" ? ( + <> + ) : waitpoint.output ? ( + + ) : waitpoint.completedAfter ? null : ( + "Completed with no output" + )} +
+ ); +} diff --git a/apps/webapp/app/components/runs/v3/WaitpointStatus.tsx b/apps/webapp/app/components/runs/v3/WaitpointStatus.tsx new file mode 100644 index 0000000000..9879286e56 --- /dev/null +++ b/apps/webapp/app/components/runs/v3/WaitpointStatus.tsx @@ -0,0 +1,79 @@ +import { CheckCircleIcon } from "@heroicons/react/20/solid"; +import { type WaitpointTokenStatus } from "@trigger.dev/core/v3"; +import assertNever from "assert-never"; +import { TimedOutIcon } from "~/assets/icons/TimedOutIcon"; +import { Spinner } from "~/components/primitives/Spinner"; +import { cn } from "~/utils/cn"; + +export function WaitpointStatusCombo({ + status, + className, + iconClassName, +}: { + status: WaitpointTokenStatus; + className?: string; + iconClassName?: string; +}) { + return ( + + + + + ); +} + +export function WaitpointStatusLabel({ status }: { status: WaitpointTokenStatus }) { + return ( + {waitpointStatusTitle(status)} + ); +} + +export function WaitpointStatusIcon({ + status, + className, +}: { + status: WaitpointTokenStatus; + className: string; +}) { + switch (status) { + case "WAITING": + return ; + case "TIMED_OUT": + return ; + case "COMPLETED": + return ; + default: { + assertNever(status); + } + } +} + +export function waitpointStatusClassNameColor(status: WaitpointTokenStatus): string { + switch (status) { + case "WAITING": + return "text-blue-500"; + case "TIMED_OUT": + return "text-error"; + case "COMPLETED": { + return "text-success"; + } + default: { + assertNever(status); + } + } +} + +export function waitpointStatusTitle(status: WaitpointTokenStatus): string { + switch (status) { + case "WAITING": + return "Waiting"; + case "TIMED_OUT": + return "Timed out"; + case "COMPLETED": { + return "Completed"; + } + default: { + assertNever(status); + } + } +} diff --git a/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx b/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx new file mode 100644 index 0000000000..73ff03e5da --- /dev/null +++ b/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx @@ -0,0 +1,675 @@ +import * as Ariakit from "@ariakit/react"; +import { CalendarIcon, FingerPrintIcon, TagIcon, TrashIcon } from "@heroicons/react/20/solid"; +import { Form, useFetcher } from "@remix-run/react"; +import { WaitpointTokenStatus, waitpointTokenStatuses } from "@trigger.dev/core/v3"; +import { ListChecks, ListFilterIcon } from "lucide-react"; +import { matchSorter } from "match-sorter"; +import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; +import { z } from "zod"; +import { StatusIcon } from "~/assets/icons/StatusIcon"; +import { AppliedFilter } from "~/components/primitives/AppliedFilter"; +import { Button } from "~/components/primitives/Buttons"; +import { FormError } from "~/components/primitives/FormError"; +import { Input } from "~/components/primitives/Input"; +import { Label } from "~/components/primitives/Label"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { + ComboBox, + SelectButtonItem, + SelectItem, + SelectList, + SelectPopover, + SelectProvider, + SelectTrigger, + shortcutFromIndex, +} from "~/components/primitives/Select"; +import { Spinner } from "~/components/primitives/Spinner"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "~/components/primitives/Tooltip"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { useSearchParams } from "~/hooks/useSearchParam"; +import { type loader as tagsLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tags"; +import { + AppliedCustomDateRangeFilter, + AppliedPeriodFilter, + appliedSummary, + CreatedAtDropdown, + CustomDateRangeDropdown, + FilterMenuProvider, +} from "./SharedFilters"; +import { WaitpointStatusCombo, waitpointStatusTitle } from "./WaitpointStatus"; + +export const WaitpointSearchParamsSchema = z.object({ + id: z.string().optional(), + statuses: z.preprocess( + (value) => (typeof value === "string" ? [value] : value), + WaitpointTokenStatus.array().optional() + ), + idempotencyKey: z.string().optional(), + tags: z.string().array().optional(), + period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()), + from: z.coerce.number().optional(), + to: z.coerce.number().optional(), + cursor: z.string().optional(), + direction: z.enum(["forward", "backward"]).optional(), +}); +export type WaitpointSearchParams = z.infer; + +type WaitpointTokenFiltersProps = { + hasFilters: boolean; +}; + +export function WaitpointTokenFilters(props: WaitpointTokenFiltersProps) { + const location = useOptimisticLocation(); + const searchParams = new URLSearchParams(location.search); + const hasFilters = + searchParams.has("statuses") || + searchParams.has("period") || + searchParams.has("tags") || + searchParams.has("from") || + searchParams.has("to") || + searchParams.has("id") || + searchParams.has("idempotencyKey"); + + return ( +
+ + + {hasFilters && ( +
+ +
+ )} +
+ ); +} + +const filterTypes = [ + { + name: "statuses", + title: "Status", + icon: , + }, + { name: "tags", title: "Tags", icon: }, + { name: "created", title: "Created", icon: }, + { name: "daterange", title: "Custom date range", icon: }, + { name: "id", title: "Waitpoint ID", icon: }, + { name: "idempotencyKey", title: "Idempotency key", icon: }, +] as const; + +type FilterType = (typeof filterTypes)[number]["name"]; + +const shortcut = { key: "f" }; + +function FilterMenu() { + const [filterType, setFilterType] = useState(); + + const filterTrigger = ( + + +
+ } + variant={"minimal/small"} + shortcut={shortcut} + tooltipTitle={"Filter runs"} + > + Filter + + ); + + return ( + setFilterType(undefined)}> + {(search, setSearch) => ( + setSearch("")} + trigger={filterTrigger} + filterType={filterType} + setFilterType={setFilterType} + /> + )} + + ); +} + +function AppliedFilters() { + return ( + <> + + + + + + + + ); +} + +type MenuProps = { + searchValue: string; + clearSearchValue: () => void; + trigger: React.ReactNode; + filterType: FilterType | undefined; + setFilterType: (filterType: FilterType | undefined) => void; +}; + +function Menu(props: MenuProps) { + switch (props.filterType) { + case undefined: + return ; + case "statuses": + return props.setFilterType(undefined)} {...props} />; + case "created": + return props.setFilterType(undefined)} {...props} />; + case "daterange": + return props.setFilterType(undefined)} {...props} />; + case "tags": + return props.setFilterType(undefined)} {...props} />; + case "id": + return props.setFilterType(undefined)} {...props} />; + case "idempotencyKey": + return props.setFilterType(undefined)} {...props} />; + } +} + +function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: MenuProps) { + const filtered = useMemo(() => { + return filterTypes.filter((item) => { + if (item.name === "daterange") return false; + return item.title.toLowerCase().includes(searchValue.toLowerCase()); + }); + }, [searchValue]); + + return ( + + {trigger} + + + + {filtered.map((type, index) => ( + { + clearSearchValue(); + setFilterType(type.name); + }} + icon={type.icon} + shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })} + > + {type.title} + + ))} + + + + ); +} + +const statuses = waitpointTokenStatuses.map((status) => ({ + title: waitpointStatusTitle(status), + value: status, +})); + +function StatusDropdown({ + trigger, + clearSearchValue, + searchValue, + onClose, +}: { + trigger: ReactNode; + clearSearchValue: () => void; + searchValue: string; + onClose?: () => void; +}) { + const { values, replace } = useSearchParams(); + + const handleChange = (values: string[]) => { + clearSearchValue(); + replace({ statuses: values, cursor: undefined, direction: undefined }); + }; + + const filtered = useMemo(() => { + return statuses.filter((item) => item.title.toLowerCase().includes(searchValue.toLowerCase())); + }, [searchValue]); + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + + return true; + }} + > + + + {filtered.map((item, index) => { + return ( + + + + + + + + + {waitpointStatusTitle(item.value)} + + + + + + ); + })} + + + + ); +} + +function AppliedStatusFilter() { + const { values, del } = useSearchParams(); + const statuses = values("statuses"); + + if (statuses.length === 0) { + return null; + } + + return ( + + {(search, setSearch) => ( + }> + waitpointStatusTitle(v as WaitpointTokenStatus)) + )} + onRemove={() => del(["statuses", "cursor", "direction"])} + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} + +function TagsDropdown({ + trigger, + clearSearchValue, + searchValue, + onClose, +}: { + trigger: ReactNode; + clearSearchValue: () => void; + searchValue: string; + onClose?: () => void; +}) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + 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/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/waitpoints/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("")} + /> + )} + + ); +} + +function WaitpointIdDropdown({ + trigger, + clearSearchValue, + searchValue, + onClose, +}: { + trigger: ReactNode; + clearSearchValue: () => void; + searchValue: string; + onClose?: () => void; +}) { + const [open, setOpen] = useState(); + const { value, replace } = useSearchParams(); + const idValue = value("id"); + + const [id, setId] = useState(idValue); + + const apply = useCallback(() => { + clearSearchValue(); + replace({ + cursor: undefined, + direction: undefined, + id: id === "" ? undefined : id?.toString(), + }); + + setOpen(false); + }, [id, replace]); + + let error: string | undefined = undefined; + if (id) { + if (!id.startsWith("waitpoint_")) { + error = "Waitpoint IDs start with 'waitpoint_'"; + } else if (id.length !== 35) { + error = "Waitpoint IDs are 35 characters long"; + } + } + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + + return true; + }} + className="max-w-[min(32ch,var(--popover-available-width))]" + > +
+
+ + setId(e.target.value)} + variant="small" + className="w-[27ch] font-mono" + spellCheck={false} + /> + {error ? {error} : null} +
+
+ + +
+
+
+
+ ); +} + +function AppliedWaitpointIdFilter() { + const { value, del } = useSearchParams(); + + if (value("id") === undefined) { + return null; + } + + const id = value("id"); + + return ( + + {(search, setSearch) => ( + }> + del(["id", "cursor", "direction"])} + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} + +function IdempotencyKeyDropdown({ + trigger, + clearSearchValue, + searchValue, + onClose, +}: { + trigger: ReactNode; + clearSearchValue: () => void; + searchValue: string; + onClose?: () => void; +}) { + const [open, setOpen] = useState(); + const { value, replace } = useSearchParams(); + const idValue = value("idempotencyKey"); + + const [idempotencyKey, setIdempotencyKey] = useState(idValue); + + const apply = useCallback(() => { + clearSearchValue(); + replace({ + cursor: undefined, + direction: undefined, + idempotencyKey: idempotencyKey === "" ? undefined : idempotencyKey?.toString(), + }); + + setOpen(false); + }, [idempotencyKey, replace]); + + let error: string | undefined = undefined; + if (idempotencyKey) { + if (idempotencyKey.length === 0) { + error = "Idempotency keys need to be at least 1 character in length"; + } + } + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + + return true; + }} + className="max-w-[min(32ch,var(--popover-available-width))]" + > +
+
+ + setIdempotencyKey(e.target.value)} + variant="small" + className="w-[27ch] font-mono" + spellCheck={false} + /> + {error ? {error} : null} +
+
+ + +
+
+
+
+ ); +} + +function AppliedIdempotencyKeyFilter() { + const { value, del } = useSearchParams(); + + if (value("idempotencyKey") === undefined) { + return null; + } + + const idempotencyKey = value("idempotencyKey"); + + return ( + + {(search, setSearch) => ( + }> + del(["idempotencyKey", "cursor", "direction"])} + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} diff --git a/apps/webapp/app/hooks/useNewCustomerSubscribed.ts b/apps/webapp/app/hooks/useNewCustomerSubscribed.ts deleted file mode 100644 index 2c81d18bf1..0000000000 --- a/apps/webapp/app/hooks/useNewCustomerSubscribed.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { useEffect } from "react"; - -export function useNewCustomerSubscribed() { - useEffect(() => { - if ("confetti" in window && typeof window.confetti !== "undefined") { - const duration = 3.5 * 1000; - const animationEnd = Date.now() + duration; - const defaults = { - startVelocity: 30, - spread: 360, - ticks: 60, - zIndex: 0, - colors: [ - "#E7FF52", - "#41FF54", - "rgb(245 158 11)", - "rgb(22 163 74)", - "rgb(37 99 235)", - "rgb(67 56 202)", - "rgb(219 39 119)", - "rgb(225 29 72)", - "rgb(217 70 239)", - ], - }; - function randomInRange(min: number, max: number): number { - return Math.random() * (max - min) + min; - } - // @ts-ignore - const interval = setInterval(function () { - const timeLeft = animationEnd - Date.now(); - - if (timeLeft <= 0) { - return clearInterval(interval); - } - - const particleCount = 60 * (timeLeft / duration); - // since particles fall down, start a bit higher than random - // @ts-ignore - window.confetti( - Object.assign({}, defaults, { - particleCount, - origin: { x: randomInRange(0.1, 0.4), y: Math.random() - 0.2 }, - }) - ); - // @ts-ignore - window.confetti( - Object.assign({}, defaults, { - particleCount, - origin: { x: randomInRange(0.6, 0.9), y: Math.random() - 0.2 }, - }) - ); - }, 250); - } - }, []); -} diff --git a/apps/webapp/app/models/taskRunTag.server.ts b/apps/webapp/app/models/taskRunTag.server.ts index 19014078b3..812d1c8610 100644 --- a/apps/webapp/app/models/taskRunTag.server.ts +++ b/apps/webapp/app/models/taskRunTag.server.ts @@ -1,28 +1,48 @@ +import { Prisma } from "@trigger.dev/database"; import { prisma } from "~/db.server"; import { generateFriendlyId } from "~/v3/friendlyIdentifiers"; import { PrismaClientOrTransaction } from "@trigger.dev/database"; export const MAX_TAGS_PER_RUN = 10; +const MAX_RETRIES = 3; export async function createTag( { tag, projectId }: { tag: string; projectId: string }, prismaClient: PrismaClientOrTransaction = prisma ) { if (tag.trim().length === 0) return; - return prismaClient.taskRunTag.upsert({ - where: { - projectId_name: { - projectId: projectId, - name: tag, - }, - }, - create: { - name: tag, - friendlyId: generateFriendlyId("runtag"), - projectId: projectId, - }, - update: {}, - }); + + let attempts = 0; + const friendlyId = generateFriendlyId("runtag"); + + while (attempts < MAX_RETRIES) { + try { + return await prisma.taskRunTag.upsert({ + where: { + projectId_name: { + projectId, + name: tag, + }, + }, + create: { + friendlyId, + name: tag, + projectId, + }, + update: {}, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + // Handle unique constraint violation (conflict) + attempts++; + if (attempts >= MAX_RETRIES) { + throw new Error(`Failed to create tag after ${MAX_RETRIES} attempts due to conflicts.`); + } + } else { + throw error; // Re-throw other errors + } + } + } } export type TagRecord = { diff --git a/apps/webapp/app/models/waitpointTag.server.ts b/apps/webapp/app/models/waitpointTag.server.ts new file mode 100644 index 0000000000..202bff1c42 --- /dev/null +++ b/apps/webapp/app/models/waitpointTag.server.ts @@ -0,0 +1,50 @@ +import { Prisma } from "@trigger.dev/database"; +import { prisma } from "~/db.server"; + +export const MAX_TAGS_PER_WAITPOINT = 10; +const MAX_RETRIES = 3; + +export async function createWaitpointTag({ + tag, + environmentId, + projectId, +}: { + tag: string; + environmentId: string; + projectId: string; +}) { + if (tag.trim().length === 0) return; + + let attempts = 0; + + while (attempts < MAX_RETRIES) { + try { + return await prisma.waitpointTag.upsert({ + where: { + environmentId_name: { + environmentId, + name: tag, + }, + }, + create: { + name: tag, + environmentId, + projectId, + }, + update: {}, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + // Handle unique constraint violation (conflict) + attempts++; + if (attempts >= MAX_RETRIES) { + throw new Error( + `Failed to create waitpoint tag after ${MAX_RETRIES} attempts due to conflicts.` + ); + } + } else { + throw error; // Re-throw other errors + } + } + } +} diff --git a/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts index 15966e3726..61b00edb2a 100644 --- a/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts @@ -1,34 +1,18 @@ -import { ListRunResponse, ListRunResponseItem, parsePacket, RunStatus } from "@trigger.dev/core/v3"; -import { Project, RuntimeEnvironment, TaskRunStatus } from "@trigger.dev/database"; +import { + type ListRunResponse, + type ListRunResponseItem, + parsePacket, + RunStatus, +} from "@trigger.dev/core/v3"; +import { type Project, type RuntimeEnvironment, type TaskRunStatus } from "@trigger.dev/database"; import assertNever from "assert-never"; import { z } from "zod"; -import { fromZodError } from "zod-validation-error"; import { logger } from "~/services/logger.server"; +import { CoercedDate } from "~/utils/zod"; import { ApiRetrieveRunPresenter } from "./ApiRetrieveRunPresenter.server"; -import { RunListOptions, RunListPresenter } from "./RunListPresenter.server"; +import { type RunListOptions, RunListPresenter } from "./RunListPresenter.server"; import { BasePresenter } from "./basePresenter.server"; -const CoercedDate = z.preprocess((arg) => { - if (arg === undefined || arg === null) { - return; - } - - if (typeof arg === "number") { - return new Date(arg); - } - - if (typeof arg === "string") { - const num = Number(arg); - if (!isNaN(num)) { - return new Date(num); - } - - return new Date(arg); - } - - return arg; -}, z.date().optional()); - export const ApiRunListSearchParams = z.object({ "page[size]": z.coerce.number().int().positive().min(1).max(100).optional(), "page[after]": z.string().optional(), diff --git a/apps/webapp/app/presenters/v3/ApiWaitpointPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiWaitpointPresenter.server.ts new file mode 100644 index 0000000000..b443568c14 --- /dev/null +++ b/apps/webapp/app/presenters/v3/ApiWaitpointPresenter.server.ts @@ -0,0 +1,81 @@ +import { logger, type RuntimeEnvironmentType } from "@trigger.dev/core/v3"; +import { type RunEngineVersion } from "@trigger.dev/database"; +import { ServiceValidationError } from "~/v3/services/baseService.server"; +import { BasePresenter } from "./basePresenter.server"; +import { WaitpointPresenter } from "./WaitpointPresenter.server"; +import { waitpointStatusToApiStatus } from "./WaitpointTokenListPresenter.server"; + +export class ApiWaitpointPresenter extends BasePresenter { + public async call( + environment: { + id: string; + type: RuntimeEnvironmentType; + project: { + id: string; + engine: RunEngineVersion; + }; + }, + waitpointId: string + ) { + return this.trace("call", async (span) => { + const waitpoint = await this._replica.waitpoint.findFirst({ + where: { + id: waitpointId, + environmentId: environment.id, + }, + select: { + friendlyId: true, + type: true, + status: true, + idempotencyKey: true, + userProvidedIdempotencyKey: true, + idempotencyKeyExpiresAt: true, + inactiveIdempotencyKey: true, + output: true, + outputType: true, + outputIsError: true, + completedAfter: true, + completedAt: true, + createdAt: true, + connectedRuns: { + select: { + friendlyId: true, + }, + take: 5, + }, + tags: true, + }, + }); + + if (!waitpoint) { + logger.error(`WaitpointPresenter: Waitpoint not found`, { + id: waitpointId, + }); + throw new ServiceValidationError("Waitpoint not found"); + } + + let isTimeout = false; + if (waitpoint.outputIsError && waitpoint.output) { + isTimeout = true; + } + + return { + id: waitpoint.friendlyId, + type: waitpoint.type, + status: waitpointStatusToApiStatus(waitpoint.status, waitpoint.outputIsError), + idempotencyKey: waitpoint.idempotencyKey, + userProvidedIdempotencyKey: waitpoint.userProvidedIdempotencyKey, + idempotencyKeyExpiresAt: waitpoint.idempotencyKeyExpiresAt ?? undefined, + inactiveIdempotencyKey: waitpoint.inactiveIdempotencyKey ?? undefined, + output: waitpoint.output ?? undefined, + outputType: waitpoint.outputType, + outputIsError: waitpoint.outputIsError, + timeoutAt: waitpoint.completedAfter ?? undefined, + completedAfter: waitpoint.completedAfter ?? undefined, + completedAt: waitpoint.completedAt ?? undefined, + createdAt: waitpoint.createdAt, + tags: waitpoint.tags, + }; + }); + } +} diff --git a/apps/webapp/app/presenters/v3/ApiWaitpointTokenListPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiWaitpointTokenListPresenter.server.ts new file mode 100644 index 0000000000..b4a181dfb7 --- /dev/null +++ b/apps/webapp/app/presenters/v3/ApiWaitpointTokenListPresenter.server.ts @@ -0,0 +1,134 @@ +import { RuntimeEnvironmentType, WaitpointTokenStatus } from "@trigger.dev/core/v3"; +import { z } from "zod"; +import { BasePresenter } from "./basePresenter.server"; +import { CoercedDate } from "~/utils/zod"; +import { AuthenticatedEnvironment } from "@internal/run-engine"; +import { + WaitpointTokenListOptions, + WaitpointTokenListPresenter, +} from "./WaitpointTokenListPresenter.server"; +import { ServiceValidationError } from "~/v3/services/baseService.server"; +import { RunEngineVersion } from "@trigger.dev/database"; + +export const ApiWaitpointTokenListSearchParams = z.object({ + "page[size]": z.coerce.number().int().positive().min(1).max(100).optional(), + "page[after]": z.string().optional(), + "page[before]": z.string().optional(), + "filter[status]": z + .string() + .optional() + .transform((value, ctx) => { + if (!value) { + return undefined; + } + + const statuses = value.split(","); + const parsedStatuses = statuses.map((status) => WaitpointTokenStatus.safeParse(status)); + + if (parsedStatuses.some((result) => !result.success)) { + const invalidStatuses: string[] = []; + + for (const [index, result] of parsedStatuses.entries()) { + if (!result.success) { + invalidStatuses.push(statuses[index]); + } + } + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid status values: ${invalidStatuses.join(", ")}`, + }); + + return z.NEVER; + } + + const $statuses = parsedStatuses + .map((result) => (result.success ? result.data : undefined)) + .filter(Boolean); + + return Array.from(new Set($statuses)); + }), + "filter[idempotencyKey]": z.string().optional(), + "filter[tags]": z + .string() + .optional() + .transform((value) => { + if (!value) return undefined; + return value.split(","); + }), + "filter[createdAt][period]": z.string().optional(), + "filter[createdAt][from]": CoercedDate, + "filter[createdAt][to]": CoercedDate, +}); + +type ApiWaitpointTokenListSearchParams = z.infer; + +export class ApiWaitpointTokenListPresenter extends BasePresenter { + public async call( + environment: { + id: string; + type: RuntimeEnvironmentType; + project: { + id: string; + engine: RunEngineVersion; + }; + }, + searchParams: ApiWaitpointTokenListSearchParams + ) { + return this.trace("call", async (span) => { + const options: WaitpointTokenListOptions = { + environment, + }; + + if (searchParams["page[size]"]) { + options.pageSize = searchParams["page[size]"]; + } + + if (searchParams["page[after]"]) { + options.cursor = searchParams["page[after]"]; + options.direction = "forward"; + } + + if (searchParams["page[before]"]) { + options.cursor = searchParams["page[before]"]; + options.direction = "backward"; + } + + if (searchParams["filter[status]"]) { + options.statuses = searchParams["filter[status]"]; + } + + if (searchParams["filter[idempotencyKey]"]) { + options.idempotencyKey = searchParams["filter[idempotencyKey]"]; + } + + if (searchParams["filter[tags]"]) { + options.tags = searchParams["filter[tags]"]; + } + + if (searchParams["filter[createdAt][period]"]) { + options.period = searchParams["filter[createdAt][period]"]; + } + + if (searchParams["filter[createdAt][from]"]) { + options.from = searchParams["filter[createdAt][from]"].getTime(); + } + + if (searchParams["filter[createdAt][to]"]) { + options.to = searchParams["filter[createdAt][to]"].getTime(); + } + + const presenter = new WaitpointTokenListPresenter(); + const result = await presenter.call(options); + + if (!result.success) { + throw new ServiceValidationError(result.error); + } + + return { + data: result.tokens, + pagination: result.pagination, + }; + }); + } +} diff --git a/apps/webapp/app/presenters/v3/DevPresenceStream.server.ts b/apps/webapp/app/presenters/v3/DevPresenceStream.server.ts index 7c3df3b3ae..ed4d3ee5f9 100644 --- a/apps/webapp/app/presenters/v3/DevPresenceStream.server.ts +++ b/apps/webapp/app/presenters/v3/DevPresenceStream.server.ts @@ -1,4 +1,3 @@ - const PRESENCE_KEY_PREFIX = "dev-presence:connection:"; const PRESENCE_CHANNEL_PREFIX = "dev-presence:updates:"; @@ -10,7 +9,4 @@ export class DevPresenceStream { static getPresenceChannel(environmentId: string) { return `${PRESENCE_CHANNEL_PREFIX}${environmentId}`; } - - //todo create a Redis client for each function call to subscribe - //todo you can get the redis options, or there might be a clone function } diff --git a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts index 287a82f4a2..8039d8cb07 100644 --- a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts @@ -26,18 +26,34 @@ export class QueueListPresenter extends BasePresenter { const totalQueues = await this._replica.taskQueue.count({ where: { runtimeEnvironmentId: environment.id, + version: "V2", }, }); //check the engine is the correct version const engineVersion = await determineEngineVersion({ environment }); - if (engineVersion === "V1") { - return { - success: false as const, - code: "engine-version", - totalQueues, - }; + if (totalQueues === 0) { + const oldQueue = await this._replica.taskQueue.findFirst({ + where: { + runtimeEnvironmentId: environment.id, + version: "V1", + }, + }); + if (oldQueue) { + return { + success: false as const, + code: "engine-version", + totalQueues: 1, + }; + } else { + return { + success: false as const, + code: "engine-version", + totalQueues, + }; + } + } } return { diff --git a/apps/webapp/app/presenters/v3/RunListPresenter.server.ts b/apps/webapp/app/presenters/v3/RunListPresenter.server.ts index cb72b0160f..ffe6d6ce07 100644 --- a/apps/webapp/app/presenters/v3/RunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunListPresenter.server.ts @@ -24,7 +24,7 @@ export type RunListOptions = { isTest?: boolean; rootOnly?: boolean; batchId?: string; - runId?: string; + runIds?: string[]; //pagination direction?: Direction; cursor?: string; @@ -52,7 +52,7 @@ export class RunListPresenter extends BasePresenter { isTest, rootOnly, batchId, - runId, + runIds, from, to, direction = "forward", @@ -72,7 +72,7 @@ export class RunListPresenter extends BasePresenter { (scheduleId !== undefined && scheduleId !== "") || (tags !== undefined && tags.length > 0) || batchId !== undefined || - runId !== undefined || + (runIds !== undefined && runIds.length > 0) || typeof isTest === "boolean" || rootOnly === true; @@ -182,7 +182,7 @@ export class RunListPresenter extends BasePresenter { } //show all runs if we are filtering by batchId or runId - if (batchId || runId || scheduleId || tasks?.length) { + if (batchId || runIds?.length || scheduleId || tasks?.length) { rootOnly = false; } @@ -261,7 +261,7 @@ WHERE : Prisma.empty } -- filters - ${runId ? Prisma.sql`AND tr."friendlyId" = ${runId}` : Prisma.empty} + ${runIds ? Prisma.sql`AND tr."friendlyId" IN (${Prisma.join(runIds)})` : Prisma.empty} ${batchId ? Prisma.sql`AND tr."batchId" = ${batchId}` : Prisma.empty} ${ restrictToRunIds diff --git a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts index e7c58c5c21..3c09282c04 100644 --- a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts @@ -1,6 +1,6 @@ import { isWaitpointOutputTimeout, - MachinePresetName, + type MachinePresetName, prettyPrintPacket, TaskRunError, } from "@trigger.dev/core/v3"; @@ -9,9 +9,10 @@ import { RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus"; import { logger } from "~/services/logger.server"; import { eventRepository } from "~/v3/eventRepository.server"; import { machinePresetFromName } from "~/v3/machinePresets.server"; -import { getTaskEventStoreTableForRun, TaskEventStoreTable } from "~/v3/taskEventStore.server"; +import { getTaskEventStoreTableForRun, type TaskEventStoreTable } from "~/v3/taskEventStore.server"; import { isFailedRunStatus, isFinalRunStatus } from "~/v3/taskStatus"; import { BasePresenter } from "./basePresenter.server"; +import { WaitpointPresenter } from "./WaitpointPresenter.server"; type Result = Awaited>; export type Span = NonNullable["span"]>; @@ -41,6 +42,7 @@ export class SpanPresenter extends BasePresenter { select: { traceId: true, runtimeEnvironmentId: true, + projectId: true, taskEventStore: true, createdAt: true, completedAt: true, @@ -78,6 +80,7 @@ export class SpanPresenter extends BasePresenter { traceId, spanId, environmentId: parentRun.runtimeEnvironmentId, + projectId: parentRun.projectId, createdAt: parentRun.createdAt, completedAt: parentRun.completedAt, }); @@ -402,12 +405,14 @@ export class SpanPresenter extends BasePresenter { traceId, spanId, environmentId, + projectId, createdAt, completedAt, }: { traceId: string; spanId: string; environmentId: string; + projectId: string; eventStore: TaskEventStoreTable; createdAt: Date; completedAt: Date | null; @@ -451,22 +456,19 @@ export class SpanPresenter extends BasePresenter { switch (span.entity.type) { case "waitpoint": - const waitpoint = await this._replica.waitpoint.findFirst({ - where: { - friendlyId: span.entity.id, - }, - select: { - friendlyId: true, - type: true, - status: true, - idempotencyKey: true, - userProvidedIdempotencyKey: true, - idempotencyKeyExpiresAt: true, - output: true, - outputType: true, - outputIsError: true, - completedAfter: true, - }, + if (!span.entity.id) { + logger.error(`SpanPresenter: No waitpoint id`, { + spanId, + waitpointFriendlyId: span.entity.id, + }); + return { ...data, entity: null }; + } + + const presenter = new WaitpointPresenter(); + const waitpoint = await presenter.call({ + friendlyId: span.entity.id, + environmentId, + projectId, }); if (!waitpoint) { @@ -477,37 +479,11 @@ export class SpanPresenter extends BasePresenter { return { ...data, entity: null }; } - const output = - waitpoint.outputType === "application/store" - ? `/resources/packets/${environmentId}/${waitpoint.output}` - : typeof waitpoint.output !== "undefined" && waitpoint.output !== null - ? await prettyPrintPacket(waitpoint.output, waitpoint.outputType ?? undefined) - : undefined; - - let isTimeout = false; - if (waitpoint.outputIsError && output) { - if (isWaitpointOutputTimeout(output)) { - isTimeout = true; - } - } - return { ...data, entity: { type: "waitpoint" as const, - object: { - friendlyId: waitpoint.friendlyId, - type: waitpoint.type, - status: waitpoint.status, - idempotencyKey: waitpoint.idempotencyKey, - userProvidedIdempotencyKey: waitpoint.userProvidedIdempotencyKey, - idempotencyKeyExpiresAt: waitpoint.idempotencyKeyExpiresAt, - output: output, - outputType: waitpoint.outputType, - outputIsError: waitpoint.outputIsError, - completedAfter: waitpoint.completedAfter, - isTimeout, - }, + object: waitpoint, }, }; diff --git a/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts b/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts new file mode 100644 index 0000000000..f005f5a2dc --- /dev/null +++ b/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts @@ -0,0 +1,102 @@ +import { isWaitpointOutputTimeout, prettyPrintPacket } from "@trigger.dev/core/v3"; +import { logger } from "~/services/logger.server"; +import { BasePresenter } from "./basePresenter.server"; +import { type RunListItem, RunListPresenter } from "./RunListPresenter.server"; +import { waitpointStatusToApiStatus } from "./WaitpointTokenListPresenter.server"; + +export type WaitpointDetail = NonNullable>>; + +export class WaitpointPresenter extends BasePresenter { + public async call({ + friendlyId, + environmentId, + projectId, + }: { + friendlyId: string; + environmentId: string; + projectId: string; + }) { + const waitpoint = await this._replica.waitpoint.findFirst({ + where: { + friendlyId, + environmentId, + }, + select: { + friendlyId: true, + type: true, + status: true, + idempotencyKey: true, + userProvidedIdempotencyKey: true, + idempotencyKeyExpiresAt: true, + inactiveIdempotencyKey: true, + output: true, + outputType: true, + outputIsError: true, + completedAfter: true, + completedAt: true, + createdAt: true, + connectedRuns: { + select: { + friendlyId: true, + }, + take: 5, + }, + tags: true, + }, + }); + + if (!waitpoint) { + logger.error(`WaitpointPresenter: Waitpoint not found`, { + friendlyId, + }); + return null; + } + + const output = + waitpoint.outputType === "application/store" + ? `/resources/packets/${environmentId}/${waitpoint.output}` + : typeof waitpoint.output !== "undefined" && waitpoint.output !== null + ? await prettyPrintPacket(waitpoint.output, waitpoint.outputType ?? undefined) + : undefined; + + let isTimeout = false; + if (waitpoint.outputIsError && output) { + if (isWaitpointOutputTimeout(output)) { + isTimeout = true; + } + } + + const connectedRunIds = waitpoint.connectedRuns.map((run) => run.friendlyId); + const connectedRuns: RunListItem[] = []; + + if (connectedRunIds.length > 0) { + const runPresenter = new RunListPresenter(); + const { runs } = await runPresenter.call({ + projectId: projectId, + environments: [environmentId], + runIds: connectedRunIds, + pageSize: 5, + }); + connectedRuns.push(...runs); + } + + return { + id: waitpoint.friendlyId, + type: waitpoint.type, + status: waitpointStatusToApiStatus(waitpoint.status, waitpoint.outputIsError), + idempotencyKey: waitpoint.idempotencyKey, + userProvidedIdempotencyKey: waitpoint.userProvidedIdempotencyKey, + idempotencyKeyExpiresAt: waitpoint.idempotencyKeyExpiresAt, + inactiveIdempotencyKey: waitpoint.inactiveIdempotencyKey, + output: output, + outputType: waitpoint.outputType, + outputIsError: waitpoint.outputIsError, + timeoutAt: waitpoint.completedAfter, + completedAfter: waitpoint.completedAfter, + completedAt: waitpoint.completedAt, + createdAt: waitpoint.createdAt, + tags: waitpoint.tags, + connectedRuns, + }; + } +} diff --git a/apps/webapp/app/presenters/v3/WaitpointTagListPresenter.server.ts b/apps/webapp/app/presenters/v3/WaitpointTagListPresenter.server.ts new file mode 100644 index 0000000000..ef209f2c6c --- /dev/null +++ b/apps/webapp/app/presenters/v3/WaitpointTagListPresenter.server.ts @@ -0,0 +1,52 @@ +import { logger } from "~/services/logger.server"; +import { BasePresenter } from "./basePresenter.server"; + +export type TagListOptions = { + environmentId: string; + names?: string[]; + //pagination + page?: number; + pageSize?: number; +}; + +const DEFAULT_PAGE_SIZE = 25; + +export type TagList = Awaited>; +export type TagListItem = TagList["tags"][number]; + +export class WaitpointTagListPresenter extends BasePresenter { + public async call({ + environmentId, + names, + page = 1, + pageSize = DEFAULT_PAGE_SIZE, + }: TagListOptions) { + const hasFilters = names !== undefined && names.length > 0; + + const tags = await this._replica.waitpointTag.findMany({ + where: { + environmentId, + OR: + names && names.length > 0 + ? names.map((name) => ({ name: { contains: name, mode: "insensitive" } })) + : undefined, + }, + orderBy: { + id: "desc", + }, + take: pageSize + 1, + skip: (page - 1) * pageSize, + }); + + return { + tags: tags + .map((tag) => ({ + name: tag.name, + })) + .slice(0, pageSize), + currentPage: page, + hasMore: tags.length > pageSize, + hasFilters, + }; + } +} diff --git a/apps/webapp/app/presenters/v3/WaitpointTokenListPresenter.server.ts b/apps/webapp/app/presenters/v3/WaitpointTokenListPresenter.server.ts new file mode 100644 index 0000000000..5b2d0b822a --- /dev/null +++ b/apps/webapp/app/presenters/v3/WaitpointTokenListPresenter.server.ts @@ -0,0 +1,290 @@ +import parse from "parse-duration"; +import { + Prisma, + type RunEngineVersion, + type RuntimeEnvironmentType, + type WaitpointStatus, +} from "@trigger.dev/database"; +import { type Direction } from "~/components/ListPagination"; +import { sqlDatabaseSchema } from "~/db.server"; +import { BasePresenter } from "./basePresenter.server"; +import { type WaitpointSearchParams } from "~/components/runs/v3/WaitpointTokenFilters"; +import { determineEngineVersion } from "~/v3/engineVersion.server"; +import { type WaitpointTokenStatus, type WaitpointTokenItem } from "@trigger.dev/core/v3"; + +const DEFAULT_PAGE_SIZE = 25; + +export type WaitpointTokenListOptions = { + environment: { + id: string; + type: RuntimeEnvironmentType; + project: { + id: string; + engine: RunEngineVersion; + }; + }; + // filters + id?: string; + statuses?: WaitpointTokenStatus[]; + idempotencyKey?: string; + tags?: string[]; + period?: string; + from?: number; + to?: number; + // pagination + direction?: Direction; + cursor?: string; + pageSize?: number; +}; + +type Result = + | { + success: true; + tokens: WaitpointTokenItem[]; + pagination: { + next: string | undefined; + previous: string | undefined; + }; + hasFilters: boolean; + filters: WaitpointSearchParams; + } + | { + success: false; + code: "ENGINE_VERSION_MISMATCH" | "UNKNOWN"; + error: string; + tokens: []; + pagination: { + next: undefined; + previous: undefined; + }; + hasFilters: false; + filters: undefined; + }; + +export class WaitpointTokenListPresenter extends BasePresenter { + public async call({ + environment, + id, + statuses, + idempotencyKey, + tags, + period, + from, + to, + direction = "forward", + cursor, + pageSize = DEFAULT_PAGE_SIZE, + }: WaitpointTokenListOptions): Promise { + const engineVersion = await determineEngineVersion({ environment }); + if (engineVersion === "V1") { + return { + success: false, + code: "ENGINE_VERSION_MISMATCH", + error: "Upgrade to SDK version 4+ to use Waitpoint tokens.", + tokens: [], + pagination: { + next: undefined, + previous: undefined, + }, + hasFilters: false, + filters: undefined, + }; + } + + const hasStatusFilters = statuses && statuses.length > 0; + + const hasFilters = + id !== undefined || + hasStatusFilters || + idempotencyKey !== undefined || + (tags !== undefined && tags.length > 0) || + (period !== undefined && period !== "all") || + from !== undefined || + to !== undefined; + + let filterOutputIsError: boolean | undefined; + //if the only status is completed: true + //if the only status is failed: false + //otherwise undefined + if (statuses?.length === 1) { + if (statuses[0] === "COMPLETED") { + filterOutputIsError = false; + } else if (statuses[0] === "TIMED_OUT") { + filterOutputIsError = true; + } + } + + const statusesToFilter: WaitpointStatus[] = + statuses?.map((status) => { + switch (status) { + case "WAITING": + return "PENDING"; + case "COMPLETED": + return "COMPLETED"; + case "TIMED_OUT": + return "COMPLETED"; + } + }) ?? []; + + const periodMs = period ? parse(period) : undefined; + + // Get the waitpoint tokens using raw SQL for better performance + const tokens = await this._replica.$queryRaw< + { + id: string; + friendlyId: string; + status: WaitpointStatus; + completedAt: Date | null; + completedAfter: Date | null; + outputIsError: boolean; + idempotencyKey: string; + idempotencyKeyExpiresAt: Date | null; + inactiveIdempotencyKey: string | null; + userProvidedIdempotencyKey: boolean; + createdAt: Date; + tags: null | string[]; + }[] + >` + SELECT + w.id, + w."friendlyId", + w.status, + w."completedAt", + w."completedAfter", + w."outputIsError", + w."idempotencyKey", + w."idempotencyKeyExpiresAt", + w."inactiveIdempotencyKey", + w."userProvidedIdempotencyKey", + w."tags", + w."createdAt" + FROM + ${sqlDatabaseSchema}."Waitpoint" w + WHERE + w."environmentId" = ${environment.id} + AND w.type = 'MANUAL' + -- cursor + ${ + cursor + ? direction === "forward" + ? Prisma.sql`AND w.id < ${cursor}` + : Prisma.sql`AND w.id > ${cursor}` + : Prisma.empty + } + -- filters + ${id ? Prisma.sql`AND w."friendlyId" = ${id}` : Prisma.empty} + ${ + statusesToFilter && statusesToFilter.length > 0 + ? Prisma.sql`AND w.status = ANY(ARRAY[${Prisma.join( + statusesToFilter + )}]::"WaitpointStatus"[])` + : Prisma.empty + } + ${ + filterOutputIsError !== undefined + ? Prisma.sql`AND w."outputIsError" = ${filterOutputIsError}` + : Prisma.empty + } + ${ + idempotencyKey + ? Prisma.sql`AND (w."idempotencyKey" = ${idempotencyKey} OR w."inactiveIdempotencyKey" = ${idempotencyKey})` + : Prisma.empty + } + ${ + periodMs + ? Prisma.sql`AND w."createdAt" >= NOW() - INTERVAL '1 millisecond' * ${periodMs}` + : Prisma.empty + } + ${ + from + ? Prisma.sql`AND w."createdAt" >= ${new Date(from).toISOString()}::timestamp` + : Prisma.empty + } + ${ + to + ? Prisma.sql`AND w."createdAt" <= ${new Date(to).toISOString()}::timestamp` + : Prisma.empty + } + ${ + tags && tags.length > 0 + ? Prisma.sql`AND w."tags" && ARRAY[${Prisma.join(tags)}]::text[]` + : Prisma.empty + } + ORDER BY + ${direction === "forward" ? Prisma.sql`w.id DESC` : Prisma.sql`w.id ASC`} + LIMIT ${pageSize + 1}`; + + const hasMore = tokens.length > pageSize; + + //get cursors for next and previous pages + let next: string | undefined; + let previous: string | undefined; + switch (direction) { + case "forward": + previous = cursor ? tokens.at(0)?.id : undefined; + if (hasMore) { + next = tokens[pageSize - 1]?.id; + } + break; + case "backward": + tokens.reverse(); + if (hasMore) { + previous = tokens[1]?.id; + next = tokens[pageSize]?.id; + } else { + next = tokens[pageSize - 1]?.id; + } + break; + } + + const tokensToReturn = + direction === "backward" && hasMore + ? tokens.slice(1, pageSize + 1) + : tokens.slice(0, pageSize); + + return { + success: true, + tokens: tokensToReturn.map((token) => ({ + id: token.friendlyId, + status: waitpointStatusToApiStatus(token.status, token.outputIsError), + completedAt: token.completedAt ?? undefined, + timeoutAt: token.completedAfter ?? undefined, + completedAfter: token.completedAfter ?? undefined, + idempotencyKey: token.userProvidedIdempotencyKey + ? token.inactiveIdempotencyKey ?? token.idempotencyKey + : undefined, + idempotencyKeyExpiresAt: token.idempotencyKeyExpiresAt ?? undefined, + tags: token.tags ? token.tags.sort((a, b) => a.localeCompare(b)) : [], + createdAt: token.createdAt, + })), + pagination: { + next, + previous, + }, + hasFilters, + filters: { + id, + statuses: statuses?.length ? statuses : undefined, + tags: tags?.length ? tags : undefined, + idempotencyKey, + period, + from, + to, + cursor, + direction, + }, + }; + } +} + +export function waitpointStatusToApiStatus( + status: WaitpointStatus, + outputIsError: boolean +): WaitpointTokenStatus { + switch (status) { + case "PENDING": + return "WAITING"; + case "COMPLETED": + return outputIsError ? "TIMED_OUT" : "COMPLETED"; + } +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx index 20de5e8cb5..9158a2f084 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx @@ -134,7 +134,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { from, to, batchId, - runId, + runIds: runId ? [runId] : undefined, scheduleId, rootOnly, direction: direction, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens.$waitpointParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens.$waitpointParam/route.tsx new file mode 100644 index 0000000000..fc02aeebe9 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens.$waitpointParam/route.tsx @@ -0,0 +1,140 @@ +import { useLocation } from "@remix-run/react"; +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { ExitIcon } from "~/assets/icons/ExitIcon"; +import { LinkButton } from "~/components/primitives/Buttons"; +import { Header2, Header3 } from "~/components/primitives/Headers"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { WaitpointPresenter } from "~/presenters/v3/WaitpointPresenter.server"; +import { requireUserId } from "~/services/session.server"; +import { cn } from "~/utils/cn"; +import { EnvironmentParamSchema, v3WaitpointTokensPath } from "~/utils/pathBuilder"; +import { CompleteWaitpointForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route"; +import { WaitpointDetailTable } from "~/components/runs/v3/WaitpointDetails"; +import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable"; +import { InfoIconTooltip } from "~/components/primitives/Tooltip"; +import { logger } from "~/services/logger.server"; + +const Params = EnvironmentParamSchema.extend({ + waitpointParam: z.string(), +}); + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { organizationSlug, projectParam, envParam, waitpointParam } = Params.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response(undefined, { + status: 404, + statusText: "Project not found", + }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response(undefined, { + status: 404, + statusText: "Environment not found", + }); + } + + try { + const presenter = new WaitpointPresenter(); + const result = await presenter.call({ + friendlyId: waitpointParam, + environmentId: environment.id, + projectId: project.id, + }); + + if (!result) { + throw new Response(undefined, { + status: 404, + statusText: "Waitpoint not found", + }); + } + + return typedjson({ waitpoint: result }); + } catch (error) { + logger.error("Error loading waitpoint for inspector", { + error, + organizationSlug, + projectParam, + envParam, + waitpointParam, + }); + throw new Response(undefined, { + status: 400, + statusText: "Something went wrong, if this problem persists please contact support.", + }); + } +}; + +export default function Page() { + const { waitpoint } = useTypedLoaderData(); + + const location = useLocation(); + + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + return ( +
+
+ {waitpoint.id} + +
+
+
+ +
+
+
+ 5 related runs + +
+ +
+
+ {waitpoint.status === "WAITING" && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx new file mode 100644 index 0000000000..ad3cccab3f --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx @@ -0,0 +1,247 @@ +import upgradeForWaitpointsPath from "~/assets/images/waitpoints-dashboard.png"; +import { BookOpenIcon } from "@heroicons/react/20/solid"; +import { Outlet, useParams, type MetaFunction } from "@remix-run/react"; +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; +import { NoWaitpointTokens } from "~/components/BlankStatePanels"; +import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { ListPagination } from "~/components/ListPagination"; +import { LinkButton } from "~/components/primitives/Buttons"; +import { DateTime } from "~/components/primitives/DateTime"; +import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "~/components/primitives/Resizable"; +import { + Table, + TableBody, + TableCell, + TableHeader, + TableHeaderCell, + TableRow, +} from "~/components/primitives/Table"; +import { SimpleTooltip } from "~/components/primitives/Tooltip"; +import { RunTag } from "~/components/runs/v3/RunTag"; +import { WaitpointStatusCombo } from "~/components/runs/v3/WaitpointStatus"; +import { + WaitpointSearchParamsSchema, + WaitpointTokenFilters, +} from "~/components/runs/v3/WaitpointTokenFilters"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { WaitpointTokenListPresenter } from "~/presenters/v3/WaitpointTokenListPresenter.server"; +import { requireUserId } from "~/services/session.server"; +import { docsPath, EnvironmentParamSchema, v3WaitpointTokenPath } from "~/utils/pathBuilder"; +import { determineEngineVersion } from "~/v3/engineVersion.server"; +import { CopyableText } from "~/components/primitives/CopyableText"; + +export const meta: MetaFunction = () => { + return [ + { + title: `Waitpoint tokens | Trigger.dev`, + }, + ]; +}; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + + const url = new URL(request.url); + const s = { + id: url.searchParams.get("id") ?? undefined, + statuses: url.searchParams.getAll("statuses"), + idempotencyKey: url.searchParams.get("idempotencyKey") ?? undefined, + tags: url.searchParams.getAll("tags"), + period: url.searchParams.get("period") ?? undefined, + from: url.searchParams.get("from") ?? undefined, + to: url.searchParams.get("to") ?? undefined, + cursor: url.searchParams.get("cursor") ?? undefined, + direction: url.searchParams.get("direction") ?? undefined, + }; + + const searchParams = WaitpointSearchParamsSchema.parse(s); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response(undefined, { + status: 404, + statusText: "Project not found", + }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response(undefined, { + status: 404, + statusText: "Environment not found", + }); + } + + try { + const presenter = new WaitpointTokenListPresenter(); + const result = await presenter.call({ + environment, + ...searchParams, + }); + + return typedjson(result); + } catch (error) { + console.error(error); + throw new Response(undefined, { + status: 400, + statusText: "Something went wrong, if this problem persists please contact support.", + }); + } +}; + +export default function Page() { + const { success, tokens, pagination, hasFilters, filters } = useTypedLoaderData(); + + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + const { waitpointParam } = useParams(); + const isShowingWaitpoint = !!waitpointParam; + + return ( + + + + + + + Waitpoints docs + + + + + {!hasFilters && tokens.length === 0 ? ( + + + + ) : ( + + +
+
+ +
+ +
+
+
+ + + + Created + ID + Status + Completed + Idempotency Key + Tags + + + + {tokens.length > 0 ? ( + tokens.map((token) => { + const ttlExpired = + token.idempotencyKeyExpiresAt && + token.idempotencyKeyExpiresAt < new Date(); + + const path = v3WaitpointTokenPath( + organization, + project, + environment, + token, + filters + ); + + return ( + + + + + + + + + + + + + + {token.completedAt ? : "–"} + + + {token.idempotencyKey ? ( + token.idempotencyKeyExpiresAt ? ( + + + {ttlExpired ? ( + (expired) + ) : null} + + } + buttonClassName={ttlExpired ? "opacity-50" : undefined} + button={token.idempotencyKey} + /> + ) : ( + token.idempotencyKey + ) + ) : ( + "–" + )} + + +
+ {token.tags.map((tag) => ) || "–"} +
+
+
+ ); + }) + ) : ( + + +
+ No waitpoint tokens found +
+
+
+ )} +
+
+ + {(pagination.next || pagination.previous) && ( +
+ +
+ )} +
+
+
+ {isShowingWaitpoint && ( + <> + + + + + + )} +
+ )} +
+
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam/route.tsx index b21b53faf3..aea6b345ee 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam/route.tsx @@ -7,18 +7,8 @@ import { useEnvironment } from "~/hooks/useEnvironment"; import { useIsImpersonating, useOrganization, useOrganizations } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { useUser } from "~/hooks/useUser"; -import { type Handle } from "~/utils/handle"; import { v3ProjectPath } from "~/utils/pathBuilder"; -export const handle: Handle = { - scripts: () => [ - { - src: "https://cdn.jsdelivr.net/npm/canvas-confetti@1.5.1/dist/confetti.browser.min.js", - crossOrigin: "anonymous", - }, - ], -}; - export default function Project() { const organizations = useOrganizations(); const organization = useOrganization(); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings._index/route.tsx index 3dc94f8780..e4c3967a36 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings._index/route.tsx @@ -374,7 +374,7 @@ export default function Page() { ); } -function LogoForm({ organization }: { organization: { avatar: Avatar } }) { +function LogoForm({ organization }: { organization: { avatar: Avatar; title: string } }) { const navigation = useNavigation(); const isSubmitting = @@ -392,7 +392,7 @@ function LogoForm({ organization }: { organization: { avatar: Avatar } }) {
- +
{/* Letters */}
@@ -416,8 +416,9 @@ function LogoForm({ organization }: { organization: { avatar: Avatar } }) { type: "letters", hex, }} - className="size-10" + size={2.5} includePadding + orgName={organization.title} />
@@ -447,8 +448,9 @@ function LogoForm({ organization }: { organization: { avatar: Avatar } }) { name, hex, }} - className="size-10" + size={2.5} includePadding + orgName={organization.title} /> diff --git a/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.complete.ts b/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.complete.ts index ace2e80cf6..1a13324f60 100644 --- a/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.complete.ts +++ b/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.complete.ts @@ -1,7 +1,7 @@ import { json } from "@remix-run/server-runtime"; import { CompleteWaitpointTokenRequestBody, - CompleteWaitpointTokenResponseBody, + type CompleteWaitpointTokenResponseBody, conditionallyExportPacket, stringifyIO, } from "@trigger.dev/core/v3"; diff --git a/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.ts b/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.ts new file mode 100644 index 0000000000..be91b35b08 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.ts @@ -0,0 +1,23 @@ +import { json } from "@remix-run/server-runtime"; +import { type WaitpointRetrieveTokenResponse } from "@trigger.dev/core/v3"; +import { WaitpointId } from "@trigger.dev/core/v3/isomorphic"; +import { z } from "zod"; +import { ApiWaitpointPresenter } from "~/presenters/v3/ApiWaitpointPresenter.server"; +import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; + +export const loader = createLoaderApiRoute( + { + params: z.object({ + waitpointFriendlyId: z.string(), + }), + findResource: async () => 1, // This is a dummy function, we don't need to find a resource + }, + async ({ params, authentication }) => { + const presenter = new ApiWaitpointPresenter(); + const result: WaitpointRetrieveTokenResponse = await presenter.call( + authentication.environment, + WaitpointId.toId(params.waitpointFriendlyId) + ); + return json(result); + } +); diff --git a/apps/webapp/app/routes/api.v1.waitpoints.tokens.ts b/apps/webapp/app/routes/api.v1.waitpoints.tokens.ts index 23da42ca0b..296c006f45 100644 --- a/apps/webapp/app/routes/api.v1.waitpoints.tokens.ts +++ b/apps/webapp/app/routes/api.v1.waitpoints.tokens.ts @@ -1,13 +1,35 @@ import { json } from "@remix-run/server-runtime"; import { CreateWaitpointTokenRequestBody, - CreateWaitpointTokenResponseBody, + type CreateWaitpointTokenResponseBody, } from "@trigger.dev/core/v3"; import { WaitpointId } from "@trigger.dev/core/v3/isomorphic"; -import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { createWaitpointTag, MAX_TAGS_PER_WAITPOINT } from "~/models/waitpointTag.server"; +import { + ApiWaitpointTokenListPresenter, + ApiWaitpointTokenListSearchParams, +} from "~/presenters/v3/ApiWaitpointTokenListPresenter.server"; +import { + createActionApiRoute, + createLoaderApiRoute, +} from "~/services/routeBuilders/apiBuilder.server"; import { parseDelay } from "~/utils/delays"; import { resolveIdempotencyKeyTTL } from "~/utils/idempotencyKeys.server"; import { engine } from "~/v3/runEngine.server"; +import { ServiceValidationError } from "~/v3/services/baseService.server"; + +export const loader = createLoaderApiRoute( + { + searchParams: ApiWaitpointTokenListSearchParams, + findResource: async () => 1, // This is a dummy function, we don't need to find a resource + }, + async ({ searchParams, authentication }) => { + const presenter = new ApiWaitpointTokenListPresenter(); + const result = await presenter.call(authentication.environment, searchParams); + + return json(result); + } +); const { action } = createActionApiRoute( { @@ -16,27 +38,61 @@ const { action } = createActionApiRoute( method: "POST", }, async ({ authentication, body }) => { - const idempotencyKeyExpiresAt = body.idempotencyKeyTTL - ? resolveIdempotencyKeyTTL(body.idempotencyKeyTTL) - : undefined; - - const timeout = await parseDelay(body.timeout); - - const result = await engine.createManualWaitpoint({ - environmentId: authentication.environment.id, - projectId: authentication.environment.projectId, - idempotencyKey: body.idempotencyKey, - idempotencyKeyExpiresAt, - timeout, - }); - - return json( - { - id: WaitpointId.toFriendlyId(result.waitpoint.id), - isCached: result.isCached, - }, - { status: 200 } - ); + try { + const idempotencyKeyExpiresAt = body.idempotencyKeyTTL + ? resolveIdempotencyKeyTTL(body.idempotencyKeyTTL) + : undefined; + + const timeout = await parseDelay(body.timeout); + + //upsert tags + let tags: { id: string; name: string }[] = []; + const bodyTags = typeof body.tags === "string" ? [body.tags] : body.tags; + + if (bodyTags && bodyTags.length > MAX_TAGS_PER_WAITPOINT) { + throw new ServiceValidationError( + `Waitpoints can only have ${MAX_TAGS_PER_WAITPOINT} tags, you're trying to set ${bodyTags.length}.` + ); + } + + if (bodyTags && bodyTags.length > 0) { + for (const tag of bodyTags) { + const tagRecord = await createWaitpointTag({ + tag, + environmentId: authentication.environment.id, + projectId: authentication.environment.projectId, + }); + if (tagRecord) { + tags.push(tagRecord); + } + } + } + + const result = await engine.createManualWaitpoint({ + environmentId: authentication.environment.id, + projectId: authentication.environment.projectId, + idempotencyKey: body.idempotencyKey, + idempotencyKeyExpiresAt, + timeout, + tags: bodyTags, + }); + + return json( + { + id: WaitpointId.toFriendlyId(result.waitpoint.id), + isCached: result.isCached, + }, + { status: 200 } + ); + } catch (error) { + if (error instanceof ServiceValidationError) { + return json({ error: error.message }, { status: 422 }); + } else if (error instanceof Error) { + return json({ error: error.message }, { status: 500 }); + } + + return json({ error: "Something went wrong" }, { status: 500 }); + } } ); diff --git a/apps/webapp/app/routes/engine.v1.runs.$runFriendlyId.waitpoints.tokens.$waitpointFriendlyId.wait.ts b/apps/webapp/app/routes/engine.v1.runs.$runFriendlyId.waitpoints.tokens.$waitpointFriendlyId.wait.ts index e9bd27d693..1b33ae0807 100644 --- a/apps/webapp/app/routes/engine.v1.runs.$runFriendlyId.waitpoints.tokens.$waitpointFriendlyId.wait.ts +++ b/apps/webapp/app/routes/engine.v1.runs.$runFriendlyId.waitpoints.tokens.$waitpointFriendlyId.wait.ts @@ -1,5 +1,5 @@ import { json } from "@remix-run/server-runtime"; -import { WaitForWaitpointTokenResponseBody } from "@trigger.dev/core/v3"; +import { type WaitForWaitpointTokenResponseBody } from "@trigger.dev/core/v3"; import { RunId, WaitpointId } from "@trigger.dev/core/v3/isomorphic"; import { z } from "zod"; import { $replica } from "~/db.server"; @@ -13,6 +13,9 @@ const { action } = createActionApiRoute( runFriendlyId: z.string(), waitpointFriendlyId: z.string(), }), + body: z.object({ + releaseConcurrency: z.boolean().optional(), + }), maxContentLength: 1024 * 10, // 10KB method: "POST", }, @@ -34,12 +37,12 @@ const { action } = createActionApiRoute( throw json({ error: "Waitpoint not found" }, { status: 404 }); } - // TODO: Add releaseConcurrency from the body const result = await engine.blockRunWithWaitpoint({ runId, waitpoints: [waitpointId], projectId: authentication.environment.project.id, organizationId: authentication.environment.organization.id, + releaseConcurrency: body.releaseConcurrency, }); return json( diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx index 046596e819..eee458ab4b 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx @@ -73,6 +73,9 @@ import { ForceTimeout, } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route"; import { useEnvironment } from "~/hooks/useEnvironment"; +import { WaitpointStatusCombo } from "~/components/runs/v3/WaitpointStatus"; +import { PacketDisplay } from "~/components/runs/v3/PacketDisplay"; +import { WaitpointDetailTable } from "~/components/runs/v3/WaitpointDetails"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const { projectParam, organizationSlug, envParam, runParam, spanParam } = @@ -618,16 +621,11 @@ function RunBody({ ) : (
{run.tags.map((tag: string) => ( - - - - } - content={`Filter runs by ${tag}`} + tag={tag} + to={v3RunsPath(organization, project, environment, { tags: [tag] })} + tooltip={`Filter runs by ${tag}`} /> ))}
@@ -833,53 +831,6 @@ function RunError({ error }: { error: TaskRunError }) { } } -function PacketDisplay({ - data, - dataType, - title, -}: { - data: string; - dataType: string; - title: string; -}) { - switch (dataType) { - case "application/store": { - return ( -
- - {title} - - - Download - -
- ); - } - case "text/plain": { - return ( - - ); - } - default: { - return ( - - ); - } - } -} - function SpanEntity({ span }: { span: Span }) { const isAdmin = useHasAdminAccess(); @@ -999,90 +950,10 @@ function SpanEntity({ span }: { span: Span }) { View docs.
- - - Status - - - - - - ID - - {span.entity.object.friendlyId} - - - - Idempotency key - -
-
- {span.entity.object.userProvidedIdempotencyKey - ? span.entity.object.idempotencyKey - : "–"} -
-
- {span.entity.object.idempotencyKeyExpiresAt ? ( - <> - TTL: - - ) : null} -
-
-
-
- {span.entity.object.type === "MANUAL" && ( - <> - - Timeout at - -
- {span.entity.object.completedAfter ? ( - - ) : ( - "–" - )} - {span.entity.object.status === "PENDING" && ( - - )} -
-
-
- - )} - {span.entity.object.status === "PENDING" ? null : span.entity.object.isTimeout ? ( - <> - ) : span.entity.object.output ? ( - - ) : span.entity.object.completedAfter ? ( - - Completed at - - - - - ) : ( - "Completed with no output" - )} -
+
- {span.entity.object.status === "PENDING" && ( -
+ {span.entity.object.status === "WAITING" && ( +
)} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route.tsx index 816eeb0d06..2544b2ea52 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route.tsx @@ -7,6 +7,7 @@ import { IOPacket, stringifyIO, timeoutError, + WaitpointTokenStatus, } from "@trigger.dev/core/v3"; import { WaitpointId } from "@trigger.dev/core/v3/isomorphic"; import type { Waitpoint } from "@trigger.dev/database"; @@ -189,7 +190,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { } }; -type FormWaitpoint = Pick; +type FormWaitpoint = Pick & { + status: WaitpointTokenStatus; +}; export function CompleteWaitpointForm({ waitpoint }: { waitpoint: FormWaitpoint }) { return ( @@ -198,7 +201,7 @@ export function CompleteWaitpointForm({ waitpoint }: { waitpoint: FormWaitpoint waitpoint.completedAfter ? ( @@ -281,7 +284,7 @@ function CompleteDateTimeWaitpointForm({ ); } -function CompleteManualWaitpointForm({ waitpoint }: { waitpoint: { friendlyId: string } }) { +function CompleteManualWaitpointForm({ waitpoint }: { waitpoint: { id: string } }) { const location = useLocation(); const navigation = useNavigation(); const submit = useSubmit(); @@ -291,7 +294,7 @@ function CompleteManualWaitpointForm({ waitpoint }: { waitpoint: { friendlyId: s const environment = useEnvironment(); const currentJson = useRef("{\n\n}"); - const formAction = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/waitpoints/${waitpoint.friendlyId}/complete`; + const formAction = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/waitpoints/${waitpoint.id}/complete`; const submitForm = useCallback( (e: React.FormEvent) => { @@ -374,7 +377,7 @@ function CompleteManualWaitpointForm({ waitpoint }: { waitpoint: { friendlyId: s ); } -export function ForceTimeout({ waitpoint }: { waitpoint: { friendlyId: string } }) { +export function ForceTimeout({ waitpoint }: { waitpoint: { id: string } }) { const location = useLocation(); const navigation = useNavigation(); const isLoading = navigation.state !== "idle"; @@ -382,7 +385,7 @@ export function ForceTimeout({ waitpoint }: { waitpoint: { friendlyId: string } const project = useProject(); const environment = useEnvironment(); - const formAction = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/waitpoints/${waitpoint.friendlyId}/complete`; + const formAction = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/waitpoints/${waitpoint.id}/complete`; return (
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tags.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tags.ts new file mode 100644 index 0000000000..765c711404 --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tags.ts @@ -0,0 +1,37 @@ +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { WaitpointTagListPresenter } from "~/presenters/v3/WaitpointTagListPresenter.server"; +import { requireUserId } from "~/services/session.server"; + +const Params = z.object({ + organizationSlug: z.string(), + projectParam: z.string(), + envParam: z.string(), +}); + +export async function loader({ request, params }: LoaderFunctionArgs) { + const userId = await requireUserId(request); + const { organizationSlug, projectParam, envParam } = Params.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response("Not Found", { status: 404 }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response("Not Found", { status: 404 }); + } + + const search = new URL(request.url).searchParams; + const name = search.get("name"); + + const presenter = new WaitpointTagListPresenter(); + const result = await presenter.call({ + environmentId: environment.id, + names: name ? [decodeURIComponent(name)] : undefined, + }); + return result; +} diff --git a/apps/webapp/app/routes/storybook.avatar/route.tsx b/apps/webapp/app/routes/storybook.avatar/route.tsx index 0f5fed5de8..a6f80cc9e8 100644 --- a/apps/webapp/app/routes/storybook.avatar/route.tsx +++ b/apps/webapp/app/routes/storybook.avatar/route.tsx @@ -20,7 +20,7 @@ export default function Story() {

Size 8

{avatars.map((avatar, index) => ( - + ))}
@@ -30,7 +30,7 @@ export default function Story() {

Size 12

{avatars.map((avatar, index) => ( - + ))}
diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index b46c3cac13..76c440c823 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -4,7 +4,7 @@ import { type TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters"; import type { Organization } from "~/models/organization.server"; import type { Project } from "~/models/project.server"; import { objectToSearchParams } from "./searchParams"; - +import { type WaitpointSearchParams } from "~/components/runs/v3/WaitpointTokenFilters"; export type OrgForPath = Pick; export type ProjectForPath = Pick; export type EnvironmentForPath = Pick; @@ -311,6 +311,29 @@ export function v3QueuesPath( return `${v3EnvironmentPath(organization, project, environment)}/queues`; } +export function v3WaitpointTokensPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath, + filters?: WaitpointSearchParams +) { + const searchParams = objectToSearchParams(filters); + const query = searchParams ? `?${searchParams.toString()}` : ""; + return `${v3EnvironmentPath(organization, project, environment)}/waitpoints/tokens${query}`; +} + +export function v3WaitpointTokenPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath, + token: { id: string }, + filters?: WaitpointSearchParams +) { + const searchParams = objectToSearchParams(filters); + const query = searchParams ? `?${searchParams.toString()}` : ""; + return `${v3WaitpointTokensPath(organization, project, environment)}/${token.id}${query}`; +} + export function v3BatchesPath( organization: OrgForPath, project: ProjectForPath, diff --git a/apps/webapp/app/utils/zod.ts b/apps/webapp/app/utils/zod.ts new file mode 100644 index 0000000000..950b2ddc25 --- /dev/null +++ b/apps/webapp/app/utils/zod.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +export const CoercedDate = z.preprocess((arg) => { + if (arg === undefined || arg === null) { + return; + } + + if (typeof arg === "number") { + return new Date(arg); + } + + if (typeof arg === "string") { + const num = Number(arg); + if (!isNaN(num)) { + return new Date(num); + } + + return new Date(arg); + } + + return arg; +}, z.date().optional()); diff --git a/apps/webapp/public/images/logo-banner.png b/apps/webapp/public/images/logo-banner.png deleted file mode 100644 index 7782a1f493..0000000000 Binary files a/apps/webapp/public/images/logo-banner.png and /dev/null differ diff --git a/apps/webapp/public/images/readme/workflow-demo.gif b/apps/webapp/public/images/readme/workflow-demo.gif deleted file mode 100644 index aa2108b372..0000000000 Binary files a/apps/webapp/public/images/readme/workflow-demo.gif and /dev/null differ diff --git a/apps/webapp/public/images/templates/github-stars-template-bg.png b/apps/webapp/public/images/templates/github-stars-template-bg.png deleted file mode 100644 index cfa75999ab..0000000000 Binary files a/apps/webapp/public/images/templates/github-stars-template-bg.png and /dev/null differ diff --git a/apps/webapp/public/images/templates/resend-slack-template-bg.png b/apps/webapp/public/images/templates/resend-slack-template-bg.png deleted file mode 100644 index 9871e72e5d..0000000000 Binary files a/apps/webapp/public/images/templates/resend-slack-template-bg.png and /dev/null differ diff --git a/apps/webapp/public/images/templates/shopify-template-bg.png b/apps/webapp/public/images/templates/shopify-template-bg.png deleted file mode 100644 index 95e7104ad3..0000000000 Binary files a/apps/webapp/public/images/templates/shopify-template-bg.png and /dev/null differ diff --git a/internal-packages/database/prisma/migrations/20250320105905_waitpoint_add_indexes_for_dashboard/migration.sql b/internal-packages/database/prisma/migrations/20250320105905_waitpoint_add_indexes_for_dashboard/migration.sql new file mode 100644 index 0000000000..9a3cf94b46 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250320105905_waitpoint_add_indexes_for_dashboard/migration.sql @@ -0,0 +1,5 @@ +-- CreateIndex +CREATE INDEX "Waitpoint_environmentId_type_createdAt_idx" ON "Waitpoint" ("environmentId", "type", "createdAt" DESC); + +-- CreateIndex +CREATE INDEX "Waitpoint_environmentId_type_status_idx" ON "Waitpoint" ("environmentId", "type", "status"); \ No newline at end of file diff --git a/internal-packages/database/prisma/migrations/20250320152314_waitpoint_tags/migration.sql b/internal-packages/database/prisma/migrations/20250320152314_waitpoint_tags/migration.sql new file mode 100644 index 0000000000..1e4459cedb --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250320152314_waitpoint_tags/migration.sql @@ -0,0 +1,22 @@ +-- AlterTable +ALTER TABLE "Waitpoint" ADD COLUMN "waitpointTags" TEXT[]; + +-- CreateTable +CREATE TABLE "WaitpointTag" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "environmentId" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "WaitpointTag_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "WaitpointTag_environmentId_name_key" ON "WaitpointTag"("environmentId", "name"); + +-- AddForeignKey +ALTER TABLE "WaitpointTag" ADD CONSTRAINT "WaitpointTag_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "RuntimeEnvironment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WaitpointTag" ADD CONSTRAINT "WaitpointTag_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/internal-packages/database/prisma/migrations/20250320152806_waitpoint_renamed_tags/migration.sql b/internal-packages/database/prisma/migrations/20250320152806_waitpoint_renamed_tags/migration.sql new file mode 100644 index 0000000000..3bb088696b --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250320152806_waitpoint_renamed_tags/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - You are about to drop the column `waitpointTags` on the `Waitpoint` table. All the data in the column will be lost. + +*/ + +-- AlterTable +ALTER TABLE "Waitpoint" DROP COLUMN "waitpointTags", +ADD COLUMN "tags" TEXT[]; \ No newline at end of file diff --git a/internal-packages/database/prisma/migrations/20250325124348_added_connected_runs_to_waitpoints/migration.sql b/internal-packages/database/prisma/migrations/20250325124348_added_connected_runs_to_waitpoints/migration.sql new file mode 100644 index 0000000000..62a64e37e4 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250325124348_added_connected_runs_to_waitpoints/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE + "_WaitpointRunConnections" ("A" TEXT NOT NULL, "B" TEXT NOT NULL); + +-- CreateIndex +CREATE UNIQUE INDEX "_WaitpointRunConnections_AB_unique" ON "_WaitpointRunConnections" ("A", "B"); + +-- CreateIndex +CREATE INDEX "_WaitpointRunConnections_B_index" ON "_WaitpointRunConnections" ("B"); + +-- AddForeignKey +ALTER TABLE "_WaitpointRunConnections" ADD CONSTRAINT "_WaitpointRunConnections_A_fkey" FOREIGN KEY ("A") REFERENCES "TaskRun" ("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_WaitpointRunConnections" ADD CONSTRAINT "_WaitpointRunConnections_B_fkey" FOREIGN KEY ("B") REFERENCES "Waitpoint" ("id") ON DELETE CASCADE ON UPDATE CASCADE; \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 7073b1a823..afe7256fb5 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -441,6 +441,7 @@ model RuntimeEnvironment { waitpoints Waitpoint[] workerInstances WorkerInstance[] executionSnapshots TaskRunExecutionSnapshot[] + waitpointTags WaitpointTag[] @@unique([projectId, slug, orgMemberId]) @@unique([projectId, shortcode]) @@ -506,6 +507,7 @@ model Project { taskRunWaitpoints TaskRunWaitpoint[] taskRunCheckpoints TaskRunCheckpoint[] executionSnapshots TaskRunExecutionSnapshot[] + waitpointTags WaitpointTag[] } enum ProjectVersion { @@ -1792,11 +1794,14 @@ model TaskRun { oneTimeUseToken String? ///When this run is finished, the waitpoint will be marked as completed - associatedWaitpoint Waitpoint? + associatedWaitpoint Waitpoint? @relation("CompletingRun") ///If there are any blocked waitpoints, the run won't be executed blockedByWaitpoints TaskRunWaitpoint[] + /// All waitpoints that blocked this run at some point, used for display purposes + connectedWaitpoints Waitpoint[] @relation("WaitpointRunConnections") + /// Where the logs are stored taskEventStore String @default("taskEvent") @@ -2100,11 +2105,6 @@ model Waitpoint { /// If there's a user provided idempotency key, this is the time it expires at idempotencyKeyExpiresAt DateTime? - //todo - /// Will automatically deactivate the idempotencyKey when the waitpoint is completed - /// "Deactivating" means moving it to the inactiveIdempotencyKey field and generating a random new one for the main column - /// deactivateIdempotencyKeyWhenCompleted Boolean @default(false) - /// If an idempotencyKey is no longer active, we store it here and generate a new one for the idempotencyKey field. /// Clearing an idempotencyKey is useful for debounce or cancelling child runs. /// This is a workaround because Prisma doesn't support partial indexes. @@ -2112,7 +2112,7 @@ model Waitpoint { /// If it's a RUN type waitpoint, this is the associated run completedByTaskRunId String? @unique - completedByTaskRun TaskRun? @relation(fields: [completedByTaskRunId], references: [id], onDelete: SetNull) + completedByTaskRun TaskRun? @relation("CompletingRun", fields: [completedByTaskRunId], references: [id], onDelete: SetNull) /// If it's a DATETIME type waitpoint, this is the date. /// If it's a MANUAL waitpoint, this can be set as the `timeout`. @@ -2125,6 +2125,9 @@ model Waitpoint { /// The runs this waitpoint is blocking blockingTaskRuns TaskRunWaitpoint[] + /// All runs that have ever been blocked by this waitpoint, used for display purposes + connectedRuns TaskRun[] @relation("WaitpointRunConnections") + /// When a waitpoint is complete completedExecutionSnapshots TaskRunExecutionSnapshot[] @relation("completedWaitpoints") @@ -2142,8 +2145,18 @@ model Waitpoint { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + /// Denormized column that holds the raw tags + tags String[] + + /// Quickly find an idempotent waitpoint @@unique([environmentId, idempotencyKey]) + /// Quickly find a batch waitpoint @@index([completedByBatchId]) + /// Used on the Waitpoint dashboard pages + /// Time period filtering + @@index([environmentId, type, createdAt(sort: Desc)]) + /// Status filtering + @@index([environmentId, type, status]) } enum WaitpointType { @@ -2190,6 +2203,21 @@ model TaskRunWaitpoint { @@index([waitpointId]) } +model WaitpointTag { + id String @id @default(cuid()) + name String + + environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + environmentId String + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + createdAt DateTime @default(now()) + + @@unique([environmentId, name]) +} + model FeatureFlag { id String @id @default(cuid()) diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index dcd71db84d..7f1f44db6d 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -808,12 +808,14 @@ export class RunEngine { idempotencyKey, idempotencyKeyExpiresAt, timeout, + tags, }: { environmentId: string; projectId: string; idempotencyKey?: string; idempotencyKeyExpiresAt?: Date; timeout?: Date; + tags?: string[]; }): Promise<{ waitpoint: Waitpoint; isCached: boolean }> { return this.waitpointSystem.createManualWaitpoint({ environmentId, @@ -821,6 +823,7 @@ export class RunEngine { idempotencyKey, idempotencyKeyExpiresAt, timeout, + tags, }); } diff --git a/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts b/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts index 8d4fe32eab..e27de73a28 100644 --- a/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts @@ -245,12 +245,14 @@ export class WaitpointSystem { idempotencyKey, idempotencyKeyExpiresAt, timeout, + tags, }: { environmentId: string; projectId: string; idempotencyKey?: string; idempotencyKeyExpiresAt?: Date; timeout?: Date; + tags?: string[]; }): Promise<{ waitpoint: Waitpoint; isCached: boolean }> { const existingWaitpoint = idempotencyKey ? await this.$.prisma.waitpoint.findUnique({ @@ -286,40 +288,62 @@ export class WaitpointSystem { } } - const waitpoint = await this.$.prisma.waitpoint.upsert({ - where: { - environmentId_idempotencyKey: { - environmentId, - idempotencyKey: idempotencyKey ?? nanoid(24), - }, - }, - create: { - ...WaitpointId.generate(), - type: "MANUAL", - idempotencyKey: idempotencyKey ?? nanoid(24), - idempotencyKeyExpiresAt, - userProvidedIdempotencyKey: !!idempotencyKey, - environmentId, - projectId, - completedAfter: timeout, - }, - update: {}, - }); + const maxRetries = 5; + let attempts = 0; - //schedule the timeout - if (timeout) { - await this.$.worker.enqueue({ - id: `finishWaitpoint.${waitpoint.id}`, - job: "finishWaitpoint", - payload: { - waitpointId: waitpoint.id, - error: JSON.stringify(timeoutError(timeout)), - }, - availableAt: timeout, - }); + while (attempts < maxRetries) { + try { + const waitpoint = await this.$.prisma.waitpoint.upsert({ + where: { + environmentId_idempotencyKey: { + environmentId, + idempotencyKey: idempotencyKey ?? nanoid(24), + }, + }, + create: { + ...WaitpointId.generate(), + type: "MANUAL", + idempotencyKey: idempotencyKey ?? nanoid(24), + idempotencyKeyExpiresAt, + userProvidedIdempotencyKey: !!idempotencyKey, + environmentId, + projectId, + completedAfter: timeout, + tags, + }, + update: {}, + }); + + //schedule the timeout + if (timeout) { + await this.$.worker.enqueue({ + id: `finishWaitpoint.${waitpoint.id}`, + job: "finishWaitpoint", + payload: { + waitpointId: waitpoint.id, + error: JSON.stringify(timeoutError(timeout)), + }, + availableAt: timeout, + }); + } + + return { waitpoint, isCached: false }; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + // Handle unique constraint violation (conflict) + attempts++; + if (attempts >= maxRetries) { + throw new Error( + `Failed to create waitpoint after ${maxRetries} attempts due to conflicts.` + ); + } + } else { + throw error; // Re-throw other errors + } + } } - return { waitpoint, isCached: false }; + throw new Error(`Failed to create waitpoint after ${maxRetries} attempts due to conflicts.`); } /** @@ -373,6 +397,13 @@ export class WaitpointSystem { WHERE w.id IN (${Prisma.join($waitpoints)}) ON CONFLICT DO NOTHING RETURNING "waitpointId" + ), + connected_runs AS ( + INSERT INTO "_WaitpointRunConnections" ("A", "B") + SELECT ${runId}, w.id + FROM "Waitpoint" w + WHERE w.id IN (${Prisma.join($waitpoints)}) + ON CONFLICT DO NOTHING ) SELECT COUNT(*) as pending_count FROM inserted i diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index 668a5c34a6..94dc718a9a 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -38,11 +38,14 @@ import { WaitForDurationRequestBody, WaitForDurationResponseBody, WaitForWaitpointTokenResponseBody, + WaitpointRetrieveTokenResponse, + WaitpointTokenItem, } from "../schemas/index.js"; import { taskContext } from "../task-context-api.js"; import { AnyRunTypes, TriggerJwtOptions } from "../types/tasks.js"; import { AnyZodFetchOptions, + ApiPromise, ApiRequestOptions, CursorPagePromise, ZodFetchOptions, @@ -68,6 +71,7 @@ import { ImportEnvironmentVariablesParams, ListProjectRunsQueryParams, ListRunsQueryParams, + ListWaitpointTokensQueryParams, SubscribeToRunsQueryParams, UpdateEnvironmentVariableParams, } from "./types.js"; @@ -669,6 +673,41 @@ export class ApiClient { ); } + listWaitpointTokens( + params?: ListWaitpointTokensQueryParams, + requestOptions?: ZodFetchOptions + ): CursorPagePromise { + const searchParams = createSearchQueryForListWaitpointTokens(params); + + return zodfetchCursorPage( + WaitpointTokenItem, + `${this.baseUrl}/api/v1/waitpoints/tokens`, + { + query: searchParams, + limit: params?.limit, + after: params?.after, + before: params?.before, + }, + { + method: "GET", + headers: this.#getHeaders(false), + }, + mergeRequestOptions(this.defaultRequestOptions, requestOptions) + ); + } + + retrieveWaitpointToken(friendlyId: string, requestOptions?: ZodFetchOptions) { + return zodfetch( + WaitpointRetrieveTokenResponse, + `${this.baseUrl}/api/v1/waitpoints/tokens/${friendlyId}`, + { + method: "GET", + headers: this.#getHeaders(false), + }, + mergeRequestOptions(this.defaultRequestOptions, requestOptions) + ); + } + completeWaitpointToken( friendlyId: string, options: CompleteWaitpointTokenRequestBody, @@ -687,8 +726,15 @@ export class ApiClient { } waitForWaitpointToken( - runFriendlyId: string, - waitpointFriendlyId: string, + { + runFriendlyId, + waitpointFriendlyId, + releaseConcurrency, + }: { + runFriendlyId: string; + waitpointFriendlyId: string; + releaseConcurrency?: boolean; + }, requestOptions?: ZodFetchOptions ) { return zodfetch( @@ -697,6 +743,9 @@ export class ApiClient { { method: "POST", headers: this.#getHeaders(false), + body: JSON.stringify({ + releaseConcurrency, + }), }, mergeRequestOptions(this.defaultRequestOptions, requestOptions) ); @@ -1014,6 +1063,52 @@ function createSearchQueryForListRuns(query?: ListRunsQueryParams): URLSearchPar return searchParams; } +function createSearchQueryForListWaitpointTokens( + query?: ListWaitpointTokensQueryParams +): URLSearchParams { + const searchParams = new URLSearchParams(); + + if (query) { + if (query.status) { + searchParams.append( + "filter[status]", + Array.isArray(query.status) ? query.status.join(",") : query.status + ); + } + + if (query.idempotencyKey) { + searchParams.append("filter[idempotencyKey]", query.idempotencyKey); + } + + if (query.tags) { + searchParams.append( + "filter[tags]", + Array.isArray(query.tags) ? query.tags.join(",") : query.tags + ); + } + + if (query.period) { + searchParams.append("filter[createdAt][period]", query.period); + } + + if (query.from) { + searchParams.append( + "filter[createdAt][from]", + query.from instanceof Date ? query.from.getTime().toString() : query.from.toString() + ); + } + + if (query.to) { + searchParams.append( + "filter[createdAt][to]", + query.to instanceof Date ? query.to.getTime().toString() : query.to.toString() + ); + } + } + + return searchParams; +} + export function mergeRequestOptions( defaultOptions: AnyZodFetchOptions, options?: ApiRequestOptions diff --git a/packages/core/src/v3/apiClient/types.ts b/packages/core/src/v3/apiClient/types.ts index 45e2f3668f..8698d7f1ff 100644 --- a/packages/core/src/v3/apiClient/types.ts +++ b/packages/core/src/v3/apiClient/types.ts @@ -1,4 +1,4 @@ -import { RunStatus } from "../schemas/index.js"; +import { RunStatus, WaitpointTokenStatus } from "../schemas/index.js"; import { CursorPageParams } from "./pagination.js"; export interface ImportEnvironmentVariablesParams { @@ -42,3 +42,12 @@ export interface SubscribeToRunsQueryParams { tasks?: Array | string; tags?: Array | string; } + +export interface ListWaitpointTokensQueryParams extends CursorPageParams { + status?: Array | WaitpointTokenStatus; + idempotencyKey?: string; + tags?: Array | string; + period?: string; + from?: Date | number; + to?: Date | number; +} diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 49d9667874..6f071ffa6d 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -925,6 +925,20 @@ export const CreateWaitpointTokenRequestBody = z.object({ * You can pass a `Date` object, or a string in this format: "30s", "1m", "2h", "3d", "4w". */ timeout: TimePeriod.optional(), + /** + * Tags to attach to the waitpoint. Tags can be used to filter waitpoints in the dashboard. + * + * You can set up to 10 tags per waitpoint, they must be less than 128 characters each. + * + * We recommend prefixing tags with a namespace using an underscore or colon, like `user_1234567` or `org:9876543`. + * + * @example + * + * ```ts + * await wait.createToken({ tags: ["user:1234567", "org:9876543"] }); + * ``` + */ + tags: RunTags.optional(), }); export type CreateWaitpointTokenRequestBody = z.infer; @@ -934,6 +948,37 @@ export const CreateWaitpointTokenResponseBody = z.object({ }); export type CreateWaitpointTokenResponseBody = z.infer; +export const waitpointTokenStatuses = ["WAITING", "COMPLETED", "TIMED_OUT"] as const; +export const WaitpointTokenStatus = z.enum(waitpointTokenStatuses); +export type WaitpointTokenStatus = z.infer; + +export const WaitpointTokenItem = z.object({ + id: z.string(), + status: WaitpointTokenStatus, + completedAt: z.coerce.date().optional(), + completedAfter: z.coerce.date().optional(), + timeoutAt: z.coerce.date().optional(), + idempotencyKey: z.string().optional(), + idempotencyKeyExpiresAt: z.coerce.date().optional(), + tags: z.array(z.string()), + createdAt: z.coerce.date(), +}); +export type WaitpointTokenItem = z.infer; + +export const WaitpointListTokenItem = WaitpointTokenItem.omit({ + completedAfter: true, +}); +export type WaitpointListTokenItem = z.infer; + +export const WaitpointRetrieveTokenResponse = WaitpointListTokenItem.and( + z.object({ + output: z.string().optional(), + outputType: z.string().optional(), + outputIsError: z.boolean().optional(), + }) +); +export type WaitpointRetrieveTokenResponse = z.infer; + export const CompleteWaitpointTokenRequestBody = z.object({ data: z.any().nullish(), }); @@ -963,8 +1008,18 @@ export const WaitForDurationRequestBody = z.object({ */ idempotencyKeyTTL: z.string().optional(), + /** + * If set to true, this will cause the waitpoint to release the current run from the queue's concurrency. + * + * This is useful if you want to allow other runs to execute while the waiting + * + * @default false + */ releaseConcurrency: z.boolean().optional(), + /** + * The date that the waitpoint will complete. + */ date: z.coerce.date(), }); export type WaitForDurationRequestBody = z.infer; diff --git a/packages/trigger-sdk/src/v3/wait.ts b/packages/trigger-sdk/src/v3/wait.ts index 31c276e767..0fee01e13f 100644 --- a/packages/trigger-sdk/src/v3/wait.ts +++ b/packages/trigger-sdk/src/v3/wait.ts @@ -12,11 +12,42 @@ import { WaitpointTokenTypedResult, Prettify, taskContext, + ListWaitpointTokensQueryParams, + CursorPagePromise, + WaitpointTokenItem, + flattenAttributes, + WaitpointListTokenItem, + WaitpointTokenStatus, + WaitpointRetrieveTokenResponse, } from "@trigger.dev/core/v3"; import { tracer } from "./tracer.js"; import { conditionallyImportAndParsePacket } from "@trigger.dev/core/v3/utils/ioSerialization"; import { SpanStatusCode } from "@opentelemetry/api"; +/** + * This creates a waitpoint token. + * You can use this to pause a run until you complete the waitpoint (or it times out). + * + * @example + * + * ```ts + * const token = await wait.createToken({ + * idempotencyKey: `approve-document-${documentId}`, + * timeout: "24h", + * tags: [`document-${documentId}`], + * }); + * + * // Later, in a different part of your codebase, you can complete the waitpoint + * await wait.completeToken(token, { + * status: "approved", + * comment: "Looks good to me!", + * }); + * ``` + * + * @param options - The options for the waitpoint token. + * @param requestOptions - The request options for the waitpoint token. + * @returns The waitpoint token. + */ function createToken( options?: CreateWaitpointTokenRequestBody, requestOptions?: ApiRequestOptions @@ -36,6 +67,7 @@ function createToken( ? options.timeout : options.timeout.toISOString() : undefined, + tags: options?.tags, }, onResponseBody: (body: CreateWaitpointTokenResponseBody, span) => { span.setAttribute("id", body.id); @@ -48,8 +80,209 @@ function createToken( return apiClient.createWaitpointToken(options ?? {}, $requestOptions); } +/** + * Lists waitpoint tokens with optional filtering and pagination. + * You can iterate over all the items in the result using a for-await-of loop (you don't need to think about pagination). + * + * @example + * Basic usage: + * ```ts + * // List all tokens + * for await (const token of wait.listTokens()) { + * console.log("Token ID:", token.id); + * } + * ``` + * + * @example + * With filters: + * ```ts + * // List completed tokens from the last 24 hours with specific tags + * for await (const token of wait.listTokens({ + * status: "COMPLETED", + * period: "24h", + * tags: ["important", "approval"], + * limit: 50 + * })) { + * console.log("Token ID:", token.id); + * } + * ``` + * + * @param params - Optional query parameters for filtering and pagination + * @param params.status - Filter by token status + * @param params.idempotencyKey - Filter by idempotency key + * @param params.tags - Filter by tags + * @param params.period - Filter by time period (e.g. "24h", "7d") + * @param params.from - Filter by start date + * @param params.to - Filter by end date + * @param params.limit - Number of items per page + * @param params.after - Cursor for next page + * @param params.before - Cursor for previous page + * @param requestOptions - Additional API request options + * @returns Waitpoint tokens that can easily be iterated over using a for-await-of loop + */ +function listTokens( + params?: ListWaitpointTokensQueryParams, + requestOptions?: ApiRequestOptions +): CursorPagePromise { + const apiClient = apiClientManager.clientOrThrow(); + + const $requestOptions = mergeRequestOptions( + { + tracer, + name: "wait.listTokens()", + icon: "wait-token", + attributes: { + ...flattenAttributes(params as Record), + }, + }, + requestOptions + ); + + return apiClient.listWaitpointTokens(params, $requestOptions); +} + +/** + * A waitpoint token that has been retrieved. + * + * If the status is `WAITING`, this means the waitpoint is still pending. + * For `COMPLETED` the `output` will be the data you passed in when completing the waitpoint. + * For `TIMED_OUT` there will be an `error`. + */ +export type WaitpointRetrievedToken = { + id: string; + status: WaitpointTokenStatus; + completedAt?: Date; + timeoutAt?: Date; + idempotencyKey?: string; + idempotencyKeyExpiresAt?: Date; + tags: string[]; + createdAt: Date; + output?: T; + error?: Error; +}; + +/** + * Retrieves a waitpoint token by its ID. + * + * @example + * ```ts + * const token = await wait.retrieveToken("waitpoint_12345678910"); + * console.log("Token status:", token.status); + * console.log("Token tags:", token.tags); + * ``` + * + * @param token - The token to retrieve. + * This can be a string token ID or an object with an `id` property. + * @param requestOptions - Optional API request options. + * @returns The waitpoint token details, including the output or error if the waitpoint is completed or timed out. + */ +async function retrieveToken( + token: string | { id: string }, + requestOptions?: ApiRequestOptions +): Promise> { + const apiClient = apiClientManager.clientOrThrow(); + + const $tokenId = typeof token === "string" ? token : token.id; + + const $requestOptions = mergeRequestOptions( + { + tracer, + name: "wait.retrieveToken()", + icon: "wait-token", + attributes: { + id: $tokenId, + ...accessoryAttributes({ + items: [ + { + text: $tokenId, + variant: "normal", + }, + ], + style: "codepath", + }), + }, + onResponseBody: (body: WaitpointRetrieveTokenResponse, span) => { + span.setAttribute("id", body.id); + span.setAttribute("status", body.status); + if (body.completedAt) { + span.setAttribute("completedAt", body.completedAt.toISOString()); + } + if (body.timeoutAt) { + span.setAttribute("timeoutAt", body.timeoutAt.toISOString()); + } + if (body.idempotencyKey) { + span.setAttribute("idempotencyKey", body.idempotencyKey); + } + if (body.idempotencyKeyExpiresAt) { + span.setAttribute("idempotencyKeyExpiresAt", body.idempotencyKeyExpiresAt.toISOString()); + } + span.setAttribute("tags", body.tags); + span.setAttribute("createdAt", body.createdAt.toISOString()); + }, + }, + requestOptions + ); + + const result = await apiClient.retrieveWaitpointToken($tokenId, $requestOptions); + + const data = result.output + ? await conditionallyImportAndParsePacket( + { data: result.output, dataType: result.outputType ?? "application/json" }, + apiClient + ) + : undefined; + + let error: Error | undefined = undefined; + let output: T | undefined = undefined; + + if (result.outputIsError) { + error = new WaitpointTimeoutError(data.message); + } else { + output = data as T; + } + + return { + id: result.id, + status: result.status, + completedAt: result.completedAt, + timeoutAt: result.timeoutAt, + idempotencyKey: result.idempotencyKey, + idempotencyKeyExpiresAt: result.idempotencyKeyExpiresAt, + tags: result.tags, + createdAt: result.createdAt, + output, + error, + }; +} + +/** + * This completes a waitpoint token. + * You can use this to complete a waitpoint token that you created earlier. + * + * @example + * + * ```ts + * await wait.completeToken(token, { + * status: "approved", + * comment: "Looks good to me!", + * }); + * ``` + * + * @param token - The token to complete. + * @param data - The data to complete the waitpoint with. + * @param requestOptions - The request options for the waitpoint token. + * @returns The waitpoint token. + */ async function completeToken( + /** + * The token to complete. + * This can be a string token ID or an object with an `id` property. + */ token: string | { id: string }, + /** + * The data to complete the waitpoint with. + * This will be returned when you wait for the token. + */ data: T, requestOptions?: ApiRequestOptions ) { @@ -220,9 +453,49 @@ export const wait = { ); }, createToken, + listTokens, completeToken, + retrieveToken, + /** + * This waits for a waitpoint token to be completed. + * It can only be used inside a task.run() block. + * + * @example + * + * ```ts + * const result = await wait.forToken(token); + * if (!result.ok) { + * // The waitpoint timed out + * throw result.error; + * } + * + * // This will be the type ApprovalData + * const approval = result.output; + * ``` + * + * @param token - The token to wait for. + * @param options - The options for the waitpoint token. + * @returns The waitpoint token. + */ forToken: async ( - token: string | { id: string } + /** + * The token to wait for. + * This can be a string token ID or an object with an `id` property. + */ + token: string | { id: string }, + /** + * The options for the waitpoint token. + */ + options?: { + /** + * If set to true, this will cause the waitpoint to release the current run from the queue's concurrency. + * + * This is useful if you want to allow other runs to execute while waiting + * + * @default false + */ + releaseConcurrency?: boolean; + } ): Promise>> => { const ctx = taskContext.ctx; @@ -237,7 +510,11 @@ export const wait = { return tracer.startActiveSpan( `wait.forToken()`, async (span) => { - const response = await apiClient.waitForWaitpointToken(ctx.run.id, tokenId); + const response = await apiClient.waitForWaitpointToken({ + runFriendlyId: ctx.run.id, + waitpointFriendlyId: tokenId, + releaseConcurrency: options?.releaseConcurrency, + }); if (!response.success) { throw new Error(`Failed to wait for wait token ${tokenId}`); diff --git a/references/hello-world/src/trigger/waits.ts b/references/hello-world/src/trigger/waits.ts index f3a84a7a52..0617d58a7d 100644 --- a/references/hello-world/src/trigger/waits.ts +++ b/references/hello-world/src/trigger/waits.ts @@ -12,12 +12,14 @@ export const waitToken = task({ idempotencyKeyTTL, completionDelay, timeout, + tags, }: { completeBeforeWaiting?: boolean; idempotencyKey?: string; idempotencyKeyTTL?: string; completionDelay?: number; timeout?: string; + tags?: string[]; }) => { logger.log("Hello, world", { completeBeforeWaiting }); @@ -25,6 +27,7 @@ export const waitToken = task({ idempotencyKey, idempotencyKeyTTL, timeout, + tags, }); logger.log("Token", token); @@ -32,6 +35,7 @@ export const waitToken = task({ idempotencyKey, idempotencyKeyTTL, timeout: "10s", + tags, }); logger.log("Token2", token2); @@ -42,13 +46,33 @@ export const waitToken = task({ await completeWaitToken.trigger({ token: token.id, delay: completionDelay }); } + const tokens = await wait.listTokens(); + await logger.trace("Tokens", async () => { + for await (const token of tokens) { + logger.log("Token", token); + } + }); + + const retrievedToken = await wait.retrieveToken(token.id); + logger.log("Retrieved token", retrievedToken); + //wait for the token - const result = await wait.forToken<{ foo: string }>(token); + const result = await wait.forToken<{ foo: string }>(token, { releaseConcurrency: true }); if (!result.ok) { logger.log("Token timeout", result); } else { logger.log("Token completed", result); } + + const tokens2 = await wait.listTokens({ tags, status: ["COMPLETED"] }); + await logger.trace("Tokens2", async () => { + for await (const token of tokens2) { + logger.log("Token2", token); + } + }); + + const retrievedToken2 = await wait.retrieveToken(token.id); + logger.log("Retrieved token2", retrievedToken2); }, });