Compare commits

...

16 Commits

Author SHA1 Message Date
Dan ca434e546f feat(ai-assistant): introduce test task tools and enhance AIChat integration
- Added new tools for listing testable tasks, generating test payloads, and running test tasks, improving the AI assistant's capabilities for testing workflows.
- Enhanced AIChatMessages and AIChatProvider to support the new test task functionalities, including handling generated payloads and navigating to test runs.
- Updated tool schemas to include new test-related operations, ensuring a seamless user experience when interacting with testing features.
- Improved the AI assistant's guidelines to reflect the new testing capabilities, providing clearer instructions for users.

These changes aim to enrich the testing experience within the AI assistant, making it easier for users to manage and execute test tasks effectively.
2026-06-04 10:21:53 +01:00
Dan 43ad5fcd2f feat(ai-assistant): enhance navigation and span detail tools
- Updated the navigateToPage tool to support deep-linking to specific runs and spans, improving user navigation capabilities.
- Introduced a new getSpanDetails tool for retrieving detailed information about individual spans within a run, aiding in error investigation.
- Enhanced AI assistant guidelines to reflect new navigation and inspection capabilities, ensuring users can effectively query run and span details.
- Added spanId and runId parameters to relevant schemas and functions for better context handling.

These changes aim to provide a more robust and informative experience for users interacting with the AI assistant, particularly in navigating and diagnosing run-related issues.
2026-06-03 17:35:42 +01:00
Dan 13e63db468 feat(ai-assistant): add ai-sparkle-hover animation and enhance AskAI and AIChatHeader components 2026-06-03 17:02:12 +01:00
Dan dfcadcaaf5 feat(ai-assistant): enhance AIChatMessages and introduce new tool result components
- Updated AIChatMessages to support structured output rendering for various tool results, improving user interaction.
- Added ToolOutput component to handle different tool outputs, including FailureSummaryCard, FilterChips, and MiniTable for better data presentation.
- Enhanced AIChatPanel to pass onSendMessage prop for improved message handling.
- Introduced AIChatToolResults for better organization of tool result components.

These changes aim to provide a richer and more informative user experience when interacting with the AI assistant.
2026-06-03 16:03:45 +01:00
Dan 76360ea261 feat(ai-assistant): enhance multi-step request handling and tool loading 2026-06-03 15:05:05 +01:00
Dan d6fa0d64ff feat(ai-assistant): enhance chat message handling, history and tool ux
- Updated AIChatMessages component to improve auto-navigation logic, ensuring navigation only occurs during active streaming.
- Introduced ToolResultCard for displaying tool outputs with collapsible details, enhancing user interaction.
- Added tool labels for better UI feedback on tool actions.
- Refined AIChatPanel to manage chat state more effectively, including resuming previous chats.
- Improved AIChatProvider to handle chat switching and state management with better reliability.

These changes aim to provide a more intuitive and responsive experience for users interacting with the AI assistant.
2026-06-03 14:52:55 +01:00
Dan f63b7c67a3 feat(ai-assistant): implement auto-navigation for dynamic tools in chat messages 2026-06-03 13:55:08 +01:00
Dan 830b830154 feat(ai-assistant): refine session management and error handling 2026-06-03 12:34:37 +01:00
Dan 0772ab4106 feat(ai-assistant): enhance analytics tools with ClickHouse integration 2026-06-03 11:20:04 +01:00
Dan 0e204a2a9a feat(ai-assistant): implement run tools 2026-06-03 07:42:33 +01:00
Dan e209bac792 feat(ai-assistant): implement error classification and retrieval tools 2026-06-03 07:41:59 +01:00
Dan 669d396f7c feat(ai-assistant): add scaffolding for expanding AI assistant capabilities with V1B tools and enhancements
- Introduced new tools for managing runs, errors, and analytics, including:
  - `listRuns`, `getRunDetails`, `getRunLogs`, `getRunGraph`, `applyRunFilters`, `queryRuns`
  - `listErrors`, `getErrorDetails`, `findSimilarErrors`, `classifyFailure`
  - `summarizeCurrentView`, `aggregateRuns`, `correlateRunsWithDeploy`
- Updated AIChatToolCall component with new tool labels for improved user feedback.
- Enhanced the AI assistant's system prompt to include capabilities for inspecting runs and errors.
- Added new utility functions for handling ClickHouse queries and summarizing run data.

These changes aim to provide a more comprehensive and user-friendly experience for users interacting with the AI assistant, enabling better insights into runs and error management.
2026-06-03 07:35:57 +01:00
Dan 36628d6bd3 feat(ai-assistant): enhance chat UI with improved input handling and message display
- Updated AskAI component to hide when the chat drawer is open.
- Modified AIChatContextBanner for better styling.
- Refactored AIChatHeader to include scroll handling and improved chat history display.
- Enhanced AIChatInput to support auto-growing text area and focus management.
- Improved AIChatMessages to handle scrolling and display messages more effectively.
- Added error handling and retry functionality in AIChatPanel.
- Introduced new suggested prompts with animation effects.

These changes aim to provide a more seamless and user-friendly chat experience in the AI assistant interface.
2026-06-02 20:19:20 +01:00
Dan f35a328e8a feat(ai-assistant): add chat panel UI and wire into dashboard nav
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 18:30:06 +01:00
Dan 3e42603e03 feat(ai-assistant): dashboard agent task, tools, and session routes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 18:30:00 +01:00
Dan 94af0df33e feat(ai-assistant): add AiChat/AiChatSession persistence tables
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 18:29:53 +01:00
66 changed files with 5610 additions and 892 deletions
@@ -0,0 +1,16 @@
---
area: webapp
type: fix
---
Fix the dashboard AI assistant (`dashboard-assistant` chat.agent) silently not
responding to messages. The session is started via `chat.createStartSessionAction`,
which triggers the first run with `trigger: "preload"`, so every chat boots
preloaded. The agent only created its `AiChat`/`AiChatSession` rows in
`onChatStart`, which early-returns on preloaded runs — so the rows were never
created and `onTurnStart`'s `aiChat.update(...)` threw before `run()` streamed.
Adds an `onPreload` hook that creates the rows (with `onChatStart` kept as the
non-preloaded fallback), and declares `tools` on the agent config (function form)
read back from the `run()` payload so the SDK re-applies each tool's
`toModelOutput` when re-converting history on later turns.
+30 -533
View File
@@ -1,549 +1,46 @@
import {
ArrowPathIcon,
ArrowUpIcon,
HandThumbDownIcon,
HandThumbUpIcon,
StopIcon,
} from "@heroicons/react/20/solid";
import { cn } from "~/utils/cn";
import { type FeedbackComment, KapaProvider, type QA, useChat } from "@kapaai/react-sdk";
import { useSearchParams } from "@remix-run/react";
import DOMPurify from "dompurify";
import { motion } from "framer-motion";
import { marked } from "marked";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTypedRouteLoaderData } from "remix-typedjson";
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
import { SparkleListIcon } from "~/assets/icons/SparkleListIcon";
import { useFeatures } from "~/hooks/useFeatures";
import { type loader } from "~/root";
import { Button } from "./primitives/Buttons";
import { Callout } from "./primitives/Callout";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./primitives/Dialog";
import { Header2 } from "./primitives/Headers";
import { Paragraph } from "./primitives/Paragraph";
import { ShortcutKey } from "./primitives/ShortcutKey";
import { Spinner } from "./primitives/Spinner";
import {
SimpleTooltip,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "./primitives/Tooltip";
import { ClientOnly } from "remix-utils/client-only";
import { useOptionalAIChat } from "./ai-assistant/AIChatProvider";
function useKapaWebsiteId() {
const routeMatch = useTypedRouteLoaderData<typeof loader>("root");
return routeMatch?.kapa.websiteId;
}
export function AskAI() {
const chat = useOptionalAIChat();
export function AskAI({ isCollapsed = false }: { isCollapsed?: boolean }) {
const { isManagedCloud } = useFeatures();
const websiteId = useKapaWebsiteId();
if (!isManagedCloud || !websiteId) {
// The provider is only mounted in the project layout. On account/settings
// pages there's no assistant, so render nothing. Hide while the drawer is open.
if (!chat || chat.isOpen) {
return null;
}
return (
<ClientOnly
fallback={
<Button
variant="small-menu-item"
data-action="ask-ai"
hideShortcutKey
data-modal-override-open-class-ask-ai="true"
disabled
className={isCollapsed ? "w-full justify-center" : ""}
>
<AISparkleIcon className="size-5" />
</Button>
}
>
{() => <AskAIProvider websiteId={websiteId} isCollapsed={isCollapsed} />}
</ClientOnly>
<TooltipProvider disableHoverableContent>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="minimal/small"
shortcut={{ modifiers: ["mod"], key: "i", enabledOnInputElements: true }}
hideShortcutKey
onClick={() => chat.toggle()}
LeadingIcon={AISparkleIcon}
leadingIconClassName="motion-safe:group-hover/button:animate-ai-sparkle-hover motion-reduce:group-hover/button:animate-none"
>
Ask AI
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={8} className="flex items-center gap-2 text-xs">
AI Assistant
<span className="flex items-center">
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "i" }} variant="medium/bright" />
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
type AskAIProviderProps = {
websiteId: string;
isCollapsed?: boolean;
};
function AskAIProvider({ websiteId, isCollapsed = false }: AskAIProviderProps) {
const [isOpen, setIsOpen] = useState(false);
const [initialQuery, setInitialQuery] = useState<string | undefined>();
const [searchParams, setSearchParams] = useSearchParams();
const openAskAI = useCallback((question?: string) => {
if (question) {
setInitialQuery(question);
} else {
setInitialQuery(undefined);
}
setIsOpen(true);
}, []);
const closeAskAI = useCallback(() => {
setIsOpen(false);
setInitialQuery(undefined);
}, []);
// Handle URL param functionality
useEffect(() => {
const aiHelp = searchParams.get("aiHelp");
if (aiHelp) {
// Delay to avoid hCaptcha bot detection
window.setTimeout(() => openAskAI(aiHelp), 1000);
// Clone instead of mutating in place
const next = new URLSearchParams(searchParams);
next.delete("aiHelp");
setSearchParams(next);
}
}, [searchParams, openAskAI]);
return (
<KapaProvider
integrationId={websiteId}
callbacks={{
askAI: {
onQuerySubmit: () => openAskAI(),
onAnswerGenerationCompleted: () => openAskAI(),
},
}}
botProtectionMechanism="hcaptcha"
>
<motion.div layout="position" transition={{ duration: 0.2, ease: "easeInOut" }}>
<TooltipProvider disableHoverableContent>
<Tooltip>
<TooltipTrigger asChild>
<span className={cn("inline-flex h-8", isCollapsed && "w-full")}>
<Button
variant="small-menu-item"
data-action="ask-ai"
shortcut={{ modifiers: ["mod"], key: "i", enabledOnInputElements: true }}
hideShortcutKey
data-modal-override-open-class-ask-ai="true"
onClick={() => openAskAI()}
fullWidth={isCollapsed}
className={cn("h-full", isCollapsed && "justify-center")}
>
<AISparkleIcon className="size-5" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent
side="right"
sideOffset={8}
className="flex items-center gap-2 text-xs"
>
Ask AI
<span className="flex items-center">
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "i" }} variant="medium/bright" />
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</motion.div>
<AskAIDialog
initialQuery={initialQuery}
isOpen={isOpen}
onOpenChange={setIsOpen}
closeAskAI={closeAskAI}
/>
</KapaProvider>
);
}
type AskAIDialogProps = {
initialQuery?: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
closeAskAI: () => void;
};
function AskAIDialog({ initialQuery, isOpen, onOpenChange, closeAskAI }: AskAIDialogProps) {
const handleOpenChange = (open: boolean) => {
if (!open) {
closeAskAI();
} else {
onOpenChange(open);
}
};
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent className="animated-gradient-glow flex max-h-[90vh] min-h-fit w-full flex-col justify-between gap-0 px-0 pb-0 pt-0 sm:max-w-prose">
<DialogHeader className="flex h-[2.75rem] items-start justify-center rounded-t-md bg-background-bright pl-3">
<div className="flex items-center gap-1">
<AISparkleIcon className="size-5" />
<DialogTitle className="text-sm font-medium text-text-bright">Ask AI</DialogTitle>
</div>
</DialogHeader>
<ChatInterface initialQuery={initialQuery} />
</DialogContent>
</Dialog>
);
}
function ChatMessages({
conversation,
isPreparingAnswer,
isGeneratingAnswer,
onReset,
onExampleClick,
error,
addFeedback,
}: {
conversation: QA[];
isPreparingAnswer: boolean;
isGeneratingAnswer: boolean;
onReset: () => void;
onExampleClick: (question: string) => void;
error: string | null;
addFeedback: (
questionAnswerId: string,
reaction: "upvote" | "downvote",
comment?: FeedbackComment
) => void;
}) {
const [feedbackGivenForQAs, setFeedbackGivenForQAs] = useState<Set<string>>(new Set());
// Reset feedback state when conversation is reset
useEffect(() => {
if (conversation.length === 0) {
setFeedbackGivenForQAs(new Set());
}
}, [conversation.length]);
// Check if feedback has been given for the latest QA
const latestQA = conversation[conversation.length - 1];
const hasFeedbackForLatestQA = latestQA?.id ? feedbackGivenForQAs.has(latestQA.id) : false;
const exampleQuestions = [
"How do I increase my concurrency limit?",
"How do I debug errors in my task?",
"How do I deploy my task?",
];
return (
<div className="flex-1 overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
{conversation.length === 0 ? (
<motion.div
className="flex flex-col gap-2 pb-2"
initial="hidden"
animate="visible"
variants={{
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.2,
},
},
}}
>
<Paragraph className="mb-3 mt-1.5 pl-1">
I'm trained on docs, examples, and other content. Ask me anything about Trigger.dev.
</Paragraph>
{exampleQuestions.map((question, index) => (
<motion.button
key={index}
className="group flex w-fit items-center gap-2 rounded-full border border-dashed border-charcoal-600 px-4 py-2 transition-colors hover:border-solid hover:border-indigo-500"
onClick={() => onExampleClick(question)}
variants={{
hidden: {
opacity: 0,
x: 20,
},
visible: {
opacity: 1,
x: 0,
transition: {
opacity: {
duration: 0.5,
ease: "linear",
},
x: {
type: "spring",
stiffness: 300,
damping: 25,
},
},
},
}}
>
<SparkleListIcon className="size-4 text-text-dimmed transition group-hover:text-indigo-500" />
<Paragraph variant="small" className="transition group-hover:text-text-bright">
{question}
</Paragraph>
</motion.button>
))}
</motion.div>
) : (
conversation.map((qa) => (
<div key={qa.id || `temp-${qa.question}`} className="mb-4">
<Header2 spacing>{qa.question}</Header2>
<div
className="prose prose-invert max-w-none text-text-dimmed"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(marked(qa.answer)) }}
/>
</div>
))
)}
{conversation.length > 0 &&
!isPreparingAnswer &&
!isGeneratingAnswer &&
!error &&
!latestQA?.id && (
<div className="flex items-center justify-between border-t border-grid-bright pt-3">
<Paragraph variant="small" className="text-text-dimmed">
Answer generation was stopped
</Paragraph>
<Button
variant="minimal/small"
LeadingIcon={<ArrowPathIcon className="size-4" />}
onClick={onReset}
className="w-fit pl-1.5"
iconSpacing="gap-x-1.5"
>
Reset chat
</Button>
</div>
)}
{conversation.length > 0 &&
!isPreparingAnswer &&
!isGeneratingAnswer &&
!error &&
latestQA?.id && (
<div className="flex items-center justify-between border-t border-grid-bright pt-3">
{hasFeedbackForLatestQA ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
<Paragraph variant="small" className="text-text-dimmed">
Thanks for your feedback!
</Paragraph>
</motion.div>
) : (
<div className="flex items-center gap-2">
<Paragraph variant="small" className="text-text-dimmed">
Was this helpful?
</Paragraph>
<div className="flex items-center">
<Button
variant="minimal/small"
onClick={() => {
const latestQA = conversation[conversation.length - 1];
if (latestQA?.id) {
addFeedback(latestQA.id, "upvote");
setFeedbackGivenForQAs((prev) => new Set(prev).add(latestQA.id));
}
}}
className="size-8 px-1.5"
>
<HandThumbUpIcon className="size-4 text-text-dimmed transition group-hover/button:text-success" />
</Button>
<Button
variant="minimal/small"
onClick={() => {
const latestQA = conversation[conversation.length - 1];
if (latestQA?.id) {
addFeedback(latestQA.id, "downvote");
setFeedbackGivenForQAs((prev) => new Set(prev).add(latestQA.id));
}
}}
className="size-8 px-1.5"
>
<HandThumbDownIcon className="size-4 text-text-dimmed transition group-hover/button:text-error" />
</Button>
</div>
</div>
)}
<Button
variant="minimal/small"
LeadingIcon={<ArrowPathIcon className="size-4" />}
onClick={onReset}
className="w-fit pl-1.5"
iconSpacing="gap-x-1.5"
>
Reset chat
</Button>
</div>
)}
{isPreparingAnswer && (
<div className="flex items-center gap-2">
<Spinner
color={{
background: "rgba(99, 102, 241, 1)",
foreground: "rgba(217, 70, 239, 1)",
}}
className="size-4"
/>
<Paragraph className="text-text-dimmed">Preparing answer</Paragraph>
</div>
)}
{error && (
<div className="flex flex-col">
<Callout variant="error" className="mb-4">
<Paragraph className="font-semibold text-error">Error generating answer:</Paragraph>
<Paragraph className="text-rose-300">
{error} If the problem persists after retrying, please contact support.
</Paragraph>
</Callout>
<div className="flex justify-end">
<Button
variant="secondary/small"
LeadingIcon={<ArrowPathIcon className="size-4" />}
onClick={onReset}
className="w-fit pl-1.5"
iconSpacing="gap-x-1.5"
>
Reset chat
</Button>
</div>
</div>
)}
</div>
);
}
function ChatInterface({ initialQuery }: { initialQuery?: string }) {
const [message, setMessage] = useState("");
const [isExpanded, setIsExpanded] = useState(false);
const hasSubmittedInitialQuery = useRef(false);
const {
conversation,
submitQuery,
isGeneratingAnswer,
isPreparingAnswer,
resetConversation,
stopGeneration,
error,
addFeedback,
} = useChat();
useEffect(() => {
if (initialQuery && !hasSubmittedInitialQuery.current) {
hasSubmittedInitialQuery.current = true;
setIsExpanded(true);
submitQuery(initialQuery);
}
}, [initialQuery, submitQuery]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (message.trim()) {
setIsExpanded(true);
submitQuery(message);
setMessage("");
}
};
const handleExampleClick = (question: string) => {
setIsExpanded(true);
submitQuery(question);
};
const handleReset = () => {
resetConversation();
setIsExpanded(false);
};
return (
<motion.div
className="flex h-full max-h-[90vh] grow flex-col overflow-y-auto rounded-b-md bg-background-bright"
animate={{ height: isExpanded ? "90vh" : "auto" }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
initial={{ height: "auto" }}
>
<ChatMessages
conversation={conversation}
isPreparingAnswer={isPreparingAnswer}
isGeneratingAnswer={isGeneratingAnswer}
onReset={handleReset}
onExampleClick={handleExampleClick}
error={error}
addFeedback={addFeedback}
/>
<form onSubmit={handleSubmit} className="flex-shrink-0 border-t border-grid-bright p-4">
<div className="flex gap-3">
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Ask a question..."
disabled={isGeneratingAnswer}
autoFocus
className="flex-1 rounded-md border border-grid-bright bg-background-dimmed px-3 py-2 text-text-bright placeholder:text-text-dimmed focus-visible:focus-custom"
/>
{isGeneratingAnswer ? (
<SimpleTooltip
button={
<span
onClick={() => stopGeneration()}
className="group relative z-10 flex size-10 min-w-10 cursor-pointer items-center justify-center"
>
<StopIcon className="z-10 size-5 text-indigo-500 transition group-hover:text-indigo-400" />
<GradientSpinnerBackground
className="absolute inset-0 animate-spin"
hoverEffect
/>
</span>
}
content="Stop generating"
/>
) : isPreparingAnswer ? (
<GradientSpinnerBackground className="flex size-10 min-w-10 items-center justify-center">
<Spinner
color={{
background: "rgba(99, 102, 241, 1)",
foreground: "rgba(217, 70, 239, 1)",
}}
className="size-5"
/>
</GradientSpinnerBackground>
) : (
<Button
type="submit"
disabled={!message.trim()}
LeadingIcon={<ArrowUpIcon className="size-5 text-text-bright" />}
variant="primary/large"
className="size-10 min-w-10 rounded-full group-disabled/button:border-charcoal-550 group-disabled/button:bg-charcoal-600"
/>
)}
</div>
</form>
</motion.div>
);
}
function GradientSpinnerBackground({
children,
className,
hoverEffect = false,
}: {
children?: React.ReactNode;
className?: string;
hoverEffect?: boolean;
}) {
return (
<div
className={`flex rounded-full bg-gradient-to-br from-indigo-500 via-purple-500 to-fuchsia-500 p-px ${className}`}
>
<div
className={`flex h-full w-full items-center justify-center rounded-full bg-charcoal-600 ${
hoverEffect ? "transition group-hover:bg-charcoal-550" : ""
}`}
>
{children}
</div>
</div>
);
}
}
@@ -0,0 +1,23 @@
interface AIChatContextBannerProps {
projectSlug: string;
environmentSlug: string;
currentPage: string;
}
export function AIChatContextBanner({
projectSlug,
environmentSlug,
currentPage,
}: AIChatContextBannerProps) {
if (!projectSlug) return null;
return (
<div className="flex items-center gap-1.5 border-b border-grid-bright bg-charcoal-800/30 px-3 py-1.5 text-xs text-text-dimmed">
<span className="font-medium text-text-bright">{projectSlug}</span>
<span>/</span>
<span>{environmentSlug}</span>
<span>/</span>
<span className="capitalize">{currentPage}</span>
</div>
);
}
@@ -0,0 +1,278 @@
import { PlusIcon, ClockIcon } from "@heroicons/react/20/solid";
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { Button } from "~/components/primitives/Buttons";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
import { cn } from "~/utils/cn";
import { useAIChat } from "./AIChatProvider";
const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
function formatRelativeTime(iso: string): string {
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return "";
const diff = Date.now() - then;
if (diff < MINUTE) return "Just now";
if (diff < HOUR) {
const mins = Math.floor(diff / MINUTE);
return `${mins} minute${mins === 1 ? "" : "s"} ago`;
}
if (diff < DAY) {
const hours = Math.floor(diff / HOUR);
return `${hours} hour${hours === 1 ? "" : "s"} ago`;
}
if (diff < 2 * DAY) return "Yesterday";
return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
const SCROLL_END_THRESHOLD_PX = 4;
type ScrollFadeEdge = "top" | "bottom";
const SCROLL_FADE_HEIGHT = "h-8";
const scrollFadeBlurLayers: Record<ScrollFadeEdge, { blur: string; mask: string }[]> = {
bottom: [
{
blur: "backdrop-blur-[2px]",
mask: "linear-gradient(to top, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.12) 50%, transparent 100%)",
},
{
blur: "backdrop-blur-[6px]",
mask: "linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.2) 45%, transparent 100%)",
},
{
blur: "backdrop-blur-[14px]",
mask: "linear-gradient(to top, black 0%, rgba(0,0,0,0.35) 40%, transparent 100%)",
},
],
top: [
{
blur: "backdrop-blur-[2px]",
mask: "linear-gradient(to bottom, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.12) 50%, transparent 100%)",
},
{
blur: "backdrop-blur-[6px]",
mask: "linear-gradient(to bottom, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.2) 45%, transparent 100%)",
},
{
blur: "backdrop-blur-[14px]",
mask: "linear-gradient(to bottom, black 0%, rgba(0,0,0,0.35) 40%, transparent 100%)",
},
],
};
function historyListLabel(chat: { title: string | null; updatedAt: string }) {
if (chat.title && chat.title !== "New chat") return chat.title;
return formatRelativeTime(chat.updatedAt);
}
function ScrollEdgeGradientBlur({ edge, visible }: { edge: ScrollFadeEdge; visible: boolean }) {
const tintMask =
edge === "bottom"
? "linear-gradient(to top, black 0%, transparent 72%)"
: "linear-gradient(to bottom, black 0%, transparent 72%)";
return (
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-x-0 z-10 transition-opacity duration-300",
SCROLL_FADE_HEIGHT,
edge === "bottom" ? "bottom-0" : "top-0",
visible ? "opacity-100" : "opacity-0"
)}
>
{scrollFadeBlurLayers[edge].map((layer) => (
<div
key={layer.blur}
className={cn("absolute inset-0", layer.blur)}
style={{ WebkitMaskImage: layer.mask, maskImage: layer.mask }}
/>
))}
<div
className={cn(
"absolute inset-0",
edge === "bottom"
? "bg-gradient-to-t from-background-bright/35 via-background-bright/8 to-transparent"
: "bg-gradient-to-b from-background-bright/35 via-background-bright/8 to-transparent"
)}
style={{ WebkitMaskImage: tintMask, maskImage: tintMask }}
/>
</div>
);
}
function measureScrollFades(el: HTMLDivElement) {
const canScroll = el.scrollHeight > el.clientHeight + 1;
const atTop = el.scrollTop <= SCROLL_END_THRESHOLD_PX;
const atBottom =
el.scrollHeight - el.scrollTop - el.clientHeight <= SCROLL_END_THRESHOLD_PX;
return {
showTop: canScroll && !atTop,
showBottom: canScroll && !atBottom,
};
}
function ChatHistoryList({
chats,
currentChatId,
isOpen,
onSelect,
}: {
chats: { id: string; title: string | null; updatedAt: string }[];
currentChatId: string;
isOpen: boolean;
onSelect: (chatId: string) => void;
}) {
const scrollRef = useRef<HTMLDivElement | null>(null);
const [scrollFades, setScrollFades] = useState({ showTop: false, showBottom: false });
const updateScrollFades = useCallback(() => {
const el = scrollRef.current;
if (!el) {
setScrollFades({ showTop: false, showBottom: false });
return;
}
setScrollFades(measureScrollFades(el));
}, []);
const setScrollContainerRef = useCallback(
(node: HTMLDivElement | null) => {
scrollRef.current = node;
if (node) {
setScrollFades(measureScrollFades(node));
}
},
[]
);
useLayoutEffect(() => {
if (!isOpen) {
setScrollFades({ showTop: false, showBottom: false });
return;
}
updateScrollFades();
const el = scrollRef.current;
if (!el) return;
const observer = new ResizeObserver(updateScrollFades);
observer.observe(el);
const raf = requestAnimationFrame(() => {
requestAnimationFrame(updateScrollFades);
});
return () => {
observer.disconnect();
cancelAnimationFrame(raf);
};
}, [isOpen, chats, updateScrollFades]);
return (
<div className="relative">
<ScrollEdgeGradientBlur edge="top" visible={scrollFades.showTop} />
<div
ref={setScrollContainerRef}
onScroll={updateScrollFades}
className="max-h-[360px] overflow-y-auto py-1 pb-3 pt-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
>
{chats.map((chat) => {
const isActive = chat.id === currentChatId;
const hasTitle = chat.title && chat.title !== "New chat";
return (
<button
key={chat.id}
onClick={() => onSelect(chat.id)}
className={cn(
"flex w-full flex-col gap-0.5 py-2 text-left transition-colors hover:bg-charcoal-700/50",
isActive ? "border-l-2 border-indigo-500 pl-2.5 pr-3" : "px-3"
)}
>
<span className="truncate text-sm text-text-bright">{historyListLabel(chat)}</span>
{hasTitle ? (
<span className="text-xs text-text-dimmed">{formatRelativeTime(chat.updatedAt)}</span>
) : null}
</button>
);
})}
</div>
<ScrollEdgeGradientBlur edge="bottom" visible={scrollFades.showBottom} />
</div>
);
}
export function AIChatHeader() {
const { close, startNewChat, chatHistory, switchChat, currentChatId } = useAIChat();
const [historyOpen, setHistoryOpen] = useState(false);
return (
<div className="flex h-11 items-center justify-between border-b border-grid-bright px-3">
<div className="flex items-center gap-1.5">
<span className="inline-flex motion-safe:hover:animate-ai-sparkle-hover motion-reduce:hover:animate-none">
<AISparkleIcon className="size-4" />
</span>
<span className="text-sm font-medium text-text-bright">AI Assistant</span>
</div>
<div className="flex items-center gap-1">
<Popover open={historyOpen} onOpenChange={setHistoryOpen}>
<PopoverTrigger asChild>
<button
className="flex size-7 items-center justify-center rounded text-text-dimmed transition-colors hover:bg-charcoal-700 hover:text-text-bright"
title="Chat history"
>
<ClockIcon className="size-4" />
</button>
</PopoverTrigger>
<PopoverContent
align="end"
side="bottom"
sideOffset={4}
collisionPadding={12}
className="w-[min(22.125rem,calc(100vw-1.5rem))] min-w-0 overflow-hidden p-0"
style={{ maxHeight: "min(400px, var(--radix-popover-content-available-height))" }}
>
<div className="border-b border-grid-bright px-3 py-2 text-xs font-medium uppercase tracking-wider text-text-dimmed">
Chat History
</div>
{chatHistory.length === 0 ? (
<div className="py-4" />
) : (
<ChatHistoryList
chats={chatHistory}
currentChatId={currentChatId}
isOpen={historyOpen}
onSelect={(chatId) => {
switchChat(chatId);
setHistoryOpen(false);
}}
/>
)}
</PopoverContent>
</Popover>
<button
onClick={startNewChat}
className="flex size-7 items-center justify-center rounded text-text-dimmed transition-colors hover:bg-charcoal-700 hover:text-text-bright"
title="New chat"
>
<PlusIcon className="size-4" />
</button>
<Button
onClick={close}
variant="minimal/small"
TrailingIcon={ExitIcon}
shortcut={{ key: "esc" }}
shortcutPosition="before-trailing-icon"
className="pl-1"
/>
</div>
</div>
);
}
@@ -0,0 +1,109 @@
import { ArrowUpIcon, StopIcon } from "@heroicons/react/20/solid";
import { useLayoutEffect, useRef, useEffect } from "react";
import { Button } from "~/components/primitives/Buttons";
import { Spinner } from "~/components/primitives/Spinner";
interface AIChatInputProps {
input: string;
onInputChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
onSubmit: (e: React.FormEvent) => void;
onStop: () => void;
isLoading: boolean;
status: string;
// Changes when the user switches/starts a chat — used to re-focus the input.
chatId: string;
}
const INDIGO_FUCHSIA = { background: "rgba(99, 102, 241, 1)", foreground: "rgba(217, 70, 239, 1)" };
export function AIChatInput({
input,
onInputChange,
onSubmit,
onStop,
isLoading,
status,
chatId,
}: AIChatInputProps) {
const inputRef = useRef<HTMLTextAreaElement>(null);
// Focus on mount and whenever the active chat changes (e.g. switching via
// history or starting a new chat) so the user can type immediately.
useEffect(() => {
inputRef.current?.focus();
}, [chatId]);
// Auto-grow the textarea with its content. A single row collapses to the
// `min-h-8` (32px = the send-button height) so `items-end` centers them; as
// content wraps it grows and the button stays pinned to the bottom. The
// `max-h-[70px]` class caps it at 3 rows, after which it scrolls. scrollHeight
// excludes the border, so add it back to avoid a phantom scrollbar. Recompute
// on every value change and on chat switch.
useLayoutEffect(() => {
const ta = inputRef.current;
if (!ta) return;
ta.style.height = "auto";
const styles = getComputedStyle(ta);
const border = parseFloat(styles.borderTopWidth) + parseFloat(styles.borderBottomWidth);
ta.style.height = `${ta.scrollHeight + border}px`;
}, [input, chatId]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Enter submits; Shift+Enter inserts a newline.
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
};
const isStreaming = status === "streaming";
const isPreparing = status === "submitted";
return (
<form onSubmit={onSubmit} className="flex-shrink-0 border-t border-grid-bright p-3">
<div className="flex items-end gap-2">
<textarea
ref={inputRef}
rows={1}
value={input}
onChange={onInputChange}
onKeyDown={handleKeyDown}
placeholder="Ask a question…"
disabled={isLoading}
autoFocus
className="min-h-8 max-h-[70px] flex-1 resize-none overflow-y-auto rounded-md border border-grid-bright bg-background-dimmed px-3 py-1 text-sm leading-5 text-text-bright placeholder:text-text-dimmed scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600 focus-visible:focus-custom disabled:opacity-50"
/>
{isStreaming ? (
// Stop button — only the gradient ring spins (a separate absolutely
// positioned layer), so the stop icon itself stays still.
<button
type="button"
onClick={onStop}
title="Stop generating"
className="group relative flex size-8 min-w-8 items-center justify-center overflow-hidden rounded-full"
>
<span className="absolute inset-0 animate-spin bg-gradient-to-br from-indigo-500 via-purple-500 to-fuchsia-500 [animation-duration:3s]" />
<span className="relative z-10 flex size-[calc(100%-2px)] items-center justify-center rounded-full bg-charcoal-600 transition group-hover:bg-charcoal-550">
<StopIcon className="size-4 text-indigo-500 group-hover:text-indigo-400" />
</span>
</button>
) : isPreparing ? (
// Preparing — static gradient ring + spinner, not interactive.
<div className="size-8 min-w-8 rounded-full bg-gradient-to-br from-indigo-500 via-purple-500 to-fuchsia-500 p-px">
<div className="flex size-full items-center justify-center rounded-full bg-charcoal-600">
<Spinner className="size-4" color={INDIGO_FUCHSIA} />
</div>
</div>
) : (
<Button
type="submit"
disabled={!input.trim()}
LeadingIcon={<ArrowUpIcon className="size-4 text-text-bright" />}
variant="primary/small"
className="size-8 min-w-8 rounded-full"
/>
)}
</div>
</form>
);
}
@@ -0,0 +1,505 @@
import {
ArrowTopRightOnSquareIcon,
ChevronDownIcon,
ChevronRightIcon,
ExclamationTriangleIcon,
HandThumbDownIcon,
HandThumbUpIcon,
SparklesIcon,
} from "@heroicons/react/20/solid";
import { useNavigate } from "@remix-run/react";
import { motion } from "framer-motion";
import { useState, useEffect, useRef } from "react";
import DOMPurify from "dompurify";
import { marked } from "marked";
import type { UIMessage } from "ai";
import { useAutoScrollToBottom } from "~/hooks/useAutoScrollToBottom";
import { AIChatToolCall } from "./AIChatToolCall";
import { FailureSummaryCard, FilterChips, MiniTable } from "./AIChatToolResults";
import { toolLabels } from "~/lib/ai-assistant/tool-schemas";
import { useAIChat } from "./AIChatProvider";
interface AIChatMessagesProps {
messages: UIMessage[];
status: string;
error: Error | undefined;
onRetry: () => void;
onSendMessage?: (text: string) => void;
}
// User has scrolled this many px up from the bottom before the
// "scroll to bottom" affordance appears.
const SCROLL_BUTTON_THRESHOLD_PX = 100;
export function AIChatMessages({
messages,
status,
error,
onRetry,
onSendMessage,
}: AIChatMessagesProps) {
const navigate = useNavigate();
const { requestTestFill } = useAIChat();
const autoScrollRef = useAutoScrollToBottom([messages]);
const [scrollContainer, setScrollContainer] = useState<HTMLDivElement | null>(null);
const [showScrollButton, setShowScrollButton] = useState(false);
const navigatedMessagesRef = useRef(new Set<string>());
const prevMessagesLengthRef = useRef(0);
const isStreaming = status === "streaming" || status === "submitted";
// Auto-navigate only during live chat, not on history load
useEffect(() => {
// Only check for navigation during active streaming
if (!isStreaming) return;
const lastMessage = messages[messages.length - 1];
if (!lastMessage || lastMessage.role !== "assistant") return;
lastMessage.parts.forEach((part, idx) => {
const toolPart = part as any;
if (toolPart.type === "dynamic-tool" || toolPart.type.startsWith("tool-")) {
const toolName =
toolPart.type === "dynamic-tool"
? toolPart.toolName ?? "tool"
: toolPart.type.slice("tool-".length);
if (toolName === "navigateToPage" && toolPart.state === "output-available") {
const key = `${lastMessage.id}-${idx}`;
if (!navigatedMessagesRef.current.has(key)) {
const result = toolPart.output as {
found: boolean;
url?: string;
};
if (result?.found && result.url) {
navigatedMessagesRef.current.add(key);
navigate(result.url);
}
}
}
// Fill the Test page editor with a generated payload (the Test page for
// the matching task consumes it). Fire-and-forget; not deduped per-key
// because re-applying the same payload is harmless.
if (toolName === "generateTestPayload" && toolPart.state === "output-available") {
const key = `${lastMessage.id}-${idx}`;
if (!navigatedMessagesRef.current.has(key)) {
const result = toolPart.output as {
success?: boolean;
taskIdentifier?: string;
payload?: string;
};
if (result?.success && result.taskIdentifier && result.payload) {
navigatedMessagesRef.current.add(key);
requestTestFill({ taskIdentifier: result.taskIdentifier, payload: result.payload });
}
}
}
// Navigate to the run that runTestTask just triggered so the user can watch it.
if (toolName === "runTestTask" && toolPart.state === "output-available") {
const key = `${lastMessage.id}-${idx}`;
if (!navigatedMessagesRef.current.has(key)) {
const result = toolPart.output as { success?: boolean; url?: string };
if (result?.success && result.url) {
navigatedMessagesRef.current.add(key);
navigate(result.url);
}
}
}
}
});
}, [messages, status, navigate, requestTestFill, isStreaming]);
// Feedback bar only attaches to the most recent assistant turn.
let lastAssistantIndex = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "assistant") {
lastAssistantIndex = i;
break;
}
}
const handleScroll = () => {
if (!scrollContainer) return;
const distanceFromBottom =
scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight;
setShowScrollButton(distanceFromBottom > SCROLL_BUTTON_THRESHOLD_PX);
};
const scrollToBottom = () => {
scrollContainer?.scrollTo({ top: scrollContainer.scrollHeight, behavior: "smooth" });
};
return (
<div className="relative flex min-h-0 flex-1 flex-col">
<div
ref={setScrollContainer}
onScroll={handleScroll}
className="flex-1 overflow-y-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
>
<div ref={autoScrollRef}>
{messages.map((message, msgIndex) => (
<div key={message.id} className="mb-4">
{message.role === "user" && (
<div className="py-2 text-sm font-semibold text-text-bright">
{message.parts
.filter((p): p is Extract<typeof p, { type: "text" }> => p.type === "text")
.map((p, i) => (
<span key={i}>{p.text}</span>
))}
</div>
)}
{message.role === "assistant" && (
<div className="space-y-1">
{message.parts.map((part, i) => {
if (part.type === "text") {
return (
<div
key={i}
className="prose prose-invert prose-sm max-w-none break-words text-text-dimmed prose-headings:text-text-bright prose-a:text-indigo-400 prose-code:text-text-bright prose-pre:overflow-x-auto prose-pre:bg-charcoal-800 prose-pre:text-xs"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(marked(part.text) as string),
}}
/>
);
}
// AI SDK v6 tool parts: `tool-${name}` (typed) or "dynamic-tool".
if (part.type === "dynamic-tool" || part.type.startsWith("tool-")) {
const toolPart = part as {
type: string;
state: string;
toolName?: string;
input?: unknown;
output?: unknown;
};
const toolName =
part.type === "dynamic-tool"
? toolPart.toolName ?? "tool"
: part.type.slice("tool-".length);
// Show spinner while the tool input is being produced / called.
if (
toolPart.state === "input-streaming" ||
toolPart.state === "input-available"
) {
return (
<AIChatToolCall key={i} toolName={toolName} state={toolPart.state} />
);
}
// Render navigation results as a clickable link card.
if (toolName === "navigateToPage" && toolPart.state === "output-available") {
const result = toolPart.output as {
found: boolean;
url?: string;
pageName?: string;
description?: string;
message?: string;
};
if (result?.found && result.url) {
const url = result.url;
return (
<div key={i} className="space-y-2">
<ToolResultCard toolName={toolName} input={toolPart.input} output={toolPart.output} />
<button
type="button"
onClick={() => navigate(url)}
className="group w-full flex items-center gap-2.5 rounded-md border border-grid-bright bg-charcoal-800/40 px-3 py-2 text-left transition-colors animate-in fade-in slide-in-from-bottom-1 duration-150 hover:border-indigo-500/50 hover:bg-charcoal-800/60"
>
<ArrowTopRightOnSquareIcon className="size-4 shrink-0 text-text-dimmed group-hover:text-indigo-400" />
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm text-text-bright group-hover:text-indigo-400">
{result.pageName}
</span>
{result.description && (
<span className="text-xs text-text-dimmed">
{result.description}
</span>
)}
</span>
</button>
</div>
);
}
}
// Render structured outputs as rich cards, falling back
// to a collapsible JSON view for everything else.
if (toolPart.state === "output-available") {
return (
<ToolOutput
key={i}
toolName={toolName}
input={toolPart.input}
output={toolPart.output}
onSendMessage={onSendMessage}
/>
);
}
return null;
}
return null;
})}
</div>
)}
{message.role === "assistant" && msgIndex === lastAssistantIndex && !isStreaming && (
<FeedbackBar key={message.id} />
)}
</div>
))}
{error && (
<div className="flex items-start gap-2 rounded-md border border-rose-500/30 bg-rose-500/5 px-3 py-2.5">
<ExclamationTriangleIcon className="mt-0.5 size-4 shrink-0 text-rose-400" />
<div className="flex flex-col">
<span className="text-sm text-rose-300">
{error.message || "Something went wrong. Please try again."}
</span>
<button
type="button"
onClick={onRetry}
className="mt-1 w-fit text-xs text-rose-400 underline transition-colors hover:text-rose-300"
>
Try again
</button>
</div>
</div>
)}
</div>
</div>
{showScrollButton && (
<button
type="button"
onClick={scrollToBottom}
className="absolute bottom-3 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-grid-bright bg-charcoal-700 px-3 py-1.5 shadow-md transition-colors animate-in fade-in slide-in-from-bottom-2 duration-150 hover:bg-charcoal-600"
>
<ChevronDownIcon className="size-3.5 text-text-dimmed" />
<span className="text-xs text-text-dimmed">New messages</span>
</button>
)}
</div>
);
}
// Dispatches a completed tool result to its specialized renderer, falling back
// to a collapsible JSON card when there's no dedicated view (or the tool errored).
function ToolOutput({
toolName,
input,
output,
onSendMessage,
}: {
toolName: string;
input: unknown;
output: unknown;
onSendMessage?: (text: string) => void;
}) {
const result = output as Record<string, unknown> | undefined;
if (toolName === "classifyFailure" && result?.category) {
return (
<FailureSummaryCard
result={result as any}
runFriendlyId={(input as { runFriendlyId?: string })?.runFriendlyId}
onSendMessage={onSendMessage}
/>
);
}
if (toolName === "applyRunFilters" && result?.success && result.filters) {
return <FilterChips filters={result.filters as any} />;
}
if (toolName === "listRuns" && Array.isArray(result?.runs) && result.runs.length > 0) {
const runs = result.runs as Array<{ id: string; status?: string; duration?: string }>;
const columns = ["Run", "Status", "Duration"];
const rows = runs.map((r) => [r.id, friendlyStatus(r.status), r.duration ?? "—"]);
return <MiniTable columns={columns} rows={rows} />;
}
if (toolName === "aggregateRuns" && Array.isArray(result?.results) && result.results.length > 0) {
const groupBy = String(result.groupBy ?? "Group");
const metric = String(result.metric ?? "Value");
const columns = [capitalize(groupBy), capitalize(metric)];
const rows = (result.results as Array<{ dimension: unknown; value: unknown }>).map((r) => [
r.dimension,
r.value,
]);
return <MiniTable columns={columns} rows={rows} />;
}
if (toolName === "queryRuns" && result?.success && Array.isArray(result.results) && result.results.length > 0) {
const data = result.results as Array<Record<string, unknown>>;
const columns = Object.keys(data[0]);
const rows = data.map((row) => columns.map((c) => row[c]));
return <MiniTable columns={columns} rows={rows} />;
}
if (toolName === "listTestableTasks" && Array.isArray(result?.tasks) && result.tasks.length > 0) {
const tasks = result.tasks as Array<{ taskIdentifier: string; triggerSource?: string }>;
const columns = ["Task", "Type"];
const rows = tasks.map((t) => [t.taskIdentifier, friendlyStatus(t.triggerSource)]);
return <MiniTable columns={columns} rows={rows} />;
}
if (toolName === "generateTestPayload" && result?.success && typeof result.payload === "string") {
return <GeneratedPayloadCard payload={result.payload} />;
}
if (toolName === "runTestTask" && result?.success && result.runId) {
return (
<TestRunCard
taskIdentifier={String(result.taskIdentifier ?? "")}
runId={String(result.runId)}
url={result.url ? String(result.url) : undefined}
/>
);
}
return <ToolResultCard toolName={toolName} input={input} output={output} />;
}
function capitalize(value: string) {
return value.charAt(0).toUpperCase() + value.slice(1);
}
function friendlyStatus(status?: string) {
if (!status) return "—";
return capitalize(status.replace(/_/g, " ").toLowerCase());
}
function GeneratedPayloadCard({ payload }: { payload: string }) {
return (
<div className="my-1 overflow-hidden rounded-md border border-grid-bright bg-charcoal-800/40">
<div className="flex items-center gap-1.5 border-b border-grid-bright px-3 py-1.5">
<SparklesIcon className="size-3.5 shrink-0 text-indigo-400" />
<span className="text-xs text-text-bright">Generated test payload</span>
<span className="ml-auto text-[10px] text-text-dimmed">filled into the editor</span>
</div>
<pre className="max-h-56 overflow-auto bg-charcoal-900 p-2.5 text-xs text-text-dimmed">
{payload}
</pre>
</div>
);
}
function TestRunCard({
taskIdentifier,
runId,
url,
}: {
taskIdentifier: string;
runId: string;
url?: string;
}) {
const navigate = useNavigate();
return (
<button
type="button"
onClick={() => url && navigate(url)}
disabled={!url}
className="group my-1 flex w-full items-center gap-2.5 rounded-md border border-green-500/30 bg-green-500/5 px-3 py-2 text-left transition-colors animate-in fade-in slide-in-from-bottom-1 duration-150 hover:border-green-500/50 enabled:hover:bg-green-500/10"
>
<ArrowTopRightOnSquareIcon className="size-4 shrink-0 text-green-400" />
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm text-text-bright group-hover:text-green-300">
Test run triggered{taskIdentifier ? `${taskIdentifier}` : ""}
</span>
<span className="truncate text-xs text-text-dimmed">{runId}</span>
</span>
</button>
);
}
function ToolResultCard({
toolName,
input,
output,
}: {
toolName: string;
input: unknown;
output: unknown;
}) {
const [isExpanded, setIsExpanded] = useState(false);
const label = toolLabels[toolName] || `Running ${toolName}`;
return (
<div className="my-1">
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="group inline-flex items-center gap-1.5 text-xs transition-colors hover:text-indigo-400"
>
{isExpanded ? (
<ChevronDownIcon className="size-3.5 shrink-0 text-indigo-400" />
) : (
<ChevronRightIcon className="size-3.5 shrink-0 text-text-dimmed group-hover:text-indigo-400" />
)}
<SparklesIcon className="size-3 shrink-0 text-indigo-400" />
<span className="text-text-bright group-hover:text-indigo-400">{label}</span>
</button>
{isExpanded && (
<div className="mt-2 ml-4 space-y-2 text-xs">
{input && (
<div>
<div className="text-text-dimmed mb-1">Input:</div>
<pre className="bg-charcoal-900 rounded p-2 overflow-x-auto text-text-dimmed text-xs max-h-48 overflow-y-auto">
{JSON.stringify(input, null, 2)}
</pre>
</div>
)}
{output && (
<div>
<div className="text-text-dimmed mb-1">Output:</div>
<pre className="bg-charcoal-900 rounded p-2 overflow-x-auto text-text-dimmed text-xs max-h-48 overflow-y-auto">
{JSON.stringify(output, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
);
}
function FeedbackBar() {
const [submitted, setSubmitted] = useState(false);
if (submitted) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="pt-1 text-xs text-text-dimmed"
>
Thanks for the feedback
</motion.div>
);
}
return (
<div className="flex items-center gap-1 pt-1">
<button
type="button"
onClick={() => setSubmitted(true)}
title="Good response"
className="rounded p-1 transition-colors hover:bg-charcoal-700"
>
<HandThumbUpIcon className="size-3.5 text-text-dimmed transition-colors hover:text-green-400" />
</button>
<button
type="button"
onClick={() => setSubmitted(true)}
title="Bad response"
className="rounded p-1 transition-colors hover:bg-charcoal-700"
>
<HandThumbDownIcon className="size-3.5 text-text-dimmed transition-colors hover:text-rose-400" />
</button>
</div>
);
}
@@ -0,0 +1,186 @@
import { useChat } from "@ai-sdk/react";
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { dashboardAssistant } from "~/trigger/ai-assistant";
import { useAIChat } from "./AIChatProvider";
import { AIChatHeader } from "./AIChatHeader";
import { AIChatContextBanner } from "./AIChatContextBanner";
import { AIChatMessages } from "./AIChatMessages";
import { AIChatSuggestedPrompts } from "./AIChatSuggestedPrompts";
import { AIChatInput } from "./AIChatInput";
async function postAssistant(body: Record<string, unknown>): Promise<any> {
const res = await fetch("/resources/ai-assistant", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`ai-assistant ${body.intent} failed: ${res.status}`);
}
return res.json();
}
export function AIChatPanel() {
const {
currentChatId,
currentChatMessages,
pageContext,
isOpen,
close,
pendingQuery,
clearPendingQuery,
refreshHistory,
} = useAIChat();
const panelRef = useRef<HTMLDivElement>(null);
const [input, setInput] = useState("");
const transport = useTriggerChatTransport<typeof dashboardAssistant>({
task: "dashboard-assistant",
// Head Start intentionally disabled: running every turn inside the agent
// run is what surfaces the LLM + tool-call spans in the trace (at the cost
// of ~750ms first-token).
baseURL: typeof window !== "undefined" ? window.location.origin : undefined,
clientData: pageContext,
// Mint a fresh session-scoped PAT. Fired on first use + 401/403 refresh.
accessToken: async ({ chatId }) => {
const { publicAccessToken } = await postAssistant({
intent: "refreshToken",
chatId,
clientData: pageContext,
});
return publicAccessToken;
},
// Create (or resume) the session + trigger the first run server-side.
startSession: async ({ chatId }) => {
const { publicAccessToken } = await postAssistant({
intent: "createSession",
chatId,
clientData: pageContext,
});
return { publicAccessToken };
},
});
const {
messages,
sendMessage,
status,
stop: aiStop,
error,
regenerate,
} = useChat({
id: currentChatId,
messages: currentChatMessages,
transport,
resume: (currentChatMessages?.length ?? 0) > 0,
});
const stop = useCallback(() => {
transport.stopGeneration(currentChatId);
aiStop();
}, [transport, currentChatId, aiStop]);
// Warm the agent run when the panel opens so it's waiting on `session.in`
// by the time the user sends — a cold boot races the first message ahead of
// the run's waitpoint and silently drops the turn.
useEffect(() => {
void transport.preload(currentChatId);
}, [transport, currentChatId]);
const submit = useCallback(
(text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
void sendMessage({ text: trimmed });
setInput("");
},
[sendMessage]
);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
submit(input);
},
[submit, input]
);
// Close on Escape, but only when focus is inside the panel — a global
// Escape listener would hijack the key from menus/inputs elsewhere.
useEffect(() => {
if (!isOpen) return;
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape" && panelRef.current?.contains(document.activeElement)) {
close();
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [isOpen, close]);
// A pending query is set when the assistant is opened with an initial
// question (e.g. from a "Ask AI about this" affordance). Send it once.
const sentPending = useRef(false);
useEffect(() => {
if (pendingQuery && !sentPending.current) {
sentPending.current = true;
submit(pendingQuery);
clearPendingQuery();
}
if (!pendingQuery) {
sentPending.current = false;
}
}, [pendingQuery, submit, clearPendingQuery]);
const prevStatus = useRef(status);
useEffect(() => {
if (prevStatus.current === "streaming" && status === "ready") {
refreshHistory();
}
prevStatus.current = status;
}, [status, refreshHistory]);
const isLoading = status === "submitted" || status === "streaming";
const isEmpty = messages.length === 0;
return (
<div
ref={panelRef}
className="flex h-full w-[380px] flex-col border-l border-grid-bright bg-background-bright animate-in fade-in slide-in-from-right-2 duration-200"
>
<AIChatHeader />
<AIChatContextBanner
projectSlug={pageContext.projectSlug}
environmentSlug={pageContext.environmentSlug}
currentPage={pageContext.currentPage}
/>
{isEmpty ? (
<div className="flex-1 overflow-y-auto py-3">
<AIChatSuggestedPrompts currentPage={pageContext.currentPage} onSelect={submit} />
</div>
) : (
<AIChatMessages
messages={messages}
status={status}
error={error}
onRetry={() => void regenerate()}
onSendMessage={submit}
/>
)}
<AIChatInput
input={input}
onInputChange={(e) => setInput(e.target.value)}
onSubmit={handleSubmit}
onStop={stop}
isLoading={isLoading}
status={status}
chatId={currentChatId}
/>
</div>
);
}
@@ -0,0 +1,246 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { useParams, useLocation } from "@remix-run/react";
import type { UIMessage } from "ai";
interface PageContext {
userId: string;
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
currentPage: string;
currentParams: Record<string, string>;
}
interface ChatHistoryEntry {
id: string;
title: string;
updatedAt: string;
}
interface SessionState {
publicAccessToken: string;
lastEventId: string | null;
}
// A payload the assistant generated and wants filled into the Test page editor.
// The Test page for the matching task consumes it and clears it.
interface PendingTestFill {
taskIdentifier: string;
payload: string;
}
interface AIChatContextValue {
isOpen: boolean;
toggle: () => void;
open: (initialQuery?: string) => void;
close: () => void;
currentChatId: string;
startNewChat: () => void;
switchChat: (chatId: string) => void;
chatHistory: ChatHistoryEntry[];
refreshHistory: () => void;
currentChatMessages: UIMessage[] | undefined;
sessionState: SessionState | undefined;
pageContext: PageContext;
pendingQuery: string | undefined;
clearPendingQuery: () => void;
pendingTestFill: PendingTestFill | undefined;
requestTestFill: (fill: PendingTestFill) => void;
clearTestFill: () => void;
}
const AIChatContext = createContext<AIChatContextValue | null>(null);
export function useAIChat() {
const ctx = useContext(AIChatContext);
if (!ctx) {
throw new Error("useAIChat must be used within an AIChatProvider");
}
return ctx;
}
/**
* Like useAIChat, but returns null instead of throwing when there is no
* provider. The provider is only mounted inside the project layout, so
* components rendered on account/org-settings pages (e.g. the global NavBar
* AskAI button) use this to no-op when the assistant isn't available.
*/
export function useOptionalAIChat() {
return useContext(AIChatContext);
}
function generateChatId() {
return `chat_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
}
function usePageContext(userId: string): PageContext {
const params = useParams();
const location = useLocation();
const segments = location.pathname.split("/").filter(Boolean);
const currentPage = segments[segments.length - 1] ?? "overview";
return {
userId,
organizationSlug: params.organizationSlug ?? "",
projectSlug: params.projectParam ?? "",
environmentSlug: params.envParam ?? "",
currentPage,
currentParams: params as Record<string, string>,
};
}
export function AIChatProvider({
userId,
children,
}: {
userId: string;
children: ReactNode;
}) {
const [isOpen, setIsOpen] = useState(false);
const [currentChatId, setCurrentChatId] = useState(() => generateChatId());
const [chatHistory, setChatHistory] = useState<ChatHistoryEntry[]>([]);
const [currentChatMessages, setCurrentChatMessages] = useState<UIMessage[] | undefined>();
const [sessionState, setSessionState] = useState<SessionState | undefined>();
const [pendingQuery, setPendingQuery] = useState<string | undefined>();
const [pendingTestFill, setPendingTestFill] = useState<PendingTestFill | undefined>();
const switchRequestRef = useRef<string | undefined>(undefined);
const pageContext = usePageContext(userId);
const refreshHistory = useCallback(async () => {
try {
const res = await fetch("/resources/ai-assistant/history");
if (res.ok) {
const data = (await res.json()) as { chats?: ChatHistoryEntry[] };
setChatHistory(data.chats ?? []);
}
} catch {
// Silently fail — history is non-critical
}
}, []);
const open = useCallback(
(initialQuery?: string) => {
if (initialQuery) {
setPendingQuery(initialQuery);
}
setIsOpen(true);
},
[]
);
const close = useCallback(() => {
setIsOpen(false);
setPendingQuery(undefined);
}, []);
const toggle = useCallback(() => {
setIsOpen((prev) => {
if (prev) {
setPendingQuery(undefined);
}
return !prev;
});
}, []);
const startNewChat = useCallback(() => {
const newChatId = generateChatId();
switchRequestRef.current = newChatId;
setCurrentChatId(newChatId);
setCurrentChatMessages(undefined);
setSessionState(undefined);
setPendingQuery(undefined);
}, []);
const switchChat = useCallback(
async (chatId: string) => {
switchRequestRef.current = chatId;
setPendingQuery(undefined);
try {
const res = await fetch(`/resources/ai-assistant/chat/${chatId}`);
if (switchRequestRef.current !== chatId) return;
if (res.ok) {
const data = (await res.json()) as {
chat?: { title?: string; messages?: UIMessage[] };
session?: SessionState;
};
if (switchRequestRef.current !== chatId) return;
// Messages must be set before the id: useChat reads `messages` only
// when `id` changes, so both must land in the same render.
setCurrentChatMessages(data.chat?.messages ?? []);
setSessionState(data.session ?? undefined);
setCurrentChatId(chatId);
if (data.chat?.title) {
setChatHistory((prev) =>
prev.map((c) => (c.id === chatId ? { ...c, title: data.chat!.title! } : c))
);
}
} else {
setCurrentChatMessages([]);
setSessionState(undefined);
setCurrentChatId(chatId);
}
} catch {
if (switchRequestRef.current !== chatId) return;
setCurrentChatMessages([]);
setSessionState(undefined);
setCurrentChatId(chatId);
}
},
[]
);
const clearPendingQuery = useCallback(() => {
setPendingQuery(undefined);
}, []);
const requestTestFill = useCallback((fill: PendingTestFill) => {
setPendingTestFill(fill);
}, []);
const clearTestFill = useCallback(() => {
setPendingTestFill(undefined);
}, []);
// Load history when panel opens
useEffect(() => {
if (isOpen) {
refreshHistory();
}
}, [isOpen, refreshHistory]);
return (
<AIChatContext.Provider
value={{
isOpen,
toggle,
open,
close,
currentChatId,
startNewChat,
switchChat,
chatHistory,
refreshHistory,
currentChatMessages,
sessionState,
pageContext,
pendingQuery,
clearPendingQuery,
pendingTestFill,
requestTestFill,
clearTestFill,
}}
>
{children}
</AIChatContext.Provider>
);
}
@@ -0,0 +1,70 @@
import { motion } from "framer-motion";
import { SparkleListIcon } from "~/assets/icons/SparkleListIcon";
import { Paragraph } from "~/components/primitives/Paragraph";
import { useAIChat } from "./AIChatProvider";
import { getPrompts } from "./suggested-prompts";
interface AIChatSuggestedPromptsProps {
currentPage: string;
onSelect: (prompt: string) => void;
}
// Stagger the pills in from the right — same motion vocabulary as the legacy
// AskAI suggested prompts so the assistant feels consistent across surfaces.
const container = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.1, delayChildren: 0.2 },
},
};
const item = {
hidden: { opacity: 0, x: 20 },
visible: {
opacity: 1,
x: 0,
transition: {
opacity: { duration: 0.5, ease: "linear" },
x: { type: "spring", stiffness: 300, damping: 25 },
},
},
};
export function AIChatSuggestedPrompts({ currentPage, onSelect }: AIChatSuggestedPromptsProps) {
const prompts = getPrompts(currentPage);
// The panel stays mounted across close/open, so this component never
// unmounts. Keying the motion container on `isOpen` remounts it on each
// open, replaying the stagger every time rather than only on first mount.
const { isOpen } = useAIChat();
return (
<div className="flex flex-col gap-2 px-3 pb-2">
<Paragraph className="mb-2 mt-1.5 pl-1 text-text-dimmed">
I can help you navigate the dashboard, find documentation, and understand Trigger.dev
features. Ask me anything.
</Paragraph>
<motion.div
key={isOpen ? "open" : "closed"}
className="flex flex-col gap-2"
variants={container}
initial="hidden"
animate="visible"
>
{prompts.map((prompt, index) => (
<motion.button
key={index}
variants={item}
className="group flex w-fit items-center gap-2 rounded-full border border-dashed border-charcoal-600 px-4 py-2 text-left transition-colors hover:border-solid hover:border-indigo-500"
onClick={() => onSelect(prompt)}
>
<SparkleListIcon className="size-4 shrink-0 text-text-dimmed transition group-hover:text-indigo-500" />
<Paragraph variant="small" className="transition group-hover:text-text-bright">
{prompt}
</Paragraph>
</motion.button>
))}
</motion.div>
</div>
);
}
@@ -0,0 +1,50 @@
import { Spinner } from "~/components/primitives/Spinner";
const TOOL_LABELS: Record<string, string> = {
// V1A - Docs and Navigation
searchDocs: "Searching documentation…",
navigateToPage: "Finding page…",
getCurrentContext: "Checking context…",
searchPages: "Searching pages…",
// V1B - Runs
listRuns: "Querying runs…",
getRunDetails: "Loading run details…",
getRunLogs: "Fetching logs…",
getRunGraph: "Building run graph…",
applyRunFilters: "Building filters…",
queryRuns: "Running analytics query…",
// V1B - Errors
listErrors: "Loading error groups…",
getErrorDetails: "Loading error details…",
findSimilarErrors: "Searching error history…",
classifyFailure: "Classifying failure…",
// V1B - Analytics
summarizeCurrentView: "Analyzing current view…",
aggregateRuns: "Computing aggregations…",
correlateRunsWithDeploy: "Checking deploy correlation…",
};
interface AIChatToolCallProps {
toolName: string;
state: string;
}
export function AIChatToolCall({ toolName, state }: AIChatToolCallProps) {
const label = TOOL_LABELS[toolName] ?? `Running ${toolName}`;
const isRunning = state === "input-streaming" || state === "input-available";
if (!isRunning) return null;
return (
<div className="flex items-center gap-2 py-2 text-xs text-text-dimmed">
<Spinner
className="size-3.5"
color={{ background: "rgba(99, 102, 241, 1)", foreground: "rgba(217, 70, 239, 1)" }}
/>
<span>{label}</span>
</div>
);
}
@@ -0,0 +1,277 @@
import { useNavigate } from "@remix-run/react";
import { useState } from "react";
import type { TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
import { v3RunPath, v3RunsPath } from "~/utils/pathBuilder";
import { useAIChat } from "./AIChatProvider";
// ---------------------------------------------------------------------------
// Failure summary card — renders the structured output of `classifyFailure`.
// ---------------------------------------------------------------------------
interface FailureClassification {
category: string;
confidence: string;
evidence: string;
nextSteps: string[];
}
const CATEGORY_BADGE: Record<string, string> = {
Timeout: "bg-amber-500/10 text-amber-400",
"OOM / Memory": "bg-rose-500/10 text-rose-400",
"Missing env var": "bg-yellow-500/10 text-yellow-400",
"Child task failed": "bg-orange-500/10 text-orange-400",
"User code exception": "bg-rose-500/10 text-rose-400",
"AI provider rate limit": "bg-amber-500/10 text-amber-400",
"Deploy regression": "bg-purple-500/10 text-purple-400",
"Platform issue": "bg-charcoal-600 text-text-dimmed",
Unknown: "bg-charcoal-600 text-text-dimmed",
};
export function FailureSummaryCard({
result,
runFriendlyId,
onSendMessage,
}: {
result: FailureClassification;
runFriendlyId?: string;
onSendMessage?: (text: string) => void;
}) {
const navigate = useNavigate();
const { pageContext } = useAIChat();
const badge = CATEGORY_BADGE[result.category] ?? CATEGORY_BADGE.Unknown;
const openRun = () => {
if (!runFriendlyId) return;
navigate(
v3RunPath(
{ slug: pageContext.organizationSlug },
{ slug: pageContext.projectSlug },
{ slug: pageContext.environmentSlug },
{ friendlyId: runFriendlyId }
)
);
};
return (
<div className="my-1 flex flex-col gap-2 rounded-md border border-rose-500/20 bg-rose-500/5 p-3 animate-in fade-in slide-in-from-bottom-1 duration-150">
<span
className={`inline-flex w-fit items-center rounded-full px-2 py-0.5 text-xs font-medium ${badge}`}
>
{result.category}
</span>
<span className="text-xs text-text-dimmed">{result.confidence} confidence</span>
{result.evidence && (
<div className="text-xs text-text-dimmed">
<span className="font-medium text-text-bright">Evidence: </span>
{result.evidence}
</div>
)}
{result.nextSteps?.length > 0 && (
<ol className="ml-4 list-decimal space-y-0.5 text-xs text-text-bright">
{result.nextSteps.map((step, i) => (
<li key={i}>{step}</li>
))}
</ol>
)}
{(runFriendlyId || onSendMessage) && (
<div className="mt-1 flex items-center gap-2 border-t border-grid-bright pt-1">
{runFriendlyId && (
<button
type="button"
onClick={openRun}
className="cursor-pointer text-xs text-indigo-400 hover:text-indigo-300"
>
Open run
</button>
)}
{runFriendlyId && onSendMessage && (
<button
type="button"
onClick={() => onSendMessage(`Show me the run graph for ${runFriendlyId}`)}
className="cursor-pointer text-xs text-indigo-400 hover:text-indigo-300"
>
Show run graph
</button>
)}
{onSendMessage && (
<button
type="button"
onClick={() =>
onSendMessage(
runFriendlyId
? `Find errors similar to the failure in run ${runFriendlyId}`
: "Find similar errors"
)
}
className="cursor-pointer text-xs text-indigo-400 hover:text-indigo-300"
>
Find similar errors
</button>
)}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Filter chips — renders the structured output of `applyRunFilters`.
// ---------------------------------------------------------------------------
const FILTER_LABELS: Record<string, string> = {
statuses: "Status",
tasks: "Task",
tags: "Tag",
versions: "Version",
queues: "Queue",
machines: "Machine",
sources: "Source",
period: "Period",
from: "From",
to: "To",
batchId: "Batch",
runId: "Run",
scheduleId: "Schedule",
};
function humanizeStatus(status: string) {
const lower = status.replace(/_/g, " ").toLowerCase();
return lower.charAt(0).toUpperCase() + lower.slice(1);
}
function buildChips(filters: Record<string, unknown>): string[] {
const chips: string[] = [];
for (const [key, value] of Object.entries(filters)) {
if (value === undefined || value === null) continue;
if (key === "rootOnly" && value === true) {
chips.push("Root runs only");
continue;
}
const label = FILTER_LABELS[key];
if (!label) continue;
const display = (v: unknown) => (key === "statuses" ? humanizeStatus(String(v)) : String(v));
if (Array.isArray(value)) {
for (const v of value) chips.push(`${label}: ${display(v)}`);
} else {
chips.push(`${label}: ${display(value)}`);
}
}
return chips;
}
export function FilterChips({ filters }: { filters: TaskRunListSearchFilters }) {
const navigate = useNavigate();
const { pageContext } = useAIChat();
const chips = buildChips(filters as Record<string, unknown>);
if (chips.length === 0) {
return <div className="py-1 text-xs text-text-dimmed">No filters detected.</div>;
}
const applyFilters = () => {
navigate(
v3RunsPath(
{ slug: pageContext.organizationSlug },
{ slug: pageContext.projectSlug },
{ slug: pageContext.environmentSlug },
filters
)
);
};
return (
<div className="my-1">
<div className="flex flex-wrap gap-1.5 py-1">
{chips.map((chip, i) => (
<span
key={i}
className="inline-flex items-center gap-1 rounded-full border border-charcoal-600 bg-charcoal-800/40 px-2.5 py-1 text-xs text-text-dimmed"
>
{chip}
</span>
))}
</div>
<button
type="button"
onClick={applyFilters}
className="mt-1 cursor-pointer text-xs text-indigo-400 underline hover:text-indigo-300"
>
Apply these filters
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// Mini table — renders tabular output of `aggregateRuns` / `queryRuns`.
// ---------------------------------------------------------------------------
const MAX_VISIBLE_ROWS = 10;
function formatCell(value: unknown): string {
if (value === null || value === undefined) return "—";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
export function MiniTable({
columns,
rows,
}: {
columns: string[];
rows: unknown[][];
}) {
const [showAll, setShowAll] = useState(false);
const visibleRows = showAll ? rows : rows.slice(0, MAX_VISIBLE_ROWS);
const truncated = rows.length > MAX_VISIBLE_ROWS;
return (
<div className="my-1 overflow-hidden rounded-md border border-grid-bright">
<table className="w-full border-collapse">
<thead className="bg-charcoal-800">
<tr>
{columns.map((col, i) => (
<th
key={i}
className="px-2.5 py-1.5 text-left text-xs font-medium text-text-dimmed"
>
{col}
</th>
))}
</tr>
</thead>
<tbody>
{visibleRows.map((row, ri) => (
<tr
key={ri}
className="animate-in fade-in slide-in-from-bottom-1 duration-100 even:bg-charcoal-800/20"
style={{ animationDelay: `${ri * 30}ms` }}
>
{columns.map((_, ci) => (
<td
key={ci}
className="break-all border-t border-grid-bright px-2.5 py-1.5 text-xs text-text-bright"
>
{formatCell(row[ci])}
</td>
))}
</tr>
))}
</tbody>
</table>
{truncated && !showAll && (
<button
type="button"
onClick={() => setShowAll(true)}
className="w-full cursor-pointer border-t border-grid-bright px-2.5 py-1.5 text-left text-xs text-indigo-400 hover:text-indigo-300"
>
Show all {rows.length} rows
</button>
)}
</div>
);
}
@@ -0,0 +1,45 @@
// Page → suggested prompts. Imported by frontend components, so keep it free
// of server-side dependencies.
const DEFAULT_PROMPTS = [
"How do retries work?",
"Where do I configure concurrency?",
"How do I deploy my task?",
];
export const SUGGESTED_PROMPTS: Record<string, string[]> = {
runs: [
"How do I filter runs?",
"How do I replay a failed run?",
"What do the run statuses mean?",
],
errors: [
"How do I debug task errors?",
"How do I set up error alerts?",
"What causes SYSTEM_FAILURE?",
],
deployments: [
"How do I set up CI/CD deployments?",
"How do preview branches work?",
"How do I rollback a deployment?",
],
schedules: [
"How do I create a cron schedule?",
"How does timezone handling work?",
"Can I pause a schedule?",
],
"environment-variables": [
"How do environment variables work?",
"How do I sync env vars from Vercel?",
"Can I use different values per environment?",
],
query: [
"How does TRQL work?",
"Show me example queries",
"How do I query run metrics?",
],
};
export function getPrompts(pageId: string): string[] {
return SUGGESTED_PROMPTS[pageId] ?? DEFAULT_PROMPTS;
}
@@ -98,7 +98,6 @@ import {
v3WaitpointTokensPath,
} from "~/utils/pathBuilder";
import { AlphaBadge } from "../AlphaBadge";
import { AskAI } from "../AskAI";
import { FreePlanUsage } from "../billing/FreePlanUsage";
import { ConnectionIcon, DevPresencePanel, useDevPresence } from "../DevPresence";
import { ImpersonationBanner } from "../ImpersonationBanner";
@@ -1202,7 +1201,6 @@ function HelpAndAI({ isCollapsed, organizationId, projectId }: { isCollapsed: bo
>
<ShortcutsAutoOpen />
<HelpAndFeedback isCollapsed={isCollapsed} organizationId={organizationId} projectId={projectId} />
<AskAI isCollapsed={isCollapsed} />
</div>
</LayoutGroup>
);
@@ -6,6 +6,7 @@ import { BreadcrumbIcon } from "./BreadcrumbIcon";
import { Header2 } from "./Headers";
import { LoadingBarDivider } from "./LoadingBarDivider";
import { EnvironmentBanner } from "../navigation/EnvironmentBanner";
import { AskAI } from "../AskAI";
type WithChildren = {
children: React.ReactNode;
@@ -22,7 +23,10 @@ export function NavBar({ children }: WithChildren) {
return (
<div>
<div className="grid h-10 w-full grid-rows-[auto_1px] bg-background-bright">
<div className="flex w-full items-center justify-between pl-3 pr-2">{children}</div>
<div className="flex w-full items-center gap-2 pl-3 pr-2">
<div className="flex flex-1 items-center justify-between">{children}</div>
<AskAI />
</div>
<LoadingBarDivider isLoading={isLoading} />
</div>
{showUpgradePrompt.shouldShow && organization ? <UpgradePrompt /> : <EnvironmentBanner />}
@@ -4,13 +4,13 @@ import {
ChevronUpIcon,
ClipboardDocumentIcon,
CodeBracketSquareIcon,
DocumentTextIcon,
} from "@heroicons/react/20/solid";
import { Suspense, useEffect, useState } from "react";
import { CodeBlock } from "~/components/code/CodeBlock";
import { StreamdownRenderer } from "~/components/code/StreamdownRenderer";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Header3 } from "~/components/primitives/Headers";
import tablerSpritePath from "~/components/primitives/tabler-sprite.svg";
import type { DisplayItem, ToolUse } from "./types";
export type PromptLink = {
@@ -89,9 +89,7 @@ function SystemSection({
{promptLink && (
<LinkButton to={promptLink.path} variant="minimal/small">
<span className="flex items-center gap-1">
<svg className="size-3.5 shrink-0 text-text-dimmed">
<use xlinkHref={`${tablerSpritePath}#tabler-file-text-ai`} />
</svg>
<DocumentTextIcon className="size-3.5 shrink-0 text-text-dimmed" />
{promptLink.slug}
{promptLink.version ? ` v${promptLink.version}` : ""}
</span>
@@ -0,0 +1,318 @@
// Schema-only tool definitions. Execute functions live in
// app/trigger/ai-assistant-tools/ and spread these in, so descriptions are
// single-sourced. Keep this file dependency-light (only `ai` and `zod`) — no
// SDK runtime, Prisma, or Node built-ins.
import { tool } from "ai";
import { z } from "zod";
export const searchDocs = tool({
description:
"Search Trigger.dev documentation for guides, API reference, configuration, " +
"troubleshooting, and help articles. Use when the user asks how a feature works, " +
"how to configure something, or needs help with an error.",
inputSchema: z.object({
query: z.string().describe("Search query about Trigger.dev features or APIs"),
}),
});
export const navigateToPage = tool({
description:
"Navigate the user to a specific dashboard page, or deep-link directly to a run " +
"(and optionally a specific span/subtrace within it). Use when the user asks " +
"'where do I find X', 'take me to Y', 'show me the Z page', 'go to settings', or " +
"'open run run_…' / 'take me to that run'. Returns a URL that the frontend renders " +
"as a clickable link and auto-navigates to during live chat.",
inputSchema: z.object({
destination: z
.string()
.optional()
.describe(
"A named section to go to, e.g. 'runs page', 'environment variables', " +
"'deployment settings', 'error alerts', 'concurrency configuration'. " +
"Omit when deep-linking to a run via runId."
),
runId: z
.string()
.optional()
.describe(
"Deep-link to a specific run by its friendly ID (e.g. 'run_cmpy8wwvg0006htra3f5jtr8i'). " +
"Opens the run detail / trace view. Takes precedence over destination."
),
spanId: z
.string()
.optional()
.describe(
"When deep-linking to a run, optionally select a specific span (subtrace) in the " +
"trace view by its span ID. Requires runId."
),
testTaskId: z
.string()
.optional()
.describe(
"Deep-link to the Test page for a specific task by its task identifier (e.g. " +
"'hello-world'). Opens the test form where a payload can be filled and the task run. " +
"Use when the user wants to test/run a specific task. Takes precedence over destination."
),
}),
});
// V1C Test domain tools
export const listTestableTasks = tool({
description:
"List the tasks available to test in the current environment (the same list shown on the " +
"Test page). Use when the user asks what they can test, or to resolve a task before testing it.",
inputSchema: z.object({
query: z
.string()
.optional()
.describe("Optional substring to filter task identifiers (e.g. 'hello')"),
}),
});
export const generateTestPayload = tool({
description:
"Generate a realistic JSON payload to test a task with — the same AI payload generation used " +
"on the Test page. Conforms to the task's payload schema if it has one, otherwise infers the " +
"shape from the task's source code. When the user is on (or you have just navigated to) that " +
"task's Test page, the generated payload is filled into the editor for them. Call this before " +
"runTestTask so the run uses a sensible payload.",
inputSchema: z.object({
taskIdentifier: z.string().describe("The task identifier to generate a payload for"),
instruction: z
.string()
.optional()
.describe(
"What kind of payload to generate, e.g. 'a smoke test', 'minimal valid payload', " +
"'edge cases', 'a payload with nested objects'. Defaults to a simple valid payload."
),
}),
});
export const runTestTask = tool({
description:
"Trigger a test run of a task — the equivalent of filling the payload and clicking 'Run test' " +
"on the Test page. Only call this when the user explicitly asks to run/trigger/smoke-test a " +
"task. Pass the payload from generateTestPayload. Returns the new run's friendly ID; the UI " +
"navigates the user to the run so they can watch it.",
inputSchema: z.object({
taskIdentifier: z.string().describe("The task identifier to run"),
payload: z
.record(z.unknown())
.optional()
.describe("The JSON payload object to run the task with (from generateTestPayload). Defaults to {}"),
metadata: z
.record(z.unknown())
.optional()
.describe("Optional run metadata object"),
tags: z.array(z.string()).optional().describe("Optional run tags (max 10)"),
}),
});
export const getCurrentContext = tool({
description:
"Get information about what the user is currently viewing in the dashboard. " +
"Returns the current project, environment, page, and any active parameters. " +
"Use to ground your answers in the user's current context.",
inputSchema: z.object({}),
});
export const searchPages = tool({
description:
"Search for available dashboard pages by description. Returns matching pages " +
"with descriptions and URLs. Use when the user's destination is ambiguous or " +
"you want to suggest relevant pages.",
inputSchema: z.object({
query: z.string().describe("Description of what the user is looking for"),
}),
});
// V1B Runs domain tools
export const listRuns = tool({
description:
"List recent runs with optional filters by status, task, time period, and tags. " +
"Use to help the user find specific runs or understand run patterns.",
inputSchema: z.object({
status: z
.array(z.string())
.optional()
.describe(
"Filter by run status. Valid statuses: PENDING, DELAYED, DEQUEUED, EXECUTING, " +
"WAITING_TO_RESUME, COMPLETED_SUCCESSFULLY, COMPLETED_WITH_ERRORS, TIMED_OUT, " +
"CRASHED, SYSTEM_FAILURE, CANCELED, EXPIRED. For 'failed' runs pass " +
"[COMPLETED_WITH_ERRORS, CRASHED, TIMED_OUT, SYSTEM_FAILURE]; for 'successful' pass " +
"[COMPLETED_SUCCESSFULLY]; for 'running' pass [EXECUTING]."
),
taskIdentifier: z.string().optional().describe("Filter by task identifier"),
period: z.string().optional().describe("Time period filter (e.g., '1h', '24h', '7d')"),
tags: z.array(z.string()).optional().describe("Filter by tags"),
limit: z.number().optional().default(20).describe("Maximum number of runs to return"),
}),
});
export const getRunDetails = tool({
description:
"Get detailed information about a specific run, including status, timing, and trace summary. " +
"Use when the user wants to investigate a particular run.",
inputSchema: z.object({
runFriendlyId: z.string().describe("The friendly ID of the run"),
}),
});
export const getSpanDetails = tool({
description:
"Get the full detail of a single span (subtrace) within a run's trace, including its " +
"error/exception (message + stack trace), log level, span events, properties, and metadata. " +
"First call getRunDetails to list the trace's spans, then pass a span's `id` here as `spanId` " +
"to drill into the one that failed. Use this to answer 'look at the subtrace and tell me exactly " +
"what and why caused the error'. If the span is itself a triggered child run, also returns that " +
"run's error and output.",
inputSchema: z.object({
runFriendlyId: z
.string()
.describe("The friendly ID of the run that owns the trace (e.g. 'run_…')"),
spanId: z
.string()
.describe("The span ID to inspect — the `id` field from a span in getRunDetails' trace"),
}),
});
export const getRunLogs = tool({
description:
"Fetch log lines from a specific run, optionally filtered by log level. " +
"Returns formatted log lines with timestamps.",
inputSchema: z.object({
runFriendlyId: z.string().describe("The friendly ID of the run"),
level: z
.enum(["debug", "info", "warn", "error"])
.optional()
.describe("Filter by log level"),
limit: z.number().optional().default(50).describe("Maximum number of log lines to return"),
}),
});
export const getRunGraph = tool({
description:
"Get the hierarchical structure of a run, including parent and child runs. " +
"Useful for understanding task dependencies and call chains.",
inputSchema: z.object({
runFriendlyId: z.string().describe("The friendly ID of the run"),
}),
});
export const applyRunFilters = tool({
description:
"Convert natural language description into structured filters for the runs list. " +
"Returns filters as URL parameters that can be applied to the runs page.",
inputSchema: z.object({
description: z.string().describe("Natural language description of filters (e.g., 'failed runs in the last 24 hours')"),
}),
});
export const queryRuns = tool({
description:
"Convert natural language questions into SQL queries executed against ClickHouse. " +
"Use for analytics and trend analysis. Returns structured query results.",
inputSchema: z.object({
question: z.string().describe("Natural language question about runs (e.g., 'what is the failure rate for the email task?')"),
}),
});
// V1B Errors domain tools
export const listErrors = tool({
description:
"List error groups (unique errors) with their occurrence counts and timing. " +
"Use to identify common failure patterns and most frequent errors.",
inputSchema: z.object({
period: z.string().optional().describe("Time period (e.g., '1h', '24h', '7d')"),
taskIdentifier: z.string().optional().describe("Filter by task identifier"),
limit: z.number().optional().default(20).describe("Maximum number of error groups to return"),
}),
});
export const getErrorDetails = tool({
description:
"Get detailed information about a specific error group, including stack trace, " +
"affected runs sample, and timing. Use to understand a specific error in depth.",
inputSchema: z.object({
fingerprint: z.string().describe("The error fingerprint identifying the error group"),
}),
});
export const findSimilarErrors = tool({
description:
"Search for error groups with similar messages. Useful for finding patterns or regressions across tasks.",
inputSchema: z.object({
errorMessage: z.string().describe("The error message to search for similar errors"),
limit: z.number().optional().default(10).describe("Maximum number of similar errors to return"),
}),
});
export const classifyFailure = tool({
description:
"Classify the cause of a run failure into categories like timeout, OOM, missing env var, etc. " +
"Uses AI analysis of run details and logs to determine the most likely failure reason.",
inputSchema: z.object({
runFriendlyId: z.string().describe("The friendly ID of the run to classify"),
}),
});
// V1B Analytics domain tools
export const summarizeCurrentView = tool({
description:
"Get a summary of the current view: total runs, status distribution, top failing tasks, and error rate trend. " +
"Use to understand the overall health of the system.",
inputSchema: z.object({
period: z.string().optional().describe("Time period for summary (e.g., '1h', '24h', '7d')"),
}),
});
export const aggregateRuns = tool({
description:
"Compute aggregated metrics (count, failure rate, duration) for runs grouped by task, status, version, or queue. " +
"Use for performance analysis and bottleneck identification.",
inputSchema: z.object({
groupBy: z
.enum(["task", "status", "version", "queue"])
.describe("Dimension to group by"),
metric: z
.enum(["count", "failureRate", "avgDuration", "p95Duration"])
.optional()
.describe("Metric to compute"),
period: z.string().optional().describe("Time period (e.g., '1h', '24h', '7d')"),
}),
});
export const correlateRunsWithDeploy = tool({
description:
"Analyze failure rates by deployment version to identify deploy regressions. " +
"Shows correlation between deployments and failure patterns.",
inputSchema: z.object({
taskIdentifier: z.string().optional().describe("Filter by task identifier"),
period: z.string().optional().describe("Time period (e.g., '1h', '24h', '7d')"),
}),
});
// Tool labels for UI display (3-4 words max, action-oriented)
export const toolLabels: Record<string, string> = {
searchDocs: "Searching documentation",
navigateToPage: "Navigating to page",
getCurrentContext: "Checking current context",
searchPages: "Searching dashboard pages",
listRuns: "Querying task runs",
getRunDetails: "Loading run details",
getSpanDetails: "Inspecting subtrace",
getRunLogs: "Fetching run logs",
getRunGraph: "Building run hierarchy",
applyRunFilters: "Applying run filters",
queryRuns: "Running analytics query",
listErrors: "Loading error groups",
getErrorDetails: "Loading error details",
findSimilarErrors: "Finding similar errors",
classifyFailure: "Classifying run failure",
summarizeCurrentView: "Analyzing current view",
aggregateRuns: "Computing aggregations",
correlateRunsWithDeploy: "Checking deploy correlation",
listTestableTasks: "Listing testable tasks",
generateTestPayload: "Generating test payload",
runTestTask: "Running test task",
};
@@ -35,6 +35,7 @@ import { Select, SelectItem } from "~/components/primitives/Select";
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
import { TextLink } from "~/components/primitives/TextLink";
import { TimezoneList } from "~/components/scheduled/timezones";
import { useOptionalAIChat } from "~/components/ai-assistant/AIChatProvider";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useSearchParams } from "~/hooks/useSearchParam";
import { useParams, Form, useActionData, useFetcher, useSubmit } from "@remix-run/react";
@@ -384,6 +385,18 @@ function StandardTaskForm({
const currentPayloadJson = useRef<string>(defaultPayloadJson);
// The AI assistant can generate a payload for this task and ask us to fill the
// editor with it (matching what the on-page AI does). Consume it once.
const aiChat = useOptionalAIChat();
const pendingTestFill = aiChat?.pendingTestFill;
const clearTestFill = aiChat?.clearTestFill;
useEffect(() => {
if (!pendingTestFill || pendingTestFill.taskIdentifier !== task.taskIdentifier) return;
setPayload(pendingTestFill.payload);
currentPayloadJson.current = pendingTestFill.payload;
clearTestFill?.();
}, [pendingTestFill, task.taskIdentifier, setPayload, clearTestFill]);
const [defaultMetadataJson, setDefaultMetadataJson] = useState<string>(
lastRun?.seedMetadata ?? startingJson
);
@@ -8,6 +8,39 @@ import { useIsImpersonating, useOrganization, useOrganizations } from "~/hooks/u
import { useProject } from "~/hooks/useProject";
import { useUser } from "~/hooks/useUser";
import { v3ProjectPath } from "~/utils/pathBuilder";
import { AIChatProvider, useAIChat } from "~/components/ai-assistant/AIChatProvider";
import { AIChatPanel } from "~/components/ai-assistant/AIChatPanel";
import { useEffect, useState, type ReactNode } from "react";
function AIChatLayout({ children }: { children: ReactNode }) {
const { isOpen } = useAIChat();
// Keep the panel mounted after the first open so the conversation persists
// across toggles and so the close transition has something to animate. Lazy
// mounting avoids the input stealing focus on every page load.
const [hasOpened, setHasOpened] = useState(false);
useEffect(() => {
if (isOpen) {
setHasOpened(true);
}
}, [isOpen]);
return (
<div
className="grid overflow-hidden transition-[grid-template-columns] duration-200 ease-in-out"
style={{
gridTemplateColumns: isOpen ? "auto minmax(0, 1fr) 380px" : "auto minmax(0, 1fr) 0px",
}}
>
{children}
{/* Right-anchored + overflow-hidden so the panel slides in from the right
edge as the column grows, rather than being revealed left-to-right. */}
<div className="flex h-full justify-end overflow-hidden">
{hasOpened && <AIChatPanel />}
</div>
</div>
);
}
export default function Project() {
const organizations = useOrganizations();
@@ -18,8 +51,8 @@ export default function Project() {
const isImpersonating = useIsImpersonating();
return (
<>
<div className="grid grid-cols-[auto_1fr] overflow-hidden">
<AIChatProvider userId={user.id}>
<AIChatLayout>
<DevPresenceProvider enabled={environment.type === "DEVELOPMENT"}>
<SideMenu
user={{ ...user, isImpersonating }}
@@ -32,8 +65,8 @@ export default function Project() {
<Outlet />
</MainBody>
</DevPresenceProvider>
</div>
</>
</AIChatLayout>
</AIChatProvider>
);
}
@@ -41,4 +74,4 @@ export function ErrorBoundary() {
const org = useOrganization();
const project = useProject();
return <RouteErrorDisplay button={{ title: project.name, to: v3ProjectPath(org, project) }} />;
}
}
@@ -0,0 +1,46 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { prisma } from "~/db.server";
import { requireUserId } from "~/services/session.server";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { chatId } = params;
if (!chatId) {
return json({ error: "Missing chatId" }, { status: 400 });
}
const [chat, session] = await Promise.all([
prisma.aiChat.findFirst({
where: { id: chatId, userId },
select: {
id: true,
title: true,
messages: true,
model: true,
},
}),
prisma.aiChatSession.findFirst({
where: { id: chatId },
select: {
publicAccessToken: true,
lastEventId: true,
},
}),
]);
if (!chat) {
return json({ error: "Chat not found" }, { status: 404 });
}
return json({
chat,
session: session
? {
publicAccessToken: session.publicAccessToken,
lastEventId: session.lastEventId,
}
: null,
});
};
@@ -0,0 +1,23 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { prisma } from "~/db.server";
import { requireUserId } from "~/services/session.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const chats = await prisma.aiChat.findMany({
where: { userId, NOT: { messages: { equals: [] } } },
select: { id: true, title: true, updatedAt: true },
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
take: 50,
});
return json({
chats: chats.map((c) => ({
id: c.id,
title: c.title,
updatedAt: c.updatedAt.toISOString(),
})),
});
};
@@ -0,0 +1,99 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { auth } from "@trigger.dev/sdk";
import { chat } from "@trigger.dev/sdk/ai";
import { requireUserId } from "~/services/session.server";
import { withAssistantAuth, type AssistantEnvContext } from "~/services/aiAssistant.server";
const startDashboardAssistant = chat.createStartSessionAction("dashboard-assistant");
// Auth context from the server-trusted userId + the slugs the browser sends.
// The userId is never trusted from the browser — membership is re-checked
// against it in `withAssistantAuth`.
function envContext(
userId: string,
clientData: Record<string, unknown> | undefined
): AssistantEnvContext {
const orgSlug = String(clientData?.organizationSlug ?? "").trim();
const projSlug = String(clientData?.projectSlug ?? "").trim();
const envSlug = String(clientData?.environmentSlug ?? "").trim();
if (!orgSlug || !projSlug || !envSlug) {
throw new Error("Missing organization, project, or environment slug");
}
return {
userId,
organizationSlug: orgSlug,
projectSlug: projSlug,
environmentSlug: envSlug,
};
}
export const action = async ({ request }: ActionFunctionArgs) => {
try {
const userId = await requireUserId(request);
const body = (await request.json()) as {
intent?: string;
chatId?: string;
clientData?: Record<string, unknown>;
};
if (!body.chatId) {
return json({ error: "Missing chatId" }, { status: 400 });
}
const chatId = body.chatId;
if (body.intent === "createSession") {
const { clientData } = body;
const ctx = envContext(userId, clientData);
const result = await withAssistantAuth(ctx, () =>
startDashboardAssistant({
chatId,
// Override the browser-claimed userId with the server-trusted one.
clientData: {
userId,
organizationSlug: ctx.organizationSlug,
projectSlug: ctx.projectSlug,
environmentSlug: ctx.environmentSlug,
currentPage: String(clientData?.currentPage ?? ""),
currentParams: clientData?.currentParams
? (clientData.currentParams as Record<string, string>)
: undefined,
},
})
);
return json({
sessionId: result.sessionId,
publicAccessToken: result.publicAccessToken,
});
}
if (body.intent === "refreshToken") {
const { chatId, clientData } = body;
const ctx = envContext(userId, clientData);
// Pure mint — no session create, no run trigger. Scoped to this chat.
const publicAccessToken = await withAssistantAuth(ctx, () =>
auth.createPublicToken({
scopes: {
read: { sessions: chatId },
write: { sessions: chatId },
},
})
);
return json({ publicAccessToken });
}
return json({ error: "Unknown intent" }, { status: 400 });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return json(
{ error: `AI assistant error: ${message}` },
{ status: 500 }
);
}
};
@@ -0,0 +1,54 @@
import { auth } from "@trigger.dev/sdk";
import { env } from "~/env.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { findProjectBySlug } from "~/models/project.server";
// The assistant runs as a Trigger.dev task on this same platform. Rather than
// stashing a secret, we authenticate SDK calls with the apiKey of the
// environment the user is currently viewing, read from the DB and scoped via
// `auth.withAuth`. Moving the assistant to a dedicated project would only
// change `resolveAssistantApiKey`.
export type AssistantEnvContext = {
userId: string;
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
};
class AssistantAuthError extends Error {}
async function resolveAssistantApiKey(ctx: AssistantEnvContext): Promise<string> {
const project = await findProjectBySlug(ctx.organizationSlug, ctx.projectSlug, ctx.userId);
if (!project) {
throw new AssistantAuthError(
`AI assistant: no project "${ctx.projectSlug}" in org "${ctx.organizationSlug}" for this user`
);
}
const environment = await findEnvironmentBySlug(project.id, ctx.environmentSlug, ctx.userId);
if (!environment) {
throw new AssistantAuthError(
`AI assistant: no environment "${ctx.environmentSlug}" in project "${ctx.projectSlug}"`
);
}
return environment.apiKey;
}
// Run `fn` with the SDK API client scoped to the current environment's key and
// this instance's own origin, so session/token calls hit the local platform.
export async function withAssistantAuth<T>(
ctx: AssistantEnvContext,
fn: () => Promise<T>
): Promise<T> {
const apiKey = await resolveAssistantApiKey(ctx);
return auth.withAuth(
{
baseURL: env.API_ORIGIN ?? env.APP_ORIGIN,
accessToken: apiKey,
},
fn
);
}
@@ -0,0 +1,122 @@
import { tool } from "ai";
import { aggregateRuns as aggregateRunsSchema } from "~/lib/ai-assistant/tool-schemas";
import type { ToolContext } from "../types";
import {
buildTimeRange,
formatClickhouseTimestamp,
CLICKHOUSE_QUERY_SETTINGS,
} from "./clickhouse-queries";
export function createAggregateRunsTool(ctx: ToolContext) {
return tool({
...aggregateRunsSchema,
execute: async (params: { groupBy: string; metric?: string; period?: string }) => {
try {
const { clickhouseClient } = await import("~/v3/clickhouse.server");
const { prisma } = await import("~/db.server");
// Validate groupBy parameter
if (!["task", "status", "version", "queue"].includes(params.groupBy)) {
return {
error: "Invalid groupBy parameter. Must be one of: task, status, version, queue",
results: [],
};
}
// Get environment IDs
const environment = await prisma.runtimeEnvironment.findFirst({
where: {
slug: ctx.clientData.environmentSlug,
project: {
slug: ctx.clientData.projectSlug,
},
},
select: {
id: true,
organizationId: true,
project: {
select: {
id: true,
},
},
},
});
if (!environment) {
return {
error: "Environment not found",
results: [],
};
}
const { from, to } = buildTimeRange(params.period);
const metric = params.metric || "count";
// Map groupBy to column names
const columnMap: Record<string, string> = {
task: "task_identifier",
status: "status",
version: "deployment_version",
queue: "queue_name",
};
const groupColumn = columnMap[params.groupBy];
// Build metric aggregate
let metricSelect = "COUNT(*) as count";
if (metric === "failureRate") {
metricSelect = `
ROUND(
SUM(CASE WHEN status IN ('COMPLETED_WITH_ERRORS', 'CRASHED', 'TIMED_OUT') THEN 1 ELSE 0 END) * 100.0 / COUNT(*),
2
) as failure_rate_percent
`;
} else if (metric === "avgDuration") {
metricSelect = `ROUND(AVG(duration_ms), 0) as avg_duration_ms`;
} else if (metric === "p95Duration") {
metricSelect = `ROUND(quantile(0.95)(duration_ms), 0) as p95_duration_ms`;
}
const query = `
SELECT
${groupColumn} as dimension,
${metricSelect}
FROM trigger_dev.task_runs_v2
WHERE
organization_id = '${environment.organizationId}'
AND project_id = '${environment.project.id}'
AND environment_id = '${environment.id}'
AND engine = 'V2'
AND triggered_at >= '${formatClickhouseTimestamp(from)}'
AND triggered_at < '${formatClickhouseTimestamp(to)}'
GROUP BY ${groupColumn}
ORDER BY count DESC
LIMIT 50
SETTINGS max_execution_time = ${CLICKHOUSE_QUERY_SETTINGS.max_execution_time}
`;
const results = await clickhouseClient.query({
query,
format: "JSONCompact",
});
const parsed = JSON.parse(results.text);
const formattedResults = parsed.data?.map((row: unknown[]) => ({
dimension: row[0],
value: row[1],
})) || [];
return {
groupBy: params.groupBy,
metric,
results: formattedResults,
};
} catch (error) {
return {
error: `Failed to aggregate runs: ${error instanceof Error ? error.message : String(error)}`,
results: [],
};
}
},
});
}
@@ -0,0 +1,39 @@
// Shared ClickHouse query helpers for analytics tools
// All queries apply the same limits as the query page to prevent resource exhaustion
const QUERY_CLICKHOUSE_MAX_EXECUTION_TIME = 10; // seconds
const QUERY_CLICKHOUSE_MAX_MEMORY_USAGE = 1024 * 1024 * 1024; // 1GB
export const CLICKHOUSE_QUERY_SETTINGS = {
max_execution_time: QUERY_CLICKHOUSE_MAX_EXECUTION_TIME,
max_memory_usage: QUERY_CLICKHOUSE_MAX_MEMORY_USAGE,
};
export function formatClickhouseTimestamp(date: Date): string {
return date.toISOString().replace("T", " ").split(".")[0];
}
export function buildTimeRange(period: string | undefined): {
from: Date;
to: Date;
} {
const to = new Date();
const from = new Date();
const periods: Record<string, () => void> = {
"1h": () => from.setHours(from.getHours() - 1),
"6h": () => from.setHours(from.getHours() - 6),
"24h": () => from.setDate(from.getDate() - 1),
"7d": () => from.setDate(from.getDate() - 7),
"30d": () => from.setDate(from.getDate() - 30),
};
if (period && period in periods) {
periods[period]();
} else {
// Default to 24h
from.setDate(from.getDate() - 1);
}
return { from, to };
}
@@ -0,0 +1,120 @@
import { tool } from "ai";
import { correlateRunsWithDeploy as correlateRunsWithDeploySchema } from "~/lib/ai-assistant/tool-schemas";
import type { ToolContext } from "../types";
import {
buildTimeRange,
formatClickhouseTimestamp,
CLICKHOUSE_QUERY_SETTINGS,
} from "./clickhouse-queries";
export function createCorrelateRunsWithDeployTool(ctx: ToolContext) {
return tool({
...correlateRunsWithDeploySchema,
execute: async (params: { taskIdentifier?: string; period?: string }) => {
try {
const { clickhouseClient } = await import("~/v3/clickhouse.server");
const { prisma } = await import("~/db.server");
// Get environment IDs
const environment = await prisma.runtimeEnvironment.findFirst({
where: {
slug: ctx.clientData.environmentSlug,
project: {
slug: ctx.clientData.projectSlug,
},
},
select: {
id: true,
organizationId: true,
project: {
select: {
id: true,
},
},
},
});
if (!environment) {
return {
error: "Environment not found",
deploys: [],
correlation: {},
};
}
const { from, to } = buildTimeRange(params.period);
// Query: Failure rates by deployment version
const query = `
SELECT
deployment_version,
COUNT(*) as total_runs,
SUM(CASE WHEN status IN ('COMPLETED_WITH_ERRORS', 'CRASHED', 'TIMED_OUT') THEN 1 ELSE 0 END) as failed_runs,
ROUND(
SUM(CASE WHEN status IN ('COMPLETED_WITH_ERRORS', 'CRASHED', 'TIMED_OUT') THEN 1 ELSE 0 END) * 100.0 / COUNT(*),
2
) as failure_rate_percent,
MIN(triggered_at) as first_run,
MAX(triggered_at) as last_run
FROM trigger_dev.task_runs_v2
WHERE
organization_id = '${environment.organizationId}'
AND project_id = '${environment.project.id}'
AND environment_id = '${environment.id}'
AND engine = 'V2'
AND triggered_at >= '${formatClickhouseTimestamp(from)}'
AND triggered_at < '${formatClickhouseTimestamp(to)}'
${params.taskIdentifier ? `AND task_identifier = '${params.taskIdentifier.replace(/'/g, "''")}'` : ""}
GROUP BY deployment_version
ORDER BY first_run DESC
LIMIT 20
SETTINGS max_execution_time = ${CLICKHOUSE_QUERY_SETTINGS.max_execution_time}
`;
const results = await clickhouseClient.query({
query,
format: "JSONCompact",
});
const parsed = JSON.parse(results.text);
const deploys = parsed.data?.map((row: unknown[]) => ({
version: row[0],
totalRuns: row[1],
failedRuns: row[2],
failureRatePercent: row[3],
firstRun: row[4],
lastRun: row[5],
})) || [];
// Analyze correlation: compare failure rates between versions
const correlation: Record<string, unknown> = {};
if (deploys.length >= 2) {
const current = deploys[0];
const previous = deploys[1];
if (current && previous) {
const failureChange = (current.failureRatePercent as number) - (previous.failureRatePercent as number);
correlation.currentVersion = current.version;
correlation.previousVersion = previous.version;
correlation.failureChangePercent = failureChange;
correlation.isRegression = failureChange > 5; // Flag as regression if failure rate increased by >5%
correlation.recommendation = failureChange > 5
? `Possible regression in version ${current.version}: failure rate increased by ${failureChange.toFixed(1)}%`
: "No significant regression detected";
}
}
return {
deploys,
correlation,
};
} catch (error) {
return {
error: `Failed to correlate deploys: ${error instanceof Error ? error.message : String(error)}`,
deploys: [],
correlation: {},
};
}
},
});
}
@@ -0,0 +1,137 @@
import { tool } from "ai";
import { summarizeCurrentView as summarizeCurrentViewSchema } from "~/lib/ai-assistant/tool-schemas";
import type { ToolContext } from "../types";
import {
buildTimeRange,
formatClickhouseTimestamp,
CLICKHOUSE_QUERY_SETTINGS,
} from "./clickhouse-queries";
export function createSummarizeCurrentViewTool(ctx: ToolContext) {
return tool({
...summarizeCurrentViewSchema,
execute: async (params: { period?: string }) => {
try {
const { clickhouseClient } = await import("~/v3/clickhouse.server");
const { prisma } = await import("~/db.server");
// Get environment IDs
const environment = await prisma.runtimeEnvironment.findFirst({
where: {
slug: ctx.clientData.environmentSlug,
project: {
slug: ctx.clientData.projectSlug,
},
},
select: {
id: true,
organizationId: true,
project: {
select: {
id: true,
},
},
},
});
if (!environment) {
return {
error: "Environment not found",
totalRuns: 0,
statusDistribution: {},
topFailingTasks: [],
errorRate: "0%",
};
}
const { from, to } = buildTimeRange(params.period);
// Query 1: Total runs and status distribution
const statusQuery = `
SELECT
status,
COUNT(*) as count
FROM trigger_dev.task_runs_v2
WHERE
organization_id = '${environment.organizationId}'
AND project_id = '${environment.project.id}'
AND environment_id = '${environment.id}'
AND engine = 'V2'
AND triggered_at >= '${formatClickhouseTimestamp(from)}'
AND triggered_at < '${formatClickhouseTimestamp(to)}'
GROUP BY status
SETTINGS max_execution_time = ${CLICKHOUSE_QUERY_SETTINGS.max_execution_time}
`;
const statusResults = await clickhouseClient.query({
query: statusQuery,
format: "JSONCompact",
});
const statusData = JSON.parse(statusResults.text);
const statusDistribution: Record<string, number> = {};
let totalRuns = 0;
statusData.data?.forEach((row: [string, number]) => {
statusDistribution[row[0]] = row[1];
totalRuns += row[1];
});
// Query 2: Top failing tasks
const failingTasksQuery = `
SELECT
task_identifier,
COUNT(*) as failure_count
FROM trigger_dev.task_runs_v2
WHERE
organization_id = '${environment.organizationId}'
AND project_id = '${environment.project.id}'
AND environment_id = '${environment.id}'
AND engine = 'V2'
AND triggered_at >= '${formatClickhouseTimestamp(from)}'
AND triggered_at < '${formatClickhouseTimestamp(to)}'
AND status IN ('COMPLETED_WITH_ERRORS', 'CRASHED', 'TIMED_OUT')
GROUP BY task_identifier
ORDER BY failure_count DESC
LIMIT 5
SETTINGS max_execution_time = ${CLICKHOUSE_QUERY_SETTINGS.max_execution_time}
`;
const failingResults = await clickhouseClient.query({
query: failingTasksQuery,
format: "JSONCompact",
});
const failingData = JSON.parse(failingResults.text);
const topFailingTasks = failingData.data?.map((row: [string, number]) => row[0]) || [];
// Calculate error rate
const failureCount =
Object.entries(statusDistribution).reduce((sum, [status, count]) => {
if (["COMPLETED_WITH_ERRORS", "CRASHED", "TIMED_OUT"].includes(status)) {
return sum + count;
}
return sum;
}, 0) || 0;
const errorRate =
totalRuns > 0 ? Math.round((failureCount / totalRuns) * 100) : 0;
return {
totalRuns,
statusDistribution,
topFailingTasks,
errorRate: `${errorRate}%`,
};
} catch (error) {
return {
error: `Failed to summarize view: ${error instanceof Error ? error.message : String(error)}`,
totalRuns: 0,
statusDistribution: {},
topFailingTasks: [],
errorRate: "0%",
};
}
},
});
}
@@ -0,0 +1,71 @@
import { tool } from "ai";
import { searchDocs as searchDocsSchema } from "~/lib/ai-assistant/tool-schemas";
const MINTLIFY_MCP_URL = "https://trigger.dev/docs/mcp";
const MCP_PROTOCOL_VERSION = "2025-06-18";
export function createSearchDocsTool() {
return tool({
...searchDocsSchema,
execute: async ({ query }) => {
try {
const body = {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: { name: "search_trigger_dev", arguments: { query } },
};
const response = await fetch(MINTLIFY_MCP_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
"MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
},
body: JSON.stringify(body),
});
const data: any = await parseResponse(response);
return { success: true, results: data?.result ?? data };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
},
});
}
// --- Mintlify response parsing (handles both SSE and JSON) ---
// Adapted from packages/cli-v3/src/mcp/mintlifyClient.ts
async function parseResponse(response: Response) {
if (response.headers.get("content-type")?.includes("text/event-stream")) {
return parseSSEResponse(response);
}
return response.json();
}
async function parseSSEResponse(response: Response) {
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (!reader) throw new Error("No reader found");
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) throw new Error("SSE stream closed before data arrived");
buffer += decoder.decode(value, { stream: true });
const events = buffer.split("\n\n");
buffer = events.pop()!;
for (const evt of events) {
for (const line of evt.split("\n")) {
if (line.startsWith("data:")) {
return JSON.parse(line.slice(5).trim());
}
}
}
}
}
@@ -0,0 +1,97 @@
import { tool } from "ai";
import { openai } from "@ai-sdk/openai";
import { generateObject } from "ai";
import { z } from "zod";
import { classifyFailure as classifyFailureSchema } from "~/lib/ai-assistant/tool-schemas";
import type { ToolContext } from "../types";
const FailureClassificationSchema = z.object({
category: z
.enum([
"Timeout",
"OOM / Memory",
"Missing env var",
"Child task failed",
"User code exception",
"AI provider rate limit",
"Deploy regression",
"Platform issue",
"Unknown",
])
.describe("The category of failure"),
confidence: z
.enum(["High", "Medium", "Low"])
.describe("How confident we are in this classification"),
evidence: z.string().describe("The key evidence supporting this classification"),
nextSteps: z
.array(z.string())
.describe("Suggested next steps for investigation or remediation"),
});
export function createClassifyFailureTool(ctx: ToolContext) {
return tool({
...classifyFailureSchema,
execute: async (params: { runFriendlyId: string }) => {
try {
// Lazy import to avoid env validation issues
const { getRunForLLM } = await import("../runs/run-presenter-adapter");
// Fetch run details including logs
const runWithTrace = await getRunForLLM(ctx, params.runFriendlyId);
if (!runWithTrace) {
return {
category: "Unknown",
confidence: "Low",
evidence: "Could not fetch run details",
nextSteps: [],
};
}
const { run, trace } = runWithTrace;
// Build a summary of the run for classification
const runSummary = `
Run Status: ${run.status}
Duration: ${run.duration || "unknown"}
Started: ${run.startedAt || "unknown"}
Completed: ${run.completedAt || "unknown"}
Trace Summary: ${trace ? `${trace.totalSpans} spans, root status ${trace.rootStatus}` : "No trace data"}
`;
// Use a cheaper model for classification
const classification = await generateObject({
model: openai("gpt-4o-mini"),
schema: FailureClassificationSchema,
prompt: `
Classify the cause of this task run failure based on the following information:
${runSummary}
Consider these categories:
- Timeout: Run exceeded max duration
- OOM / Memory: Out of memory or memory limit exceeded
- Missing env var: Missing required environment variable
- Child task failed: Subtask or dependent task failed
- User code exception: Exception in user's code
- AI provider rate limit: Hit rate limit from external AI service
- Deploy regression: Likely caused by a recent deployment
- Platform issue: Platform/infrastructure issue
- Unknown: Cannot determine the cause
Provide your classification with supporting evidence and next steps.
`,
});
return classification;
} catch (error) {
return {
category: "Unknown",
confidence: "Low",
evidence: `Error during classification: ${error instanceof Error ? error.message : String(error)}`,
nextSteps: ["Check the run logs manually in the dashboard"],
};
}
},
});
}
@@ -0,0 +1,50 @@
import type { ErrorGroup } from "~/presenters/v3/ErrorsListPresenter.server";
import type { ErrorGroupSummary, ErrorDetailsSummary } from "../types";
const MAX_STACK_TRACE_LENGTH = 1000;
const MAX_AFFECTED_RUNS = 5;
export function summarizeErrorGroup(
errorGroup: ErrorGroup
): ErrorGroupSummary {
return {
fingerprint: errorGroup.fingerprint,
message: errorGroup.errorMessage,
taskIdentifier: errorGroup.taskIdentifier,
count: errorGroup.count,
firstSeen: errorGroup.firstSeen?.toISOString() ?? new Date().toISOString(),
lastSeen: errorGroup.lastSeen?.toISOString() ?? new Date().toISOString(),
status: errorGroup.status ?? "UNRESOLVED",
};
}
export function truncateStackTrace(stackTrace: string): string {
if (stackTrace.length <= MAX_STACK_TRACE_LENGTH) return stackTrace;
return stackTrace.slice(0, MAX_STACK_TRACE_LENGTH) + "...[truncated]";
}
export function summarizeErrorDetails(
fingerprint: string,
message: string,
taskIdentifier: string,
stackTrace: string | null,
count: number,
firstSeen: Date | null,
lastSeen: Date | null,
affectedRuns: Array<{ friendlyId: string; status: string; createdAt: Date }>
): ErrorDetailsSummary {
return {
fingerprint,
message,
taskIdentifier,
stackTrace: stackTrace ? truncateStackTrace(stackTrace) : undefined,
count,
firstSeen: firstSeen?.toISOString() ?? new Date().toISOString(),
lastSeen: lastSeen?.toISOString() ?? new Date().toISOString(),
affectedRuns: affectedRuns.slice(0, MAX_AFFECTED_RUNS).map((run) => ({
runFriendlyId: run.friendlyId,
status: run.status,
occurredAt: run.createdAt.toISOString(),
})),
};
}
@@ -0,0 +1,91 @@
import { tool } from "ai";
import { findSimilarErrors as findSimilarErrorsSchema } from "~/lib/ai-assistant/tool-schemas";
import type { ToolContext, ErrorGroupSummary } from "../types";
export function createFindSimilarErrorsTool(ctx: ToolContext) {
return tool({
...findSimilarErrorsSchema,
execute: async (params: { errorMessage: string; limit?: number }) => {
try {
const { clickhouseClient } = await import("~/v3/clickhouse.server");
const { prisma } = await import("~/db.server");
// Get the environment
const environment = await prisma.runtimeEnvironment.findFirst({
where: {
slug: ctx.clientData.environmentSlug,
project: {
slug: ctx.clientData.projectSlug,
},
},
select: {
id: true,
organizationId: true,
project: {
select: {
id: true,
},
},
},
});
if (!environment) {
return {
errors: [],
error: "Environment not found",
};
}
// Search for similar error messages using LIKE
const limit = params.limit || 10;
const searchTerm = params.errorMessage.substring(0, 100); // Use first 100 chars to avoid extremely long searches
const query = `
SELECT
error_fingerprint,
error_message,
task_identifier,
COUNT(*) as occurrence_count,
MIN(triggered_at) as first_seen,
MAX(triggered_at) as last_seen
FROM trigger_dev.task_runs_v2
WHERE
organization_id = '${environment.organizationId}'
AND project_id = '${environment.project.id}'
AND environment_id = '${environment.id}'
AND engine = 'V2'
AND error_message ILIKE '%${searchTerm.replace(/'/g, "''")}%'
GROUP BY error_fingerprint, error_message, task_identifier
ORDER BY occurrence_count DESC
LIMIT ${limit}
`;
const results = await clickhouseClient.query({
query,
format: "JSONCompact",
});
const parsed = JSON.parse(results.text);
const errors: ErrorGroupSummary[] = parsed.data?.map((row: any) => ({
fingerprint: row[0],
message: row[1],
taskIdentifier: row[2],
count: row[3],
firstSeen: new Date(row[4]).toISOString(),
lastSeen: new Date(row[5]).toISOString(),
status: "UNRESOLVED",
})) || [];
return {
errors,
total: errors.length,
};
} catch (error) {
return {
errors: [],
error: `Failed to find similar errors: ${error instanceof Error ? error.message : String(error)}`,
};
}
},
});
}
@@ -0,0 +1,107 @@
import { tool } from "ai";
import { getErrorDetails as getErrorDetailsSchema } from "~/lib/ai-assistant/tool-schemas";
import type { ToolContext } from "../types";
export function createGetErrorDetailsTool(ctx: ToolContext) {
return tool({
...getErrorDetailsSchema,
execute: async (params: { fingerprint: string }) => {
try {
// Lazy import to avoid env validation issues at module load
const { ErrorGroupPresenter } = await import("~/presenters/v3/ErrorGroupPresenter.server");
const { summarizeErrorDetails } = await import("./error-formatters");
const { prisma } = await import("~/db.server");
const { clickhouseFactory } = await import("~/services/clickhouse/clickhouseFactoryInstance.server");
// Get the environment and project IDs
const environment = await prisma.runtimeEnvironment.findFirst({
where: {
slug: ctx.clientData.environmentSlug,
project: {
slug: ctx.clientData.projectSlug,
},
},
select: {
id: true,
organizationId: true,
project: {
select: {
id: true,
},
},
},
});
if (!environment) {
return {
error: "Environment not found",
fingerprint: params.fingerprint,
message: "",
taskIdentifier: "",
count: 0,
firstSeen: new Date().toISOString(),
lastSeen: new Date().toISOString(),
affectedRuns: [],
};
}
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
environment.organizationId,
"standard"
);
const presenter = new ErrorGroupPresenter(prisma, clickhouse, clickhouse);
const result = await presenter.call(environment.organizationId, environment.id, {
projectId: environment.project.id,
userId: ctx.clientData.userId,
fingerprint: params.fingerprint,
runsPageSize: 5,
});
if (!result.errorGroup) {
return {
error: "Error group not found",
fingerprint: params.fingerprint,
message: "",
taskIdentifier: "",
count: 0,
firstSeen: new Date().toISOString(),
lastSeen: new Date().toISOString(),
affectedRuns: [],
};
}
// Get affected runs
const affectedRuns = result.runList?.runs
? result.runList.runs.map((run: any) => ({
friendlyId: run.friendlyId,
status: run.status,
createdAt: new Date(run.createdAt),
}))
: [];
return summarizeErrorDetails(
result.errorGroup.fingerprint,
result.errorGroup.errorMessage,
result.errorGroup.taskIdentifier,
result.errorGroup.stackTrace || null,
result.errorGroup.count,
result.errorGroup.firstSeen,
result.errorGroup.lastSeen,
affectedRuns
);
} catch (error) {
return {
error: `Failed to get error details: ${error instanceof Error ? error.message : String(error)}`,
fingerprint: params.fingerprint,
message: "",
taskIdentifier: "",
count: 0,
firstSeen: new Date().toISOString(),
lastSeen: new Date().toISOString(),
affectedRuns: [],
};
}
},
});
}
@@ -0,0 +1,97 @@
import { tool } from "ai";
import { listErrors as listErrorsSchema } from "~/lib/ai-assistant/tool-schemas";
import type { ToolContext } from "../types";
function parsePeriod(period: string): { from: number; to: number } {
const now = Date.now();
const units: Record<string, number> = {
m: 60 * 1000,
h: 60 * 60 * 1000,
d: 24 * 60 * 60 * 1000,
};
const match = period.match(/^(\d+)([mhd])$/);
if (!match) return { from: now - 86400000, to: now }; // default 24h
const value = parseInt(match[1], 10);
const unit = match[2];
const milliseconds = value * (units[unit] || units.h);
return { from: now - milliseconds, to: now };
}
export function createListErrorsTool(ctx: ToolContext) {
return tool({
...listErrorsSchema,
execute: async (params: { period?: string; taskIdentifier?: string; limit?: number }) => {
try {
// Lazy import to avoid env validation issues at module load
const { ErrorsListPresenter } = await import("~/presenters/v3/ErrorsListPresenter.server");
const { summarizeErrorGroup } = await import("./error-formatters");
const { prisma } = await import("~/db.server");
const { clickhouseFactory } = await import("~/services/clickhouse/clickhouseFactoryInstance.server");
// Get the environment and project IDs
const environment = await prisma.runtimeEnvironment.findFirst({
where: {
slug: ctx.clientData.environmentSlug,
project: {
slug: ctx.clientData.projectSlug,
},
},
select: {
id: true,
organizationId: true,
project: {
select: {
id: true,
},
},
},
});
if (!environment) {
return {
errors: [],
total: 0,
error: "Environment not found",
};
}
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
environment.organizationId,
"standard"
);
const presenter = new ErrorsListPresenter(prisma, clickhouse);
// Convert period to time bounds
const timeFilters = params.period
? parsePeriod(params.period)
: { from: Date.now() - 86400000, to: Date.now() }; // default 24h
const result = await presenter.call(environment.organizationId, environment.id, {
projectId: environment.project.id,
userId: ctx.clientData.userId,
tasks: params.taskIdentifier ? [params.taskIdentifier] : undefined,
from: timeFilters.from,
to: timeFilters.to,
pageSize: params.limit || 20,
});
// Summarize error groups for LLM
const summaries = result.errorGroups.map((eg: any) => summarizeErrorGroup(eg));
return {
errors: summaries,
total: result.pagination?.total || 0,
};
} catch (error) {
return {
errors: [],
total: 0,
error: `Failed to list errors: ${error instanceof Error ? error.message : String(error)}`,
};
}
},
});
}
@@ -0,0 +1,91 @@
import type { ClientData } from "./types";
import { buildToolContext } from "./types";
// Docs and navigation tools are safe to import at module load (no env.server.ts).
import { createSearchDocsTool } from "./docs/search-docs";
import { createNavigateToPageTool } from "./navigation/navigate-to-page";
import { createSearchPagesTool } from "./navigation/search-pages";
import { createGetCurrentContextTool } from "./navigation/get-current-context";
// Runs, errors, and analytics tools reach into env.server.ts, so they're loaded
// lazily to keep that out of the CLI indexing path.
async function loadServerTools() {
const { createListRunsTool } = await import("./runs/list-runs");
const { createGetRunDetailsTool } = await import("./runs/get-run-details");
const { createGetSpanDetailsTool } = await import("./runs/get-span-details");
const { createGetRunLogsTool } = await import("./runs/get-run-logs");
const { createGetRunGraphTool } = await import("./runs/get-run-graph");
const { createApplyRunFiltersTool } = await import("./runs/apply-run-filters");
const { createQueryRunsTool } = await import("./runs/query-runs");
const { createListErrorsTool } = await import("./errors/list-errors");
const { createGetErrorDetailsTool } = await import("./errors/get-error-details");
const { createFindSimilarErrorsTool } = await import("./errors/find-similar-errors");
const { createClassifyFailureTool } = await import("./errors/classify-failure");
const { createSummarizeCurrentViewTool } = await import("./analytics/summarize-current-view");
const { createAggregateRunsTool } = await import("./analytics/aggregate-runs");
const { createCorrelateRunsWithDeployTool } = await import("./analytics/correlate-runs-with-deploy");
const { createListTestableTasksTool } = await import("./test/list-testable-tasks");
const { createGenerateTestPayloadTool } = await import("./test/generate-test-payload");
const { createRunTestTaskTool } = await import("./test/run-test-task");
return {
createListRunsTool,
createGetRunDetailsTool,
createGetSpanDetailsTool,
createGetRunLogsTool,
createGetRunGraphTool,
createApplyRunFiltersTool,
createQueryRunsTool,
createListErrorsTool,
createGetErrorDetailsTool,
createFindSimilarErrorsTool,
createClassifyFailureTool,
createSummarizeCurrentViewTool,
createAggregateRunsTool,
createCorrelateRunsWithDeployTool,
createListTestableTasksTool,
createGenerateTestPayloadTool,
createRunTestTaskTool,
};
}
// Builds the tool set for a client context. Called from the agent's run() per turn.
export async function buildAssistantTools(clientData: ClientData) {
const ctx = buildToolContext(clientData);
const serverTools = await loadServerTools();
return {
// Docs
searchDocs: createSearchDocsTool(),
// Navigation
navigateToPage: createNavigateToPageTool(ctx),
searchPages: createSearchPagesTool(ctx),
getCurrentContext: createGetCurrentContextTool(ctx),
// Runs
listRuns: serverTools.createListRunsTool(ctx),
getRunDetails: serverTools.createGetRunDetailsTool(ctx),
getSpanDetails: serverTools.createGetSpanDetailsTool(ctx),
getRunLogs: serverTools.createGetRunLogsTool(ctx),
getRunGraph: serverTools.createGetRunGraphTool(ctx),
applyRunFilters: serverTools.createApplyRunFiltersTool(ctx),
queryRuns: serverTools.createQueryRunsTool(ctx),
// Errors
listErrors: serverTools.createListErrorsTool(ctx),
getErrorDetails: serverTools.createGetErrorDetailsTool(ctx),
findSimilarErrors: serverTools.createFindSimilarErrorsTool(ctx),
classifyFailure: serverTools.createClassifyFailureTool(ctx),
// Analytics
summarizeCurrentView: serverTools.createSummarizeCurrentViewTool(ctx),
aggregateRuns: serverTools.createAggregateRunsTool(ctx),
correlateRunsWithDeploy: serverTools.createCorrelateRunsWithDeployTool(ctx),
// Test
listTestableTasks: serverTools.createListTestableTasksTool(ctx),
generateTestPayload: serverTools.createGenerateTestPayloadTool(ctx),
runTestTask: serverTools.createRunTestTaskTool(ctx),
};
}
@@ -0,0 +1,18 @@
import { tool } from "ai";
import { getCurrentContext as contextSchema } from "~/lib/ai-assistant/tool-schemas";
import type { ToolContext } from "../types";
export function createGetCurrentContextTool(ctx: ToolContext) {
return tool({
...contextSchema,
execute: async () => {
return {
project: ctx.clientData.projectSlug,
environment: ctx.clientData.environmentSlug,
currentPage: ctx.clientData.currentPage,
currentParams: ctx.clientData.currentParams ?? {},
description: `The user is viewing the ${ctx.clientData.currentPage} page in project "${ctx.clientData.projectSlug}" (${ctx.clientData.environmentSlug} environment).`,
};
},
});
}
@@ -0,0 +1,64 @@
import { tool } from "ai";
import { navigateToPage as navigateSchema } from "~/lib/ai-assistant/tool-schemas";
import { v3RunPath, v3RunSpanPath, v3TestTaskPath } from "~/utils/pathBuilder";
import type { ToolContext } from "../types";
import { findBestMatch } from "./page-matcher";
export function createNavigateToPageTool(ctx: ToolContext) {
return tool({
...navigateSchema,
execute: async ({ destination, runId, spanId, testTaskId }) => {
// Deep-link to a task's Test page takes precedence — the user wants to test it.
if (testTaskId) {
const url = v3TestTaskPath(ctx.org, ctx.project, ctx.env, {
taskIdentifier: testTaskId,
});
return {
found: true,
pageName: `Test ${testTaskId}`,
description: "Test page for the task — fill a payload and run it",
url,
};
}
// Deep-link to a specific run (optionally a span within it) takes precedence
// over a named-section lookup.
if (runId) {
const run = { friendlyId: runId };
const url = spanId
? v3RunSpanPath(ctx.org, ctx.project, ctx.env, run, { spanId })
: v3RunPath(ctx.org, ctx.project, ctx.env, run);
return {
found: true,
pageName: spanId ? `Span in run ${runId}` : `Run ${runId}`,
description: spanId
? "Run trace view with the selected span open"
: "Run detail and trace view",
url,
};
}
if (!destination) {
return {
found: false,
message:
"I need either a page name or a run ID to navigate to. Try naming a page or a run.",
};
}
const match = findBestMatch(destination);
if (!match) {
return {
found: false,
message: "I couldn't find that page. Try asking me to search for available pages.",
};
}
return {
found: true,
pageName: match.id,
description: match.description,
url: match.pathFn(ctx.org, ctx.project, ctx.env),
};
},
});
}
@@ -0,0 +1,53 @@
import type { PageEntry } from "./page-registry";
import { PAGE_REGISTRY } from "./page-registry";
/**
* Score a page entry against a search query. Higher = better match.
* Uses keyword overlap and substring matching — intentionally simple.
*/
function scoreMatch(entry: PageEntry, query: string): number {
const lower = query.toLowerCase();
let score = 0;
// Exact ID match
if (lower === entry.id) return 100;
// ID substring
if (lower.includes(entry.id) || entry.id.includes(lower)) score += 10;
// Keyword matches
for (const keyword of entry.keywords) {
if (lower.includes(keyword)) score += 5;
if (keyword.includes(lower)) score += 3;
}
// Description substring
if (entry.description.toLowerCase().includes(lower)) score += 2;
return score;
}
/** Find the best matching page for a destination string */
export function findBestMatch(query: string): PageEntry | null {
let best: PageEntry | null = null;
let bestScore = 0;
for (const entry of PAGE_REGISTRY) {
const score = scoreMatch(entry, query);
if (score > bestScore) {
bestScore = score;
best = entry;
}
}
return bestScore > 0 ? best : null;
}
/** Find top N matching pages for a search query */
export function findMatches(query: string, limit = 5): PageEntry[] {
return PAGE_REGISTRY.map((entry) => ({ entry, score: scoreMatch(entry, query) }))
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map(({ entry }) => entry);
}
@@ -0,0 +1,152 @@
import {
v3RunsPath,
v3ErrorsPath,
v3DeploymentsPath,
v3BatchesPath,
v3SchedulesPath,
v3EnvironmentVariablesPath,
v3ApiKeysPath,
v3QueuesPath,
v3TestPath,
v3LogsPath,
v3SessionsPath,
v3AgentsPath,
v3ModelsPath,
v3PromptsPath,
v3ProjectAlertsPath,
v3ProjectSettingsPath,
branchesPath,
concurrencyPath,
regionsPath,
queryPath,
} from "~/utils/pathBuilder";
export interface PageEntry {
id: string;
keywords: string[];
description: string;
pathFn: (org: { slug: string }, project: { slug: string }, env: { slug: string }) => string;
}
export const PAGE_REGISTRY: PageEntry[] = [
{
id: "runs",
keywords: ["runs", "task runs", "executions", "jobs", "run list"],
description: "Task runs list — view, filter, and manage all task runs",
pathFn: (org, project, env) => v3RunsPath(org, project, env),
},
{
id: "errors",
keywords: ["errors", "error groups", "failures", "exceptions", "bugs"],
description: "Error groups — see grouped errors across tasks with counts and trends",
pathFn: (org, project, env) => v3ErrorsPath(org, project, env),
},
{
id: "deployments",
keywords: ["deployments", "deploys", "versions", "releases"],
description: "Deployments — view deployment history, promote, and rollback versions",
pathFn: (org, project, env) => v3DeploymentsPath(org, project, env),
},
{
id: "batches",
keywords: ["batches", "batch runs", "batch triggers"],
description: "Batches — view and monitor batch trigger operations",
pathFn: (org, project, env) => v3BatchesPath(org, project, env),
},
{
id: "schedules",
keywords: ["schedules", "cron", "scheduled tasks", "recurring"],
description: "Schedules — create, edit, and manage scheduled task triggers",
pathFn: (org, project, env) => v3SchedulesPath(org, project, env),
},
{
id: "environment-variables",
keywords: ["env vars", "environment variables", "secrets", "config", "configuration"],
description: "Environment variables — configure secrets and config values per environment",
pathFn: (org, project, env) => v3EnvironmentVariablesPath(org, project, env),
},
{
id: "api-keys",
keywords: ["api keys", "tokens", "authentication", "secret keys"],
description: "API keys — manage server and public API keys for each environment",
pathFn: (org, project, env) => v3ApiKeysPath(org, project, env),
},
{
id: "queues",
keywords: ["queues", "concurrency", "queue management"],
description: "Queues — view queue status, set concurrency limits, pause queues",
pathFn: (org, project, env) => v3QueuesPath(org, project, env),
},
{
id: "test",
keywords: ["test", "testing", "test tasks", "trigger test", "playground"],
description: "Test — trigger test runs for your tasks with custom payloads",
pathFn: (org, project, env) => v3TestPath(org, project, env),
},
{
id: "logs",
keywords: ["logs", "log viewer", "logging", "log lines"],
description: "Logs — search and filter log output from task runs",
pathFn: (org, project, env) => v3LogsPath(org, project, env),
},
{
id: "sessions",
keywords: ["sessions", "chat sessions", "agent sessions"],
description: "Sessions — view active and past chat agent sessions",
pathFn: (org, project, env) => v3SessionsPath(org, project, env),
},
{
id: "agents",
keywords: ["agents", "ai agents", "chat agents"],
description: "Agents — view registered chat agents and their status",
pathFn: (org, project, env) => v3AgentsPath(org, project, env),
},
{
id: "models",
keywords: ["models", "ai models", "llm", "model registry"],
description: "Models — view LLM model usage, costs, and performance",
pathFn: (org, project, env) => v3ModelsPath(org, project, env),
},
{
id: "prompts",
keywords: ["prompts", "prompt management", "prompt versions"],
description: "Prompts — manage versioned prompts, create overrides, promote versions",
pathFn: (org, project, env) => v3PromptsPath(org, project, env),
},
{
id: "alerts",
keywords: ["alerts", "notifications", "alert rules"],
description: "Alerts — configure alert rules for task failures and performance",
pathFn: (org, project, env) => v3ProjectAlertsPath(org, project, env),
},
{
id: "settings",
keywords: ["settings", "project settings", "general settings"],
description: "Settings — general project configuration and integrations",
pathFn: (org, project, env) => v3ProjectSettingsPath(org, project, env),
},
{
id: "branches",
keywords: ["branches", "preview branches", "git branches"],
description: "Branches — manage preview branch environments",
pathFn: (org, project, env) => branchesPath(org, project, env),
},
{
id: "concurrency",
keywords: ["concurrency", "concurrency limits", "parallel"],
description: "Concurrency — view and configure task concurrency limits",
pathFn: (org, project, env) => concurrencyPath(org, project, env),
},
{
id: "regions",
keywords: ["regions", "deployment regions", "geography"],
description: "Regions — configure deployment regions for task execution",
pathFn: (org, project, env) => regionsPath(org, project, env),
},
{
id: "query",
keywords: ["query", "trql", "query editor", "search runs"],
description: "Query — write and execute TRQL queries against your task data",
pathFn: (org, project, env) => queryPath(org, project, env),
},
];
@@ -0,0 +1,21 @@
import { tool } from "ai";
import { searchPages as searchPagesSchema } from "~/lib/ai-assistant/tool-schemas";
import type { ToolContext } from "../types";
import { findMatches } from "./page-matcher";
export function createSearchPagesTool(ctx: ToolContext) {
return tool({
...searchPagesSchema,
execute: async ({ query }) => {
const matches = findMatches(query, 5);
return {
matches: matches.map((m) => ({
pageName: m.id,
description: m.description,
url: m.pathFn(ctx.org, ctx.project, ctx.env),
})),
total: matches.length,
};
},
});
}
@@ -0,0 +1,141 @@
import { tool } from "ai";
import { applyRunFilters as applyRunFiltersSchema } from "~/lib/ai-assistant/tool-schemas";
import type { ToolContext } from "../types";
export function createApplyRunFiltersTool(ctx: ToolContext) {
return tool({
...applyRunFiltersSchema,
execute: async (params: { description: string }) => {
try {
const { AIRunFilterService, type: QueryTagsType } = await import(
"~/v3/services/aiRunFilterService.server"
);
const { prisma } = await import("~/db.server");
// Fetch the environment to get its ID
const environment = await prisma.runtimeEnvironment.findFirst({
where: {
slug: ctx.clientData.environmentSlug,
project: {
slug: ctx.clientData.projectSlug,
},
},
select: {
id: true,
},
});
if (!environment) {
return {
success: false,
error: "Environment not found",
};
}
// Create query functions that fetch from the database
const queryTags = {
query: async (search?: string) => {
const tags = await prisma.taskRunTag.findMany({
where: {
taskRun: {
runtimeEnvironment: {
id: environment.id,
},
},
...(search && { name: { contains: search } }),
},
select: { name: true },
distinct: ["name"],
take: 50,
});
return { tags: tags.map((t) => t.name) };
},
};
const queryVersions = {
query: async (versionPrefix?: string, isCurrent?: boolean) => {
const versions = await prisma.backgroundWorkerVersion.findMany({
where: {
runtimeEnvironment: {
id: environment.id,
},
...(versionPrefix && { friendlyId: { contains: versionPrefix } }),
...(isCurrent && { isDeployed: true }),
},
select: { friendlyId: true },
orderBy: { createdAt: "desc" },
take: isCurrent ? 1 : 20,
});
if (isCurrent && versions.length > 0) {
return { version: versions[0].friendlyId };
}
return { versions: versions.map((v) => v.friendlyId) };
},
};
const queryQueues = {
query: async (search?: string, type?: "task" | "custom") => {
const queues = await prisma.taskQueue.findMany({
where: {
runtimeEnvironment: {
id: environment.id,
},
...(search && { friendlyId: { contains: search } }),
...(type === "task" && { name: null }),
...(type === "custom" && { name: { not: null } }),
},
select: { friendlyId: true },
take: 50,
});
return { queues: queues.map((q) => q.friendlyId) };
},
};
const queryTasks = {
query: async () => {
const tasks = await prisma.backgroundWorkerTask.findMany({
where: {
runtimeEnvironment: {
id: environment.id,
},
},
select: {
slug: true,
triggerSource: true,
},
take: 100,
});
return { tasks };
},
};
const service = new AIRunFilterService({
queryTags,
queryVersions,
queryQueues,
queryTasks,
});
const result = await service.call(params.description, environment.id);
if (result.success) {
return {
success: true,
filters: result.filters,
};
} else {
return {
success: false,
error: result.error,
};
}
} catch (error) {
return {
success: false,
error: `Failed to apply filters: ${error instanceof Error ? error.message : String(error)}`,
};
}
},
});
}
@@ -0,0 +1,23 @@
import { tool } from "ai";
import { getRunDetails as getRunDetailsSchema } from "~/lib/ai-assistant/tool-schemas";
import type { ToolContext } from "../types";
export function createGetRunDetailsTool(ctx: ToolContext) {
return tool({
...getRunDetailsSchema,
execute: async (params: { runFriendlyId: string }) => {
try {
const { getRunForLLM } = await import("./run-presenter-adapter");
const result = await getRunForLLM(ctx, params.runFriendlyId);
if (!result) {
return { error: `Run ${params.runFriendlyId} not found` };
}
return result;
} catch (error) {
return {
error: `Failed to get run details: ${error instanceof Error ? error.message : String(error)}`,
};
}
},
});
}
@@ -0,0 +1,82 @@
import { tool } from "ai";
import { getRunGraph as getRunGraphSchema } from "~/lib/ai-assistant/tool-schemas";
import type { ToolContext } from "../types";
export function createGetRunGraphTool(ctx: ToolContext) {
return tool({
...getRunGraphSchema,
execute: async (params: { runFriendlyId: string }) => {
try {
const { getRunForLLM } = await import("./run-presenter-adapter");
const runWithTrace = await getRunForLLM(ctx, params.runFriendlyId);
if (!runWithTrace) {
return {
error: `Run ${params.runFriendlyId} not found`,
root: null,
children: [],
};
}
const { run } = runWithTrace;
// Build a graph structure showing parent/child relationships
const graph = {
root: run,
parent: run.parentRunId ? { id: run.parentRunId } : null,
ancestorChain: [] as string[],
children: [] as any[],
};
// If we have root run info, that's the top of the chain
if (run.rootRunId && run.rootRunId !== run.id) {
graph.ancestorChain.push(run.rootRunId);
}
// Fetch child runs if this is the root
if (!run.parentRunId) {
try {
const { prisma } = await import("~/db.server");
const childRuns = await prisma.taskRun.findMany({
where: {
parentRunId: run.id,
runtimeEnvironment: {
slug: ctx.clientData.environmentSlug,
project: {
slug: ctx.clientData.projectSlug,
},
},
},
select: {
friendlyId: true,
status: true,
startedAt: true,
finishedAt: true,
},
orderBy: { createdAt: "asc" },
take: 20,
});
graph.children = childRuns.map((child: any) => ({
id: child.friendlyId,
status: child.status,
startedAt: child.startedAt ? new Date(child.startedAt).toISOString() : undefined,
finishedAt: child.finishedAt ? new Date(child.finishedAt).toISOString() : undefined,
}));
} catch {
// If we can't fetch children, just return the root
}
}
return graph;
} catch (error) {
return {
error: `Failed to get run graph: ${error instanceof Error ? error.message : String(error)}`,
root: null,
children: [],
};
}
},
});
}
@@ -0,0 +1,87 @@
import { tool } from "ai";
import { getRunLogs as getRunLogsSchema } from "~/lib/ai-assistant/tool-schemas";
import type { ToolContext } from "../types";
export function createGetRunLogsTool(ctx: ToolContext) {
return tool({
...getRunLogsSchema,
execute: async (params: { runFriendlyId: string; level?: string; limit?: number }) => {
try {
const { prisma } = await import("~/db.server");
const { getTaskEventStoreTableForRun, TaskEventStore } = await import("~/v3/taskEventStore.server");
const { $replica } = await import("~/db.server");
// Fetch the run to get its ID and event store table
const run = await prisma.taskRun.findFirst({
where: {
friendlyId: params.runFriendlyId,
runtimeEnvironment: {
slug: ctx.clientData.environmentSlug,
project: {
slug: ctx.clientData.projectSlug,
},
},
},
select: {
id: true,
taskEventStore: true,
createdAt: true,
},
});
if (!run) {
return {
error: `Run ${params.runFriendlyId} not found`,
logs: [],
};
}
const eventStore = new TaskEventStore(prisma, $replica);
const table = getTaskEventStoreTableForRun(run);
// Build the where clause for log filtering
const where = {
runId: run.id,
kind: { in: ["LOG", "TASK"] },
...(params.level && { level: params.level.toUpperCase() }),
};
// Fetch log events
const logEvents = await eventStore.findMany(
table,
where,
run.createdAt,
undefined,
{
message: true,
level: true,
startTime: true,
},
{ startTime: "asc" },
{ limit: params.limit || 50 }
);
// Format for LLM
const logs = logEvents.map((event: any) => {
const timestamp = event.startTime
? new Date(Number(event.startTime) / 1000000).toISOString()
: new Date().toISOString();
const level = event.level || "INFO";
return `[${level}] ${timestamp}: ${event.message || "(no message)"}`;
});
return {
runFriendlyId: params.runFriendlyId,
logs,
total: logs.length,
};
} catch (error) {
return {
error: `Failed to get run logs: ${error instanceof Error ? error.message : String(error)}`,
logs: [],
runFriendlyId: params.runFriendlyId,
};
}
},
});
}
@@ -0,0 +1,25 @@
import { tool } from "ai";
import { getSpanDetails as getSpanDetailsSchema } from "~/lib/ai-assistant/tool-schemas";
import type { ToolContext } from "../types";
export function createGetSpanDetailsTool(ctx: ToolContext) {
return tool({
...getSpanDetailsSchema,
execute: async (params: { runFriendlyId: string; spanId: string }) => {
try {
const { getSpanForLLM } = await import("./span-detail-adapter");
const result = await getSpanForLLM(ctx, params.runFriendlyId, params.spanId);
if (!result) {
return { error: `Span ${params.spanId} not found in run ${params.runFriendlyId}` };
}
return result;
} catch (error) {
return {
error: `Failed to get span details: ${
error instanceof Error ? error.message : String(error)
}`,
};
}
},
});
}
@@ -0,0 +1,151 @@
import { tool } from "ai";
import { logger } from "@trigger.dev/sdk";
import { isTaskRunStatus, QUEUED_STATUSES, RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus";
import { FAILED_RUN_STATUSES } from "~/v3/taskStatus";
import { listRuns as listRunsSchema } from "~/lib/ai-assistant/tool-schemas";
import type { ToolContext } from "../types";
// The LLM passes colloquial statuses ("failed", "running") that aren't real
// TaskRunStatus values, so filtering on them matches nothing. Map those onto
// the canonical groupings the /runs status filter uses, so this stays in sync.
const STATUS_SYNONYMS: Record<string, readonly string[]> = {
FAILED: FAILED_RUN_STATUSES,
FAILURE: FAILED_RUN_STATUSES,
ERROR: FAILED_RUN_STATUSES,
ERRORED: FAILED_RUN_STATUSES,
SUCCESS: ["COMPLETED_SUCCESSFULLY"],
SUCCESSFUL: ["COMPLETED_SUCCESSFULLY"],
SUCCEEDED: ["COMPLETED_SUCCESSFULLY"],
COMPLETED: ["COMPLETED_SUCCESSFULLY"],
RUNNING: RUNNING_STATUSES,
IN_PROGRESS: RUNNING_STATUSES,
CANCELLED: ["CANCELED"],
TIMEOUT: ["TIMED_OUT"],
QUEUED: QUEUED_STATUSES,
};
function normalizeStatuses(input?: string[]): string[] | undefined {
if (!input || input.length === 0) return undefined;
const out = new Set<string>();
const unrecognized: string[] = [];
for (const raw of input) {
const key = raw.trim().toUpperCase().replace(/\s+/g, "_");
if (isTaskRunStatus(key)) {
out.add(key);
} else if (STATUS_SYNONYMS[key]) {
for (const s of STATUS_SYNONYMS[key]) out.add(s);
} else {
// Drop rather than pass through — a bogus status would silently filter
// out everything (ClickHouse returns zero rows, no error).
unrecognized.push(raw);
}
}
if (unrecognized.length > 0) {
logger.warn("listRuns ignored unrecognized status filter values", { unrecognized });
}
return out.size > 0 ? Array.from(out) : undefined;
}
function parsePeriod(period: string): { from: number; to: number } {
const now = Date.now();
const units: Record<string, number> = {
m: 60 * 1000,
h: 60 * 60 * 1000,
d: 24 * 60 * 60 * 1000,
};
const match = period.match(/^(\d+)([mhd])$/);
if (!match) return { from: now - 86400000, to: now }; // default 24h
const value = parseInt(match[1], 10);
const unit = match[2];
const milliseconds = value * (units[unit] || units.h);
return { from: now - milliseconds, to: now };
}
export function createListRunsTool(ctx: ToolContext) {
return tool({
...listRunsSchema,
execute: async (params) => {
// All imports inside execute() to avoid env.server.ts at CLI indexing time
const dynamicImport = () => import("~/presenters/v3/NextRunListPresenter.server");
try {
const { NextRunListPresenter } = await dynamicImport();
const { prisma } = await import("~/db.server");
const { clickhouseFactory } = await import("~/services/clickhouse/clickhouseFactoryInstance.server");
// Get the environment from context
const environment = await prisma.runtimeEnvironment.findFirst({
where: {
project: { slug: ctx.clientData.projectSlug },
slug: ctx.clientData.environmentSlug,
},
select: {
id: true,
organizationId: true,
project: { select: { id: true } },
},
});
if (!environment) {
return {
runs: [],
total: 0,
error: "Environment not found",
};
}
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
environment.organizationId,
"standard"
);
const presenter = new NextRunListPresenter(prisma, clickhouse);
// Convert period to ClickHouse time bounds
const timeFilters = parsePeriod(params.period || "");
const result = await presenter.call(
environment.organizationId,
environment.id,
{
projectId: environment.project.id,
tasks: params.taskIdentifier ? [params.taskIdentifier] : undefined,
statuses: normalizeStatuses(params.status) as any[] | undefined,
tags: params.tags,
from: timeFilters.from,
to: timeFilters.to,
pageSize: params.limit || 20,
}
);
// Summarize runs for LLM consumption
const summarizedRuns = result.runs.map((run: any) => ({
id: run.friendlyId,
status: run.status,
isFinished: run.hasFinished ?? false,
startedAt: run.startedAt ? new Date(run.startedAt).toISOString() : undefined,
completedAt: run.finishedAt ? new Date(run.finishedAt).toISOString() : undefined,
duration:
run.finishedAt && run.startedAt
? `${Math.round((new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime()) / 1000)}s`
: undefined,
parentRunId: (run as any).parentTaskRun?.friendlyId,
rootRunId: (run as any).rootTaskRun?.friendlyId,
}));
return {
runs: summarizedRuns,
total: result.pagination?.total || 0,
};
} catch (error) {
return {
runs: [],
total: 0,
error: `Failed to list runs: ${error instanceof Error ? error.message : String(error)}`,
};
}
},
});
}
@@ -0,0 +1,94 @@
import { tool } from "ai";
import { queryRuns as queryRunsSchema } from "~/lib/ai-assistant/tool-schemas";
import type { ToolContext } from "../types";
export function createQueryRunsTool(ctx: ToolContext) {
return tool({
...queryRunsSchema,
execute: async (params: { question: string }) => {
try {
const { AIQueryService } = await import("~/v3/services/aiQueryService.server");
const { runsSchema } = await import("~/v3/querySchemas");
const { clickhouseFactory } = await import("~/services/clickhouse/clickhouseFactoryInstance.server");
const { prisma } = await import("~/db.server");
// Fetch environment to validate access
const environment = await prisma.runtimeEnvironment.findFirst({
where: {
slug: ctx.clientData.environmentSlug,
project: {
slug: ctx.clientData.projectSlug,
},
},
select: {
id: true,
organizationId: true,
project: {
select: {
id: true,
},
},
},
});
if (!environment) {
return {
success: false,
error: "Environment not found",
};
}
// Create the AI query service with the schema
const service = new AIQueryService([runsSchema]);
// Generate a TSQL query from the natural language question
const queryResult = await service.call(params.question);
if (!queryResult.success) {
return {
success: false,
error: queryResult.error,
};
}
// Get the clickhouse client for this organization
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
environment.organizationId,
"standard"
);
// Build the final query with tenant filters applied
const tenantFiltered = `
SELECT *
FROM trigger_dev.task_runs_v2
WHERE
organization_id = '${environment.organizationId}'
AND project_id = '${environment.project.id}'
AND environment_id = '${environment.id}'
AND engine = 'V2'
AND (${queryResult.query})
LIMIT 100
`;
const results = await clickhouse.query({
query: tenantFiltered,
format: "JSONCompact",
});
const parsedResults = JSON.parse(results.text) as Record<string, unknown>;
return {
success: true,
query: queryResult.query,
results: (parsedResults.data as unknown[]) || [],
rowCount: (parsedResults.rows as number) || 0,
};
} catch (error) {
return {
success: false,
error: `Failed to query runs: ${error instanceof Error ? error.message : String(error)}`,
};
}
},
});
}
@@ -0,0 +1,62 @@
import type { Run, RunEvent } from "~/presenters/v3/RunPresenter.server";
import type { RunSummary, SpanSummary, TraceSummary } from "../types";
const MAX_ERROR_LENGTH = 500;
const MAX_SPANS = 20;
const MAX_LOG_LINES = 50;
export function summarizeRun(run: Run): RunSummary {
const duration =
run.completedAt && run.startedAt
? `${Math.round((run.completedAt.getTime() - run.startedAt.getTime()) / 1000)}s`
: undefined;
return {
id: run.friendlyId,
status: run.status,
isFinished: run.isFinished,
startedAt: run.startedAt?.toISOString(),
completedAt: run.completedAt?.toISOString(),
duration,
parentRunId: run.parentTaskRun?.friendlyId ?? undefined,
rootRunId: run.rootTaskRun?.friendlyId ?? undefined,
};
}
export function summarizeSpan(span: RunEvent): SpanSummary {
return {
id: span.id ?? "",
message: span.data?.message ?? "",
isError: span.data?.isError ?? false,
isPartial: span.data?.isPartial ?? false,
duration: span.data?.duration ?? undefined,
level: span.data?.level ?? "info",
runId: span.runId ?? undefined,
};
}
export function summarizeTrace(trace: {
rootSpanStatus: string;
events: RunEvent[];
}): TraceSummary {
return {
rootStatus: trace.rootSpanStatus,
totalSpans: trace.events.length,
spans: trace.events.slice(0, MAX_SPANS).map(summarizeSpan),
truncated: trace.events.length > MAX_SPANS,
};
}
export function truncateError(error: string): string {
if (error.length <= MAX_ERROR_LENGTH) return error;
return error.slice(0, MAX_ERROR_LENGTH) + "...";
}
export function formatLogLines(
logs: Array<{ timestamp?: Date; message: string }>
): string[] {
return logs.slice(0, MAX_LOG_LINES).map((log) => {
const time = log.timestamp?.toISOString() ?? new Date().toISOString();
return `${time}: ${log.message}`;
});
}
@@ -0,0 +1,29 @@
import type { ToolContext, RunWithTrace } from "../types";
import { summarizeRun, summarizeTrace } from "./run-formatters";
export async function getRunForLLM(
ctx: ToolContext,
runFriendlyId: string
): Promise<RunWithTrace | null> {
try {
// Dynamic import keeps `~/db.server` / `~/v3/tracer.server` off the module graph
// during CLI indexing (index worker already registers its own TracingSDK).
const { RunPresenter } = await import("~/presenters/v3/RunPresenter.server");
const presenter = new RunPresenter();
const result = await presenter.call({
userId: ctx.clientData.userId,
projectSlug: ctx.clientData.projectSlug,
environmentSlug: ctx.clientData.environmentSlug,
runFriendlyId,
showDeletedLogs: false,
showDebug: false,
});
return {
run: summarizeRun(result.run),
trace: result.trace ? summarizeTrace(result.trace) : undefined,
};
} catch (error) {
return null;
}
}
@@ -0,0 +1,201 @@
// Import from the narrow subpaths only — never the `@trigger.dev/core/v3` root
// barrel. That barrel re-exports dozens of `*-api.js` global-singleton modules
// and pulling it into the webapp's running task worker re-initializes those
// globals. `/v3/errors` and `/v3/schemas` are pure zod/data with no side effects.
import { createJsonErrorObject } from "@trigger.dev/core/v3/errors";
import {
isAttemptFailedSpanEvent,
isCancellationSpanEvent,
isExceptionSpanEvent,
TaskRunError,
type SpanEvents,
} from "@trigger.dev/core/v3/schemas";
import type { SpanDetailSummary, SpanException, ToolContext } from "../types";
const MAX_FIELD_LENGTH = 2000;
const MAX_STACK_LENGTH = 4000;
const MAX_EXCEPTIONS = 5;
const FAILED_RUN_STATUSES = new Set([
"COMPLETED_WITH_ERRORS",
"CRASHED",
"SYSTEM_FAILURE",
"TIMED_OUT",
"EXPIRED",
]);
function truncate(value: string | undefined, max: number): string | undefined {
if (value === undefined) return undefined;
return value.length <= max ? value : value.slice(0, max) + "\n…(truncated)";
}
function stringifyField(value: unknown): string | undefined {
if (value === undefined || value === null) return undefined;
const s = typeof value === "string" ? value : JSON.stringify(value, null, 2);
return truncate(s, MAX_FIELD_LENGTH);
}
// Pull every exception/cancellation out of a span's OTel events.
function extractExceptions(events: SpanEvents | undefined): SpanException[] {
if (!events?.length) return [];
const out: SpanException[] = [];
for (const event of events) {
if (isExceptionSpanEvent(event) || isAttemptFailedSpanEvent(event)) {
const ex = event.properties.exception;
out.push({
type: ex.type,
message: ex.message,
stackTrace: truncate(ex.stacktrace, MAX_STACK_LENGTH),
});
} else if (isCancellationSpanEvent(event)) {
out.push({ type: "cancellation", message: event.properties.reason });
}
if (out.length >= MAX_EXCEPTIONS) break;
}
return out;
}
function exceptionFromRunError(error: unknown): SpanException | undefined {
const parsed = TaskRunError.safeParse(error);
if (parsed.success) {
const json = createJsonErrorObject(parsed.data);
return {
type: json.name,
message: json.message,
stackTrace: truncate(json.stackTrace, MAX_STACK_LENGTH),
};
}
return { message: truncate(JSON.stringify(error), MAX_STACK_LENGTH) };
}
function formatDuration(nanos: number | null | undefined): string | undefined {
if (nanos === null || nanos === undefined) return undefined;
return `${Math.round(nanos / 1_000_000)}ms`;
}
export async function getSpanForLLM(
ctx: ToolContext,
runFriendlyId: string,
spanId: string
): Promise<SpanDetailSummary | { error: string } | null> {
try {
// Use the dedicated trigger-task Prisma client and the engine-free event
// repository. We deliberately do NOT touch SpanPresenter here: it imports
// `~/v3/runEngine.server`, whose `engine.resolveTaskRunContext()` boots the
// full RunEngine singleton (Redis, background workers, heartbeats) inside the
// task worker and floods the OTel ingest endpoint. The event repository's
// `getSpan` returns the same span detail without any of that.
const { prisma } = await import("../../db");
const { getEventRepositoryForStore } = await import("~/v3/eventRepository/index.server");
const { getTaskEventStoreTableForRun } = await import("~/v3/taskEventStore.server");
const parentRun = await prisma.taskRun.findFirst({
where: {
friendlyId: runFriendlyId,
runtimeEnvironment: {
slug: ctx.clientData.environmentSlug,
project: { slug: ctx.clientData.projectSlug },
},
},
select: {
traceId: true,
taskEventStore: true,
createdAt: true,
completedAt: true,
runtimeEnvironmentId: true,
runtimeEnvironment: { select: { organizationId: true } },
},
});
if (!parentRun) {
return { error: `Run ${runFriendlyId} not found` };
}
const repository = await getEventRepositoryForStore(
parentRun.taskEventStore,
parentRun.runtimeEnvironment.organizationId
);
const eventStore = getTaskEventStoreTableForRun(parentRun);
const span = await repository.getSpan(
eventStore,
parentRun.runtimeEnvironmentId,
spanId,
parentRun.traceId,
parentRun.createdAt,
parentRun.completedAt ?? undefined,
{ includeDebugLogs: true }
);
// If this span is itself a triggered run, surface that run's error/output
// straight from the TaskRun row (no presenter, no engine).
const spanRun = await prisma.taskRun.findFirst({
where: { spanId, runtimeEnvironmentId: parentRun.runtimeEnvironmentId },
select: {
friendlyId: true,
status: true,
taskIdentifier: true,
error: true,
output: true,
outputType: true,
metadata: true,
},
});
if (!span && !spanRun) {
return { error: `Span ${spanId} not found in run ${runFriendlyId}` };
}
const exceptions = extractExceptions(span?.events as SpanEvents | undefined);
if (spanRun?.error) {
const runException = exceptionFromRunError(spanRun.error);
if (runException) exceptions.unshift(runException);
}
const properties = stringifyField(span?.properties);
if (spanRun) {
// Packet references (application/store) are download keys, not data.
const output =
spanRun.output && spanRun.outputType !== "application/store"
? stringifyField(spanRun.output)
: undefined;
return {
spanId,
kind: "run",
message: span?.message ?? spanRun.taskIdentifier,
isError: span?.isError ?? FAILED_RUN_STATUSES.has(spanRun.status),
isCancelled: span?.isCancelled ?? spanRun.status === "CANCELED",
level: span?.level,
duration: formatDuration(span?.duration),
runFriendlyId: spanRun.friendlyId,
taskIdentifier: spanRun.taskIdentifier,
status: spanRun.status,
exceptions,
metadata: stringifyField(spanRun.metadata),
properties,
output,
};
}
// A generic trace span (HTTP call, log group, tool call, etc.).
return {
spanId: span!.spanId,
kind: "span",
message: span!.message,
isError: span!.isError,
isCancelled: span!.isCancelled,
level: span!.level,
duration: formatDuration(span!.duration),
exceptions,
properties,
};
} catch (error) {
return {
error: `Failed to get span details: ${
error instanceof Error ? error.message : String(error)
}`,
};
}
}
@@ -0,0 +1,174 @@
import { openai } from "@ai-sdk/openai";
import { generateText, stepCountIs, tool } from "ai";
import { inflate } from "node:zlib";
import { promisify } from "node:util";
import { z } from "zod";
import type { ToolContext } from "../types";
const inflateAsync = promisify(inflate);
type GeneratePayloadResult =
| { success: true; taskIdentifier: string; payload: string; schemaSource: "schema" | "source" }
| { success: false; error: string };
// Mirrors the Test page's AI payload generation
// (resources…test.ai-generate-payload.tsx) but returns the payload as a value
// so the assistant can fill the editor and/or feed it into runTestTask.
export async function generatePayloadForTask(
ctx: ToolContext,
taskIdentifier: string,
instruction?: string
): Promise<GeneratePayloadResult> {
const { env } = await import("~/env.server");
const { $replica } = await import("~/db.server");
const { resolveTestEnvironment } = await import("./resolve-environment");
if (!env.OPENAI_API_KEY) {
return { success: false, error: "OpenAI API key is not configured" };
}
const environment = await resolveTestEnvironment(ctx);
const task = await $replica.backgroundWorkerTask.findFirst({
where: { slug: taskIdentifier, runtimeEnvironmentId: environment.id },
orderBy: { createdAt: "desc" },
select: { payloadSchema: true },
});
const payloadSchema =
task?.payloadSchema != null ? JSON.stringify(task.payloadSchema) : undefined;
const system = buildSystemPrompt(taskIdentifier, payloadSchema);
const prompt =
instruction && instruction.trim().length > 0
? instruction.trim()
: "Generate a simple valid payload to test this task with.";
const result = await generateText({
model: openai(env.AI_RUN_FILTER_MODEL ?? "gpt-5-mini"),
temperature: 1,
system,
prompt,
tools: {
getTaskSourceCode: tool({
description:
"Look up the source code of the task to understand the payload shape it expects. Use " +
"when there is no JSON Schema available.",
inputSchema: z.object({}),
execute: async () =>
getTaskSourceCode(environment.id, environment.type, taskIdentifier),
}),
},
stopWhen: stepCountIs(3),
});
const payload = extractJsonFromText(result.text);
if (!payload) {
return { success: false, error: "Could not generate a valid JSON payload" };
}
return {
success: true,
taskIdentifier,
payload,
schemaSource: payloadSchema ? "schema" : "source",
};
}
async function getTaskSourceCode(
environmentId: string,
environmentType: string,
taskIdentifier: string
): Promise<string> {
try {
const { $replica } = await import("~/db.server");
let fileId: string | null | undefined;
if (environmentType !== "DEVELOPMENT") {
const { findCurrentWorkerDeployment } = await import(
"~/v3/models/workerDeployment.server"
);
const deployment = await findCurrentWorkerDeployment({ environmentId });
fileId = deployment?.worker?.tasks.find((t) => t.slug === taskIdentifier)?.fileId;
} else {
const task = await $replica.backgroundWorkerTask.findFirst({
where: { slug: taskIdentifier, runtimeEnvironmentId: environmentId },
orderBy: { createdAt: "desc" },
select: { fileId: true },
});
fileId = task?.fileId;
}
if (!fileId) return "Source code not available for this task.";
const file = await $replica.backgroundWorkerFile.findUnique({
where: { id: fileId },
select: { contents: true, filePath: true },
});
if (!file) return "Source code not available for this task.";
// File contents are zlib-deflated then base64-encoded by the CLI.
const base64 = Buffer.from(file.contents).toString("utf-8");
const decompressed = (await inflateAsync(Buffer.from(base64, "base64"))).toString("utf-8");
return `File: ${file.filePath}\n\n${decompressed}`;
} catch {
return "Failed to retrieve task source code.";
}
}
function buildSystemPrompt(taskIdentifier: string, payloadSchema?: string): string {
let prompt = `You are a JSON payload generator for a Trigger.dev task with id "${taskIdentifier}".
Your job is to generate a valid JSON payload that can be used to test this task. Return ONLY valid JSON wrapped in a \`\`\`json code block. Do not include any explanation outside the code block.
Requirements:
- Generate realistic, meaningful example data
- All string values should be plausible (real-looking names, emails, URLs, etc.)
- Number values should be reasonable for their context
- The JSON must be valid and parseable`;
if (payloadSchema) {
prompt += `
The task has the following JSON Schema that the payload must conform to:
\`\`\`json
${payloadSchema}
\`\`\`
Generate a payload that strictly conforms to this schema, respecting all type constraints, required fields, enums, formats, and validation rules.`;
} else {
prompt += `
No JSON Schema is available for this task. Use the getTaskSourceCode tool to look up the task's source code file.
IMPORTANT instructions for reading the source code:
- The file may contain multiple task definitions. Find the one with id "${taskIdentifier}".
- Look at the \`run\` function's payload parameter type to determine the expected shape.
- If the payload is typed as \`any\`/\`unknown\` or has no annotation, infer the shape from how payload properties are accessed inside the \`run\` body.
- If the payload is typed explicitly (e.g. \`{ name: string, count: number }\`), use that exactly.
- If the payload is never accessed, the task likely accepts any payload — generate a simple \`{}\` object.
- Do NOT invent fields you can't confirm from the type or usage.`;
}
return prompt;
}
function extractJsonFromText(text: string): string | null {
const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (codeBlockMatch) {
try {
return JSON.stringify(JSON.parse(codeBlockMatch[1].trim()), null, 2);
} catch {
// fall through
}
}
const jsonMatch = text.match(/(\{[\s\S]*\}|\[[\s\S]*\])/);
if (jsonMatch) {
try {
return JSON.stringify(JSON.parse(jsonMatch[1]), null, 2);
} catch {
// fall through
}
}
return null;
}
@@ -0,0 +1,22 @@
import { tool } from "ai";
import { generateTestPayload as generateTestPayloadSchema } from "~/lib/ai-assistant/tool-schemas";
import type { ToolContext } from "../types";
export function createGenerateTestPayloadTool(ctx: ToolContext) {
return tool({
...generateTestPayloadSchema,
execute: async (params: { taskIdentifier: string; instruction?: string }) => {
try {
const { generatePayloadForTask } = await import("./generate-payload-adapter");
return await generatePayloadForTask(ctx, params.taskIdentifier, params.instruction);
} catch (error) {
return {
success: false,
error: `Failed to generate test payload: ${
error instanceof Error ? error.message : String(error)
}`,
};
}
},
});
}
@@ -0,0 +1,49 @@
import { tool } from "ai";
import { listTestableTasks as listTestableTasksSchema } from "~/lib/ai-assistant/tool-schemas";
import type { ToolContext } from "../types";
const MAX_TASKS = 50;
export function createListTestableTasksTool(ctx: ToolContext) {
return tool({
...listTestableTasksSchema,
execute: async ({ query }: { query?: string }) => {
try {
const { resolveTestEnvironment } = await import("./resolve-environment");
const { TestPresenter } = await import("~/presenters/v3/TestPresenter.server");
const environment = await resolveTestEnvironment(ctx);
const presenter = new TestPresenter();
const { tasks } = await presenter.call({
userId: ctx.clientData.userId,
projectId: environment.projectId,
environmentId: environment.id,
environmentType: environment.type,
});
const needle = query?.trim().toLowerCase();
const filtered = (needle
? tasks.filter((t) => t.taskIdentifier.toLowerCase().includes(needle))
: tasks
).slice(0, MAX_TASKS);
return {
tasks: filtered.map((t) => ({
taskIdentifier: t.taskIdentifier,
triggerSource: t.triggerSource,
filePath: t.filePath,
})),
total: tasks.length,
truncated: tasks.length > filtered.length,
};
} catch (error) {
return {
error: `Failed to list testable tasks: ${
error instanceof Error ? error.message : String(error)
}`,
};
}
},
});
}
@@ -0,0 +1,31 @@
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import type { ToolContext } from "../types";
// Resolves the project + authenticated environment for a tool call from the
// client data slugs. Throws a friendly error the tool layer converts to text.
export async function resolveTestEnvironment(
ctx: ToolContext
): Promise<AuthenticatedEnvironment> {
const { findProjectBySlug } = await import("~/models/project.server");
const { findEnvironmentBySlug } = await import("~/models/runtimeEnvironment.server");
const project = await findProjectBySlug(
ctx.clientData.organizationSlug,
ctx.clientData.projectSlug,
ctx.clientData.userId
);
if (!project) {
throw new Error(`Project "${ctx.clientData.projectSlug}" not found`);
}
const environment = await findEnvironmentBySlug(
project.id,
ctx.clientData.environmentSlug,
ctx.clientData.userId
);
if (!environment) {
throw new Error(`Environment "${ctx.clientData.environmentSlug}" not found`);
}
return environment;
}
@@ -0,0 +1,60 @@
import { tool } from "ai";
import { runTestTask as runTestTaskSchema } from "~/lib/ai-assistant/tool-schemas";
import type { ToolContext } from "../types";
type RunTestTaskParams = {
taskIdentifier: string;
payload?: Record<string, unknown>;
metadata?: Record<string, unknown>;
tags?: string[];
};
export function createRunTestTaskTool(ctx: ToolContext) {
return tool({
...runTestTaskSchema,
execute: async (params: RunTestTaskParams) => {
try {
const { resolveTestEnvironment } = await import("./resolve-environment");
const { TestTaskService } = await import("~/v3/services/testTask.server");
const environment = await resolveTestEnvironment(ctx);
const service = new TestTaskService();
// TestTaskData is the *parsed* shape: payload/metadata are objects and
// tags is a string[] (the zod transforms have already run).
const run = await service.call(environment, {
triggerSource: "STANDARD",
taskIdentifier: params.taskIdentifier,
environmentId: environment.id,
payload: params.payload ?? {},
metadata: params.metadata ?? {},
tags: params.tags && params.tags.length > 0 ? params.tags.slice(0, 10) : undefined,
});
if (!run) {
return {
success: false,
error: `Could not trigger a test run for "${params.taskIdentifier}"`,
};
}
const { v3RunPath } = await import("~/utils/pathBuilder");
const url = v3RunPath(ctx.org, ctx.project, ctx.env, { friendlyId: run.friendlyId });
return {
success: true,
taskIdentifier: params.taskIdentifier,
runId: run.friendlyId,
url,
};
} catch (error) {
return {
success: false,
error: `Failed to run test task: ${
error instanceof Error ? error.message : String(error)
}`,
};
}
},
});
}
@@ -0,0 +1,119 @@
// Matches the `withClientData` schema on the chat.agent definition.
export interface ClientData {
userId: string;
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
currentPage: string;
currentParams?: Record<string, string>;
}
export interface ToolContext {
clientData: ClientData;
// Pre-built path objects for pathBuilder functions.
org: { slug: string };
project: { slug: string };
env: { slug: string };
}
export function buildToolContext(clientData: ClientData): ToolContext {
if (!clientData?.organizationSlug || !clientData?.projectSlug || !clientData?.environmentSlug) {
throw new Error("Invalid clientData: missing organization, project, or environment slug");
}
return {
clientData,
org: { slug: clientData.organizationSlug },
project: { slug: clientData.projectSlug },
env: { slug: clientData.environmentSlug },
};
}
// V1B Summaries — LLM-friendly, token-efficient versions of presenter results
export interface SpanSummary {
id: string;
message: string;
isError: boolean;
isPartial: boolean;
duration?: number;
level: string;
// Friendly ID of the run this span triggered, if the span is itself a run.
// Lets the agent drill into the child run (getRunDetails / getSpanDetails).
runId?: string;
}
// Extracted exception from a span event or run error.
export interface SpanException {
type?: string;
message?: string;
stackTrace?: string;
}
// Full detail of a single span (subtrace), tuned for LLM error investigation.
export interface SpanDetailSummary {
spanId: string;
// "span" = generic trace span; "run" = the span is itself a triggered run.
kind: "span" | "run";
message: string;
isError: boolean;
isCancelled?: boolean;
level?: string;
duration?: string;
// For run-kind spans:
runFriendlyId?: string;
taskIdentifier?: string;
status?: string;
// The thing the agent actually wants when asked "why did this fail":
exceptions: SpanException[];
metadata?: string;
properties?: string;
output?: string;
}
export interface TraceSummary {
rootStatus: string;
totalSpans: number;
spans: SpanSummary[];
truncated: boolean;
}
export interface RunSummary {
id: string;
status: string;
isFinished: boolean;
startedAt?: string;
completedAt?: string;
duration?: string;
parentRunId?: string;
rootRunId?: string;
}
export interface RunWithTrace {
run: RunSummary;
trace?: TraceSummary;
}
export interface ErrorGroupSummary {
fingerprint: string;
message: string;
taskIdentifier: string;
count: number;
firstSeen: string;
lastSeen: string;
status: string;
}
export interface ErrorDetailsSummary {
fingerprint: string;
message: string;
taskIdentifier: string;
stackTrace?: string;
count: number;
firstSeen: string;
lastSeen: string;
affectedRuns: Array<{
runFriendlyId: string;
status: string;
occurredAt: string;
}>;
}
+268
View File
@@ -0,0 +1,268 @@
import { chat } from "@trigger.dev/sdk/ai";
import { logger, prompts, sessions } from "@trigger.dev/sdk";
import { streamText, stepCountIs, generateText, generateId } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
import { prisma } from "./db";
import { buildAssistantTools } from "./ai-assistant-tools";
type ChatMessagesForWrite = NonNullable<
Parameters<typeof prisma.aiChat.update>[0]["data"]
>["messages"];
const systemPrompt = prompts.define({
id: "dashboard-assistant-system",
model: "openai:gpt-4.1-mini",
config: { temperature: 0.7 },
variables: z.object({
projectSlug: z.string(),
environmentSlug: z.string(),
currentPage: z.string(),
}),
content: `You are the Trigger.dev AI assistant, embedded in the dashboard.
## Your role
Help the user navigate the dashboard, find documentation, understand Trigger.dev features, and investigate runs and errors.
## Current context
The user is viewing: project "{{projectSlug}}" / {{environmentSlug}} environment / {{currentPage}} page.
## Guidelines
- Be concise and friendly. Prefer short, direct answers unless the user asks for detail.
- When the user asks how something works, ALWAYS search documentation first.
- When the user asks "where do I find X" or "take me to Y", use navigateToPage. To open a specific run, call navigateToPage with that run's friendly ID as runId (and a spanId to deep-link to a specific span/subtrace within its trace).
- Use getCurrentContext to ground answers in what the user is viewing. If the user refers to "this run" or "the run I'm looking at" without an ID, check getCurrentContext for a run ID in the current params before asking.
- To investigate why a run failed, call getRunDetails first — its trace lists each span with an \`id\` and an \`isError\` flag. Then call getSpanDetails with the failing span's \`id\` to read the exact exception, stack trace, and metadata for that subtrace. Don't guess the cause from the span message alone — drill in.
- To test or run a task (e.g. "run a smoke test on hello-world", "test the email task"): (1) if the task isn't named or you're unsure it exists, call listTestableTasks to find it; (2) call navigateToPage with \`testTaskId\` set to the task identifier so the user sees the Test page; (3) call generateTestPayload to create a payload (this fills the editor on that page for the user); (4) ONLY if the user asked to actually run/trigger/smoke-test it, call runTestTask with the payload from step 3 — this triggers the run and navigates them to it. If the user only asked to "fill" or "prepare" a payload, stop after step 3 and do NOT run it.
- Use markdown formatting for code blocks and prose. Do NOT format tool data as markdown tables.
- If you don't know something, say so — don't make things up.
- When you use a tool, briefly explain what you're doing.
## Rendering tool results
Run, error, and analytics tool results (listRuns, listErrors, queryRuns, aggregateRuns, classifyFailure, applyRunFilters, etc.) are rendered for the user as rich UI — tables, cards, and chips — directly from the tool output. Do NOT repeat that data in your reply: never re-list the rows as a bullet list or a hand-written markdown table, even if the user says "show it as a table" (the table is already there). After a data tool runs, just give a one-line summary (e.g. "Here are your 8 most recent failed runs") and let the rendered component show the rows.
## Completing multi-step requests
Many requests have several parts — e.g. "go to the runs page and show me the failed ones" is two steps: (1) navigate there, then (2) inspect and show the results. Carry out EVERY part in the same turn by chaining tool calls: act on the first part, then immediately continue to the next. Do NOT stop after the first step to ask "do you want me to do the next thing?" — the user already asked for it, so just do it. Only pause to ask a question when a step is genuinely ambiguous (you can't tell what the user means) or would change/delete something.
## What you CAN do
- Search and read Trigger.dev documentation
- Navigate the user to any dashboard page, or deep-link straight to a run or a span within its trace
- Explain Trigger.dev features, configuration, and APIs
- Help with common questions about retries, concurrency, deployments, env vars, etc.
- Inspect specific runs, errors, and logs — including drilling into an individual span (subtrace) to read its exact exception and metadata
- Analyze run failures and trends
- Find similar errors and correlate with deployments
- List testable tasks, generate test payloads, and run/trigger a test of a task when asked
## What you CANNOT do yet
- Modify settings
- Access the user's code (beyond what generateTestPayload reads to infer a payload)
## When explaining why a run failed, use this format:
**Summary:** One sentence.
**Category:** Timeout / OOM / Missing env var / Child task failed / User code exception / AI provider error / Deploy regression / Platform issue / Unknown
**Likely cause:** 1-2 sentences with evidence.
**Confidence:** High / Medium / Low
**Evidence:**
- Error: <message>
- Failed span: <name, duration>
- Deploy: <version, time since deploy>`,
});
export const dashboardAssistant = chat
.withClientData({
schema: z.object({
userId: z.string(),
organizationSlug: z.string(),
projectSlug: z.string(),
environmentSlug: z.string(),
currentPage: z.string(),
currentParams: z.record(z.string()).optional(),
}),
})
.agent({
id: "dashboard-assistant",
idleTimeoutInSeconds: 60,
chatAccessTokenTTL: "1h",
// Declared here (not just on streamText) so the SDK re-applies each tool's
// `toModelOutput` when re-converting prior-turn history. run() reads them
// back via `tools`.
tools: async (event) => buildAssistantTools(event.clientData!),
uiMessageStreamOptions: {
onError: (error: unknown) => {
logger.error("Stream error", { error });
if (error instanceof Error && error.message.includes("rate limit")) {
return "Rate limited — please wait a moment and try again.";
}
return "Something went wrong. Please try again.";
},
},
onBoot: async ({ clientData }) => {
if (!clientData) return;
const resolved = await systemPrompt.resolve({
projectSlug: clientData.projectSlug,
environmentSlug: clientData.environmentSlug,
currentPage: clientData.currentPage,
});
chat.prompt.set(resolved);
},
onPreload: async ({ chatId, clientData }) => {
if (!clientData) return;
// Create session through Trigger platform and local chat record
await sessions.start({
type: "chat.agent",
externalId: chatId,
taskIdentifier: "dashboard-assistant",
triggerConfig: {
basePayload: {
userId: clientData.userId,
organizationSlug: clientData.organizationSlug,
projectSlug: clientData.projectSlug,
environmentSlug: clientData.environmentSlug,
currentPage: clientData.currentPage,
currentParams: clientData.currentParams,
},
},
tags: [
`user:${clientData.userId}`,
`org:${clientData.organizationSlug}`,
`project:${clientData.projectSlug}`,
],
});
},
// Fallback for non-preloaded runs; onPreload already created the session.
onChatStart: async ({ chatId, clientData, preloaded }) => {
if (preloaded) return;
if (!clientData) return;
// Create session through Trigger platform and local chat record
await sessions.start({
type: "chat.agent",
externalId: chatId,
taskIdentifier: "dashboard-assistant",
triggerConfig: {
basePayload: {
userId: clientData.userId,
organizationSlug: clientData.organizationSlug,
projectSlug: clientData.projectSlug,
environmentSlug: clientData.environmentSlug,
currentPage: clientData.currentPage,
currentParams: clientData.currentParams,
},
},
tags: [
`user:${clientData.userId}`,
`org:${clientData.organizationSlug}`,
`project:${clientData.projectSlug}`,
],
});
},
// Await the write (not chat.defer): a deferred write loses the user
// message on a mid-stream page refresh.
onTurnStart: async ({ chatId, uiMessages, clientData }) => {
const messages = uiMessages as unknown as ChatMessagesForWrite;
await prisma.aiChat.upsert({
where: { id: chatId },
create: {
id: chatId,
title: "New chat",
userId: clientData?.userId ?? "",
model: "gpt-4.1-mini",
messages,
},
update: { messages },
});
if (Array.isArray(uiMessages)) {
const firstUser = uiMessages.find((m) => m?.role === "user");
const text = firstUser
? (firstUser.parts ?? [])
.filter((p: { type?: string }) => p?.type === "text")
.map((p: { text?: string }) => p.text ?? "")
.join(" ")
.trim()
: "";
if (text) {
const title = text.length > 60 ? `${text.slice(0, 60).trimEnd()}` : text;
await prisma.aiChat.updateMany({
where: { id: chatId, title: "New chat" },
data: { title },
});
}
}
},
// Persist messages after turn completes
onTurnComplete: async ({ chatId, uiMessages }) => {
await prisma.aiChat.update({
where: { id: chatId },
data: { messages: uiMessages as unknown as ChatMessagesForWrite },
});
},
compaction: {
shouldCompact: ({ totalTokens }) => (totalTokens ?? 0) > 80_000,
summarize: async ({ messages }) => {
return generateText({
model: openai("gpt-4.1-mini"),
messages: [
...messages,
{
role: "user",
content: "Summarize this conversation concisely. Capture key topics, " +
"questions asked, answers given, runs/errors inspected, and any " +
"conclusions reached. Keep context needed to continue naturally.",
},
],
}).then((r) => r.text);
},
compactUIMessages: ({ uiMessages, summary }) => [
{
id: generateId(),
role: "assistant",
parts: [{ type: "text", text: `[Conversation summary]\n\n${summary}` }],
},
...uiMessages.slice(-2),
],
},
pendingMessages: {
shouldInject: ({ steps }) => steps.length > 0,
prepare: ({ messages }) => {
const getMessageText = (m: any) => {
const textPart = m?.parts?.find((p: any) => p?.type === "text");
return textPart?.text ?? "";
};
if (messages.length === 1) {
return [{ role: "user", content: getMessageText(messages[0]) }];
}
const messageList = messages
.map((m, i) => `${i + 1}. ${getMessageText(m)}`)
.join("\n");
return [{
role: "user",
content: `The user sent ${messages.length} messages while you were working:\n\n${messageList}`,
}];
},
},
// chat.toStreamTextOptions() must be spread first. `tools` comes from the
// run payload so streamText and the history re-converter see the same set.
run: async ({ messages, tools, stopSignal }) => {
return streamText({
...chat.toStreamTextOptions({ tools }),
model: openai("gpt-4.1-mini"),
messages,
abortSignal: stopSignal,
stopWhen: stepCountIs(10),
});
},
});
+8
View File
@@ -0,0 +1,8 @@
import { PrismaClient } from "@trigger.dev/database";
// Dedicated client for trigger tasks. Importing the webapp's `~/db.server`
// pulls in `~/v3/tracer.server`, whose module-load OTel registration collides
// with the worker's own ("Attempted duplicate registration of API: trace").
export const prisma = new PrismaClient({
datasourceUrl: process.env.DATABASE_URL,
});
+23 -1
View File
@@ -60,9 +60,20 @@ import { prisma } from "~/db.server";
import { metricsRegister } from "~/metrics.server";
import type { Prisma } from "@trigger.dev/database";
import { performance } from "node:perf_hooks";
import type { TracerProvider } from "@opentelemetry/api";
export const SEMINTATTRS_FORCE_RECORDING = "forceRecording";
/** True when another SDK (e.g. CLI index/run worker `TracingSDK`) already registered OTel globals. */
function isOtelGloballyInitialized(): boolean {
const provider = trace.getTracerProvider();
if (typeof (provider as TracerProvider & { getDelegate?: () => TracerProvider }).getDelegate === "function") {
const delegate = (provider as TracerProvider & { getDelegate: () => TracerProvider }).getDelegate();
return delegate.constructor.name !== "NoopTracerProvider";
}
return true;
}
export const DATASOURCE_CONTEXT_KEY = createContextKey("trigger.db.datasource");
class DatasourceAttributeSpanProcessor implements SpanProcessor {
@@ -210,6 +221,17 @@ function getResource() {
}
function setupTelemetry() {
if (isOtelGloballyInitialized()) {
console.log(`🔦 Tracer: reusing existing global OpenTelemetry provider`);
return {
tracer: trace.getTracer("trigger.dev", "3.3.12"),
logger: logs.getLogger("trigger.dev", "3.3.12"),
provider: trace.getTracerProvider() as NodeTracerProvider,
meter: setupMetrics(),
};
}
if (env.INTERNAL_OTEL_TRACE_DISABLED === "1") {
console.log(`🔦 Tracer disabled, returning a noop tracer`);
@@ -331,7 +353,7 @@ function setupTelemetry() {
}
function setupMetrics() {
if (env.INTERNAL_OTEL_METRIC_EXPORTER_ENABLED === "0") {
if (env.INTERNAL_OTEL_METRIC_EXPORTER_ENABLED === "0" || isOtelGloballyInitialized()) {
return metrics.getMeter("trigger.dev", "3.3.12");
}
+4 -3
View File
@@ -56,7 +56,6 @@
"@electric-sql/react": "^0.3.5",
"@headlessui/react": "^1.7.8",
"@heroicons/react": "^2.0.12",
"@jsonhero/schema-infer": "^0.1.5",
"@internal/cache": "workspace:*",
"@internal/compute": "workspace:*",
"@internal/llm-model-catalog": "workspace:*",
@@ -67,6 +66,7 @@
"@internal/tsql": "workspace:*",
"@internal/zod-worker": "workspace:*",
"@internationalized/date": "^3.5.1",
"@jsonhero/schema-infer": "^0.1.5",
"@kapaai/react-sdk": "^0.1.3",
"@lezer/highlight": "^1.1.6",
"@opentelemetry/api": "1.9.0",
@@ -116,6 +116,7 @@
"@sentry/remix": "9.46.0",
"@slack/web-api": "7.16.0",
"@socket.io/redis-adapter": "^8.3.0",
"@streamdown/code": "^1.1.1",
"@tabler/icons-react": "^3.36.1",
"@tailwindcss/container-queries": "^0.1.1",
"@tanstack/match-sorter-utils": "^8.19.4",
@@ -125,9 +126,9 @@
"@trigger.dev/companyicons": "^1.5.35",
"@trigger.dev/core": "workspace:*",
"@trigger.dev/database": "workspace:*",
"@trigger.dev/rbac": "workspace:*",
"@trigger.dev/otlp-importer": "workspace:*",
"@trigger.dev/platform": "1.0.27",
"@trigger.dev/rbac": "workspace:*",
"@trigger.dev/redis-worker": "workspace:*",
"@trigger.dev/sdk": "workspace:*",
"@types/pg": "8.6.6",
@@ -220,7 +221,6 @@
"sonner": "^1.0.3",
"sql-formatter": "^15.4.10",
"sqs-consumer": "^7.4.0",
"@streamdown/code": "^1.1.1",
"streamdown": "^2.5.0",
"superjson": "^2.2.1",
"tailwind-merge": "^1.12.0",
@@ -249,6 +249,7 @@
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/typography": "^0.5.9",
"@total-typescript/ts-reset": "^0.4.2",
"@trigger.dev/build": "4.4.6",
"@types/bcryptjs": "^2.4.2",
"@types/compression": "^1.7.2",
"@types/cookie": "^0.6.0",
+7
View File
@@ -321,12 +321,19 @@ module.exports = {
"0%": { "background-position": "-1px" },
"100%": { "background-position": "7px" },
},
"ai-sparkle-hover": {
"0%": { transform: "scale(1) rotate(0deg)" },
"45%": { transform: "scale(1.1) rotate(-6deg)" },
"75%": { transform: "scale(1.06) rotate(4deg)" },
"100%": { transform: "scale(1) rotate(0deg)" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"tile-scroll": "tile-move 0.5s infinite linear",
"tile-scroll-offset": "tile-move-offset 0.5s infinite linear",
"ai-sparkle-hover": "ai-sparkle-hover 0.45s ease-out 1",
},
backgroundImage: {
"gradient-radial": "radial-gradient(closest-side, var(--tw-gradient-stops))",
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "@trigger.dev/sdk";
export default defineConfig({
project: process.env.TRIGGER_PROJECT_REF!,
dirs: ["./app/trigger"],
maxDuration: 3600,
runtime: "node-22",
});
+7 -1
View File
@@ -1,6 +1,12 @@
{
"exclude": ["./cypress", "./cypress.config.ts"],
"include": ["remix.env.d.ts", "global.d.ts", "**/*.ts", "**/*.tsx"],
"include": [
"remix.env.d.ts",
"global.d.ts",
"**/*.ts",
"**/*.tsx",
"trigger.config.ts"
],
"compilerOptions": {
"types": ["vitest/globals", "node"],
"lib": ["DOM", "DOM.Iterable", "DOM.AsyncIterable", "ES2020"],
@@ -0,0 +1,26 @@
-- CreateTable
CREATE TABLE "public"."AiChat" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"title" TEXT NOT NULL DEFAULT 'New chat',
"messages" JSONB NOT NULL DEFAULT '[]',
"model" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AiChat_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."AiChatSession" (
"id" TEXT NOT NULL,
"publicAccessToken" TEXT NOT NULL,
"lastEventId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AiChatSession_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "AiChat_userId_updatedAt_idx" ON "public"."AiChat"("userId", "updatedAt" DESC);
@@ -3172,3 +3172,31 @@ model OrganizationDataStore {
@@index([kind])
}
// ====================================================
// AI Assistant Chat Persistence
// ====================================================
/// Stores chat conversations for the dashboard AI assistant.
/// The id is the same as the chatId / session externalId.
model AiChat {
id String @id
userId String
title String @default("New chat")
messages Json @default("[]")
model String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId, updatedAt(sort: Desc)])
}
/// Stores session state for AI assistant chat sessions.
/// Used for SSE resume and token refresh.
model AiChatSession {
id String @id
publicAccessToken String
lastEventId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+2 -1
View File
@@ -119,7 +119,8 @@
"postcss@>=8 <8.5.10": "^8.5.10",
"yaml@>=2 <2.8.3": "^2.8.3",
"semver@>=5 <5.7.2": "^5.7.2",
"defu@>=6 <6.1.5": "^6.1.5"
"defu@>=6 <6.1.5": "^6.1.5",
"ai": "6.0.116"
},
"onlyBuiltDependencies": [
"@depot/cli",
+1
View File
@@ -92,6 +92,7 @@
"@types/ws": "^8.5.3",
"ai": "^6.0.116",
"encoding": "^0.1.13",
"react": "18.2.0",
"rimraf": "^6.0.1",
"tshy": "^3.0.2",
"tsx": "4.17.0",
+115 -341
View File
@@ -37,6 +37,7 @@ overrides:
yaml@>=2 <2.8.3: ^2.8.3
semver@>=5 <5.7.2: ^5.7.2
defu@>=6 <6.1.5: ^6.1.5
ai: 6.0.116
patchedDependencies:
'@changesets/assemble-release-plan@5.2.4':
@@ -578,8 +579,8 @@ importers:
specifier: 1.1.3
version: 1.1.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
ai:
specifier: ^6.0.116
version: 6.0.168(zod@3.25.76)
specifier: 6.0.116
version: 6.0.116(zod@3.25.76)
assert-never:
specifier: ^1.2.1
version: 1.2.1
@@ -902,6 +903,9 @@ importers:
'@total-typescript/ts-reset':
specifier: ^0.4.2
version: 0.4.2
'@trigger.dev/build':
specifier: 4.4.6
version: 4.4.6(bufferutil@4.0.9)(typescript@5.5.4)
'@types/bcryptjs':
specifier: ^2.4.2
version: 2.4.2
@@ -1015,7 +1019,7 @@ importers:
version: 2.0.5(eslint@8.31.0)
evalite:
specifier: 1.0.0-beta.16
version: 1.0.0-beta.16(ai@6.0.168(zod@3.25.76))(better-sqlite3@11.10.0)(bufferutil@4.0.9)
version: 1.0.0-beta.16(ai@6.0.116(zod@3.25.76))(better-sqlite3@11.10.0)(bufferutil@4.0.9)
npm-run-all:
specifier: ^4.1.5
version: 4.1.5
@@ -1894,8 +1898,8 @@ importers:
specifier: ^4.0.14
version: 4.0.14
ai:
specifier: ^6.0.0
version: 6.0.3(zod@3.25.76)
specifier: 6.0.116
version: 6.0.116(zod@3.25.76)
defu:
specifier: ^6.1.5
version: 6.1.7
@@ -2166,9 +2170,6 @@ importers:
evt:
specifier: ^2.4.13
version: 2.4.13
react:
specifier: ^18.0 || ^19.0
version: 18.3.1
slug:
specifier: ^6.0.0
version: 6.1.0
@@ -2201,11 +2202,14 @@ importers:
specifier: ^8.5.3
version: 8.5.4
ai:
specifier: ^6.0.116
specifier: 6.0.116
version: 6.0.116(zod@3.25.76)
encoding:
specifier: ^0.1.13
version: 0.1.13
react:
specifier: 18.2.0
version: 18.2.0
rimraf:
specifier: ^6.0.1
version: 6.0.1
@@ -2259,7 +2263,7 @@ importers:
specifier: workspace:*
version: link:../../packages/trigger-sdk
ai:
specifier: ^6.0.0
specifier: 6.0.116
version: 6.0.116(zod@3.25.76)
next:
specifier: 15.3.3
@@ -2393,8 +2397,8 @@ importers:
specifier: ^0.10.0
version: 0.10.0
ai:
specifier: 5.0.14
version: 5.0.14(zod@3.25.76)
specifier: 6.0.116
version: 6.0.116(zod@3.25.76)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -2484,8 +2488,8 @@ importers:
specifier: ^0.10.0
version: 0.10.0
ai:
specifier: 4.2.5
version: 4.2.5(react@19.0.0)(zod@3.25.76)
specifier: 6.0.116
version: 6.0.116(zod@3.25.76)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -2582,7 +2586,7 @@ importers:
specifier: workspace:*
version: link:../../packages/trigger-sdk
ai:
specifier: ^6.0.116
specifier: 6.0.116
version: 6.0.116(zod@3.25.76)
arktype:
specifier: ^2.0.0
@@ -2699,8 +2703,8 @@ importers:
specifier: ^7.0.3
version: 7.0.3(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(react@18.3.1)(uploadthing@7.1.0(express@5.2.1)(fastify@5.8.5)(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.1))
ai:
specifier: ^4.0.0
version: 4.0.0(react@18.3.1)(zod@3.25.76)
specifier: 6.0.116
version: 6.0.116(zod@3.25.76)
class-variance-authority:
specifier: ^0.7.0
version: 0.7.0
@@ -2966,8 +2970,8 @@ importers:
specifier: workspace:*
version: link:../../packages/trigger-sdk
ai:
specifier: ^5.0.76
version: 5.0.76(zod@3.25.76)
specifier: 6.0.116
version: 6.0.116(zod@3.25.76)
next:
specifier: 15.5.6
version: 15.5.6(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -3114,30 +3118,6 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/gateway@1.0.6':
resolution: {integrity: sha512-JuSj1MtTr4vw2VBBth4wlbciQnQIV0o1YV9qGLFA+r85nR5H+cJp3jaYE0nprqfzC9rYG8w9c6XGHB3SDKgcgA==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4
'@ai-sdk/gateway@2.0.0':
resolution: {integrity: sha512-Gj0PuawK7NkZuyYgO/h5kDK/l6hFOjhLdTq3/Lli1FTl47iGmwhH1IZQpAL3Z09BeFYWakcwUmn02ovIm2wy9g==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/gateway@3.0.104':
resolution: {integrity: sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/gateway@3.0.2':
resolution: {integrity: sha512-giJEg9ob45htbu3iautK+2kvplY2JnTj7ir4wZzYSQWvqGatWfBBfDuNCU5wSJt9BCGjymM5ZS9ziD42JGCZBw==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/gateway@3.0.66':
resolution: {integrity: sha512-SIQ0YY0iMuv+07HLsZ+bB990zUJ6S4ujORAh+Jv1V2KGNn73qQKnGO0JBk+w+Res8YqOFSycwDoWcFlQrVxS4A==}
engines: {node: '>=18'}
@@ -3210,12 +3190,6 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4
'@ai-sdk/provider-utils@4.0.1':
resolution: {integrity: sha512-de2v8gH9zj47tRI38oSxhQIewmNc+OZjYIOOaMoVWKL65ERSav2PYYZHPSPCrfOeLMkv+Dyh8Y0QGwkO29wMWQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider-utils@4.0.19':
resolution: {integrity: sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg==}
engines: {node: '>=18'}
@@ -3244,57 +3218,16 @@ packages:
resolution: {integrity: sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==}
engines: {node: '>=18'}
'@ai-sdk/provider@3.0.0':
resolution: {integrity: sha512-m9ka3ptkPQbaHHZHqDXDF9C9B5/Mav0KTdky1k2HZ3/nrW2t1AgObxIVPyGDWQNS9FXT/FS6PIoSjpcP/No8rQ==}
engines: {node: '>=18'}
'@ai-sdk/provider@3.0.8':
resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==}
engines: {node: '>=18'}
'@ai-sdk/react@1.0.0':
resolution: {integrity: sha512-BDrZqQA07Btg64JCuhFvBgYV+tt2B8cXINzEqWknGoxqcwgdE8wSLG2gkXoLzyC2Rnj7oj0HHpOhLUxDCmoKZg==}
engines: {node: '>=18'}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
zod: ^3.0.0
peerDependenciesMeta:
react:
optional: true
zod:
optional: true
'@ai-sdk/react@1.2.2':
resolution: {integrity: sha512-rxyNTFjUd3IilVOJFuUJV5ytZBYAIyRi50kFS2gNmSEiG4NHMBBm31ddrxI/i86VpY8gzZVp1/igtljnWBihUA==}
engines: {node: '>=18'}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
zod: ^3.23.8
peerDependenciesMeta:
zod:
optional: true
'@ai-sdk/react@3.0.170':
resolution: {integrity: sha512-YUDn+mK0c8iUz14rCBf1A0zg6SV5b5aSVUz+azF1bdBd1SFXVI19dKYR+PQSpZY+0+z+zs252AAsacUqiO98Kw==}
engines: {node: '>=18'}
peerDependencies:
react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1
'@ai-sdk/ui-utils@1.0.0':
resolution: {integrity: sha512-oXBDIM/0niWeTWyw77RVl505dNxBUDLLple7bTsqo2d3i1UKwGlzBUX8XqZsh7GbY7I6V05nlG0Y8iGlWxv1Aw==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.0.0
peerDependenciesMeta:
zod:
optional: true
'@ai-sdk/ui-utils@1.2.1':
resolution: {integrity: sha512-BzvMbYm7LHBlbWuLlcG1jQh4eu14MGpz7L+wrGO1+F4oQ+O0fAjgUSNwPWGlZpKmg4NrcVq/QLmxiVJrx2R4Ew==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.23.8
'@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
@@ -9332,6 +9265,9 @@ packages:
'@s2-dev/streamstore@0.22.10':
resolution: {integrity: sha512-dtm+oFHVE8szINwOUoNQdx9xpGSJOrcAEvsxspPFvomjYKGnmhIRmU4OX8o6kxcPoiK76S1tPeU0smjZdmOngA==}
'@s2-dev/streamstore@0.22.5':
resolution: {integrity: sha512-GqdOKIbIoIxT+40fnKzHbrsHB6gBqKdECmFe7D3Ojk4FoN1Hu0LhFzZv6ZmVMjoHHU+55debS1xSWjZwQmbIyQ==}
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
@@ -10429,12 +10365,20 @@ packages:
'@total-typescript/ts-reset@0.4.2':
resolution: {integrity: sha512-vqd7ZUDSrXFVT1n8b2kc3LnklncDQFPvR58yUS1kEP23/nHPAO9l1lMjUfnPrXYYk4Hj54rrLKMW5ipwk7k09A==}
'@trigger.dev/build@4.4.6':
resolution: {integrity: sha512-eHPPaeuFe9GZDndQzP4QUlxocyIJWYSx0FMx1GEiAnEVKwXWUqiW72DRFH7cr9v7IQnI9YbAWRuWvyMPHSVLwg==}
engines: {node: '>=18.20.0'}
'@trigger.dev/companyicons@1.5.35':
resolution: {integrity: sha512-AhY7yshwh0onlgB6EGiyjyLSzl38Cuxo4tpUJVHxs5im8gDA+fuUq7o6Vz1WetFeNXwjMqh3f+bPW7bfqR4epg==}
peerDependencies:
react: ^18.2.0
react-dom: 18.2.0
'@trigger.dev/core@4.4.6':
resolution: {integrity: sha512-oXAjxBNVMiKXUKj1EnHUlO2ULujc4Dy8ad+H/59DYZGY1barkGVzrQAQIpBZo/kDygu7dKGT+F5yBkbIkYjAUw==}
engines: {node: '>=18.20.0'}
'@trigger.dev/platform@1.0.27':
resolution: {integrity: sha512-gYfYDBnp2RJpgL4V2oOeqzV20flMZg+oGALhxHmmNJdJM01MW4aJSLsFFjoj+dikpL+4/3gocJcoyZk1G02UNg==}
@@ -10598,9 +10542,6 @@ packages:
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/diff-match-patch@1.0.36':
resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==}
'@types/docker-modem@3.0.6':
resolution: {integrity: sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==}
@@ -11058,22 +10999,10 @@ packages:
'@vanilla-extract/private@1.0.3':
resolution: {integrity: sha512-17kVyLq3ePTKOkveHxXuIJZtGYs+cSoev7BlP+Lf4916qfDhk/HBjvlYDe8egrea7LNPHKwSZJK/bzZC+Q6AwQ==}
'@vercel/oidc@3.0.3':
resolution: {integrity: sha512-yNEQvPcVrK9sIe637+I0jD6leluPxzwJKx/Haw6F4H77CdDsszUn5V3o96LPziXkSNE2B83+Z3mjqGKBK/R6Gg==}
engines: {node: '>= 20'}
'@vercel/oidc@3.0.5':
resolution: {integrity: sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==}
engines: {node: '>= 20'}
'@vercel/oidc@3.1.0':
resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==}
engines: {node: '>= 20'}
'@vercel/oidc@3.2.0':
resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==}
engines: {node: '>= 20'}
'@vercel/otel@1.13.0':
resolution: {integrity: sha512-esRkt470Y2jRK1B1g7S1vkt4Csu44gp83Zpu8rIyPoqy2BKgk4z7ik1uSMswzi45UogLHFl6yR5TauDurBQi4Q==}
engines: {node: '>=18'}
@@ -11369,58 +11298,12 @@ packages:
ahocorasick@1.0.2:
resolution: {integrity: sha512-hCOfMzbFx5IDutmWLAt6MZwOUjIfSM9G9FyVxytmE4Rs/5YDPWQrD/+IR1w+FweD9H2oOZEnv36TmkjhNURBVA==}
ai@4.0.0:
resolution: {integrity: sha512-cqf2GCaXnOPhUU+Ccq6i+5I0jDjnFkzfq7t6mc0SUSibSa1wDPn5J4p8+Joh2fDGDYZOJ44rpTW9hSs40rXNAw==}
engines: {node: '>=18'}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
zod: ^3.0.0
peerDependenciesMeta:
react:
optional: true
zod:
optional: true
ai@4.2.5:
resolution: {integrity: sha512-URJEslI3cgF/atdTJHtz+Sj0W1JTmiGmD3znw9KensL3qV605odktDim+GTazNJFPR4QaIu1lUio5b8RymvOjA==}
engines: {node: '>=18'}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
zod: ^3.23.8
peerDependenciesMeta:
react:
optional: true
ai@5.0.14:
resolution: {integrity: sha512-xiujFa879skB7YxGzbeHAxepsr6AEaWcHPXrc5a9MRM6p4WdVAwn6mGwVZkBnhqGfZtXFr4LUnU2ayvcjWp5ig==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4
ai@5.0.76:
resolution: {integrity: sha512-ZCxi1vrpyCUnDbtYrO/W8GLvyacV9689f00yshTIQ3mFFphbD7eIv40a2AOZBv3GGRA7SSRYIDnr56wcS/gyQg==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
ai@6.0.116:
resolution: {integrity: sha512-7yM+cTmyRLeNIXwt4Vj+mrrJgVQ9RMIW5WO0ydoLoYkewIvsMcvUmqS4j2RJTUXaF1HphwmSKUMQ/HypNRGOmA==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
ai@6.0.168:
resolution: {integrity: sha512-2HqCJuO+1V2aV7vfYs5LFEUfxbkGX+5oa54q/gCCTL7KLTdbxcCu5D7TdLA5kwsrs3Szgjah9q6D9tpjHM3hUQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
ai@6.0.3:
resolution: {integrity: sha512-OOo+/C+sEyscoLnbY3w42vjQDICioVNyS+F+ogwq6O5RJL/vgWGuiLzFwuP7oHTeni/MkmX8tIge48GTdaV7QQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
ajv-formats@2.1.1:
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
peerDependencies:
@@ -12953,9 +12836,6 @@ packages:
didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
diff-match-patch@1.0.5:
resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==}
diff@5.1.0:
resolution: {integrity: sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==}
engines: {node: '>=0.3.1'}
@@ -13663,7 +13543,7 @@ packages:
resolution: {integrity: sha512-14G+Y1Rqi9xOJck1vykPwaRSPSPpSvaklMS9WjJA04KWIOUwrT3jHUyA/6GkMC0twi1vdkssGS5FstNtVqVCWQ==}
hasBin: true
peerDependencies:
ai: ^6
ai: 6.0.116
better-sqlite3: ^11.6.0
peerDependenciesMeta:
ai:
@@ -14987,9 +14867,6 @@ packages:
jose@5.4.0:
resolution: {integrity: sha512-6rpxTHPAQyWMb9A35BroFl1Sp0ST3DpPcm5EVIxZxdH+e0Hv9fwhyB3XLKFUcHNpdSDnETmBfuPPTTlYz5+USw==}
jose@6.0.8:
resolution: {integrity: sha512-EyUPtOKyTYq+iMOszO42eobQllaIjJnwkZ2U93aJzNyPibCy7CEvT9UQnaCVB51IAd49gbNdCew1c0LcLTCB2g==}
jose@6.1.3:
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
@@ -15100,11 +14977,6 @@ packages:
jsonc-parser@3.2.1:
resolution: {integrity: sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==}
jsondiffpatch@0.6.0:
resolution: {integrity: sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
jsonfile@4.0.0:
resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
@@ -19193,9 +19065,6 @@ packages:
tinyexec@0.3.2:
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
tinyexec@1.0.1:
resolution: {integrity: sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==}
tinyexec@1.2.3:
resolution: {integrity: sha512-g62dB+w1/OEFnPvmX0yd/HnetYITOL+1nJW7kitOycOeAvmbWC/nu0fwmmQ/kupNojqExzyC/T++pST/jRJ2mQ==}
engines: {node: '>=18'}
@@ -20392,33 +20261,6 @@ snapshots:
'@ai-sdk/provider-utils': 4.0.23(zod@3.25.76)
zod: 3.25.76
'@ai-sdk/gateway@1.0.6(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 2.0.0
'@ai-sdk/provider-utils': 3.0.3(zod@3.25.76)
zod: 3.25.76
'@ai-sdk/gateway@2.0.0(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 2.0.0
'@ai-sdk/provider-utils': 3.0.12(zod@3.25.76)
'@vercel/oidc': 3.0.3
zod: 3.25.76
'@ai-sdk/gateway@3.0.104(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.8
'@ai-sdk/provider-utils': 4.0.23(zod@3.25.76)
'@vercel/oidc': 3.2.0
zod: 3.25.76
'@ai-sdk/gateway@3.0.2(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.0
'@ai-sdk/provider-utils': 4.0.1(zod@3.25.76)
'@vercel/oidc': 3.0.5
zod: 3.25.76
'@ai-sdk/gateway@3.0.66(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.8
@@ -20496,13 +20338,6 @@ snapshots:
zod: 3.25.76
zod-to-json-schema: 3.24.6(zod@3.25.76)
'@ai-sdk/provider-utils@4.0.1(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.0
'@standard-schema/spec': 1.1.0
eventsource-parser: 3.0.6
zod: 3.25.76
'@ai-sdk/provider-utils@4.0.19(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.8
@@ -20533,38 +20368,14 @@ snapshots:
dependencies:
json-schema: 0.4.0
'@ai-sdk/provider@3.0.0':
dependencies:
json-schema: 0.4.0
'@ai-sdk/provider@3.0.8':
dependencies:
json-schema: 0.4.0
'@ai-sdk/react@1.0.0(react@18.3.1)(zod@3.25.76)':
dependencies:
'@ai-sdk/provider-utils': 2.0.0(zod@3.25.76)
'@ai-sdk/ui-utils': 1.0.0(zod@3.25.76)
swr: 2.2.5(react@18.3.1)
throttleit: 2.1.0
optionalDependencies:
react: 18.3.1
zod: 3.25.76
'@ai-sdk/react@1.2.2(react@19.0.0)(zod@3.25.76)':
dependencies:
'@ai-sdk/provider-utils': 2.2.1(zod@3.25.76)
'@ai-sdk/ui-utils': 1.2.1(zod@3.25.76)
react: 19.0.0
swr: 2.2.5(react@19.0.0)
throttleit: 2.1.0
optionalDependencies:
zod: 3.25.76
'@ai-sdk/react@3.0.170(react@18.2.0)(zod@3.25.76)':
dependencies:
'@ai-sdk/provider-utils': 4.0.23(zod@3.25.76)
ai: 6.0.168(zod@3.25.76)
ai: 6.0.116(zod@3.25.76)
react: 18.2.0
swr: 2.2.5(react@18.2.0)
throttleit: 2.1.0
@@ -20574,28 +20385,13 @@ snapshots:
'@ai-sdk/react@3.0.170(react@19.1.0)(zod@3.25.76)':
dependencies:
'@ai-sdk/provider-utils': 4.0.23(zod@3.25.76)
ai: 6.0.168(zod@3.25.76)
ai: 6.0.116(zod@3.25.76)
react: 19.1.0
swr: 2.2.5(react@19.1.0)
throttleit: 2.1.0
transitivePeerDependencies:
- zod
'@ai-sdk/ui-utils@1.0.0(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 1.0.0
'@ai-sdk/provider-utils': 2.0.0(zod@3.25.76)
zod-to-json-schema: 3.24.6(zod@3.25.76)
optionalDependencies:
zod: 3.25.76
'@ai-sdk/ui-utils@1.2.1(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 1.1.0
'@ai-sdk/provider-utils': 2.2.1(zod@3.25.76)
zod: 3.25.76
zod-to-json-schema: 3.24.6(zod@3.25.76)
'@alloc/quick-lru@5.2.0': {}
'@ampproject/remapping@2.3.0':
@@ -20608,7 +20404,7 @@ snapshots:
'@antfu/install-pkg@1.1.0':
dependencies:
package-manager-detector: 1.4.1
tinyexec: 1.0.1
tinyexec: 1.2.3
'@antfu/utils@9.3.0': {}
@@ -28170,8 +27966,8 @@ snapshots:
dependencies:
html-to-text: 9.0.5
js-beautify: 1.15.1
react: 18.3.1
react-dom: 18.2.0(react@18.3.1)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
'@react-email/row@0.0.7(react@18.3.1)':
dependencies:
@@ -28868,6 +28664,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@s2-dev/streamstore@0.22.5':
dependencies:
'@protobuf-ts/runtime': 2.11.1
debug: 4.4.3(supports-color@10.0.0)
transitivePeerDependencies:
- supports-color
'@sec-ant/readable-stream@0.4.1': {}
'@selderee/plugin-htmlparser2@0.11.0':
@@ -30350,11 +30153,69 @@ snapshots:
'@total-typescript/ts-reset@0.4.2': {}
'@trigger.dev/build@4.4.6(bufferutil@4.0.9)(typescript@5.5.4)':
dependencies:
'@prisma/config': 6.19.0(magicast@0.3.5)
'@trigger.dev/core': 4.4.6(bufferutil@4.0.9)
mlly: 1.7.4
pkg-types: 1.1.3
resolve: 1.22.8
tinyglobby: 0.2.16
tsconfck: 3.1.3(typescript@5.5.4)
transitivePeerDependencies:
- bufferutil
- magicast
- supports-color
- typescript
- utf-8-validate
'@trigger.dev/companyicons@1.5.35(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
'@trigger.dev/core@4.4.6(bufferutil@4.0.9)':
dependencies:
'@bugsnag/cuid': 3.1.1
'@electric-sql/client': 1.0.14
'@google-cloud/precise-date': 4.0.0
'@jsonhero/path': 1.0.21
'@opentelemetry/api': 1.9.0
'@opentelemetry/api-logs': 0.203.0
'@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0)
'@opentelemetry/exporter-logs-otlp-http': 0.203.0(@opentelemetry/api@1.9.0)
'@opentelemetry/exporter-metrics-otlp-http': 0.203.0(@opentelemetry/api@1.9.0)
'@opentelemetry/exporter-trace-otlp-http': 0.203.0(@opentelemetry/api@1.9.0)
'@opentelemetry/host-metrics': 0.37.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0)(supports-color@10.0.0)
'@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-logs': 0.203.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-metrics': 2.0.1(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-trace-node': 2.0.1(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.36.0
'@s2-dev/streamstore': 0.22.5
dequal: 2.0.3
eventsource: 3.0.5
eventsource-parser: 3.0.6
execa: 8.0.1
humanize-duration: 3.27.3
jose: 5.4.0
nanoid: 3.3.8
prom-client: 15.1.0
socket.io: 4.7.4(bufferutil@4.0.9)
socket.io-client: 4.7.5(bufferutil@4.0.9)(supports-color@10.0.0)
std-env: 3.10.0
tinyexec: 0.3.2
uncrypto: 0.1.3
zod: 3.25.76
zod-error: 1.5.0
zod-validation-error: 1.5.0(zod@3.25.76)
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
'@trigger.dev/platform@1.0.27':
dependencies:
zod: 3.23.8
@@ -30548,8 +30409,6 @@ snapshots:
'@types/deep-eql@4.0.2': {}
'@types/diff-match-patch@1.0.36': {}
'@types/docker-modem@3.0.6':
dependencies:
'@types/node': 20.14.14
@@ -31154,14 +31013,8 @@ snapshots:
'@vanilla-extract/private@1.0.3': {}
'@vercel/oidc@3.0.3': {}
'@vercel/oidc@3.0.5': {}
'@vercel/oidc@3.1.0': {}
'@vercel/oidc@3.2.0': {}
'@vercel/otel@1.13.0(@opentelemetry/api-logs@0.203.0)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-logs@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-metrics@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))':
dependencies:
'@opentelemetry/api': 1.9.0
@@ -31529,47 +31382,6 @@ snapshots:
ahocorasick@1.0.2: {}
ai@4.0.0(react@18.3.1)(zod@3.25.76):
dependencies:
'@ai-sdk/provider': 1.0.0
'@ai-sdk/provider-utils': 2.0.0(zod@3.25.76)
'@ai-sdk/react': 1.0.0(react@18.3.1)(zod@3.25.76)
'@ai-sdk/ui-utils': 1.0.0(zod@3.25.76)
'@opentelemetry/api': 1.9.0
jsondiffpatch: 0.6.0
zod-to-json-schema: 3.24.6(zod@3.25.76)
optionalDependencies:
react: 18.3.1
zod: 3.25.76
ai@4.2.5(react@19.0.0)(zod@3.25.76):
dependencies:
'@ai-sdk/provider': 1.1.0
'@ai-sdk/provider-utils': 2.2.1(zod@3.25.76)
'@ai-sdk/react': 1.2.2(react@19.0.0)(zod@3.25.76)
'@ai-sdk/ui-utils': 1.2.1(zod@3.25.76)
'@opentelemetry/api': 1.9.0
jsondiffpatch: 0.6.0
zod: 3.25.76
optionalDependencies:
react: 19.0.0
ai@5.0.14(zod@3.25.76):
dependencies:
'@ai-sdk/gateway': 1.0.6(zod@3.25.76)
'@ai-sdk/provider': 2.0.0
'@ai-sdk/provider-utils': 3.0.3(zod@3.25.76)
'@opentelemetry/api': 1.9.0
zod: 3.25.76
ai@5.0.76(zod@3.25.76):
dependencies:
'@ai-sdk/gateway': 2.0.0(zod@3.25.76)
'@ai-sdk/provider': 2.0.0
'@ai-sdk/provider-utils': 3.0.12(zod@3.25.76)
'@opentelemetry/api': 1.9.0
zod: 3.25.76
ai@6.0.116(zod@3.25.76):
dependencies:
'@ai-sdk/gateway': 3.0.66(zod@3.25.76)
@@ -31578,22 +31390,6 @@ snapshots:
'@opentelemetry/api': 1.9.0
zod: 3.25.76
ai@6.0.168(zod@3.25.76):
dependencies:
'@ai-sdk/gateway': 3.0.104(zod@3.25.76)
'@ai-sdk/provider': 3.0.8
'@ai-sdk/provider-utils': 4.0.23(zod@3.25.76)
'@opentelemetry/api': 1.9.0
zod: 3.25.76
ai@6.0.3(zod@3.25.76):
dependencies:
'@ai-sdk/gateway': 3.0.2(zod@3.25.76)
'@ai-sdk/provider': 3.0.0
'@ai-sdk/provider-utils': 4.0.1(zod@3.25.76)
'@opentelemetry/api': 1.9.0
zod: 3.25.76
ajv-formats@2.1.1(ajv@8.18.0):
optionalDependencies:
ajv: 8.18.0
@@ -33201,8 +32997,6 @@ snapshots:
didyoumean@1.2.2: {}
diff-match-patch@1.0.5: {}
diff@5.1.0: {}
dir-glob@3.0.1:
@@ -34217,7 +34011,7 @@ snapshots:
dependencies:
require-like: 0.1.2
evalite@1.0.0-beta.16(ai@6.0.168(zod@3.25.76))(better-sqlite3@11.10.0)(bufferutil@4.0.9):
evalite@1.0.0-beta.16(ai@6.0.116(zod@3.25.76))(better-sqlite3@11.10.0)(bufferutil@4.0.9):
dependencies:
'@fastify/static': 8.2.0
'@fastify/websocket': 11.2.0(bufferutil@4.0.9)
@@ -34234,7 +34028,7 @@ snapshots:
table: 6.9.0
tinyrainbow: 3.1.0
optionalDependencies:
ai: 6.0.168(zod@3.25.76)
ai: 6.0.116(zod@3.25.76)
better-sqlite3: 11.10.0
transitivePeerDependencies:
- bufferutil
@@ -35784,8 +35578,6 @@ snapshots:
jose@5.4.0: {}
jose@6.0.8: {}
jose@6.1.3: {}
joycon@3.1.1: {}
@@ -35869,12 +35661,6 @@ snapshots:
jsonc-parser@3.2.1: {}
jsondiffpatch@0.6.0:
dependencies:
'@types/diff-match-patch': 1.0.36
chalk: 5.3.0
diff-match-patch: 1.0.5
jsonfile@4.0.0:
optionalDependencies:
graceful-fs: 4.2.11
@@ -36278,8 +36064,8 @@ snapshots:
magicast@0.3.5:
dependencies:
'@babel/parser': 7.27.5
'@babel/types': 7.27.3
'@babel/parser': 7.29.7
'@babel/types': 7.29.7
source-map-js: 1.2.1
optional: true
@@ -37695,7 +37481,7 @@ snapshots:
consola: 3.4.2
pathe: 2.0.3
pkg-types: 2.3.0
tinyexec: 1.0.1
tinyexec: 1.2.3
oauth-sign@0.9.0: {}
@@ -37909,7 +37695,7 @@ snapshots:
openid-client@6.3.3:
dependencies:
jose: 6.0.8
jose: 6.1.3
oauth4webapi: 3.3.0
optionator@0.9.1:
@@ -40805,12 +40591,6 @@ snapshots:
react: 18.3.1
use-sync-external-store: 1.2.2(react@18.3.1)
swr@2.2.5(react@19.0.0):
dependencies:
client-only: 0.0.1
react: 19.0.0
use-sync-external-store: 1.2.2(react@19.0.0)
swr@2.2.5(react@19.1.0):
dependencies:
client-only: 0.0.1
@@ -41140,8 +40920,6 @@ snapshots:
tinyexec@0.3.2: {}
tinyexec@1.0.1: {}
tinyexec@1.2.3: {}
tinyglobby@0.2.10:
@@ -41795,10 +41573,6 @@ snapshots:
dependencies:
react: 18.3.1
use-sync-external-store@1.2.2(react@19.0.0):
dependencies:
react: 19.0.0
use-sync-external-store@1.2.2(react@19.1.0):
dependencies:
react: 19.1.0
@@ -41988,11 +41762,11 @@ snapshots:
vite@6.4.2(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@3.12.2)(yaml@2.8.3):
dependencies:
esbuild: 0.25.1
fdir: 6.4.4(picomatch@4.0.4)
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
postcss: 8.5.10
rollup: 4.60.1
tinyglobby: 0.2.13
tinyglobby: 0.2.16
optionalDependencies:
'@types/node': 20.14.14
fsevents: 2.3.3
@@ -42005,11 +41779,11 @@ snapshots:
vite@6.4.2(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.3):
dependencies:
esbuild: 0.25.1
fdir: 6.4.4(picomatch@4.0.4)
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
postcss: 8.5.10
rollup: 4.60.1
tinyglobby: 0.2.13
tinyglobby: 0.2.16
optionalDependencies:
'@types/node': 20.14.14
fsevents: 2.3.3