diff --git a/.changeset/dashboard-agent-chat-streams.md b/.changeset/dashboard-agent-chat-streams.md new file mode 100644 index 0000000000..a6dc7d1880 --- /dev/null +++ b/.changeset/dashboard-agent-chat-streams.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/sdk": patch +--- + +Watch-mode chat streams now survive quiet windows and page reloads, and a reply cut off by a lost connection shows an error instead of appearing finished. Aborting a resumed subscription only closes your local stream — call `stopGeneration(chatId)` or pass `stopOnAbort: true` to stop the run. Also fixed a race where quickly restarting a stream could break stop and reconnect, and stopping a chat now hands it back to your other tabs instead of leaving them read-only. diff --git a/.server-changes/agent-plan-limits.md b/.server-changes/agent-plan-limits.md new file mode 100644 index 0000000000..36e66bd02b --- /dev/null +++ b/.server-changes/agent-plan-limits.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +The dashboard agent now has a monthly message allowance and plan-based limits on watches. Queries stay read-only with clearer errors when busy, and messages with unusual characters no longer fail to send. diff --git a/apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx b/apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx index 4b795edde9..70a5da7b82 100644 --- a/apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx +++ b/apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx @@ -1,18 +1,21 @@ import { Link } from "@remix-run/react"; +import { AgentMonoLogo } from "~/components/primitives/AgentDotMatrix"; import { LinkButton } from "~/components/primitives/Buttons"; import { useOrganization } from "~/hooks/useOrganizations"; -import { cn } from "~/utils/cn"; import { v3BillingPath } from "~/utils/pathBuilder"; -import { AgentIcon, AGENT_ICON_ACCENT_CLASS, ASK_AGENT_LABEL } from "./agent-identity"; +import { ASK_AGENT_LABEL } from "./agent-identity"; +import { messageQuotaReachedCopy } from "./message-quota"; // Matches the composer's outer geometry so the replacement lands in the same place. const SLOT = "flex shrink-0 flex-col bg-background-bright px-3 pb-3 pt-1"; export function AgentUpgradeBlock({ limit, + planResolved, context, }: { limit: number; + planResolved: boolean; context?: React.ReactNode; }) { const organization = useOrganization(); @@ -22,14 +25,12 @@ export function AgentUpgradeBlock({ {context}
- + Upgrade to unlock {ASK_AGENT_LABEL}
-

- You've used all {limit} messages included on the Free plan. Your chats stay here to read. -

+

{messageQuotaReachedCopy(limit, planResolved)}

Upgrade diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx index 19e65a570c..bd1404e742 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx @@ -17,6 +17,12 @@ import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner"; import { DashboardAgentHero } from "./DashboardAgentHero"; import { DashboardAgentMessages, type TurnActivity } from "./DashboardAgentMessages"; import { MESSAGE_TOO_LARGE_ERROR } from "./message-limits"; +import { + FREE_PLAN_MESSAGE_LIMIT, + MESSAGE_QUOTA_REACHED_REASON, + parseQuotaReachedResponse, + type MessageQuota, +} from "./message-quota"; import { createTranscriptOrder, orderTranscript } from "./message-order"; import { navigateDestination } from "./navigate-target"; import { pendingNavigateIntents, pendingWatchIntents } from "./pending-intents"; @@ -73,6 +79,7 @@ export function DashboardAgentChat({ onCancelWatch, onTurnSettled, onActivityChange, + onQuotaChange, }: { chatId: string; initialMessages: UIMessage[]; @@ -100,8 +107,15 @@ export function DashboardAgentChat({ onCancelWatch: (watchId: string) => void; onTurnSettled: () => void; onActivityChange?: (chatId: string, activity: TurnActivity | null) => void; + /** The poll lives here, so this is where the panel learns the cap has lifted. */ + onQuotaChange?: (quota: MessageQuota) => void; }) { const [input, setInput] = useState(""); + // Set when the server refuses a send over the cap, so the block shows at once rather than + // waiting for the next quota poll. + const [quotaReached, setQuotaReached] = useState<{ limit: number; planResolved: boolean } | null>( + null + ); const navigate = useNavigate(); const location = useLocation(); const toast = useToast(); @@ -128,6 +142,18 @@ export function DashboardAgentChat({ .catch(() => null)) as { error?: string } | null; throw new Error(data?.error ?? MESSAGE_TOO_LARGE_ERROR); } + // Over the message cap: show the upgrade block instead of a generic turn error. + if (res.status === 403) { + const data = (await res + .clone() + .json() + .catch(() => null)) as { error?: string; limit?: number } | null; + const reached = parseQuotaReachedResponse(res.status, data); + if (reached) { + setQuotaReached(reached); + throw new Error("You've reached your message limit."); + } + } return res; }, clientData, @@ -185,9 +211,20 @@ export function DashboardAgentChat({ const orderRef = useRef(createTranscriptOrder(initialMessages)); const messages = orderTranscript(rawMessages, orderRef.current); - // Counted here, not in the panel, so it includes the turn just sent. - const quota = useAgentMessageQuota({ actionPath, chatId, messages }); - const atMessageCap = quota.kind === "reached"; + // Read here, not in the panel, so it re-reads as each turn settles. + const quota = useAgentMessageQuota({ actionPath, chatId, status }); + useEffect(() => { + onQuotaChange?.(quota); + // The quota object is rebuilt every render; only its kind is acted on. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [quota.kind, onQuotaChange]); + // Either the poll saw the cap, or a send was just refused over it. + const atMessageCap = quota.kind === "reached" || quotaReached !== null; + const messageCapLimit = + quotaReached?.limit ?? (quota.kind === "unlimited" ? FREE_PLAN_MESSAGE_LIMIT : quota.limit); + // The poll only runs on the free plan, so its cap is the free-plan nudge; a refusal + // carries the plan limit the server resolved. + const messageCapPlanResolved = quotaReached?.planResolved ?? false; const isStreaming = status === "streaming"; // From status, not the last part: the indicator must stay up through silent tool calls. @@ -252,6 +289,8 @@ export function DashboardAgentChat({ }, [sendRequest, submit, canSend]); const retry = useCallback(() => { + // Over the cap, a retry only earns another 403 — same guard as `submit`. + if (atMessageCap) return; // A watch's consent record is a user message nobody typed, so retry never treats it as one. const action = retryAction( messages.filter((m) => !(m.role === "user" && isWatchRequestMessageId(m.id))) @@ -264,7 +303,7 @@ export function DashboardAgentChat({ return; } void sendMessage({ text: action.text, messageId: action.messageId }); - }, [messages, sendMessage, regenerate, clearError]); + }, [messages, sendMessage, regenerate, clearError, atMessageCap]); const resolveUri = useTriggerUriResolver(actionPath); @@ -399,6 +438,7 @@ export function DashboardAgentChat({ onSelect={submit} pageContext={clientData.pageContext} promoted={promotedPrompt} + promptsDisabledReason={atMessageCap ? MESSAGE_QUOTA_REACHED_REASON : undefined} /> ) : ( )} {watchCard ?
{watchCard}
: null} - {quota.kind === "reached" ? ( + {atMessageCap ? ( { escapeGuardArmed.current = true; - onChange(e.target.value.slice(0, MAX_MESSAGE_CHARS)); + onChange(sliceWellFormed(e.target.value, MAX_MESSAGE_CHARS)); }} onBlur={() => { escapeGuardArmed.current = true; diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx index 9698329abc..d7dcfabcc5 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx @@ -1,8 +1,10 @@ import type { SuggestedPrompt } from "@internal/dashboard-agent-contracts"; import { useCallback, useMemo, useState } from "react"; +import { AgentUpgradeBlock } from "./AgentUpgradeGate"; import { DashboardAgentComposer } from "./DashboardAgentComposer"; import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner"; import { DashboardAgentHero } from "./DashboardAgentHero"; +import { MESSAGE_QUOTA_REACHED_REASON } from "./message-quota"; import type { AgentPageContext } from "./page-context-types"; import { readDismissedPromptIds, resolveSuggestedPromptsBySlot } from "./suggested-prompts"; @@ -16,6 +18,7 @@ export function DashboardAgentDraft({ pageContext, promotedPrompt, watchCard, + capReached, }: { onSubmit: (text: string) => void; projectSlug: string; @@ -24,6 +27,7 @@ export function DashboardAgentDraft({ pageContext?: AgentPageContext; promotedPrompt?: SuggestedPrompt; watchCard?: React.ReactNode; + capReached?: { limit: number; planResolved: boolean } | null; }) { const [input, setInput] = useState(""); @@ -43,12 +47,14 @@ export function DashboardAgentDraft({ const submit = useCallback( (text: string) => { + // Suggested prompts reach here via the hero, bypassing the composer's cap guard. + if (capReached) return; const trimmed = text.trim(); if (!trimmed) return; setInput(""); onSubmit(trimmed); }, - [onSubmit] + [onSubmit, capReached] ); return ( @@ -56,26 +62,44 @@ export function DashboardAgentDraft({ onSelect={submit} pageContext={pageContext} promoted={promotedPrompt} + promptsDisabledReason={capReached ? MESSAGE_QUOTA_REACHED_REASON : undefined} composer={ -
- {watchCard} - submit(input)} - onStop={() => {}} - isStreaming={false} - placeholderSuggestion={watchCard ? undefined : placeholderSuggestion} - context={ - - } - /> -
+ capReached ? ( +
+ {watchCard} + + } + /> +
+ ) : ( +
+ {watchCard} + submit(input)} + onStop={() => {}} + isStreaming={false} + placeholderSuggestion={watchCard ? undefined : placeholderSuggestion} + context={ + + } + /> +
+ ) } /> ); diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentHero.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentHero.tsx index 15d399b37f..6695f3cda9 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentHero.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentHero.tsx @@ -11,6 +11,7 @@ export function DashboardAgentHero({ promoted, dismissedIds, composer, + promptsDisabledReason, }: { /** Receives the prompt text to send, not the button label. */ onSelect: (prompt: string) => void; @@ -18,6 +19,8 @@ export function DashboardAgentHero({ promoted?: SuggestedPrompt; dismissedIds?: string[]; composer?: React.ReactNode; + /** Set to disable the suggestion chips and say why. */ + promptsDisabledReason?: string; }) { // Centred by the child's `m-auto`, not by `justify-center`: auto margins give up their space // once the content outgrows the panel, so the heading stays scrollable to. @@ -40,6 +43,7 @@ export function DashboardAgentHero({ pageContext={pageContext} promoted={promoted} dismissedIds={dismissedIds} + disabledReason={promptsDisabledReason} />
diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx index 6ec77399aa..ab8d9656ec 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx @@ -35,6 +35,8 @@ export type DashboardAgentMessagesProps = { activity: TurnActivity | null; error?: Error; onRetry?: () => void; + /** Set to disable the retry button and say why, e.g. over the message cap. */ + retryDisabledReason?: string; onDismissError?: () => void; onIntent?: (intent: AgentIntent) => void; resolveUri?: (uri: string) => ResolvedUri | null; @@ -356,6 +358,7 @@ export function DashboardAgentTurns({ activity, error, onRetry, + retryDisabledReason, onDismissError, onIntent, resolveUri, @@ -402,7 +405,16 @@ export function DashboardAgentTurns({ (onRetry || onDismissError) && ( {onRetry && ( - )} diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx index 894a80e8e6..6e9b9bbcbd 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx @@ -24,6 +24,11 @@ import { writeLastChat, } from "./last-chat-storage"; import { DashboardAgentDraft } from "./DashboardAgentDraft"; +import { + parseQuotaReachedResponse, + shouldClearCapReached, + type MessageQuota, +} from "./message-quota"; import { WatchCard } from "./WatchCard"; import { watchDraftFor } from "./watch-card"; import { NO_WATCH_CARD, watchCardReducer } from "./watch-card-state"; @@ -114,6 +119,10 @@ export function DashboardAgentPanel({ // Until the list has arrived, the page load's server count is the better answer. const [chatsLoaded, setChatsLoaded] = useState(false); const [active, setActive] = useState(null); + // A refused `create` over the cap: the draft shows the upgrade block instead of a raw toast. + const [capReached, setCapReached] = useState<{ limit: number; planResolved: boolean } | null>( + null + ); // Starts true so an `openWith` request waits for the restore instead of racing it. const [loading, setLoading] = useState( () => readLastChat(storageKey)?.path === location.pathname @@ -200,6 +209,8 @@ export function DashboardAgentPanel({ // half-configured watch card, which would otherwise be submitted against the new chat. const claimChatSlot = useCallback(() => { dispatchWatchCard({ type: "chat-changed" }); + // A new attempt goes back to the server, which re-refuses if the cap still stands. + setCapReached(null); // A request belongs to the chat it was made in: the remounting chat has a fresh guard ref, // so a kept request would be sent a second time. setSendRequest(undefined); @@ -260,14 +271,22 @@ export function DashboardAgentPanel({ publicAccessToken?: string; headStarted?: boolean; error?: string; + limit?: number; }; if (seq !== openChatRequestSeq.current) return; if (!res.ok || !data.chatId || !data.publicAccessToken) { + const reached = parseQuotaReachedResponse(res.status, data); + if (reached) { + setCapReached(reached); + setActive(null); + return; + } console.error(`Dashboard agent: failed to create chat (${res.status})`, data.error); toast.error(data.error ?? "We couldn't start that chat. Try again in a moment."); setActive(null); return; } + setCapReached(null); setActive({ chatId: data.chatId, organizationId: organization.id, @@ -309,6 +328,7 @@ export function DashboardAgentPanel({ panelOrg.current = organization.id; claimChatSlot(); setActive(null); + setCapReached(null); setLoading(false); setChats([]); setChatsLoaded(false); @@ -477,6 +497,11 @@ export function DashboardAgentPanel({ setActive(null); }, [claimChatSlot]); + // Released only by a read that proves capacity: an unknown quota keeps the block. + const handleQuotaChange = useCallback((quota: MessageQuota) => { + if (shouldClearCapReached(quota)) setCapReached(null); + }, []); + const switchChat = useCallback( (id: string) => { void openChat(id); @@ -624,6 +649,7 @@ export function DashboardAgentPanel({ // The generated chat name is written before the turn-complete chunk lands. onTurnSettled={loadHistory} onActivityChange={handleActivityChange} + onQuotaChange={handleQuotaChange} /> ) : ( )} diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentSuggestedPrompts.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentSuggestedPrompts.tsx index b9a53192ba..c83cc33570 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentSuggestedPrompts.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentSuggestedPrompts.tsx @@ -35,6 +35,7 @@ export function DashboardAgentSuggestedPrompts({ pageContext, promoted, dismissedIds, + disabledReason, }: { /** Receives the prompt text to send, not the button label. */ onSelect: (prompt: string) => void; @@ -43,6 +44,8 @@ export function DashboardAgentSuggestedPrompts({ promoted?: SuggestedPrompt; /** Omitted means the component reads its own localStorage. */ dismissedIds?: string[]; + /** Set to disable every chip and say why, e.g. over the message cap. */ + disabledReason?: string; }) { // Read once on mount: re-reading per render churns the resolved set. const [storedDismissedIds] = useState(() => @@ -70,6 +73,9 @@ export function DashboardAgentSuggestedPrompts({ variant={style.variant} LeadingIcon={style.icon} onClick={() => onSelect(prompt.prompt)} + disabled={!!disabledReason} + tooltip={disabledReason} + aria-label={disabledReason ? `${prompt.label} — ${disabledReason}` : undefined} > {prompt.label} diff --git a/apps/webapp/app/components/dashboard-agent/demo/components/DemoChartCard.tsx b/apps/webapp/app/components/dashboard-agent/demo/components/DemoChartCard.tsx new file mode 100644 index 0000000000..bd45153d95 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/demo/components/DemoChartCard.tsx @@ -0,0 +1,32 @@ +import type { AgentIntent, ChartAction } from "@internal/dashboard-agent-contracts"; +import { QueryResultsChart } from "~/components/code/QueryResultsChart"; +import { AGENT_CHART_PLOT_CLASS, ChartActions } from "../../AgentChart"; +import { AgentCard, AgentCardHeader } from "../../agent-card"; +import { demoChart } from "../fixtures/chart"; + +export function DemoChartCard({ + title = demoChart.title, + actions, + onIntent, +}: { + title?: string; + actions?: ChartAction[]; + onIntent?: (intent: AgentIntent) => void; +}) { + return ( + + {title ? ( + {title} + ) : null} +
+ +
+ +
+ ); +} diff --git a/apps/webapp/app/components/dashboard-agent/demo/components/DemoIntentBubble.tsx b/apps/webapp/app/components/dashboard-agent/demo/components/DemoIntentBubble.tsx new file mode 100644 index 0000000000..c2f1820750 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/demo/components/DemoIntentBubble.tsx @@ -0,0 +1,60 @@ +import { + ArrowTopRightOnSquareIcon, + CheckCircleIcon, + NoSymbolIcon, +} from "@heroicons/react/20/solid"; +import { Button } from "~/components/primitives/Buttons"; +import { cn } from "~/utils/cn"; +import { AgentStatusIcon } from "../../agent-badges"; +import { ChatStatusLine } from "../../chat-layout"; +import type { DemoIntent } from "../fixtures/intents"; + +export function DemoIntentBubble({ + intent, + onIntercept, +}: { + intent: DemoIntent; + onIntercept?: (message: string) => void; +}) { + const rejected = !intent.executable; + + return ( +
+ + } + > +

{intent.outcome}

+ {intent.deepLinkLabel ? ( + + ) : null} +
+
+ ); +} diff --git a/apps/webapp/app/components/dashboard-agent/demo/demo.test.ts b/apps/webapp/app/components/dashboard-agent/demo/demo.test.ts new file mode 100644 index 0000000000..e9bb746dcc --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/demo/demo.test.ts @@ -0,0 +1,352 @@ +import { + agentIntentSchema, + agentPageContextSchema, + isRevisableBlock, + safeParseStoredViewBlock, + safeParseTriggerUri, + suggestedPromptSchema, + viewBlockSchema, + watchIdentity, + watchSpecSchema, + SUGGESTED_PROMPT_CAP, + type Evidence, +} from "@internal/dashboard-agent-contracts"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { resolveSuggestedPrompts } from "../suggested-prompts"; +import * as fixtures from "./fixtures"; +import { DEMO_ID_PREFIX, DEMO_MARKER, demoSourceUri } from "./ids"; + +const DEMO_DIR = __dirname; + +function walk(dir: string): string[] { + return readdirSync(dir).flatMap((entry) => { + const path = join(dir, entry); + return statSync(path).isDirectory() ? walk(path) : [path]; + }); +} + +const sourceFiles = walk(DEMO_DIR).filter( + (path) => /\.(ts|tsx)$/.test(path) && !path.endsWith(".test.ts") +); + +function importSpecifiers(source: string): string[] { + return [ + ...source.matchAll(/(?:import|export)[\s\S]*?from\s+["']([^"']+)["']/g), + // A side-effect or dynamic import binds nothing, so it never reaches a `from`. + ...source.matchAll(/\bimport\s*\(?\s*["']([^"']+)["']/g), + ].map((match) => match[1]!); +} + +describe("demo ids", () => { + it("namespaces investigation, hypothesis, watch and prompt ids", () => { + for (const investigation of Object.values(fixtures.demoInvestigations)) { + expect(investigation.investigationId.startsWith(DEMO_ID_PREFIX)).toBe(true); + for (const hypothesis of investigation.hypotheses) { + expect(hypothesis.id.startsWith(DEMO_ID_PREFIX)).toBe(true); + } + } + for (const watch of fixtures.demoWatches.row) { + expect(watch.id.startsWith(DEMO_ID_PREFIX)).toBe(true); + } + for (const prompts of Object.values(fixtures.demoPromptSets)) { + for (const prompt of prompts) { + expect(prompt.id.startsWith(DEMO_ID_PREFIX)).toBe(true); + } + } + }); + + it("marks every resource id, so nothing can pass for a real one", () => { + for (const value of Object.values(fixtures.demoViewBlocks)) { + if (value.type === "diagnosis") { + expect(value.runId).toContain(DEMO_MARKER); + } + } + for (const id of Object.values({ + failedRunId: fixtures.demoInvestigationConcluded.runId, + slowRunId: fixtures.demoInvestigationInconclusive.runId, + })) { + expect(id).toContain(DEMO_MARKER); + } + }); + + it("rejects a zero source line instead of dropping it", () => { + expect(() => demoSourceUri("abc", "src/a.ts", 0)).toThrow(/positive integer/); + expect(demoSourceUri("abc", "src/a.ts", 42)).toContain("?line=42"); + expect(demoSourceUri("abc", "src/a.ts")).not.toContain("line="); + }); +}); + +describe("view block fixtures", () => { + const blocks = Object.values(fixtures.demoViewBlocks); + + it("parses every block through the lenient stored-block schema", () => { + for (const block of blocks) { + const result = safeParseStoredViewBlock(block); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + } + }); + + it("parses the enveloped blocks through the strict schema too", () => { + for (const block of [ + fixtures.demoDiagnosisBlockFirstPass, + fixtures.demoDiagnosisBlockRevised, + fixtures.demoChartBlock, + ]) { + expect(viewBlockSchema.safeParse(block).success).toBe(true); + expect(isRevisableBlock(block)).toBe(true); + } + }); + + it("keeps one legacy, envelope-less block that is not revisable", () => { + const legacy = fixtures.demoLegacyDiagnosisBlock; + expect(viewBlockSchema.safeParse(legacy).success).toBe(false); + expect(safeParseStoredViewBlock(legacy).success).toBe(true); + expect(isRevisableBlock(legacy)).toBe(false); + }); + + it("revises a block by id rather than emitting a second one", () => { + expect(fixtures.demoDiagnosisBlockRevised.id).toBe(fixtures.demoDiagnosisBlockFirstPass.id); + expect(fixtures.demoDiagnosisBlockRevised.revision).toBeGreaterThan( + fixtures.demoDiagnosisBlockFirstPass.revision + ); + }); +}); + +describe("investigation fixtures", () => { + const investigations = Object.values(fixtures.demoInvestigations); + + const allEvidence = (): Evidence[] => + investigations.flatMap((investigation) => [ + ...investigation.evidence, + ...investigation.hypotheses.flatMap((hypothesis) => hypothesis.evidence), + ]); + + it("cites only valid trigger:// URIs, with the kind matching the URI", () => { + for (const evidence of allEvidence()) { + const parsed = safeParseTriggerUri(evidence.uri); + expect(parsed.success, `${evidence.uri}: ${!parsed.success ? parsed.error : ""}`).toBe(true); + if (parsed.success) expect(parsed.data.kind).toBe(evidence.kind); + expect(evidence.uri).toContain(DEMO_MARKER); + } + }); + + it("only offers a fix when it concluded, and only 'check next' when it didn't", () => { + for (const investigation of investigations) { + if (investigation.outcome === "concluded") { + expect(investigation.remediation).toBeTruthy(); + expect(investigation.checkNext).toBeUndefined(); + } else { + expect(investigation.remediation).toBeUndefined(); + } + if (investigation.outcome === "inconclusive") { + expect(investigation.checkNext?.length).toBeGreaterThan(0); + } + } + }); + + it("gives the concluded card at least two settled hypotheses", () => { + const settled = fixtures.demoInvestigationConcluded.hypotheses.filter( + (hypothesis) => hypothesis.verdict !== "testing" + ); + expect(settled.length).toBeGreaterThanOrEqual(2); + expect(settled.some((h) => h.verdict === "validated")).toBe(true); + expect(settled.some((h) => h.verdict === "invalidated")).toBe(true); + expect( + fixtures.demoInvestigationConcluded.hypotheses.every( + (h) => h.verdict === "testing" || h.finding + ) + ).toBe(true); + }); + + it("keeps a streaming revision with a hypothesis still testing", () => { + expect(fixtures.demoInvestigationStreamingRev1.investigationId).toBe( + fixtures.demoInvestigationStreamingRev0.investigationId + ); + expect(fixtures.demoInvestigationStreamingRev1.revision).toBeGreaterThan( + fixtures.demoInvestigationStreamingRev0.revision + ); + expect( + fixtures.demoInvestigationStreamingRev1.hypotheses.some((h) => h.verdict === "testing") + ).toBe(true); + }); + + it("hedges the dirty-commit variant with the agreed wording", () => { + expect(fixtures.demoInvestigationDirtyCommit.caveat?.kind).toBe("dirty_commit"); + expect(fixtures.demoInvestigationDirtyCommit.caveat?.message).toContain( + "nearest repository snapshot" + ); + }); + + it("cites file:line@sha in the show-code turn", () => { + expect(fixtures.demoShowCodeMarkdown).toMatch(/\.ts:\d+(-\d+)?@[0-9a-z]{7}/); + expect(fixtures.demoShowCodeMarkdown).toContain("```diff"); + }); +}); + +describe("watch fixtures", () => { + it("validates every spec against the contracts schema", () => { + for (const watch of fixtures.demoWatches.row) { + const result = watchSpecSchema.safeParse(watch.spec); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + } + }); + + it("derives the chip identity from the spec", () => { + for (const watch of fixtures.demoWatches.row) { + expect(watch.identity).toBe(watchIdentity(watch.spec)); + } + }); + + it("covers every watch status and offers cancel only while active", () => { + const statuses = new Set(fixtures.demoWatches.row.map((watch) => watch.status)); + expect(statuses).toEqual(new Set(["active", "fired", "expired", "cancelled"])); + for (const watch of fixtures.demoWatches.row) { + expect(watch.cancellable).toBe(watch.status === "active"); + } + }); + + it("has an expiry narration that admits it could not verify", () => { + expect(fixtures.demoWatchNarration.expiryUnverified).toContain("couldn't verify"); + }); +}); + +describe("intent fixtures", () => { + it("validates every intent and marks propose_fix non-executable", () => { + for (const demoIntent of Object.values(fixtures.demoIntents)) { + const result = agentIntentSchema.safeParse(demoIntent.intent); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + expect(demoIntent.executable).toBe(demoIntent.intent.kind !== "propose_fix"); + } + }); + + it("points the filtered-runs example at the runs collection, not one run", () => { + const target = fixtures.demoIntents.navigateToFailedRuns.intent; + expect(target.kind).toBe("navigate"); + if (target.kind !== "navigate") return; + const parsed = safeParseTriggerUri(target.target); + expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.data.kind).toBe("runs"); + }); +}); + +describe("page context and prompt fixtures", () => { + it("validates every page context", () => { + for (const context of Object.values(fixtures.demoPageContexts)) { + const result = agentPageContextSchema.safeParse(context); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + } + }); + + it("covers all four signal kinds", () => { + const kinds = new Set(fixtures.demoSignalsByPriority.map((signal) => signal.kind)); + expect(kinds).toEqual( + new Set(["fresh_failure", "waiting_run", "slow_run", "concurrency_saturation"]) + ); + }); + + it("validates every chip, stays under the cap, and promotes at most one", () => { + for (const prompts of Object.values(fixtures.demoPromptSets)) { + expect(prompts.length).toBeLessThanOrEqual(SUGGESTED_PROMPT_CAP); + expect(prompts.filter((prompt) => prompt.source === "promoted").length).toBeLessThanOrEqual( + 1 + ); + for (const prompt of prompts) { + expect(suggestedPromptSchema.safeParse(prompt).success).toBe(true); + } + } + }); + + it("drops dismissed chips from the resolved row", () => { + for (const id of fixtures.demoDismissedPromptIds) { + expect(fixtures.demoPromptsAfterDismissal.some((prompt) => prompt.id === id)).toBe(false); + } + }); + + it("dismisses a chip the resolver actually emits", () => { + const full = resolveSuggestedPrompts(fixtures.demoFailedRunPageContext); + const after = resolveSuggestedPrompts(fixtures.demoFailedRunPageContext, { + dismissedIds: fixtures.demoResolvedDismissedPromptIds, + }); + expect(full.map((prompt) => prompt.id)).toContain(fixtures.demoResolvedDismissedPromptIds[0]); + expect(after.map((prompt) => prompt.id)).not.toEqual(full.map((prompt) => prompt.id)); + }); +}); + +describe("report fixtures", () => { + it("covers a healthy and a degraded verdict", () => { + expect(fixtures.demoHealthyReport.summary.severity).toBe("ok"); + expect(fixtures.demoDegradedReport.summary.severity).toBe("crit"); + }); + + it("references only metrics the report carries, and only links it declares", () => { + for (const vm of Object.values(fixtures.demoReports)) { + const metricIds = new Set(vm.metrics.map((metric) => metric.id)); + for (const finding of vm.findings) { + for (const id of finding.metricIds) expect(metricIds.has(id), id).toBe(true); + } + const linkKeys = new Set(vm.links.map((link) => link.key)); + for (const entry of vm.footer) { + if (entry.link) expect(linkKeys.has(entry.link), entry.link).toBe(true); + } + expect(vm.footer.length).toBeLessThanOrEqual(3); + } + }); +}); + +describe("chart fixtures", () => { + it("has a row for every configured column", () => { + const columns = fixtures.demoChart.columns.map((column) => column.name); + for (const row of fixtures.demoChart.rows) { + expect(Object.keys(row).sort()).toEqual([...columns].sort()); + } + expect(columns).toContain(fixtures.demoChart.config.xAxisColumn); + for (const y of fixtures.demoChart.config.yAxisColumns) expect(columns).toContain(y); + }); +}); + +describe("isolation", () => { + it("reads every form an import can take, including the ones that bind nothing", () => { + const source = [ + `import { a } from "./a";`, + `import "~/db.server";`, + `import type { B } from "./b";`, + `export * from "./c";`, + `const d = await import("~/routes/thing");`, + `import("./lazy").then((m) => m.go());`, + ].join("\n"); + + expect(importSpecifiers(source).sort()).toEqual([ + "./a", + "./b", + "./c", + "./lazy", + "~/db.server", + "~/routes/thing", + ]); + }); + + it("imports no server module and no route", () => { + for (const path of sourceFiles) { + const specifiers = importSpecifiers(readFileSync(path, "utf8")); + for (const specifier of specifiers) { + expect(specifier.includes(".server"), `${path} -> ${specifier}`).toBe(false); + expect(/routes?\//.test(specifier), `${path} -> ${specifier}`).toBe(false); + expect(specifier.includes("~/db"), `${path} -> ${specifier}`).toBe(false); + } + } + }); + + it("makes no network calls", () => { + for (const path of sourceFiles) { + const source = readFileSync(path, "utf8"); + expect(/\bfetch\s*\(/.test(source), path).toBe(false); + expect(/\buseFetcher\b/.test(source), path).toBe(false); + } + }); + + it("has no server file of its own", () => { + expect(sourceFiles.filter((path) => path.endsWith(".server.ts"))).toEqual([]); + }); +}); diff --git a/apps/webapp/app/components/dashboard-agent/demo/fixtures/blocks.ts b/apps/webapp/app/components/dashboard-agent/demo/fixtures/blocks.ts new file mode 100644 index 0000000000..2b5c7c5db8 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/demo/fixtures/blocks.ts @@ -0,0 +1,126 @@ +import { + VIEW_BLOCK_VERSION, + type EnvelopedChartBlock, + type EnvelopedDiagnosisBlock, + type ViewBlock, +} from "@internal/dashboard-agent-contracts"; +import { demoId, demoRunsUri, DEMO_WORLD } from "../ids"; + +const envelope = (id: string, revision = 0) => ({ + id: demoId(id), + revision, + version: VIEW_BLOCK_VERSION, +}); + +export const demoDiagnosisBlockFirstPass: EnvelopedDiagnosisBlock = { + ...envelope("diagnosis-order-receipt", 0), + type: "diagnosis", + runId: DEMO_WORLD.failedRunId, + summary: `${DEMO_WORLD.taskId} failed while calling the email provider. The call came back 429 and the run exhausted its 3 retries.`, + category: "rate_limit", + likelyCause: + "The email provider is rate limiting this API key. All three attempts landed inside the same 20-second window, so the retries never had a chance to clear the limit.", + confidence: "medium", + evidence: [ + { + type: "error", + detail: "ProviderError: 429 Too Many Requests (rate_limit_exceeded)", + reference: DEMO_WORLD.failedRunId, + }, + { + type: "failed_span", + detail: "sendEmail span failed after 412ms on attempt 3 of 3", + reference: DEMO_WORLD.failedSpanId, + }, + ], + nextSteps: [ + "Spread the retries out: raise the retry delay so attempts don't land in the same rate-limit window.", + "Cap concurrency on the queue so the task can't burst past the provider's per-second limit.", + ], +}; + +export const demoDiagnosisBlockRevised: EnvelopedDiagnosisBlock = { + ...demoDiagnosisBlockFirstPass, + ...envelope("diagnosis-order-receipt", 1), + summary: `${DEMO_WORLD.taskId} failed because the email provider rate limited it. 41 runs on this queue hit the same 429 in the last hour — this run isn't special.`, + confidence: "high", + impact: `41 runs of ${DEMO_WORLD.taskId} failed the same way in the last hour, all on the ${DEMO_WORLD.queue} queue.`, + evidence: [ + ...demoDiagnosisBlockFirstPass.evidence, + { + type: "historical_match", + detail: "41 runs failed with the same error fingerprint in the last hour", + reference: DEMO_WORLD.errorFingerprint, + }, + { + type: "source", + detail: "retry.maxAttempts is 3 with a 1s base delay and no jitter", + reference: `${DEMO_WORLD.sourcePath}:18`, + }, + ], + nextSteps: [ + "Raise the retry delay (or add jitter) so attempts don't all land inside one rate-limit window.", + `Cap concurrency on ${DEMO_WORLD.queue} to stay under the provider's per-second limit.`, + "Consider a queue-level rate limit so a backlog can't burst into the provider.", + ], + actions: [ + { label: "View run", kind: "view_run", target: DEMO_WORLD.failedRunId }, + { + label: "Read the retries docs", + kind: "docs", + target: "https://trigger.dev/docs/errors-retrying", + }, + ], +}; + +export const demoChartBlock: EnvelopedChartBlock = { + ...envelope("chart-failures-by-task", 0), + type: "chart", + title: "Failed runs per hour, by task", + query: + "SELECT toStartOfHour(created_at) AS hour, task_identifier, countIf(status = 'COMPLETED_WITH_ERROR') AS failures FROM task_runs GROUP BY hour, task_identifier ORDER BY hour", + period: "24h", + chartType: "line", + xAxisColumn: "hour", + yAxisColumns: ["failures"], + groupByColumn: "task_identifier", + stacked: false, + aggregation: "sum", + actions: [ + { + label: `Investigate ${DEMO_WORLD.taskId}`, + intent: { + kind: "ask", + prompt: `Investigate the ${DEMO_WORLD.taskId} failures — why are they failing?`, + }, + }, + { + label: "See its failed runs", + intent: { + kind: "navigate", + target: demoRunsUri(), + filters: { tasks: [DEMO_WORLD.taskId], statuses: ["COMPLETED_WITH_ERROR"], period: "1d" }, + }, + }, + ], +}; + +// No envelope on purpose: the pre-envelope transcript path must still render. +export const demoLegacyDiagnosisBlock: ViewBlock = { + type: "diagnosis", + runId: DEMO_WORLD.priorRunId, + summary: + "This run failed the same way three weeks ago, before the panel stamped identity onto its cards.", + category: "rate_limit", + likelyCause: "The email provider rate limited the same API key.", + confidence: "medium", + evidence: [{ type: "error", detail: "ProviderError: 429 Too Many Requests" }], + nextSteps: ["Nothing to do — kept as a fixture for the pre-envelope render path."], +}; + +export const demoViewBlocks = { + diagnosisFirstPass: demoDiagnosisBlockFirstPass, + diagnosisRevised: demoDiagnosisBlockRevised, + chart: demoChartBlock, + legacyDiagnosis: demoLegacyDiagnosisBlock, +} as const; diff --git a/apps/webapp/app/components/dashboard-agent/demo/fixtures/chart.ts b/apps/webapp/app/components/dashboard-agent/demo/fixtures/chart.ts new file mode 100644 index 0000000000..c1a00a2632 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/demo/fixtures/chart.ts @@ -0,0 +1,50 @@ +import type { OutputColumnMetadata } from "@internal/clickhouse"; +import type { ChartConfiguration } from "~/components/metrics/QueryWidget"; + +export const demoChartColumns: OutputColumnMetadata[] = [ + { name: "hour", type: "DateTime" }, + { name: "task_identifier", type: "String" }, + { name: "failures", type: "UInt64", format: "quantity" }, +]; + +const SERIES: Record = { + "send-order-receipt": [1, 0, 2, 1, 3, 2, 4, 9, 14, 22, 31, 41], + "generate-monthly-report": [0, 1, 0, 0, 1, 0, 2, 1, 0, 1, 2, 1], + "sync-crm-contacts": [3, 2, 4, 3, 2, 3, 2, 4, 3, 2, 3, 2], +}; + +const START_MS = Date.parse("2026-07-26T23:00:00.000Z"); +const HOUR_MS = 3_600_000; + +export const demoChartRows: Record[] = Object.entries(SERIES).flatMap( + ([task, points]) => + points.map((failures, i) => ({ + hour: new Date(START_MS + i * HOUR_MS).toISOString(), + task_identifier: task, + failures, + })) +); + +export const demoChartConfig: ChartConfiguration = { + chartType: "line", + xAxisColumn: "hour", + yAxisColumns: ["failures"], + groupByColumn: "task_identifier", + stacked: false, + sortByColumn: null, + sortDirection: "desc", + aggregation: "sum", +}; + +export const demoChartTimeRange = { + from: new Date(START_MS).toISOString(), + to: new Date(START_MS + 11 * HOUR_MS).toISOString(), +}; + +export const demoChart = { + rows: demoChartRows, + columns: demoChartColumns, + config: demoChartConfig, + timeRange: demoChartTimeRange, + title: "Failed runs per hour, by task", +} as const; diff --git a/apps/webapp/app/components/dashboard-agent/demo/fixtures/index.ts b/apps/webapp/app/components/dashboard-agent/demo/fixtures/index.ts new file mode 100644 index 0000000000..15a2f1be72 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/demo/fixtures/index.ts @@ -0,0 +1,8 @@ +export * from "./blocks"; +export * from "./chart"; +export * from "./intents"; +export * from "./investigation"; +export * from "./messages"; +export * from "./page-context"; +export * from "./reports"; +export * from "./watches"; diff --git a/apps/webapp/app/components/dashboard-agent/demo/fixtures/intents.ts b/apps/webapp/app/components/dashboard-agent/demo/fixtures/intents.ts new file mode 100644 index 0000000000..3fea9f2095 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/demo/fixtures/intents.ts @@ -0,0 +1,60 @@ +import { isExecutableIntent, type AgentIntent } from "@internal/dashboard-agent-contracts"; +import { DEMO_WORLD, demoRunUri, demoRunsUri } from "../ids"; +import { demoBacklogDrainWatch } from "./watches"; + +export type DemoIntent = { + intent: AgentIntent; + outcome: string; + deepLinkLabel?: string; + executable: boolean; +}; + +const demoIntent = (intent: AgentIntent, outcome: string, deepLinkLabel?: string): DemoIntent => ({ + intent, + outcome, + deepLinkLabel, + executable: isExecutableIntent(intent), +}); + +export const demoNavigateToFailedRuns = demoIntent( + { + kind: "navigate", + target: demoRunsUri(), + filters: { + statuses: ["COMPLETED_WITH_ERROR"], + period: "24h", + tasks: [DEMO_WORLD.taskId], + }, + }, + "Opened runs filtered to failed · last 24h · send-order-receipt", + "/runs?statuses=COMPLETED_WITH_ERROR&period=24h&tasks=send-order-receipt" +); + +export const demoNavigateToRun = demoIntent( + { kind: "navigate", target: demoRunUri(DEMO_WORLD.failedRunId) }, + `Opened ${DEMO_WORLD.failedRunId}`, + `/runs/${DEMO_WORLD.failedRunId}` +); + +export const demoAskIntent = demoIntent( + { kind: "ask", prompt: "Do you want me to watch the retry and tell you when it finishes?" }, + "Asked a follow-up" +); + +export const demoWatchIntent = demoIntent( + { kind: "watch", spec: demoBacklogDrainWatch.spec }, + `Watching ${DEMO_WORLD.backlogQueue} · checking every 5 min for up to 6h` +); + +export const demoProposeFixIntent = demoIntent( + { kind: "propose_fix", investigationId: "demo:investigation-order-receipt" }, + "Rejected: proposing a fix isn't available yet" +); + +export const demoIntents = { + navigateToFailedRuns: demoNavigateToFailedRuns, + navigateToRun: demoNavigateToRun, + ask: demoAskIntent, + watch: demoWatchIntent, + proposeFix: demoProposeFixIntent, +} as const; diff --git a/apps/webapp/app/components/dashboard-agent/demo/fixtures/investigation.ts b/apps/webapp/app/components/dashboard-agent/demo/fixtures/investigation.ts new file mode 100644 index 0000000000..8b3c250633 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/demo/fixtures/investigation.ts @@ -0,0 +1,438 @@ +// Block `id` is the `investigationId` and `revision` climbs. The contracts package freezes that. +import type { Evidence } from "@internal/dashboard-agent-contracts"; +import { + DEMO_WORLD, + demoDeploymentUri, + demoErrorUri, + demoId, + demoQueueUri, + demoRunUri, + demoSourceUri, + demoSpanUri, +} from "../ids"; + +export type DemoHypothesisVerdict = "testing" | "validated" | "invalidated"; + +export type DemoHypothesis = { + id: string; + statement: string; + verdict: DemoHypothesisVerdict; + finding?: string; + evidence: Evidence[]; +}; + +export type DemoInvestigationOutcome = "in_progress" | "concluded" | "inconclusive"; + +export type DemoInvestigationSeverity = "info" | "warn" | "crit"; + +export type DemoInvestigationCaveat = { + kind: "dirty_commit"; + message: string; +}; + +export type DemoInvestigation = { + investigationId: string; + revision: number; + outcome: DemoInvestigationOutcome; + severity: DemoInvestigationSeverity; + confidence: "high" | "medium" | "low"; + runId?: string; + title: string; + headline: string; + remediation?: string; + checkNext?: string[]; + progress?: string; + hypotheses: DemoHypothesis[]; + evidence: Evidence[]; + caveat?: DemoInvestigationCaveat; + startedAt: string; + updatedAt: string; +}; + +const runUri = demoRunUri(DEMO_WORLD.failedRunId); +const spanUri = demoSpanUri(DEMO_WORLD.failedRunId, DEMO_WORLD.failedSpanId); +const errorUri = demoErrorUri(DEMO_WORLD.errorFingerprint); +const queueUri = demoQueueUri(DEMO_WORLD.queue); +const sourceUri = demoSourceUri(DEMO_WORLD.sourceSha, DEMO_WORLD.sourcePath, 18); + +const INVESTIGATION_ID = demoId("investigation-order-receipt"); + +const errorEvidence: Evidence = { + kind: "error", + uri: errorUri, + label: "rate_limit_exceeded · 41 runs in the last hour", + excerpt: "ProviderError: 429 Too Many Requests (rate_limit_exceeded)", +}; + +const spanEvidence: Evidence = { + kind: "span", + uri: spanUri, + label: "sendEmail span, attempt 3 of 3", + excerpt: "sendEmail 412ms ✕ 429 Too Many Requests", +}; + +const sourceEvidence: Evidence = { + kind: "source", + uri: sourceUri, + label: `${DEMO_WORLD.sourcePath}:18`, + excerpt: "retry: { maxAttempts: 3, minTimeoutInMs: 1_000, factor: 1 },", +}; + +const queueEvidence: Evidence = { + kind: "queue", + uri: queueUri, + label: `${DEMO_WORLD.queue} · concurrency 50 of 50`, + excerpt: "concurrency pinned at 50 for 38 of the last 60 min", +}; + +const runEvidence: Evidence = { + kind: "run", + uri: runUri, + label: `${DEMO_WORLD.failedRunId} · failed after 3 attempts`, + excerpt: "attempt 1 429 · attempt 2 429 · attempt 3 429 — all within 19.4s", +}; + +const priorRunEvidence: Evidence = { + kind: "run", + uri: demoRunUri(DEMO_WORLD.priorRunId), + label: `${DEMO_WORLD.priorRunId} · same payload, completed in 1.2s`, + excerpt: "2,104 runs with this payload shape succeeded earlier today", +}; + +const deploymentEvidence: Evidence = { + kind: "deployment", + uri: demoDeploymentUri(DEMO_WORLD.deploymentVersion), + label: `${DEMO_WORLD.deploymentVersion} · deployed 19h before the first failure`, + excerpt: "first failure 09:02, deploy 14:11 the previous day — no overlap", +}; + +export const demoInvestigationStreamingRev0: DemoInvestigation = { + investigationId: INVESTIGATION_ID, + revision: 0, + outcome: "in_progress", + severity: "warn", + confidence: "low", + runId: DEMO_WORLD.failedRunId, + title: `Why is ${DEMO_WORLD.taskId} failing?`, + headline: + "All three attempts of this run ended in an error from the email provider. I'm reading the spans to see which call failed and whether the retries had a chance to succeed.", + progress: "Reading the run's spans", + hypotheses: [ + { + id: demoId("hyp-rate-limit"), + statement: "The email provider is rate limiting this API key.", + verdict: "testing", + evidence: [], + }, + { + id: demoId("hyp-bad-payload"), + statement: "The payload is malformed and the provider rejects it.", + verdict: "testing", + evidence: [], + }, + { + id: demoId("hyp-retry-window"), + statement: "The retry schedule keeps every attempt inside one rate-limit window.", + verdict: "testing", + evidence: [], + }, + ], + evidence: [runEvidence, spanEvidence], + startedAt: "2026-07-27T10:14:02.000Z", + updatedAt: "2026-07-27T10:14:06.000Z", +}; + +export const demoInvestigationEarly: DemoInvestigation = { + investigationId: demoId("investigation-order-receipt-early"), + revision: 0, + outcome: "in_progress", + severity: "warn", + confidence: "low", + runId: DEMO_WORLD.failedRunId, + title: `Why is ${DEMO_WORLD.taskId} failing?`, + headline: + "The run failed after three attempts. I'm reading its spans to see which call failed before I put any hypotheses up.", + progress: "Reading the run's spans", + hypotheses: [], + evidence: [runEvidence], + startedAt: "2026-07-27T10:14:02.000Z", + updatedAt: "2026-07-27T10:14:03.000Z", +}; + +export const demoInvestigationStreamingRev1: DemoInvestigation = { + ...demoInvestigationStreamingRev0, + revision: 1, + confidence: "medium", + headline: + "Every attempt came back 429 rate_limit_exceeded, and 41 other runs of this task hit the same error in the last hour. Checking whether the retry schedule made it worse.", + progress: "Comparing against the last hour of runs on this queue", + hypotheses: [ + { + ...demoInvestigationStreamingRev0.hypotheses[0]!, + verdict: "validated", + finding: "All three attempts returned 429 rate_limit_exceeded inside a 20-second window.", + evidence: [errorEvidence, spanEvidence], + }, + { + ...demoInvestigationStreamingRev0.hypotheses[1]!, + verdict: "invalidated", + finding: + "The same payload shape succeeded on 2,104 runs earlier today, and the provider never returned a 4xx other than 429.", + evidence: [priorRunEvidence], + }, + { + ...demoInvestigationStreamingRev0.hypotheses[2]!, + verdict: "testing", + evidence: [queueEvidence], + }, + ], + evidence: [runEvidence, errorEvidence, queueEvidence], + updatedAt: "2026-07-27T10:14:11.000Z", +}; + +export const demoInvestigationConcluded: DemoInvestigation = { + investigationId: INVESTIGATION_ID, + revision: 2, + outcome: "concluded", + severity: "crit", + confidence: "high", + runId: DEMO_WORLD.failedRunId, + title: `${DEMO_WORLD.taskId} is failing on every retry`, + headline: + "The email provider is rate limiting this API key, and the task's retries all land inside the same limit window — so every attempt fails. 41 runs failed this way in the last hour.", + remediation: + "Spread the attempts out and stop the queue bursting into the provider: raise `minTimeoutInMs` to 30s with a factor of 2 (or add jitter) so the three attempts span the limit window instead of sharing it, and cap the queue's concurrency at 20 to stay under the provider's per-second ceiling. Neither change needs a code deploy if you set the queue limit from the dashboard.", + hypotheses: [ + { + id: demoId("hyp-rate-limit"), + statement: "The email provider is rate limiting this API key.", + verdict: "validated", + finding: + "All three attempts returned 429 rate_limit_exceeded, and 41 other runs hit the same fingerprint in the last hour.", + evidence: [errorEvidence, spanEvidence], + }, + { + id: demoId("hyp-bad-payload"), + statement: "The payload is malformed and the provider rejects it.", + verdict: "invalidated", + finding: + "The same payload succeeded on 2,104 runs earlier today; the provider never returned a 4xx other than 429.", + evidence: [priorRunEvidence, runEvidence], + }, + { + id: demoId("hyp-retry-window"), + statement: "The retry schedule keeps every attempt inside one rate-limit window.", + verdict: "validated", + finding: + "maxAttempts 3 with a 1s base delay and factor 1 puts all three attempts inside 20 seconds.", + evidence: [sourceEvidence, spanEvidence], + }, + { + id: demoId("hyp-queue-burst"), + statement: "The queue is bursting into the provider faster than its per-second ceiling.", + verdict: "validated", + finding: + "The queue sat at its concurrency limit of 50 for 38 of the last 60 minutes, so ~50 sends land on the provider at once every time it drains.", + evidence: [queueEvidence], + }, + { + id: demoId("hyp-deploy-regression"), + statement: "Yesterday's deploy introduced the failure.", + verdict: "invalidated", + finding: + "The deploy went out 19 hours before the first failure and the task ran clean for most of that window, so the timing rules it out.", + evidence: [deploymentEvidence], + }, + ], + evidence: [errorEvidence, spanEvidence, sourceEvidence, queueEvidence, deploymentEvidence], + startedAt: "2026-07-27T10:14:02.000Z", + updatedAt: "2026-07-27T10:14:24.000Z", +}; + +export const demoInvestigationConcludedNoCode: DemoInvestigation = { + investigationId: demoId("investigation-queue-saturation"), + revision: 2, + outcome: "concluded", + severity: "crit", + confidence: "high", + title: `${DEMO_WORLD.queue} is starving — nothing is starting`, + headline: `The ${DEMO_WORLD.queue} queue has sat at its concurrency limit of 50 for 38 of the last 60 minutes, so new runs wait behind the ones already running. The p95 wait is 2 minutes against a p50 of 38 seconds, and every run that does start finishes normally.`, + remediation: + "Raise the queue's concurrency limit (or the environment's, if that's the one it's hitting) until the depth trend flattens. You can set it from the queue page — no deploy needed. If the limit is deliberate, the backlog is telling you the arrival rate now exceeds it, and the trigger side is what has to change.", + hypotheses: [ + { + id: demoId("hyp-queue-limit"), + statement: "Runs are waiting on the queue's concurrency limit, not failing.", + verdict: "validated", + finding: + "The queue was pinned at 50 of 50 for 38 of the last 60 minutes while the depth climbed from 10 to 4,210, and no run in the window failed.", + evidence: [queueEvidence], + }, + { + id: demoId("hyp-queue-slow-task"), + statement: "The task itself got slower, so each slot is held longer.", + verdict: "invalidated", + finding: + "Runs that did start completed in ~1.2s, the same as earlier today — the slots turn over as fast as they ever did.", + evidence: [priorRunEvidence], + }, + ], + evidence: [queueEvidence, priorRunEvidence], + startedAt: "2026-07-27T11:02:00.000Z", + updatedAt: "2026-07-27T11:02:19.000Z", +}; + +export const demoInvestigationInconclusive: DemoInvestigation = { + investigationId: demoId("investigation-monthly-report"), + revision: 1, + outcome: "inconclusive", + severity: "warn", + confidence: "low", + runId: DEMO_WORLD.slowRunId, + title: `Why is ${DEMO_WORLD.slowTaskId} slow?`, + headline: + "This run has been executing for 24 minutes against a p95 of 3 minutes, and the time is spent inside one un-instrumented span. I can see where it stalls but not why — nothing in the telemetry explains it.", + checkNext: [ + "Add a span (or a log) around the report aggregation step so the stall shows up in the trace.", + "Check the warehouse the aggregation reads from — a slow upstream query would look exactly like this.", + "Compare against the last run that finished normally to see whether the payload got bigger.", + ], + hypotheses: [ + { + id: demoId("hyp-slow-oom"), + statement: "The run is thrashing against its memory limit.", + verdict: "invalidated", + finding: + "Peak memory stayed at 38% of the machine's limit for the whole run, and there is no OOM signal on the attempt.", + evidence: [ + { + kind: "run", + uri: demoRunUri(DEMO_WORLD.slowRunId), + label: `${DEMO_WORLD.slowRunId} · machine metrics, large-1x`, + excerpt: "memory peak 38% · cpu 11% avg · no restarts", + }, + ], + }, + { + id: demoId("hyp-slow-queue-wait"), + statement: "The run spent the time waiting for a worker rather than executing.", + verdict: "invalidated", + finding: + "It was dequeued 40ms after it was triggered and has been executing ever since — the time is inside the attempt, not in front of it.", + evidence: [ + { + kind: "queue", + uri: demoQueueUri(DEMO_WORLD.backlogQueue), + label: `${DEMO_WORLD.backlogQueue} · 3 of 20 concurrency in use`, + excerpt: "dequeued 40ms after trigger · no queue wait", + }, + ], + }, + { + id: demoId("hyp-slow-upstream"), + statement: "An upstream call inside the aggregation step is blocking.", + verdict: "testing", + finding: undefined, + evidence: [ + { + kind: "span", + uri: demoSpanUri(DEMO_WORLD.slowRunId, "span_demoe71f"), + label: "aggregate span · 23m 41s, no children", + excerpt: "aggregate 23m41s ● (no child spans)", + }, + ], + }, + ], + evidence: [ + { + kind: "run", + uri: demoRunUri(DEMO_WORLD.slowRunId), + label: `${DEMO_WORLD.slowRunId} · executing for 24m, p95 is 3m`, + excerpt: "status EXECUTING · attempt 1 · started 09:17:22", + }, + { + kind: "span", + uri: demoSpanUri(DEMO_WORLD.slowRunId, "span_demoe71f"), + label: "aggregate span · 23m 41s, no children", + excerpt: "aggregate 23m41s ● (no child spans)", + }, + ], + startedAt: "2026-07-27T09:41:00.000Z", + updatedAt: "2026-07-27T09:41:38.000Z", +}; + +export const demoInvestigationDegraded: DemoInvestigation = { + investigationId: demoId("investigation-order-receipt-degraded"), + revision: 1, + outcome: "inconclusive", + severity: "warn", + confidence: "low", + runId: DEMO_WORLD.failedRunId, + title: `Why is ${DEMO_WORLD.taskId} failing?`, + headline: `Every attempt of this run ended in a 429 from the email provider, and 41 other runs hit the same error in the last hour. I couldn't read the trace — the spans for this run are no longer retained — so I can't tell whether the retries all landed inside one rate-limit window, which is what would explain it.`, + checkNext: [ + "Re-run the investigation on a fresher failure, while its spans are still retained.", + "Check the provider's dashboard for the rate limit on this API key and when it resets.", + "Compare the task's retry settings against that window — three attempts inside one window would fail as a group.", + ], + hypotheses: [ + { + id: demoId("hyp-rate-limit"), + statement: "The email provider is rate limiting this API key.", + verdict: "validated", + finding: "All three attempts returned 429 rate_limit_exceeded on the same fingerprint.", + evidence: [errorEvidence], + }, + { + id: demoId("hyp-retry-window"), + statement: "The retry schedule keeps every attempt inside one rate-limit window.", + verdict: "testing", + finding: "The run's spans are no longer retained, so the attempt timings can't be read.", + evidence: [], + }, + ], + evidence: [runEvidence, errorEvidence], + startedAt: "2026-07-27T10:31:00.000Z", + updatedAt: "2026-07-27T10:31:14.000Z", +}; + +export const demoInvestigationDirtyCommit: DemoInvestigation = { + ...demoInvestigationConcluded, + investigationId: demoId("investigation-order-receipt-dirty"), + confidence: "medium", + caveat: { + kind: "dirty_commit", + message: + "Source lines below come from the nearest repository snapshot, not the exact deployed code — this deploy was built from a working tree with uncommitted changes. The run, span and error evidence is unaffected.", + }, +}; + +export const demoInvestigations = { + early: demoInvestigationEarly, + streamingRev0: demoInvestigationStreamingRev0, + streamingRev1: demoInvestigationStreamingRev1, + concluded: demoInvestigationConcluded, + concludedNoCode: demoInvestigationConcludedNoCode, + inconclusive: demoInvestigationInconclusive, + degraded: demoInvestigationDegraded, + dirtyCommit: demoInvestigationDirtyCommit, +} as const; + +export const demoShowCodeMarkdown = `Here's the change, against \`${DEMO_WORLD.sourcePath}:14-20@${DEMO_WORLD.sourceSha.slice(0, 7)}\`: + +\`\`\`diff +--- a/${DEMO_WORLD.sourcePath} ++++ b/${DEMO_WORLD.sourcePath} +@@ -14,7 +14,8 @@ export const sendOrderReceipt = task({ + id: "${DEMO_WORLD.taskId}", + retry: { + maxAttempts: 3, +- minTimeoutInMs: 1_000, +- factor: 1, ++ minTimeoutInMs: 30_000, ++ factor: 2, ++ randomize: true, + }, +\`\`\` + +That spreads the three attempts across ~2 minutes instead of 20 seconds. I haven't applied anything — this is the patch I'd suggest.`; diff --git a/apps/webapp/app/components/dashboard-agent/demo/fixtures/messages.ts b/apps/webapp/app/components/dashboard-agent/demo/fixtures/messages.ts new file mode 100644 index 0000000000..8cfc119170 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/demo/fixtures/messages.ts @@ -0,0 +1,71 @@ +import type { UIMessage } from "@ai-sdk/react"; +import type { ViewBlock } from "@internal/dashboard-agent-contracts"; +import { demoId } from "../ids"; + +type Part = UIMessage["parts"][number]; + +export function demoMessageId(name: string): string { + return demoId(`msg-${name}`); +} + +export function userMessage(name: string, text: string): UIMessage { + return { id: demoMessageId(name), role: "user", parts: [{ type: "text", text }] }; +} + +export function assistantMessage(name: string, parts: Part[]): UIMessage { + return { id: demoMessageId(name), role: "assistant", parts }; +} + +export function textPart(text: string): Part { + return { type: "text", text, state: "done" }; +} + +export function streamingTextPart(text: string): Part { + return { type: "text", text, state: "streaming" }; +} + +export function reasoningPart(text: string): Part { + return { type: "reasoning", text, state: "done" }; +} + +export function toolPart(name: string, input: unknown, output: unknown, callName?: string): Part { + return { + type: `tool-${name}`, + toolCallId: demoId(`call-${callName ?? name}`), + state: "output-available", + input, + output, + } as Part; +} + +export function pendingToolPart(name: string, input: unknown, callName?: string): Part { + return { + type: `tool-${name}`, + toolCallId: demoId(`call-${callName ?? name}`), + state: "input-available", + input, + } as Part; +} + +export function failedToolPart( + name: string, + input: unknown, + errorText: string, + callName?: string +): Part { + return { + type: `tool-${name}`, + toolCallId: demoId(`call-${callName ?? name}`), + state: "output-error", + input, + errorText, + } as Part; +} + +export function renderViewPart(blocks: ViewBlock[], callName?: string): Part { + return toolPart("render_view", { blocks }, { blocks }, callName ?? "render-view"); +} + +export function sourceUrlPart(url: string, title: string): Part { + return { type: "source-url", sourceId: demoId(`source-${title}`), url, title } as Part; +} diff --git a/apps/webapp/app/components/dashboard-agent/demo/fixtures/page-context.ts b/apps/webapp/app/components/dashboard-agent/demo/fixtures/page-context.ts index 2cae37107b..14f58b6169 100644 --- a/apps/webapp/app/components/dashboard-agent/demo/fixtures/page-context.ts +++ b/apps/webapp/app/components/dashboard-agent/demo/fixtures/page-context.ts @@ -238,6 +238,12 @@ export const demoPromptSets: Record = { export const demoDismissedPromptIds: string[] = [demoId("prompt-watch-retry")]; +/** + * Ids the resolver itself emits, for dismissing against a live resolve. Dismissing the + * fresh-failure chip on `demoFailedRunPageContext` falls back to `sp:run-investigate`. + */ +export const demoResolvedDismissedPromptIds: string[] = ["sp:fresh-failure"]; + export const demoPromptsAfterDismissal: SuggestedPrompt[] = demoPromptSets.failedRun .filter((p) => !demoDismissedPromptIds.includes(p.id)) .slice(0, SUGGESTED_PROMPT_CAP); diff --git a/apps/webapp/app/components/dashboard-agent/demo/fixtures/watches.ts b/apps/webapp/app/components/dashboard-agent/demo/fixtures/watches.ts new file mode 100644 index 0000000000..1b2367a123 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/demo/fixtures/watches.ts @@ -0,0 +1,149 @@ +import { + watchIdentity, + type WatchSpec, + type WatchStatus, +} from "@internal/dashboard-agent-contracts"; +import { DEMO_WORLD, demoId } from "../ids"; + +export type DemoWatch = { + id: string; + spec: WatchSpec; + status: WatchStatus; + identity: string; + chipLabel: string; + createdAt: string; + expiresAt: string; + cancellable: boolean; +}; + +const watch = ( + name: string, + spec: WatchSpec, + chipLabel: string, + status: WatchStatus, + createdAt: string, + expiresAt: string +): DemoWatch => ({ + id: demoId(`watch-${name}`), + spec, + status, + identity: watchIdentity(spec), + chipLabel, + createdAt, + expiresAt, + cancellable: status === "active", +}); + +export const demoRunFinishedWatch = watch( + "run-finished", + { + kind: "run_finished", + runId: DEMO_WORLD.failedRunId, + note: "Tell me when the retry of send-order-receipt finishes.", + maxHours: 2, + checkEveryMinutes: 1, + }, + DEMO_WORLD.taskId, + "active", + "2026-07-27T10:15:10.000Z", + "2026-07-27T12:15:10.000Z" +); + +export const demoBacklogDrainWatch = watch( + "backlog-drain", + { + kind: "backlog_drain", + queue: DEMO_WORLD.backlogQueue, + note: "Tell me when the backlog on demo-backlog-drain clears.", + maxHours: 6, + checkEveryMinutes: 5, + }, + "backlog-drain", + "active", + "2026-07-27T09:02:00.000Z", + "2026-07-27T15:02:00.000Z" +); + +export const demoErrorRecurrenceWatch = watch( + "email-sends", + { + kind: "error_recurrence", + // The page cites `error_`, the spec keeps the bare form. + fingerprint: DEMO_WORLD.errorFingerprint.replace(/^error_/, ""), + note: "Tell me if the rate-limit error comes back.", + maxHours: 12, + checkEveryMinutes: 15, + }, + "email-sends", + "fired", + "2026-07-26T22:40:00.000Z", + "2026-07-27T10:40:00.000Z" +); + +export const demoHealthRecoveryWatch = watch( + "health-recovery", + { + kind: "health_recovery", + report: "health", + fromSeverity: "crit", + note: "Tell me when prod is healthy again.", + maxHours: 4, + checkEveryMinutes: 15, + }, + "health-recovery", + "expired", + "2026-07-27T04:20:00.000Z", + "2026-07-27T08:20:00.000Z" +); + +export const demoCancelledWatch = watch( + "run-start", + { + kind: "run_start", + runId: DEMO_WORLD.waitingRunId, + note: "Tell me when this run starts.", + maxHours: 1, + checkEveryMinutes: 1, + }, + "run-start", + "cancelled", + "2026-07-27T10:01:00.000Z", + "2026-07-27T11:01:00.000Z" +); + +export const demoWatchRow: DemoWatch[] = [ + demoRunFinishedWatch, + demoBacklogDrainWatch, + demoErrorRecurrenceWatch, + demoHealthRecoveryWatch, + demoCancelledWatch, +]; + +export const demoActiveWatchRow: DemoWatch[] = [demoRunFinishedWatch, demoBacklogDrainWatch]; + +export const demoWatchNarration = { + wake: `**The retry finished.** \`${DEMO_WORLD.failedRunId}\` completed successfully 4 minutes ago, on attempt 2 — the provider accepted the request once the delay pushed it out of the rate-limit window. + +I've stopped watching it. The other 40 runs from the same burst are still queued behind the concurrency limit; ask me if you want them watched too.`, + + expiry: `**I've stopped watching \`${DEMO_WORLD.backlogQueue}\`.** The 6-hour window is up and the backlog never fully drained — it's down from 4,812 to 610 pending, so it's clearing, just slower than the window I was given. + +Ask again if you want another 6 hours.`, + + expiryUnverified: `**I've stopped watching prod's health, but I couldn't verify the condition at expiry.** The health data was unavailable on my last few checks, so I can't tell you whether prod recovered — only that I never saw it recover. + +Re-run the health report to get a current answer.`, + + cancelled: `Stopped watching \`${DEMO_WORLD.waitingRunId}\`.`, +} as const; + +export const demoWatches = { + runFinished: demoRunFinishedWatch, + backlogDrain: demoBacklogDrainWatch, + errorRecurrence: demoErrorRecurrenceWatch, + healthRecovery: demoHealthRecoveryWatch, + cancelled: demoCancelledWatch, + row: demoWatchRow, + activeRow: demoActiveWatchRow, + narration: demoWatchNarration, +} as const; diff --git a/apps/webapp/app/components/dashboard-agent/demo/ids.ts b/apps/webapp/app/components/dashboard-agent/demo/ids.ts index 22697e3361..1db8a9fdcb 100644 --- a/apps/webapp/app/components/dashboard-agent/demo/ids.ts +++ b/apps/webapp/app/components/dashboard-agent/demo/ids.ts @@ -1,10 +1,62 @@ -// Every id the demo layer produces contains "demo". +// Every id the demo layer produces contains "demo". Resource ids carry the marker +// inline: `trigger://` segments are percent-encoded, so `demo:` would render as `demo%3A`. +import { formatTriggerUri, type TriggerUri } from "@internal/dashboard-agent-contracts"; + export const DEMO_ID_PREFIX = "demo:"; +export const DEMO_MARKER = "demo"; + export function demoId(rest: string): string { return `${DEMO_ID_PREFIX}${rest}`; } +export const DEMO_PROJECT_REF = "proj_demo00000000000000"; +export const DEMO_ENVIRONMENT_ID = "env_demo00000000000000"; + +const scope = { projectRef: DEMO_PROJECT_REF, environmentId: DEMO_ENVIRONMENT_ID }; + +export function demoRunsUri(): TriggerUri { + return formatTriggerUri({ kind: "runs", ...scope }); +} + +export function demoRunUri(runId: string): TriggerUri { + return formatTriggerUri({ kind: "run", ...scope, runId }); +} + +export function demoSpanUri(runId: string, spanId: string): TriggerUri { + return formatTriggerUri({ kind: "span", ...scope, runId, spanId }); +} + +export function demoErrorUri(fingerprint: string): TriggerUri { + return formatTriggerUri({ kind: "error", ...scope, fingerprint }); +} + +export function demoQueueUri(name: string): TriggerUri { + return formatTriggerUri({ kind: "queue", ...scope, name }); +} + +export function demoDeploymentUri(version: string): TriggerUri { + return formatTriggerUri({ kind: "deployment", ...scope, version }); +} + +export function demoReportUri(key: string): TriggerUri { + return formatTriggerUri({ kind: "report", ...scope, key }); +} + +export function demoSourceUri(sha: string, path: string, line?: number): TriggerUri { + return formatTriggerUri({ + kind: "source", + ...scope, + sha, + path, + ...(line !== undefined ? { line } : {}), + }); +} + +export function demoInvestigationUri(investigationId: string): TriggerUri { + return formatTriggerUri({ kind: "investigation", ...scope, investigationId }); +} + export const DEMO_WORLD = { failedRunId: "run_demo0f2c91", failedSpanId: "span_demoa41b", diff --git a/apps/webapp/app/components/dashboard-agent/demo/index.ts b/apps/webapp/app/components/dashboard-agent/demo/index.ts new file mode 100644 index 0000000000..62e6a04bbd --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/demo/index.ts @@ -0,0 +1,7 @@ +// Must stay free of server imports. `demo.test.ts` asserts that. +export { DEMO_ID_PREFIX, DEMO_MARKER, DEMO_WORLD, demoId, demoReportUri, demoRunsUri } from "./ids"; + +export * as demoFixtures from "./fixtures"; + +export { DemoChartCard } from "./components/DemoChartCard"; +export { DemoIntentBubble } from "./components/DemoIntentBubble"; diff --git a/apps/webapp/app/components/dashboard-agent/message-quota.test.ts b/apps/webapp/app/components/dashboard-agent/message-quota.test.ts index 5a252db7ac..deb888b05d 100644 --- a/apps/webapp/app/components/dashboard-agent/message-quota.test.ts +++ b/apps/webapp/app/components/dashboard-agent/message-quota.test.ts @@ -1,5 +1,55 @@ import { describe, expect, it } from "vitest"; -import { countUserMessages, FREE_PLAN_MESSAGE_LIMIT, resolveMessageQuota } from "./message-quota"; +import { + countUserMessages, + FREE_PLAN_MESSAGE_LIMIT, + MESSAGE_QUOTA_REACHED_ERROR, + messageQuotaReachedCopy, + parseQuotaReachedResponse, + quotaResponseUpdate, + resolveMessageLimit, + resolveMessageQuota, + shouldClearCapReached, +} from "./message-quota"; + +describe("quotaResponseUpdate", () => { + it("takes both fields from a coherent body", () => { + expect(quotaResponseUpdate({ used: 30, limit: 50 })).toEqual({ used: 30, limit: 50 }); + expect(quotaResponseUpdate({ used: 30, limit: null })).toEqual({ used: 30, limit: null }); + expect(quotaResponseUpdate({ used: 0, limit: 0 })).toEqual({ used: 0, limit: 0 }); + }); + + it("changes nothing on a degraded body", () => { + // Control break: apply `{}` field-by-field and a good {used:30, limit:50} read decays to + // used 30 against the client's 20 — "reached" against a cap the server never set. + expect(quotaResponseUpdate({})).toBeNull(); + expect(quotaResponseUpdate(null)).toBeNull(); + expect(quotaResponseUpdate({ limit: 50 })).toBeNull(); + }); +}); + +describe("resolveMessageLimit", () => { + it("prefers a finite server-resolved limit over the client constant", () => { + expect(resolveMessageLimit(5)).toBe(5); + expect(resolveMessageLimit(0)).toBe(0); + expect(resolveMessageLimit(500)).toBe(500); + }); + + it("keeps the free-plan nudge when the server has no finite limit", () => { + // Pre-P0 the server limit is the unlimited sentinel and is sent as null: the client's + // own 20 IS the nudge. Control break: thread the server number here and it disappears. + expect(resolveMessageLimit(null)).toBe(FREE_PLAN_MESSAGE_LIMIT); + expect(resolveMessageLimit(undefined)).toBe(FREE_PLAN_MESSAGE_LIMIT); + }); + + it("caps against the server limit once it is known", () => { + expect( + resolveMessageQuota({ isFreePlan: true, used: 5, limit: resolveMessageLimit(5) }) + ).toMatchObject({ kind: "reached", limit: 5 }); + expect( + resolveMessageQuota({ isFreePlan: true, used: 5, limit: resolveMessageLimit(null) }) + ).toMatchObject({ kind: "within", limit: FREE_PLAN_MESSAGE_LIMIT, remaining: 15 }); + }); +}); describe("resolveMessageQuota", () => { it("caps a Free plan at the limit", () => { @@ -42,6 +92,76 @@ describe("resolveMessageQuota", () => { }); }); +describe("parseQuotaReachedResponse", () => { + it("maps a create/in 403 cap body to the limit", () => { + // Both the create path and the `in` transport refuse with this exact body. + expect( + parseQuotaReachedResponse(403, { error: MESSAGE_QUOTA_REACHED_ERROR, limit: 20 }) + ).toEqual({ limit: 20, planResolved: true }); + }); + + it("falls back to the free limit when the body omits it", () => { + expect(parseQuotaReachedResponse(403, { error: MESSAGE_QUOTA_REACHED_ERROR })).toEqual({ + limit: FREE_PLAN_MESSAGE_LIMIT, + planResolved: false, + }); + }); + + it("ignores other errors and non-403 statuses so they surface normally", () => { + expect(parseQuotaReachedResponse(403, { error: "something_else" })).toBeNull(); + expect(parseQuotaReachedResponse(500, { error: MESSAGE_QUOTA_REACHED_ERROR })).toBeNull(); + expect(parseQuotaReachedResponse(403, null)).toBeNull(); + }); +}); + +describe("shouldClearCapReached", () => { + const readQuota = (data: { used?: number; limit?: number | null } | null) => { + const update = quotaResponseUpdate(data); + return resolveMessageQuota({ + isFreePlan: true, + used: update?.used, + limit: resolveMessageLimit(update?.limit), + }); + }; + + it("releases the block once a read shows capacity", () => { + // Refused at 20/20, then the allowance resets or the plan's cap grows. + expect(shouldClearCapReached(readQuota({ used: 20, limit: 20 }))).toBe(false); + expect(shouldClearCapReached(readQuota({ used: 0, limit: 20 }))).toBe(true); + expect(shouldClearCapReached(readQuota({ used: 20, limit: 500 }))).toBe(true); + }); + + it("keeps the block when the read is degraded, so the composer can't flash", () => { + expect(shouldClearCapReached(readQuota({ used: 20, limit: 20 }))).toBe(false); + expect(shouldClearCapReached(readQuota(null))).toBe(false); + expect(shouldClearCapReached(readQuota({ limit: 20 }))).toBe(false); + }); + + it("keeps the block while the plan hasn't resolved", () => { + expect(shouldClearCapReached(resolveMessageQuota({ isFreePlan: undefined, used: 0 }))).toBe( + false + ); + }); +}); + +describe("messageQuotaReachedCopy", () => { + it("names the Free plan only for the client nudge", () => { + const copy = messageQuotaReachedCopy(FREE_PLAN_MESSAGE_LIMIT, false); + expect(copy).toContain(`all ${FREE_PLAN_MESSAGE_LIMIT} messages`); + expect(copy).toContain("Free plan"); + // Control break: if the mapping leaked the server code, this fails. + expect(copy).not.toContain(MESSAGE_QUOTA_REACHED_ERROR); + }); + + it("stays plan-agnostic for a server-resolved limit, which paying orgs also hit", () => { + const copy = messageQuotaReachedCopy(500, true); + expect(copy).toContain("all 500 messages"); + expect(copy).toContain("your plan"); + expect(copy).not.toContain("Free plan"); + expect(copy).not.toContain(MESSAGE_QUOTA_REACHED_ERROR); + }); +}); + describe("countUserMessages", () => { it("counts only what the user sent", () => { expect( diff --git a/apps/webapp/app/components/dashboard-agent/message-quota.ts b/apps/webapp/app/components/dashboard-agent/message-quota.ts index f65481c870..227f0a0232 100644 --- a/apps/webapp/app/components/dashboard-agent/message-quota.ts +++ b/apps/webapp/app/components/dashboard-agent/message-quota.ts @@ -4,6 +4,27 @@ import { isWatchRequestMessageId } from "@internal/dashboard-agent-contracts"; // would reset. export const FREE_PLAN_MESSAGE_LIMIT = 20; +/** + * The cap to show: the plan limit the server resolved, when it resolved a finite one. The + * server sends null while no plan limit exists (self-hosted, or before billing carries one), + * and then the free-plan nudge is the cap — dropping it would remove the nudge entirely. + */ +export function resolveMessageLimit(serverLimit: number | null | undefined): number { + return typeof serverLimit === "number" ? serverLimit : FREE_PLAN_MESSAGE_LIMIT; +} + +/** + * What a `?quota=1` body should change, or null for a degraded one. Both fields move together: + * applying a `{}` on top of a good read would keep the count and drop back to the nudge limit, + * which reads as "reached" against a cap the server never set. + */ +export function quotaResponseUpdate( + data: { used?: number; limit?: number | null } | null | undefined +): { used: number; limit: number | null } | null { + if (typeof data?.used !== "number") return null; + return { used: data.used, limit: typeof data.limit === "number" ? data.limit : null }; +} + export type MessageQuota = | { kind: "unlimited" } | { kind: "within"; used: number; limit: number; remaining: number } @@ -27,6 +48,47 @@ export function resolveMessageQuota({ : { kind: "within", used, limit, remaining }; } +/** + * Whether a refusal-set cap can be released: only a read that proves capacity is back. An + * unknown quota (degraded read, plan not resolved) keeps the block, so the composer never + * flashes back for someone the server is about to refuse again. + */ +export function shouldClearCapReached(quota: MessageQuota): boolean { + return quota.kind === "within"; +} + +// The server code both the create and `in` paths refuse with. The client owns the copy, +// so this code must never reach the UI as text. +export const MESSAGE_QUOTA_REACHED_ERROR = "message_quota_reached"; + +// Maps a 403 refusal body to the cap signal, or null for any other error. Both paths use +// this so a `message_quota_reached` code routes to the upgrade block, never a raw toast. +export function parseQuotaReachedResponse( + status: number, + data: { error?: string; limit?: number } | null | undefined +): { limit: number; planResolved: boolean } | null { + if (status === 403 && data?.error === MESSAGE_QUOTA_REACHED_ERROR) { + return typeof data.limit === "number" + ? { limit: data.limit, planResolved: true } + : { limit: FREE_PLAN_MESSAGE_LIMIT, planResolved: false }; + } + return null; +} + +/** + * The upgrade block's sentence. Pure so the copy is asserted directly, and so the raw + * server code can never be what the user reads. Only the client's free-plan nudge may name + * the Free plan — a server-resolved cap also lands on paying orgs, whose allowance isn't it. + */ +export function messageQuotaReachedCopy(limit: number, planResolved: boolean): string { + return planResolved + ? `You've used all ${limit} messages included in your plan this month. Your chats stay here to read.` + : `You've used all ${limit} messages included on the Free plan. Your chats stay here to read.`; +} + +/** Why a suggestion chip is disabled: the upgrade block carries the full sentence. */ +export const MESSAGE_QUOTA_REACHED_REASON = "You've used your message allowance"; + // A watch's consent record is a user message the person never typed, so it is // excluded here exactly as the stored count excludes it. export function countUserMessages(messages: { role: string; id?: string }[]): number { diff --git a/apps/webapp/app/components/dashboard-agent/useAgentMessageQuota.ts b/apps/webapp/app/components/dashboard-agent/useAgentMessageQuota.ts index da6cc3d83b..0ddf3279f3 100644 --- a/apps/webapp/app/components/dashboard-agent/useAgentMessageQuota.ts +++ b/apps/webapp/app/components/dashboard-agent/useAgentMessageQuota.ts @@ -1,46 +1,65 @@ -import type { UIMessage } from "@ai-sdk/react"; -import { useEffect, useState } from "react"; -import { countUserMessages, resolveMessageQuota, type MessageQuota } from "./message-quota"; +import { useEffect, useRef, useState } from "react"; +import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route"; +import { + quotaResponseUpdate, + resolveMessageLimit, + resolveMessageQuota, + type MessageQuota, +} from "./message-quota"; -// Always undefined until billing supplies plan detection, which means no cap. +// Gated on billing PRESENCE, not the plan value: no subscription means billing isn't wired +// up (self-hosted), so there is no cap and no upgrade UI. A wired-up, non-paying plan is free. function useIsFreePlan(): boolean | undefined { - return undefined; + const subscription = useCurrentPlan()?.v3Subscription; + if (!subscription) return undefined; + return subscription.isPaying === false; } -// Counted in two halves: the server aggregates other chats, this chat's own count -// comes from the live transcript so the message just sent counts immediately. +// `used` is the server's per-period count for the org. Re-read once a turn settles — the +// server increment happens mid-turn in the `.in` proxy, so reading on optimistic append +// would lag the count by one message and show the cap a message late. export function useAgentMessageQuota({ actionPath, chatId, - messages, + status, }: { actionPath: string; chatId: string; - messages: UIMessage[]; + status: string; }): MessageQuota { const isFreePlan = useIsFreePlan(); - const [usedElsewhere, setUsedElsewhere] = useState(undefined); + const [used, setUsed] = useState(undefined); + const [serverLimit, setServerLimit] = useState(null); + + // Bumped each time the status leaves streaming/submitted, which drives the re-read. + const [settleTick, setSettleTick] = useState(0); + const prevStatus = useRef(status); + useEffect(() => { + const wasInFlight = prevStatus.current === "streaming" || prevStatus.current === "submitted"; + const nowSettled = status === "ready" || status === "error"; + prevStatus.current = status; + if (wasInFlight && nowSettled) setSettleTick((tick) => tick + 1); + }, [status]); useEffect(() => { if (isFreePlan !== true) return; const controller = new AbortController(); void (async () => { try { - const res = await fetch(`${actionPath}?quota=1&chatId=${encodeURIComponent(chatId)}`, { - signal: controller.signal, - }); + const res = await fetch(`${actionPath}?quota=1`, { signal: controller.signal }); if (!res.ok) return; - const data = (await res.json()) as { used?: number }; - if (typeof data.used === "number") setUsedElsewhere(data.used); + const update = quotaResponseUpdate( + (await res.json()) as { used?: number; limit?: number | null } + ); + if (!update) return; + setUsed(update.used); + setServerLimit(update.limit); } catch { // Leave the count unknown, which means no cap. See `resolveMessageQuota`. } })(); return () => controller.abort(); - }, [isFreePlan, actionPath, chatId]); + }, [isFreePlan, actionPath, chatId, settleTick]); - return resolveMessageQuota({ - isFreePlan, - used: usedElsewhere === undefined ? undefined : usedElsewhere + countUserMessages(messages), - }); + return resolveMessageQuota({ isFreePlan, used, limit: resolveMessageLimit(serverLimit) }); } diff --git a/apps/webapp/app/routes/api.v1.query.ts b/apps/webapp/app/routes/api.v1.query.ts index adad14859d..5ff7f56cfe 100644 --- a/apps/webapp/app/routes/api.v1.query.ts +++ b/apps/webapp/app/routes/api.v1.query.ts @@ -2,7 +2,11 @@ import { json } from "@remix-run/server-runtime"; import { QueryError } from "@internal/clickhouse"; import { z } from "zod"; import { createActionApiRoute, everyResource } from "~/services/routeBuilders/apiBuilder.server"; -import { executeQuery, type QueryScope } from "~/services/queryService.server"; +import { + executeQuery, + isQueryConcurrencyRejection, + type QueryScope, +} from "~/services/queryService.server"; import { logger } from "~/services/logger.server"; import { rowsToCSV } from "~/utils/dataExport"; import { detectQueryTables } from "~/v3/detectQueryTables"; @@ -78,6 +82,12 @@ const { action, loader } = createActionApiRoute( }); if (!queryResult.success) { + // A concurrency rejection is "too busy", not a bad query: 429 so callers retry it + // instead of rewriting a query that was fine. + if (isQueryConcurrencyRejection(queryResult.error)) { + return json({ error: queryResult.error.message }, { status: 429 }); + } + // QueryError surfaces customer SQL problems (invalid syntax, // unsupported construct). Returned to the caller as 400; system // handles it gracefully, no alert needed. diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$.ts index 5939e836db..e42acbd94c 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$.ts +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$.ts @@ -7,6 +7,7 @@ import { MESSAGE_TOO_LARGE_CODE, MESSAGE_TOO_LARGE_ERROR, } from "~/components/dashboard-agent/message-limits"; +import { MESSAGE_QUOTA_REACHED_ERROR } from "~/components/dashboard-agent/message-quota"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { @@ -15,6 +16,13 @@ import { resolveDashboardAgentRepoSnapshot, } from "~/services/dashboardAgent.server"; import { dashboardAgentEnvironmentAddress } from "~/services/dashboardAgentEnvironmentAddress.server"; +import { dashboardAgentDb } from "~/services/dashboardAgentDb.server"; +import { wellFormMessageText } from "~/services/dashboardAgentMessageText.server"; +import { + agentTurnCountsAgainstQuota, + recordAgentMessageSent, + resolveAgentMessageQuota, +} from "~/services/dashboardAgentQuota.server"; import { logger } from "~/services/logger.server"; import { requireUser } from "~/services/session.server"; import { readBoundedBodyText } from "~/utils/boundedRequestBody.server"; @@ -115,6 +123,9 @@ export async function action({ request, params }: ActionFunctionArgs) { parsed = undefined; } + // Hoisted so it is visible after the fetch: quota is charged only once the send succeeds. + let countsAgainstQuota = false; + if (parsed) { // Actions are placed by the server only, and this proxy is the one path a browser // can reach `.in` through. @@ -127,6 +138,19 @@ export async function action({ request, params }: ActionFunctionArgs) { return tooLarge(); } + wellFormMessageText(parsed.payload.message?.parts); + + // Only a real user message consumes quota; action turns were refused above. + countsAgainstQuota = agentTurnCountsAgainstQuota(parsed); + if (countsAgainstQuota) { + const quota = await resolveAgentMessageQuota(dashboardAgentDb, { + organizationId: project.organizationId, + }); + if (quota?.reached) { + return json({ error: MESSAGE_QUOTA_REACHED_ERROR, limit: quota.limit }, { status: 403 }); + } + } + let userActorToken: string; try { userActorToken = await mintDashboardAgentUserActorToken(user.id, { @@ -165,6 +189,13 @@ export async function action({ request, params }: ActionFunctionArgs) { try { const upstream = await fetch(upstreamUrl, { method: "POST", headers, body }); const text = await upstream.text(); + // Charge quota only for a delivered message: a non-2xx upstream (or a throw below) + // must not burn a send that never reached the agent. + if (countsAgainstQuota && upstream.ok) { + await recordAgentMessageSent(dashboardAgentDb, { + organizationId: project.organizationId, + }); + } return new Response(text, { status: upstream.status, headers: { "content-type": upstream.headers.get("content-type") ?? "application/json" }, diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts index d808c56754..499e621b83 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts @@ -3,7 +3,6 @@ import { chatExists, countUnreadWatchWakes, countChatsWithUnreadWork, - countUserMessages, createChat, getChatMessages, getSession, @@ -28,6 +27,7 @@ import { MESSAGE_TOO_LARGE_CODE, MESSAGE_TOO_LARGE_ERROR, } from "~/components/dashboard-agent/message-limits"; +import { MESSAGE_QUOTA_REACHED_ERROR } from "~/components/dashboard-agent/message-quota"; import { MAX_URIS_PER_RESOLVE_REQUEST } from "~/components/dashboard-agent/resolve-uris"; import { $replica } from "~/db.server"; import { env } from "~/env.server"; @@ -50,9 +50,15 @@ import { startDashboardAgentSession, } from "~/services/dashboardAgent.server"; import { dashboardAgentEnvironmentAddress } from "~/services/dashboardAgentEnvironmentAddress.server"; +import { wellFormMessageText } from "~/services/dashboardAgentMessageText.server"; import { watchErrorStatus } from "~/services/dashboardAgentWatchErrorStatus.server"; import { startDashboardAgentHeadStart } from "~/services/dashboardAgentHeadStart.server"; import { dashboardAgentDb } from "~/services/dashboardAgentDb.server"; +import { + recordAgentMessageSent, + resolveAgentMessageQuota, + UNLIMITED_AGENT_MESSAGES, +} from "~/services/dashboardAgentQuota.server"; import { logger } from "~/services/logger.server"; import { resolveTriggerUri } from "~/services/resolveTriggerUri.server"; import { requireUser } from "~/services/session.server"; @@ -151,15 +157,18 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const project = await findProjectBySlug(organizationSlug, projectParam, userId); if (!project) return json({ error: "Project not found" }, { status: 404 }); - // The open chat is excluded and counted from the live transcript instead, so an - // unpersisted turn still counts against the cap. + // The per-period counter, org-wide: a deleted chat can't lower it within the period. if (searchParams.get("quota") === "1") { - const used = await countUserMessages(dashboardAgentDb, { + const quota = await resolveAgentMessageQuota(dashboardAgentDb, { organizationId: project.organizationId, - userId, - excludeChatId: searchParams.get("chatId") ?? undefined, }); - return json({ used }); + if (!quota) return json({}); + // The sentinel is "no plan limit" — send null so the client keeps its own free-plan nudge + // instead of showing a number nobody would ever reach. + return json({ + used: quota.used, + limit: quota.limit < UNLIMITED_AGENT_MESSAGES ? quota.limit : null, + }); } const chatId = searchParams.get("chatId"); @@ -291,6 +300,15 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { return messageTooLarge(); } + wellFormMessageText(firstMessage.parts); + + const quota = await resolveAgentMessageQuota(dashboardAgentDb, { + organizationId: project.organizationId, + }); + if (quota?.reached) { + return json({ error: MESSAGE_QUOTA_REACHED_ERROR, limit: quota.limit }, { status: 403 }); + } + let clientData: Record | undefined; try { clientData = parsed.data.clientData @@ -388,6 +406,14 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { throw error; } + // Only the head start dispatches the first message here; a cold start sends it through + // the `in` proxy, which counts it there. Counting both would double-count. + if (headStarted) { + await recordAgentMessageSent(dashboardAgentDb, { + organizationId: project.organizationId, + }); + } + let publicAccessToken: string; try { publicAccessToken = await mintDashboardAgentToken(chatId); diff --git a/apps/webapp/app/routes/storybook.agent-investigation/route.tsx b/apps/webapp/app/routes/storybook.agent-investigation/route.tsx new file mode 100644 index 0000000000..518f8740ad --- /dev/null +++ b/apps/webapp/app/routes/storybook.agent-investigation/route.tsx @@ -0,0 +1,87 @@ +import { + INVESTIGATION_CAPABILITIES_VERSION, + type InvestigationCapabilities, +} from "@internal/dashboard-agent-contracts"; +import { demoFixtures } from "~/components/dashboard-agent/demo"; +import { InvestigationCard } from "~/components/dashboard-agent/InvestigationCard"; +import { investigationBlock } from "../storybook.agent-ui/fixtures"; +import { fixtureResolveUri, GalleryPage, noop } from "../storybook.agent-ui/gallery"; + +const { demoInvestigations } = demoFixtures; + +const citedUri = ( + fixture: (typeof demoInvestigations)[keyof typeof demoInvestigations], + kind: string +) => fixture.evidence.find((evidence) => evidence.kind === kind)!.uri; + +const codeGroundedCapabilities: InvestigationCapabilities = { + version: INVESTIGATION_CAPABILITIES_VERSION, + actions: [ + { + kind: "show_code", + label: "Show code", + intent: { + kind: "ask", + prompt: + "Show me the code behind this and propose the minimal fix as a fenced diff, anchored to the file, line and commit you read.", + }, + }, + { + kind: "view_similar", + label: "View similar failures", + intent: { kind: "navigate", target: citedUri(demoInvestigations.concluded, "error") }, + }, + ], +}; + +const notCodeGroundedCapabilities: InvestigationCapabilities = { + version: INVESTIGATION_CAPABILITIES_VERSION, + actions: [ + { + kind: "view_similar", + label: "View the queue", + intent: { kind: "navigate", target: citedUri(demoInvestigations.concludedNoCode, "queue") }, + }, + ], +}; + +const STATES: Record = { + "investigation-card-streaming-rev1": ( + + ), + "investigation-card-concluded": ( + + ), + "investigation-card-concluded-code-grounded": ( + + ), + "investigation-card-concluded-not-code-grounded": ( + + ), + "investigation-card-inconclusive": ( + + ), + "investigation-card-degraded": ( + + ), +}; + +export default function Story() { + return ; +} diff --git a/apps/webapp/app/routes/storybook.agent-report/route.tsx b/apps/webapp/app/routes/storybook.agent-report/route.tsx new file mode 100644 index 0000000000..7382176ab6 --- /dev/null +++ b/apps/webapp/app/routes/storybook.agent-report/route.tsx @@ -0,0 +1,32 @@ +import { DEMO_WORLD, demoFixtures, demoReportUri } from "~/components/dashboard-agent/demo"; +import { ReportView } from "~/components/dashboard-agent/ReportView"; +import { untrustworthyReport } from "../storybook.agent-ui/fixtures"; +import { fixtureResolveUri, GalleryPage, noop } from "../storybook.agent-ui/gallery"; + +const reportUri = demoReportUri(DEMO_WORLD.reportKey); + +const STATES: Record = { + "report-view-healthy": ( + + ), + "report-view-degraded": ( + + ), + "report-view-untrustworthy": ( + + ), +}; + +export default function Story() { + return ; +} diff --git a/apps/webapp/app/routes/storybook.agent-ui/fixtures.test.ts b/apps/webapp/app/routes/storybook.agent-ui/fixtures.test.ts new file mode 100644 index 0000000000..7cb3e72829 --- /dev/null +++ b/apps/webapp/app/routes/storybook.agent-ui/fixtures.test.ts @@ -0,0 +1,174 @@ +import { + safeParseStoredViewBlock, + viewBlockSchema, + watchExternalNotificationLine, +} from "@internal/dashboard-agent-contracts"; +import { ErrorId } from "@trigger.dev/core/v3/isomorphic"; +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { DEMO_MARKER } from "~/components/dashboard-agent/demo/ids"; +import { planDiagnosisActions } from "~/components/dashboard-agent/diagnosis-actions"; +import { renderableActions } from "~/components/dashboard-agent/view-actions"; +import { reportTrust } from "~/presenters/v3/reports/report-layout"; +import { + externalServiceDiagnosis, + fullDiagnosis, + lowConfidenceDiagnosis, + offerActionsBlock, + revisedDiagnosisBlocks, + untrustworthyReport, + watchConfirmationBlock, + watchDegradedConfirmationBlock, + watchSatisfiedBlock, +} from "./fixtures"; + +/** + * The gallery's hand-written fixtures, checked against the code that reads them rather + * than against themselves. A fixture that still typechecks but no longer matches what + * the product emits would otherwise render a state nobody can reach. + */ + +describe("gallery view blocks", () => { + it("parses every enveloped block through the schema the product persists with", () => { + for (const block of [ + ...revisedDiagnosisBlocks, + offerActionsBlock, + watchConfirmationBlock, + watchDegradedConfirmationBlock, + watchSatisfiedBlock, + ]) { + const result = viewBlockSchema.safeParse(block); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + } + }); + + it("parses the envelope-less diagnoses through the stored-block schema", () => { + for (const block of [fullDiagnosis, externalServiceDiagnosis, lowConfidenceDiagnosis]) { + const result = safeParseStoredViewBlock(block); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + } + }); + + /** Every stored spec is normalized, so an unprefixed fingerprint is a shape no card sees. */ + it("cites error fingerprints in the normalized form the watch service stores", () => { + for (const action of offerActionsBlock.actions) { + if (action.intent.kind !== "watch" || action.intent.spec.kind !== "error_recurrence") + continue; + const { fingerprint } = action.intent.spec; + expect(fingerprint).toBe(ErrorId.toId(fingerprint)); + } + }); + + it("offers only actions the panel would render", () => { + expect(renderableActions(offerActionsBlock.actions)).toHaveLength( + offerActionsBlock.actions.length + ); + }); + + /** + * The gallery renders outside a project route, so the card resolves no run path and drops + * the button. With a resolver both survive: the fixture's actions are still ones the card + * can plan, not ones it silently discards. + */ + it("plans every diagnosis action once its destination resolves", () => { + const planned = planDiagnosisActions(fullDiagnosis.actions ?? [], { + runPath: (runId) => `/runs/${runId}`, + docsUrl: (target) => target, + }); + expect(planned.map((action) => action.kind)).toEqual(["view_run", "docs"]); + }); +}); + +/** + * `demo.test.ts` marks the demo layer's ids. These fixtures are hand-written next to the + * route, so the same rule is asserted here: a `view_run` or `navigate` the presenter can + * resolve must land on demo data, never on somebody's environment. + */ +describe("gallery identifiers", () => { + // The digit keeps discriminants like `error_recurrence` out; ids always carry one. + const IDENTIFIER = /^(run|error|watch|queue|proj|env|deployment)_[a-z0-9]*\d|^trigger:\/\//i; + + function strings(value: unknown, path = "fixture"): Array<[string, string]> { + if (typeof value === "string") return [[value, path]]; + if (Array.isArray(value)) return value.flatMap((item, i) => strings(item, `${path}[${i}]`)); + if (value && typeof value === "object") { + return Object.entries(value).flatMap(([key, item]) => strings(item, `${path}.${key}`)); + } + return []; + } + + const fixtures = { + fullDiagnosis, + externalServiceDiagnosis, + lowConfidenceDiagnosis, + revisedDiagnosisBlocks, + offerActionsBlock, + watchConfirmationBlock, + watchDegradedConfirmationBlock, + watchSatisfiedBlock, + untrustworthyReport, + }; + + it("names no resource that isn't demo data", () => { + for (const [value, path] of strings(fixtures)) { + if (!IDENTIFIER.test(value)) continue; + expect(value, path).toContain(DEMO_MARKER); + } + }); + + it("watches only demo subjects, whatever shape their id takes", () => { + for (const action of offerActionsBlock.actions) { + if (action.intent.kind !== "watch") continue; + const subject = Object.entries(action.intent.spec).filter(([key]) => + ["queue", "runId", "fingerprint"].includes(key) + ); + expect(subject.length).toBeGreaterThan(0); + for (const [key, value] of subject) expect(String(value), key).toContain(DEMO_MARKER); + } + }); +}); + +describe("gallery watch confirmations", () => { + it("states the external outcome each confirmation claims", () => { + expect(watchConfirmationBlock.followUp).toContain( + watchExternalNotificationLine({ status: "enabled" }) + ); + expect(watchDegradedConfirmationBlock.followUp).toContain( + watchExternalNotificationLine({ + status: "unavailable", + reason: "email_alerts_not_configured", + }) + ); + }); + + it("says the first check could not run on the degraded one, and not on the other", () => { + expect(watchDegradedConfirmationBlock.detail).toBeTruthy(); + expect(watchConfirmationBlock.detail).toBeNull(); + }); +}); + +describe("gallery report", () => { + it("names an untrustworthy reason the card has a caveat for", () => { + const trust = reportTrust(untrustworthyReport); + expect(trust?.badge).toBe("stale data"); + }); +}); + +/** + * `demo/index.ts` re-exports `DemoChartCard` and `DemoIntentBubble`, so importing it here would + * pull React components into a suite that runs without a DOM. `demo.test.ts` reaches past the + * barrel for the same reason; these fixtures and their test do too. + * + * Structural: what a module drags in is not observable from inside it. + */ +describe("the gallery fixtures stay out of the demo barrel", () => { + const BARREL = /from "~\/components\/dashboard-agent\/demo"/; + + for (const file of ["fixtures.ts", "fixtures.test.ts"]) { + it(`${file} reaches past it`, () => { + const source = readFileSync(new URL(`./${file}`, import.meta.url), "utf8"); + expect(BARREL.test(source), `${file} imports the demo barrel`).toBe(false); + expect(source).toContain('"~/components/dashboard-agent/demo/'); + }); + } +}); diff --git a/apps/webapp/app/routes/storybook.agent-ui/fixtures.ts b/apps/webapp/app/routes/storybook.agent-ui/fixtures.ts new file mode 100644 index 0000000000..abb49e1826 --- /dev/null +++ b/apps/webapp/app/routes/storybook.agent-ui/fixtures.ts @@ -0,0 +1,410 @@ +import type { UIMessage } from "@ai-sdk/react"; +import { + VIEW_BLOCK_VERSION, + type DiagnosisBlock, + type InvestigationBlock, + type InvestigationCapabilities, + type ReportViewModelPayload, + type ViewBlock, + type WatchResultBlock as WatchResultBlockPayload, +} from "@internal/dashboard-agent-contracts"; +import * as demoFixtures from "~/components/dashboard-agent/demo/fixtures"; +import { DEMO_WORLD, demoId, demoRunsUri } from "~/components/dashboard-agent/demo/ids"; +import type { TurnActivity } from "~/components/dashboard-agent/DashboardAgentMessages"; +import { watchConfirmationBlockBody, watchOneShotBlockBody } from "~/presenters/v3/dashboardAgent"; +import { + queueWatchRecommendation, + runWatchRecommendation, +} from "~/components/dashboard-agent/watch-recommendations"; + +export function investigationBlock( + fixture: (typeof demoFixtures.demoInvestigations)[keyof typeof demoFixtures.demoInvestigations], + capabilities?: InvestigationCapabilities +): InvestigationBlock { + const { investigationId, revision, ...investigation } = fixture; + return { + type: "investigation", + id: investigationId, + revision, + version: VIEW_BLOCK_VERSION, + investigation, + ...(capabilities ? { capabilities } : {}), + }; +} + +const { + assistantMessage, + demoDiagnosisBlockFirstPass, + demoDiagnosisBlockRevised, + demoLegacyDiagnosisBlock, + failedToolPart, + pendingToolPart, + reasoningPart, + renderViewPart, + sourceUrlPart, + streamingTextPart, + textPart, + toolPart, + userMessage, +} = demoFixtures; + +const DEMO_WATCH_ID = demoId("watch_gallery"); + +// A stored error_recurrence spec holds the internal id, so the demo one drops its prefix. +const DEMO_FINGERPRINT = DEMO_WORLD.errorFingerprint.replace(/^error_/, ""); + +/** One transcript the message gallery renders, with the turn state it belongs to. */ +export type DemoTranscript = { + messages: UIMessage[]; + activity?: TurnActivity; + /** The turn's failure. Only the section that asks for it renders one. */ + error?: string; +}; + +export const demoTranscripts = { + streamingText: { + activity: "working", + messages: [ + userMessage("stream-q", "What's failing right now?"), + assistantMessage("stream-a", [ + toolPart("query_runs", { period: "1h" }, { failures: 41 }, "query-runs-streaming"), + streamingTextPart( + "41 runs failed in the last hour, and they're all `send-order-receipt`. The error is the same every time — a 429 from the email provider, which means" + ), + ]), + ], + }, + + reasoning: { + activity: "working", + messages: [ + userMessage("inv-q", "Why did this run fail?"), + assistantMessage("inv-step1", [ + reasoningPart( + "Start from the run itself: status, attempts, and which span failed. Don't guess at a cause before reading the error." + ), + toolPart( + "get_run_details", + { runId: DEMO_WORLD.failedRunId }, + { + runId: DEMO_WORLD.failedRunId, + status: "COMPLETED_WITH_ERROR", + attempts: 3, + error: "ProviderError: 429 Too Many Requests", + }, + "get-run-details" + ), + textPart( + `\`${DEMO_WORLD.failedRunId}\` failed three times in 19 seconds, every attempt with the same error from the email provider. Three things could produce that, so I'll test them one at a time rather than settle on the first plausible one.` + ), + ]), + ], + }, + + toolInFlight: { + activity: "working", + messages: [ + userMessage("tool-q", "Check the queue depth for me."), + assistantMessage("tool-intro", [ + textPart( + `Counting what's pending across the environment first, then pulling \`${DEMO_WORLD.queue}\` on its own so we can see whether the depth is one queue or all of them.` + ), + ]), + assistantMessage("tool-a", [ + toolPart( + "run_query", + { query: "SELECT count() FROM task_runs WHERE status = 'PENDING'" }, + { rows: [{ "count()": 4812 }] }, + "run-query-done" + ), + pendingToolPart( + "get_queue", + { queue: DEMO_WORLD.queue, period: "1h" }, + "get-queue-pending" + ), + ]), + ], + }, + + errorRetry: { + error: "The chat stopped unexpectedly. Nothing was saved for this turn.", + messages: [ + userMessage("err-q", "Chart failures by task for the last week."), + assistantMessage("err-a", [ + failedToolPart( + "run_query", + { query: "SELECT task_identifier, count() FROM task_runs", period: "7d" }, + "query timed out after 30s", + "run-query-failed" + ), + ]), + ], + }, + + // Cut before the follow-up turn: this section is about the one `render_view` part, + // which carries two revisions of a diagnosis plus an envelope-less legacy block. + renderView: { + messages: [ + userMessage("res-q", "Did this happen last month too?"), + assistantMessage("res-a", [ + textPart( + `Yes — same error, same task, three weeks ago. \`${DEMO_WORLD.taskId}\` hit the same rate limit on 6 July and it was diagnosed then too; the card below is that diagnosis, replayed from this conversation rather than re-run. The retry config hasn't changed since, which is why it came back.` + ), + renderViewPart( + [demoDiagnosisBlockFirstPass, demoDiagnosisBlockRevised, demoLegacyDiagnosisBlock], + "render-view-resumed" + ), + ]), + ], + }, + + docsSources: { + messages: [ + userMessage("docs-q", "How do retries actually work? Is the delay exponential?"), + assistantMessage("docs-a", [ + toolPart( + "search_docs", + { query: "retry configuration exponential backoff" }, + { hits: 3 }, + "search-docs" + ), + textPart( + `Yes — retries back off exponentially by default. + +- \`maxAttempts\` counts the *first* attempt, so \`3\` means one try plus two retries. +- The delay is \`minTimeoutInMs * factor^(attempt - 1)\`, capped at \`maxTimeoutInMs\`. +- \`randomize: true\` adds jitter, which is what stops a whole batch retrying in lockstep — the thing that bit \`${DEMO_WORLD.taskId}\` above.` + ), + sourceUrlPart("https://trigger.dev/docs/errors-retrying", "Errors & retrying"), + sourceUrlPart("https://trigger.dev/docs/tasks/overview", "Task options"), + ]), + ], + }, +} satisfies Record; + +/* ------------------------------------------------------------------ * + * View blocks + * ------------------------------------------------------------------ */ + +export const fullDiagnosis: DiagnosisBlock = { + type: "diagnosis", + runId: DEMO_WORLD.failedRunId, + summary: + "The run failed because processOrder threw on an order with no line items. The payload had an empty items array.", + category: "user_code_error", + likelyCause: + "processOrder calls order.items[0] without checking length, so an empty items array throws a TypeError before any work happens.", + confidence: "high", + evidence: [ + { + type: "error", + detail: "TypeError: Cannot read properties of undefined (reading 'sku')", + reference: DEMO_WORLD.failedRunId, + }, + { type: "failed_span", detail: "processOrder attempt 1 failed after 42ms" }, + { + type: "source", + detail: "The throwing line reads order.items[0].sku with no guard.", + reference: "src/trigger/processOrder.ts:18", + }, + { + type: "historical_match", + detail: "14 runs of this task hit the same error in the last 24h.", + reference: DEMO_WORLD.errorFingerprint, + }, + ], + impact: + "14 runs of process-order failed with this error in the last 24 hours, all in production.", + nextSteps: [ + "Guard against an empty items array at the top of processOrder and return early.", + "Validate the payload before triggering so empty orders never reach the task.", + ], + actions: [ + { label: "View run", kind: "view_run", target: DEMO_WORLD.failedRunId }, + { label: "Retries docs", kind: "docs", target: "https://trigger.dev/docs/errors-retrying" }, + ], +}; + +export const externalServiceDiagnosis: DiagnosisBlock = { + type: "diagnosis", + runId: DEMO_WORLD.slowRunId, + summary: "chargePayment timed out waiting on the Stripe API after 30 seconds.", + category: "external_service", + likelyCause: + "The Stripe call has no timeout or retry, so a slow upstream response runs past the task's max duration.", + confidence: "medium", + evidence: [ + { + type: "error", + detail: "TimeoutError: Stripe API timed out after 30s", + reference: DEMO_WORLD.slowRunId, + }, + { type: "deploy", detail: "First seen on version 20260620.2", reference: "20260620.2" }, + ], + impact: "Intermittent: 3 of the last 50 charge-payment runs timed out.", + nextSteps: [ + "Wrap the Stripe call in a retry with backoff.", + "Set an explicit request timeout shorter than the task's max duration.", + ], + actions: [{ label: "View run", kind: "view_run", target: DEMO_WORLD.slowRunId }], +}; + +export const lowConfidenceDiagnosis: DiagnosisBlock = { + type: "diagnosis", + runId: DEMO_WORLD.priorRunId, + summary: + "The run crashed without a captured error, so the cause isn't conclusive from the available signals.", + category: "unknown", + likelyCause: + "The container exited without writing an error. This is consistent with an out-of-memory kill, but there's no OOM signal in the trace to confirm it.", + confidence: "low", + evidence: [ + { type: "failed_span", detail: "Root span ended with status CRASHED and no error payload." }, + { type: "logs", detail: "Logs stop abruptly mid-execution with no stack trace." }, + ], + nextSteps: [ + "Re-run with a larger machine to rule out out-of-memory.", + "Add logging around the last successful step to narrow where it stops.", + ], +}; + +export const revisedDiagnosisBlocks: ViewBlock[] = [ + { + ...lowConfidenceDiagnosis, + id: `diagnosis-${DEMO_WORLD.failedRunId}`, + revision: 1, + version: VIEW_BLOCK_VERSION, + summary: "Revision 1 — first guess, before the logs came back. Should not render.", + }, + { + ...externalServiceDiagnosis, + id: `diagnosis-${DEMO_WORLD.failedRunId}`, + revision: 2, + version: VIEW_BLOCK_VERSION, + summary: "Revision 2 — narrowed to the payload, still unconfirmed. Should not render.", + }, + { + ...fullDiagnosis, + id: `diagnosis-${DEMO_WORLD.failedRunId}`, + revision: 3, + version: VIEW_BLOCK_VERSION, + summary: + "Revision 3 — the only card that should render: processOrder threw on an order with no line items.", + }, +]; + +export const offerActionsBlock: ViewBlock = { + type: "actions", + id: "actions-offer", + revision: 0, + version: VIEW_BLOCK_VERSION, + actions: [ + { + label: "Set up a watch", + intent: { + kind: "watch", + spec: { + kind: "error_recurrence", + fingerprint: DEMO_FINGERPRINT, + checkEveryMinutes: 15, + maxHours: 6, + note: "the TypeError in send-order-receipt", + }, + }, + }, + { + label: "See its failed runs", + intent: { kind: "navigate", target: demoRunsUri() }, + }, + ], +}; + +/* ------------------------------------------------------------------ * + * Watch result blocks + * ------------------------------------------------------------------ */ + +const WATCH_BLOCK_ENVELOPE = { + id: `watch:${DEMO_WATCH_ID}`, + revision: 0, + version: VIEW_BLOCK_VERSION, +} as const; + +/** Both opt-ins took effect: the happy path a submit with `notifyExternally` produces. */ +export const watchConfirmationBlock: WatchResultBlockPayload = { + ...watchConfirmationBlockBody({ + spec: queueWatchRecommendation(DEMO_WORLD.queue), + watchId: DEMO_WATCH_ID, + followUp: { investigateOnAttention: true, external: { status: "enabled" } }, + }), + ...WATCH_BLOCK_ENVELOPE, +}; + +/** + * The same submit, degraded: the creation-time check couldn't run and the email + * subscription failed. Neither fails the watch — both are said out loud instead. + */ +export const watchDegradedConfirmationBlock: WatchResultBlockPayload = { + ...watchConfirmationBlockBody({ + spec: queueWatchRecommendation(DEMO_WORLD.queue), + watchId: DEMO_WATCH_ID, + unavailable: true, + followUp: { + investigateOnAttention: true, + external: { status: "unavailable", reason: "email_alerts_not_configured" }, + }, + }), + ...WATCH_BLOCK_ENVELOPE, +}; + +export const watchSatisfiedBlock: WatchResultBlockPayload = { + ...watchOneShotBlockBody({ + spec: runWatchRecommendation(DEMO_WORLD.failedRunId), + result: "satisfied", + }), + ...WATCH_BLOCK_ENVELOPE, +}; + +/* ------------------------------------------------------------------ * + * Reports + * ------------------------------------------------------------------ */ + +/** Every number is informational: the verdict stands but the card must say why. */ +export const untrustworthyReport: ReportViewModelPayload = { + ...demoFixtures.demoDegradedReport, + summary: { + severity: "crit", + statements: [ + { findingType: "flow", severity: "crit", reason: "unknown" }, + { findingType: "execution", severity: "crit", reason: "unknown" }, + { findingType: "liveness", severity: "crit" }, + ], + }, + findings: demoFixtures.demoDegradedReport.findings.map((finding) => + finding.type === "liveness" + ? { + ...finding, + severity: "crit", + reason: "stale", + recommendation: { code: "check_control_plane", link: "status" }, + } + : { + ...finding, + severity: "crit", + reason: "unknown", + recommendation: undefined, + attribution: undefined, + exclusions: undefined, + observations: undefined, + hedge: undefined, + anomalyWindow: undefined, + } + ), + metrics: demoFixtures.demoDegradedReport.metrics.map((metric) => + metric.id === "liveness" + ? { ...metric, value: 21 * 60_000, severity: "crit" } + : { ...metric, annotation: undefined } + ), + facts: { trustworthy: false, untrustworthyReason: "telemetry_stale" }, + links: [{ key: "status", label: "status.trigger.dev", url: "https://status.trigger.dev" }], + footer: [{ code: "check_control_plane", link: "status" }], +}; diff --git a/apps/webapp/app/routes/storybook.agent-ui/gallery.tsx b/apps/webapp/app/routes/storybook.agent-ui/gallery.tsx new file mode 100644 index 0000000000..5a88c0a304 --- /dev/null +++ b/apps/webapp/app/routes/storybook.agent-ui/gallery.tsx @@ -0,0 +1,166 @@ +import { Link } from "@remix-run/react"; +import { safeParseTriggerUri } from "@internal/dashboard-agent-contracts"; +import { Header1, Header2 } from "~/components/primitives/Headers"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { cn } from "~/utils/cn"; +import { + GALLERY_PAGES, + groupsOnPage, + sectionsInGroup, + sectionsOnPage, + type GalleryPageId, + type GallerySection, +} from "./manifest"; + +export const noop = () => undefined; + +export const PANEL = "w-[380px]"; + +const CANVAS = "bg-background-bright"; + +export const PANEL_FRAME = "rounded-lg border border-border-bright bg-background-bright"; + +export function Missing({ what }: { what: string }) { + return ( +
+ No renderer for {what}. The manifest and the gallery are out of sync. +
+ ); +} + +export function fixtureResolveUri(uri: string): { label: string; url: string } | null { + const parsed = safeParseTriggerUri(uri); + if (!parsed.success) return null; + return { label: uri.split("/").slice(-1)[0]!, url: "#resolved-by-the-host" }; +} + +const WIDE_SECTIONS = new Set(["diagnosis-badge-matrix", "hero-fullscreen"]); + +function Section({ + section, + states, +}: { + section: GallerySection; + states: Record; +}) { + const state = states[section.sectionId]; + return ( +
+

{section.title}

+
+ {state ?? } +
+
+ ); +} + +function ThemeToggle() { + return ( +
+ {/* classic is still the default theme for most users, so it's in the pack */} + {(["classic", "dark", "light"] as const).map((theme) => ( + + ))} +
+ ); +} + +function PageLinks({ page }: { page: GalleryPageId }) { + return ( +
+ {GALLERY_PAGES.map((entry) => ( + + {entry.title} + + ))} +
+ ); +} + +function Nav({ page }: { page: GalleryPageId }) { + return ( + + ); +} + +export function GalleryPage({ + page, + states, +}: { + page: GalleryPageId; + states: Record; +}) { + const meta = GALLERY_PAGES.find((entry) => entry.id === page)!; + const sections = sectionsOnPage(page); + return ( +
+
+ ); +} diff --git a/apps/webapp/app/routes/storybook.agent-ui/manifest.ts b/apps/webapp/app/routes/storybook.agent-ui/manifest.ts new file mode 100644 index 0000000000..4870b53a6d --- /dev/null +++ b/apps/webapp/app/routes/storybook.agent-ui/manifest.ts @@ -0,0 +1,290 @@ +// Keep this file free of imports and JSX so plain node can read it. + +export type GalleryPageId = "chat" | "view-blocks" | "report" | "investigation" | "watch"; + +export type GalleryPage = { + id: GalleryPageId; + slug: string; + title: string; + blurb: string; +}; + +export const GALLERY_PAGES: GalleryPage[] = [ + { + id: "chat", + slug: "agent-ui", + title: "Chat UI", + blurb: + "The chat chrome: the blank-state hero, suggested prompts, the transcript and its one progress line, wake banners, watch chips and the context banner.", + }, + { + id: "view-blocks", + slug: "agent-view-blocks", + title: "View blocks", + blurb: + "The envelope rules every card obeys, the diagnosis card, the actions block and the chart card.", + }, + { + id: "report", + slug: "agent-report", + title: "Report view", + blurb: "The health report, one state per verdict it can reach.", + }, + { + id: "investigation", + slug: "agent-investigation", + title: "Investigation card", + blurb: "One card per ending an investigation can have, plus the state while it is still going.", + }, + { + id: "watch", + slug: "agent-watch", + title: "Watch card", + blurb: + "The configuration card, what a submitted card leaves in the transcript, and the wake headline.", + }, +]; + +export type GalleryGroup = + | "card" + | "diagnosis" + | "view-blocks" + | "investigation" + | "report" + | "chart" + | "watches" + | "watch-card" + | "wakes" + | "hero" + | "prompts" + | "intents" + | "messages" + | "banner"; + +export type GallerySection = { + /** DOM id and deep-link anchor. Renaming breaks the link. */ + sectionId: string; + title: string; + group: GalleryGroup; +}; + +export const GALLERY_GROUPS: { group: GalleryGroup; page: GalleryPageId; label: string }[] = [ + { group: "hero", page: "chat", label: "Blank-state hero" }, + { group: "prompts", page: "chat", label: "Suggested prompts" }, + { group: "messages", page: "chat", label: "Message-level states" }, + { group: "intents", page: "chat", label: "Intent bubbles" }, + { group: "wakes", page: "chat", label: "Wake banners" }, + { group: "watches", page: "chat", label: "Watch chips" }, + { group: "banner", page: "chat", label: "Context banner" }, + { group: "view-blocks", page: "view-blocks", label: "Envelope & actions" }, + { group: "card", page: "view-blocks", label: "Card chrome" }, + { group: "diagnosis", page: "view-blocks", label: "Diagnosis card" }, + { group: "chart", page: "view-blocks", label: "Chart card" }, + { group: "report", page: "report", label: "Report view" }, + { group: "investigation", page: "investigation", label: "Investigation card" }, + { group: "watch-card", page: "watch", label: "Watch card" }, +]; + +export const MANIFEST: GallerySection[] = [ + { sectionId: "hero-panel", title: "Side panel (380px) — no page context", group: "hero" }, + { + sectionId: "hero-panel-contextual", + title: "Side panel — failed run on the page", + group: "hero", + }, + { sectionId: "hero-fullscreen", title: "Fullscreen takeover — centred column", group: "hero" }, + { sectionId: "hero-in-chat", title: "Empty chat — hero without its own composer", group: "hero" }, + + { sectionId: "prompts-default", title: "Default set, no page context", group: "prompts" }, + { + sectionId: "prompts-contextual-fresh-failure", + title: "Contextual — fresh failure first", + group: "prompts", + }, + { sectionId: "prompts-promoted", title: "Promoted chip on top", group: "prompts" }, + { sectionId: "prompts-dismissed", title: "After a dismissal", group: "prompts" }, + + { + sectionId: "messages-streaming-text", + title: "Text part still streaming, with activity row", + group: "messages", + }, + { sectionId: "messages-reasoning", title: "Reasoning part", group: "messages" }, + { + sectionId: "messages-tool-in-flight", + title: "Tool call in flight — the turn's one progress line", + group: "messages", + }, + { + sectionId: "messages-tool-pending-pills", + title: "Progress labels — one per tool, including a card tool", + group: "messages", + }, + { + sectionId: "messages-error-retry", + title: "Failed turn — error row and retry", + group: "messages", + }, + { + sectionId: "messages-render-view", + title: "render_view part — blocks as cards", + group: "messages", + }, + { + sectionId: "messages-investigation-live", + title: "Live investigation — the card, and the turn's one progress line under it", + group: "messages", + }, + { sectionId: "messages-docs-sources", title: "Answer with source links", group: "messages" }, + + { + sectionId: "intent-navigate-filtered-runs", + title: "Navigate — runs with filters", + group: "intents", + }, + { sectionId: "intent-watch", title: "Watch started", group: "intents" }, + { + sectionId: "intent-rejected-propose-fix", + title: "Rejected — propose_fix is reserved", + group: "intents", + }, + + { sectionId: "wake-positive", title: "Positive", group: "wakes" }, + { sectionId: "wake-attention", title: "Attention", group: "wakes" }, + { sectionId: "wake-neutral-impossible", title: "Neutral — no longer possible", group: "wakes" }, + { sectionId: "wake-unverified", title: "Unverified at the window's end", group: "wakes" }, + + { sectionId: "watches-live", title: "All four states, cancellable", group: "watches" }, + + { sectionId: "banner-prod", title: "Production environment", group: "banner" }, + { sectionId: "banner-preview-long", title: "Preview branch with a long name", group: "banner" }, + + { + sectionId: "view-blocks-revisions", + title: "Three same-id revisions collapse to one card", + group: "view-blocks", + }, + { + sectionId: "view-blocks-mixed", + title: "Enveloped revisions plus a legacy block with no envelope", + group: "view-blocks", + }, + { + sectionId: "view-blocks-actions-offer", + title: "Actions block — the watch offer as buttons", + group: "view-blocks", + }, + + { sectionId: "card-compact", title: "Header plus a compact body", group: "card" }, + { sectionId: "card-roomy", title: "Header plus a roomy body", group: "card" }, + { sectionId: "card-headerless", title: "No header — body only", group: "card" }, + + { sectionId: "diagnosis-full-high", title: "Full card, high confidence", group: "diagnosis" }, + { + sectionId: "diagnosis-low-minimal", + title: "Low confidence, minimal evidence", + group: "diagnosis", + }, + { + sectionId: "diagnosis-badge-matrix", + title: "Badge matrix — every category x confidence", + group: "diagnosis", + }, + + { + sectionId: "chart-with-actions", + title: "Ranking chart with actions on the top item", + group: "chart", + }, + { sectionId: "chart-empty", title: "Empty — no data to display", group: "chart" }, + + { sectionId: "report-view-healthy", title: "Healthy — nothing to do", group: "report" }, + { + sectionId: "report-view-degraded", + title: "Degraded — env limit saturation, actions wired", + group: "report", + }, + { + sectionId: "report-view-untrustworthy", + title: "Stale telemetry — verdict unknown, numbers informational", + group: "report", + }, + + { + sectionId: "investigation-card-streaming-rev1", + title: "In progress — one hypothesis settled", + group: "investigation", + }, + { + sectionId: "investigation-card-concluded", + title: "Concluded, collapsed", + group: "investigation", + }, + { + sectionId: "investigation-card-concluded-code-grounded", + title: "Concluded, code-grounded — source citation and Show code", + group: "investigation", + }, + { + sectionId: "investigation-card-concluded-not-code-grounded", + title: "Concluded, not code-grounded — no source citation, no Show code", + group: "investigation", + }, + { + sectionId: "investigation-card-inconclusive", + title: "Inconclusive — no fix, what to check next", + group: "investigation", + }, + { + sectionId: "investigation-card-degraded", + title: "Inconclusive, degraded after a tool failure — names what it couldn't read", + group: "investigation", + }, + + { sectionId: "watch-card-compact", title: "Compact — the recommendation", group: "watch-card" }, + { sectionId: "watch-card-expanded", title: "Expanded (Customize)", group: "watch-card" }, + { sectionId: "watch-card-validation-error", title: "Validation error", group: "watch-card" }, + { sectionId: "watch-card-pending", title: "Pending create", group: "watch-card" }, + { + sectionId: "watch-card-queue-below", + title: "Customize — back below a threshold", + group: "watch-card", + }, + { + sectionId: "watch-card-queue-stalled", + title: "Customize — stopped moving (no parameter)", + group: "watch-card", + }, + { + sectionId: "watch-card-confirmation", + title: "Confirmation block — both opt-ins took effect", + group: "watch-card", + }, + { + sectionId: "watch-card-confirmation-degraded", + title: "Confirmation block — first check and email both unavailable", + group: "watch-card", + }, + { + sectionId: "watch-card-one-shot-satisfied", + title: "One-shot result — already true", + group: "watch-card", + }, + { + sectionId: "watch-card-toast-headline", + title: "Wake toast headline (fact first)", + group: "watch-card", + }, +]; + +export function groupsOnPage(page: GalleryPageId) { + return GALLERY_GROUPS.filter((entry) => entry.page === page); +} + +export function sectionsInGroup(group: GalleryGroup): GallerySection[] { + return MANIFEST.filter((section) => section.group === group); +} + +export function sectionsOnPage(page: GalleryPageId): GallerySection[] { + return groupsOnPage(page).flatMap((entry) => sectionsInGroup(entry.group)); +} diff --git a/apps/webapp/app/routes/storybook.agent-ui/route.tsx b/apps/webapp/app/routes/storybook.agent-ui/route.tsx index 24017b2b9c..5358d6b939 100644 --- a/apps/webapp/app/routes/storybook.agent-ui/route.tsx +++ b/apps/webapp/app/routes/storybook.agent-ui/route.tsx @@ -1,124 +1,337 @@ -import type { DiagnosisBlock, ViewBlock } from "@internal/dashboard-agent"; -import { ViewBlocks } from "~/components/dashboard-agent/view-catalog"; -import { Header1, Header2 } from "~/components/primitives/Headers"; -import { Paragraph } from "~/components/primitives/Paragraph"; - -// Storybook for the dashboard agent's view catalog — the blocks the agent emits -// via its render_view tool. Each example is a real block spec rendered through -// the same ViewBlocks registry the chat panel uses, at roughly panel width. - -const fullDiagnosis: DiagnosisBlock = { - type: "diagnosis", - runId: "run_a1b2c3d4e5", - summary: - "The run failed because processOrder threw on an order with no line items. The payload had an empty items array.", - category: "user_code_error", - likelyCause: - "processOrder calls order.items[0] without checking length, so an empty items array throws a TypeError before any work happens.", - confidence: "high", - evidence: [ - { - type: "error", - detail: "TypeError: Cannot read properties of undefined (reading 'sku')", - reference: "run_a1b2c3d4e5", - }, - { type: "failed_span", detail: "processOrder attempt 1 failed after 42ms" }, - { - type: "source", - detail: "The throwing line reads order.items[0].sku with no guard.", - reference: "src/trigger/processOrder.ts:18", - }, - { - type: "historical_match", - detail: "14 runs of this task hit the same error in the last 24h.", - reference: "error_emptyorder", - }, - ], - impact: - "14 runs of process-order failed with this error in the last 24 hours, all in production.", - nextSteps: [ - "Guard against an empty items array at the top of processOrder and return early.", - "Validate the payload before triggering so empty orders never reach the task.", - ], - actions: [ - { label: "View run", kind: "view_run", target: "run_a1b2c3d4e5" }, - { label: "Retries docs", kind: "docs", target: "https://trigger.dev/docs/errors-retrying" }, - ], -}; +import type { UIMessage } from "@ai-sdk/react"; +import type { AgentPageContext, SuggestedPrompt } from "@internal/dashboard-agent-contracts"; +import { useState } from "react"; +import { demoFixtures, DemoIntentBubble } from "~/components/dashboard-agent/demo"; +import { ChatProgress, ChatTranscript, ChatTurn } from "~/components/dashboard-agent/chat-layout"; +import { DashboardAgentComposer } from "~/components/dashboard-agent/DashboardAgentComposer"; +import { DashboardAgentContextBanner } from "~/components/dashboard-agent/DashboardAgentContextBanner"; +import { DashboardAgentHero } from "~/components/dashboard-agent/DashboardAgentHero"; +import { DashboardAgentMessages } from "~/components/dashboard-agent/DashboardAgentMessages"; +import { DashboardAgentSuggestedPrompts } from "~/components/dashboard-agent/DashboardAgentSuggestedPrompts"; +import { AgentPanelColumn } from "~/components/dashboard-agent/panel-layout"; +import { liveProgress } from "~/components/dashboard-agent/progress-line"; +import type { WakeWatch } from "~/components/dashboard-agent/WakeBanner"; +import { WatchChips, type WatchChip } from "~/components/dashboard-agent/WatchChips"; +import { cn } from "~/utils/cn"; +import { demoTranscripts, investigationBlock, type DemoTranscript } from "./fixtures"; +import { fixtureResolveUri, GalleryPage, noop, PANEL_FRAME } from "./gallery"; -const externalServiceDiagnosis: DiagnosisBlock = { - type: "diagnosis", - runId: "run_f6g7h8i9j0", - summary: "chargePayment timed out waiting on the Stripe API after 30 seconds.", - category: "external_service", - likelyCause: - "The Stripe call has no timeout or retry, so a slow upstream response runs past the task's max duration.", - confidence: "medium", - evidence: [ - { - type: "error", - detail: "TimeoutError: Stripe API timed out after 30s", - reference: "run_f6g7h8i9j0", - }, - { type: "deploy", detail: "First seen on version 20260620.2", reference: "20260620.2" }, - ], - impact: "Intermittent: 3 of the last 50 charge-payment runs timed out.", - nextSteps: [ - "Wrap the Stripe call in a retry with backoff.", - "Set an explicit request timeout shorter than the task's max duration.", - ], - actions: [{ label: "View run", kind: "view_run", target: "run_f6g7h8i9j0" }], -}; +const { demoIntents, demoWatches, demoPageContexts, demoInvestigations } = demoFixtures; -const lowConfidenceDiagnosis: DiagnosisBlock = { - type: "diagnosis", - runId: "run_k1l2m3n4o5", - summary: - "The run crashed without a captured error, so the cause isn't conclusive from the available signals.", - category: "unknown", - likelyCause: - "The container exited without writing an error. This is consistent with an out-of-memory kill, but there's no OOM signal in the trace to confirm it.", - confidence: "low", - evidence: [ - { type: "failed_span", detail: "Root span ended with status CRASHED and no error payload." }, - { type: "logs", detail: "Logs stop abruptly mid-execution with no stack trace." }, - ], - nextSteps: [ - "Re-run with a larger machine to rule out out-of-memory.", - "Add logging around the last successful step to narrow where it stops.", - ], -}; +function MessageHarness({ + transcript, + withError = false, +}: { + transcript: DemoTranscript; + withError?: boolean; +}) { + return ( +
+ +
+ ); +} + +const PENDING_PILL_TOOLS: { tool: string; input: unknown }[] = [ + { tool: "render_view", input: { blocks: [{ type: "diagnosis" }] } }, + { tool: "get_report", input: { window: "24h" } }, + { tool: "get_run", input: { runId: "run_demo" } }, + { tool: "run_query", input: { query: "SELECT count() FROM task_runs" } }, + { tool: "search_docs", input: { query: "concurrency limits" } }, + { tool: "brand_new_tool", input: {} }, +]; -function Example({ title, block }: { title: string; block: ViewBlock }) { +function PendingPillsHarness() { + const lines = PENDING_PILL_TOOLS.map(({ tool, input }) => ({ + tool, + progress: liveProgress( + [ + demoFixtures.assistantMessage(`pending-${tool}`, [ + demoFixtures.pendingToolPart(tool, input, `pending-${tool}`), + ]), + ], + "working" + ), + })); return ( -
- {title} -
- -
+
+ + {lines.map(({ tool, progress }) => ( + + {progress?.label} + + ))} +
); } -export default function Story() { +function PromptsHarness({ + context, + promoted, + dismissedIds = [], +}: { + context: AgentPageContext; + promoted?: SuggestedPrompt; + dismissedIds?: string[]; +}) { + const signals = + context.signals.length > 0 ? context.signals.map((s) => s.kind).join(", ") : "no signals"; return ( -
-
- Dashboard agent UI - - Blocks the dashboard agent renders via its render_view tool, shown through the same - ViewBlocks registry the chat panel uses. The catalog has the diagnosis (failure) card, - shown here, and a chart block that runs a TRQL query live (only renders inside a - project/env, so it's not shown here). Run links resolve inside a project; here they render - as plain text. - +
+

+ {context.page.kind} — {signals} +

+
+
+
+ ); +} -
- - - -
+function HeroHarness({ + context, + promoted, + fullscreen = false, + withComposer = true, +}: { + context: AgentPageContext; + promoted?: SuggestedPrompt; + fullscreen?: boolean; + withComposer?: boolean; +}) { + const [input, setInput] = useState(""); + return ( +
+ + + } + /> + ) : undefined + } + /> + +
+ ); +} + +function LiveInvestigationHarness() { + const messages: UIMessage[] = [ + demoFixtures.userMessage("live-inv-q", "Why did this run fail?"), + demoFixtures.assistantMessage("live-inv", [ + demoFixtures.renderViewPart( + [investigationBlock(demoInvestigations.streamingRev1)], + "render-live-investigation" + ), + ]), + ]; + return ( +
+ +
+ ); +} + +const promotedPrompt: SuggestedPrompt = { + id: "sp:promo-storybook", + label: "Try the new health report", + prompt: "Give me a health report for this environment.", + source: "promoted", +}; + +// Resolver-minted ids: the panel resolves its own chips, so a demo-namespaced id would +// match nothing and the state would render undismissed. +const dismissedPromptIds = demoFixtures.demoResolvedDismissedPromptIds; + +function toWatchChip(watch: (typeof demoWatches.row)[number]): WatchChip { + return { + id: watch.id, + identity: watch.identity, + status: watch.status, + kind: watch.spec.kind, + note: watch.spec.note, + checkEveryMinutes: watch.spec.checkEveryMinutes, + expiresAt: watch.expiresAt, + }; +} + +function wakeMessage(watchId: string, outcome: "fired" | "expired", text: string): UIMessage { + return { + id: `wake:watch:${watchId}:${outcome}`, + role: "assistant", + parts: [{ type: "text", text }], + }; +} + +const wakeWatches: WakeWatch[] = [ + { + id: "watch_health", + kind: "health_recovery", + note: "prod health back to normal", + identity: "health_recovery:health", + resolution: "condition_met", + observedOutcome: { kind: "health_recovery", verified: true, severity: "ok" }, + }, + { + id: "watch_error", + kind: "error_recurrence", + note: "tell me if that TypeError comes back", + identity: "error_recurrence:a1b2c3d4e5f6", + resolution: "condition_met", + observedOutcome: { kind: "error_recurrence", verified: true, countSince: 6 }, + }, + { + id: "watch_queue_gone", + kind: "backlog_drain", + note: "tell me when the email-sends backlog clears", + identity: "backlog_drain:email-sends", + resolution: "condition_impossible", + observedOutcome: { kind: "backlog_drain", verified: true, depth: null }, + }, + { + id: "watch_unverified", + kind: "backlog_drain", + note: "tell me when the email-sends backlog clears", + identity: "backlog_drain:email-sends", + resolution: "window_completed", + observedOutcome: { kind: "backlog_drain", verified: false, depth: null }, + }, +]; + +function WakeHarness({ message, watches }: { message: UIMessage; watches?: WakeWatch[] }) { + return ( +
+
); } + +const STATES: Record = { + "hero-panel": , + "hero-panel-contextual": , + "hero-fullscreen": , + "hero-in-chat": , + + "prompts-default": , + "prompts-contextual-fresh-failure": , + "prompts-promoted": ( + + ), + "prompts-dismissed": ( + + ), + + "messages-streaming-text": , + "messages-reasoning": , + "messages-tool-in-flight": , + "messages-tool-pending-pills": , + "messages-error-retry": , + "messages-render-view": , + "messages-investigation-live": , + "messages-docs-sources": , + + "intent-navigate-filtered-runs": ( + + ), + "intent-watch": , + "intent-rejected-propose-fix": ( + + ), + + "wake-positive": ( + + ), + "wake-attention": ( + + ), + "wake-neutral-impossible": ( + + ), + "wake-unverified": ( + + ), + + "watches-live": , + + "banner-prod": ( + + ), + "banner-preview-long": ( + + ), +}; + +export default function Story() { + return ; +} diff --git a/apps/webapp/app/routes/storybook.agent-view-blocks/route.tsx b/apps/webapp/app/routes/storybook.agent-view-blocks/route.tsx new file mode 100644 index 0000000000..cf6f5d8461 --- /dev/null +++ b/apps/webapp/app/routes/storybook.agent-view-blocks/route.tsx @@ -0,0 +1,124 @@ +import type { DiagnosisBlock } from "@internal/dashboard-agent"; +import { QueryResultsChart } from "~/components/code/QueryResultsChart"; +import { AGENT_CHART_PLOT_CLASS } from "~/components/dashboard-agent/AgentChart"; +import { ConfidenceBadge } from "~/components/dashboard-agent/agent-badges"; +import { + AgentCard, + AgentCardBody, + AgentCardHeader, + type AgentCardDensity, +} from "~/components/dashboard-agent/agent-card"; +import { DemoChartCard, demoFixtures } from "~/components/dashboard-agent/demo"; +import { RunDiagnosisCard } from "~/components/dashboard-agent/RunDiagnosisCard"; +import { ViewBlocks } from "~/components/dashboard-agent/view-catalog"; +import { + fullDiagnosis, + lowConfidenceDiagnosis, + offerActionsBlock, + revisedDiagnosisBlocks, +} from "../storybook.agent-ui/fixtures"; +import { GalleryPage, noop } from "../storybook.agent-ui/gallery"; + +const DIAGNOSIS_CATEGORIES: DiagnosisBlock["category"][] = [ + "user_code_error", + "configuration", + "dependency", + "timeout", + "out_of_memory", + "rate_limit", + "external_service", + "infrastructure", + "cancellation", + "unknown", +]; + +const CONFIDENCES: DiagnosisBlock["confidence"][] = ["high", "medium", "low"]; + +const badgeMatrixBlocks: DiagnosisBlock[] = DIAGNOSIS_CATEGORIES.map((category, i) => ({ + ...demoFixtures.demoDiagnosisBlockFirstPass, + category, + confidence: CONFIDENCES[i % CONFIDENCES.length]!, + evidence: [], + nextSteps: [], + actions: undefined, + impact: undefined, +})); + +function EmptyChartCard() { + return ( + + + {demoFixtures.demoChart.title} + +
+ +
+
+ ); +} + +/** The card primitive on its own: both body densities, and a card with no header. */ +function CardChrome({ density, header }: { density?: AgentCardDensity; header?: boolean }) { + return ( + + {header ? ( + + Card header + + + ) : null} + +

+ The card owns its border, surface and insets; the transcript owns where it sits. +

+

+ A second section, so the body's density is visible as the gap between them. +

+
+
+ ); +} + +const STATES: Record = { + "view-blocks-revisions": , + "view-blocks-mixed": ( + + ), + "view-blocks-actions-offer": , + + "card-compact": , + "card-roomy": , + "card-headerless": , + + "diagnosis-full-high": , + "diagnosis-low-minimal": , + "diagnosis-badge-matrix": ( +
+ {badgeMatrixBlocks.map((block, i) => ( +
+ +
+ ))} +
+ ), + + "chart-with-actions": ( + + ), + "chart-empty": , +}; + +export default function Story() { + return ; +} diff --git a/apps/webapp/app/routes/storybook.agent-watch/route.tsx b/apps/webapp/app/routes/storybook.agent-watch/route.tsx new file mode 100644 index 0000000000..9d7d62dc6a --- /dev/null +++ b/apps/webapp/app/routes/storybook.agent-watch/route.tsx @@ -0,0 +1,124 @@ +import { + watchDraftFor, + withFollowUp, + withThreshold, + withVariant, +} from "~/components/dashboard-agent/watch-card"; +import { WatchCard } from "~/components/dashboard-agent/WatchCard"; +import { + errorWatchRecommendation, + queueWatchRecommendation, + runWatchRecommendation, +} from "~/components/dashboard-agent/watch-recommendations"; +import { WatchResultBlock } from "~/components/dashboard-agent/WatchResultBlock"; +import { watchWakeToastTitle, type WatchWake } from "~/components/dashboard-agent/WatchWakeToast"; +import { cn } from "~/utils/cn"; +import { + watchConfirmationBlock, + watchDegradedConfirmationBlock, + watchSatisfiedBlock, +} from "../storybook.agent-ui/fixtures"; +import { GalleryPage, noop, PANEL_FRAME } from "../storybook.agent-ui/gallery"; + +const queueWatchDraft = watchDraftFor(queueWatchRecommendation("email-sends")); + +const runWatchDraft = withFollowUp(watchDraftFor(runWatchRecommendation("run_a1b2c3d4e5")), { + investigateOnAttention: true, +}); + +const invalidThresholdDraft = withThreshold( + withVariant(queueWatchDraft, "queue_depth_above"), + Number.NaN +); + +const queueBelowDraft = withThreshold(withVariant(queueWatchDraft, "queue_depth_below"), 100); +const queueStalledDraft = withVariant(queueWatchDraft, "queue_stalled"); + +const toastWakes: WatchWake[] = [ + { + watchId: "watch_queue", + chatId: "chat_demo", + outcome: "fired", + note: "tell me when the email-sends backlog clears", + kind: "backlog_drain", + identity: "backlog_drain:email-sends", + resolution: "condition_met", + observedOutcome: { kind: "backlog_drain", verified: true, depth: 0 }, + }, + { + watchId: "watch_run_failed", + chatId: "chat_demo", + outcome: "fired", + note: "ping me when the nightly backfill finishes", + kind: "run_finished", + identity: "run_finished:run_a1b2c3d4e5", + resolution: "condition_met", + observedOutcome: { + kind: "run_finished", + verified: true, + finalStatus: "COMPLETED_WITH_ERRORS", + durationMs: 812_000, + }, + }, +]; + +function WakeToastHeadlines({ wakes }: { wakes: WatchWake[] }) { + return ( +
+ {wakes.map((wake) => ( +
+

{wake.kind}

+

{watchWakeToastTitle(wake)}

+

{wake.note}

+
+ ))} +
+ ); +} + +const STATES: Record = { + "watch-card-compact": ( + + ), + "watch-card-expanded": ( + + ), + "watch-card-validation-error": ( + + ), + "watch-card-pending": ( + + ), + "watch-card-queue-below": ( + + ), + "watch-card-queue-stalled": ( + + ), + "watch-card-confirmation": , + "watch-card-confirmation-degraded": , + "watch-card-one-shot-satisfied": , + "watch-card-toast-headline": , +}; + +export default function Story() { + return ; +} diff --git a/apps/webapp/app/routes/storybook.toast/route.tsx b/apps/webapp/app/routes/storybook.toast/route.tsx index e5daa0dd82..fffc53621b 100644 --- a/apps/webapp/app/routes/storybook.toast/route.tsx +++ b/apps/webapp/app/routes/storybook.toast/route.tsx @@ -1,4 +1,4 @@ -import { Toaster, toast } from "sonner"; +import { toast } from "sonner"; import { Button } from "~/components/primitives/Buttons"; import { ToastUI } from "~/components/primitives/Toast"; @@ -17,6 +17,18 @@ export default function Story() { message="This is a long error message that wraps over multiple lines so we can test the UI." t="-" /> + + + Open chat + + } + />
- - + + } + /> + ), + { duration: Infinity } + ) + } + > + Trigger agent toast +
); } diff --git a/apps/webapp/app/routes/storybook/route.tsx b/apps/webapp/app/routes/storybook/route.tsx index e033f5d8ee..e423318c49 100644 --- a/apps/webapp/app/routes/storybook/route.tsx +++ b/apps/webapp/app/routes/storybook/route.tsx @@ -7,10 +7,6 @@ import { requireUser } from "~/services/session.server"; import { cn } from "~/utils/cn"; const stories: Story[] = [ - { - name: "AI agent", - slug: "ai-agent", - }, { name: "Animated panel", slug: "animated-panel", @@ -163,12 +159,31 @@ const stories: Story[] = [ name: "Usage", slug: "usage", }, - // Dashboard agent section { - sectionTitle: "Dashboard agent", - name: "Agent UI", + sectionTitle: "Trigger Agent", + name: "Chat UI", slug: "agent-ui", }, + { + name: "View blocks", + slug: "agent-view-blocks", + }, + { + name: "Report view", + slug: "agent-report", + }, + { + name: "Investigation card", + slug: "agent-investigation", + }, + { + name: "Watch card", + slug: "agent-watch", + }, + { + name: "Icons & Buttons", + slug: "ai-agent", + }, // Forms section { sectionTitle: "Forms", diff --git a/apps/webapp/app/services/dashboardAgentInvestigationSweep.server.ts b/apps/webapp/app/services/dashboardAgentInvestigationSweep.server.ts index b10ee6b06a..38853ea0dd 100644 --- a/apps/webapp/app/services/dashboardAgentInvestigationSweep.server.ts +++ b/apps/webapp/app/services/dashboardAgentInvestigationSweep.server.ts @@ -5,8 +5,11 @@ import { listStaleOpenInvestigations, + recordInvestigationSweepAttempt, settleInvestigationAndCloseCard, + settleInvestigationAsInconclusive, type Investigation, + type SettledInvestigation, type SettledInvestigationCard, } from "@internal/dashboard-agent-db"; import { UNSETTLED_INVESTIGATION_NOTE } from "@internal/dashboard-agent-contracts"; @@ -22,6 +25,13 @@ export const INVESTIGATION_STALE_MS = 30 * 60 * 1000; /** Per-run cap. Oldest first, so the rest land next run. */ const SWEEP_BATCH_LIMIT = 100; +/** + * After this many failed settle attempts a row is force-abandoned: settled `inconclusive` + * WITHOUT the closing card, so a card that never renders leaves the queue instead of + * looping forever. The rare stuck spinner is the price of not starving every other row. + */ +export const MAX_SWEEP_ATTEMPTS = 5; + export type InvestigationSweepResult = { /** Stale `in_progress` rows seen. */ stale: number; @@ -30,6 +40,8 @@ export type InvestigationSweepResult = { closed: number; /** A turn (or another sweep) settled it first. */ alreadySettled: number; + /** Rows past the attempt cap, force-settled without a card so they leave the queue. */ + abandoned: number; failed: number; }; @@ -46,6 +58,10 @@ export type InvestigationSweepDeps = { chatId: string; note: string; }) => Promise; + /** Record a failed settle out-of-band; returns the new attempt count, or null if gone. */ + recordAttempt?: (params: { id: string }) => Promise; + /** Force a poison row terminal without the failing render path. */ + forceAbandon?: (params: { id: string; note: string }) => Promise; }; /** @@ -61,12 +77,17 @@ export async function sweepDashboardAgentInvestigations( deps.listStale ?? ((params) => listStaleOpenInvestigations(dashboardAgentDb, params)); const settleAndClose = deps.settleAndClose ?? ((params) => settleInvestigationAndCloseCard(dashboardAgentDb, params)); + const recordAttempt = + deps.recordAttempt ?? ((params) => recordInvestigationSweepAttempt(dashboardAgentDb, params)); + const forceAbandon = + deps.forceAbandon ?? ((params) => settleInvestigationAsInconclusive(dashboardAgentDb, params)); const result: InvestigationSweepResult = { stale: 0, settled: 0, closed: 0, alreadySettled: 0, + abandoned: 0, failed: 0, }; @@ -93,10 +114,49 @@ export async function sweepDashboardAgentInvestigations( result.settled++; if (outcome.closed) result.closed++; } catch (error) { + // The settle rolled back, so the row is still `in_progress`. Record the attempt in + // its own write — this rotates the row to the back of the sweep order (see + // `listStaleOpenInvestigations`) so it can't pin the head and starve newer rows. + let attempts: number | null = null; + try { + attempts = await recordAttempt({ id: investigation.id }); + } catch (recordError) { + logger.error("Dashboard agent investigation sweep: failed to record a sweep attempt", { + investigationId: investigation.id, + chatId: investigation.chatId, + error: recordError, + }); + } + + // Past the cap the card will never render; force it terminal without the render + // path so it leaves the queue instead of looping forever. + if (attempts !== null && attempts >= MAX_SWEEP_ATTEMPTS) { + try { + await forceAbandon({ id: investigation.id, note: UNSETTLED_INVESTIGATION_NOTE }); + result.abandoned++; + logger.warn( + "Dashboard agent investigation sweep: abandoned a card past the attempt cap", + { + investigationId: investigation.id, + chatId: investigation.chatId, + attempts, + } + ); + continue; + } catch (abandonError) { + logger.error("Dashboard agent investigation sweep: failed to abandon a poison card", { + investigationId: investigation.id, + chatId: investigation.chatId, + error: abandonError, + }); + } + } + result.failed++; logger.error("Dashboard agent investigation sweep: failed to settle an investigation", { investigationId: investigation.id, chatId: investigation.chatId, + attempts, error, }); } diff --git a/apps/webapp/app/services/dashboardAgentMessageText.server.ts b/apps/webapp/app/services/dashboardAgentMessageText.server.ts new file mode 100644 index 0000000000..95e17499e5 --- /dev/null +++ b/apps/webapp/app/services/dashboardAgentMessageText.server.ts @@ -0,0 +1,17 @@ +declare global { + interface String { + toWellFormed(): string; + } +} + +/** Postgres jsonb rejects a lone surrogate, so one in the text fails the persist every retry. */ +export function wellFormMessageText(parts: unknown): void { + if (!Array.isArray(parts)) return; + + for (const part of parts) { + const text = (part as { text?: unknown } | null)?.text; + if (typeof text === "string") { + (part as { text: string }).text = text.toWellFormed(); + } + } +} diff --git a/apps/webapp/app/services/dashboardAgentQuota.server.ts b/apps/webapp/app/services/dashboardAgentQuota.server.ts new file mode 100644 index 0000000000..ded6250c4b --- /dev/null +++ b/apps/webapp/app/services/dashboardAgentQuota.server.ts @@ -0,0 +1,104 @@ +import type { Limits } from "@trigger.dev/platform"; +import { + getAgentMessageUsage, + incrementAgentMessageUsage, + type DashboardAgentDb, +} from "@internal/dashboard-agent-db"; +import { getCachedLimitAllowingZero } from "./platform.v3.server"; +import { logger } from "./logger.server"; + +// The repo's unlimited sentinel. Never Infinity: it serializes to null in the limit cache. +export const UNLIMITED_AGENT_MESSAGES = 100_000_000; + +// Filled by cloud billing (TRI-12863 P0). Absent until then, and always on self-hosted, +// so the fallback applies and the cap is effectively off. +const AGENT_MESSAGE_LIMIT_KEY = "agentMessages" as keyof Limits; + +/** The billing period the counter is scoped to: a UTC calendar month, "YYYY-MM". */ +export function currentAgentMessagePeriod(now: Date = new Date()): string { + return `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`; +} + +/** Pure so the send routes and, later, the MCP path share one rule. */ +export function checkAgentMessageQuota({ used, limit }: { used: number; limit: number }): { + reached: boolean; +} { + return { reached: used >= limit }; +} + +export type AgentMessageQuota = { reached: boolean; used: number; limit: number }; + +/** + * The period counter and the cached plan limit for one org. Fails open: an absent limit + * (self-hosted, or before the cloud side ships) resolves to the unlimited sentinel, and a + * counter read that throws returns `undefined` — either way there is no cap. + */ +export async function resolveAgentMessageQuota( + db: DashboardAgentDb, + params: { + organizationId: string; + now?: Date; + readLimit?: (organizationId: string) => Promise; + } +): Promise { + const readLimit = + params.readLimit ?? + (async (organizationId: string) => { + // Allowing zero: a plan that includes no messages must cap at 0, not read as absent. + // This call isn't covered directly; the limitValueAllowingZero cases in + // dashboardAgentQuota.test.ts guard the rule it depends on. + const cached = await getCachedLimitAllowingZero( + organizationId, + AGENT_MESSAGE_LIMIT_KEY, + UNLIMITED_AGENT_MESSAGES + ); + // A cache error leaves `val` empty; fall open to unlimited. + return cached.val ?? UNLIMITED_AGENT_MESSAGES; + }); + try { + const [limit, used] = await Promise.all([ + readLimit(params.organizationId), + getAgentMessageUsage(db, { + organizationId: params.organizationId, + period: currentAgentMessagePeriod(params.now), + }), + ]); + return { ...checkAgentMessageQuota({ used, limit }), used, limit }; + } catch (error) { + logger.error("Failed to resolve dashboard agent message quota", { + organizationId: params.organizationId, + error, + }); + return undefined; + } +} + +/** Record one sent user message. Swallows errors: the cap is a nudge, never a send blocker. */ +export async function recordAgentMessageSent( + db: DashboardAgentDb, + params: { organizationId: string; now?: Date } +): Promise { + try { + await incrementAgentMessageUsage(db, { + organizationId: params.organizationId, + period: currentAgentMessagePeriod(params.now), + }); + } catch (error) { + logger.error("Failed to record a dashboard agent message against the quota", { + organizationId: params.organizationId, + error, + }); + } +} + +/** + * Whether an agent turn consumes quota. Only a genuine new user message counts: the transport + * tags it `trigger: "submit-message"`. A retry/regenerate re-runs the agent from its own history + * without a new message (`trigger: "regenerate-message"`), and a wake is `"action"` — neither is + * something the user typed, so neither counts. + */ +export function agentTurnCountsAgainstQuota( + turn: { kind?: string; payload?: { trigger?: string } } | undefined +): boolean { + return turn?.kind === "message" && turn.payload?.trigger === "submit-message"; +} diff --git a/apps/webapp/app/services/dashboardAgentWatchErrorStatus.server.ts b/apps/webapp/app/services/dashboardAgentWatchErrorStatus.server.ts index de32c3595c..9e17c567a9 100644 --- a/apps/webapp/app/services/dashboardAgentWatchErrorStatus.server.ts +++ b/apps/webapp/app/services/dashboardAgentWatchErrorStatus.server.ts @@ -6,6 +6,7 @@ import type { SubmitWatchErrorCode } from "./dashboardAgentWatches.server"; */ const STATUS_BY_CODE: Record = { limit_reached: 409, + watch_limit_reached: 409, duplicate: 409, request_conflict: 409, invalid_target: 404, diff --git a/apps/webapp/app/services/dashboardAgentWatchLimits.server.ts b/apps/webapp/app/services/dashboardAgentWatchLimits.server.ts new file mode 100644 index 0000000000..4c96196ebd --- /dev/null +++ b/apps/webapp/app/services/dashboardAgentWatchLimits.server.ts @@ -0,0 +1,59 @@ +import type { Limits } from "@trigger.dev/platform"; +import { WATCH_MAX_HOURS } from "@internal/dashboard-agent-contracts"; +import { getCachedLimitAllowingZero, isBillingConfigured } from "./platform.v3.server"; + +// The unlimited sentinel, matching the message quota (TRI-12863 P1). Never Infinity: it +// serializes to null in the limit cache. +export const UNLIMITED_WATCH_LIMIT = 100_000_000; + +// Filled by cloud billing (TRI-12863 P0). Absent until then, and always on self-hosted, so +// the fallback applies and the plan floor is off. +const WATCH_MAX_HOURS_LIMIT_KEY = "agentWatchMaxHours" as keyof Limits; +const WATCH_COUNT_LIMIT_KEY = "agentWatchers" as keyof Limits; + +export type WatchPlanLimits = { + /** Longest window one watch may run for, in hours. */ + maxHours: number; + /** How many active watches the org may run at once. */ + watchers: number; +}; + +async function readLimit(organizationId: string, key: keyof Limits): Promise { + // A plan of 0 means zero, not absent: an org with watches switched off must not read as + // unlimited. Only a missing limit falls open. + const cached = await getCachedLimitAllowingZero(organizationId, key, UNLIMITED_WATCH_LIMIT); + // A cache error leaves `val` empty; fall open to unlimited. + return cached.val ?? UNLIMITED_WATCH_LIMIT; +} + +/** + * The org's plan floors for watches. Fails open: an absent limit (self-hosted, or before the + * cloud side ships) resolves to the unlimited sentinel, so neither floor bites. `read` is the + * plan-limit seam: tests pass their own reader instead of the cached platform one. + */ +export async function resolveWatchPlanLimits( + organizationId: string, + read: (organizationId: string, key: keyof Limits) => Promise = readLimit +): Promise { + const [maxHours, watchers] = await Promise.all([ + read(organizationId, WATCH_MAX_HOURS_LIMIT_KEY), + read(organizationId, WATCH_COUNT_LIMIT_KEY), + ]); + return { maxHours, watchers }; +} + +/** + * The window ceiling actually in force: the plan floor under the code ceiling. A plan that + * allows 100 hours still caps at {@link WATCH_MAX_HOURS}. + */ +export function effectiveWatchMaxHours(planMaxHours: number): number { + return Math.min(planMaxHours, WATCH_MAX_HOURS); +} + +/** + * A watch-limit refusal, plus an upgrade nudge when billing is present. Self-hosted never + * hits this (fails open above), and the nudge is gated so a stray refusal stays quiet there. + */ +export function watchLimitHint(base: string, billingConfigured = isBillingConfigured()): string { + return billingConfigured ? `${base} Upgrade your plan for more.` : base; +} diff --git a/apps/webapp/app/services/dashboardAgentWatches.server.ts b/apps/webapp/app/services/dashboardAgentWatches.server.ts index 10d0d7bf9d..06973d58e5 100644 --- a/apps/webapp/app/services/dashboardAgentWatches.server.ts +++ b/apps/webapp/app/services/dashboardAgentWatches.server.ts @@ -10,6 +10,7 @@ import { cancelWatch, chatExists, claimWatchSubmission, + countActiveWatchesForOrg, createChat, createWatch, generateWatchId, @@ -68,6 +69,12 @@ import { import { watchCreationCheckDeps } from "~/services/dashboardAgentWatchChecks.server"; import { normalizeErrorFingerprint } from "~/services/dashboardAgentWatchErrorChecks"; import { subscribeUserToWatchAlerts } from "~/services/dashboardAgentWatchAlerts.server"; +import { + effectiveWatchMaxHours, + resolveWatchPlanLimits, + watchLimitHint, + type WatchPlanLimits, +} from "~/services/dashboardAgentWatchLimits.server"; import { mintDashboardAgentWatchBatchToken, mintDashboardAgentWatchToken, @@ -165,6 +172,7 @@ export async function authorizeWatchEnvironmentById(params: { export type CreateWatchErrorCode = | "limit_reached" + | "watch_limit_reached" | "duplicate" | "invalid_target" | "chat_not_found" @@ -273,6 +281,12 @@ export async function createDashboardAgentWatch(params: { scheduleTick?: typeof scheduleWatchTick; /** Skip the real trigger-config gate when a tick scheduler is injected. */ configured?: () => boolean; + /** Plan floors on window and count. Fails open to unlimited when absent. */ + resolveLimits?: (organizationId: string) => Promise; + /** Org-wide active-watch count, for the watcher-count floor. */ + countActiveWatches?: (organizationId: string) => Promise; + /** Gates the upgrade nudge, so self-hosted stays quiet. */ + billingConfigured?: () => boolean; }; }): Promise { const { environment, userId, chatId } = params; @@ -284,6 +298,11 @@ export async function createDashboardAgentWatch(params: { const buildCheckDeps = params.deps?.checkDeps ?? watchCreationCheckDeps; const scheduleTick = params.deps?.scheduleTick ?? scheduleWatchTick; const isDashboardAgentConfigured = params.deps?.configured ?? isDashboardAgentConfiguredDefault; + const resolveLimits = params.deps?.resolveLimits ?? resolveWatchPlanLimits; + const countActiveWatches = + params.deps?.countActiveWatches ?? + ((organizationId: string) => countActiveWatchesForOrg(dashboardAgentDb, { organizationId })); + const hint = (base: string) => watchLimitHint(base, params.deps?.billingConfigured?.()); const checkDeps = buildCheckDeps(environment, now); if (!isDashboardAgentConfigured()) { @@ -331,6 +350,29 @@ export async function createDashboardAgentWatch(params: { return { ok: true, watching: false, identity, immediate }; } + // Both floors are read only now the immediate check didn't answer: a one-shot creates no + // row, so a plan floor must not turn an answerable question into an upgrade nudge. Plan + // floors sit below the code ceilings (min(plan, ceiling)) and fail open: an absent limit + // resolves to unlimited, so neither bites on self-hosted. + const planLimits = await resolveLimits(environment.organizationId); + if (spec.maxHours > effectiveWatchMaxHours(planLimits.maxHours)) { + return { + ok: false, + code: "watch_limit_reached", + error: hint("That watch window is longer than your plan allows."), + }; + } + + // The per-chat cap of 3 still applies independently, in `createWatch`. + const activeCount = await countActiveWatches(environment.organizationId); + if (activeCount >= planLimits.watchers) { + return { + ok: false, + code: "watch_limit_reached", + error: hint("You've reached the number of active watches your plan allows."), + }; + } + const expiresAt = new Date(now.getTime() + spec.maxHours * 60 * 60 * 1000); const created = await createWatch(dashboardAgentDb, { diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index f8b524e3cd..01b1c7d497 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -477,11 +477,48 @@ export function getDefaultEnvironmentLimitFromPlan( } export async function getCachedLimit(orgId: string, limit: keyof Limits, fallback: number) { + // No billing client means there is no plan limit to read, so don't touch the cache: + // an unreachable cache Redis would stall the caller for its whole reconnect cycle. + if (!client) return { val: fallback }; + return platformCache.limits.swr(`${orgId}:${limit}`, async () => { return getLimit(orgId, limit, fallback); }); } +/** + * Reads one plan limit, treating 0 as zero rather than absent: only a missing limit falls back. + * {@link getLimit} keeps its `!result` fallback, which its callers depend on. + */ +export function limitValueAllowingZero( + limits: Limits | undefined, + limit: keyof Limits, + fallback: number +): number { + const result = limits?.[limit]; + + if (result === undefined || result === null) return fallback; + if (typeof result === "number") return result; + if (typeof result === "object" && "number" in result) return result.number; + return fallback; +} + +/** + * Like {@link getCachedLimit}, but a plan value of 0 means zero. Cached under its own key so it + * never crosses with {@link getCachedLimit}. + */ +export async function getCachedLimitAllowingZero( + orgId: string, + limit: keyof Limits, + fallback: number +) { + if (!client) return { val: fallback }; + + return platformCache.limits.swr(`${orgId}:${limit}:allow-zero`, async () => + limitValueAllowingZero(await getLimits(orgId), limit, fallback) + ); +} + export async function customerPortalUrl(orgId: string, orgSlug: string) { if (!client) return undefined; diff --git a/apps/webapp/app/services/queryService.server.ts b/apps/webapp/app/services/queryService.server.ts index 986172775c..f4bf4b940a 100644 --- a/apps/webapp/app/services/queryService.server.ts +++ b/apps/webapp/app/services/queryService.server.ts @@ -397,6 +397,7 @@ export async function executeQuery( ...getDefaultClickhouseSettings(), ...queryCacheSettings, ...baseOptions.clickhouseSettings, // Allow caller overrides if needed + readonly: "1", // Not overridable: every query through here is read-only. }, querySettings: { maxRows: env.QUERY_CLICKHOUSE_MAX_RETURNED_ROWS, diff --git a/apps/webapp/test/dashboardAgentDurableResume.test.ts b/apps/webapp/test/dashboardAgentDurableResume.test.ts new file mode 100644 index 0000000000..8fd75d52e0 --- /dev/null +++ b/apps/webapp/test/dashboardAgentDurableResume.test.ts @@ -0,0 +1,347 @@ +import { + appendChatMessageOnceByChatId, + createChat, + createDashboardAgentDb, + getChatMessages, + getSession, + persistMessages, + persistTurn, + type DashboardAgentDb, + type DashboardAgentDbClient, +} from "@internal/dashboard-agent-db"; +import { postgresTest } from "@internal/testcontainers"; +import type { PrismaClient } from "@trigger.dev/database"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect } from "vitest"; + +/** + * Durability of a chat.agent turn across a crash and a resume, against a real table + * (TRI-11166). + * + * The primitive gives chat.agent durability by snapshotting the transcript and replaying it + * on the next boot. These tests pin the store seam that replay lands on: the completing turn + * re-sends its whole snapshot, so the store has to fold that replay into exactly one row per + * message — no double-appended turn, no lost mid-turn message — and reconstruct the session + * cursor a refreshed client resumes from. + * + * What is NOT covered here, because it lives inside the closed chat.agent primitive package + * (object-store snapshot write, S2 `.in`/`.out` replay, `.out` trimming, OOM restart): the + * transport-level replay and the snapshot URL's own auth. The client-side reconnect / Last- + * Event-ID replay is covered in packages/trigger-sdk/src/v3/chat.test.ts. These tests are the + * store-level backstop those depend on. See the PR body for the residual follow-ups. + */ + +let agentDb: DashboardAgentDb; +let agentDbClient: DashboardAgentDbClient | undefined; + +const MIGRATIONS = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle"); + +async function applyAgentSchema(prisma: PrismaClient) { + for (const name of readdirSync(MIGRATIONS) + .filter((file) => file.endsWith(".sql")) + .sort()) { + const sql = readFileSync(path.join(MIGRATIONS, name), "utf8"); + for (const statement of sql.split("--> statement-breakpoint")) { + const trimmed = statement.trim(); + if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed); + } + } +} + +const ORG = "org_resume"; +const USER = "user_resume"; + +async function boot(prisma: PrismaClient, connectionUri: string, chatId: string) { + await applyAgentSchema(prisma); + agentDbClient = createDashboardAgentDb(connectionUri, { max: 4 }); + agentDb = agentDbClient.db; + await createChat(agentDb, { id: chatId, organizationId: ORG, userId: USER }); +} + +afterEach(async () => { + await agentDbClient?.close(); + agentDbClient = undefined; +}); + +function textMessage(id: string, role: "user" | "assistant" = "assistant", text = id) { + return { id, role, parts: [{ type: "text", text }] }; +} + +/** A tool part, so a mid-flight call and its completed result share an id but differ in body. */ +function toolMessage(id: string, state: "input-available" | "output-available") { + return { + id, + role: "assistant" as const, + parts: [{ type: "tool-get_query_schema", state, toolCallId: `${id}_call`, input: {} }], + }; +} + +async function transcript(chatId: string): Promise<{ id: string }[]> { + return (await getChatMessages(agentDb, { chatId, organizationId: ORG, userId: USER })) as { + id: string; + }[]; +} + +/** The allocator, where a wasted/duplicated slot is observable. */ +async function nextPosition(prisma: PrismaClient, chatId: string): Promise { + const rows = await prisma.$queryRawUnsafe<{ next_message_position: number }[]>( + `select next_message_position from trigger_dashboard_agent.chats where id = $1`, + chatId + ); + return rows[0]!.next_message_position; +} + +async function rowCount(prisma: PrismaClient, chatId: string): Promise { + const rows = await prisma.$queryRawUnsafe<{ count: bigint }[]>( + `select count(*)::int as count from trigger_dashboard_agent.chat_messages where chat_id = $1`, + chatId + ); + return Number(rows[0]!.count); +} + +describe("a streamed-then-resumed turn is not double-appended", () => { + postgresTest( + "re-delivering the completing turn finalises in place and appends nothing", + async ({ prisma, postgresContainer }) => { + const chatId = "chat_no_double"; + await boot(prisma, postgresContainer.getConnectionUri(), chatId); + + // The turn started: onTurnStart stored the user turn and the tool call mid-flight. + await persistMessages(agentDb, { + chatId, + messages: [textMessage("u1", "user"), toolMessage("a1", "input-available")], + }); + expect(await rowCount(prisma, chatId)).toBe(2); + + const completing = { + chatId, + messages: [textMessage("u1", "user"), toolMessage("a1", "output-available")], + finalizeMessageIds: ["a1"], + session: { publicAccessToken: "pat", lastEventId: "7", runId: "run" }, + }; + + // The turn completes, replaying its whole snapshot. `a1` is finalised, not re-added. + await persistTurn(agentDb, completing); + // The resume: the same completed turn is delivered again (client reconnected and the + // host re-persisted). It must converge — no second `a1`, no extra row of any kind. + await persistTurn(agentDb, completing); + + expect((await transcript(chatId)).map((m) => m.id)).toEqual(["u1", "a1"]); + expect(await rowCount(prisma, chatId)).toBe(2); + // Only u1 and a1 ever reserved a slot (allocator starts at 1); the finalisation and the + // replay reserve none, so the next free position is still 3. + expect(await nextPosition(prisma, chatId)).toBe(3); + // And `a1` is the completed body the user saw, not the mid-flight call. + const stored = (await transcript(chatId))[1] as unknown as { + parts: { state: string }[]; + }; + expect(stored.parts[0]!.state).toBe("output-available"); + }, + 30_000 + ); +}); + +describe("a crash mid-turn is reconstructed by the next boot's replay", () => { + postgresTest( + "the resumed turn keeps the mid-turn append, finalises its own message, and rebuilds the session cursor", + async ({ prisma, postgresContainer }) => { + const chatId = "chat_crash_resume"; + await boot(prisma, postgresContainer.getConnectionUri(), chatId); + + // Turn in flight: the snapshot it started from, stored before the model finished. + const snapshot = [textMessage("u1", "user"), toolMessage("a1", "input-available")]; + await persistMessages(agentDb, { chatId, messages: snapshot }); + + // A wake lands mid-turn, off its own lane — the message the old replace-the-array + // write used to lose. + await appendChatMessageOnceByChatId(agentDb, { + chatId, + message: textMessage("wake:w1"), + }); + + // Before the crash there is no session row to resume from. + expect(await getSession(agentDb, { chatId, organizationId: ORG, userId: USER })).toBeNull(); + + // Boot after the crash: replay the whole transcript, finalise the turn's own message, + // and write the session the client resumes from — all in one persistTurn. + await persistTurn(agentDb, { + chatId, + messages: [ + textMessage("u1", "user"), + toolMessage("a1", "output-available"), + textMessage("a2"), + ], + finalizeMessageIds: ["a1", "a2"], + session: { publicAccessToken: "pat_resumed", lastEventId: "99", runId: "run_resumed" }, + }); + + // Nothing was lost and the wake sits where it happened: after the snapshot, before the + // reply the turn went on to produce. + expect((await transcript(chatId)).map((m) => m.id)).toEqual(["u1", "a1", "wake:w1", "a2"]); + + const session = await getSession(agentDb, { chatId, organizationId: ORG, userId: USER }); + expect(session).toMatchObject({ + publicAccessToken: "pat_resumed", + lastEventId: "99", + runId: "run_resumed", + }); + }, + 30_000 + ); +}); + +describe("the session cursor a refreshed client resumes from", () => { + postgresTest( + "getSession returns the last persisted cursor, and a later turn advances it", + async ({ prisma, postgresContainer }) => { + const chatId = "chat_cursor"; + await boot(prisma, postgresContainer.getConnectionUri(), chatId); + + await persistTurn(agentDb, { + chatId, + messages: [textMessage("u1", "user"), textMessage("a1")], + session: { publicAccessToken: "pat1", lastEventId: "10", runId: "run1" }, + }); + // A mid-stream refresh reads exactly this cursor and resumes .out from it. + expect( + (await getSession(agentDb, { chatId, organizationId: ORG, userId: USER }))?.lastEventId + ).toBe("10"); + + // The next turn overwrites the cursor — a stale value is replaced, never appended. + await persistTurn(agentDb, { + chatId, + messages: [textMessage("u1", "user"), textMessage("a1"), textMessage("a2")], + session: { publicAccessToken: "pat2", lastEventId: "25", runId: "run2" }, + }); + const session = await getSession(agentDb, { chatId, organizationId: ORG, userId: USER }); + expect(session).toMatchObject({ + publicAccessToken: "pat2", + lastEventId: "25", + runId: "run2", + }); + }, + 30_000 + ); +}); + +describe("a failed snapshot write leaves the next boot a clean replay", () => { + postgresTest( + "a persistTurn that throws mid-write rolls back what it already wrote, and the retry replays with no loss", + async ({ prisma, postgresContainer }) => { + const chatId = "chat_write_fail"; + await boot(prisma, postgresContainer.getConnectionUri(), chatId); + + // A durable first turn, its tool call still mid-flight, and the session cursor it left. + await persistTurn(agentDb, { + chatId, + messages: [textMessage("u1", "user"), toolMessage("a1", "input-available")], + session: { publicAccessToken: "pat1", lastEventId: "1", runId: "run1" }, + }); + const positionBefore = await nextPosition(prisma, chatId); + + // Tear the next turn at the INSERT itself, so the failure lands after `a1` is finalised + // in place and after the slots are reserved no matter how the store orders its up-front + // validation. A row planted directly at the position the allocator is about to hand out + // makes that insert violate `chat_messages_chat_position_key`. Scaffolding, not part of + // the transcript under test — removed once the tear has fired. + await prisma.$executeRawUnsafe( + `insert into trigger_dashboard_agent.chat_messages (chat_id, message_id, position, role, message) + values ($1, 'planted_collision', $2, 'assistant', '{}'::jsonb)`, + chatId, + positionBefore + ); + + // The driver names the failing statement, so the rejection itself pins where the tear fired. + await expect( + persistTurn(agentDb, { + chatId, + messages: [ + textMessage("u1", "user"), + toolMessage("a1", "output-available"), + textMessage("a2"), + ], + finalizeMessageIds: ["a1"], + session: { publicAccessToken: "pat_torn", lastEventId: "2", runId: "run_torn" }, + }) + ).rejects.toThrow(/Failed query: insert into .*chat_messages/); + + await prisma.$executeRawUnsafe( + `delete from trigger_dashboard_agent.chat_messages where chat_id = $1 and message_id = 'planted_collision'`, + chatId + ); + + // The whole turn rolled back. The in-place rewrite the store had already applied is undone: + // `a1` is the mid-flight call again, not the finalised body the torn turn wrote. + expect((await transcript(chatId)).map((m) => m.id)).toEqual(["u1", "a1"]); + const tornA1 = (await transcript(chatId))[1] as unknown as { parts: { state: string }[] }; + expect(tornA1.parts[0]!.state).toBe("input-available"); + expect(await rowCount(prisma, chatId)).toBe(2); + // The slot it reserved for `a2` came back too, so the retry doesn't leave a gap. + expect(await nextPosition(prisma, chatId)).toBe(positionBefore); + // The cursor is still the first turn's: the failed turn never got as far as writing one. + expect( + await getSession(agentDb, { chatId, organizationId: ORG, userId: USER }) + ).toMatchObject({ publicAccessToken: "pat1", lastEventId: "1" }); + + // The retry — a clean replay of the same turn — lands everything exactly once. + await persistTurn(agentDb, { + chatId, + messages: [ + textMessage("u1", "user"), + toolMessage("a1", "output-available"), + textMessage("a2"), + ], + finalizeMessageIds: ["a1"], + session: { publicAccessToken: "pat2", lastEventId: "2", runId: "run2" }, + }); + expect((await transcript(chatId)).map((m) => m.id)).toEqual(["u1", "a1", "a2"]); + const retriedA1 = (await transcript(chatId))[1] as unknown as { parts: { state: string }[] }; + expect(retriedA1.parts[0]!.state).toBe("output-available"); + // One new row, one new slot: the rolled-back reservation was not double-counted. + expect(await nextPosition(prisma, chatId)).toBe(positionBefore + 1); + expect( + await getSession(agentDb, { chatId, organizationId: ORG, userId: USER }) + ).toMatchObject({ publicAccessToken: "pat2", lastEventId: "2" }); + }, + 30_000 + ); +}); + +describe("an OOM restart replays the turn cleanly", () => { + postgresTest( + "a restarted turn that re-sends its snapshot loses no data and doubles nothing", + async ({ prisma, postgresContainer }) => { + // The store seam an OOM restart lands on: the primitive restarts the run, replays `.in`, + // and re-persists. `.out` trimming and the OOM restart itself are inside the primitive + // (not reachable here) — this pins that a re-run's re-sent snapshot is idempotent. + const chatId = "chat_oom_restart"; + await boot(prisma, postgresContainer.getConnectionUri(), chatId); + + const firstAttempt = [textMessage("u1", "user"), toolMessage("a1", "input-available")]; + await persistMessages(agentDb, { chatId, messages: firstAttempt }); + const positionAfterFirst = await nextPosition(prisma, chatId); + + // The run OOMs and restarts. It replays the same input, produces the same ids, and + // finalises the turn it now completes. + const restarted = { + chatId, + messages: [ + textMessage("u1", "user"), + toolMessage("a1", "output-available"), + textMessage("a2"), + ], + finalizeMessageIds: ["a1", "a2"], + session: { publicAccessToken: "pat", lastEventId: "5", runId: "run_restarted" }, + }; + await persistTurn(agentDb, restarted); + // A second restart delivering the same turn again still converges. + await persistTurn(agentDb, restarted); + + expect((await transcript(chatId)).map((m) => m.id)).toEqual(["u1", "a1", "a2"]); + // The replayed u1/a1 reserved no new slots; only a2 was genuinely new. + expect(await nextPosition(prisma, chatId)).toBe(positionAfterFirst + 1); + }, + 30_000 + ); +}); diff --git a/apps/webapp/test/dashboardAgentInvestigationPoison.test.ts b/apps/webapp/test/dashboardAgentInvestigationPoison.test.ts new file mode 100644 index 0000000000..52b87f35c1 --- /dev/null +++ b/apps/webapp/test/dashboardAgentInvestigationPoison.test.ts @@ -0,0 +1,166 @@ +import { + createChat, + createDashboardAgentDb, + getInvestigation, + settleInvestigationAndCloseCard, + upsertInvestigationRevision, + type DashboardAgentDb, + type DashboardAgentDbClient, +} from "@internal/dashboard-agent-db"; +import { + investigationStateSchema, + type InvestigationState, +} from "@internal/dashboard-agent-contracts"; +import { postgresTest } from "@internal/testcontainers"; +import type { PrismaClient } from "@trigger.dev/database"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, vi } from "vitest"; + +const ctx = vi.hoisted(() => ({ + agentDb: undefined as unknown as DashboardAgentDb, +})); + +vi.mock("~/services/dashboardAgentDb.server", () => ({ + get dashboardAgentDb() { + return ctx.agentDb; + }, +})); + +const { sweepDashboardAgentInvestigations, INVESTIGATION_STALE_MS, MAX_SWEEP_ATTEMPTS } = + await import("~/services/dashboardAgentInvestigationSweep.server"); + +async function applyAgentSchema(prisma: PrismaClient) { + const folder = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle"); + const migrations = readdirSync(folder) + .filter((file) => file.endsWith(".sql")) + .sort(); + for (const name of migrations) { + const sql = readFileSync(path.join(folder, name), "utf8"); + for (const statement of sql.split("--> statement-breakpoint")) { + const trimmed = statement.trim(); + if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed); + } + } +} + +let agentDbClient: DashboardAgentDbClient | undefined; +let prismaForRaw: PrismaClient | undefined; + +async function boot(prisma: PrismaClient, connectionUri: string) { + await applyAgentSchema(prisma); + agentDbClient = createDashboardAgentDb(connectionUri, { max: 2 }); + ctx.agentDb = agentDbClient.db; + prismaForRaw = prisma; +} + +afterEach(async () => { + await agentDbClient?.close(); + agentDbClient = undefined; +}); + +const ORG = "org_poison"; +const USER = "user_poison"; + +function openState(): InvestigationState { + return investigationStateSchema.parse({ + outcome: "in_progress", + severity: "warn", + confidence: "medium", + title: "a stuck card", + headline: "Still checking.", + progress: "Reading spans", + checkNext: [], + hypotheses: [], + evidence: [], + }); +} + +async function seedInvestigation(chatId: string, ageMs: number): Promise { + await createChat(ctx.agentDb, { id: chatId, organizationId: ORG, userId: USER }); + const created = await upsertInvestigationRevision(ctx.agentDb, { + chatId, + projectRef: "proj", + environmentRef: "env", + state: openState(), + }); + if (!created.ok) throw new Error("fixture investigation not created"); + await prismaForRaw!.$executeRawUnsafe( + `update trigger_dashboard_agent.investigations + set updated_at = now() - ($2 || ' milliseconds')::interval where id = $1`, + created.id, + String(ageMs) + ); + return created.id; +} + +async function outcomeOf(id: string): Promise { + const row = await getInvestigation(ctx.agentDb, { id }); + return row ? (row.state as { outcome?: string }).outcome : undefined; +} + +const STALE_AGE_MS = INVESTIGATION_STALE_MS + 60_000; +const OLDER_AGE_MS = STALE_AGE_MS + 60_000; + +describe("the investigation sweep with a poison row", () => { + postgresTest( + "a row that always fails to settle cannot pin the head and starve a newer row", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + + // Poison sorts first (older `updated_at`); renderable is newer. + const poisonId = await seedInvestigation("chat_poison", OLDER_AGE_MS); + const renderableId = await seedInvestigation("chat_ok", STALE_AGE_MS); + + // Only the poison row's settle throws; the renderable one goes through the real path. + const settleAndClose = (params: { id: string; chatId: string; note: string }) => { + if (params.id === poisonId) throw new Error("state isn't renderable"); + return settleInvestigationAndCloseCard(ctx.agentDb, params); + }; + + // limit 1 forces head contention: without backoff the poison row would win every run. + // A failed run throws so the job retries, but the attempt is recorded before it does. + await expect( + sweepDashboardAgentInvestigations({ limit: 1, settleAndClose }) + ).rejects.toThrow(); + expect(await outcomeOf(poisonId)).toBe("in_progress"); + expect(await outcomeOf(renderableId)).toBe("in_progress"); + + // Next run: the poison row now sorts behind the never-attempted renderable one, + // so the newer row is picked and settled despite the poison row still being stale. + const second = await sweepDashboardAgentInvestigations({ limit: 1, settleAndClose }); + expect(second).toMatchObject({ stale: 1, settled: 1, failed: 0 }); + expect(await outcomeOf(renderableId)).toBe("inconclusive"); + expect(await outcomeOf(poisonId)).toBe("in_progress"); + }, + 30_000 + ); + + postgresTest( + "after the attempt cap the poison row is abandoned and leaves the queue", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const poisonId = await seedInvestigation("chat_poison", STALE_AGE_MS); + + const settleAndClose = () => { + throw new Error("state isn't renderable"); + }; + + // The first MAX_SWEEP_ATTEMPTS-1 runs record a failed attempt and throw; the row stays stale. + for (let i = 1; i < MAX_SWEEP_ATTEMPTS; i++) { + await expect(sweepDashboardAgentInvestigations({ settleAndClose })).rejects.toThrow(); + expect(await outcomeOf(poisonId)).toBe("in_progress"); + } + + // The capped run force-settles the row without the render path, so it leaves the queue. + const capped = await sweepDashboardAgentInvestigations({ settleAndClose }); + expect(capped).toMatchObject({ stale: 1, abandoned: 1, failed: 0 }); + expect(await outcomeOf(poisonId)).toBe("inconclusive"); + + // Nothing stale remains, so the poison row is no longer swept. + const after = await sweepDashboardAgentInvestigations({ settleAndClose }); + expect(after).toMatchObject({ stale: 0 }); + }, + 30_000 + ); +}); diff --git a/apps/webapp/test/dashboardAgentMessageSurrogate.test.ts b/apps/webapp/test/dashboardAgentMessageSurrogate.test.ts new file mode 100644 index 0000000000..fc4a325a08 --- /dev/null +++ b/apps/webapp/test/dashboardAgentMessageSurrogate.test.ts @@ -0,0 +1,91 @@ +import { + createChat, + createDashboardAgentDb, + persistMessages, + type DashboardAgentDb, + type DashboardAgentDbClient, +} from "@internal/dashboard-agent-db"; +import { postgresTest } from "@internal/testcontainers"; +import type { PrismaClient } from "@trigger.dev/database"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { wellFormMessageText } from "~/services/dashboardAgentMessageText.server"; + +/** A lone surrogate in a message: normalized at ingest, and again by the store before jsonb. */ + +let agentDb: DashboardAgentDb; +let agentDbClient: DashboardAgentDbClient | undefined; + +const MIGRATIONS = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle"); + +async function applyAgentSchema(prisma: PrismaClient) { + for (const name of readdirSync(MIGRATIONS) + .filter((file) => file.endsWith(".sql")) + .sort()) { + const sql = readFileSync(path.join(MIGRATIONS, name), "utf8"); + for (const statement of sql.split("--> statement-breakpoint")) { + const trimmed = statement.trim(); + if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed); + } + } +} + +const ORG_ID = "org_surrogate"; +const USER_ID = "user_surrogate"; +const CHAT_ID = "chat_surrogate"; + +/** The high half of an emoji whose low half was lost. */ +const RAW_TEXT = "why did this run fail \ud83d"; +const NORMALIZED_TEXT = "why did this run fail \ufffd"; + +function userMessage(id: string) { + return { id, role: "user" as const, parts: [{ type: "text", text: RAW_TEXT }] }; +} + +async function boot(prisma: PrismaClient, connectionUri: string) { + await applyAgentSchema(prisma); + agentDbClient = createDashboardAgentDb(connectionUri, { max: 4 }); + agentDb = agentDbClient.db; + await createChat(agentDb, { id: CHAT_ID, organizationId: ORG_ID, userId: USER_ID }); +} + +afterEach(async () => { + await agentDbClient?.close(); + agentDbClient = undefined; +}); + +async function storedText(prisma: PrismaClient, messageId: string): Promise { + const rows = await prisma.$queryRawUnsafe<{ text: string }[]>( + `select message -> 'parts' -> 0 ->> 'text' as text + from trigger_dashboard_agent.chat_messages + where chat_id = $1 and message_id = $2`, + CHAT_ID, + messageId + ); + return rows[0]!.text; +} + +describe("a lone surrogate in a message", () => { + postgresTest( + "reaches the store raw and still persists, normalized", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + + await persistMessages(agentDb, { chatId: CHAT_ID, messages: [userMessage("raw")] }); + + expect(await storedText(prisma, "raw")).toBe(NORMALIZED_TEXT); + }, + 30_000 + ); + + // The store normalizes too, so only the parts themselves show what ingest did \u2014 and it is + // the parts the model is handed. + test("is gone from the parts ingest hands on", () => { + const message = userMessage("ingest"); + + wellFormMessageText(message.parts); + + expect(message.parts[0]!.text).toBe(NORMALIZED_TEXT); + }); +}); diff --git a/apps/webapp/test/dashboardAgentQuota.test.ts b/apps/webapp/test/dashboardAgentQuota.test.ts new file mode 100644 index 0000000000..34841e97eb --- /dev/null +++ b/apps/webapp/test/dashboardAgentQuota.test.ts @@ -0,0 +1,210 @@ +import { + createChat, + createDashboardAgentDb, + getAgentMessageUsage, + incrementAgentMessageUsage, + softDeleteChat, + type DashboardAgentDb, + type DashboardAgentDbClient, +} from "@internal/dashboard-agent-db"; +import { postgresTest } from "@internal/testcontainers"; +import type { PrismaClient } from "@trigger.dev/database"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + agentTurnCountsAgainstQuota, + checkAgentMessageQuota, + currentAgentMessagePeriod, + resolveAgentMessageQuota, + UNLIMITED_AGENT_MESSAGES, +} from "~/services/dashboardAgentQuota.server"; +import { limitValueAllowingZero } from "~/services/platform.v3.server"; + +/** + * Server-side agent message quota (TRI-12863): a per-(org, period) counter that a deleted chat + * can't lower, a pure at/over/under rule, and a resolver that fails open when the limit is + * absent (self-hosted) or the counter read throws. + */ + +const MIGRATIONS = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle"); + +async function applyAgentSchema(prisma: PrismaClient) { + for (const name of readdirSync(MIGRATIONS) + .filter((file) => file.endsWith(".sql")) + .sort()) { + const sql = readFileSync(path.join(MIGRATIONS, name), "utf8"); + for (const statement of sql.split("--> statement-breakpoint")) { + const trimmed = statement.trim(); + if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed); + } + } +} + +const ORG = "org_quota"; +const USER = "user_quota"; + +let agentDbClient: DashboardAgentDbClient | undefined; + +async function boot(prisma: PrismaClient, connectionUri: string): Promise { + await applyAgentSchema(prisma); + agentDbClient = createDashboardAgentDb(connectionUri, { max: 4 }); + return agentDbClient.db; +} + +afterEach(async () => { + await agentDbClient?.close(); + agentDbClient = undefined; +}); + +describe("checkAgentMessageQuota", () => { + it("is not reached under the limit", () => { + expect(checkAgentMessageQuota({ used: 5, limit: 20 })).toEqual({ reached: false }); + }); + + it("is reached at the limit", () => { + // Control break: `>=`. Flip to `>` and this fails. + expect(checkAgentMessageQuota({ used: 20, limit: 20 })).toEqual({ reached: true }); + }); + + it("is reached over the limit", () => { + expect(checkAgentMessageQuota({ used: 21, limit: 20 })).toEqual({ reached: true }); + }); + + it("is never reached against the unlimited sentinel", () => { + expect(checkAgentMessageQuota({ used: 10_000, limit: UNLIMITED_AGENT_MESSAGES })).toEqual({ + reached: false, + }); + }); +}); + +describe("agentTurnCountsAgainstQuota", () => { + it("counts a genuine new user message (submit-message)", () => { + expect( + agentTurnCountsAgainstQuota({ kind: "message", payload: { trigger: "submit-message" } }) + ).toBe(true); + }); + + it("does not count a retry/regenerate", () => { + // Control break: a regenerate re-runs from history with no new message, so it must not + // burn quota. Widen the rule back to `!== "action"` and this fails. + expect( + agentTurnCountsAgainstQuota({ kind: "message", payload: { trigger: "regenerate-message" } }) + ).toBe(false); + }); + + it("does not count a wake (action turn)", () => { + expect(agentTurnCountsAgainstQuota({ kind: "message", payload: { trigger: "action" } })).toBe( + false + ); + }); + + it("does not count a non-message turn or a missing body", () => { + expect(agentTurnCountsAgainstQuota({ kind: "action" })).toBe(false); + expect(agentTurnCountsAgainstQuota(undefined)).toBe(false); + }); +}); + +describe("currentAgentMessagePeriod", () => { + it("is a zero-padded UTC calendar month", () => { + expect(currentAgentMessagePeriod(new Date(Date.UTC(2026, 7, 9)))).toBe("2026-08"); + expect(currentAgentMessagePeriod(new Date(Date.UTC(2026, 0, 1)))).toBe("2026-01"); + }); +}); + +describe("the per-(org, period) counter", () => { + postgresTest( + "accumulates and a deleted chat cannot free quota within the period", + async ({ prisma, postgresContainer }) => { + const db = await boot(prisma, postgresContainer.getConnectionUri()); + const period = "2026-08"; + + // The create path and then an append: two messages, same period. + expect(await incrementAgentMessageUsage(db, { organizationId: ORG, period })).toBe(1); + expect(await incrementAgentMessageUsage(db, { organizationId: ORG, period })).toBe(2); + expect(await getAgentMessageUsage(db, { organizationId: ORG, period })).toBe(2); + + // Deleting a chat must not move the counter: it is not joined to chats. + await createChat(db, { id: "chat_del", organizationId: ORG, userId: USER }); + await softDeleteChat(db, { chatId: "chat_del", userId: USER, organizationId: ORG }); + expect(await getAgentMessageUsage(db, { organizationId: ORG, period })).toBe(2); + + // The next period and other orgs start fresh. + expect(await getAgentMessageUsage(db, { organizationId: ORG, period: "2026-09" })).toBe(0); + expect(await getAgentMessageUsage(db, { organizationId: "org_other", period })).toBe(0); + } + ); +}); + +describe("resolveAgentMessageQuota", () => { + postgresTest( + "reports reached over the limit, and never reached when unlimited", + async ({ prisma, postgresContainer }) => { + const db = await boot(prisma, postgresContainer.getConnectionUri()); + const now = new Date(); + const period = currentAgentMessagePeriod(now); + for (let i = 0; i < 3; i++) { + await incrementAgentMessageUsage(db, { organizationId: ORG, period }); + } + + expect( + await resolveAgentMessageQuota(db, { organizationId: ORG, now, readLimit: async () => 3 }) + ).toEqual({ + reached: true, + used: 3, + limit: 3, + }); + expect( + await resolveAgentMessageQuota(db, { organizationId: ORG, now, readLimit: async () => 20 }) + ).toEqual({ reached: false, used: 3, limit: 20 }); + + // Self-hosted: the limit is absent, so the fallback (unlimited sentinel) applies and there + // is no cap — no extra branching, it falls out of the fallback. + const selfHosted = await resolveAgentMessageQuota(db, { + organizationId: ORG, + now, + readLimit: async () => UNLIMITED_AGENT_MESSAGES, + }); + expect(selfHosted?.reached).toBe(false); + } + ); + + postgresTest( + "a plan allowance of zero caps immediately, it is not read as unlimited", + async ({ prisma, postgresContainer }) => { + const db = await boot(prisma, postgresContainer.getConnectionUri()); + const now = new Date(); + + expect( + await resolveAgentMessageQuota(db, { organizationId: ORG, now, readLimit: async () => 0 }) + ).toEqual({ reached: true, used: 0, limit: 0 }); + + // The read the default limit path performs. Control break: with the `!result` fallback + // of `getCachedLimit`, a plan value of 0 comes back as the unlimited sentinel. + expect( + limitValueAllowingZero( + { agentMessages: 0 } as never, + "agentMessages" as never, + UNLIMITED_AGENT_MESSAGES + ) + ).toBe(0); + expect( + limitValueAllowingZero(undefined, "agentMessages" as never, UNLIMITED_AGENT_MESSAGES) + ).toBe(UNLIMITED_AGENT_MESSAGES); + } + ); + + it("fails open when the counter read throws", async () => { + const throwingDb = { + select: () => { + throw new Error("db down"); + }, + } as unknown as DashboardAgentDb; + + const result = await resolveAgentMessageQuota(throwingDb, { + organizationId: ORG, + readLimit: async () => 5, + }); + expect(result).toBeUndefined(); + }); +}); diff --git a/apps/webapp/test/dashboardAgentSurrogatePersist.test.ts b/apps/webapp/test/dashboardAgentSurrogatePersist.test.ts new file mode 100644 index 0000000000..49fdfa6418 --- /dev/null +++ b/apps/webapp/test/dashboardAgentSurrogatePersist.test.ts @@ -0,0 +1,150 @@ +import { + appendChatMessageOnce, + createChat, + createDashboardAgentDb, + getChatMessages, + persistMessages, + persistTurn, + type DashboardAgentDb, + type DashboardAgentDbClient, +} from "@internal/dashboard-agent-db"; +import { postgresTest } from "@internal/testcontainers"; +import type { PrismaClient } from "@trigger.dev/database"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect } from "vitest"; + +/** + * jsonb rejects a lone UTF-16 surrogate, and a message body carries strings we never + * authored — tool inputs, filenames, urls — from transports that don't pass the webapp + * routes. `storeChatMessages` and `appendOneMessage` are where they have to be made storable. + */ + +let agentDb: DashboardAgentDb; +let agentDbClient: DashboardAgentDbClient | undefined; + +const MIGRATIONS = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle"); + +async function applyAgentSchema(prisma: PrismaClient) { + for (const name of readdirSync(MIGRATIONS) + .filter((file) => file.endsWith(".sql")) + .sort()) { + const sql = readFileSync(path.join(MIGRATIONS, name), "utf8"); + for (const statement of sql.split("--> statement-breakpoint")) { + const trimmed = statement.trim(); + if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed); + } + } +} + +const ORG = "org_surrogate"; +const USER = "user_surrogate"; + +afterEach(async () => { + await agentDbClient?.close(); + agentDbClient = undefined; +}); + +async function boot(prisma: PrismaClient, connectionUri: string, chatId: string) { + await applyAgentSchema(prisma); + agentDbClient = createDashboardAgentDb(connectionUri, { max: 4 }); + agentDb = agentDbClient.db; + await createChat(agentDb, { id: chatId, organizationId: ORG, userId: USER }); +} + +async function transcript(chatId: string): Promise { + return getChatMessages(agentDb, { chatId, organizationId: ORG, userId: USER }); +} + +describe("a lone surrogate anywhere in a message body is storable", () => { + postgresTest( + "persists a tool input carrying a lone surrogate", + async ({ prisma, postgresContainer }) => { + const chatId = "chat_surrogate"; + await boot(prisma, postgresContainer.getConnectionUri(), chatId); + + await persistMessages(agentDb, { + chatId, + messages: [ + { + id: "u1", + role: "user", + parts: [ + { + type: "tool-search_docs", + state: "input-available", + toolCallId: "u1_call", + input: { query: "how do i \ud83d", filename: "\udc00.png" }, + }, + ], + }, + ], + }); + + const stored = (await transcript(chatId)) as { + parts: { input: { query: string; filename: string } }[]; + }[]; + + expect(stored).toHaveLength(1); + expect(stored[0]!.parts[0]!.input.query).toBe("how do i �"); + expect(stored[0]!.parts[0]!.input.filename).toBe("�.png"); + }, + 30_000 + ); + + postgresTest( + "finalises a tool output carrying a lone surrogate", + async ({ prisma, postgresContainer }) => { + const chatId = "chat_surrogate_turn"; + await boot(prisma, postgresContainer.getConnectionUri(), chatId); + + const call = (state: string, extra: Record) => ({ + id: "a1", + role: "assistant", + parts: [{ type: "tool-search_docs", state, toolCallId: "a1_call", input: {}, ...extra }], + }); + + // Stored mid-flight by `onTurnStart`, then rewritten in place when the turn completes. + await persistMessages(agentDb, { chatId, messages: [call("input-available", {})] }); + await persistTurn(agentDb, { + chatId, + messages: [call("output-available", { output: { text: "the page says \ud83d" } })], + finalizeMessageIds: ["a1"], + session: { publicAccessToken: "pat", lastEventId: "1", runId: "run" }, + }); + + const stored = (await transcript(chatId)) as { + parts: { state: string; output: { text: string } }[]; + }[]; + + expect(stored).toHaveLength(1); + expect(stored[0]!.parts[0]!.state).toBe("output-available"); + expect(stored[0]!.parts[0]!.output.text).toBe("the page says �"); + }, + 30_000 + ); + + postgresTest( + "appends a wake message carrying a lone surrogate", + async ({ prisma, postgresContainer }) => { + const chatId = "chat_surrogate_append"; + await boot(prisma, postgresContainer.getConnectionUri(), chatId); + + const appended = await appendChatMessageOnce(agentDb, { + chatId, + userId: USER, + organizationId: ORG, + message: { + id: "w1", + role: "assistant", + parts: [{ type: "text", text: "the queue \udc00 backed up" }], + } as { id: string; role: string }, + }); + + expect(appended).toBe(true); + const stored = (await transcript(chatId)) as { parts: { text: string }[] }[]; + expect(stored[0]!.parts[0]!.text).toBe("the queue � backed up"); + }, + 30_000 + ); +}); diff --git a/apps/webapp/test/dashboardAgentTenantIsolation.test.ts b/apps/webapp/test/dashboardAgentTenantIsolation.test.ts new file mode 100644 index 0000000000..3aeb1bc16e --- /dev/null +++ b/apps/webapp/test/dashboardAgentTenantIsolation.test.ts @@ -0,0 +1,240 @@ +import { + appendChatMessageOnce, + chatExists, + countUserMessages, + createChat, + createDashboardAgentDb, + getChatMessages, + getSession, + listChats, + persistTurn, + type DashboardAgentDb, + type DashboardAgentDbClient, +} from "@internal/dashboard-agent-db"; +import { postgresTest } from "@internal/testcontainers"; +import type { PrismaClient } from "@trigger.dev/database"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect } from "vitest"; + +/** + * Cross-tenant isolation for the chat store, against a real table (TRI-11166). + * + * The 2026-06-10 chat.agent audit flagged a cross-tenant read: a chat/session belongs to + * one (org, user) pair, and every read that hands back its transcript or its session token + * has to be scoped by that pair. A chatId from another tenant must read as not-found — never + * as another tenant's transcript, and never as another tenant's public access token, which + * is the credential a resumed session boots from. + * + * The store's own queries are the floor: the resource route scopes on project.organizationId + * above this, but a bug there would still be caught here because these queries refuse a + * foreign (org, user) outright rather than trusting the caller. + */ + +let agentDb: DashboardAgentDb; +let agentDbClient: DashboardAgentDbClient | undefined; + +const MIGRATIONS = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle"); + +/** Replays every migration in order, so a new migration can't leave the suite on a stale schema. */ +async function applyAgentSchema(prisma: PrismaClient) { + for (const name of readdirSync(MIGRATIONS) + .filter((file) => file.endsWith(".sql")) + .sort()) { + const sql = readFileSync(path.join(MIGRATIONS, name), "utf8"); + for (const statement of sql.split("--> statement-breakpoint")) { + const trimmed = statement.trim(); + if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed); + } + } +} + +// Org A owns the chat. Org B and a same-org other user are the foreign tenants. +const ORG_A = "org_a"; +const USER_A = "user_a"; +const ORG_B = "org_b"; +const USER_B = "user_b"; +const CHAT = "chat_owned_by_a"; + +async function boot(prisma: PrismaClient, connectionUri: string) { + await applyAgentSchema(prisma); + agentDbClient = createDashboardAgentDb(connectionUri, { max: 4 }); + agentDb = agentDbClient.db; +} + +afterEach(async () => { + await agentDbClient?.close(); + agentDbClient = undefined; +}); + +function textMessage(id: string, role: "user" | "assistant" = "assistant") { + return { id, role, parts: [{ type: "text", text: id }] }; +} + +/** Seed a chat under org A with a transcript and a live session (its PAT is the credential). */ +async function seedOwnedChat() { + await createChat(agentDb, { id: CHAT, organizationId: ORG_A, userId: USER_A }); + await persistTurn(agentDb, { + chatId: CHAT, + messages: [textMessage("u1", "user"), textMessage("a1")], + session: { publicAccessToken: "pat_secret_of_a", lastEventId: "42", runId: "run_a" }, + }); +} + +const foreignScopes = [ + { name: "another org", organizationId: ORG_B, userId: USER_B }, + // Same org, different user: a member of A's org still isn't the chat's owner. + { name: "another user in the same org", organizationId: ORG_A, userId: USER_B }, + // Right user id, wrong org: the id alone must not carry across a tenant boundary. + { name: "the owner's user id under another org", organizationId: ORG_B, userId: USER_A }, +]; + +describe("getChatMessages is scoped to the owning (org, user)", () => { + postgresTest( + "the owner reads the transcript; every foreign tenant reads not-found", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + await seedOwnedChat(); + + const owned = await getChatMessages(agentDb, { + chatId: CHAT, + organizationId: ORG_A, + userId: USER_A, + }); + expect((owned as { id: string }[]).map((m) => m.id)).toEqual(["u1", "a1"]); + + for (const scope of foreignScopes) { + // null is not-found. It must never be [] (a visible-but-empty chat) and never A's rows. + const seen = await getChatMessages(agentDb, { + chatId: CHAT, + organizationId: scope.organizationId, + userId: scope.userId, + }); + expect(seen, scope.name).toBeNull(); + } + }, + 30_000 + ); +}); + +describe("getSession never hands a foreign tenant the owner's access token", () => { + postgresTest( + "the owner gets the session; every foreign tenant gets null", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + await seedOwnedChat(); + + const owned = await getSession(agentDb, { + chatId: CHAT, + organizationId: ORG_A, + userId: USER_A, + }); + expect(owned?.publicAccessToken).toBe("pat_secret_of_a"); + + for (const scope of foreignScopes) { + const seen = await getSession(agentDb, { + chatId: CHAT, + organizationId: scope.organizationId, + userId: scope.userId, + }); + // A leaked session row would carry A's PAT — the resume credential. Refuse outright. + expect(seen, scope.name).toBeNull(); + } + }, + 30_000 + ); +}); + +describe("chatExists is the owner check the action routes gate on", () => { + postgresTest( + "true for the owner, false for every foreign tenant", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + await seedOwnedChat(); + + expect( + await chatExists(agentDb, { chatId: CHAT, organizationId: ORG_A, userId: USER_A }) + ).toBe(true); + for (const scope of foreignScopes) { + expect( + await chatExists(agentDb, { + chatId: CHAT, + organizationId: scope.organizationId, + userId: scope.userId, + }), + scope.name + ).toBe(false); + } + }, + 30_000 + ); +}); + +describe("listChats and countUserMessages never surface another tenant's chat", () => { + postgresTest( + "a foreign tenant lists nothing and counts nothing of the owner's", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + await seedOwnedChat(); + + const ownedList = await listChats(agentDb, { organizationId: ORG_A, userId: USER_A }); + expect(ownedList.map((c) => c.id)).toEqual([CHAT]); + expect(await countUserMessages(agentDb, { organizationId: ORG_A, userId: USER_A })).toBe(1); + + for (const scope of foreignScopes) { + const list = await listChats(agentDb, { + organizationId: scope.organizationId, + userId: scope.userId, + }); + expect(list, scope.name).toEqual([]); + expect( + await countUserMessages(agentDb, { + organizationId: scope.organizationId, + userId: scope.userId, + }), + scope.name + ).toBe(0); + } + }, + 30_000 + ); +}); + +describe("a foreign org cannot append to another tenant's chat", () => { + postgresTest( + "appendChatMessageOnce with a foreign org writes nothing and leaves the transcript intact", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + await seedOwnedChat(); + + const before = await getChatMessages(agentDb, { + chatId: CHAT, + organizationId: ORG_A, + userId: USER_A, + }); + + // A chat id from another org appends nothing when the org is verified. + const wroteForeignOrg = await appendChatMessageOnce(agentDb, { + chatId: CHAT, + userId: USER_A, + organizationId: ORG_B, + message: { id: "intruder", role: "assistant" }, + }); + expect(wroteForeignOrg).toBe(false); + + // And a foreign user, same org, is refused too. + const wroteForeignUser = await appendChatMessageOnce(agentDb, { + chatId: CHAT, + userId: USER_B, + organizationId: ORG_A, + message: { id: "intruder2", role: "assistant" }, + }); + expect(wroteForeignUser).toBe(false); + + expect( + await getChatMessages(agentDb, { chatId: CHAT, organizationId: ORG_A, userId: USER_A }) + ).toEqual(before); + }, + 30_000 + ); +}); diff --git a/apps/webapp/test/dashboardAgentWatchLimitStatus.test.ts b/apps/webapp/test/dashboardAgentWatchLimitStatus.test.ts new file mode 100644 index 0000000000..a2ad2f3fd5 --- /dev/null +++ b/apps/webapp/test/dashboardAgentWatchLimitStatus.test.ts @@ -0,0 +1,163 @@ +import { + createDashboardAgentDb, + type DashboardAgentDb, + type DashboardAgentDbClient, +} from "@internal/dashboard-agent-db"; +import { postgresTest } from "@internal/testcontainers"; +import type { PrismaClient } from "@trigger.dev/database"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, vi } from "vitest"; +import type * as WatchLimitsModule from "~/services/dashboardAgentWatchLimits.server"; + +// A plan-limit refusal (`watch_limit_reached`) is a 409, not a 500. The card submit's status +// ladder must map it the same way the MCP route does, or a full org sees an "unexpected error". + +const ctx = vi.hoisted(() => ({ + prisma: undefined as unknown as PrismaClient, + agentDb: undefined as unknown as DashboardAgentDb, + userId: "", +})); + +vi.mock("~/db.server", () => { + const proxy = new Proxy( + {}, + { get: (_target, prop) => (ctx.prisma as unknown as Record)[prop as string] } + ); + return { prisma: proxy, $replica: proxy, sqlDatabaseSchema: undefined }; +}); + +vi.mock("~/services/session.server", () => ({ + requireUser: async () => ({ id: ctx.userId, admin: false, isImpersonating: false }), +})); + +vi.mock("~/v3/canAccessDashboardAgent.server", () => ({ + canAccessDashboardAgent: async () => true, +})); + +vi.mock("~/services/dashboardAgentDb.server", () => ({ + get dashboardAgentDb() { + return ctx.agentDb; + }, +})); + +// The only stub: the plan floor billing would resolve. A 1-hour window makes a 2-hour watch +// exceed the plan, so the real submit path returns `watch_limit_reached`. Everything else runs. +vi.mock("~/services/dashboardAgentWatchLimits.server", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveWatchPlanLimits: async () => ({ + maxHours: 1, + watchers: actual.UNLIMITED_WATCH_LIMIT, + }), + }; +}); + +process.env.SESSION_SECRET = "test-session-secret-for-watch-limit-status"; +// Unset, watch creation stops at `not_configured` (501) before the plan floor is read. +process.env.DASHBOARD_AGENT_SECRET_KEY = "test-dashboard-agent-secret"; + +const { action } = + await import("~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent"); + +/** Replays every migration in order, so a new migration can't leave the suite on a stale schema. */ +async function applyAgentSchema(prisma: PrismaClient) { + const folder = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle"); + for (const name of readdirSync(folder) + .filter((file) => file.endsWith(".sql")) + .sort()) { + const sql = readFileSync(path.join(folder, name), "utf8"); + for (const statement of sql.split("--> statement-breakpoint")) { + const trimmed = statement.trim(); + if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed); + } + } +} + +let agentDbClient: DashboardAgentDbClient | undefined; + +async function seed(prisma: PrismaClient) { + const slug = `limit_status_${Math.random().toString(36).slice(2, 10)}`; + const user = await prisma.user.create({ + data: { email: `${slug}@example.com`, authenticationMethod: "MAGIC_LINK" }, + }); + const organization = await prisma.organization.create({ data: { title: slug, slug } }); + await prisma.orgMember.create({ + data: { organizationId: organization.id, userId: user.id, role: "ADMIN" }, + }); + const project = await prisma.project.create({ + data: { name: slug, slug, organizationId: organization.id, externalRef: `proj_${slug}` }, + }); + await prisma.runtimeEnvironment.create({ + data: { + slug: "prod", + type: "PRODUCTION", + projectId: project.id, + organizationId: organization.id, + apiKey: `tr_prod_${slug}`, + pkApiKey: `pk_prod_${slug}`, + shortcode: `p${slug.slice(0, 6)}`, + }, + }); + ctx.userId = user.id; + return { user, organization, project }; +} + +// error_recurrence resolves its target with no run/queue read, so the plan floor is the only +// thing standing between a valid submit and a created watch. +const DRAFT = JSON.stringify({ + spec: { + kind: "error_recurrence", + fingerprint: "a1b2c3", + checkEveryMinutes: 5, + maxHours: 2, + note: "ping me if it happens again", + }, + followUp: { investigateOnAttention: false, notifyExternally: false }, +}); + +function submitRequest(slug: string, body: Record) { + const form = new URLSearchParams(body); + return action({ + request: new Request( + `https://app.trigger.dev/resources/orgs/${slug}/projects/${slug}/env/prod/dashboard-agent`, + { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: form.toString(), + } + ), + params: { organizationSlug: slug, projectParam: slug, envParam: "prod" }, + context: {}, + } as never) as Promise; +} + +afterEach(async () => { + await agentDbClient?.close(); + agentDbClient = undefined; +}); + +describe("the watch card submit's status for a plan-limit refusal", () => { + postgresTest( + "answers 409, not 500, when the window is longer than the plan allows", + async ({ prisma, postgresContainer }) => { + ctx.prisma = prisma; + await applyAgentSchema(prisma); + agentDbClient = createDashboardAgentDb(postgresContainer.getConnectionUri(), { max: 4 }); + ctx.agentDb = agentDbClient.db; + + const seeded = await seed(prisma); + + const response = await submitRequest(seeded.organization.slug, { + intent: "watch-create", + draft: DRAFT, + clientRequestId: "wreq_limit_1", + }); + + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ code: "watch_limit_reached" }); + }, + 30_000 + ); +}); diff --git a/apps/webapp/test/dashboardAgentWatchLimits.test.ts b/apps/webapp/test/dashboardAgentWatchLimits.test.ts new file mode 100644 index 0000000000..7bd8f4ee22 --- /dev/null +++ b/apps/webapp/test/dashboardAgentWatchLimits.test.ts @@ -0,0 +1,411 @@ +import { + countActiveWatchesForOrg, + createChat, + createDashboardAgentDb, + listActiveWatchesForChat, + type DashboardAgentDb, + type DashboardAgentDbClient, +} from "@internal/dashboard-agent-db"; +import type { WatchSpec } from "@internal/dashboard-agent-contracts"; +import type * as TriggerSdk from "@trigger.dev/sdk"; +import { postgresTest } from "@internal/testcontainers"; +import type { PrismaClient } from "@trigger.dev/database"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { WatchCheckDeps, WatchRunRow } from "~/services/dashboardAgentWatchChecks"; +import type { WatchPlanLimits } from "~/services/dashboardAgentWatchLimits.server"; + +vi.setConfig({ testTimeout: 60_000 }); + +const ctx = vi.hoisted(() => ({ + prisma: undefined as unknown as PrismaClient, + agentDb: undefined as unknown as DashboardAgentDb, +})); + +vi.mock("~/db.server", () => { + const proxy = new Proxy( + {}, + { get: (_target, prop) => (ctx.prisma as unknown as Record)[prop as string] } + ); + return { prisma: proxy, $replica: proxy, sqlDatabaseSchema: undefined }; +}); + +vi.mock("~/services/dashboardAgentDb.server", () => ({ + get dashboardAgentDb() { + return ctx.agentDb; + }, +})); + +vi.mock("@trigger.dev/sdk", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + TriggerClient: class { + tasks = { trigger: async () => ({ id: "run_test" }) }; + }, + }; +}); + +process.env.SESSION_SECRET = "test-session-secret-for-watch-limits"; + +const { createDashboardAgentWatch } = await import("~/services/dashboardAgentWatches.server"); +const { effectiveWatchMaxHours, resolveWatchPlanLimits, watchLimitHint, UNLIMITED_WATCH_LIMIT } = + await import("~/services/dashboardAgentWatchLimits.server"); +const { limitValueAllowingZero } = await import("~/services/platform.v3.server"); + +async function applyAgentSchema(prisma: PrismaClient) { + const folder = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle"); + const migrations = readdirSync(folder) + .filter((file) => file.endsWith(".sql")) + .sort(); + for (const name of migrations) { + const sql = readFileSync(path.join(folder, name), "utf8"); + for (const statement of sql.split("--> statement-breakpoint")) { + const trimmed = statement.trim(); + if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed); + } + } +} + +let agentDbClient: DashboardAgentDbClient | undefined; + +async function boot(prisma: PrismaClient, connectionUri: string) { + ctx.prisma = prisma; + await applyAgentSchema(prisma); + agentDbClient = createDashboardAgentDb(connectionUri, { max: 4 }); + ctx.agentDb = agentDbClient.db; +} + +async function seed(prisma: PrismaClient, slugBase: string) { + const slug = `${slugBase}_${Math.random().toString(36).slice(2, 10)}`; + const user = await prisma.user.create({ + data: { email: `${slug}@example.com`, authenticationMethod: "MAGIC_LINK" }, + }); + const organization = await prisma.organization.create({ data: { title: slug, slug } }); + await prisma.orgMember.create({ + data: { organizationId: organization.id, userId: user.id, role: "ADMIN" }, + }); + const project = await prisma.project.create({ + data: { name: slug, slug, organizationId: organization.id, externalRef: `proj_${slug}` }, + }); + const environment = await prisma.runtimeEnvironment.create({ + data: { + slug: "prod", + type: "PRODUCTION", + projectId: project.id, + organizationId: organization.id, + apiKey: `tr_prod_${slug}`, + pkApiKey: `pk_prod_${slug}`, + shortcode: `p${slug.slice(0, 6)}`, + }, + }); + return { user, organization, project, environment }; +} + +type Seeded = Awaited>; + +function authenticated(seeded: Seeded) { + return { + id: seeded.environment.id, + organizationId: seeded.organization.id, + projectId: seeded.project.id, + slug: "prod", + type: "PRODUCTION", + project: { id: seeded.project.id, externalRef: seeded.project.externalRef }, + organization: { id: seeded.organization.id, slug: seeded.organization.slug }, + } as any; +} + +async function seedChat(seeded: Seeded, chatId: string) { + await createChat(ctx.agentDb, { + id: chatId, + organizationId: seeded.organization.id, + userId: seeded.user.id, + }); + return chatId; +} + +function runRow(overrides: Partial = {}): WatchRunRow { + return { + friendlyId: "run_1", + status: "PENDING", + queue: "task/my-task", + createdAt: new Date(), + queuedAt: null, + startedAt: null, + completedAt: null, + delayUntil: null, + ...overrides, + }; +} + +function fakeCheckDeps(overrides: Partial = {}): WatchCheckDeps { + return { + readRun: async () => runRow(), + queueExists: async () => true, + readQueueDepth: async () => ({ depth: 7, source: "live_queue", current: true }), + readQueueOldestAge: async () => ({ ageMs: 30_000, source: "live_queue", current: true }), + readErrorRecurrence: async () => null, + readHealth: async () => ({ trustworthy: true, severity: "warn" }), + ...overrides, + }; +} + +const UNLIMITED: WatchPlanLimits = { + maxHours: UNLIMITED_WATCH_LIMIT, + watchers: UNLIMITED_WATCH_LIMIT, +}; + +function runStart(runId: string, maxHours = 2): WatchSpec { + return { kind: "run_start", runId, checkEveryMinutes: 1, maxHours, note: "tell me" }; +} + +function create(args: { + seeded: Seeded; + spec: WatchSpec; + chatId: string; + limits?: WatchPlanLimits; + billingConfigured?: boolean; + countActiveWatches?: (organizationId: string) => Promise; + checkDeps?: Partial; +}) { + return createDashboardAgentWatch({ + environment: authenticated(args.seeded), + userId: args.seeded.user.id, + chatId: args.chatId, + spec: args.spec, + deps: { + configured: () => true, + checkDeps: () => fakeCheckDeps(args.checkDeps), + scheduleTick: async () => {}, + resolveLimits: async () => args.limits ?? UNLIMITED, + ...(args.countActiveWatches ? { countActiveWatches: args.countActiveWatches } : {}), + ...(args.billingConfigured === undefined + ? {} + : { billingConfigured: () => args.billingConfigured! }), + }, + }); +} + +afterEach(async () => { + await agentDbClient?.close(); + agentDbClient = undefined; +}); + +describe("watch plan limits (pure)", () => { + it("caps the window ceiling at the code ceiling of 24 hours", () => { + expect(effectiveWatchMaxHours(100)).toBe(24); + expect(effectiveWatchMaxHours(1)).toBe(1); + expect(effectiveWatchMaxHours(0.5)).toBe(0.5); + }); + + it("reads a plan limit of zero as zero, not as an absent limit", async () => { + // The read the cached platform limit performs: a plan that switched watches off must not + // fall back to the unlimited sentinel. + expect( + limitValueAllowingZero( + { agentWatchMaxHours: 0 } as never, + "agentWatchMaxHours" as never, + UNLIMITED_WATCH_LIMIT + ) + ).toBe(0); + expect( + limitValueAllowingZero(undefined, "agentWatchMaxHours" as never, UNLIMITED_WATCH_LIMIT) + ).toBe(UNLIMITED_WATCH_LIMIT); + + expect(await resolveWatchPlanLimits("org_1", async () => 0)).toEqual({ + maxHours: 0, + watchers: 0, + }); + }); + + it("adds an upgrade nudge only when billing is configured", () => { + expect(watchLimitHint("too long.", true)).toBe("too long. Upgrade your plan for more."); + expect(watchLimitHint("too long.", false)).toBe("too long."); + }); +}); + +describe("createDashboardAgentWatch plan enforcement", () => { + postgresTest( + "refuses a window longer than the plan allows", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "window"); + await seedChat(seeded, "chat_1"); + + const result = await create({ + seeded, + chatId: "chat_1", + spec: runStart("run_1", 2), + limits: { maxHours: 1, watchers: UNLIMITED_WATCH_LIMIT }, + billingConfigured: true, + }); + + expect(result).toMatchObject({ ok: false, code: "watch_limit_reached" }); + if (result.ok) return; + expect(result.error).toContain("Upgrade your plan"); + expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toHaveLength(0); + } + ); + + postgresTest( + "creates a watch whose window is within the plan", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "within"); + await seedChat(seeded, "chat_1"); + + const result = await create({ + seeded, + chatId: "chat_1", + spec: runStart("run_1", 1), + limits: { maxHours: 1, watchers: UNLIMITED_WATCH_LIMIT }, + }); + + expect(result.ok).toBe(true); + expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toHaveLength(1); + } + ); + + postgresTest( + "refuses once the org is at its watcher count, counting active watches for real", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "count"); + await seedChat(seeded, "chat_1"); + await seedChat(seeded, "chat_2"); + + const limits: WatchPlanLimits = { maxHours: UNLIMITED_WATCH_LIMIT, watchers: 1 }; + + const first = await create({ seeded, chatId: "chat_1", spec: runStart("run_1"), limits }); + expect(first.ok).toBe(true); + expect( + await countActiveWatchesForOrg(ctx.agentDb, { organizationId: seeded.organization.id }) + ).toBe(1); + + const second = await create({ seeded, chatId: "chat_2", spec: runStart("run_2"), limits }); + expect(second).toMatchObject({ ok: false, code: "watch_limit_reached" }); + expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_2" })).toHaveLength(0); + } + ); + + postgresTest( + "fails open: an absent limit resolves to unlimited and a 2h watch is created", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "failopen"); + await seedChat(seeded, "chat_1"); + + const result = await create({ + seeded, + chatId: "chat_1", + spec: runStart("run_1", 2), + limits: UNLIMITED, + }); + + expect(result.ok).toBe(true); + expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toHaveLength(1); + } + ); + + postgresTest( + "leaves no upgrade nudge on a refusal when billing is unconfigured", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "selfhosted"); + await seedChat(seeded, "chat_1"); + + const result = await create({ + seeded, + chatId: "chat_1", + spec: runStart("run_1", 2), + limits: { maxHours: 1, watchers: UNLIMITED_WATCH_LIMIT }, + billingConfigured: false, + }); + + expect(result).toMatchObject({ ok: false, code: "watch_limit_reached" }); + if (result.ok) return; + expect(result.error).not.toContain("Upgrade"); + } + ); + + postgresTest( + "a plan window of zero hours refuses every watch", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "zerohours"); + await seedChat(seeded, "chat_1"); + + const result = await create({ + seeded, + chatId: "chat_1", + spec: runStart("run_1", 1), + limits: { maxHours: 0, watchers: UNLIMITED_WATCH_LIMIT }, + }); + + expect(result).toMatchObject({ ok: false, code: "watch_limit_reached" }); + expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toHaveLength(0); + } + ); + + postgresTest( + "a plan of zero watchers refuses creation", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "zerowatchers"); + await seedChat(seeded, "chat_1"); + + const result = await create({ + seeded, + chatId: "chat_1", + spec: runStart("run_1", 1), + limits: { maxHours: UNLIMITED_WATCH_LIMIT, watchers: 0 }, + }); + + expect(result).toMatchObject({ ok: false, code: "watch_limit_reached" }); + expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toHaveLength(0); + } + ); + + postgresTest( + "answers a condition that already happened, instead of refusing the window", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "instant"); + await seedChat(seeded, "chat_1"); + + const result = await create({ + seeded, + chatId: "chat_1", + spec: runStart("run_1", 2), + limits: { maxHours: 1, watchers: 0 }, + billingConfigured: true, + checkDeps: { + readRun: async () => runRow({ status: "EXECUTING", startedAt: new Date() }), + }, + }); + + expect(result).toMatchObject({ ok: true, watching: false }); + expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toHaveLength(0); + } + ); + + postgresTest( + "min semantics: a plan of 100 hours still permits only up to the 24h ceiling", + async ({ prisma, postgresContainer }) => { + await boot(prisma, postgresContainer.getConnectionUri()); + const seeded = await seed(prisma, "minsem"); + await seedChat(seeded, "chat_1"); + + const created = await create({ + seeded, + chatId: "chat_1", + spec: runStart("run_1", 24), + limits: { maxHours: 100, watchers: UNLIMITED_WATCH_LIMIT }, + }); + expect(created.ok).toBe(true); + expect(await listActiveWatchesForChat(ctx.agentDb, { chatId: "chat_1" })).toHaveLength(1); + } + ); +}); diff --git a/apps/webapp/test/queryRouteReadOnly.test.ts b/apps/webapp/test/queryRouteReadOnly.test.ts new file mode 100644 index 0000000000..5cd08d65e6 --- /dev/null +++ b/apps/webapp/test/queryRouteReadOnly.test.ts @@ -0,0 +1,181 @@ +import { generateJWT } from "@trigger.dev/core/v3/jwt"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * The query API is read-only, and the grammar is what enforces it. A parser test alone would + * stay green if the route ever compiled agent SQL somewhere else, so these drive the real route + * with a real signed environment JWT and stub only the ClickHouse client. A write must be + * refused before anything reaches ClickHouse. + */ + +const ENVIRONMENT_ID = "env_1234"; +const API_KEY = "tr_dev_abcdefghijklmnop"; + +const environment = { + id: ENVIRONMENT_ID, + type: "DEVELOPMENT", + slug: "dev", + branchName: null, + apiKey: API_KEY, + organizationId: "org_1", + projectId: "proj_1", + archivedAt: null, + concurrencyLimitBurstFactor: { toNumber: () => 1 }, + maximumConcurrencyLimit: 10, + project: { id: "proj_1", externalRef: "proj_ref", deletedAt: null }, + organization: { id: "org_1" }, + orgMember: null, + parentEnvironment: null, +}; + +const mocks = vi.hoisted(() => ({ + runtimeEnvironmentFindFirst: vi.fn(), + queryWithStats: vi.fn(), + customerQueryCreate: vi.fn(), + concurrencyAcquire: vi.fn(), +})); + +vi.mock("~/db.server", () => { + const client = { + runtimeEnvironment: { + findFirst: mocks.runtimeEnvironmentFindFirst, + findMany: async () => [], + }, + revokedApiKey: { findMany: async () => [], findFirst: async () => null }, + project: { findMany: async () => [] }, + customerQuery: { findFirst: async () => null, create: mocks.customerQueryCreate }, + }; + return { prisma: client, $replica: client }; +}); +vi.mock("~/env.server", () => ({ + env: { + SESSION_SECRET: "test-session-secret", + QUERY_CLICKHOUSE_MAX_EXECUTION_TIME: "30", + QUERY_CLICKHOUSE_MAX_MEMORY_USAGE: 1000000, + QUERY_CLICKHOUSE_MAX_AST_ELEMENTS: 50000, + QUERY_CLICKHOUSE_MAX_EXPANDED_AST_ELEMENTS: 500000, + QUERY_CLICKHOUSE_MAX_BYTES_BEFORE_EXTERNAL_GROUP_BY: 1000000, + QUERY_CLICKHOUSE_MAX_RETURNED_ROWS: 1000, + }, +})); +vi.mock("~/services/clickhouse/clickhouseFactoryInstance.server", () => ({ + clickhouseFactory: { + getClickhouseForOrganization: async () => ({ + reader: { queryWithStats: mocks.queryWithStats }, + }), + }, +})); +vi.mock("~/services/platform.v3.server", () => ({ getLimit: async () => 30 })); +vi.mock("~/services/queryConcurrencyLimiter.server", () => ({ + queryConcurrencyLimiter: { + acquire: mocks.concurrencyAcquire, + release: async () => {}, + }, + DEFAULT_ORG_CONCURRENCY_LIMIT: 10, + GLOBAL_CONCURRENCY_LIMIT: 100, +})); +vi.mock("~/services/logger.server", () => ({ + logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() }, +})); +vi.mock("~/v3/services/worker/workerGroupTokenService.server", () => ({ + WorkerGroupTokenService: class {}, +})); +vi.mock("~/v3/services/common.server", () => ({ ServiceValidationError: class extends Error {} })); +vi.mock("@internal/run-engine", () => ({ EngineServiceValidationError: class extends Error {} })); + +import { action } from "~/routes/api.v1.query"; +import { executeQuery } from "~/services/queryService.server"; + +/** The claims the env-JWT exchange mints (api.v1.projects.$projectRef.$env.jwt.ts). */ +function mintEnvJwt(scopes: string[]) { + return generateJWT({ + secretKey: API_KEY, + payload: { + sub: ENVIRONMENT_ID, + pub: true, + scopes, + act: { sub: "usr_1", client: "dashboard-agent" }, + }, + expirationTime: "1h", + }); +} + +async function runQuery(query: string): Promise<{ status: number; body: any }> { + const jwt = await mintEnvJwt(["read:query"]); + const response = await action({ + request: new Request("https://api.trigger.dev/api/v1/query", { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify({ query }), + }), + params: {}, + context: {}, + } as any); + return { status: response.status, body: await response.json() }; +} + +describe("the query API route", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.runtimeEnvironmentFindFirst.mockResolvedValue(environment); + mocks.customerQueryCreate.mockResolvedValue({ id: "cq_1" }); + mocks.concurrencyAcquire.mockResolvedValue({ success: true }); + mocks.queryWithStats.mockReturnValue(async () => [null, { rows: [], stats: {} }]); + }); + + // Pins the seam the two refusals assert against: a read really does reach ClickHouse here, + // so `not.toHaveBeenCalled()` below means refused, not unreachable. + it("runs a read against ClickHouse", async () => { + const result = await runQuery("SELECT count() FROM runs"); + + expect(result.status).toBe(200); + expect(mocks.queryWithStats).toHaveBeenCalled(); + }); + + it("refuses a write smuggled in as a second statement", async () => { + const result = await runQuery("SELECT 1 FROM runs; DROP TABLE runs"); + + expect(result.status).toBe(400); + expect(mocks.queryWithStats).not.toHaveBeenCalled(); + }); + + it("refuses a mutating statement", async () => { + const result = await runQuery("INSERT INTO runs (task_identifier) VALUES ('x')"); + + expect(result.status).toBe(400); + expect(mocks.queryWithStats).not.toHaveBeenCalled(); + }); + + // A busy service is not a bad query: 400 would tell a caller to rewrite a query that was fine. + it("answers a concurrency rejection with 429", async () => { + mocks.concurrencyAcquire.mockResolvedValue({ success: false, reason: "key_limit" }); + + const result = await runQuery("SELECT count() FROM runs"); + + expect(result.status).toBe(429); + expect(result.body.error).toContain("try again later"); + }); +}); + +describe("the query service", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.concurrencyAcquire.mockResolvedValue({ success: true }); + mocks.queryWithStats.mockReturnValue(async () => [null, { rows: [], stats: {} }]); + }); + + it("keeps ClickHouse read-only when a caller overrides the settings", async () => { + await executeQuery({ + name: "test-query", + query: "SELECT count() FROM runs", + scope: "environment", + organizationId: "org_1", + projectId: "proj_1", + environmentId: ENVIRONMENT_ID, + clickhouseSettings: { readonly: "0" }, + } as any); + + expect(mocks.queryWithStats).toHaveBeenCalled(); + expect(mocks.queryWithStats.mock.calls[0][0].settings.readonly).toBe("1"); + }); +}); diff --git a/apps/webapp/vitest.config.ts b/apps/webapp/vitest.config.ts index 2763b0ba4f..80ce4c0a27 100644 --- a/apps/webapp/vitest.config.ts +++ b/apps/webapp/vitest.config.ts @@ -21,6 +21,7 @@ export default defineConfig({ "app/components/code/**/*.test.ts", "app/components/dashboard-agent/**/*.test.ts", "app/components/queues/**/*.test.ts", + "app/routes/storybook.agent-ui/*.test.ts", "app/presenters/v3/reports/**/*.test.ts", ], // *.e2e.test.ts: smoke matrix, run via vitest.e2e.config.ts. diff --git a/docs/ai-chat/frontend.mdx b/docs/ai-chat/frontend.mdx index 0d7b068fb7..8d5e0be4a9 100644 --- a/docs/ai-chat/frontend.mdx +++ b/docs/ai-chat/frontend.mdx @@ -215,10 +215,12 @@ function ChatClient({ chatId, initialMessages, initialSessions }) { - After resuming, `useChat`'s built-in `stop()` won't send the stop signal to the backend because - the AI SDK doesn't pass its abort signal through `reconnectToStream`. Use - `transport.stopGeneration(chatId)` for reliable stop behavior after resume — see - [Stop generation](#stop-generation) for the recommended pattern. + After resuming, `useChat`'s built-in `stop()` won't send the stop signal to the backend. The + transport accepts an abort signal on `reconnectToStream` but doesn't treat it as owning the turn, + so aborting a resumed subscription only closes your local stream while the run keeps generating. + Use `transport.stopGeneration(chatId)` for reliable stop behavior after resume — see + [Stop generation](#stop-generation) for the recommended pattern. Pass `stopOnAbort: true` to + `reconnectToStream` only when that subscriber owns the turn. @@ -340,11 +342,10 @@ const stop = useCallback(() => { - A [PR to the AI SDK](https://github.com/vercel/ai/pull/14350) has been - submitted to pass `abortSignal` through `reconnectToStream`, which would make - `useChat`'s built-in `stop()` work after resume without needing - `stopGeneration`. Until that lands, use the pattern above for reliable stop - behavior after page refresh. + Aborting a resumed stream never stops the run by itself, so `useChat`'s + built-in `stop()` isn't enough after a page refresh. Use the pattern above, or + pass `stopOnAbort: true` to `reconnectToStream` when that subscriber owns the + turn. See [Stop generation](/ai-chat/backend#stop-generation) in the backend docs for how to handle stop signals in your task. diff --git a/docs/v3-openapi.yaml b/docs/v3-openapi.yaml index bb42f7af2c..a97ae70307 100644 --- a/docs/v3-openapi.yaml +++ b/docs/v3-openapi.yaml @@ -1203,6 +1203,16 @@ paths: description: Error message describing the query error "401": description: Unauthorized - API key is missing or invalid + "429": + description: Query service is busy or rate limited - retry shortly + content: + application/json: + schema: + type: object + properties: + error: + type: string + description: Error message describing why the query was turned away "500": description: Internal server error during query execution tags: diff --git a/internal-packages/dashboard-agent-contracts/src/index.ts b/internal-packages/dashboard-agent-contracts/src/index.ts index 76a43ef359..7181dc1326 100644 --- a/internal-packages/dashboard-agent-contracts/src/index.ts +++ b/internal-packages/dashboard-agent-contracts/src/index.ts @@ -11,3 +11,4 @@ export * from "./suggested-prompts.js"; export * from "./trigger-uri.js"; export * from "./watch.js"; export * from "./watch-wording.js"; +export * from "./well-formed.js"; diff --git a/internal-packages/dashboard-agent-contracts/src/well-formed.test.ts b/internal-packages/dashboard-agent-contracts/src/well-formed.test.ts new file mode 100644 index 0000000000..6c6ba459e2 --- /dev/null +++ b/internal-packages/dashboard-agent-contracts/src/well-formed.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { sliceWellFormed, toWellFormedDeep } from "./well-formed.js"; + +const emoji = "😀"; // one surrogate pair + +describe("sliceWellFormed", () => { + it("drops the high surrogate when the cut splits a pair", () => { + expect(sliceWellFormed(`ab${emoji}`, 3)).toBe("ab"); + }); + + it("keeps a pair that fits exactly", () => { + expect(sliceWellFormed(`ab${emoji}cd`, 4)).toBe(`ab${emoji}`); + }); + + it("leaves ascii alone", () => { + expect(sliceWellFormed("abcdef", 3)).toBe("abc"); + }); + + it("is a no-op when the limit is at or past the length", () => { + expect(sliceWellFormed(`ab${emoji}`, 4)).toBe(`ab${emoji}`); + expect(sliceWellFormed(`ab${emoji}`, 99)).toBe(`ab${emoji}`); + }); + + it("keeps a lone surrogate that was already in the input", () => { + const lone = "ab\ud83d"; + expect(sliceWellFormed(lone, 99)).toBe(lone); + expect(sliceWellFormed("a\ud83dbc", 3)).toBe("a\ud83db"); + }); + + it("drops a pre-existing lone high surrogate that lands on the cut", () => { + expect(sliceWellFormed("ab\ud83dz", 3)).toBe("ab"); + }); + + it("does not alter interior content", () => { + expect(sliceWellFormed(`${emoji}x${emoji}yz`, 5)).toBe(`${emoji}x${emoji}`); + }); +}); + +describe("toWellFormedDeep", () => { + it("replaces a lone surrogate nested in a tool input", () => { + const message = { + id: "msg_1", + role: "assistant", + parts: [{ type: "tool-search", input: { query: "cat \ud83d", limit: 5 } }], + }; + const result = toWellFormedDeep(message); + expect(result.parts[0].input.query).toBe("cat �"); + expect(result.parts[0].input.limit).toBe(5); + expect(result).not.toBe(message); + }); + + it("returns the same reference when nothing changed", () => { + const message = { id: "msg_1", parts: [{ text: `hello ${emoji}` }], meta: null }; + expect(toWellFormedDeep(message)).toBe(message); + }); + + it("replaces a lone surrogate in a key", () => { + const result: Record = toWellFormedDeep({ "k\ud800": "v" }); + expect(Object.keys(result)).toEqual(["k�"]); + expect(result["k�"]).toBe("v"); + }); + + it("walks arrays", () => { + const messages = [{ text: "ok" }, { text: "\udc00bad" }]; + const result = toWellFormedDeep(messages); + expect(result[1].text).toBe("�bad"); + expect(result[0]).toBe(messages[0]); + }); + + it("leaves non-string primitives and non-plain objects alone", () => { + const date = new Date(0); + const value = { n: 1, b: true, nil: null, u: undefined, date }; + const result = toWellFormedDeep(value); + expect(result).toBe(value); + expect(result.date).toBe(date); + }); +}); diff --git a/internal-packages/dashboard-agent-contracts/src/well-formed.ts b/internal-packages/dashboard-agent-contracts/src/well-formed.ts new file mode 100644 index 0000000000..6839e76e55 --- /dev/null +++ b/internal-packages/dashboard-agent-contracts/src/well-formed.ts @@ -0,0 +1,53 @@ +// `slice` that can't end on a high surrogate — split pair or already lone, dropped +// either way. A lone surrogate is invalid UTF-8 and jsonb rejects it. +export function sliceWellFormed(s: string, n: number): string { + if (n >= s.length) return s; + const cut = s.slice(0, n); + const last = cut.charCodeAt(cut.length - 1); + if (last >= 0xd800 && last <= 0xdbff) return cut.slice(0, -1); + return cut; +} + +// `toWellFormed` is ES2024; this package targets ES2022. +interface WellFormable { + toWellFormed(): string; +} + +function wellFormed(s: string): string { + return (s as unknown as WellFormable).toWellFormed(); +} + +function isPlainObject(value: object): boolean { + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +/** + * Every string in a JSON-ish value made well-formed, keys included, so a lone surrogate + * anywhere — tool input, filename, url — can't reach jsonb. Anything unchanged is returned + * as it was, and anything with a custom prototype (a Date, a class instance) is untouched. + */ +export function toWellFormedDeep(value: T): T { + if (typeof value === "string") return wellFormed(value) as T; + if (Array.isArray(value)) { + let changed = false; + const next = value.map((item) => { + const fixed = toWellFormedDeep(item); + if (fixed !== item) changed = true; + return fixed; + }); + return (changed ? next : value) as T; + } + if (value !== null && typeof value === "object" && isPlainObject(value)) { + let changed = false; + const next: Record = {}; + for (const [key, item] of Object.entries(value)) { + const fixedKey = wellFormed(key); + const fixed = toWellFormedDeep(item); + if (fixed !== item || fixedKey !== key) changed = true; + next[fixedKey] = fixed; + } + return (changed ? next : value) as T; + } + return value; +} diff --git a/internal-packages/dashboard-agent-db/drizzle/0004_stale_corsair.sql b/internal-packages/dashboard-agent-db/drizzle/0004_stale_corsair.sql new file mode 100644 index 0000000000..d581b4fec3 --- /dev/null +++ b/internal-packages/dashboard-agent-db/drizzle/0004_stale_corsair.sql @@ -0,0 +1,8 @@ +CREATE TABLE "trigger_dashboard_agent"."agent_message_usage" ( + "organization_id" text NOT NULL, + "period" text NOT NULL, + "count" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "agent_message_usage_organization_id_period_pk" PRIMARY KEY("organization_id","period") +); diff --git a/internal-packages/dashboard-agent-db/drizzle/0005_ambitious_mordo.sql b/internal-packages/dashboard-agent-db/drizzle/0005_ambitious_mordo.sql new file mode 100644 index 0000000000..3d56343452 --- /dev/null +++ b/internal-packages/dashboard-agent-db/drizzle/0005_ambitious_mordo.sql @@ -0,0 +1,2 @@ +ALTER TABLE "trigger_dashboard_agent"."investigations" ADD COLUMN "sweep_attempts" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "trigger_dashboard_agent"."investigations" ADD COLUMN "last_sweep_attempt_at" timestamp with time zone; \ No newline at end of file diff --git a/internal-packages/dashboard-agent-db/drizzle/meta/0004_snapshot.json b/internal-packages/dashboard-agent-db/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000000..5ee40ae24f --- /dev/null +++ b/internal-packages/dashboard-agent-db/drizzle/meta/0004_snapshot.json @@ -0,0 +1,1344 @@ +{ + "id": "f7cbfef4-7fc8-4deb-8da2-59248b242a60", + "prevId": "efb6f8b8-af9f-4ba7-9e38-bafd1f430b28", + "version": "7", + "dialect": "postgresql", + "tables": { + "trigger_dashboard_agent.agent_message_usage": { + "name": "agent_message_usage", + "schema": "trigger_dashboard_agent", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_message_usage_organization_id_period_pk": { + "name": "agent_message_usage_organization_id_period_pk", + "columns": ["organization_id", "period"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.chat_messages": { + "name": "chat_messages", + "schema": "trigger_dashboard_agent", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_messages_chat_user_role_idx": { + "name": "chat_messages_chat_user_role_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"chat_messages\".\"role\" = 'user'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "chat_messages_chat_id_message_id_pk": { + "name": "chat_messages_chat_id_message_id_pk", + "columns": ["chat_id", "message_id"] + } + }, + "uniqueConstraints": { + "chat_messages_chat_position_key": { + "name": "chat_messages_chat_position_key", + "nullsNotDistinct": false, + "columns": ["chat_id", "position"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.chat_sessions": { + "name": "chat_sessions", + "schema": "trigger_dashboard_agent", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_access_token": { + "name": "public_access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_event_id": { + "name": "last_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.chat_turn_evals": { + "name": "chat_turn_evals", + "schema": "trigger_dashboard_agent", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn": { + "name": "turn", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_run_id": { + "name": "agent_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "eval_run_id": { + "name": "eval_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_ref": { + "name": "project_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_page": { + "name": "current_page", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_slug": { + "name": "prompt_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_version": { + "name": "prompt_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tools_used": { + "name": "tools_used", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "tool_error": { + "name": "tool_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "judge_model": { + "name": "judge_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "score_grounded": { + "name": "score_grounded", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "score_answered": { + "name": "score_answered", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "score_concise": { + "name": "score_concise", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "passed": { + "name": "passed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "intent_category": { + "name": "intent_category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sentiment": { + "name": "sentiment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_gap": { + "name": "capability_gap", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "docs_gap": { + "name": "docs_gap", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "support_opportunity": { + "name": "support_opportunity", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "feature_request": { + "name": "feature_request", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "topics": { + "name": "topics", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "signals": { + "name": "signals", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_text": { + "name": "user_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "judge": { + "name": "judge", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_turn_evals_org_created_idx": { + "name": "chat_turn_evals_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_turn_evals_created_idx": { + "name": "chat_turn_evals_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_turn_evals_org_opps_idx": { + "name": "chat_turn_evals_org_opps_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"chat_turn_evals\".\"capability_gap\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"docs_gap\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"support_opportunity\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"feature_request\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "chat_turn_evals_chat_id_turn_pk": { + "name": "chat_turn_evals_chat_id_turn_pk", + "columns": ["chat_id", "turn"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.chats": { + "name": "chats", + "schema": "trigger_dashboard_agent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New chat'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_message_position": { + "name": "next_message_position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chats_org_user_last_msg_idx": { + "name": "chats_org_user_last_msg_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"chats\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.investigations": { + "name": "investigations", + "schema": "trigger_dashboard_agent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_ref": { + "name": "project_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_ref": { + "name": "environment_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "investigations_chat_idx": { + "name": "investigations_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "investigations_open_updated_idx": { + "name": "investigations_open_updated_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"investigations\".\"state\"->>'outcome' = 'in_progress'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.watch_batches": { + "name": "watch_batches", + "schema": "trigger_dashboard_agent", + "columns": { + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cadence_minutes": { + "name": "cadence_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "epoch": { + "name": "epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "armed_at": { + "name": "armed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_tick_at": { + "name": "last_tick_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "watch_batches_environment_id_cadence_minutes_pk": { + "name": "watch_batches_environment_id_cadence_minutes_pk", + "columns": ["environment_id", "cadence_minutes"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.watch_submissions": { + "name": "watch_submissions", + "schema": "trigger_dashboard_agent", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_request_id": { + "name": "client_request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_hash": { + "name": "draft_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft": { + "name": "draft", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "watch_id": { + "name": "watch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unavailable": { + "name": "unavailable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "external_notification_status": { + "name": "external_notification_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_requested'" + }, + "external_notification_reason": { + "name": "external_notification_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "immediate_result": { + "name": "immediate_result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refusal_code": { + "name": "refusal_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refusal_error": { + "name": "refusal_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refusal_existing_id": { + "name": "refusal_existing_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "watch_submissions_created_idx": { + "name": "watch_submissions_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "watch_submissions_chat_id_client_request_id_pk": { + "name": "watch_submissions_chat_id_client_request_id_pk", + "columns": ["chat_id", "client_request_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.watches": { + "name": "watches", + "schema": "trigger_dashboard_agent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "delivery_status": { + "name": "delivery_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_required'" + }, + "cancel_reason": { + "name": "cancel_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolution": { + "name": "resolution", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "observed_outcome": { + "name": "observed_outcome", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "investigate_on_attention": { + "name": "investigate_on_attention", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_ref": { + "name": "project_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_attempted_at": { + "name": "last_attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "fired_at": { + "name": "fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivery_claimed_at": { + "name": "delivery_claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivery_claim_id": { + "name": "delivery_claim_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_result": { + "name": "last_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tick_count": { + "name": "tick_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "alert_dispatch_key": { + "name": "alert_dispatch_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retention_at": { + "name": "retention_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "greatest(delivered_at, cancelled_at, fired_at, last_checked_at, created_at)", + "type": "stored" + } + }, + "cadence_minutes": { + "name": "cadence_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "((spec ->> 'checkEveryMinutes')::int)", + "type": "stored" + } + } + }, + "indexes": { + "watches_chat_idx": { + "name": "watches_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_chat_active_identity_key": { + "name": "watches_chat_active_identity_key", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"trigger_dashboard_agent\".\"watches\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_status_expires_idx": { + "name": "watches_status_expires_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_pending_delivery_idx": { + "name": "watches_pending_delivery_idx", + "columns": [ + { + "expression": "fired_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_checked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"delivery_status\" in ('pending', 'delivering')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_org_user_wake_idx": { + "name": "watches_org_user_wake_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"fired_at\", \"last_checked_at\") desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"delivery_status\" = 'delivered' and \"trigger_dashboard_agent\".\"watches\".\"status\" in ('fired', 'expired')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_org_user_active_idx": { + "name": "watches_org_user_active_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_active_env_cadence_idx": { + "name": "watches_active_env_cadence_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cadence_minutes", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"last_attempted_at\", \"last_checked_at\", \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_env_cadence_delivery_idx": { + "name": "watches_env_cadence_delivery_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cadence_minutes", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delivery_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"fired_at\", \"last_checked_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"status\" in ('fired', 'expired') and \"trigger_dashboard_agent\".\"watches\".\"delivery_status\" in ('pending', 'delivering')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_retention_idx": { + "name": "watches_retention_idx", + "columns": [ + { + "expression": "retention_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"status\" in ('fired', 'expired', 'cancelled') and \"trigger_dashboard_agent\".\"watches\".\"delivery_status\" in ('not_required', 'delivered')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": { + "trigger_dashboard_agent": "trigger_dashboard_agent" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/internal-packages/dashboard-agent-db/drizzle/meta/0005_snapshot.json b/internal-packages/dashboard-agent-db/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000000..b00ae150c5 --- /dev/null +++ b/internal-packages/dashboard-agent-db/drizzle/meta/0005_snapshot.json @@ -0,0 +1,1357 @@ +{ + "id": "9f0a4739-19ca-4a15-82dd-25598116feb9", + "prevId": "f7cbfef4-7fc8-4deb-8da2-59248b242a60", + "version": "7", + "dialect": "postgresql", + "tables": { + "trigger_dashboard_agent.agent_message_usage": { + "name": "agent_message_usage", + "schema": "trigger_dashboard_agent", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_message_usage_organization_id_period_pk": { + "name": "agent_message_usage_organization_id_period_pk", + "columns": ["organization_id", "period"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.chat_messages": { + "name": "chat_messages", + "schema": "trigger_dashboard_agent", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_messages_chat_user_role_idx": { + "name": "chat_messages_chat_user_role_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"chat_messages\".\"role\" = 'user'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "chat_messages_chat_id_message_id_pk": { + "name": "chat_messages_chat_id_message_id_pk", + "columns": ["chat_id", "message_id"] + } + }, + "uniqueConstraints": { + "chat_messages_chat_position_key": { + "name": "chat_messages_chat_position_key", + "nullsNotDistinct": false, + "columns": ["chat_id", "position"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.chat_sessions": { + "name": "chat_sessions", + "schema": "trigger_dashboard_agent", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_access_token": { + "name": "public_access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_event_id": { + "name": "last_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.chat_turn_evals": { + "name": "chat_turn_evals", + "schema": "trigger_dashboard_agent", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn": { + "name": "turn", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_run_id": { + "name": "agent_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "eval_run_id": { + "name": "eval_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_ref": { + "name": "project_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_page": { + "name": "current_page", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_slug": { + "name": "prompt_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_version": { + "name": "prompt_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tools_used": { + "name": "tools_used", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "tool_error": { + "name": "tool_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "judge_model": { + "name": "judge_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "score_grounded": { + "name": "score_grounded", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "score_answered": { + "name": "score_answered", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "score_concise": { + "name": "score_concise", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "passed": { + "name": "passed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "intent_category": { + "name": "intent_category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sentiment": { + "name": "sentiment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_gap": { + "name": "capability_gap", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "docs_gap": { + "name": "docs_gap", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "support_opportunity": { + "name": "support_opportunity", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "feature_request": { + "name": "feature_request", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "topics": { + "name": "topics", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "signals": { + "name": "signals", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_text": { + "name": "user_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "judge": { + "name": "judge", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_turn_evals_org_created_idx": { + "name": "chat_turn_evals_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_turn_evals_created_idx": { + "name": "chat_turn_evals_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_turn_evals_org_opps_idx": { + "name": "chat_turn_evals_org_opps_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"chat_turn_evals\".\"capability_gap\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"docs_gap\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"support_opportunity\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"feature_request\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "chat_turn_evals_chat_id_turn_pk": { + "name": "chat_turn_evals_chat_id_turn_pk", + "columns": ["chat_id", "turn"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.chats": { + "name": "chats", + "schema": "trigger_dashboard_agent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New chat'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_message_position": { + "name": "next_message_position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chats_org_user_last_msg_idx": { + "name": "chats_org_user_last_msg_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"chats\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.investigations": { + "name": "investigations", + "schema": "trigger_dashboard_agent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_ref": { + "name": "project_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_ref": { + "name": "environment_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "sweep_attempts": { + "name": "sweep_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_sweep_attempt_at": { + "name": "last_sweep_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "investigations_chat_idx": { + "name": "investigations_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "investigations_open_updated_idx": { + "name": "investigations_open_updated_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"investigations\".\"state\"->>'outcome' = 'in_progress'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.watch_batches": { + "name": "watch_batches", + "schema": "trigger_dashboard_agent", + "columns": { + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cadence_minutes": { + "name": "cadence_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "epoch": { + "name": "epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "armed_at": { + "name": "armed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_tick_at": { + "name": "last_tick_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "watch_batches_environment_id_cadence_minutes_pk": { + "name": "watch_batches_environment_id_cadence_minutes_pk", + "columns": ["environment_id", "cadence_minutes"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.watch_submissions": { + "name": "watch_submissions", + "schema": "trigger_dashboard_agent", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_request_id": { + "name": "client_request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_hash": { + "name": "draft_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft": { + "name": "draft", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "watch_id": { + "name": "watch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unavailable": { + "name": "unavailable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "external_notification_status": { + "name": "external_notification_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_requested'" + }, + "external_notification_reason": { + "name": "external_notification_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "immediate_result": { + "name": "immediate_result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refusal_code": { + "name": "refusal_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refusal_error": { + "name": "refusal_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refusal_existing_id": { + "name": "refusal_existing_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "watch_submissions_created_idx": { + "name": "watch_submissions_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "watch_submissions_chat_id_client_request_id_pk": { + "name": "watch_submissions_chat_id_client_request_id_pk", + "columns": ["chat_id", "client_request_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "trigger_dashboard_agent.watches": { + "name": "watches", + "schema": "trigger_dashboard_agent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "delivery_status": { + "name": "delivery_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_required'" + }, + "cancel_reason": { + "name": "cancel_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolution": { + "name": "resolution", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "observed_outcome": { + "name": "observed_outcome", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "investigate_on_attention": { + "name": "investigate_on_attention", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_ref": { + "name": "project_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_attempted_at": { + "name": "last_attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "fired_at": { + "name": "fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivery_claimed_at": { + "name": "delivery_claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivery_claim_id": { + "name": "delivery_claim_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_result": { + "name": "last_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tick_count": { + "name": "tick_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "alert_dispatch_key": { + "name": "alert_dispatch_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retention_at": { + "name": "retention_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "greatest(delivered_at, cancelled_at, fired_at, last_checked_at, created_at)", + "type": "stored" + } + }, + "cadence_minutes": { + "name": "cadence_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "((spec ->> 'checkEveryMinutes')::int)", + "type": "stored" + } + } + }, + "indexes": { + "watches_chat_idx": { + "name": "watches_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_chat_active_identity_key": { + "name": "watches_chat_active_identity_key", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"trigger_dashboard_agent\".\"watches\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_status_expires_idx": { + "name": "watches_status_expires_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_pending_delivery_idx": { + "name": "watches_pending_delivery_idx", + "columns": [ + { + "expression": "fired_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_checked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"delivery_status\" in ('pending', 'delivering')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_org_user_wake_idx": { + "name": "watches_org_user_wake_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"fired_at\", \"last_checked_at\") desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"delivery_status\" = 'delivered' and \"trigger_dashboard_agent\".\"watches\".\"status\" in ('fired', 'expired')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_org_user_active_idx": { + "name": "watches_org_user_active_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_active_env_cadence_idx": { + "name": "watches_active_env_cadence_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cadence_minutes", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"last_attempted_at\", \"last_checked_at\", \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_env_cadence_delivery_idx": { + "name": "watches_env_cadence_delivery_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cadence_minutes", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delivery_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"fired_at\", \"last_checked_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"status\" in ('fired', 'expired') and \"trigger_dashboard_agent\".\"watches\".\"delivery_status\" in ('pending', 'delivering')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watches_retention_idx": { + "name": "watches_retention_idx", + "columns": [ + { + "expression": "retention_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_dashboard_agent\".\"watches\".\"status\" in ('fired', 'expired', 'cancelled') and \"trigger_dashboard_agent\".\"watches\".\"delivery_status\" in ('not_required', 'delivered')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": { + "trigger_dashboard_agent": "trigger_dashboard_agent" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/internal-packages/dashboard-agent-db/drizzle/meta/_journal.json b/internal-packages/dashboard-agent-db/drizzle/meta/_journal.json index 9efe7bc1a1..213320fa64 100644 --- a/internal-packages/dashboard-agent-db/drizzle/meta/_journal.json +++ b/internal-packages/dashboard-agent-db/drizzle/meta/_journal.json @@ -29,6 +29,20 @@ "when": 1786264383741, "tag": "0003_backfill_chat_last_read_at", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1786359241538, + "tag": "0004_stale_corsair", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1786376934874, + "tag": "0005_ambitious_mordo", + "breakpoints": true } ] } diff --git a/internal-packages/dashboard-agent-db/src/queries.ts b/internal-packages/dashboard-agent-db/src/queries.ts index 40c2a10f55..c59810addc 100644 --- a/internal-packages/dashboard-agent-db/src/queries.ts +++ b/internal-packages/dashboard-agent-db/src/queries.ts @@ -1,5 +1,6 @@ import { investigationBlockSchema, + toWellFormedDeep, VIEW_BLOCK_VERSION, WATCH_REQUEST_MESSAGE_ID_PREFIX, } from "@internal/dashboard-agent-contracts"; @@ -8,6 +9,7 @@ import type { DashboardAgentDb } from "./client.js"; import { generateInvestigationId } from "./ids.js"; import { lockChatForWatches, type DashboardAgentDbOrTx } from "./internal.js"; import { + agentMessageUsage, chatMessages, chats, chatSessions, @@ -126,6 +128,45 @@ export async function countUserMessages( return rows[0]?.count ?? 0; } +/** + * The message count for one org in one billing period. Reads the standalone counter, + * never the chat rows, so a deleted chat can't lower it within the period. `period` is + * a UTC calendar month, "YYYY-MM"; the caller chooses it. + */ +export async function getAgentMessageUsage( + db: DashboardAgentDb, + params: { organizationId: string; period: string } +): Promise { + const rows = await db + .select({ count: agentMessageUsage.count }) + .from(agentMessageUsage) + .where( + and( + eq(agentMessageUsage.organizationId, params.organizationId), + eq(agentMessageUsage.period, params.period) + ) + ) + .limit(1); + return rows[0]?.count ?? 0; +} + +/** Bump the counter by one, creating the period row on first use. Returns the new count. */ +export async function incrementAgentMessageUsage( + db: DashboardAgentDb, + params: { organizationId: string; period: string; by?: number } +): Promise { + const by = params.by ?? 1; + const rows = await db + .insert(agentMessageUsage) + .values({ organizationId: params.organizationId, period: params.period, count: by }) + .onConflictDoUpdate({ + target: [agentMessageUsage.organizationId, agentMessageUsage.period], + set: { count: sql`${agentMessageUsage.count} + ${by}`, updatedAt: sql`now()` }, + }) + .returning({ count: agentMessageUsage.count }); + return rows[0]?.count ?? by; +} + /** * Chats whose transcript moved on after their owner last looked. A watch wake is one way * that happens; an answer that landed while the panel was closed is another, and the panel @@ -216,7 +257,7 @@ export async function createChat( organizationId: params.organizationId, userId: params.userId, title: params.title ?? DEFAULT_CHAT_TITLE, - metadata: params.metadata ?? {}, + metadata: toWellFormedDeep(params.metadata ?? {}), }) .onConflictDoNothing(); } @@ -406,8 +447,9 @@ async function storeChatMessages( tx: DashboardAgentDbOrTx, params: { chatId: string; messages: unknown[]; finalizable?: ReadonlySet } ): Promise { + // Every batch write lands here, so this is where a lone surrogate stops before jsonb. const deduped = new Map(); - for (const message of params.messages) { + for (const message of toWellFormedDeep(params.messages)) { const id = messageIdOf(params.chatId, message); if (deduped.has(id)) { throw new Error(`Chat ${params.chatId} was handed message id ${id} twice in one batch`); @@ -528,7 +570,9 @@ async function appendOneMessage( db: DashboardAgentDbOrTx, params: { chatId: string; message: unknown; scope: SQL[] } ): Promise { - const messageId = messageIdOf(params.chatId, params.message); + // Single-message appends land here — normalize like storeChatMessages. + const message = toWellFormedDeep(params.message); + const messageId = messageIdOf(params.chatId, message); const rows = await db.execute<{ message_id: string }>(sql` with reserved as ( update ${chats} @@ -545,8 +589,8 @@ async function appendOneMessage( returning "next_message_position" - 1 as "position" ) insert into ${chatMessages} ("chat_id", "message_id", "position", "role", "message") - select ${params.chatId}, ${messageId}, reserved."position", ${messageRoleOf(params.chatId, params.message)}, - ${JSON.stringify(params.message)}::jsonb + select ${params.chatId}, ${messageId}, reserved."position", ${messageRoleOf(params.chatId, message)}, + ${JSON.stringify(message)}::jsonb from reserved on conflict ("chat_id", "message_id") do nothing returning "message_id" @@ -700,7 +744,7 @@ export async function persistTurn( /** Idempotent on `(chatId, turn)`: a retried eval task can't write a second row. */ export async function insertTurnEval(db: DashboardAgentDb, row: NewChatTurnEval): Promise { - await db.insert(chatTurnEvals).values(row).onConflictDoNothing(); + await db.insert(chatTurnEvals).values(toWellFormedDeep(row)).onConflictDoNothing(); } /** @@ -824,6 +868,7 @@ export async function upsertInvestigationRevision( state: unknown; } ): Promise { + const state = toWellFormedDeep(params.state); if (!params.id) { const id = generateInvestigationId(); await db.insert(investigations).values({ @@ -832,7 +877,7 @@ export async function upsertInvestigationRevision( projectRef: params.projectRef, environmentRef: params.environmentRef, revision: 0, - state: params.state, + state, }); return { ok: true, id, revision: 0, created: true }; } @@ -840,7 +885,7 @@ export async function upsertInvestigationRevision( const rows = await db .update(investigations) .set({ - state: params.state, + state, revision: sql`${investigations.revision} + 1`, updatedAt: sql`now()`, }) @@ -898,7 +943,7 @@ export async function seedInvestigation( projectRef: params.projectRef, environmentRef: params.environmentRef, revision: 0, - state: params.state, + state: toWellFormedDeep(params.state), }) .onConflictDoNothing({ target: investigations.id }) .returning({ id: investigations.id }); @@ -1045,6 +1090,10 @@ export async function listChatIdsWithOpenInvestigations( /** * Sweep for investigations nothing else settles. `olderThan` is on `updated_at`, * which every revision bumps, so a card a live turn is writing to stays out. + * + * Order is `last_sweep_attempt_at` nulls first, then `updated_at`: a never-attempted + * row is always seen before one a prior sweep already failed on, so a row that can't + * settle rotates to the back instead of pinning the head and starving newer rows. */ export async function listStaleOpenInvestigations( db: DashboardAgentDb, @@ -1062,12 +1111,39 @@ export async function listStaleOpenInvestigations( sql`${investigations.updatedAt} <= ${params.olderThan.toISOString()}::timestamptz` ) ) - .orderBy(investigations.updatedAt) + .orderBy(sql`${investigations.lastSweepAttemptAt} asc nulls first`, investigations.updatedAt) .limit(params.limit ?? 100); return rows.map((row) => row.investigation); } +/** + * Record a failed stale-sweep settle on its own, committed outside the settle tx that + * rolled back. Bumps the attempt count and stamps `last_sweep_attempt_at` — which does + * NOT touch `updated_at`, so the row still reads as stale, only later in the order. + * Returns the new count, or null when the row is no longer `in_progress`. + */ +export async function recordInvestigationSweepAttempt( + db: DashboardAgentDbOrTx, + params: { id: string } +): Promise { + const rows = await db + .update(investigations) + .set({ + sweepAttempts: sql`${investigations.sweepAttempts} + 1`, + lastSweepAttemptAt: sql`now()`, + }) + .where( + and( + eq(investigations.id, params.id), + sql`${investigations.state}->>'outcome' = 'in_progress'` + ) + ) + .returning({ sweepAttempts: investigations.sweepAttempts }); + + return rows[0]?.sweepAttempts ?? null; +} + /** What the settle wrote, which is what the closing card has to render. */ export type SettledInvestigation = { id: string; revision: number; state: unknown }; diff --git a/internal-packages/dashboard-agent-db/src/schema.ts b/internal-packages/dashboard-agent-db/src/schema.ts index 0943f838d1..d080d75991 100644 --- a/internal-packages/dashboard-agent-db/src/schema.ts +++ b/internal-packages/dashboard-agent-db/src/schema.ts @@ -170,6 +170,10 @@ export const investigations = dashboardAgentSchema.table( // Monotonic; bumped by a single atomic UPDATE. revision: integer("revision").notNull().default(0), state: jsonb("state").$type().notNull(), + // Failed stale-sweep settle attempts. Bumped outside the rolled-back settle tx so a + // row that can't render rotates to the back of the sweep order instead of pinning it. + sweepAttempts: integer("sweep_attempts").notNull().default(0), + lastSweepAttemptAt: timestamp("last_sweep_attempt_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, @@ -183,6 +187,23 @@ export const investigations = dashboardAgentSchema.table( ] ); +/** + * Per-(org, period) message counter. Deliberately not joined to chats: deleting a chat + * must not free quota inside the period. `period` is a UTC calendar month, "YYYY-MM". + * Org id is a main-DB id with no FK. + */ +export const agentMessageUsage = dashboardAgentSchema.table( + "agent_message_usage", + { + organizationId: text("organization_id").notNull(), + period: text("period").notNull(), + count: integer("count").notNull().default(0), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [primaryKey({ columns: [t.organizationId, t.period] })] +); + export type Chat = typeof chats.$inferSelect; export type NewChat = typeof chats.$inferInsert; export type ChatMessage = typeof chatMessages.$inferSelect; @@ -193,3 +214,5 @@ export type ChatTurnEval = typeof chatTurnEvals.$inferSelect; export type NewChatTurnEval = typeof chatTurnEvals.$inferInsert; export type Investigation = typeof investigations.$inferSelect; export type NewInvestigation = typeof investigations.$inferInsert; +export type AgentMessageUsage = typeof agentMessageUsage.$inferSelect; +export type NewAgentMessageUsage = typeof agentMessageUsage.$inferInsert; diff --git a/internal-packages/dashboard-agent-db/src/watch-queries.ts b/internal-packages/dashboard-agent-db/src/watch-queries.ts index 1f8164f668..2755adeb80 100644 --- a/internal-packages/dashboard-agent-db/src/watch-queries.ts +++ b/internal-packages/dashboard-agent-db/src/watch-queries.ts @@ -494,6 +494,22 @@ export async function countUnreadWatchWakes( return rows[0]?.count ?? 0; } +/** + * How many active watches an org has, across all its chats and users. The plan-limit floor + * is org-wide, so this is org-scoped only; a chat deletion cancels its watches, so `active` + * is the whole count. + */ +export async function countActiveWatchesForOrg( + db: DashboardAgentDb, + params: { organizationId: string } +): Promise { + const rows = await db + .select({ count: sql`count(*)::int` }) + .from(watches) + .where(and(eq(watches.status, "active"), eq(watches.organizationId, params.organizationId))); + return rows[0]?.count ?? 0; +} + /** * Whether this user has a watch that can still wake them here. Covered by * `watches_org_user_active_idx`; a chat deletion cancels its watches, so `active` is enough. diff --git a/internal-packages/dashboard-agent/src/compaction.ts b/internal-packages/dashboard-agent/src/compaction.ts index cb178a1440..83e5d82b83 100644 --- a/internal-packages/dashboard-agent/src/compaction.ts +++ b/internal-packages/dashboard-agent/src/compaction.ts @@ -1,3 +1,4 @@ +import { sliceWellFormed } from "@internal/dashboard-agent-contracts"; import { locals, logger } from "@trigger.dev/sdk"; import type { ChatAgentCompactionOptions, SummarizeEvent } from "@trigger.dev/sdk/ai"; import { generateText, type ModelMessage, type UIMessage } from "ai"; @@ -263,7 +264,7 @@ export function renderTranscriptForSummary(messages: ModelMessage[]): string { .map((message) => { const content = typeof message.content === "string" ? message.content : JSON.stringify(message.content); - return `${message.role}: ${(content ?? "").slice(0, SUMMARY_INPUT_MESSAGE_CHARS)}`; + return `${message.role}: ${sliceWellFormed(content ?? "", SUMMARY_INPUT_MESSAGE_CHARS)}`; }) .join("\n\n"); } diff --git a/internal-packages/dashboard-agent/src/dashboard-agent.ts b/internal-packages/dashboard-agent/src/dashboard-agent.ts index 25cc0b103d..237cbc64d3 100644 --- a/internal-packages/dashboard-agent/src/dashboard-agent.ts +++ b/internal-packages/dashboard-agent/src/dashboard-agent.ts @@ -1,4 +1,4 @@ -import { isWatchRequestMessageId } from "@internal/dashboard-agent-contracts"; +import { isWatchRequestMessageId, sliceWellFormed } from "@internal/dashboard-agent-contracts"; import { chat } from "@trigger.dev/sdk/ai"; import { locals, logger, tasks } from "@trigger.dev/sdk"; import { generateText, stepCountIs, streamText, type ModelMessage, type UIMessage } from "ai"; @@ -181,7 +181,7 @@ export function truncateEvalToolValue(value: unknown, limit: number): unknown { if (serialized === undefined || serialized.length <= limit) return value; return { truncated: true, - outputPrefix: serialized.slice(0, limit), + outputPrefix: sliceWellFormed(serialized, limit), note: `[truncated: the first ${limit} of ${serialized.length} characters of this value]`, }; } @@ -267,12 +267,11 @@ export function extractToolActivity( } function cleanTitle(raw: string): string { - return raw + const normalized = raw .trim() .replace(/^["'`]+|["'`]+$/g, "") - .replace(/\s+/g, " ") - .slice(0, 80) - .trim(); + .replace(/\s+/g, " "); + return sliceWellFormed(normalized, 80).trim(); } /** diff --git a/internal-packages/dashboard-agent/src/eval-policy.ts b/internal-packages/dashboard-agent/src/eval-policy.ts index 3bae1c440c..c17399c9c3 100644 --- a/internal-packages/dashboard-agent/src/eval-policy.ts +++ b/internal-packages/dashboard-agent/src/eval-policy.ts @@ -1,3 +1,4 @@ +import { sliceWellFormed } from "@internal/dashboard-agent-contracts"; import { logger } from "@trigger.dev/sdk"; /** @@ -353,7 +354,7 @@ const APPLICATION_ERROR = /\b[A-Z][A-Za-z0-9]*(Error|Exception)\b|\b(Error|Excep * recognises is `unknown` rather than guessed into a bucket. */ export function classifyEvalError(output: unknown): EvalErrorCategory { - const signal = errorSignalText(output).slice(0, MAX_CLASSIFY_CHARS); + const signal = sliceWellFormed(errorSignalText(output), MAX_CLASSIFY_CHARS); const haystack = signal.toLowerCase(); for (const [category, pattern] of CATEGORY_RULES) { diff --git a/internal-packages/dashboard-agent/src/repo-tools.ts b/internal-packages/dashboard-agent/src/repo-tools.ts index 96b4409517..e7e5032a0f 100644 --- a/internal-packages/dashboard-agent/src/repo-tools.ts +++ b/internal-packages/dashboard-agent/src/repo-tools.ts @@ -4,6 +4,7 @@ import { mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs import { tmpdir } from "node:os"; import { isAbsolute, join, relative, resolve, sep } from "node:path"; import { promisify } from "node:util"; +import { sliceWellFormed } from "@internal/dashboard-agent-contracts"; import { tool, type ToolSet } from "ai"; import { getRepoInfoSchema, @@ -313,7 +314,7 @@ export function buildRepoTools( .map((line) => { const m = line.match(/^([^:]+):(\d+):(.*)$/); return m - ? { file: m[1], line: Number(m[2]), text: m[3].slice(0, 300) } + ? { file: m[1], line: Number(m[2]), text: sliceWellFormed(m[3], 300) } : { text: line }; }); return { matches, truncated: matches.length >= cap }; diff --git a/internal-packages/dashboard-agent/src/tool-api-client.ts b/internal-packages/dashboard-agent/src/tool-api-client.ts index 2392fe6d8c..2458f31c79 100644 --- a/internal-packages/dashboard-agent/src/tool-api-client.ts +++ b/internal-packages/dashboard-agent/src/tool-api-client.ts @@ -42,11 +42,12 @@ const GET_TIMEOUT_MS = 10_000; const JWT_TIMEOUT_MS = 10_000; const QUERY_TIMEOUT_MS = 30_000; -// "query" is the server rejecting the TRQL, "transport" is the request breaking. Chart +// "query" is the server rejecting the TRQL, "transport" is the request breaking, "busy" is +// the server too loaded or rate limited to answer — the same query may work shortly. Chart // validation only fails a render on "query". export type QueryPostResult = | { ok: true; rows: Array> } - | { ok: false; kind: "query" | "transport"; error: string }; + | { ok: false; kind: "query" | "transport" | "busy"; error: string }; export const NO_AUTH = { error: "No delegated access is available for this turn." } as const; @@ -215,6 +216,15 @@ export function createApiClient(ctx: ApiClientContext): DashboardAgentApiClient // The route returns 400 with { error } for invalid TRQL. const data = (await res.json().catch(() => ({}))) as { results?: unknown; error?: string }; if (!res.ok) { + // 429 is the concurrency rejection and the rate limiter: nothing is wrong with the + // query, so it is not a query error. + if (res.status === 429) { + return { + ok: false, + kind: "busy", + error: `${data.error ?? "The query service is busy right now."} You can retry the same query shortly.`, + }; + } return { ok: false, kind: res.status >= 500 ? "transport" : "query", @@ -234,7 +244,7 @@ export function createApiClient(ctx: ApiClientContext): DashboardAgentApiClient ): Promise { const result = await postQuery(query, period); if (isEnvUnavailable(result) || result.ok) return null; - if (result.kind === "transport") { + if (result.kind === "transport" || result.kind === "busy") { logger.warn("Skipped chart query validation", { error: result.error }); return null; } diff --git a/internal-packages/dashboard-agent/src/tool-api-transport.test.ts b/internal-packages/dashboard-agent/src/tool-api-transport.test.ts index 137c43409c..6c60988681 100644 --- a/internal-packages/dashboard-agent/src/tool-api-transport.test.ts +++ b/internal-packages/dashboard-agent/src/tool-api-transport.test.ts @@ -88,6 +88,45 @@ describe("a broken request reads as a broken request, never as an answer", () => expect(JSON.stringify(result)).not.toContain("isn't locked to a deployed version"); }); + it("classifies a busy query route as busy, not as a bad query", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => + url.endsWith("/jwt") + ? Response.json({ token: "jwt" }) + : Response.json( + { error: "We're experiencing a lot of queries at the moment." }, + { status: 429 } + ) + ) + ); + + const result = await createApiClient(CTX).postQuery("SELECT 1", undefined); + + expect(result).toMatchObject({ ok: false, kind: "busy" }); + expect((result as { error: string }).error).toContain("retry the same query shortly"); + }); + + // The other half of the same invariant: only 429 is busy, so a rejected query still counts. + it("classifies a rejected query as a query error", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => + url.endsWith("/jwt") + ? Response.json({ token: "jwt" }) + : Response.json({ error: "Unknown expression identifier 'createdAt'." }, { status: 400 }) + ) + ); + + const result = await createApiClient(CTX).postQuery("SELECT createdAt FROM runs", undefined); + + expect(result).toMatchObject({ + ok: false, + kind: "query", + error: "Unknown expression identifier 'createdAt'.", + }); + }); + it("still reports a real 404 as the answer it is", async () => { vi.stubGlobal( "fetch", diff --git a/internal-packages/dashboard-agent/src/tool-api.ts b/internal-packages/dashboard-agent/src/tool-api.ts index b043acad12..e037601508 100644 --- a/internal-packages/dashboard-agent/src/tool-api.ts +++ b/internal-packages/dashboard-agent/src/tool-api.ts @@ -186,6 +186,9 @@ export function withLiveState(metrics: unknown, queueType: "task" | "custom", li }; } +/** Failed `run_query` calls in a row before the tool tells the model to stop and answer. */ +export const MAX_CONSECUTIVE_QUERY_FAILURES = 3; + export function buildApiTools(args: { ctx: DashboardAgentToolContext; client: DashboardAgentApiClient; @@ -195,6 +198,12 @@ export function buildApiTools(args: { const { userActorToken, projectRef, environmentName, environmentBranch } = ctx; const { origin, hasAuth, envApiGet, postQuery, validateChartQuery } = client; + // A failed query hands the model the database error to fix, and it usually does. When it + // doesn't, the only other limit is the turn's 10 steps, so one broken query can eat the + // whole turn and leave the user with no answer at all. This tool set is built per turn, + // so the counter caps consecutive failures within one turn. + let consecutiveQueryFailures = 0; + return { list_projects: tool({ ...listProjectsSchema, @@ -350,7 +359,20 @@ export function buildApiTools(args: { execute: async ({ query, period }) => { const result = await postQuery(query, period); if (isEnvUnavailable(result)) return envUnavailableError(result, "query"); - if (!result.ok) return { error: result.error }; + if (!result.ok) { + // Only SQL errors count toward the cap; transport and busy errors are transient, + // and the same query may work on a retry. + if (result.kind === "query") { + consecutiveQueryFailures++; + if (consecutiveQueryFailures >= MAX_CONSECUTIVE_QUERY_FAILURES) { + return { + error: `${result.error} That is ${consecutiveQueryFailures} queries in a row that failed. Stop querying and answer the user with what you already have.`, + }; + } + } + return { error: result.error }; + } + consecutiveQueryFailures = 0; const cap = 200; const rows = result.rows; return { rows: rows.slice(0, cap), rowCount: rows.length, truncated: rows.length > cap }; diff --git a/internal-packages/dashboard-agent/src/tool-curation.ts b/internal-packages/dashboard-agent/src/tool-curation.ts index 9efa18aa20..5286d4366f 100644 --- a/internal-packages/dashboard-agent/src/tool-curation.ts +++ b/internal-packages/dashboard-agent/src/tool-curation.ts @@ -1,4 +1,5 @@ import type { JSONValue } from "@ai-sdk/provider"; +import { sliceWellFormed } from "@internal/dashboard-agent-contracts"; /** * Everything that trims an API payload down to what a tool returns, plus the two @@ -18,7 +19,7 @@ export function fenceUntrusted(label: string, text: unknown): string | undefined const raw = String(text).replaceAll("«", "<").replaceAll("»", ">"); const capped = raw.length > MAX_UNTRUSTED_FIELD_CHARS - ? `${raw.slice(0, MAX_UNTRUSTED_FIELD_CHARS)}…[truncated ${ + ? `${sliceWellFormed(raw, MAX_UNTRUSTED_FIELD_CHARS)}…[truncated ${ raw.length - MAX_UNTRUSTED_FIELD_CHARS } chars]` : raw; diff --git a/internal-packages/dashboard-agent/src/tool-docs.ts b/internal-packages/dashboard-agent/src/tool-docs.ts index 42f6cf00a6..fb7f9b011e 100644 --- a/internal-packages/dashboard-agent/src/tool-docs.ts +++ b/internal-packages/dashboard-agent/src/tool-docs.ts @@ -1,3 +1,5 @@ +import { sliceWellFormed } from "@internal/dashboard-agent-contracts"; + // A JSON-RPC `tools/call` against the public docs MCP endpoint: no auth, no user data. // The endpoint answers with either JSON or a single-event SSE stream. const DOCS_MCP_URL = "https://trigger.dev/docs/mcp"; @@ -44,7 +46,7 @@ export function formatDocsResults(parts: string[]): string { const excerpt = body.length > DOC_EXCERPT_MAX_CHARS - ? `${body.slice(0, DOC_EXCERPT_MAX_CHARS).trimEnd()}… [excerpt — the rest is on the page]` + ? `${sliceWellFormed(body, DOC_EXCERPT_MAX_CHARS).trimEnd()}… [excerpt — the rest is on the page]` : body; const entry = [ diff --git a/internal-packages/dashboard-agent/src/tool-query-retry-cap.test.ts b/internal-packages/dashboard-agent/src/tool-query-retry-cap.test.ts new file mode 100644 index 0000000000..0cf3b2f0a9 --- /dev/null +++ b/internal-packages/dashboard-agent/src/tool-query-retry-cap.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildApiTools, MAX_CONSECUTIVE_QUERY_FAILURES } from "./tool-api"; +import type { DashboardAgentApiClient } from "./tool-api-client"; + +/** + * A failed query hands the model the database error to fix. Without a cap, the only other + * limit is the turn's step budget, so a model that keeps rewriting the same broken query + * burns the whole turn and the user gets no answer. After three failures in a row the tool + * tells it to stop and answer. + */ + +function queryTool(postQuery: DashboardAgentApiClient["postQuery"]) { + const client = { + origin: "https://api.example.com", + hasAuth: true, + envApiGet: async () => ({ ok: false as const, status: 500 }), + postQuery, + validateChartQuery: async () => null, + } as unknown as DashboardAgentApiClient; + const tools = buildApiTools({ + ctx: { userActorToken: "uat", apiOrigin: client.origin }, + client, + renderInvestigations: (() => []) as any, + }); + return (query: string) => (tools.run_query as any).execute({ query }, {} as any); +} + +const failure = { + ok: false as const, + kind: "query" as const, + error: "Unknown expression identifier 'createdAt'.", +}; +const transportFailure = { + ok: false as const, + kind: "transport" as const, + error: "The environment is temporarily unavailable.", +}; +const busyFailure = { + ok: false as const, + kind: "busy" as const, + error: "We're experiencing a lot of queries at the moment. You can retry the same query shortly.", +}; +const success = { ok: true as const, rows: [{ n: 1 }] }; + +describe("run_query's consecutive-failure cap", () => { + it("keeps handing back the plain error until the cap", async () => { + const run = queryTool(async () => failure); + + for (let attempt = 1; attempt < MAX_CONSECUTIVE_QUERY_FAILURES; attempt++) { + const result = await run("SELECT createdAt FROM runs"); + expect(result.error).toBe(failure.error); + } + }); + + it("tells the model to stop and answer at the cap", async () => { + const run = queryTool(async () => failure); + + let result: { error: string } = { error: "" }; + for (let attempt = 0; attempt < MAX_CONSECUTIVE_QUERY_FAILURES; attempt++) { + result = await run("SELECT createdAt FROM runs"); + } + + expect(result.error).toContain(failure.error); + expect(result.error).toContain("answer the user with what you already have"); + }); + + it("counts consecutive failures only, so a good query clears the count", async () => { + const postQuery = vi + .fn() + .mockResolvedValueOnce(failure) + .mockResolvedValueOnce(failure) + .mockResolvedValueOnce(success) + .mockResolvedValue(failure); + const run = queryTool(postQuery as any); + + await run("bad"); + await run("bad"); + await run("good"); + const result = await run("bad"); + + expect(result.error).toBe(failure.error); + }); + + it("does not count transport errors toward the cap", async () => { + const run = queryTool(async () => transportFailure); + + let result: { error: string } = { error: "" }; + for (let attempt = 0; attempt < MAX_CONSECUTIVE_QUERY_FAILURES + 2; attempt++) { + result = await run("SELECT createdAt FROM runs"); + } + + expect(result.error).toBe(transportFailure.error); + expect(result.error).not.toContain("answer the user with what you already have"); + }); + + // A "too busy" rejection says nothing about the query, so spending the cap on it would + // stop the model over a queue that clears in seconds. + it("does not count busy rejections toward the cap", async () => { + const run = queryTool(async () => busyFailure); + + let result: { error: string } = { error: "" }; + for (let attempt = 0; attempt < MAX_CONSECUTIVE_QUERY_FAILURES + 2; attempt++) { + result = await run("SELECT createdAt FROM runs"); + } + + expect(result.error).toBe(busyFailure.error); + expect(result.error).not.toContain("answer the user with what you already have"); + }); + + it("still caps real SQL errors that follow busy rejections", async () => { + const postQuery = vi.fn().mockResolvedValueOnce(busyFailure).mockResolvedValue(failure); + const run = queryTool(postQuery as any); + + await run("busy"); + let result: { error: string } = { error: "" }; + for (let attempt = 0; attempt < MAX_CONSECUTIVE_QUERY_FAILURES; attempt++) { + result = await run("SELECT createdAt FROM runs"); + } + + expect(result.error).toContain("answer the user with what you already have"); + }); +}); diff --git a/packages/trigger-sdk/src/v3/chat.test.ts b/packages/trigger-sdk/src/v3/chat.test.ts index 21aa004c9a..452c8090b7 100644 --- a/packages/trigger-sdk/src/v3/chat.test.ts +++ b/packages/trigger-sdk/src/v3/chat.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import type { UIMessage, UIMessageChunk } from "ai"; -import { TriggerChatTransport, createChatTransport } from "./chat.js"; +import { TriggerChatTransport, createChatTransport, type ChatTransportEvent } from "./chat.js"; // ─────────────────────────────────────────────────────────────────────────── // Test helpers @@ -132,6 +132,35 @@ function defaultSseResponse( }); } +/** + * An SSE response whose body stays open until the request signal aborts. + * Models a live subscription sitting on a quiet server. + */ +function openSseResponse(signal?: AbortSignal | null): Response { + const body = new ReadableStream({ + start(controller) { + const onAbort = () => { + const err = new Error("aborted"); + err.name = "AbortError"; + try { + controller.error(err); + } catch { + /* already errored */ + } + }; + if (signal?.aborted) onAbort(); + else signal?.addEventListener("abort", onAbort, { once: true }); + }, + }); + return new Response(body, { + status: 200, + headers: { + "content-type": "text/event-stream", + "X-Stream-Version": "v2", + }, + }); +} + function authError(status = 401): Response { return new Response(JSON.stringify({ error: "Unauthorized", name: "TriggerApiError", status }), { status, @@ -1027,6 +1056,37 @@ describe("TriggerChatTransport", () => { expect(result).toBeNull(); }); + it("resumes in watch mode when the session is hydrated with isStreaming=false", async () => { + let subscribeCount = 0; + global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (isSessionOutSubscribeUrl(urlStr)) { + subscribeCount++; + const response = defaultSseResponse([{ type: "text-delta", id: "p1", delta: "turn2" }]); + const headers = new Headers(response.headers); + headers.set("X-Session-Settled", "true"); + return new Response(response.body, { status: 200, headers }); + } + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + watch: true, + sessions: { + "chat-rc-watch": { publicAccessToken: "p", isStreaming: false }, + }, + }); + + const stream = await transport.reconnectToStream({ chatId: "chat-rc-watch" }); + expect(stream).not.toBeNull(); + const chunks = await drainChunks(stream!); + + expect(subscribeCount).toBe(1); + expect(chunks).toEqual([{ type: "text-delta", id: "p1", delta: "turn2" }]); + }); + it("opens an SSE subscription with the X-Peek-Settled header set", async () => { let subscribeHeaders: Headers | undefined; global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { @@ -1176,7 +1236,7 @@ describe("TriggerChatTransport", () => { expect(transport.getSession("chat-slow")?.isStreaming).toBe(false); }); - it("gives up after a bounded number of resubscribes", async () => { + it("surfaces an error after the resubscribe budget is exhausted", async () => { // Fake timers so the 100ms..1.6s backoffs don't cost real seconds. vi.useFakeTimers(); try { @@ -1205,12 +1265,18 @@ describe("TriggerChatTransport", () => { messages: [createUserMessage("hi")], abortSignal: undefined, }); + // A cut-off turn surfaces an error rather than reading as complete. + // Attach the rejection assertion before advancing timers so the + // rejection is never unhandled. const drained = drainChunks(stream); + const rejects = expect(drained).rejects.toThrow(/reconnect budget exhausted/i); await vi.advanceTimersByTimeAsync(10_000); - await drained; + await rejects; // One initial connect plus the five-attempt resubscribe budget. expect(subscribeCount).toBe(6); + // State is cleared before the throw, so a reload won't reopen a + // doomed subscription. expect(transport.getSession("chat-empty")?.isStreaming).toBe(false); } finally { vi.useRealTimers(); @@ -1218,6 +1284,455 @@ describe("TriggerChatTransport", () => { }); }); + describe("watch mode across long-poll window boundaries", () => { + function settled(response: Response): Response { + const headers = new Headers(response.headers); + headers.set("X-Session-Settled", "true"); + return new Response(response.body, { status: 200, headers }); + } + + it("resubscribes after a completed turn and receives a later wake", async () => { + let subscribeCount = 0; + global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (isSessionOutSubscribeUrl(urlStr)) { + subscribeCount++; + // Window 1: a turn completes, then the body EOFs with no + // settled header — the quiet long-poll boundary. + return subscribeCount === 1 + ? defaultSseResponse([ + { type: "text-delta", id: "p1", delta: "turn1" }, + { type: "trigger:turn-complete" }, + ]) + : settled(defaultSseResponse([{ type: "text-delta", id: "p2", delta: "wake" }])); + } + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + watch: true, + sessions: { "chat-watch-eof": { publicAccessToken: "p", isStreaming: true } }, + }); + + const stream = await transport.reconnectToStream({ chatId: "chat-watch-eof" }); + const chunks = await drainChunks(stream!); + + expect(subscribeCount).toBe(2); + expect(chunks).toEqual([ + { type: "text-delta", id: "p1", delta: "turn1" }, + { type: "text-delta", id: "p2", delta: "wake" }, + ]); + }); + + it("does not peek-settle an idle resubscribe, so the next turn is delivered", async () => { + // Watch mode must NOT send X-Peek-Settled between turns: a settled peek + // while no turn is in flight closes the standing subscription and the + // viewer never sees turn 2. This mock plays the server's peek shortcut — + // a peek request with nothing in flight settles — to prove the transport + // long-polls instead. + const subscribeHeaders: Headers[] = []; + global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (isSessionOutSubscribeUrl(urlStr)) { + subscribeHeaders.push(new Headers(init?.headers)); + const n = subscribeHeaders.length; + if (n === 1) { + // Turn 1 completes, then the body EOFs (no settled header). + return defaultSseResponse([ + { type: "text-delta", id: "p1", delta: "turn1" }, + { type: "trigger:turn-complete" }, + ]); + } + if (n === 2) { + // Idle resubscribe. If it peeked, the server settles and the + // subscription would close before turn 2; a long-poll delivers it. + if (init && new Headers(init.headers).get("X-Peek-Settled")) { + return settled(defaultSseResponse([])); + } + return defaultSseResponse([ + { type: "text-delta", id: "p2", delta: "turn2" }, + { type: "trigger:turn-complete" }, + ]); + } + // Turn 2 done — end the watch cleanly. + return settled(defaultSseResponse([])); + } + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + watch: true, + sessions: { "chat-watch-turn2": { publicAccessToken: "p", isStreaming: true } }, + }); + + const stream = await transport.reconnectToStream({ chatId: "chat-watch-turn2" }); + const chunks = await drainChunks(stream!); + + expect(subscribeHeaders[1]?.get("X-Peek-Settled")).toBeNull(); + expect(chunks).toEqual([ + { type: "text-delta", id: "p1", delta: "turn1" }, + { type: "text-delta", id: "p2", delta: "turn2" }, + ]); + }); + + it("cancelling the reader stops the resubscribe loop", async () => { + // A consumer that stops reading without aborting must not leak the + // resubscribe loop — the stream's cancel() aborts it. + vi.useFakeTimers(); + try { + let subscribeCount = 0; + global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (isSessionOutSubscribeUrl(urlStr)) { + subscribeCount++; + // Quiet: EOF, no records, never settled — watch keeps resubscribing. + return defaultSseResponse([]); + } + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const events: ChatTransportEvent[] = []; + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + watch: true, + onEvent: (e) => events.push(e), + sessions: { "chat-watch-cancel": { publicAccessToken: "p", isStreaming: true } }, + }); + + const stream = await transport.reconnectToStream({ chatId: "chat-watch-cancel" }); + const reader = stream!.getReader(); + await vi.advanceTimersByTimeAsync(10_000); + expect(subscribeCount).toBeGreaterThan(1); + + const countAtCancel = subscribeCount; + await reader.cancel(); + await vi.advanceTimersByTimeAsync(10_000); + expect(subscribeCount).toBe(countAtCancel); + // A clean cancel must not surface a spurious stream-error (an + // unguarded controller.close() after cancel would throw "Invalid + // state" and leak it onto the telemetry channel). + expect(events.some((e) => e.type === "stream-error")).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it("stops when the server says the session settled", async () => { + let subscribeCount = 0; + global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (isSessionOutSubscribeUrl(urlStr)) { + subscribeCount++; + return settled( + defaultSseResponse([ + { type: "text-delta", id: "p1", delta: "last" }, + { type: "trigger:turn-complete" }, + ]) + ); + } + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + watch: true, + sessions: { "chat-watch-settled": { publicAccessToken: "p", isStreaming: true } }, + }); + + const stream = await transport.reconnectToStream({ chatId: "chat-watch-settled" }); + const chunks = await drainChunks(stream!); + + expect(subscribeCount).toBe(1); + expect(chunks).toHaveLength(1); + expect(transport.getSession("chat-watch-settled")?.isStreaming).toBe(false); + }); + + it("stops promptly when aborted during backoff", async () => { + vi.useFakeTimers(); + try { + let subscribeCount = 0; + global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (isSessionStreamAppendUrl(urlStr)) return defaultAppendResponse(); + if (isSessionOutSubscribeUrl(urlStr)) { + subscribeCount++; + // Every window is quiet: EOF with no records, never settled. + return defaultSseResponse([]); + } + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const abortController = new AbortController(); + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + watch: true, + sessions: { "chat-watch-abort": { publicAccessToken: "p", isStreaming: true } }, + }); + + const stream = await transport.reconnectToStream({ + chatId: "chat-watch-abort", + abortSignal: abortController.signal, + }); + const drained = drainChunks(stream!); + await vi.advanceTimersByTimeAsync(10_000); + // The budget doesn't apply in watch mode, so it is still reconnecting. + expect(subscribeCount).toBeGreaterThan(6); + + const countAtAbort = subscribeCount; + abortController.abort(); + await drained; + await vi.advanceTimersByTimeAsync(10_000); + expect(subscribeCount).toBe(countAtAbort); + } finally { + vi.useRealTimers(); + } + }); + }); + + describe("reconnectToStream stop-on-abort ownership (TRI-13070)", () => { + // A quiet stream: EOF, no records, never settled — the subscription + // stays alive (watch mode) so an abort mid-flight exercises the stop path. + function quietWatchTransport(): { + transport: TriggerChatTransport; + appends: () => number; + } { + let appendCount = 0; + global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (isSessionStreamAppendUrl(urlStr)) { + appendCount++; + return defaultAppendResponse(); + } + if (isSessionOutSubscribeUrl(urlStr)) return defaultSseResponse([]); + throw new Error(`Unexpected URL: ${urlStr}`); + }); + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + watch: true, + sessions: { "chat-own": { publicAccessToken: "p", isStreaming: true } }, + }); + return { transport, appends: () => appendCount }; + } + + it("passive subscriber aborting writes no stop chunk to .in", async () => { + vi.useFakeTimers(); + try { + const { transport, appends } = quietWatchTransport(); + const abort = new AbortController(); + const stream = await transport.reconnectToStream({ + chatId: "chat-own", + abortSignal: abort.signal, + }); + const drained = drainChunks(stream!); + await vi.advanceTimersByTimeAsync(1_000); + abort.abort(); + await drained; + await vi.advanceTimersByTimeAsync(1_000); + expect(appends()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it("owning subscriber with stopOnAbort:true sends a stop chunk on abort", async () => { + vi.useFakeTimers(); + try { + const { transport, appends } = quietWatchTransport(); + const abort = new AbortController(); + const stream = await transport.reconnectToStream({ + chatId: "chat-own", + abortSignal: abort.signal, + stopOnAbort: true, + }); + const drained = drainChunks(stream!); + await vi.advanceTimersByTimeAsync(1_000); + abort.abort(); + await drained; + await vi.advanceTimersByTimeAsync(1_000); + expect(appends()).toBe(1); + } finally { + vi.useRealTimers(); + } + }); + + it("abortSignal presence alone (stopOnAbort unset) sends no stop", async () => { + vi.useFakeTimers(); + try { + const { transport, appends } = quietWatchTransport(); + const abort = new AbortController(); + const stream = await transport.reconnectToStream({ + chatId: "chat-own", + abortSignal: abort.signal, + }); + const drained = drainChunks(stream!); + await vi.advanceTimersByTimeAsync(1_000); + abort.abort(); + await drained; + await vi.advanceTimersByTimeAsync(1_000); + expect(appends()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + }); + + describe("superseded stream teardown", () => { + it("keeps the successor's controller registered when the aborted stream tears down", async () => { + vi.useFakeTimers(); + try { + let appendCount = 0; + global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (isSessionStreamAppendUrl(urlStr)) { + appendCount++; + return defaultAppendResponse(); + } + // Quiet stream: EOF, no records, never settled — watch keeps it open. + if (isSessionOutSubscribeUrl(urlStr)) return defaultSseResponse([]); + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + watch: true, + sessions: { "chat-race": { publicAccessToken: "p", isStreaming: true } }, + }); + + const send = () => + transport.sendMessages({ + trigger: "submit-message" as const, + chatId: "chat-race", + messageId: undefined, + messages: [createUserMessage("hi")], + abortSignal: undefined, + }); + + const first = drainChunks(await send()); + await vi.advanceTimersByTimeAsync(1_000); + + // Supersede: the new stream registers its controller synchronously, + // the aborted one tears down a microtask later. + const second = await send(); + let secondClosed = false; + const secondDrain = drainChunks(second).then(() => { + secondClosed = true; + }); + await first; + await vi.advanceTimersByTimeAsync(1_000); + + // stopGeneration posts the stop chunk either way — only the + // closing assertion proves it found the successor to abort. + appendCount = 0; + expect(await transport.stopGeneration("chat-race")).toBe(true); + await vi.advanceTimersByTimeAsync(1_000); + expect(appendCount).toBe(1); + expect(secondClosed).toBe(true); + + transport.dispose(); + await secondDrain; + } finally { + vi.useRealTimers(); + } + }); + + it("keeps the tab claim the successor took (multi-tab)", async () => { + vi.useFakeTimers(); + try { + global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (isSessionStreamAppendUrl(urlStr)) return defaultAppendResponse(); + // Open SSE that only ends when the subscription is aborted, so + // the superseded stream tears down while the successor is live. + if (isSessionOutSubscribeUrl(urlStr)) return openSseResponse(init?.signal); + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + multiTab: true, + sessions: { "chat-race-tab": { publicAccessToken: "p", isStreaming: true } }, + }); + + const send = () => + transport.sendMessages({ + trigger: "submit-message" as const, + chatId: "chat-race-tab", + messageId: undefined, + messages: [createUserMessage("hi")], + abortSignal: undefined, + }); + + const first = drainChunks(await send()); + await vi.advanceTimersByTimeAsync(1_000); + const secondDrain = drainChunks(await send()); + await first; + await vi.advanceTimersByTimeAsync(1_000); + + // The superseded stream must not release the claim its successor + // holds — otherwise this tab flips to read-only mid-turn. + expect(transport.hasClaim("chat-race-tab")).toBe(true); + + transport.dispose(); + await secondDrain; + } finally { + vi.useRealTimers(); + } + }); + + it("releases the tab claim when the user stops generation (multi-tab)", async () => { + vi.useFakeTimers(); + try { + global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (isSessionStreamAppendUrl(urlStr)) return defaultAppendResponse(); + if (isSessionOutSubscribeUrl(urlStr)) return openSseResponse(init?.signal); + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + multiTab: true, + sessions: { "chat-stop-tab": { publicAccessToken: "p", isStreaming: true } }, + }); + + const drain = drainChunks( + await transport.sendMessages({ + trigger: "submit-message" as const, + chatId: "chat-stop-tab", + messageId: undefined, + messages: [createUserMessage("hi")], + abortSignal: undefined, + }) + ); + await vi.advanceTimersByTimeAsync(1_000); + expect(transport.hasClaim("chat-stop-tab")).toBe(true); + + expect(await transport.stopGeneration("chat-stop-tab")).toBe(true); + await vi.advanceTimersByTimeAsync(1_000); + + // The turn ends here with no successor stream, so the claim must be + // freed or other tabs stay read-only until this one closes. + expect(transport.hasClaim("chat-stop-tab")).toBe(false); + + transport.dispose(); + await drain; + } finally { + vi.useRealTimers(); + } + }); + }); + describe("multi-tab coordination", () => { it("isReadOnly defaults to false when multiTab is disabled", () => { const transport = new TriggerChatTransport({ @@ -1488,9 +2003,18 @@ describe("TriggerChatTransport", () => { { type: "text-delta", id: "p2", delta: "Again" }, { type: "trigger:turn-complete" }, ]; + let subscribeCount = 0; global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { const urlStr = typeof url === "string" ? url : url.toString(); - if (isSessionOutSubscribeUrl(urlStr)) return defaultSseResponse(turn1); + if (isSessionOutSubscribeUrl(urlStr)) { + subscribeCount++; + if (subscribeCount === 1) return defaultSseResponse(turn1); + // Watch mode reconnects past the body EOF; settle so the drain ends. + const response = defaultSseResponse([]); + const headers = new Headers(response.headers); + headers.set("X-Session-Settled", "true"); + return new Response(response.body, { status: 200, headers }); + } throw new Error(`Unexpected URL: ${urlStr}`); }); diff --git a/packages/trigger-sdk/src/v3/chat.ts b/packages/trigger-sdk/src/v3/chat.ts index 137222b1ba..a7c7125575 100644 --- a/packages/trigger-sdk/src/v3/chat.ts +++ b/packages/trigger-sdk/src/v3/chat.ts @@ -873,7 +873,11 @@ export class TriggerChatTransport implements ChatTransport { state.isStreaming = true; this.notifySessionChange(chatId, state); - return this.subscribeToSessionStream(state, abortSignal, chatId, { sinceInSeq: inSeq }); + // Owning turn: aborting this live send stops the turn the user drives. + return this.subscribeToSessionStream(state, abortSignal, chatId, { + sinceInSeq: inSeq, + sendStopOnAbort: true, + }); }; /** @@ -1146,12 +1150,21 @@ export class TriggerChatTransport implements ChatTransport { options: { chatId: string; abortSignal?: AbortSignal | undefined; + /** + * Whether aborting this subscription sends `{kind:"stop"}` on `.in`. + * A subscription ending is not session ownership — a passive/watch + * reader unmounting must never stop a turn it doesn't drive. Only + * pass `true` from a caller that owns the live turn. @default false + */ + stopOnAbort?: boolean; } & ChatRequestOptions ): Promise | null> => { const state = this.sessions.get(options.chatId); if (!state) return null; - if (state.isStreaming === false) return null; + // Watch is a standing subscription: a settled session is exactly the + // state it waits in, so a completed last turn must not block the resume. + if (state.isStreaming === false && !this.watchMode) return null; if (this.activeStreams.has(options.chatId)) return null; const abortController = new AbortController(); @@ -1163,12 +1176,14 @@ export class TriggerChatTransport implements ChatTransport { return this.subscribeToSessionStream(state, abortSignal, options.chatId, { resumed: true, - sendStopOnAbort: !!options.abortSignal, + sendStopOnAbort: options.stopOnAbort ?? false, // Reconnect-on-reload opts into the server's settled-peek shortcut // so the SSE doesn't hang for 60s when no turn is in flight. Active // send-a-message paths must keep wait=60 to avoid racing the - // freshly-triggered turn's first chunk. - peekSettled: true, + // freshly-triggered turn's first chunk. Watch mode must NOT peek: a + // settled peek between turns sets sessionSettled and closes the + // standing subscription, so the viewer never sees the next turn. + peekSettled: !this.watchMode, }); }; @@ -1205,6 +1220,11 @@ export class TriggerChatTransport implements ChatTransport { activeStream.abort(); this.activeStreams.delete(chatId); } + // Release here, not in the stream teardown: that only releases while it + // still owns the map entry, and we just deleted it. Unlike a supersede, + // no successor stream follows a stop, so the claim would never be freed + // and other tabs would stay read-only until this one closes. + this.coordinator?.release(chatId); // The turn won't reach its turn-complete on this client (we just // aborted the reader), so clear the streaming flag here and persist — @@ -1266,7 +1286,11 @@ export class TriggerChatTransport implements ChatTransport { state.isStreaming = true; this.notifySessionChange(chatId, state); - return this.subscribeToSessionStream(state, undefined, chatId, { sinceInSeq: inSeq }); + // Owning action: aborting this send stops the turn the user drives. + return this.subscribeToSessionStream(state, undefined, chatId, { + sinceInSeq: inSeq, + sendStopOnAbort: true, + }); }; // ------------------------------------------------------------------------- @@ -1780,11 +1804,13 @@ export class TriggerChatTransport implements ChatTransport { let eofResubscribes = 0; const resumeAfterEof = async () => { + // Watch mode is a standing subscription: it outlives turn-complete + // (which clears `isStreaming`) and idle windows EOF by design, so the + // give-up budget doesn't apply. Only abort or a settled session ends it. while ( - state.isStreaming && + (this.watchMode || (state.isStreaming && eofResubscribes < MAX_EOF_RESUBSCRIBES)) && !currentSubscription?.sessionSettled && - !combinedSignal.aborted && - eofResubscribes < MAX_EOF_RESUBSCRIBES + !combinedSignal.aborted ) { eofResubscribes++; // Sleep, but wake immediately on abort — otherwise a stop lands @@ -1796,7 +1822,10 @@ export class TriggerChatTransport implements ChatTransport { combinedSignal.removeEventListener("abort", done); resolve(); }; - timer = setTimeout(done, Math.min(100 * 2 ** (eofResubscribes - 1), 5_000)); + // Jitter the backoff so many clients reconnecting after the same + // dropped window don't resubscribe in lockstep. + const backoff = Math.min(100 * 2 ** (eofResubscribes - 1), 5_000); + timer = setTimeout(done, backoff * (0.5 + Math.random() * 0.5)); combinedSignal.addEventListener("abort", done); }); if (combinedSignal.aborted) break; @@ -1804,6 +1833,25 @@ export class TriggerChatTransport implements ChatTransport { if (opened) return opened; } + // A settled session or an abort ends the turn cleanly. Exhausting the + // resubscribe budget while the turn is still streaming means it was cut + // off — surface an error so the UI doesn't read a truncated reply as + // complete. The caller's catch emits stream-error and errors the stream. + if ( + state.isStreaming && + !currentSubscription?.sessionSettled && + !combinedSignal.aborted + ) { + // Clear + persist before throwing so the surfaced error leaves + // consistent state — otherwise a reload sees isStreaming: true + // and reopens a doomed subscription. + state.isStreaming = false; + this.notifySessionChange(chatId, state); + throw new Error( + "Chat stream ended before the turn completed (reconnect budget exhausted)." + ); + } + // Settled close, or the turn is gone — tell the UI instead of // leaving it spinning on a stream nobody will finish. if (state.isStreaming) { @@ -1823,7 +1871,11 @@ export class TriggerChatTransport implements ChatTransport { const opened = (await openWithAuthRetry()) ?? (await resumeAfterEof()); if (opened === null) { - controller.close(); + try { + controller.close(); + } catch { + /* already closed by a consumer cancel */ + } return; } reader = opened.reader; @@ -1853,7 +1905,11 @@ export class TriggerChatTransport implements ChatTransport { if (next.done) { const resumed = await resumeAfterEof(); if (resumed === null) { - controller.close(); + try { + controller.close(); + } catch { + /* already closed by a consumer cancel */ + } return; } reader = resumed.reader; @@ -1866,7 +1922,11 @@ export class TriggerChatTransport implements ChatTransport { if (combinedSignal.aborted) { internalAbort.abort(); await reader.cancel(); - controller.close(); + try { + controller.close(); + } catch { + /* already closed by a consumer cancel */ + } return; } @@ -2001,10 +2061,20 @@ export class TriggerChatTransport implements ChatTransport { controller.error(error); } finally { teardownWakeListeners(); - this.activeStreams.delete(chatId); - this.coordinator?.release(chatId); + // Only clear the entry (and drop the tab claim) if it is still + // ours — a superseding send registers its controller before this + // teardown runs, and owns the claim from then on. + if (this.activeStreams.get(chatId) === internalAbort) { + this.activeStreams.delete(chatId); + this.coordinator?.release(chatId); + } } }, + // A consumer that stops reading without aborting (drops the reader) + // would otherwise leave the resubscribe loop running forever. + cancel() { + internalAbort.abort(); + }, }); } } diff --git a/packages/trigger-sdk/test/chat-transport-events.test.ts b/packages/trigger-sdk/test/chat-transport-events.test.ts index ded1895dfd..39f4e53d72 100644 --- a/packages/trigger-sdk/test/chat-transport-events.test.ts +++ b/packages/trigger-sdk/test/chat-transport-events.test.ts @@ -211,11 +211,20 @@ describe("transport stream events", () => { ``, ].join("\n"); + let subscribes = 0; const { transport, events } = makeTransport({ watch: true, sessions: { c1: { publicAccessToken: "tok_test", isStreaming: true } }, - fetch: async (_url, _init, ctx) => - ctx.endpoint === "in" ? jsonOk() : sseResponse(TWO_TURNS), + fetch: async (_url, _init, ctx) => { + if (ctx.endpoint === "in") return jsonOk(); + if (subscribes++ > 0) { + // Watch mode reconnects past the body EOF; settle so the read ends. + const settled = sseResponse(""); + settled.headers.set("X-Session-Settled", "true"); + return settled; + } + return sseResponse(TWO_TURNS); + }, }); const stream = await transport.reconnectToStream({ chatId: "c1" });