feat(webapp,sdk): dashboard agent plan enforcement, component gallery — and fixes (#4516)
Plan enforcement for the dashboard agent — message quota and watch limits — plus the component gallery, fixes and test hardening from the same stack (#4548, #4549, #4550, #4552, #4556 merged here). ## Plan enforcement ([TRI-12863](https://linear.app/triggerdotdev/issue/TRI-12863)) **Agent message quota.** The Free-plan allowance becomes a real server-side limit with a durable counter. New `agent_message_usage` table keyed `(organization_id, period)` — deliberately not joined to chats, so deleting a chat can't free quota within the period. Both send paths count one user message (wakes never count) and refuse at the cap with `403 message_quota_reached`, which the client renders as an upgrade panel, never a silent drop. The refusal code is a single shared constant on both sides. **Watch limits.** A watch whose window exceeds the plan's `agentWatchMaxHours`, or that would push the org past its `agentWatchers` count, is refused with `watch_limit_reached` (409 on the API, an upgrade hint on the card). Plan limits only tighten the existing code ceilings (`min(plan, 24h)`, per-chat cap of 3 still applies). A plan limit of zero means zero, not unlimited. Questions answerable instantly are answered before any plan refusal — a one-shot consumes no slot and never sees an upgrade nag. **Fails open by design.** Cloud ships the actual per-plan numbers separately (TRI-12863 P0). Until then absent limits resolve to the unlimited sentinel and the upgrade UI is gated on billing presence — self-hosted sees no cap, no upsell, with tests proving the fallback. Both quotas are nudges, not security boundaries: a failing limit read never blocks a send. ## Component gallery An admin-only gallery of every agent card state: five `storybook.agent-*` pages (chat UI, view blocks, report, investigation, watch) with their shared shell and manifest, demo fixtures, two demo-only cards, toast examples, and the screenshot script. No LLM and no data — every state renders from fixtures under `dashboard-agent/demo/`, never reachable from a production path. Designers and reviewers can look at every state, including the report states, without seeding anything. ## And fixes **SDK: watch-mode chat subscriptions survive quiet windows** (TRI-13065, TRI-13070) — watch mode keeps reconnecting across empty long-poll windows and only stops on abort or a settled session; a passive subscriber can no longer stop a turn it doesn't own (`stopOnAbort` is explicit, default off). Review findings fixed alongside: a superseded stream's async teardown no longer removes the live successor's abort controller or multi-tab claim, and stopping a generation hands the chat back to the user's other tabs. **Query boundary pinned end-to-end** ([TRI-11165](https://linear.app/triggerdotdev/issue/TRI-11165)) — a route-level test drives `api.v1.query` with a real signed environment JWT (writes refused before ClickHouse, a read passes); `readonly=1` made non-overridable; a per-turn cap stops the model burning a turn rewriting a query it can't fix (deterministic SQL errors only — busy/transport rejections don't count). **chat.agent durability regression suite** ([TRI-11166](https://linear.app/triggerdotdev/issue/TRI-11166)) — testcontainers-backed coverage of the two audit criticals (cross-tenant isolation, no duplicate mid-stream turn, both control-broken) plus crash-resume, cursor-based refresh, clean rollback of a mid-write turn failure (torn by a real constraint violation), and OOM-restart replay. **Investigation sweep backoff** — stale investigations get an attempt counter and backoff so a poison row can't pin the sweep queue head (migration `0005`: `sweep_attempts`, `last_sweep_attempt_at`). ## Screenshots <img width="1440" height="791" alt="Screenshot 2026-08-06 at 00 36 19" src="https://github.com/user-attachments/assets/6a68cd42-8580-469d-afe7-e28d1eef18e1" /> 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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}
|
||||
<div className="mt-1.5 flex flex-col gap-2 rounded-md border border-border-bright bg-background-dimmed p-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<AgentIcon className={cn("size-4 shrink-0", AGENT_ICON_ACCENT_CLASS)} />
|
||||
<AgentMonoLogo size={16} decorative className="shrink-0" />
|
||||
<span className="text-sm font-medium text-text-bright">
|
||||
Upgrade to unlock {ASK_AGENT_LABEL}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-dimmed">
|
||||
You've used all {limit} messages included on the Free plan. Your chats stay here to read.
|
||||
</p>
|
||||
<p className="text-xs text-text-dimmed">{messageQuotaReachedCopy(limit, planResolved)}</p>
|
||||
<LinkButton variant="primary/small" to={v3BillingPath(organization)} fullWidth>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
<DashboardAgentMessages
|
||||
@@ -406,6 +446,7 @@ export function DashboardAgentChat({
|
||||
activity={activity}
|
||||
error={error}
|
||||
onRetry={retry}
|
||||
retryDisabledReason={atMessageCap ? MESSAGE_QUOTA_REACHED_REASON : undefined}
|
||||
onDismissError={clearError}
|
||||
onIntent={handleIntent}
|
||||
pagePaths={pagePaths}
|
||||
@@ -414,9 +455,10 @@ export function DashboardAgentChat({
|
||||
/>
|
||||
)}
|
||||
{watchCard ? <div className="px-3 pb-2">{watchCard}</div> : null}
|
||||
{quota.kind === "reached" ? (
|
||||
{atMessageCap ? (
|
||||
<AgentUpgradeBlock
|
||||
limit={quota.limit}
|
||||
limit={messageCapLimit}
|
||||
planResolved={messageCapPlanResolved}
|
||||
context={
|
||||
<DashboardAgentContextBanner
|
||||
projectSlug={projectSlug}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ArrowUpIcon, StopIcon } from "@heroicons/react/20/solid";
|
||||
import { sliceWellFormed } from "@internal/dashboard-agent-contracts";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { cn } from "~/utils/cn";
|
||||
@@ -94,7 +95,7 @@ export function DashboardAgentComposer({
|
||||
maxLength={MAX_MESSAGE_CHARS}
|
||||
onChange={(e) => {
|
||||
escapeGuardArmed.current = true;
|
||||
onChange(e.target.value.slice(0, MAX_MESSAGE_CHARS));
|
||||
onChange(sliceWellFormed(e.target.value, MAX_MESSAGE_CHARS));
|
||||
}}
|
||||
onBlur={() => {
|
||||
escapeGuardArmed.current = true;
|
||||
|
||||
@@ -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={
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
{watchCard}
|
||||
<DashboardAgentComposer
|
||||
layout="hero"
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSubmit={() => submit(input)}
|
||||
onStop={() => {}}
|
||||
isStreaming={false}
|
||||
placeholderSuggestion={watchCard ? undefined : placeholderSuggestion}
|
||||
context={
|
||||
<DashboardAgentContextBanner
|
||||
projectSlug={projectSlug}
|
||||
environmentSlug={environmentSlug}
|
||||
currentPage={currentPage}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
capReached ? (
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
{watchCard}
|
||||
<AgentUpgradeBlock
|
||||
limit={capReached.limit}
|
||||
planResolved={capReached.planResolved}
|
||||
context={
|
||||
<DashboardAgentContextBanner
|
||||
projectSlug={projectSlug}
|
||||
environmentSlug={environmentSlug}
|
||||
currentPage={currentPage}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
{watchCard}
|
||||
<DashboardAgentComposer
|
||||
layout="hero"
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSubmit={() => submit(input)}
|
||||
onStop={() => {}}
|
||||
isStreaming={false}
|
||||
placeholderSuggestion={watchCard ? undefined : placeholderSuggestion}
|
||||
context={
|
||||
<DashboardAgentContextBanner
|
||||
projectSlug={projectSlug}
|
||||
environmentSlug={environmentSlug}
|
||||
currentPage={currentPage}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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) && (
|
||||
<ChatActionsRow>
|
||||
{onRetry && (
|
||||
<Button variant="primary/small" LeadingIcon={ArrowPathIcon} onClick={onRetry}>
|
||||
<Button
|
||||
variant="primary/small"
|
||||
LeadingIcon={ArrowPathIcon}
|
||||
onClick={onRetry}
|
||||
disabled={!!retryDisabledReason}
|
||||
tooltip={retryDisabledReason}
|
||||
aria-label={
|
||||
retryDisabledReason ? `Try again — ${retryDisabledReason}` : undefined
|
||||
}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -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<ActiveChat | null>(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}
|
||||
/>
|
||||
) : (
|
||||
<DashboardAgentDraft
|
||||
@@ -634,6 +660,7 @@ export function DashboardAgentPanel({
|
||||
pageContext={pageContext}
|
||||
promotedPrompt={promotedPrompt}
|
||||
watchCard={watchCardElement}
|
||||
capReached={capReached}
|
||||
/>
|
||||
)}
|
||||
</AgentPanelColumn>
|
||||
|
||||
@@ -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<string[]>(() =>
|
||||
@@ -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}
|
||||
</Button>
|
||||
|
||||
@@ -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 (
|
||||
<AgentCard>
|
||||
{title ? (
|
||||
<AgentCardHeader className="text-xs font-medium text-text-dimmed">{title}</AgentCardHeader>
|
||||
) : null}
|
||||
<div className={AGENT_CHART_PLOT_CLASS}>
|
||||
<QueryResultsChart
|
||||
rows={demoChart.rows}
|
||||
columns={demoChart.columns}
|
||||
config={demoChart.config}
|
||||
timeRange={demoChart.timeRange}
|
||||
/>
|
||||
</div>
|
||||
<ChartActions actions={actions ?? []} onIntent={onIntent} />
|
||||
</AgentCard>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md border px-3 py-3",
|
||||
rejected
|
||||
? "border-border-bright bg-background-bright/40"
|
||||
: "border-indigo-500/30 bg-indigo-500/5"
|
||||
)}
|
||||
>
|
||||
<ChatStatusLine
|
||||
icon={
|
||||
<AgentStatusIcon
|
||||
tone={rejected ? "error" : "success"}
|
||||
icon={rejected ? NoSymbolIcon : CheckCircleIcon}
|
||||
className="mt-px"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<p className="text-xs text-text-bright">{intent.outcome}</p>
|
||||
{intent.deepLinkLabel ? (
|
||||
<Button
|
||||
variant="secondary/small"
|
||||
LeadingIcon={ArrowTopRightOnSquareIcon}
|
||||
onClick={() =>
|
||||
onIntercept?.(
|
||||
`would navigate to ${intent.deepLinkLabel} (${
|
||||
intent.intent.kind === "navigate" ? intent.intent.target : intent.intent.kind
|
||||
})`
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="break-all text-left font-mono text-[10px]">
|
||||
{intent.deepLinkLabel}
|
||||
</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</ChatStatusLine>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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<string, number[]> = {
|
||||
"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<string, unknown>[] = 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;
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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.`;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -238,6 +238,12 @@ export const demoPromptSets: Record<DemoPageContextKey, SuggestedPrompt[]> = {
|
||||
|
||||
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);
|
||||
|
||||
@@ -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_<fingerprint>`, 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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<number | undefined>(undefined);
|
||||
const [used, setUsed] = useState<number | undefined>(undefined);
|
||||
const [serverLimit, setServerLimit] = useState<number | null>(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) });
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
+31
@@ -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" },
|
||||
|
||||
+33
-7
@@ -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<string, unknown> | 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);
|
||||
|
||||
@@ -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<string, React.ReactNode> = {
|
||||
"investigation-card-streaming-rev1": (
|
||||
<InvestigationCard block={investigationBlock(demoInvestigations.streamingRev1)} />
|
||||
),
|
||||
"investigation-card-concluded": (
|
||||
<InvestigationCard block={investigationBlock(demoInvestigations.concluded)} />
|
||||
),
|
||||
"investigation-card-concluded-code-grounded": (
|
||||
<InvestigationCard
|
||||
block={investigationBlock(demoInvestigations.concluded, codeGroundedCapabilities)}
|
||||
resolveUri={fixtureResolveUri}
|
||||
onIntent={noop}
|
||||
/>
|
||||
),
|
||||
"investigation-card-concluded-not-code-grounded": (
|
||||
<InvestigationCard
|
||||
block={investigationBlock(demoInvestigations.concludedNoCode, notCodeGroundedCapabilities)}
|
||||
resolveUri={fixtureResolveUri}
|
||||
onIntent={noop}
|
||||
/>
|
||||
),
|
||||
"investigation-card-inconclusive": (
|
||||
<InvestigationCard
|
||||
block={investigationBlock(demoInvestigations.inconclusive)}
|
||||
defaultExpanded
|
||||
resolveUri={fixtureResolveUri}
|
||||
/>
|
||||
),
|
||||
"investigation-card-degraded": (
|
||||
<InvestigationCard
|
||||
block={investigationBlock(demoInvestigations.degraded)}
|
||||
defaultExpanded
|
||||
resolveUri={fixtureResolveUri}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export default function Story() {
|
||||
return <GalleryPage page="investigation" states={STATES} />;
|
||||
}
|
||||
@@ -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<string, React.ReactNode> = {
|
||||
"report-view-healthy": (
|
||||
<ReportView
|
||||
vm={demoFixtures.demoHealthyReport}
|
||||
reportUri={reportUri}
|
||||
onIntent={noop}
|
||||
resolveUri={fixtureResolveUri}
|
||||
/>
|
||||
),
|
||||
"report-view-degraded": (
|
||||
<ReportView
|
||||
vm={demoFixtures.demoDegradedReport}
|
||||
reportUri={reportUri}
|
||||
onIntent={noop}
|
||||
resolveUri={fixtureResolveUri}
|
||||
/>
|
||||
),
|
||||
"report-view-untrustworthy": (
|
||||
<ReportView vm={untrustworthyReport} onIntent={noop} resolveUri={fixtureResolveUri} />
|
||||
),
|
||||
};
|
||||
|
||||
export default function Story() {
|
||||
return <GalleryPage page="report" states={STATES} />;
|
||||
}
|
||||
@@ -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/');
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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<string, DemoTranscript>;
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 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" }],
|
||||
};
|
||||
@@ -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 (
|
||||
<div className="rounded-md border border-error/50 bg-error/10 px-3 py-2 text-xs text-error">
|
||||
No renderer for {what}. The manifest and the gallery are out of sync.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, React.ReactNode>;
|
||||
}) {
|
||||
const state = states[section.sectionId];
|
||||
return (
|
||||
<section id={section.sectionId} className="w-fit scroll-mt-4 space-y-1.5">
|
||||
<h3 className="text-sm font-medium text-text-bright">{section.title}</h3>
|
||||
<div className={cn(WIDE_SECTIONS.has(section.sectionId) ? "w-auto" : PANEL)}>
|
||||
{state ?? <Missing what={`section "${section.sectionId}"`} />}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeToggle() {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* classic is still the default theme for most users, so it's in the pack */}
|
||||
{(["classic", "dark", "light"] as const).map((theme) => (
|
||||
<button
|
||||
key={theme}
|
||||
type="button"
|
||||
onClick={() => document.documentElement.setAttribute("data-theme", theme)}
|
||||
className="rounded border border-border-bright bg-background-bright px-2 py-1 text-xs text-text-dimmed transition hover:text-text-bright"
|
||||
>
|
||||
{theme}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PageLinks({ page }: { page: GalleryPageId }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{GALLERY_PAGES.map((entry) => (
|
||||
<Link
|
||||
key={entry.id}
|
||||
to={`/storybook/${entry.slug}`}
|
||||
className={cn(
|
||||
"rounded border px-2 py-1 text-xs transition",
|
||||
entry.id === page
|
||||
? "border-border-bright bg-tertiary text-text-bright"
|
||||
: "border-border-bright bg-background-bright text-text-dimmed hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{entry.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Nav({ page }: { page: GalleryPageId }) {
|
||||
return (
|
||||
<nav className="sticky top-0 space-y-3 self-start py-6 pr-4">
|
||||
{groupsOnPage(page).map(({ group, label }) => (
|
||||
<div key={group} className="space-y-1">
|
||||
<p className="text-[10px] uppercase tracking-wide text-text-faint">{label}</p>
|
||||
<ul className="space-y-0.5">
|
||||
{sectionsInGroup(group).map((section) => (
|
||||
<li key={section.sectionId}>
|
||||
<a
|
||||
href={`#${section.sectionId}`}
|
||||
className="block truncate text-xs text-text-dimmed transition hover:text-text-bright"
|
||||
>
|
||||
{section.title}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export function GalleryPage({
|
||||
page,
|
||||
states,
|
||||
}: {
|
||||
page: GalleryPageId;
|
||||
states: Record<string, React.ReactNode>;
|
||||
}) {
|
||||
const meta = GALLERY_PAGES.find((entry) => entry.id === page)!;
|
||||
const sections = sectionsOnPage(page);
|
||||
return (
|
||||
<div className={cn("grid min-h-full grid-cols-[15rem_1fr] gap-4 px-6", CANVAS)}>
|
||||
<Nav page={page} />
|
||||
|
||||
<div className="flex flex-col gap-10 py-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<Header1>Trigger Agent — {meta.title}</Header1>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<PageLinks page={page} />
|
||||
<Paragraph variant="small">
|
||||
{meta.blurb} {sections.length} states, rendered in isolation at panel width (380px) from
|
||||
the demo fixtures in{" "}
|
||||
<code className="font-mono text-xs">app/components/dashboard-agent/demo/fixtures</code>.
|
||||
The list lives in <code className="font-mono text-xs">manifest.ts</code>.
|
||||
</Paragraph>
|
||||
<Paragraph variant="extra-small">
|
||||
Run ids, queues, errors and reports are fabricated. Deep links resolve inside a project,
|
||||
so here they render as plain text or navigate nowhere. The theme buttons flip{" "}
|
||||
<code className="font-mono text-xs">data-theme</code> on the root element.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{groupsOnPage(page).map(({ group, label }) => (
|
||||
<div key={group} className="flex flex-col gap-4">
|
||||
<Header2 className="border-b border-grid-bright pb-1">{label}</Header2>
|
||||
<div className="flex flex-wrap items-start gap-8">
|
||||
{sectionsInGroup(group).map((section) => (
|
||||
<Section key={section.sectionId} section={section} states={states} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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";
|
||||
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";
|
||||
|
||||
// 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 { demoIntents, demoWatches, demoPageContexts, demoInvestigations } = demoFixtures;
|
||||
|
||||
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" },
|
||||
],
|
||||
};
|
||||
|
||||
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 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 Example({ title, block }: { title: string; block: ViewBlock }) {
|
||||
function MessageHarness({
|
||||
transcript,
|
||||
withError = false,
|
||||
}: {
|
||||
transcript: DemoTranscript;
|
||||
withError?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Header2>{title}</Header2>
|
||||
<div className="w-104 max-w-full">
|
||||
<ViewBlocks blocks={[block]} />
|
||||
<div className={PANEL_FRAME}>
|
||||
<DashboardAgentMessages
|
||||
messages={transcript.messages}
|
||||
activity={transcript.activity ?? null}
|
||||
error={withError && transcript.error ? new Error(transcript.error) : undefined}
|
||||
onRetry={withError ? noop : undefined}
|
||||
onDismissError={withError ? noop : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 PendingPillsHarness() {
|
||||
const lines = PENDING_PILL_TOOLS.map(({ tool, input }) => ({
|
||||
tool,
|
||||
progress: liveProgress(
|
||||
[
|
||||
demoFixtures.assistantMessage(`pending-${tool}`, [
|
||||
demoFixtures.pendingToolPart(tool, input, `pending-${tool}`),
|
||||
]),
|
||||
],
|
||||
"working"
|
||||
),
|
||||
}));
|
||||
return (
|
||||
<div className={PANEL_FRAME}>
|
||||
<ChatTranscript>
|
||||
{lines.map(({ tool, progress }) => (
|
||||
<ChatTurn key={tool}>
|
||||
<ChatProgress>{progress?.label}</ChatProgress>
|
||||
</ChatTurn>
|
||||
))}
|
||||
</ChatTranscript>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[10px] uppercase tracking-wide text-text-faint">
|
||||
{context.page.kind} — {signals}
|
||||
</p>
|
||||
<div className={cn(PANEL_FRAME, "py-4")}>
|
||||
<DashboardAgentSuggestedPrompts
|
||||
onSelect={noop}
|
||||
pageContext={context}
|
||||
promoted={promoted}
|
||||
dismissedIds={dismissedIds}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroHarness({
|
||||
context,
|
||||
promoted,
|
||||
fullscreen = false,
|
||||
withComposer = true,
|
||||
}: {
|
||||
context: AgentPageContext;
|
||||
promoted?: SuggestedPrompt;
|
||||
fullscreen?: boolean;
|
||||
withComposer?: boolean;
|
||||
}) {
|
||||
const [input, setInput] = useState("");
|
||||
return (
|
||||
<div className={cn(PANEL_FRAME, "flex h-[30rem] flex-col", fullscreen && "w-[60rem]")}>
|
||||
<AgentPanelColumn fullscreen={fullscreen}>
|
||||
<DashboardAgentHero
|
||||
onSelect={noop}
|
||||
pageContext={context}
|
||||
promoted={promoted}
|
||||
dismissedIds={[]}
|
||||
composer={
|
||||
withComposer ? (
|
||||
<DashboardAgentComposer
|
||||
layout="hero"
|
||||
autoFocus={false}
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSubmit={noop}
|
||||
onStop={noop}
|
||||
isStreaming={false}
|
||||
context={
|
||||
<DashboardAgentContextBanner
|
||||
projectSlug="demo-storefront"
|
||||
environmentSlug="prod"
|
||||
currentPage="Runs"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</AgentPanelColumn>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={PANEL_FRAME}>
|
||||
<DashboardAgentMessages
|
||||
messages={messages}
|
||||
activity="working"
|
||||
resolveUri={fixtureResolveUri}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={PANEL_FRAME}>
|
||||
<DashboardAgentMessages messages={[message]} activity={null} watches={watches} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const STATES: Record<string, React.ReactNode> = {
|
||||
"hero-panel": <HeroHarness context={demoPageContexts.other} />,
|
||||
"hero-panel-contextual": <HeroHarness context={demoPageContexts.failedRun} />,
|
||||
"hero-fullscreen": <HeroHarness context={demoPageContexts.failedRun} fullscreen />,
|
||||
"hero-in-chat": <HeroHarness context={demoPageContexts.runs} withComposer={false} />,
|
||||
|
||||
"prompts-default": <PromptsHarness context={demoPageContexts.other} />,
|
||||
"prompts-contextual-fresh-failure": <PromptsHarness context={demoPageContexts.failedRun} />,
|
||||
"prompts-promoted": (
|
||||
<PromptsHarness context={demoPageContexts.failedRun} promoted={promotedPrompt} />
|
||||
),
|
||||
"prompts-dismissed": (
|
||||
<PromptsHarness context={demoPageContexts.failedRun} dismissedIds={dismissedPromptIds} />
|
||||
),
|
||||
|
||||
"messages-streaming-text": <MessageHarness transcript={demoTranscripts.streamingText} />,
|
||||
"messages-reasoning": <MessageHarness transcript={demoTranscripts.reasoning} />,
|
||||
"messages-tool-in-flight": <MessageHarness transcript={demoTranscripts.toolInFlight} />,
|
||||
"messages-tool-pending-pills": <PendingPillsHarness />,
|
||||
"messages-error-retry": <MessageHarness transcript={demoTranscripts.errorRetry} withError />,
|
||||
"messages-render-view": <MessageHarness transcript={demoTranscripts.renderView} />,
|
||||
"messages-investigation-live": <LiveInvestigationHarness />,
|
||||
"messages-docs-sources": <MessageHarness transcript={demoTranscripts.docsSources} />,
|
||||
|
||||
"intent-navigate-filtered-runs": (
|
||||
<DemoIntentBubble intent={demoIntents.navigateToFailedRuns} onIntercept={noop} />
|
||||
),
|
||||
"intent-watch": <DemoIntentBubble intent={demoIntents.watch} onIntercept={noop} />,
|
||||
"intent-rejected-propose-fix": (
|
||||
<DemoIntentBubble intent={demoIntents.proposeFix} onIntercept={noop} />
|
||||
),
|
||||
|
||||
"wake-positive": (
|
||||
<WakeHarness
|
||||
watches={wakeWatches}
|
||||
message={wakeMessage(
|
||||
"watch_health",
|
||||
"fired",
|
||||
"Production is back to normal: the failure rate has been under 1% for the last 15 minutes and the queue has drained. Nothing left for me to watch here."
|
||||
)}
|
||||
/>
|
||||
),
|
||||
"wake-attention": (
|
||||
<WakeHarness
|
||||
watches={wakeWatches}
|
||||
message={wakeMessage(
|
||||
"watch_error",
|
||||
"fired",
|
||||
"That TypeError is back — 6 runs of process-order failed with it in the last 10 minutes, all on version 20260620.2. Same empty-items payload as before."
|
||||
)}
|
||||
/>
|
||||
),
|
||||
"wake-neutral-impossible": (
|
||||
<WakeHarness
|
||||
watches={wakeWatches}
|
||||
message={wakeMessage(
|
||||
"watch_queue_gone",
|
||||
"expired",
|
||||
"The email-sends queue was deleted, so there is nothing left to drain. I've stopped watching."
|
||||
)}
|
||||
/>
|
||||
),
|
||||
"wake-unverified": (
|
||||
<WakeHarness
|
||||
watches={wakeWatches}
|
||||
message={wakeMessage(
|
||||
"watch_unverified",
|
||||
"expired",
|
||||
"The window ran out while I couldn't read the queue depth, so I can't tell you whether it drained. The last reading I do have was 42 pending, an hour ago."
|
||||
)}
|
||||
/>
|
||||
),
|
||||
|
||||
"watches-live": <WatchChips watches={demoWatches.row.map(toWatchChip)} onCancel={noop} />,
|
||||
|
||||
"banner-prod": (
|
||||
<DashboardAgentContextBanner
|
||||
projectSlug="demo-storefront"
|
||||
environmentSlug="prod"
|
||||
currentPage="Runs"
|
||||
/>
|
||||
),
|
||||
"banner-preview-long": (
|
||||
<DashboardAgentContextBanner
|
||||
projectSlug="demo-storefront"
|
||||
environmentSlug="preview-demo-feature-rework-receipt-email-batching"
|
||||
currentPage="Deployments"
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export default function Story() {
|
||||
return (
|
||||
<div className="flex flex-col gap-8 p-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Header1>Dashboard agent UI</Header1>
|
||||
<Paragraph variant="small">
|
||||
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.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-8">
|
||||
<Example title="Diagnosis — full, high confidence" block={fullDiagnosis} />
|
||||
<Example title="Diagnosis — external service, medium" block={externalServiceDiagnosis} />
|
||||
<Example title="Diagnosis — low confidence, minimal" block={lowConfidenceDiagnosis} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <GalleryPage page="chat" states={STATES} />;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<AgentCard>
|
||||
<AgentCardHeader className="text-xs font-medium text-text-dimmed">
|
||||
{demoFixtures.demoChart.title}
|
||||
</AgentCardHeader>
|
||||
<div className={AGENT_CHART_PLOT_CLASS}>
|
||||
<QueryResultsChart
|
||||
rows={[]}
|
||||
columns={demoFixtures.demoChart.columns}
|
||||
config={demoFixtures.demoChart.config}
|
||||
timeRange={demoFixtures.demoChart.timeRange}
|
||||
/>
|
||||
</div>
|
||||
</AgentCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** The card primitive on its own: both body densities, and a card with no header. */
|
||||
function CardChrome({ density, header }: { density?: AgentCardDensity; header?: boolean }) {
|
||||
return (
|
||||
<AgentCard>
|
||||
{header ? (
|
||||
<AgentCardHeader className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-medium text-text-dimmed">Card header</span>
|
||||
<ConfidenceBadge confidence="high" />
|
||||
</AgentCardHeader>
|
||||
) : null}
|
||||
<AgentCardBody density={density}>
|
||||
<p className="text-sm text-text-bright">
|
||||
The card owns its border, surface and insets; the transcript owns where it sits.
|
||||
</p>
|
||||
<p className="text-sm text-text-dimmed">
|
||||
A second section, so the body's density is visible as the gap between them.
|
||||
</p>
|
||||
</AgentCardBody>
|
||||
</AgentCard>
|
||||
);
|
||||
}
|
||||
|
||||
const STATES: Record<string, React.ReactNode> = {
|
||||
"view-blocks-revisions": <ViewBlocks blocks={revisedDiagnosisBlocks} />,
|
||||
"view-blocks-mixed": (
|
||||
<ViewBlocks
|
||||
blocks={[
|
||||
demoFixtures.demoDiagnosisBlockFirstPass,
|
||||
demoFixtures.demoDiagnosisBlockRevised,
|
||||
demoFixtures.demoLegacyDiagnosisBlock,
|
||||
]}
|
||||
/>
|
||||
),
|
||||
"view-blocks-actions-offer": <ViewBlocks blocks={[offerActionsBlock]} onIntent={noop} />,
|
||||
|
||||
"card-compact": <CardChrome header density="compact" />,
|
||||
"card-roomy": <CardChrome header density="roomy" />,
|
||||
"card-headerless": <CardChrome />,
|
||||
|
||||
"diagnosis-full-high": <RunDiagnosisCard block={fullDiagnosis} />,
|
||||
"diagnosis-low-minimal": <RunDiagnosisCard block={lowConfidenceDiagnosis} />,
|
||||
"diagnosis-badge-matrix": (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{badgeMatrixBlocks.map((block, i) => (
|
||||
<div key={i} className="w-[300px]">
|
||||
<RunDiagnosisCard block={block} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
|
||||
"chart-with-actions": (
|
||||
<DemoChartCard actions={demoFixtures.demoChartBlock.actions ?? []} onIntent={noop} />
|
||||
),
|
||||
"chart-empty": <EmptyChartCard />,
|
||||
};
|
||||
|
||||
export default function Story() {
|
||||
return <GalleryPage page="view-blocks" states={STATES} />;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={cn(PANEL_FRAME, "space-y-3 p-3")}>
|
||||
{wakes.map((wake) => (
|
||||
<div key={wake.watchId} className="space-y-0.5">
|
||||
<p className="text-[10px] uppercase tracking-wide text-text-faint">{wake.kind}</p>
|
||||
<p className="text-sm text-text-bright">{watchWakeToastTitle(wake)}</p>
|
||||
<p className="text-xs text-text-dimmed">{wake.note}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const STATES: Record<string, React.ReactNode> = {
|
||||
"watch-card-compact": (
|
||||
<WatchCard draft={queueWatchDraft} onChange={noop} onSubmit={noop} onCancel={noop} />
|
||||
),
|
||||
"watch-card-expanded": (
|
||||
<WatchCard
|
||||
draft={runWatchDraft}
|
||||
onChange={noop}
|
||||
onSubmit={noop}
|
||||
onCancel={noop}
|
||||
defaultExpanded
|
||||
/>
|
||||
),
|
||||
"watch-card-validation-error": (
|
||||
<WatchCard
|
||||
draft={invalidThresholdDraft}
|
||||
onChange={noop}
|
||||
onSubmit={noop}
|
||||
onCancel={noop}
|
||||
defaultExpanded
|
||||
/>
|
||||
),
|
||||
"watch-card-pending": (
|
||||
<WatchCard
|
||||
draft={watchDraftFor(errorWatchRecommendation("a1b2c3d4e5f6"))}
|
||||
onChange={noop}
|
||||
onSubmit={noop}
|
||||
onCancel={noop}
|
||||
pending
|
||||
/>
|
||||
),
|
||||
"watch-card-queue-below": (
|
||||
<WatchCard draft={queueBelowDraft} onChange={noop} onSubmit={noop} defaultExpanded />
|
||||
),
|
||||
"watch-card-queue-stalled": (
|
||||
<WatchCard draft={queueStalledDraft} onChange={noop} onSubmit={noop} defaultExpanded />
|
||||
),
|
||||
"watch-card-confirmation": <WatchResultBlock block={watchConfirmationBlock} />,
|
||||
"watch-card-confirmation-degraded": <WatchResultBlock block={watchDegradedConfirmationBlock} />,
|
||||
"watch-card-one-shot-satisfied": <WatchResultBlock block={watchSatisfiedBlock} />,
|
||||
"watch-card-toast-headline": <WakeToastHeadlines wakes={toastWakes} />,
|
||||
};
|
||||
|
||||
export default function Story() {
|
||||
return <GalleryPage page="watch" states={STATES} />;
|
||||
}
|
||||
@@ -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="-"
|
||||
/>
|
||||
<ToastUI variant="agent" message="Agent info UI" t="-" />
|
||||
<ToastUI
|
||||
variant="agent"
|
||||
title="Watch update"
|
||||
message="Error error_c4b4a797 happened again — 1 new occurrence since the watch started."
|
||||
t="-"
|
||||
actionNode={
|
||||
<Button variant="secondary/small" className="my-2 self-start">
|
||||
Open chat
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<br />
|
||||
<Button
|
||||
variant="primary/medium"
|
||||
@@ -38,8 +50,33 @@ export default function Story() {
|
||||
>
|
||||
Trigger error toast
|
||||
</Button>
|
||||
|
||||
<Toaster />
|
||||
<Button
|
||||
variant="secondary/medium"
|
||||
onClick={() =>
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<ToastUI
|
||||
variant="agent"
|
||||
title="Watch update"
|
||||
message="Error error_c4b4a797 happened again — 1 new occurrence since the watch started."
|
||||
t={t as string}
|
||||
actionNode={
|
||||
<Button
|
||||
variant="secondary/small"
|
||||
className="my-2 self-start"
|
||||
onClick={() => toast.dismiss(t as string)}
|
||||
>
|
||||
Open chat
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
),
|
||||
{ duration: Infinity }
|
||||
)
|
||||
}
|
||||
>
|
||||
Trigger agent toast
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<SettledInvestigationCard | null>;
|
||||
/** Record a failed settle out-of-band; returns the new attempt count, or null if gone. */
|
||||
recordAttempt?: (params: { id: string }) => Promise<number | null>;
|
||||
/** Force a poison row terminal without the failing render path. */
|
||||
forceAbandon?: (params: { id: string; note: string }) => Promise<SettledInvestigation | null>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<number>;
|
||||
}
|
||||
): Promise<AgentMessageQuota | undefined> {
|
||||
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<void> {
|
||||
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";
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { SubmitWatchErrorCode } from "./dashboardAgentWatches.server";
|
||||
*/
|
||||
const STATUS_BY_CODE: Record<SubmitWatchErrorCode, number> = {
|
||||
limit_reached: 409,
|
||||
watch_limit_reached: 409,
|
||||
duplicate: 409,
|
||||
request_conflict: 409,
|
||||
invalid_target: 404,
|
||||
|
||||
@@ -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<number> {
|
||||
// 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<number> = readLimit
|
||||
): Promise<WatchPlanLimits> {
|
||||
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;
|
||||
}
|
||||
@@ -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<WatchPlanLimits>;
|
||||
/** Org-wide active-watch count, for the watcher-count floor. */
|
||||
countActiveWatches?: (organizationId: string) => Promise<number>;
|
||||
/** Gates the upgrade nudge, so self-hosted stays quiet. */
|
||||
billingConfigured?: () => boolean;
|
||||
};
|
||||
}): Promise<CreateDashboardAgentWatchResult> {
|
||||
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, {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -397,6 +397,7 @@ export async function executeQuery<TOut extends z.ZodSchema>(
|
||||
...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,
|
||||
|
||||
@@ -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<number> {
|
||||
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<number> {
|
||||
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
|
||||
);
|
||||
});
|
||||
@@ -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<string> {
|
||||
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<string | undefined> {
|
||||
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
|
||||
);
|
||||
});
|
||||
@@ -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<string> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<DashboardAgentDb> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<unknown[]> {
|
||||
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<string, unknown>) => ({
|
||||
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
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
@@ -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<string, unknown>)[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<typeof WatchLimitsModule>();
|
||||
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<string, string>) {
|
||||
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<Response>;
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
});
|
||||
@@ -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<string, unknown>)[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<typeof TriggerSdk>();
|
||||
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<ReturnType<typeof seed>>;
|
||||
|
||||
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> = {}): 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> = {}): 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<number>;
|
||||
checkDeps?: Partial<WatchCheckDeps>;
|
||||
}) {
|
||||
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);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -215,10 +215,12 @@ function ChatClient({ chatId, initialMessages, initialSessions }) {
|
||||
</Info>
|
||||
|
||||
<Note>
|
||||
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.
|
||||
</Note>
|
||||
|
||||
<Warning>
|
||||
@@ -340,11 +342,10 @@ const stop = useCallback(() => {
|
||||
</Info>
|
||||
|
||||
<Tip>
|
||||
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.
|
||||
</Tip>
|
||||
|
||||
See [Stop generation](/ai-chat/backend#stop-generation) in the backend docs for how to handle stop signals in your task.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<string, unknown> = 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);
|
||||
});
|
||||
});
|
||||
@@ -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<T>(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<string, unknown> = {};
|
||||
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;
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
@@ -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;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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<number> {
|
||||
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<number> {
|
||||
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<string> }
|
||||
): Promise<void> {
|
||||
// Every batch write lands here, so this is where a lone surrogate stops before jsonb.
|
||||
const deduped = new Map<string, unknown>();
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<UpsertInvestigationResult> {
|
||||
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<number | null> {
|
||||
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 };
|
||||
|
||||
|
||||
@@ -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<unknown>().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;
|
||||
|
||||
@@ -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<number> {
|
||||
const rows = await db
|
||||
.select({ count: sql<number>`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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<Record<string, unknown>> }
|
||||
| { 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<string | null> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<Uint8Array>({
|
||||
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}`);
|
||||
});
|
||||
|
||||
|
||||
@@ -873,7 +873,11 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
|
||||
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<UIMessage> {
|
||||
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<ReadableStream<UIMessageChunk> | 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<UIMessage> {
|
||||
|
||||
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<UIMessage> {
|
||||
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<UIMessage> {
|
||||
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<UIMessage> {
|
||||
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<UIMessage> {
|
||||
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<UIMessage> {
|
||||
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<UIMessage> {
|
||||
|
||||
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<UIMessage> {
|
||||
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<UIMessage> {
|
||||
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<UIMessage> {
|
||||
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();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
|
||||
Reference in New Issue
Block a user