Waitpoint tokens page, wait.listTokens() and wait.retrieveToken() (#1824)

* Added waitpoints/tokens to the sidebar

* Added indexes to the Waitpoint time for filtering

* Begun work on `WaitpointTokenListPresenter`, the pag is a copy of the Queues page for now

* MVP of waitpoint token page

* Added status

* Expiry of timeout/ttl

* Improvements to the waitpoint table

* Improved columns and icon

* Changes from the RunTag copy on hover branch

* Fix for nested button error

* Added waitpoint tags to the DB/table

* Applied Eric’s task run tag fix (it’s live on prod in the legacy run engine branch)

* Added tags to waitpoints

* Removed todos that have been done

* Added token support for releaseConcurrency. Also added a ton of JSDocs

* Added releaseConcurrency to the API token endpoint…

* WIP on waitpoint page filters

* Fix for tags filtering

* Waitpoint filters working

* Fix for badly named function

* WaitpointPresenter used from SpanPresenter

* Waitpoint detail panel WIP

* Fix for server client hydration issue with CodeBlock

* Selected waitpoint panel

* Added a blank state

* Added waitpoint docs link

* Fix for animated number going past the target

* Fix for the queue list pagination and upgrade status

* Engine version error for waitpoint token list

* RunTag component doesn’t get squished and hover behaviour is nicer

* Associating runs with waitpoints

* Added triggered icon

* Link directly to the waitpoint

* Fix for TS error on waitpoint retrieve

* Added CopyableText component, used for waitpoint id in the table

* Removed the confetti 🎊

* Deleted some old images

* Moved some schemas/types to core. Use `id` instead of `friendlyId`

* Added wait.listTokens() function. Made some changes to the types to make it nicer

* WIP wait.retrieveToken()

* wait.retrieveToken working

* Added data to retrieve token

* Separate ApiWaitpointPresenter completely

* Added completed time to the waitpoint detail panel

* Fix for the Avatar component having SSR issues. Specify the size in rems and removed the useLayoutEffect

* Fix for applied idempotency key filter dropdown showing the id field

* Use parentheses to make sure the token list query respects idempotency key correctly

* Use the proper logger, and have a decent message with info to track the bug down

* Pass the org title into the Avatar

* Better error when failing to creating a manual waitpoint after X attempts
This commit is contained in:
Matt Aitken
2025-03-26 18:05:26 +00:00
committed by GitHub
parent c08c3b488c
commit 00586ffaaf
63 changed files with 3275 additions and 486 deletions
+3
View File
@@ -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
@@ -0,0 +1,9 @@
import { cn } from "~/utils/cn";
export function StatusIcon({ className }: { className?: string }) {
return (
<div className={cn("grid place-items-center", className)}>
<div className="size-[75%] rounded-full border-2 border-text-dimmed" />
</div>
);
}
@@ -0,0 +1,5 @@
import { BoltIcon } from "@heroicons/react/20/solid";
export function TriggerIcon({ className }: { className?: string }) {
return <BoltIcon className={className} />;
}
@@ -0,0 +1,12 @@
export function WaitpointTokenIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.71193 3.50338C6.89005 3.1921 7.22123 3.00004 7.57987 3.00003L16.4201 3C16.7787 3 17.1099 3.19206 17.288 3.50334L21.8658 11.5034C22.0419 11.8111 22.0419 12.189 21.8658 12.4967L17.288 20.4967C17.1099 20.8079 16.7787 21 16.4201 21H7.57987C7.22123 21 6.89005 20.8079 6.71193 20.4967L2.1342 12.4967C1.95813 12.189 1.95813 11.8111 2.1342 11.5034L6.71193 3.50338ZM8.5 9.00011C8.5 8.44783 8.94771 8.00011 9.5 8.00011H10C10.5523 8.00011 11 8.44783 11 9.00011V15.0001C11 15.5524 10.5523 16.0001 10 16.0001H9.5C8.94771 16.0001 8.5 15.5524 8.5 15.0001V9.00011ZM14 8.00006C13.4477 8.00006 13 8.44777 13 9.00006V15.0001C13 15.5523 13.4477 16.0001 14 16.0001H14.5C15.0523 16.0001 15.5 15.5523 15.5 15.0001V9.00006C15.5 8.44777 15.0523 8.00006 14.5 8.00006H14Z"
fill="currentColor"
/>
</svg>
);
}
@@ -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 (
<InfoPanel
title="You don't have any waitpoint tokens"
icon={WaitpointTokenIcon}
iconClassName="text-sky-500"
panelClassName="max-w-md"
accessory={
<LinkButton to={docsPath("wait")} variant="docs/small" LeadingIcon={BookOpenIcon}>
Waitpoint docs
</LinkButton>
}
>
<Paragraph spacing variant="small">
Waitpoint tokens are used to pause runs until you complete the token so the run can
continue.
</Paragraph>
<Paragraph spacing variant="small">
You can build approval workflows using them, as well as other use cases.
</Paragraph>
</InfoPanel>
);
}
function SwitcherPanel() {
const organization = useOrganization();
const project = useProject();
+27 -1
View File
@@ -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 (
<div
dir="ltr"
className={cn(
"overflow-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600",
className
)}
>
<pre className={cn("relative mr-2 font-mono leading-relaxed", preClassName)}>{code}</pre>
</div>
);
}
return (
<Highlight theme={theme} code={code} language={language}>
{({
@@ -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<User, "email" | "admin"> & { isImpersonating: boolean };
export type SideMenuProject = Pick<
@@ -211,6 +213,15 @@ export function SideMenu({
/>
</div>
<SideMenuSection title="Waitpoints">
<SideMenuItem
name="Tokens"
icon={WaitpointTokenIcon}
activeIconColor="text-sky-500"
to={v3WaitpointTokensPath(organization, project, environment)}
/>
</SideMenuSection>
<SideMenuSection title="Manage">
<SideMenuItem
name="API keys"
@@ -293,7 +304,7 @@ function ProjectSelector({
)}
>
<span className="flex items-center gap-1.5 overflow-hidden">
<Avatar avatar={organization.avatar} className="size-5" />
<Avatar avatar={organization.avatar} size={1.25} orgName={organization.title} />
<SelectorDivider />
<span className="truncate text-2sm font-normal text-text-bright">
{project.name ?? "Select a project"}
@@ -308,7 +319,7 @@ function ProjectSelector({
<div className="flex flex-col gap-2 bg-charcoal-750 p-2">
<div className="flex items-center gap-2.5">
<div className="box-content size-10 overflow-clip rounded-sm bg-charcoal-800">
<Avatar avatar={organization.avatar} className="size-10" includePadding />
<Avatar avatar={organization.avatar} size={2.5} orgName={organization.title} />
</div>
<div className="space-y-0.5">
<Paragraph variant="small/bright">{organization.title}</Paragraph>
@@ -472,7 +483,7 @@ function SwitchOrganizations({
key={org.id}
to={organizationPath(org)}
title={org.title}
icon={<Avatar className="size-4" avatar={org.avatar} />}
icon={<Avatar size={1} avatar={org.avatar} orgName={org.title} />}
leadingIconClassName="text-text-dimmed"
isSelected={org.id === organization.id}
/>
@@ -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 <motion.span>{display}</motion.span>;
}
@@ -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 <AvatarIcon avatar={avatar} className={className} includePadding={includePadding} />;
return <AvatarIcon avatar={avatar} size={size} includePadding={includePadding} />;
case "letters":
return (
<AvatarLetters avatar={avatar} className={className} includePadding={includePadding} />
<AvatarLetters
avatar={avatar}
size={size}
includePadding={includePadding}
orgName={orgName}
/>
);
case "image":
return <AvatarImage avatar={avatar} className={className} />;
return <AvatarImage avatar={avatar} size={size} />;
}
}
@@ -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<HTMLSpanElement>(null);
const textRef = useRef<HTMLSpanElement>(null);
const [fontSize, setFontSize] = useState("1rem");
const letters = orgName.slice(0, 2);
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 style = {
backgroundColor: avatar.hex,
};
const scaleFactor = includePadding ? 0.8 : 1;
return (
<span className={cn("grid place-items-center overflow-hidden text-charcoal-750", classes)}>
<span
className="grid place-items-center overflow-hidden text-charcoal-750"
style={styleFromSize(size)}
>
{/* This is the square container */}
<span
ref={containerRef}
className={cn(
"relative grid place-items-center overflow-hidden rounded-[10%] font-semibold",
includePadding ? "size-[80%]" : "size-[100%]"
)}
style={style}
>
<span ref={textRef} className="font-bold leading-none" style={{ fontSize }}>
<span
className="font-bold leading-none"
style={{ fontSize: `${size * 0.6 * scaleFactor}rem` }}
>
{letters}
</span>
</span>
@@ -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 (
<span className={cn("grid place-items-center", classes)}>
<span className="grid aspect-square place-items-center" style={styleFromSize(size)}>
<IconComponent className={includePadding ? "size-[80%]" : "size-[100%]"} style={style} />
</span>
);
}
function AvatarImage({ avatar, className }: { avatar: ImageAvatar; className?: string }) {
function AvatarImage({ avatar, size }: { avatar: ImageAvatar; size: number }) {
return (
<span className="grid place-items-center">
<span className="grid place-items-center" style={styleFromSize(size)}>
<img src={avatar.url} alt="Organization avatar" className="size-6" />
</span>
);
@@ -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 (
<span
className={cn("group relative inline-flex h-6 items-center", className)}
onMouseLeave={() => setIsHovered(false)}
>
<span onMouseEnter={() => setIsHovered(true)}>{value}</span>
<span
onClick={copy}
onMouseDown={(e) => e.stopPropagation()}
className={cn(
"absolute -right-6 top-0 z-10 size-6 font-sans",
isHovered ? "flex" : "hidden"
)}
>
<SimpleTooltip
button={
<span
className={cn(
"ml-1 flex size-6 items-center justify-center rounded border border-charcoal-650 bg-charcoal-750",
copied
? "text-green-500"
: "text-text-dimmed hover:border-charcoal-600 hover:bg-charcoal-700 hover:text-text-bright"
)}
>
{copied ? (
<ClipboardCheckIcon className="size-3.5" />
) : (
<ClipboardIcon className="size-3.5" />
)}
</span>
}
content={copied ? "Copied!" : "Copy text"}
className="font-sans"
disableHoverableContent
/>
</span>
</span>
);
}
@@ -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 (
<div className="flex flex-col">
<Paragraph variant="base/bright" className="w-full border-b border-grid-dimmed py-2.5">
{title}
</Paragraph>
<LinkButton LeadingIcon={CloudArrowDownIcon} to={data} variant="tertiary/medium" download>
Download
</LinkButton>
</div>
);
}
case "text/plain": {
return (
<CodeBlock
language="markdown"
rowTitle={title}
code={data}
maxLines={20}
showLineNumbers={false}
/>
);
}
default: {
return (
<CodeBlock
language="json"
rowTitle={title}
code={data}
maxLines={20}
showLineNumbers={false}
/>
);
}
}
}
@@ -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: (
<div className="flex size-4 items-center justify-center">
<div className="size-3 rounded-full border-2 border-text-dimmed" />
</div>
),
icon: <StatusIcon className="size-4" />,
},
{ name: "tasks", title: "Tasks", icon: <TaskIcon className="size-4" /> },
{ name: "tags", title: "Tags", icon: <TagIcon className="size-4" /> },
@@ -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 <TagIcon className={cn(className, "text-text-dimmed")} />;
case "queue":
return <RectangleStackIcon className={cn(className, "text-purple-500")} />;
case "trigger":
return <TriggerIcon className={cn(className, "text-orange-500")} />;
//log levels
case "debug":
case "log":
+108 -35
View File
@@ -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 (
<span className="flex h-6 items-stretch">
<img src={tagLeftPath} alt="" className="block h-full w-[0.5625rem]" />
<span className="flex items-center rounded-r-sm border-y border-r border-charcoal-700 bg-charcoal-800 pr-1.5 text-text-dimmed">
{tag}
// Render the basic tag content
const renderTagContent = () => {
if (typeof tagResult === "string") {
return (
<>
<img src={tagLeftPath} alt="" className="block h-full w-[0.5625rem]" />
<span className="flex items-center rounded-r-sm border-y border-r border-charcoal-700 bg-charcoal-800 pr-1.5 text-text-dimmed group-hover:rounded-r-none group-has-[[href]]:group-hover:border-charcoal-650 group-has-[[href]]:group-hover:text-charcoal-300">
{tag}
</span>
</>
);
} else {
return (
<>
<img src={tagLeftPath} alt="" className="block h-full w-[0.5625rem]" />
<span className="flex items-center border-y border-r border-charcoal-700 bg-charcoal-800 pr-1.5 text-text-dimmed group-has-[[href]]:group-hover:border-charcoal-650 group-has-[[href]]:group-hover:text-charcoal-300">
{tagResult.key}
</span>
<span className="flex items-center whitespace-nowrap rounded-r-sm border-y border-r border-charcoal-700 bg-charcoal-750 px-1.5 text-text-dimmed group-hover:rounded-r-none group-has-[[href]]:group-hover:border-charcoal-650 group-has-[[href]]:group-hover:bg-charcoal-700 group-has-[[href]]:group-hover:text-charcoal-300">
{tagResult.value}
</span>
</>
);
}
};
// The main tag content, optionally wrapped in a Link and SimpleTooltip
const tagContent = to ? (
<SimpleTooltip
button={
<Link to={to} className="group shrink-0" onMouseEnter={() => setIsHovered(true)}>
<span className="flex h-6 items-stretch">{renderTagContent()}</span>
</Link>
}
content={tooltip || `Filter by ${tag}`}
disableHoverableContent
/>
) : (
<span className="flex h-6 shrink-0 items-stretch" onMouseEnter={() => setIsHovered(true)}>
{renderTagContent()}
</span>
);
return (
<div className="group relative inline-flex shrink-0" onMouseLeave={() => setIsHovered(false)}>
{tagContent}
<CopyButton textToCopy={tag} isHovered={isHovered} />
</div>
);
}
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 (
<SimpleTooltip
button={
<span
onClick={copy}
onMouseDown={(e) => 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 ? (
<ClipboardCheckIcon className="size-3.5" />
) : (
<ClipboardIcon className="size-3.5" />
)}
</span>
</span>
);
} else {
return (
<span className="flex h-6 items-stretch">
<img src={tagLeftPath} alt="" className="block h-full w-[0.5625rem]" />
<span className="flex items-center border-y border-r border-charcoal-700 bg-charcoal-800 pr-1.5 text-text-dimmed">
{tagResult.key}
</span>
<span className="flex items-center whitespace-nowrap rounded-r-sm border-y border-r border-charcoal-700 bg-charcoal-750 px-1.5 text-text-dimmed">
{tagResult.value}
</span>
</span>
);
}
}
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;
}
@@ -384,7 +384,7 @@ export function TaskRunsTable({
{run.delayUntil ? <DateTime date={run.delayUntil} /> : ""}
</TableCell>
<TableCell to={path}>{run.ttl ?? ""}</TableCell>
<TableCell to={path} actionClassName="py-1">
<TableCell to={path} actionClassName="py-1" className="pr-16">
<div className="flex gap-1">
{run.tags.map((tag) => <RunTag key={tag} tag={tag} />) || ""}
</div>
@@ -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 (
<Property.Table>
<Property.Item>
<Property.Label>Status</Property.Label>
<Property.Value>
<WaitpointStatusCombo status={waitpoint.status} className="text-sm" />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>ID</Property.Label>
<Property.Value className="whitespace-pre-wrap">
{linkToList ? (
<TextLink
to={v3WaitpointTokenPath(organization, project, environment, waitpoint, {
id: waitpoint.id,
})}
>
{waitpoint.id}
</TextLink>
) : (
waitpoint.id
)}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Idempotency key</Property.Label>
<Property.Value>
<div>
<div>
{waitpoint.userProvidedIdempotencyKey
? waitpoint.inactiveIdempotencyKey ?? waitpoint.idempotencyKey
: ""}
</div>
<div>
{waitpoint.idempotencyKeyExpiresAt ? (
<>
{hasExpired ? "Expired" : "Expires at"}:{" "}
<DateTime date={waitpoint.idempotencyKeyExpiresAt} />
</>
) : null}
</div>
</div>
</Property.Value>
</Property.Item>
{waitpoint.type === "MANUAL" && (
<>
<Property.Item>
<Property.Label>Timeout</Property.Label>
<Property.Value>
<div>
<div className="flex w-full flex-wrap items-center justify-between gap-1">
{waitpoint.completedAfter ? (
<>
<DateTimeAccurate date={waitpoint.completedAfter} />
</>
) : (
""
)}
{waitpoint.status === "WAITING" && <ForceTimeout waitpoint={waitpoint} />}
</div>
<Paragraph variant="extra-small" className="text-text-dimmed/70">
{waitpoint.status === "TIMED_OUT"
? "The waitpoint timed out"
: waitpoint.status === "COMPLETED"
? "The waitpoint completed before this timeout was reached"
: "The waitpoint is still waiting"}
</Paragraph>
</div>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Tags</Property.Label>
<Property.Value>
<div className="flex flex-wrap gap-1 pt-1 text-xs">
{waitpoint.tags.map((tag) => (
<RunTag
key={tag}
tag={tag}
to={v3WaitpointTokensPath(organization, project, environment, { tags: [tag] })}
/>
))}
</div>
</Property.Value>
</Property.Item>
</>
)}
<Property.Item>
<Property.Label>Completed</Property.Label>
<Property.Value>
{waitpoint.completedAt ? <DateTimeAccurate date={waitpoint.completedAt} /> : ""}
</Property.Value>
</Property.Item>
{waitpoint.status === "WAITING" ? null : waitpoint.status === "TIMED_OUT" ? (
<></>
) : waitpoint.output ? (
<PacketDisplay title="Output" data={waitpoint.output} dataType={waitpoint.outputType} />
) : waitpoint.completedAfter ? null : (
"Completed with no output"
)}
</Property.Table>
);
}
@@ -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 (
<span className={cn("flex items-center gap-1", className)}>
<WaitpointStatusIcon status={status} className={cn("h-4 w-4", iconClassName)} />
<WaitpointStatusLabel status={status} />
</span>
);
}
export function WaitpointStatusLabel({ status }: { status: WaitpointTokenStatus }) {
return (
<span className={waitpointStatusClassNameColor(status)}>{waitpointStatusTitle(status)}</span>
);
}
export function WaitpointStatusIcon({
status,
className,
}: {
status: WaitpointTokenStatus;
className: string;
}) {
switch (status) {
case "WAITING":
return <Spinner className={cn(waitpointStatusClassNameColor(status), className)} />;
case "TIMED_OUT":
return <TimedOutIcon className={cn(waitpointStatusClassNameColor(status), className)} />;
case "COMPLETED":
return <CheckCircleIcon className={cn(waitpointStatusClassNameColor(status), className)} />;
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);
}
}
}
@@ -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<typeof WaitpointSearchParamsSchema>;
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 (
<div className="flex flex-row flex-wrap items-center gap-1">
<FilterMenu />
<AppliedFilters />
{hasFilters && (
<Form className="h-6">
<Button variant="minimal/small" LeadingIcon={TrashIcon}>
Clear all
</Button>
</Form>
)}
</div>
);
}
const filterTypes = [
{
name: "statuses",
title: "Status",
icon: <StatusIcon className="size-4" />,
},
{ name: "tags", title: "Tags", icon: <TagIcon className="size-4" /> },
{ name: "created", title: "Created", icon: <CalendarIcon className="size-4" /> },
{ name: "daterange", title: "Custom date range", icon: <CalendarIcon className="size-4" /> },
{ name: "id", title: "Waitpoint ID", icon: <FingerPrintIcon className="size-4" /> },
{ name: "idempotencyKey", title: "Idempotency key", icon: <ListChecks className="size-4" /> },
] as const;
type FilterType = (typeof filterTypes)[number]["name"];
const shortcut = { key: "f" };
function FilterMenu() {
const [filterType, setFilterType] = useState<FilterType | undefined>();
const filterTrigger = (
<SelectTrigger
icon={
<div className="flex size-4 items-center justify-center">
<ListFilterIcon className="size-3.5" />
</div>
}
variant={"minimal/small"}
shortcut={shortcut}
tooltipTitle={"Filter runs"}
>
Filter
</SelectTrigger>
);
return (
<FilterMenuProvider onClose={() => setFilterType(undefined)}>
{(search, setSearch) => (
<Menu
searchValue={search}
clearSearchValue={() => setSearch("")}
trigger={filterTrigger}
filterType={filterType}
setFilterType={setFilterType}
/>
)}
</FilterMenuProvider>
);
}
function AppliedFilters() {
return (
<>
<AppliedStatusFilter />
<AppliedTagsFilter />
<AppliedPeriodFilter />
<AppliedCustomDateRangeFilter />
<AppliedWaitpointIdFilter />
<AppliedIdempotencyKeyFilter />
</>
);
}
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 <MainMenu {...props} />;
case "statuses":
return <StatusDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "created":
return <CreatedAtDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "daterange":
return <CustomDateRangeDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "tags":
return <TagsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "id":
return <WaitpointIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "idempotencyKey":
return <IdempotencyKeyDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
}
}
function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: MenuProps) {
const filtered = useMemo(() => {
return filterTypes.filter((item) => {
if (item.name === "daterange") return false;
return item.title.toLowerCase().includes(searchValue.toLowerCase());
});
}, [searchValue]);
return (
<SelectProvider virtualFocus={true}>
{trigger}
<SelectPopover>
<ComboBox placeholder={"Filter by..."} shortcut={shortcut} value={searchValue} />
<SelectList>
{filtered.map((type, index) => (
<SelectButtonItem
key={type.name}
onClick={() => {
clearSearchValue();
setFilterType(type.name);
}}
icon={type.icon}
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
>
{type.title}
</SelectButtonItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
const statuses = 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 (
<SelectProvider value={values("statuses")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox placeholder={"Filter by status..."} value={searchValue} />
<SelectList>
{filtered.map((item, index) => {
return (
<SelectItem
key={item.value}
value={item.value}
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="group flex w-full flex-col py-0">
<WaitpointStatusCombo status={item.value} />
</TooltipTrigger>
<TooltipContent side="right" sideOffset={50}>
<Paragraph variant="extra-small">
{waitpointStatusTitle(item.value)}
</Paragraph>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</SelectItem>
);
})}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
function AppliedStatusFilter() {
const { values, del } = useSearchParams();
const statuses = values("statuses");
if (statuses.length === 0) {
return null;
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<StatusDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Status"
value={appliedSummary(
statuses.map((v) => waitpointStatusTitle(v as WaitpointTokenStatus))
)}
onRemove={() => del(["statuses", "cursor", "direction"])}
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
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<typeof tagsLoader>();
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 (
<SelectProvider value={values("tags")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox
value={searchValue}
render={(props) => (
<div className="flex items-center justify-stretch">
<input {...props} placeholder={"Filter by tags..."} />
{fetcher.state === "loading" && <Spinner color="muted" />}
</div>
)}
/>
<SelectList>
{filtered.length > 0
? filtered.map((tag, index) => (
<SelectItem key={tag} value={tag}>
{tag}
</SelectItem>
))
: null}
{filtered.length === 0 && fetcher.state !== "loading" && (
<SelectItem disabled>No tags found</SelectItem>
)}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
function AppliedTagsFilter() {
const { values, del } = useSearchParams();
const tags = values("tags");
if (tags.length === 0) {
return null;
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<TagsDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Tags"
value={appliedSummary(values("tags"))}
onRemove={() => del(["tags", "cursor", "direction"])}
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
function WaitpointIdDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const [open, setOpen] = useState<boolean | undefined>();
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 (
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
{trigger}
<SelectPopover
hideOnEnter={false}
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
className="max-w-[min(32ch,var(--popover-available-width))]"
>
<div className="flex flex-col gap-4 p-3">
<div className="flex flex-col gap-1">
<Label>Waitpoint ID</Label>
<Input
placeholder="run_"
value={id ?? ""}
onChange={(e) => setId(e.target.value)}
variant="small"
className="w-[27ch] font-mono"
spellCheck={false}
/>
{error ? <FormError>{error}</FormError> : null}
</div>
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
disabled={error !== undefined || !id}
variant="secondary/small"
shortcut={{
modifiers: ["mod"],
key: "Enter",
enabledOnInputElements: true,
}}
onClick={() => apply()}
>
Apply
</Button>
</div>
</div>
</SelectPopover>
</SelectProvider>
);
}
function AppliedWaitpointIdFilter() {
const { value, del } = useSearchParams();
if (value("id") === undefined) {
return null;
}
const id = value("id");
return (
<FilterMenuProvider>
{(search, setSearch) => (
<WaitpointIdDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="ID"
value={id}
onRemove={() => del(["id", "cursor", "direction"])}
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
function IdempotencyKeyDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const [open, setOpen] = useState<boolean | undefined>();
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 (
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
{trigger}
<SelectPopover
hideOnEnter={false}
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
className="max-w-[min(32ch,var(--popover-available-width))]"
>
<div className="flex flex-col gap-4 p-3">
<div className="flex flex-col gap-1">
<Label>Idempotency key</Label>
<Input
placeholder="waitpoint_"
value={idempotencyKey ?? ""}
onChange={(e) => setIdempotencyKey(e.target.value)}
variant="small"
className="w-[27ch] font-mono"
spellCheck={false}
/>
{error ? <FormError>{error}</FormError> : null}
</div>
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
disabled={error !== undefined || !idempotencyKey}
variant="secondary/small"
shortcut={{
modifiers: ["mod"],
key: "Enter",
enabledOnInputElements: true,
}}
onClick={() => apply()}
>
Apply
</Button>
</div>
</div>
</SelectPopover>
</SelectProvider>
);
}
function AppliedIdempotencyKeyFilter() {
const { value, del } = useSearchParams();
if (value("idempotencyKey") === undefined) {
return null;
}
const idempotencyKey = value("idempotencyKey");
return (
<FilterMenuProvider>
{(search, setSearch) => (
<IdempotencyKeyDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Idempotency key"
value={idempotencyKey}
onRemove={() => del(["idempotencyKey", "cursor", "direction"])}
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
@@ -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);
}
}, []);
}
+34 -14
View File
@@ -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 = {
@@ -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
}
}
}
}
@@ -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(),
@@ -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,
};
});
}
}
@@ -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<typeof ApiWaitpointTokenListSearchParams>;
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,
};
});
}
}
@@ -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
}
@@ -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 {
@@ -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
@@ -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<ReturnType<SpanPresenter["call"]>>;
export type Span = NonNullable<NonNullable<Result>["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,
},
};
@@ -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<Awaited<ReturnType<WaitpointPresenter["call"]>>>;
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,
};
}
}
@@ -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<ReturnType<WaitpointTagListPresenter["call"]>>;
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,
};
}
}
@@ -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<Result> {
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";
}
}
@@ -134,7 +134,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
from,
to,
batchId,
runId,
runIds: runId ? [runId] : undefined,
scheduleId,
rootOnly,
direction: direction,
@@ -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<typeof loader>();
const location = useLocation();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
return (
<div
className={cn(
cn(
"grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden bg-background-bright",
waitpoint.status === "WAITING" && "grid-rows-[2.5rem_1fr_auto]"
)
)}
>
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
<Header2 className={cn("whitespace-nowrap")}>{waitpoint.id}</Header2>
<LinkButton
to={`${v3WaitpointTokensPath(organization, project, environment)}${location.search}`}
variant="minimal/small"
TrailingIcon={ExitIcon}
shortcut={{ key: "esc" }}
shortcutPosition="before-trailing-icon"
className="pl-1"
/>
</div>
<div className="overflow-y-auto pt-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div className="px-3">
<WaitpointDetailTable waitpoint={waitpoint} />
</div>
<div className="flex flex-col gap-1 pt-6">
<div className="mb-1 flex items-center gap-1 pl-3">
<Header3>5 related runs</Header3>
<InfoIconTooltip content="These runs have been blocked by this waitpoint." />
</div>
<TaskRunsTable
total={waitpoint.connectedRuns.length}
hasFilters={false}
filters={{
tasks: [],
versions: [],
statuses: [],
environments: [],
from: undefined,
to: undefined,
}}
runs={waitpoint.connectedRuns}
isLoading={false}
variant="bright"
/>
</div>
</div>
{waitpoint.status === "WAITING" && (
<div>
<CompleteWaitpointForm waitpoint={waitpoint} />
</div>
)}
</div>
);
}
@@ -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<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const { waitpointParam } = useParams();
const isShowingWaitpoint = !!waitpointParam;
return (
<PageContainer>
<NavBar>
<PageTitle title="Waitpoint Tokens" />
<PageAccessories>
<AdminDebugTooltip />
<LinkButton variant={"docs/small"} LeadingIcon={BookOpenIcon} to={docsPath("/wait")}>
Waitpoints docs
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
{!hasFilters && tokens.length === 0 ? (
<MainCenteredContainer className="max-w-md">
<NoWaitpointTokens />
</MainCenteredContainer>
) : (
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
<ResizablePanel id="waitpoint-tokens-main" min={"100px"}>
<div className="grid max-h-full grid-rows-[auto_1fr] overflow-hidden">
<div className="flex items-start justify-between gap-x-2 p-2">
<WaitpointTokenFilters hasFilters={hasFilters} />
<div className="flex items-center justify-end gap-x-2">
<ListPagination list={{ pagination }} />
</div>
</div>
<div className="grid h-fit max-h-full min-h-full grid-rows-[1fr] overflow-x-auto">
<Table containerClassName="border-t">
<TableHeader>
<TableRow>
<TableHeaderCell className="w-[1%]">Created</TableHeaderCell>
<TableHeaderCell className="w-[20%]">ID</TableHeaderCell>
<TableHeaderCell className="w-[20%]">Status</TableHeaderCell>
<TableHeaderCell className="w-[20%]">Completed</TableHeaderCell>
<TableHeaderCell className="w-[20%]">Idempotency Key</TableHeaderCell>
<TableHeaderCell className="w-[20%]">Tags</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{tokens.length > 0 ? (
tokens.map((token) => {
const ttlExpired =
token.idempotencyKeyExpiresAt &&
token.idempotencyKeyExpiresAt < new Date();
const path = v3WaitpointTokenPath(
organization,
project,
environment,
token,
filters
);
return (
<TableRow key={token.id}>
<TableCell to={path}>
<span className="opacity-60">
<DateTime date={token.createdAt} />
</span>
</TableCell>
<TableCell to={path}>
<CopyableText value={token.id} className="font-mono" />
</TableCell>
<TableCell to={path}>
<WaitpointStatusCombo status={token.status} className="text-xs" />
</TableCell>
<TableCell to={path}>
{token.completedAt ? <DateTime date={token.completedAt} /> : ""}
</TableCell>
<TableCell to={path}>
{token.idempotencyKey ? (
token.idempotencyKeyExpiresAt ? (
<SimpleTooltip
content={
<>
<DateTime date={token.idempotencyKeyExpiresAt} />
{ttlExpired ? (
<span className="text-xs opacity-50"> (expired)</span>
) : null}
</>
}
buttonClassName={ttlExpired ? "opacity-50" : undefined}
button={token.idempotencyKey}
/>
) : (
token.idempotencyKey
)
) : (
""
)}
</TableCell>
<TableCell to={path} actionClassName="py-1">
<div className="flex gap-1">
{token.tags.map((tag) => <RunTag key={tag} tag={tag} />) || ""}
</div>
</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell colSpan={6}>
<div className="grid place-items-center py-6 text-text-dimmed">
<Paragraph>No waitpoint tokens found</Paragraph>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{(pagination.next || pagination.previous) && (
<div className="flex justify-end border-t border-grid-dimmed px-2 py-3">
<ListPagination list={{ pagination }} />
</div>
)}
</div>
</div>
</ResizablePanel>
{isShowingWaitpoint && (
<>
<ResizableHandle id="waitpoint-tokens-handle" />
<ResizablePanel id="waitpoint-tokens-inspector" min="100px" default="500px">
<Outlet />
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
)}
</PageBody>
</PageContainer>
);
}
@@ -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();
@@ -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 } }) {
<Label>Icon</Label>
<div className="flex w-full items-end justify-between gap-2">
<div className="grid place-items-center overflow-hidden rounded-sm border border-charcoal-750 bg-background-bright">
<Avatar avatar={avatar} className="size-20" includePadding />
<Avatar avatar={avatar} size={5} includePadding orgName={organization.title} />
</div>
{/* Letters */}
<Form method="post">
@@ -416,8 +416,9 @@ function LogoForm({ organization }: { organization: { avatar: Avatar } }) {
type: "letters",
hex,
}}
className="size-10"
size={2.5}
includePadding
orgName={organization.title}
/>
</button>
</Form>
@@ -447,8 +448,9 @@ function LogoForm({ organization }: { organization: { avatar: Avatar } }) {
name,
hex,
}}
className="size-10"
size={2.5}
includePadding
orgName={organization.title}
/>
</button>
</Form>
@@ -1,7 +1,7 @@
import { json } from "@remix-run/server-runtime";
import {
CompleteWaitpointTokenRequestBody,
CompleteWaitpointTokenResponseBody,
type CompleteWaitpointTokenResponseBody,
conditionallyExportPacket,
stringifyIO,
} from "@trigger.dev/core/v3";
@@ -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);
}
);
@@ -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;
try {
const idempotencyKeyExpiresAt = body.idempotencyKeyTTL
? resolveIdempotencyKeyTTL(body.idempotencyKeyTTL)
: undefined;
const timeout = await parseDelay(body.timeout);
const timeout = await parseDelay(body.timeout);
const result = await engine.createManualWaitpoint({
environmentId: authentication.environment.id,
projectId: authentication.environment.projectId,
idempotencyKey: body.idempotencyKey,
idempotencyKeyExpiresAt,
timeout,
});
//upsert tags
let tags: { id: string; name: string }[] = [];
const bodyTags = typeof body.tags === "string" ? [body.tags] : body.tags;
return json<CreateWaitpointTokenResponseBody>(
{
id: WaitpointId.toFriendlyId(result.waitpoint.id),
isCached: result.isCached,
},
{ status: 200 }
);
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<CreateWaitpointTokenResponseBody>(
{
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 });
}
}
);
@@ -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<WaitForWaitpointTokenResponseBody>(
@@ -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({
) : (
<div className="mt-1 flex flex-wrap items-center gap-1 text-xs">
{run.tags.map((tag: string) => (
<SimpleTooltip
<RunTag
key={tag}
button={
<Link
to={v3RunsPath(organization, project, environment, { tags: [tag] })}
>
<RunTag tag={tag} />
</Link>
}
content={`Filter runs by ${tag}`}
tag={tag}
to={v3RunsPath(organization, project, environment, { tags: [tag] })}
tooltip={`Filter runs by ${tag}`}
/>
))}
</div>
@@ -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 (
<div className="flex flex-col">
<Paragraph variant="base/bright" className="w-full border-b border-grid-dimmed py-2.5">
{title}
</Paragraph>
<LinkButton LeadingIcon={CloudArrowDownIcon} to={data} variant="tertiary/medium" download>
Download
</LinkButton>
</div>
);
}
case "text/plain": {
return (
<CodeBlock
language="markdown"
rowTitle={title}
code={data}
maxLines={20}
showLineNumbers={false}
/>
);
}
default: {
return (
<CodeBlock
language="json"
rowTitle={title}
code={data}
maxLines={20}
showLineNumbers={false}
/>
);
}
}
}
function SpanEntity({ span }: { span: Span }) {
const isAdmin = useHasAdminAccess();
@@ -999,90 +950,10 @@ function SpanEntity({ span }: { span: Span }) {
<TextLink to={docsPath("wait")}>View docs</TextLink>.
</Paragraph>
</div>
<Property.Table>
<Property.Item>
<Property.Label>Status</Property.Label>
<Property.Value>
<TaskRunStatusCombo
status={
span.entity.object.isTimeout
? "TIMED_OUT"
: span.entity.object.status === "PENDING"
? "EXECUTING"
: span.entity.object.outputIsError
? "COMPLETED_WITH_ERRORS"
: "COMPLETED_SUCCESSFULLY"
}
className="text-sm"
/>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>ID</Property.Label>
<Property.Value className="whitespace-pre-wrap">
{span.entity.object.friendlyId}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Idempotency key</Property.Label>
<Property.Value>
<div>
<div>
{span.entity.object.userProvidedIdempotencyKey
? span.entity.object.idempotencyKey
: ""}
</div>
<div>
{span.entity.object.idempotencyKeyExpiresAt ? (
<>
TTL: <DateTime date={span.entity.object.idempotencyKeyExpiresAt} />
</>
) : null}
</div>
</div>
</Property.Value>
</Property.Item>
{span.entity.object.type === "MANUAL" && (
<>
<Property.Item>
<Property.Label>Timeout at</Property.Label>
<Property.Value>
<div className="flex w-full flex-wrap items-center justify-between gap-1">
{span.entity.object.completedAfter ? (
<DateTimeAccurate date={span.entity.object.completedAfter} />
) : (
""
)}
{span.entity.object.status === "PENDING" && (
<ForceTimeout waitpoint={span.entity.object} />
)}
</div>
</Property.Value>
</Property.Item>
</>
)}
{span.entity.object.status === "PENDING" ? null : span.entity.object.isTimeout ? (
<></>
) : span.entity.object.output ? (
<PacketDisplay
title="Output"
data={span.entity.object.output}
dataType={span.entity.object.outputType}
/>
) : span.entity.object.completedAfter ? (
<Property.Item>
<Property.Label>Completed at</Property.Label>
<Property.Value>
<DateTimeAccurate date={span.entity.object.completedAfter} />
</Property.Value>
</Property.Item>
) : (
"Completed with no output"
)}
</Property.Table>
<WaitpointDetailTable waitpoint={span.entity.object} linkToList />
</div>
{span.entity.object.status === "PENDING" && (
<div className="">
{span.entity.object.status === "WAITING" && (
<div>
<CompleteWaitpointForm waitpoint={span.entity.object} />
</div>
)}
@@ -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<Waitpoint, "friendlyId" | "type" | "completedAfter" | "status">;
type FormWaitpoint = Pick<Waitpoint, "id" | "type" | "completedAfter"> & {
status: WaitpointTokenStatus;
};
export function CompleteWaitpointForm({ waitpoint }: { waitpoint: FormWaitpoint }) {
return (
@@ -198,7 +201,7 @@ export function CompleteWaitpointForm({ waitpoint }: { waitpoint: FormWaitpoint
waitpoint.completedAfter ? (
<CompleteDateTimeWaitpointForm
waitpoint={{
friendlyId: waitpoint.friendlyId,
friendlyId: waitpoint.id,
completedAfter: 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<string>("{\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<HTMLFormElement>) => {
@@ -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 (
<Form action={formAction} method="post">
@@ -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;
}
@@ -20,7 +20,7 @@ export default function Story() {
<h2 className="mb-4 text-lg font-semibold text-white">Size 8</h2>
<div className="flex flex-wrap gap-2">
{avatars.map((avatar, index) => (
<Avatar key={`small-${index}`} avatar={avatar} className="size-8" />
<Avatar key={`small-${index}`} avatar={avatar} size={2} orgName={`Org ${index}`} />
))}
</div>
</div>
@@ -30,7 +30,7 @@ export default function Story() {
<h2 className="mb-4 text-lg font-semibold text-white">Size 12</h2>
<div className="flex flex-wrap gap-4">
{avatars.map((avatar, index) => (
<Avatar key={`large-${index}`} avatar={avatar} className="size-12" />
<Avatar key={`large-${index}`} avatar={avatar} size={3} orgName={`Org ${index}`} />
))}
</div>
</div>
+24 -1
View File
@@ -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<Organization, "slug">;
export type ProjectForPath = Pick<Project, "slug">;
export type EnvironmentForPath = Pick<RuntimeEnvironment, "slug">;
@@ -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,
+22
View File
@@ -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());
Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

@@ -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");
@@ -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;
@@ -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[];
@@ -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;
@@ -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())
@@ -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,
});
}
@@ -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
+97 -2
View File
@@ -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<typeof WaitpointTokenItem> {
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
+10 -1
View File
@@ -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> | string;
tags?: Array<string> | string;
}
export interface ListWaitpointTokensQueryParams extends CursorPageParams {
status?: Array<WaitpointTokenStatus> | WaitpointTokenStatus;
idempotencyKey?: string;
tags?: Array<string> | string;
period?: string;
from?: Date | number;
to?: Date | number;
}
+55
View File
@@ -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<typeof CreateWaitpointTokenRequestBody>;
@@ -934,6 +948,37 @@ export const CreateWaitpointTokenResponseBody = z.object({
});
export type CreateWaitpointTokenResponseBody = z.infer<typeof CreateWaitpointTokenResponseBody>;
export const waitpointTokenStatuses = ["WAITING", "COMPLETED", "TIMED_OUT"] as const;
export const WaitpointTokenStatus = z.enum(waitpointTokenStatuses);
export type WaitpointTokenStatus = z.infer<typeof WaitpointTokenStatus>;
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<typeof WaitpointTokenItem>;
export const WaitpointListTokenItem = WaitpointTokenItem.omit({
completedAfter: true,
});
export type WaitpointListTokenItem = z.infer<typeof WaitpointListTokenItem>;
export const WaitpointRetrieveTokenResponse = WaitpointListTokenItem.and(
z.object({
output: z.string().optional(),
outputType: z.string().optional(),
outputIsError: z.boolean().optional(),
})
);
export type WaitpointRetrieveTokenResponse = z.infer<typeof WaitpointRetrieveTokenResponse>;
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<typeof WaitForDurationRequestBody>;
+280 -3
View File
@@ -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);
}
async function completeToken<T>(
/**
* 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<typeof WaitpointListTokenItem> {
const apiClient = apiClientManager.clientOrThrow();
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "wait.listTokens()",
icon: "wait-token",
attributes: {
...flattenAttributes(params as Record<string, unknown>),
},
},
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<T> = {
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<T>(
token: string | { id: string },
requestOptions?: ApiRequestOptions
): Promise<WaitpointRetrievedToken<T>> {
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<T>(
/**
* 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<typeof ApprovalData>(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 <T>(
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<Prettify<WaitpointTokenTypedResult<T>>> => {
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}`);
+25 -1
View File
@@ -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);
},
});